mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-02-13 01:03:03 +00:00
Compare commits
103 Commits
v0.8.1-bet
...
v0.9.8-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b1b98ae0f | ||
|
|
ec063e9c27 | ||
|
|
bdddbb5d8e | ||
|
|
f05a31f40b | ||
|
|
3150297bb0 | ||
|
|
988516c040 | ||
|
|
fa6b6ffcda | ||
|
|
8381388ffa | ||
|
|
fa16e3c299 | ||
|
|
a17c50188e | ||
|
|
5c2bfbc76e | ||
|
|
a8ac237317 | ||
|
|
eeedbd7da7 | ||
|
|
3ddebd2664 | ||
|
|
b5c210d009 | ||
|
|
208b3486f3 | ||
|
|
04a6d129b7 | ||
|
|
944df59d7c | ||
|
|
29031b1372 | ||
|
|
6bcfef0caa | ||
|
|
d2a3e96a86 | ||
|
|
395ef77fe3 | ||
|
|
57acff8ae7 | ||
|
|
a437d9bf60 | ||
|
|
c4c1505253 | ||
|
|
42c03eca7d | ||
|
|
bcc4461621 | ||
|
|
3cb875b67a | ||
|
|
d03ef6b50d | ||
|
|
6db691dbeb | ||
|
|
5ccf215f4e | ||
|
|
deb9a4272b | ||
|
|
1b3c3e620c | ||
|
|
c42d3afd0b | ||
|
|
f4ae861bc6 | ||
|
|
07d18ae33c | ||
|
|
92255eb03e | ||
|
|
3026b88230 | ||
|
|
728cef22af | ||
|
|
d7fbfaaaeb | ||
|
|
9c05f1d7a9 | ||
|
|
2c275ec528 | ||
|
|
f8726880d7 | ||
|
|
497b9e52be | ||
|
|
d9f6c8c8e0 | ||
|
|
45bf73aeee | ||
|
|
7ff945e262 | ||
|
|
26d8eca312 | ||
|
|
efbb8765de | ||
|
|
fae1cac6e4 | ||
|
|
aee0dcf8b8 | ||
|
|
2db4f597dc | ||
|
|
376fa27736 | ||
|
|
24b20e8a57 | ||
|
|
2d0dc7fd66 | ||
|
|
b735283f27 | ||
|
|
ebf7f93dd5 | ||
|
|
d56a6e8e7c | ||
|
|
84e057c986 | ||
|
|
c1e25ec5b1 | ||
|
|
a3edcfc2de | ||
|
|
17c9ee0c5c | ||
|
|
9e620ef9e4 | ||
|
|
bedfdcca6e | ||
|
|
f1c73a5e55 | ||
|
|
4ee783793f | ||
|
|
aada97295b | ||
|
|
813f4f69ea | ||
|
|
2d615128aa | ||
|
|
024d3f09c3 | ||
|
|
e65b9f58a6 | ||
|
|
7bd6f68a99 | ||
|
|
f11bd6e238 | ||
|
|
f45279ecfe | ||
|
|
d6625ccc23 | ||
|
|
722e640a72 | ||
|
|
a21e807d88 | ||
|
|
a2bc3309c0 | ||
|
|
f6adffc84e | ||
|
|
01f73322c7 | ||
|
|
257aefb2fc | ||
|
|
63ebc2b682 | ||
|
|
1f3849cd84 | ||
|
|
e35266c160 | ||
|
|
05de16b2e2 | ||
|
|
32507e1646 | ||
|
|
1272eb9409 | ||
|
|
4cc8929378 | ||
|
|
44707bf064 | ||
|
|
ff9a052d3f | ||
|
|
df5e26f78d | ||
|
|
865f91ea55 | ||
|
|
268c9ebb3a | ||
|
|
7875fd0d58 | ||
|
|
4bb57580cd | ||
|
|
5521da28c4 | ||
|
|
e5d00803f7 | ||
|
|
a73605cc53 | ||
|
|
7aa0c9dff4 | ||
|
|
e2830a189b | ||
|
|
d9beeb9d83 | ||
|
|
446b70eaff | ||
|
|
2829730705 |
200
README.md
200
README.md
@@ -1,145 +1,131 @@
|
||||
# Flock Map App
|
||||
# DeFlock
|
||||
|
||||
A Flutter app for mapping and tagging ALPR-style cameras (and other surveillance nodes) for OpenStreetMap, with advanced offline support, robust camera profile management, and a pro-grade UX.
|
||||
A comprehensive Flutter app for mapping public surveillance infrastructure with OpenStreetMap. Includes offline capabilities, editing ability, and an intuitive interface.
|
||||
|
||||
**DeFlock** is a privacy-focused initiative to document the rapid expansion of ALPRs, AI surveillance cameras, and other public surveillance infrastructure. This app aims to be the go-to tool for contributors to map surveillance devices in their communities and upload the data to OpenStreetMap, making surveillance infrastructure visible and searchable.
|
||||
|
||||
**For complete documentation, tutorials, and community info, visit [deflock.me](https://deflock.me)**
|
||||
|
||||
---
|
||||
|
||||
## Code Organization (2025 Refactor)
|
||||
## What This App Does
|
||||
|
||||
- **Data providers:** All map tile and camera data fetching now routes through `lib/services/map_data_provider.dart`, which supports both OSM/Overpass and fully offline/local sources, with pluggable submodules:
|
||||
- Remote tile fetch: `map_data_submodules/tiles_from_osm.dart`
|
||||
- Remote cameras: `map_data_submodules/cameras_from_overpass.dart`
|
||||
- *Coming soon:* Local tile/camera modules for offline/area-aware access
|
||||
- **Settings UI:** Each settings section lives in its own widget under `lib/screens/settings_screen_sections/`, using clean, modular ListTile-based layouts.
|
||||
- **Offline areas:** Management, persistence, and download logic remain in `OfflineAreaService`, but all fetch/caching is routed through the new provider.
|
||||
- **Legacy OSM/Overpass tile and camera fetch code has been removed from old modules.**
|
||||
- **Map surveillance infrastructure** including cameras, ALPRs, gunshot detectors, and more with precise location, direction, and manufacturer details
|
||||
- **Upload to OpenStreetMap** with OAuth2 integration (live or sandbox modes)
|
||||
- **Work completely offline** with downloadable map areas and device data, plus upload queue
|
||||
- **Multiple map types** including satellite imagery from Google, Esri, Mapbox, and OpenStreetMap, plus custom map tile provider support
|
||||
- **Editing Ability** to update existing device locations and properties
|
||||
- **Built-in device profiles** for Flock Safety, Motorola, Genetec, Leonardo, and other major manufacturers, plus custom profiles for more specific tag sets
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
### Map Data & Provider Architecture
|
||||
- **All map tile and camera fetches** go through MapDataProvider, which selects local or remote sources as needed, automatically obeying the user's offline/online preference and settings.
|
||||
- **Offline Mode:** A global toggle in Settings disables all remote network fetches, forcing the app to use only locally downloaded map areas and cached camera data. (Instant feedback; no network calls when enabled.)
|
||||
- **MapSource Selection:** MapDataProvider lets calling code specify local-only, remote-only, or auto preference for tiles and camera points.
|
||||
### Map & Navigation
|
||||
- **Multi-source tiles**: Switch between OpenStreetMap, Google Satellite, Esri imagery, Mapbox, and any custom providers
|
||||
- **Offline-first design**: Download a region for complete offline operation
|
||||
- **Smooth UX**: Intuitive controls, follow-me mode with GPS rotation, and gesture-friendly interactions
|
||||
- **Device visualization**: Color-coded markers showing real devices (blue), pending uploads (purple), new devices (white), edited devices (grey), and devices being edited (orange)
|
||||
|
||||
### Map View
|
||||
- **Seamless offline/online tile loading:** Tiles are fetched (in parallel, with global concurrency/throttle control and exponential backoff) from OSM *only as needed*, with robust error handling and UI updates as tiles arrive.
|
||||
- **Camera overlays** are fetched from Overpass or local cache, respecting both offline mode and user preference for which camera types to display.
|
||||
### Device Management
|
||||
- **Comprehensive profiles**: Built-in profiles for major manufacturers (Flock Safety, Motorola/Vigilant, Genetec, Leonardo/ELSAG, Neology) plus custom profile creation
|
||||
- **Editing capabilities**: Update location, direction, and tags of existing devices
|
||||
- **Direction visualization**: Interactive field-of-view cones showing camera viewing angles
|
||||
- **Bulk operations**: Tag multiple devices efficiently with profile-based workflow
|
||||
|
||||
### Camera Profiles & Upload Queue
|
||||
- Unchanged: creation/editing/enabling; see prior documentation.
|
||||
### Professional Upload & Sync
|
||||
- **OpenStreetMap integration**: Direct upload with full OAuth2 authentication
|
||||
- **Upload modes**: Production OSM, testing sandbox, or simulate-only mode
|
||||
- **Queue management**: Review, edit, retry, or cancel pending uploads
|
||||
- **Changeset tracking**: Automatic grouping and commenting for organized contributions
|
||||
|
||||
### Offline Map Areas
|
||||
- **Download tiles/cameras for any bounding box**; areas cover any region/zoom, and are automatically de-duped and managed.
|
||||
- **Robust area downloads** use the same MapDataProvider for source-of-truth logic, so downloads are always consistent with runtime lookup.
|
||||
- **Permanent world base map** at low zoom always available for core map functionality, even on first-use/offline.
|
||||
### Offline Operations
|
||||
- **Smart area downloads**: Automatically calculate tile counts and storage requirements
|
||||
- **Device caching**: Offline areas include surveillance device data for complete functionality without network
|
||||
- **Global base map**: Permanent worldwide coverage at low zoom levels
|
||||
- **Robust downloads**: Exponential backoff, retry logic, and progress tracking for reliable area downloads
|
||||
|
||||
### Modular, Future-friendly Codebase
|
||||
- **No network fetch code outside the provider and submodules.**
|
||||
- **All legacy/duplicate OSM/Overpass downloaders have been removed or marked for deprecation.**
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Install** the app on iOS or Android
|
||||
2. **Enable location** permissions
|
||||
3. **Log into OpenStreetMap**: Choose upload mode and get OAuth2 credentials
|
||||
4. **Add your first device**: Tap the "tag node" button, position the pin, set direction, select a profile, and tap submit
|
||||
|
||||
**New to OpenStreetMap?** Visit [deflock.me](https://deflock.me) for complete setup instructions and community guidelines.
|
||||
|
||||
---
|
||||
|
||||
## For Developers
|
||||
|
||||
**Highlights:**
|
||||
- To add a new data source, just drop in a new submodule and route fetch via MapDataProvider.
|
||||
- Any section of the app that needs tiles or camera data calls MapDataProvider with the relevant bounds/zoom/profiles and source preference.
|
||||
- Offline Mode and all core settings are strictly respected at a single data/control point.
|
||||
### Architecture Highlights
|
||||
- **Unified data provider**: All map tiles and surveillance device data route through `MapDataProvider` with pluggable remote/local sources
|
||||
- **Modular settings**: Each settings section is a separate widget for maintainability
|
||||
- **State management**: Provider pattern with clean separation of concerns
|
||||
- **Offline-first**: Network calls are optional; app functions fully offline with downloaded data and queues uploads until online
|
||||
|
||||
---
|
||||
### Build Setup
|
||||
**Prerequisites**: Flutter SDK, Xcode (iOS), Android Studio
|
||||
**OAuth Setup**: Register apps at [openstreetmap.org/oauth2](https://www.openstreetmap.org/oauth2/applications) and [OSM Sandbox](https://master.apis.dev.openstreetmap.org/oauth2/applications) to get a client ID
|
||||
|
||||
## Roadmap (2025+)
|
||||
```shell
|
||||
# Basic setup
|
||||
flutter pub get
|
||||
cp lib/keys.dart.example lib/keys.dart
|
||||
# Add your OAuth2 client IDs to keys.dart
|
||||
|
||||
- **COMPLETE:** Core provider logic, settings, robust downloading and modular prefetch/caching.
|
||||
- **IN PROGRESS:** Local/offline tile/camera fetch modules for runtime map viewing and offline area management.
|
||||
- **NEXT:** More map overlays, offline routing, and data visualization.
|
||||
- **SOON:** UX polish for download/error states, multi-layer base maps.
|
||||
# iOS additional setup
|
||||
cd ios && pod install
|
||||
|
||||
---
|
||||
|
||||
*See prior README version for detailed setup/build/dependency notes—they remain unchanged!*
|
||||
|
||||
|
||||
### Map View
|
||||
- **Explore the Map:** View OSM raster tiles, live camera overlays, and a visual scale bar and zoom indicator in the lower left.
|
||||
- **Tag Cameras:** Add a camera by dropping a pin, setting direction, and choosing a camera profile. Camera tap/double-tap is smart—double-tap always zooms, single-tap opens camera info.
|
||||
- **Location:** Blue GPS dot shows your current location, always on top of map icons.
|
||||
|
||||
### Camera Profiles
|
||||
- **Flexible, Private Profiles:** Enable/disable, create, edit, or delete camera types in Settings. At least one profile must be enabled at all times.
|
||||
- If the last enabled profile is disabled, the generic profile will be auto-enabled so the app always works.
|
||||
|
||||
### Upload Destinations/Queue
|
||||
- **Full OSM OAuth2 Integration:** Upload to live OSM, OSM Sandbox for testing, or keep your changes private in simulate mode.
|
||||
- **Queue Management:** Settings screen shows a queue of pending uploads—clear or retry them as you wish.
|
||||
|
||||
### Offline Map Areas
|
||||
- **Download Any Region, Any Zoom:** Save the current map area at any zoom for true offline viewing.
|
||||
- **Intelligent Tile Management:** World tiles at zooms 1–4 are permanently available (via a protected offline area). All downloads include accurate tile and storage estimates, and never request duplicate or unnecessary tiles.
|
||||
- **Robust Downloading:** All tile/download logic uses serial fetching and exponential backoff for network failures, minimizing risk of OSM rate-limits and always respecting API etiquette.
|
||||
- **No Duplicates:** Only one world area; can be re-downloaded (refreshed) but never deleted or renamed.
|
||||
- **Camera Cache:** Download areas keep camera points in sync for full offline visibility—except the global area, which never attempts to fetch all world cameras.
|
||||
- **Settings Management:** Cancel, refresh, or remove downloads as needed. Progress, tile count, storage consumption, and cached camera count always displayed.
|
||||
|
||||
### Polished UX & Settings Architecture
|
||||
- **Permanent global base map:** Coverage for the entire world at zooms 1–4, always present.
|
||||
- **Smooth map gestures:** Double-tap to zoom even on markers; pinch zoom; camera popups distinguished from zoom.
|
||||
- **Modular Settings:** All major settings/queue/offline/camera management UI sections are cleanly separated for extensibility and rapid development.
|
||||
- **Order-preserving overlays:** Your location is always drawn on top for easy visibility.
|
||||
- **No more dead ends:** Disabling all profiles is impossible; canceling downloads is clean and instant.
|
||||
|
||||
---
|
||||
|
||||
## OAuth & Build Setup
|
||||
|
||||
**Before uploading to OSM:**
|
||||
- Register OAuth2 applications on both [Production OSM](https://www.openstreetmap.org/oauth2/applications) and [Sandbox OSM](https://master.apis.dev.openstreetmap.org/oauth2/applications).
|
||||
- Copy generated client IDs to `lib/keys.dart` (see template `.example` file).
|
||||
|
||||
### Build Environment Notes
|
||||
- Requires Xcode, Android Studio, and standard Flutter dependencies. See notes at the end of this file for CLI setup details.
|
||||
# Run
|
||||
flutter run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
- **COMPLETE**:
|
||||
- Offline map area download/storage/camera overlay; cancel/retry; fast tile/camera/size estimates; exponential backoff and robust retry logic for network outages or rate-limiting.
|
||||
- Pro-grade map UX (zoom bar, marker tap/double-tap, robust FABs).
|
||||
- Modularized, maintainable codebase using small service/helper files and section-separated UI components.
|
||||
- **SOON**:
|
||||
- "Offline mode" setting: map never hits the network and always provides a fallback tile for every view (no blank maps; graceful offline-first UX).
|
||||
- Resumable/robust interrupted downloads.
|
||||
- Further polish for edge cases (queue, error states).
|
||||
- **LATER**:
|
||||
- Satellite base layers, north-up/satellite-mode.
|
||||
- Offline wayfinding or routing.
|
||||
- Fancier icons and overlays.
|
||||
### v1 todo/bug List
|
||||
- Fix "tiles loaded" indicator accuracy across different providers
|
||||
- Generic tile provider error messages (not always "OSM tiles slow")
|
||||
- Optional custom icons for camera profiles
|
||||
- Camera deletions
|
||||
- Clean up cache when submitted changesets appear in Overpass results
|
||||
- Upgrade device marker design (considering nullplate's svg)
|
||||
|
||||
### Future Features & Wishlist
|
||||
- Location-based notifications when approaching surveillance devices
|
||||
- Red/yellow ring for devices missing specific tag details
|
||||
- iOS/Android native themes and dark mode support
|
||||
- "Cache accumulating" offline areas?
|
||||
- "Offline areas" as tile provider?
|
||||
- Jump to location by coordinates, address, or POI name
|
||||
- Route planning that avoids surveillance devices
|
||||
- Custom device providers and OSM/Overpass alternatives
|
||||
|
||||
---
|
||||
|
||||
## Build Environment Quick Setup
|
||||
## Contributing & Community
|
||||
|
||||
# Install from GUI:
|
||||
Xcode, Android Studio.
|
||||
Xcode cmdline tools
|
||||
Android cmdline tools + NDK
|
||||
This app is part of the larger **DeFlock** initiative. Join the community:
|
||||
|
||||
# Terminal
|
||||
brew install openjdk@17
|
||||
sudo ln -sfn /usr/local/opt/openjdk@17/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk.jdk
|
||||
- **Documentation & Guides**: [deflock.me](https://deflock.me)
|
||||
- **Community Discussion**: [deflock.me](https://deflock.me)
|
||||
- **Issues & Feature Requests**: GitHub Issues
|
||||
- **Development**: See developer setup above
|
||||
|
||||
brew install ruby
|
||||
---
|
||||
|
||||
gem install cocoapods
|
||||
## Privacy & Ethics
|
||||
|
||||
sdkmanager --install "ndk;27.0.12077973"
|
||||
This project helps make existing public surveillance infrastructure transparent and searchable. We only document surveillance devices that are already installed and visible in public spaces.
|
||||
|
||||
export PATH="/Users/bob/.gem/ruby/3.4.0/bin:$PATH"
|
||||
export PATH=$HOME/development/flutter/bin:$PATH
|
||||
No user information is ever collected, and no data leaves your device except submissions to OSM and whatever data your tile provider can glean from your requests.
|
||||
|
||||
flutter clean
|
||||
flutter pub get
|
||||
flutter run
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
This project is open source. See [LICENSE](LICENSE) for details.
|
||||
|
||||
@@ -6,7 +6,7 @@ plugins {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.flock_map_app"
|
||||
namespace = "me.deflock.deflockapp"
|
||||
|
||||
// Matches current stable Flutter (compileSdk 34 as of July 2025)
|
||||
compileSdk = 35
|
||||
@@ -24,7 +24,7 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
// Application ID (package name)
|
||||
applicationId = "com.example.flock_map_app"
|
||||
applicationId = "me.deflock.deflockapp"
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// oauth2_client 4.x & flutter_web_auth_2 5.x require minSdk 23
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
<application
|
||||
android:name="${applicationName}"
|
||||
android:label="flock_map_app"
|
||||
android:label="DeFlock"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:enableOnBackInvokedCallback="true">
|
||||
<!-- Main Flutter activity -->
|
||||
@@ -17,7 +17,6 @@
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:hardwareAccelerated="true"
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
@@ -44,8 +43,7 @@
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<!-- flockmap://auth -->
|
||||
<data android:scheme="flockmap" android:host="auth"/>
|
||||
<data android:scheme="deflockapp" android:host="auth"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
package com.example.flock_map_app
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
@@ -0,0 +1,5 @@
|
||||
package me.deflock.deflockapp
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
27
assets/deflock-logo.svg
Normal file
27
assets/deflock-logo.svg
Normal file
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 1150 300" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<rect id="Artboard1" x="0" y="0" width="1150" height="300" style="fill:none;"/>
|
||||
<g id="Artboard11" serif:id="Artboard1">
|
||||
<g>
|
||||
<g transform="matrix(344.475,0,0,344.475,30.1181,267.042)">
|
||||
<path d="M0.377,-0.658L0.377,-0.655C0.421,-0.629 0.415,-0.593 0.415,-0.547L0.415,-0.415C0.373,-0.452 0.317,-0.473 0.261,-0.473C0.124,-0.473 0.024,-0.364 0.024,-0.229C0.024,-0.08 0.131,0.013 0.277,0.013C0.295,0.013 0.312,0.013 0.329,0.008L0.388,-0.082C0.361,-0.065 0.334,-0.053 0.302,-0.053C0.197,-0.053 0.125,-0.142 0.125,-0.243C0.125,-0.334 0.19,-0.407 0.27,-0.407C0.323,-0.407 0.374,-0.383 0.399,-0.335C0.418,-0.298 0.415,-0.254 0.415,-0.214L0.415,-0L0.544,-0L0.544,-0.003C0.5,-0.027 0.506,-0.064 0.506,-0.11L0.506,-0.674L0.503,-0.674C0.492,-0.658 0.468,-0.658 0.445,-0.658L0.377,-0.658Z" style="fill:rgb(0,128,188);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(344.475,0,0,344.475,229.914,267.042)">
|
||||
<path d="M0.5,-0.246C0.504,-0.375 0.411,-0.473 0.275,-0.473C0.126,-0.473 0.025,-0.372 0.025,-0.233C0.025,-0.094 0.142,0.013 0.312,0.013C0.359,0.013 0.407,0.006 0.45,-0.012L0.5,-0.106L0.497,-0.106C0.451,-0.07 0.393,-0.053 0.333,-0.053C0.22,-0.053 0.135,-0.124 0.133,-0.246L0.5,-0.246ZM0.137,-0.304C0.149,-0.367 0.199,-0.407 0.266,-0.407C0.338,-0.407 0.384,-0.374 0.395,-0.304L0.137,-0.304Z" style="fill:rgb(0,128,188);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(344.475,0,0,344.475,409.04,267.042)">
|
||||
<path d="M0.023,-0.394L0.071,-0.394L0.071,-0.11C0.071,-0.064 0.077,-0.027 0.033,-0.003L0.033,-0L0.2,-0L0.2,-0.003C0.156,-0.028 0.162,-0.064 0.162,-0.11L0.162,-0.394L0.264,-0.394C0.276,-0.394 0.291,-0.391 0.295,-0.38L0.298,-0.38L0.298,-0.46L0.162,-0.46C0.162,-0.56 0.157,-0.608 0.249,-0.608C0.278,-0.608 0.308,-0.603 0.333,-0.59L0.333,-0.11C0.333,-0.064 0.339,-0.027 0.295,-0.003L0.295,-0L0.462,-0L0.462,-0.003C0.418,-0.027 0.424,-0.064 0.424,-0.11L0.424,-0.674L0.421,-0.674C0.411,-0.663 0.394,-0.656 0.378,-0.656C0.347,-0.656 0.319,-0.674 0.266,-0.674C0.206,-0.674 0.148,-0.654 0.107,-0.608C0.068,-0.564 0.071,-0.525 0.071,-0.46L0.023,-0.394Z" style="fill:rgb(86,86,86);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(344.475,0,0,344.475,581.278,267.042)">
|
||||
<path d="M0.276,0.013C0.417,0.013 0.537,-0.091 0.537,-0.235C0.537,-0.303 0.506,-0.369 0.455,-0.414C0.407,-0.456 0.352,-0.473 0.288,-0.473C0.144,-0.473 0.023,-0.376 0.023,-0.226C0.023,-0.084 0.139,0.013 0.276,0.013ZM0.281,-0.053C0.179,-0.053 0.124,-0.152 0.124,-0.244C0.124,-0.334 0.184,-0.407 0.277,-0.407C0.384,-0.407 0.436,-0.311 0.436,-0.214C0.436,-0.124 0.373,-0.053 0.281,-0.053Z" style="fill:rgb(86,86,86);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(344.475,0,0,344.475,774.184,267.042)">
|
||||
<path d="M0.415,-0.461C0.38,-0.469 0.343,-0.473 0.307,-0.473C0.156,-0.473 0.022,-0.39 0.022,-0.218C0.022,-0.088 0.142,0.013 0.296,0.013C0.34,0.013 0.386,0.009 0.428,-0.007L0.48,-0.102L0.477,-0.102C0.438,-0.073 0.382,-0.053 0.331,-0.053C0.22,-0.053 0.123,-0.129 0.123,-0.244C0.123,-0.339 0.193,-0.407 0.29,-0.407C0.335,-0.407 0.383,-0.391 0.412,-0.358L0.415,-0.358L0.415,-0.461Z" style="fill:rgb(86,86,86);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(344.475,0,0,344.475,932.642,267.042)">
|
||||
<path d="M0.029,-0.658L0.029,-0.655C0.072,-0.63 0.066,-0.593 0.066,-0.547L0.066,-0.111C0.066,-0.065 0.072,-0.028 0.029,-0.003L0.029,-0L0.196,-0L0.196,-0.003C0.151,-0.028 0.157,-0.065 0.157,-0.111L0.157,-0.674L0.154,-0.674C0.141,-0.659 0.117,-0.658 0.095,-0.658L0.029,-0.658ZM0.324,-0.056C0.343,-0.029 0.368,-0 0.426,-0L0.504,-0C0.459,-0.028 0.429,-0.071 0.398,-0.112L0.276,-0.267L0.443,-0.46L0.291,-0.46L0.291,-0.457C0.301,-0.451 0.31,-0.442 0.31,-0.428C0.31,-0.403 0.274,-0.365 0.259,-0.348L0.176,-0.257L0.324,-0.056Z" style="fill:rgb(86,86,86);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
@@ -1,7 +1,73 @@
|
||||
Flock Map App
|
||||
🇺🇸 ENGLISH
|
||||
|
||||
Built with Flutter.
|
||||
DeFlock - Surveillance Transparency
|
||||
|
||||
Offline areas, privacy-respecting, designed for OpenStreetMap camera tagging.
|
||||
DeFlock is a privacy-focused mobile app for mapping public surveillance infrastructure using OpenStreetMap. Document cameras, ALPRs, gunshot detectors, and other surveillance devices in your community to make this infrastructure visible and searchable.
|
||||
|
||||
This text is loaded from assets/info.txt.
|
||||
• Offline-capable mapping with downloadable areas
|
||||
• Upload directly to OpenStreetMap with OAuth2
|
||||
• Built-in profiles for major manufacturers
|
||||
• Privacy-respecting - no user data collected
|
||||
• Multiple map tile providers (OSM, satellite imagery)
|
||||
|
||||
Part of the broader DeFlock initiative to promote surveillance transparency.
|
||||
|
||||
Visit: deflock.me
|
||||
Built with Flutter • Open Source
|
||||
|
||||
---
|
||||
|
||||
🇪🇸 ESPAÑOL
|
||||
|
||||
DeFlock - Transparencia en Vigilancia
|
||||
|
||||
DeFlock es una aplicación móvil enfocada en la privacidad para mapear infraestructura de vigilancia pública usando OpenStreetMap. Documenta cámaras, ALPRs, detectores de disparos y otros dispositivos de vigilancia en tu comunidad para hacer visible y consultable esta infraestructura.
|
||||
|
||||
• Mapeo con capacidad offline con áreas descargables
|
||||
• Subida directa a OpenStreetMap con OAuth2
|
||||
• Perfiles integrados para fabricantes principales
|
||||
• Respeta la privacidad - no se recopilan datos del usuario
|
||||
• Múltiples proveedores de mapas (OSM, imágenes satelitales)
|
||||
|
||||
Parte de la iniciativa más amplia DeFlock para promover la transparencia en vigilancia.
|
||||
|
||||
Visita: deflock.me
|
||||
Construido con Flutter • Código Abierto
|
||||
|
||||
---
|
||||
|
||||
🇫🇷 FRANÇAIS
|
||||
|
||||
DeFlock - Transparence de la Surveillance
|
||||
|
||||
DeFlock est une application mobile axée sur la confidentialité pour cartographier l'infrastructure de surveillance publique en utilisant OpenStreetMap. Documentez les caméras, ALPRs, détecteurs de coups de feu et autres dispositifs de surveillance dans votre communauté pour rendre cette infrastructure visible et consultable.
|
||||
|
||||
• Cartographie hors ligne avec zones téléchargeables
|
||||
• Upload direct vers OpenStreetMap avec OAuth2
|
||||
• Profils intégrés pour les principaux fabricants
|
||||
• Respectueux de la confidentialité - aucune donnée utilisateur collectée
|
||||
• Multiples fournisseurs de cartes (OSM, imagerie satellite)
|
||||
|
||||
Partie de l'initiative plus large DeFlock pour promouvoir la transparence de la surveillance.
|
||||
|
||||
Visitez : deflock.me
|
||||
Construit avec Flutter • Source Ouverte
|
||||
|
||||
---
|
||||
|
||||
🇩🇪 DEUTSCH
|
||||
|
||||
DeFlock - Überwachungs-Transparenz
|
||||
|
||||
DeFlock ist eine datenschutzorientierte mobile App zur Kartierung öffentlicher Überwachungsinfrastruktür mit OpenStreetMap. Dokumentieren Sie Kameras, ALPRs, Schussdetektoren und andere Überwachungsgeräte in Ihrer Gemeinde, um diese Infrastruktur sichtbar und durchsuchbar zu machen.
|
||||
|
||||
• Offline-fähige Kartierung mit herunterladbaren Bereichen
|
||||
• Direkter Upload zu OpenStreetMap mit OAuth2
|
||||
• Integrierte Profile für große Hersteller
|
||||
• Datenschutzfreundlich - keine Nutzerdaten gesammelt
|
||||
• Multiple Kartenanbieter (OSM, Satellitenbilder)
|
||||
|
||||
Teil der breiteren DeFlock-Initiative zur Förderung von Überwachungstransparenz.
|
||||
|
||||
Besuchen Sie: deflock.me
|
||||
Gebaut mit Flutter • Open Source
|
||||
|
||||
16
do_builds.sh
Executable file
16
do_builds.sh
Executable file
@@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
|
||||
appver=$(cat lib/dev_config.dart | grep "kClientVersion" | cut -d '=' -f 2 | tr -d ';' | tr -d "\'" | tr -d " ")
|
||||
echo
|
||||
echo "Building app version ${appver}..."
|
||||
flutter build ios --no-codesign
|
||||
flutter build apk
|
||||
echo
|
||||
echo "Converting .app to .ipa..."
|
||||
./app2ipa.sh build/ios/iphoneos/Runner.app
|
||||
echo
|
||||
echo "Moving files..."
|
||||
cp build/app/outputs/flutter-apk/app-release.apk ../flockmap_v${appver}.apk
|
||||
mv Runner.ipa ../flockmap_v${appver}.ipa
|
||||
echo
|
||||
echo "Done."
|
||||
@@ -477,7 +477,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.flockMapApp;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = me.deflock.deflockapp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -494,7 +494,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.flockMapApp.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = me.deflock.deflockapp.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Flock Map App</string>
|
||||
<string>DeFlock</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
@@ -13,7 +13,7 @@
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>flock_map_app</string>
|
||||
<string>deflockapp</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
@@ -55,7 +55,7 @@
|
||||
<string>None</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>flockmap</string>
|
||||
<string>deflockapp</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
|
||||
@@ -1,473 +1,296 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'models/camera_profile.dart';
|
||||
import 'models/node_profile.dart';
|
||||
import 'models/operator_profile.dart';
|
||||
import 'models/osm_camera_node.dart';
|
||||
import 'models/pending_upload.dart';
|
||||
import 'services/auth_service.dart';
|
||||
import 'services/uploader.dart';
|
||||
import 'services/profile_service.dart';
|
||||
import 'widgets/tile_provider_with_cache.dart';
|
||||
|
||||
// Enum for upload mode (Production, OSM Sandbox, Simulate)
|
||||
enum UploadMode { production, sandbox, simulate }
|
||||
|
||||
// ------------------ AddCameraSession ------------------
|
||||
class AddCameraSession {
|
||||
AddCameraSession({required this.profile, this.directionDegrees = 0});
|
||||
CameraProfile profile;
|
||||
double directionDegrees;
|
||||
LatLng? target;
|
||||
}
|
||||
import 'models/tile_provider.dart';
|
||||
import 'services/offline_area_service.dart';
|
||||
import 'state/auth_state.dart';
|
||||
import 'state/operator_profile_state.dart';
|
||||
import 'state/profile_state.dart';
|
||||
import 'state/session_state.dart';
|
||||
import 'state/settings_state.dart';
|
||||
import 'state/upload_queue_state.dart';
|
||||
|
||||
// Re-export types
|
||||
export 'state/settings_state.dart' show UploadMode, FollowMeMode;
|
||||
export 'state/session_state.dart' show AddNodeSession, EditNodeSession;
|
||||
|
||||
// ------------------ AppState ------------------
|
||||
class AppState extends ChangeNotifier {
|
||||
static late AppState instance;
|
||||
|
||||
// State modules
|
||||
late final AuthState _authState;
|
||||
late final OperatorProfileState _operatorProfileState;
|
||||
late final ProfileState _profileState;
|
||||
late final SessionState _sessionState;
|
||||
late final SettingsState _settingsState;
|
||||
late final UploadQueueState _uploadQueueState;
|
||||
|
||||
bool _isInitialized = false;
|
||||
|
||||
AppState() {
|
||||
instance = this;
|
||||
_authState = AuthState();
|
||||
_operatorProfileState = OperatorProfileState();
|
||||
_profileState = ProfileState();
|
||||
_sessionState = SessionState();
|
||||
_settingsState = SettingsState();
|
||||
_uploadQueueState = UploadQueueState();
|
||||
|
||||
// Set up state change listeners
|
||||
_authState.addListener(_onStateChanged);
|
||||
_operatorProfileState.addListener(_onStateChanged);
|
||||
_profileState.addListener(_onStateChanged);
|
||||
_sessionState.addListener(_onStateChanged);
|
||||
_settingsState.addListener(_onStateChanged);
|
||||
_uploadQueueState.addListener(_onStateChanged);
|
||||
|
||||
_init();
|
||||
}
|
||||
|
||||
// ------------------- Offline Mode -------------------
|
||||
static const String _offlineModePrefsKey = 'offline_mode';
|
||||
bool _offlineMode = false;
|
||||
bool get offlineMode => _offlineMode;
|
||||
Future<void> setOfflineMode(bool enabled) async {
|
||||
final wasOffline = _offlineMode;
|
||||
_offlineMode = enabled;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_offlineModePrefsKey, enabled);
|
||||
if (wasOffline && !enabled) {
|
||||
// Transitioning from offline to online: clear tile cache!
|
||||
TileProviderWithCache.clearCache();
|
||||
_startUploader(); // Resume upload queue processing as we leave offline mode
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
final _auth = AuthService();
|
||||
String? _username;
|
||||
|
||||
bool _isInitialized = false;
|
||||
// Getters that delegate to individual state modules
|
||||
bool get isInitialized => _isInitialized;
|
||||
|
||||
// Auth state
|
||||
bool get isLoggedIn => _authState.isLoggedIn;
|
||||
String get username => _authState.username;
|
||||
|
||||
// Profile state
|
||||
List<NodeProfile> get profiles => _profileState.profiles;
|
||||
List<NodeProfile> get enabledProfiles => _profileState.enabledProfiles;
|
||||
bool isEnabled(NodeProfile p) => _profileState.isEnabled(p);
|
||||
|
||||
// Operator profile state
|
||||
List<OperatorProfile> get operatorProfiles => _operatorProfileState.profiles;
|
||||
|
||||
// Session state
|
||||
AddNodeSession? get session => _sessionState.session;
|
||||
EditNodeSession? get editSession => _sessionState.editSession;
|
||||
|
||||
// Settings state
|
||||
bool get offlineMode => _settingsState.offlineMode;
|
||||
int get maxCameras => _settingsState.maxCameras;
|
||||
UploadMode get uploadMode => _settingsState.uploadMode;
|
||||
FollowMeMode get followMeMode => _settingsState.followMeMode;
|
||||
|
||||
// Tile provider state
|
||||
List<TileProvider> get tileProviders => _settingsState.tileProviders;
|
||||
TileType? get selectedTileType => _settingsState.selectedTileType;
|
||||
TileProvider? get selectedTileProvider => _settingsState.selectedTileProvider;
|
||||
|
||||
|
||||
final List<CameraProfile> _profiles = [];
|
||||
final Set<CameraProfile> _enabled = {};
|
||||
static const String _enabledPrefsKey = 'enabled_profiles';
|
||||
static const String _maxCamerasPrefsKey = 'max_cameras';
|
||||
|
||||
// Upload queue state
|
||||
int get pendingCount => _uploadQueueState.pendingCount;
|
||||
List<PendingUpload> get pendingUploads => _uploadQueueState.pendingUploads;
|
||||
|
||||
// Maximum number of cameras fetched/drawn
|
||||
int _maxCameras = 250;
|
||||
int get maxCameras => _maxCameras;
|
||||
set maxCameras(int n) {
|
||||
if (n < 10) n = 10; // minimum
|
||||
_maxCameras = n;
|
||||
SharedPreferences.getInstance().then((prefs) {
|
||||
prefs.setInt(_maxCamerasPrefsKey, n);
|
||||
});
|
||||
void _onStateChanged() {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Upload mode: production, sandbox, or simulate (in-memory, no uploads)
|
||||
UploadMode _uploadMode = UploadMode.simulate;
|
||||
static const String _uploadModePrefsKey = 'upload_mode';
|
||||
UploadMode get uploadMode => _uploadMode;
|
||||
Future<void> setUploadMode(UploadMode mode) async {
|
||||
_uploadMode = mode;
|
||||
// Update AuthService to match new mode
|
||||
_auth.setUploadMode(mode);
|
||||
// Refresh user display for active mode, validating token
|
||||
try {
|
||||
if (await _auth.isLoggedIn()) {
|
||||
print('AppState: Switching mode, token exists; validating...');
|
||||
final isValid = await validateToken();
|
||||
if (isValid) {
|
||||
print("AppState: Switching mode; fetching username for $mode...");
|
||||
_username = await _auth.login();
|
||||
if (_username != null) {
|
||||
print("AppState: Switched mode, now logged in as $_username");
|
||||
} else {
|
||||
print('AppState: Switched mode but failed to retrieve username');
|
||||
}
|
||||
} else {
|
||||
print('AppState: Switching mode, token invalid—auto-logout.');
|
||||
await logout(); // This clears _username also.
|
||||
}
|
||||
} else {
|
||||
_username = null;
|
||||
print("AppState: Mode change: not logged in in $mode");
|
||||
}
|
||||
} catch (e) {
|
||||
_username = null;
|
||||
print("AppState: Mode change user restoration error: $e");
|
||||
}
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_uploadModePrefsKey, mode.index);
|
||||
print("AppState: Upload mode set to $mode");
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// For legacy bool test mode
|
||||
static const String _legacyTestModePrefsKey = 'test_mode';
|
||||
|
||||
AddCameraSession? _session;
|
||||
AddCameraSession? get session => _session;
|
||||
final List<PendingUpload> _queue = [];
|
||||
Timer? _uploadTimer;
|
||||
|
||||
bool get isLoggedIn => _username != null;
|
||||
String get username => _username ?? '';
|
||||
|
||||
// ---------- Init ----------
|
||||
Future<void> _init() async {
|
||||
// Initialize profiles: built-in + custom
|
||||
_profiles.add(CameraProfile.alpr());
|
||||
_profiles.addAll(await ProfileService().load());
|
||||
|
||||
// Load enabled profile IDs and upload/test mode from prefs
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final enabledIds = prefs.getStringList(_enabledPrefsKey);
|
||||
if (enabledIds != null && enabledIds.isNotEmpty) {
|
||||
// Restore enabled profiles by id
|
||||
_enabled.addAll(_profiles.where((p) => enabledIds.contains(p.id)));
|
||||
} else {
|
||||
// By default, all are enabled
|
||||
_enabled.addAll(_profiles);
|
||||
}
|
||||
// Upload mode loading (including migration from old test_mode bool)
|
||||
if (prefs.containsKey(_uploadModePrefsKey)) {
|
||||
final idx = prefs.getInt(_uploadModePrefsKey) ?? 0;
|
||||
if (idx >= 0 && idx < UploadMode.values.length) {
|
||||
_uploadMode = UploadMode.values[idx];
|
||||
}
|
||||
} else if (prefs.containsKey(_legacyTestModePrefsKey)) {
|
||||
// migrate legacy test_mode (true->simulate, false->prod)
|
||||
final legacy = prefs.getBool(_legacyTestModePrefsKey) ?? false;
|
||||
_uploadMode = legacy ? UploadMode.simulate : UploadMode.production;
|
||||
await prefs.remove(_legacyTestModePrefsKey);
|
||||
await prefs.setInt(_uploadModePrefsKey, _uploadMode.index);
|
||||
}
|
||||
// Max cameras
|
||||
if (prefs.containsKey(_maxCamerasPrefsKey)) {
|
||||
_maxCameras = prefs.getInt(_maxCamerasPrefsKey) ?? 250;
|
||||
}
|
||||
// Offline mode loading
|
||||
if (prefs.containsKey(_offlineModePrefsKey)) {
|
||||
_offlineMode = prefs.getBool(_offlineModePrefsKey) ?? false;
|
||||
}
|
||||
// Ensure AuthService follows loaded mode
|
||||
_auth.setUploadMode(_uploadMode);
|
||||
print('AppState: AuthService mode now updated to $_uploadMode');
|
||||
|
||||
await _loadQueue();
|
||||
// Initialize all state modules
|
||||
await _settingsState.init();
|
||||
await _operatorProfileState.init();
|
||||
await _profileState.init();
|
||||
await _uploadQueueState.init();
|
||||
await _authState.init(_settingsState.uploadMode);
|
||||
|
||||
// Check if we're already logged in and get username
|
||||
try {
|
||||
if (await _auth.isLoggedIn()) {
|
||||
print('AppState: User appears to be logged in, fetching username...');
|
||||
_username = await _auth.login();
|
||||
if (_username != null) {
|
||||
print("AppState: Successfully retrieved username: $_username");
|
||||
} else {
|
||||
print('AppState: Failed to retrieve username despite being logged in');
|
||||
}
|
||||
} else {
|
||||
print('AppState: User is not logged in');
|
||||
}
|
||||
} catch (e) {
|
||||
print("AppState: Error during auth initialization: $e");
|
||||
}
|
||||
// Initialize OfflineAreaService to ensure offline areas are loaded
|
||||
await OfflineAreaService().ensureInitialized();
|
||||
|
||||
// Start uploader if conditions are met
|
||||
_startUploader();
|
||||
|
||||
_isInitialized = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ---------- Auth ----------
|
||||
// ---------- Auth Methods ----------
|
||||
Future<void> login() async {
|
||||
try {
|
||||
print('AppState: Starting login process...');
|
||||
_username = await _auth.login();
|
||||
if (_username != null) {
|
||||
print("AppState: Login successful for user: $_username");
|
||||
} else {
|
||||
print('AppState: Login failed - no username returned');
|
||||
}
|
||||
} catch (e) {
|
||||
print("AppState: Login error: $e");
|
||||
_username = null;
|
||||
}
|
||||
notifyListeners();
|
||||
await _authState.login();
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
await _auth.logout();
|
||||
_username = null;
|
||||
notifyListeners();
|
||||
await _authState.logout();
|
||||
}
|
||||
|
||||
// Add method to refresh auth state
|
||||
Future<void> refreshAuthState() async {
|
||||
try {
|
||||
print('AppState: Refreshing auth state...');
|
||||
if (await _auth.isLoggedIn()) {
|
||||
print('AppState: Token exists, fetching username...');
|
||||
_username = await _auth.login();
|
||||
if (_username != null) {
|
||||
print("AppState: Auth refresh successful: $_username");
|
||||
} else {
|
||||
print('AppState: Auth refresh failed - no username');
|
||||
}
|
||||
} else {
|
||||
print('AppState: No valid token found');
|
||||
_username = null;
|
||||
}
|
||||
} catch (e) {
|
||||
print("AppState: Auth refresh error: $e");
|
||||
_username = null;
|
||||
}
|
||||
notifyListeners();
|
||||
await _authState.refreshAuthState();
|
||||
}
|
||||
|
||||
// Force a completely fresh login (clears stored tokens)
|
||||
Future<void> forceLogin() async {
|
||||
try {
|
||||
print('AppState: Starting forced fresh login...');
|
||||
_username = await _auth.forceLogin();
|
||||
if (_username != null) {
|
||||
print("AppState: Forced login successful: $_username");
|
||||
} else {
|
||||
print('AppState: Forced login failed - no username returned');
|
||||
}
|
||||
} catch (e) {
|
||||
print("AppState: Forced login error: $e");
|
||||
_username = null;
|
||||
}
|
||||
notifyListeners();
|
||||
await _authState.forceLogin();
|
||||
}
|
||||
|
||||
// Validate current token/credentials
|
||||
Future<bool> validateToken() async {
|
||||
try {
|
||||
return await _auth.isLoggedIn();
|
||||
} catch (e) {
|
||||
print("AppState: Token validation error: $e");
|
||||
return false;
|
||||
}
|
||||
return await _authState.validateToken();
|
||||
}
|
||||
|
||||
// ---------- Profiles ----------
|
||||
List<CameraProfile> get profiles => List.unmodifiable(_profiles);
|
||||
bool isEnabled(CameraProfile p) => _enabled.contains(p);
|
||||
List<CameraProfile> get enabledProfiles =>
|
||||
_profiles.where(isEnabled).toList(growable: false);
|
||||
void toggleProfile(CameraProfile p, bool e) {
|
||||
if (e) {
|
||||
_enabled.add(p);
|
||||
} else {
|
||||
_enabled.remove(p);
|
||||
// Safety: Always have at least one enabled profile
|
||||
if (_enabled.isEmpty) {
|
||||
final builtIn = _profiles.firstWhere((profile) => profile.builtin, orElse: () => _profiles.first);
|
||||
_enabled.add(builtIn);
|
||||
}
|
||||
}
|
||||
_saveEnabledProfiles();
|
||||
notifyListeners();
|
||||
// ---------- Profile Methods ----------
|
||||
void toggleProfile(NodeProfile p, bool e) {
|
||||
_profileState.toggleProfile(p, e);
|
||||
}
|
||||
|
||||
void addOrUpdateProfile(CameraProfile p) {
|
||||
final idx = _profiles.indexWhere((x) => x.id == p.id);
|
||||
if (idx >= 0) {
|
||||
_profiles[idx] = p;
|
||||
} else {
|
||||
_profiles.add(p);
|
||||
_enabled.add(p);
|
||||
_saveEnabledProfiles();
|
||||
}
|
||||
ProfileService().save(_profiles);
|
||||
notifyListeners();
|
||||
void addOrUpdateProfile(NodeProfile p) {
|
||||
_profileState.addOrUpdateProfile(p);
|
||||
}
|
||||
|
||||
void deleteProfile(CameraProfile p) {
|
||||
if (p.builtin) return;
|
||||
_enabled.remove(p);
|
||||
_profiles.removeWhere((x) => x.id == p.id);
|
||||
// Safety: Always have at least one enabled profile
|
||||
if (_enabled.isEmpty) {
|
||||
final builtIn = _profiles.firstWhere((profile) => profile.builtin, orElse: () => _profiles.first);
|
||||
_enabled.add(builtIn);
|
||||
}
|
||||
_saveEnabledProfiles();
|
||||
ProfileService().save(_profiles);
|
||||
notifyListeners();
|
||||
void deleteProfile(NodeProfile p) {
|
||||
_profileState.deleteProfile(p);
|
||||
}
|
||||
|
||||
// Save enabled profile IDs to disk
|
||||
Future<void> _saveEnabledProfiles() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList(
|
||||
_enabledPrefsKey,
|
||||
_enabled.map((p) => p.id).toList(),
|
||||
);
|
||||
// ---------- Operator Profile Methods ----------
|
||||
void addOrUpdateOperatorProfile(OperatorProfile p) {
|
||||
_operatorProfileState.addOrUpdateProfile(p);
|
||||
}
|
||||
|
||||
// ---------- Add‑camera session ----------
|
||||
void deleteOperatorProfile(OperatorProfile p) {
|
||||
_operatorProfileState.deleteProfile(p);
|
||||
}
|
||||
|
||||
// ---------- Session Methods ----------
|
||||
void startAddSession() {
|
||||
_session = AddCameraSession(profile: enabledProfiles.first);
|
||||
notifyListeners();
|
||||
_sessionState.startAddSession(enabledProfiles);
|
||||
}
|
||||
|
||||
void startEditSession(OsmCameraNode node) {
|
||||
_sessionState.startEditSession(node, enabledProfiles);
|
||||
}
|
||||
|
||||
void updateSession({
|
||||
double? directionDeg,
|
||||
CameraProfile? profile,
|
||||
NodeProfile? profile,
|
||||
OperatorProfile? operatorProfile,
|
||||
LatLng? target,
|
||||
}) {
|
||||
if (_session == null) return;
|
||||
_sessionState.updateSession(
|
||||
directionDeg: directionDeg,
|
||||
profile: profile,
|
||||
operatorProfile: operatorProfile,
|
||||
target: target,
|
||||
);
|
||||
}
|
||||
|
||||
bool dirty = false;
|
||||
if (directionDeg != null && directionDeg != _session!.directionDegrees) {
|
||||
_session!.directionDegrees = directionDeg;
|
||||
dirty = true;
|
||||
}
|
||||
if (profile != null && profile != _session!.profile) {
|
||||
_session!.profile = profile;
|
||||
dirty = true;
|
||||
}
|
||||
if (target != null) {
|
||||
_session!.target = target;
|
||||
dirty = true;
|
||||
}
|
||||
if (dirty) notifyListeners(); // <-- slider & map update
|
||||
void updateEditSession({
|
||||
double? directionDeg,
|
||||
NodeProfile? profile,
|
||||
OperatorProfile? operatorProfile,
|
||||
LatLng? target,
|
||||
}) {
|
||||
_sessionState.updateEditSession(
|
||||
directionDeg: directionDeg,
|
||||
profile: profile,
|
||||
operatorProfile: operatorProfile,
|
||||
target: target,
|
||||
);
|
||||
}
|
||||
|
||||
void cancelSession() {
|
||||
_session = null;
|
||||
notifyListeners();
|
||||
_sessionState.cancelSession();
|
||||
}
|
||||
|
||||
void cancelEditSession() {
|
||||
_sessionState.cancelEditSession();
|
||||
}
|
||||
|
||||
void commitSession() {
|
||||
if (_session?.target == null) return;
|
||||
_queue.add(
|
||||
PendingUpload(
|
||||
coord: _session!.target!,
|
||||
direction: _session!.directionDegrees,
|
||||
profile: _session!.profile,
|
||||
),
|
||||
);
|
||||
_saveQueue();
|
||||
_session = null;
|
||||
|
||||
// Restart uploader when new items are added
|
||||
_startUploader();
|
||||
|
||||
notifyListeners();
|
||||
final session = _sessionState.commitSession();
|
||||
if (session != null) {
|
||||
_uploadQueueState.addFromSession(session, uploadMode: uploadMode);
|
||||
_startUploader();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Queue persistence ----------
|
||||
Future<void> _saveQueue() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonList = _queue.map((e) => e.toJson()).toList();
|
||||
await prefs.setString('queue', jsonEncode(jsonList));
|
||||
void commitEditSession() {
|
||||
final session = _sessionState.commitEditSession();
|
||||
if (session != null) {
|
||||
_uploadQueueState.addFromEditSession(session, uploadMode: uploadMode);
|
||||
_startUploader();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadQueue() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonStr = prefs.getString('queue');
|
||||
if (jsonStr == null) return;
|
||||
final list = jsonDecode(jsonStr) as List<dynamic>;
|
||||
_queue
|
||||
..clear()
|
||||
..addAll(list.map((e) => PendingUpload.fromJson(e)));
|
||||
// ---------- Settings Methods ----------
|
||||
Future<void> setOfflineMode(bool enabled) async {
|
||||
await _settingsState.setOfflineMode(enabled);
|
||||
if (!enabled) {
|
||||
_startUploader(); // Resume upload queue processing as we leave offline mode
|
||||
} else {
|
||||
_uploadQueueState.stopUploader(); // Stop uploader in offline mode
|
||||
// Cancel any active area downloads
|
||||
await OfflineAreaService().cancelActiveDownloads();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Uploader ----------
|
||||
void _startUploader() {
|
||||
_uploadTimer?.cancel();
|
||||
|
||||
// No uploads without auth or queue, or if offline mode is enabled.
|
||||
if (_queue.isEmpty || _offlineMode) return;
|
||||
|
||||
_uploadTimer = Timer.periodic(const Duration(seconds: 10), (t) async {
|
||||
if (_queue.isEmpty || _offlineMode) {
|
||||
_uploadTimer?.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the first queue item that is NOT in error state and act on that
|
||||
final item = _queue.where((pu) => !pu.error).cast<PendingUpload?>().firstOrNull;
|
||||
if (item == null) return;
|
||||
|
||||
// Retrieve access after every tick (accounts for re-login)
|
||||
final access = await _auth.getAccessToken();
|
||||
if (access == null) return; // not logged in
|
||||
|
||||
bool ok;
|
||||
if (_uploadMode == UploadMode.simulate) {
|
||||
// Simulate successful upload without calling real API
|
||||
print("AppState: UploadMode.simulate - simulating upload for ${item.coord}");
|
||||
await Future.delayed(const Duration(seconds: 1)); // Simulate network delay
|
||||
ok = true;
|
||||
print('AppState: Simulated upload successful');
|
||||
} else {
|
||||
// Real upload -- pass uploadMode so uploader can switch between prod and sandbox
|
||||
final up = Uploader(access, () {
|
||||
_queue.remove(item);
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
}, uploadMode: _uploadMode);
|
||||
ok = await up.upload(item);
|
||||
}
|
||||
|
||||
if (ok && _uploadMode == UploadMode.simulate) {
|
||||
// Remove manually for simulate mode
|
||||
_queue.remove(item);
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
}
|
||||
if (!ok) {
|
||||
item.attempts++;
|
||||
if (item.attempts >= 3) {
|
||||
// Mark as error and stop the uploader. User can manually retry.
|
||||
item.error = true;
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
_uploadTimer?.cancel();
|
||||
} else {
|
||||
await Future.delayed(const Duration(seconds: 20));
|
||||
}
|
||||
}
|
||||
});
|
||||
set maxCameras(int n) {
|
||||
_settingsState.maxCameras = n;
|
||||
}
|
||||
|
||||
// ---------- Exposed getters ----------
|
||||
int get pendingCount => _queue.length;
|
||||
List<PendingUpload> get pendingUploads => List.unmodifiable(_queue);
|
||||
|
||||
// ---------- Queue management ----------
|
||||
Future<void> setUploadMode(UploadMode mode) async {
|
||||
await _settingsState.setUploadMode(mode);
|
||||
await _authState.onUploadModeChanged(mode);
|
||||
_startUploader(); // Restart uploader with new mode
|
||||
}
|
||||
|
||||
/// Select a tile type by ID
|
||||
Future<void> setSelectedTileType(String tileTypeId) async {
|
||||
await _settingsState.setSelectedTileType(tileTypeId);
|
||||
}
|
||||
|
||||
/// Add or update a tile provider
|
||||
Future<void> addOrUpdateTileProvider(TileProvider provider) async {
|
||||
await _settingsState.addOrUpdateTileProvider(provider);
|
||||
}
|
||||
|
||||
/// Delete a tile provider
|
||||
Future<void> deleteTileProvider(String providerId) async {
|
||||
await _settingsState.deleteTileProvider(providerId);
|
||||
}
|
||||
|
||||
/// Set follow-me mode
|
||||
Future<void> setFollowMeMode(FollowMeMode mode) async {
|
||||
await _settingsState.setFollowMeMode(mode);
|
||||
}
|
||||
|
||||
// ---------- Queue Methods ----------
|
||||
void clearQueue() {
|
||||
print("AppState: Clearing upload queue (${_queue.length} items)");
|
||||
_queue.clear();
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
_uploadQueueState.clearQueue();
|
||||
}
|
||||
|
||||
void removeFromQueue(PendingUpload upload) {
|
||||
print("AppState: Removing upload from queue: ${upload.coord}");
|
||||
_queue.remove(upload);
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
_uploadQueueState.removeFromQueue(upload);
|
||||
}
|
||||
|
||||
// Retry a failed upload (clear error and attempts, then try uploading again)
|
||||
void retryUpload(PendingUpload upload) {
|
||||
upload.error = false;
|
||||
upload.attempts = 0;
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
_uploadQueueState.retryUpload(upload);
|
||||
_startUploader(); // resume uploader if not busy
|
||||
}
|
||||
|
||||
// ---------- Private Methods ----------
|
||||
void _startUploader() {
|
||||
_uploadQueueState.startUploader(
|
||||
offlineMode: offlineMode,
|
||||
uploadMode: uploadMode,
|
||||
getAccessToken: _authState.getAccessToken,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_authState.removeListener(_onStateChanged);
|
||||
_operatorProfileState.removeListener(_onStateChanged);
|
||||
_profileState.removeListener(_onStateChanged);
|
||||
_sessionState.removeListener(_onStateChanged);
|
||||
_settingsState.removeListener(_onStateChanged);
|
||||
_uploadQueueState.removeListener(_onStateChanged);
|
||||
|
||||
_uploadQueueState.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// lib/dev_config.dart
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Developer/build-time configuration for global/non-user-tunable constants.
|
||||
const int kWorldMinZoom = 1;
|
||||
const int kWorldMaxZoom = 5;
|
||||
@@ -7,26 +9,39 @@ const int kWorldMaxZoom = 5;
|
||||
const double kTileEstimateKb = 25.0;
|
||||
|
||||
// Direction cone for map view
|
||||
const double kDirectionConeHalfAngle = 20.0; // degrees
|
||||
const double kDirectionConeBaseLength = 0.0012; // multiplier
|
||||
const double kDirectionConeHalfAngle = 30.0; // degrees
|
||||
const double kDirectionConeBaseLength = 0.001; // multiplier
|
||||
const Color kDirectionConeColor = Color(0xFF000000); // FOV cone color
|
||||
|
||||
// Add Camera pin vertical offset (for pin tip to match coordinate on map)
|
||||
const double kAddPinYOffset = -16.0;
|
||||
// Margin (bottom) for positioning the floating bottom button bar
|
||||
const double kBottomButtonBarMargin = 4.0;
|
||||
|
||||
// Bottom overlay vertical positions (distance from bottom of screen)
|
||||
const double kAttributionBottom = 5.0;
|
||||
const double kScaleBarBottom = 40.0;
|
||||
const double kZoomIndicatorBottom = 70.0;
|
||||
// Map overlay (attribution, scale bar, zoom) vertical offset from bottom edge
|
||||
const double kAttributionBottomOffset = 110.0;
|
||||
const double kZoomIndicatorBottomOffset = 142.0;
|
||||
const double kScaleBarBottomOffset = 170.0;
|
||||
|
||||
// Add Camera icon vertical offset (no offset needed since circle is centered)
|
||||
const double kAddPinYOffset = 0.0;
|
||||
|
||||
// Client name and version for OSM uploads ("created_by" tag)
|
||||
const String kClientName = 'FlockMap';
|
||||
const String kClientVersion = '0.8.1';
|
||||
const String kClientName = 'DeFlock';
|
||||
const String kClientVersion = '0.9.8';
|
||||
|
||||
// Marker/camera interaction
|
||||
const int kCameraMinZoomLevel = 10; // Minimum zoom to show cameras or warning
|
||||
// Marker/node interaction
|
||||
const int kCameraMinZoomLevel = 10; // Minimum zoom to show nodes or warning
|
||||
const Duration kMarkerTapTimeout = Duration(milliseconds: 250);
|
||||
const Duration kDebounceCameraRefresh = Duration(milliseconds: 500);
|
||||
|
||||
// Follow-me mode smooth transitions
|
||||
const Duration kFollowMeAnimationDuration = Duration(milliseconds: 600);
|
||||
const double kMinSpeedForRotationMps = 1.0; // Minimum speed (m/s) to apply rotation
|
||||
|
||||
// Last map location and settings storage
|
||||
const String kLastMapLatKey = 'last_map_latitude';
|
||||
const String kLastMapLngKey = 'last_map_longitude';
|
||||
const String kLastMapZoomKey = 'last_map_zoom';
|
||||
|
||||
// Tile/OSM fetch retry parameters (for tunable backoff)
|
||||
const int kTileFetchMaxAttempts = 3;
|
||||
const int kTileFetchInitialDelayMs = 4000;
|
||||
@@ -38,3 +53,17 @@ const int kTileFetchJitter3Ms = 5000;
|
||||
|
||||
// User download max zoom span (user can download up to kMaxUserDownloadZoomSpan zooms above min)
|
||||
const int kMaxUserDownloadZoomSpan = 7;
|
||||
|
||||
// Download area limits and constants
|
||||
const int kMaxReasonableTileCount = 20000;
|
||||
const int kAbsoluteMaxZoom = 19;
|
||||
|
||||
// Camera icon configuration
|
||||
const double kCameraIconDiameter = 20.0;
|
||||
const double kCameraRingThickness = 4.0;
|
||||
const double kCameraDotOpacity = 0.4; // Opacity for the grey dot interior
|
||||
const Color kCameraRingColorReal = Color(0xC43F55F3); // Real nodes from OSM - blue
|
||||
const Color kCameraRingColorMock = Color(0xC4FFFFFF); // Add node mock point - white
|
||||
const Color kCameraRingColorPending = Color(0xC49C27B0); // Submitted/pending nodes - purple
|
||||
const Color kCameraRingColorEditing = Color(0xC4FF9800); // Node being edited - orange
|
||||
const Color kCameraRingColorPendingEdit = Color(0xC4757575); // Original node with pending edit - grey
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
//
|
||||
// NEVER commit real secrets to public repos. For open source, use keys.dart.example instead.
|
||||
|
||||
const String kOsmProdClientId = 'Js6Fn3NR3HEGaD0ZIiHBQlV9LrVcHmsOsDmApHtSyuY'; // example - replace with real
|
||||
const String kOsmSandboxClientId = 'x26twxRKTZwf1a4Ha1a-wkXncBzqnJv8JwtacJope9Q'; // example - replace with real
|
||||
const String kOsmProdClientId = 'U8p_n6IjZfQiL1KtdiwbB0-o9nto6CAKz7LC2GifJzk'; // example - replace with real
|
||||
const String kOsmSandboxClientId = 'SBHWpWTKf31EdSiTApnah3Fj2rLnk2pEwBORlX0NyZI'; // example - replace with real
|
||||
|
||||
|
||||
57
lib/localizations/README.md
Normal file
57
lib/localizations/README.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# DeFlock Localizations
|
||||
|
||||
This directory contains translation files for DeFlock. Each language is a simple JSON file.
|
||||
|
||||
## Adding a New Language
|
||||
|
||||
Want to add support for your language? It's simple:
|
||||
|
||||
1. **Copy the English file**: `cp en.json your_language_code.json`
|
||||
- Use 2-letter language codes: `es` (Spanish), `fr` (French), `it` (Italian), etc.
|
||||
|
||||
2. **Edit your new file**:
|
||||
```json
|
||||
{
|
||||
"language": {
|
||||
"name": "Your Language Name" ← Change this to your language in your language
|
||||
},
|
||||
"app": {
|
||||
"title": "DeFlock" ← Keep this as-is
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "Your Translation Here",
|
||||
"download": "Your Translation Here",
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Add your language to the About screen**: Edit `assets/info.txt` and add your language section at the bottom (copy the English section and translate it)
|
||||
|
||||
4. **Submit a PR** with your JSON file and the updated about.txt. Done!
|
||||
|
||||
The new language will automatically appear in Settings → Language.
|
||||
|
||||
## Translation Rules
|
||||
|
||||
- **Only translate the values** (text after the `:`), never the keys
|
||||
- **Keep `{}` placeholders** if you see them - they get replaced with numbers/text
|
||||
- **Don't translate "DeFlock"** - it's the app name
|
||||
- **Use your language's name for itself** - "Français" not "French", "Español" not "Spanish"
|
||||
|
||||
## Current Languages
|
||||
|
||||
- `en.json` - English
|
||||
- `es.json` - Español
|
||||
- `fr.json` - Français
|
||||
- `de.json` - Deutsch
|
||||
|
||||
## Files to Update
|
||||
|
||||
For a complete translation, you'll need to touch:
|
||||
1. **`lib/localizations/xx.json`** - Main UI translations (buttons, menus, etc.)
|
||||
2. **`assets/info.txt`** - About screen content (add your language section)
|
||||
|
||||
## That's It!
|
||||
|
||||
No configuration files, no build steps, no complex setup. Add your files and it works.
|
||||
215
lib/localizations/de.json
Normal file
215
lib/localizations/de.json
Normal file
@@ -0,0 +1,215 @@
|
||||
{
|
||||
"language": {
|
||||
"name": "Deutsch"
|
||||
},
|
||||
"app": {
|
||||
"title": "DeFlock"
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "Knoten Markieren",
|
||||
"download": "Herunterladen",
|
||||
"settings": "Einstellungen",
|
||||
"edit": "Bearbeiten",
|
||||
"cancel": "Abbrechen",
|
||||
"ok": "OK",
|
||||
"close": "Schließen",
|
||||
"submit": "Senden",
|
||||
"saveEdit": "Bearbeitung Speichern",
|
||||
"clear": "Löschen"
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Verfolgung aktivieren (Norden oben)",
|
||||
"northUp": "Verfolgung aktivieren (Rotation)",
|
||||
"rotating": "Verfolgung deaktivieren"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
"language": "Sprache",
|
||||
"systemDefault": "Systemstandard",
|
||||
"aboutInfo": "Über / Informationen",
|
||||
"aboutThisApp": "Über Diese App",
|
||||
"maxNodes": "Max. geladene/angezeigte Knoten",
|
||||
"maxNodesSubtitle": "Obergrenze für die Anzahl der Knoten auf der Karte festlegen (Standard: 250).",
|
||||
"maxNodesWarning": "Sie möchten das wahrscheinlich nicht tun, es sei denn, Sie sind absolut sicher, dass Sie einen guten Grund dafür haben.",
|
||||
"offlineMode": "Offline-Modus",
|
||||
"offlineModeSubtitle": "Alle Netzwerkanfragen außer für lokale/Offline-Bereiche deaktivieren.",
|
||||
"offlineModeWarningTitle": "Aktive Downloads",
|
||||
"offlineModeWarningMessage": "Die Aktivierung des Offline-Modus bricht alle aktiven Bereichsdownloads ab. Möchten Sie fortfahren?",
|
||||
"enableOfflineMode": "Offline-Modus Aktivieren"
|
||||
},
|
||||
"node": {
|
||||
"title": "Knoten #{}",
|
||||
"tagSheetTitle": "Gerät-Tags",
|
||||
"queuedForUpload": "Knoten zum Upload eingereiht",
|
||||
"editQueuedForUpload": "Knotenbearbeitung zum Upload eingereiht"
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "Profil",
|
||||
"direction": "Richtung {}°",
|
||||
"profileNoDirectionInfo": "Dieses Profil benötigt keine Richtung.",
|
||||
"mustBeLoggedIn": "Sie müssen angemeldet sein, um neue Knoten zu übertragen. Bitte melden Sie sich über die Einstellungen an.",
|
||||
"enableSubmittableProfile": "Aktivieren Sie ein übertragbares Profil in den Einstellungen, um neue Knoten zu übertragen.",
|
||||
"profileViewOnlyWarning": "Dieses Profil ist nur zum Anzeigen der Karte gedacht. Bitte wählen Sie ein übertragbares Profil aus, um neue Knoten zu übertragen.",
|
||||
"refineTags": "Tags Verfeinern",
|
||||
"refineTagsWithProfile": "Tags Verfeinern ({})"
|
||||
},
|
||||
"editNode": {
|
||||
"title": "Knoten #{} Bearbeiten",
|
||||
"profile": "Profil",
|
||||
"direction": "Richtung {}°",
|
||||
"profileNoDirectionInfo": "Dieses Profil benötigt keine Richtung.",
|
||||
"mustBeLoggedIn": "Sie müssen angemeldet sein, um Knoten zu bearbeiten. Bitte melden Sie sich über die Einstellungen an.",
|
||||
"sandboxModeWarning": "Bearbeitungen von Produktionsknoten können nicht an die Sandbox übertragen werden. Wechseln Sie in den Produktionsmodus in den Einstellungen, um Knoten zu bearbeiten.",
|
||||
"enableSubmittableProfile": "Aktivieren Sie ein übertragbares Profil in den Einstellungen, um Knoten zu bearbeiten.",
|
||||
"profileViewOnlyWarning": "Dieses Profil ist nur zum Anzeigen der Karte gedacht. Bitte wählen Sie ein übertragbares Profil aus, um Knoten zu bearbeiten.",
|
||||
"refineTags": "Tags Verfeinern",
|
||||
"refineTagsWithProfile": "Tags Verfeinern ({})"
|
||||
},
|
||||
"download": {
|
||||
"title": "Kartenbereich Herunterladen",
|
||||
"maxZoomLevel": "Max. Zoom-Level",
|
||||
"storageEstimate": "Speicher-Schätzung:",
|
||||
"tilesAndSize": "{} Kacheln, {} MB",
|
||||
"minZoom": "Min. Zoom:",
|
||||
"maxRecommendedZoom": "Max. empfohlenes Zoom: Z{}",
|
||||
"withinTileLimit": "Innerhalb {} Kachel-Limit",
|
||||
"exceedsTileLimit": "Aktuelle Auswahl überschreitet {} Kachel-Limit",
|
||||
"offlineModeWarning": "Downloads im Offline-Modus deaktiviert. Deaktivieren Sie den Offline-Modus, um neue Bereiche herunterzuladen.",
|
||||
"downloadStarted": "Download gestartet! Lade Kacheln und Kameras...",
|
||||
"downloadFailed": "Download konnte nicht gestartet werden: {}"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Upload-Ziel",
|
||||
"subtitle": "Wählen Sie, wohin Kameras hochgeladen werden",
|
||||
"production": "Produktion",
|
||||
"sandbox": "Sandbox",
|
||||
"simulate": "Simulieren",
|
||||
"productionDescription": "Hochladen in die Live-OSM-Datenbank (für alle Benutzer sichtbar)",
|
||||
"sandboxDescription": "Uploads gehen an die OSM Sandbox (sicher zum Testen, wird regelmäßig zurückgesetzt).",
|
||||
"sandboxNote": "HINWEIS: Aufgrund von OpenStreetMap-Limitierungen werden Kameras, die an die Sandbox übermittelt werden, NICHT in der Karte dieser App angezeigt.",
|
||||
"simulateDescription": "Uploads simulieren (kontaktiert OSM-Server nicht)"
|
||||
},
|
||||
"auth": {
|
||||
"loggedInAs": "Angemeldet als {}",
|
||||
"loginToOSM": "Bei OpenStreetMap anmelden",
|
||||
"tapToLogout": "Zum Abmelden antippen",
|
||||
"requiredToSubmit": "Erforderlich, um Kameradaten zu übertragen",
|
||||
"loggedOut": "Abgemeldet",
|
||||
"testConnection": "Verbindung Testen",
|
||||
"testConnectionSubtitle": "OSM-Anmeldedaten überprüfen",
|
||||
"connectionOK": "Verbindung OK - Anmeldedaten sind gültig",
|
||||
"connectionFailed": "Verbindung fehlgeschlagen - bitte erneut anmelden"
|
||||
},
|
||||
"queue": {
|
||||
"pendingUploads": "Ausstehende Uploads: {}",
|
||||
"simulateModeEnabled": "Simulationsmodus aktiviert – Uploads simuliert",
|
||||
"sandboxMode": "Sandbox-Modus – Uploads gehen an OSM Sandbox",
|
||||
"tapToViewQueue": "Zum Anzeigen der Warteschlange antippen",
|
||||
"clearUploadQueue": "Upload-Warteschlange Löschen",
|
||||
"removeAllPending": "Alle {} ausstehenden Uploads entfernen",
|
||||
"clearQueueTitle": "Warteschlange Löschen",
|
||||
"clearQueueConfirm": "Alle {} ausstehenden Uploads entfernen?",
|
||||
"queueCleared": "Warteschlange geleert",
|
||||
"uploadQueueTitle": "Upload-Warteschlange ({} Elemente)",
|
||||
"queueIsEmpty": "Warteschlange ist leer",
|
||||
"cameraWithIndex": "Kamera {}",
|
||||
"error": " (Fehler)",
|
||||
"completing": " (Wird abgeschlossen...)",
|
||||
"destination": "Ziel: {}",
|
||||
"latitude": "Lat: {}",
|
||||
"longitude": "Lon: {}",
|
||||
"direction": "Richtung: {}°",
|
||||
"attempts": "Versuche: {}",
|
||||
"uploadFailedRetry": "Upload fehlgeschlagen. Zum Wiederholen antippen.",
|
||||
"retryUpload": "Upload wiederholen",
|
||||
"clearAll": "Alle Löschen"
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "Kachel-Anbieter",
|
||||
"noProvidersConfigured": "Keine Kachel-Anbieter konfiguriert",
|
||||
"tileTypesCount": "{} Kachel-Typen",
|
||||
"apiKeyConfigured": "API-Schlüssel konfiguriert",
|
||||
"needsApiKey": "Benötigt API-Schlüssel",
|
||||
"editProvider": "Anbieter Bearbeiten",
|
||||
"addProvider": "Anbieter Hinzufügen",
|
||||
"deleteProvider": "Anbieter Löschen",
|
||||
"deleteProviderConfirm": "Sind Sie sicher, dass Sie \"{}\" löschen möchten?",
|
||||
"providerName": "Anbieter-Name",
|
||||
"providerNameHint": "z.B. Benutzerdefinierte Karten GmbH",
|
||||
"providerNameRequired": "Anbieter-Name ist erforderlich",
|
||||
"apiKey": "API-Schlüssel (Optional)",
|
||||
"apiKeyHint": "API-Schlüssel eingeben, falls von Kachel-Typen benötigt",
|
||||
"tileTypes": "Kachel-Typen",
|
||||
"addType": "Typ Hinzufügen",
|
||||
"noTileTypesConfigured": "Keine Kachel-Typen konfiguriert",
|
||||
"atLeastOneTileTypeRequired": "Mindestens ein Kachel-Typ ist erforderlich",
|
||||
"manageTileProviders": "Anbieter Verwalten"
|
||||
},
|
||||
"tileTypeEditor": {
|
||||
"editTileType": "Kachel-Typ Bearbeiten",
|
||||
"addTileType": "Kachel-Typ Hinzufügen",
|
||||
"name": "Name",
|
||||
"nameHint": "z.B. Satellit",
|
||||
"nameRequired": "Name ist erforderlich",
|
||||
"urlTemplate": "URL-Vorlage",
|
||||
"urlTemplateHint": "https://beispiel.com/{z}/{x}/{y}.png",
|
||||
"urlTemplateRequired": "URL-Vorlage ist erforderlich",
|
||||
"urlTemplatePlaceholders": "URL muss {z}, {x} und {y} Platzhalter enthalten",
|
||||
"attribution": "Zuschreibung",
|
||||
"attributionHint": "© Karten-Anbieter",
|
||||
"attributionRequired": "Zuschreibung ist erforderlich",
|
||||
"fetchPreview": "Vorschau Laden",
|
||||
"previewTileLoaded": "Vorschau-Kachel erfolgreich geladen",
|
||||
"previewTileFailed": "Vorschau laden fehlgeschlagen: {}",
|
||||
"save": "Speichern"
|
||||
},
|
||||
"profiles": {
|
||||
"nodeProfiles": "Knoten-Profile",
|
||||
"newProfile": "Neues Profil",
|
||||
"builtIn": "Eingebaut",
|
||||
"custom": "Benutzerdefiniert",
|
||||
"view": "Anzeigen",
|
||||
"deleteProfile": "Profil Löschen",
|
||||
"deleteProfileConfirm": "Sind Sie sicher, dass Sie \"{}\" löschen möchten?",
|
||||
"profileDeleted": "Profil gelöscht"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Karten-Kacheln",
|
||||
"manageProviders": "Anbieter Verwalten"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Profil Anzeigen",
|
||||
"newProfile": "Neues Profil",
|
||||
"editProfile": "Profil Bearbeiten",
|
||||
"profileName": "Profil-Name",
|
||||
"profileNameHint": "z.B. Benutzerdefinierte ALPR-Kamera",
|
||||
"profileNameRequired": "Profil-Name ist erforderlich",
|
||||
"requiresDirection": "Benötigt Richtung",
|
||||
"requiresDirectionSubtitle": "Ob Kameras dieses Typs ein Richtungs-Tag benötigen",
|
||||
"submittable": "Übertragbar",
|
||||
"submittableSubtitle": "Ob dieses Profil für Kamera-Übertragungen verwendet werden kann",
|
||||
"osmTags": "OSM-Tags",
|
||||
"addTag": "Tag Hinzufügen",
|
||||
"saveProfile": "Profil Speichern",
|
||||
"keyHint": "Schlüssel",
|
||||
"valueHint": "Wert",
|
||||
"atLeastOneTagRequired": "Mindestens ein Tag ist erforderlich",
|
||||
"profileSaved": "Profil \"{}\" gespeichert"
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "Neues Betreiber-Profil",
|
||||
"editOperatorProfile": "Betreiber-Profil Bearbeiten",
|
||||
"operatorName": "Betreiber-Name",
|
||||
"operatorNameHint": "z.B. Polizei Austin",
|
||||
"operatorNameRequired": "Betreiber-Name ist erforderlich",
|
||||
"operatorProfileSaved": "Betreiber-Profil \"{}\" gespeichert"
|
||||
},
|
||||
"operatorProfiles": {
|
||||
"title": "Betreiber-Profile",
|
||||
"noProfilesMessage": "Keine Betreiber-Profile definiert. Erstellen Sie eines, um Betreiber-Tags auf Knoten-Übertragungen anzuwenden.",
|
||||
"tagsCount": "{} Tags",
|
||||
"deleteOperatorProfile": "Betreiber-Profil Löschen",
|
||||
"deleteOperatorProfileConfirm": "Sind Sie sicher, dass Sie \"{}\" löschen möchten?",
|
||||
"operatorProfileDeleted": "Betreiber-Profil gelöscht"
|
||||
}
|
||||
}
|
||||
215
lib/localizations/en.json
Normal file
215
lib/localizations/en.json
Normal file
@@ -0,0 +1,215 @@
|
||||
{
|
||||
"language": {
|
||||
"name": "English"
|
||||
},
|
||||
"app": {
|
||||
"title": "DeFlock"
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "Tag Node",
|
||||
"download": "Download",
|
||||
"settings": "Settings",
|
||||
"edit": "Edit",
|
||||
"cancel": "Cancel",
|
||||
"ok": "OK",
|
||||
"close": "Close",
|
||||
"submit": "Submit",
|
||||
"saveEdit": "Save Edit",
|
||||
"clear": "Clear"
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Enable follow-me (north up)",
|
||||
"northUp": "Enable follow-me (rotating)",
|
||||
"rotating": "Disable follow-me"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"language": "Language",
|
||||
"systemDefault": "System Default",
|
||||
"aboutInfo": "About / Info",
|
||||
"aboutThisApp": "About This App",
|
||||
"maxNodes": "Max nodes fetched/drawn",
|
||||
"maxNodesSubtitle": "Set an upper limit for the number of nodes on the map (default: 250).",
|
||||
"maxNodesWarning": "You probably don't want to do that unless you are absolutely sure you have a good reason for it.",
|
||||
"offlineMode": "Offline Mode",
|
||||
"offlineModeSubtitle": "Disable all network requests except for local/offline areas.",
|
||||
"offlineModeWarningTitle": "Active Downloads",
|
||||
"offlineModeWarningMessage": "Enabling offline mode will cancel any active area downloads. Do you want to continue?",
|
||||
"enableOfflineMode": "Enable Offline Mode"
|
||||
},
|
||||
"node": {
|
||||
"title": "Node #{}",
|
||||
"tagSheetTitle": "Surveillance Device Tags",
|
||||
"queuedForUpload": "Node queued for upload",
|
||||
"editQueuedForUpload": "Node edit queued for upload"
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "Profile",
|
||||
"direction": "Direction {}°",
|
||||
"profileNoDirectionInfo": "This profile does not require a direction.",
|
||||
"mustBeLoggedIn": "You must be logged in to submit new nodes. Please log in via Settings.",
|
||||
"enableSubmittableProfile": "Enable a submittable profile in Settings to submit new nodes.",
|
||||
"profileViewOnlyWarning": "This profile is for map viewing only. Please select a submittable profile to submit new nodes.",
|
||||
"refineTags": "Refine Tags",
|
||||
"refineTagsWithProfile": "Refine Tags ({})"
|
||||
},
|
||||
"editNode": {
|
||||
"title": "Edit Node #{}",
|
||||
"profile": "Profile",
|
||||
"direction": "Direction {}°",
|
||||
"profileNoDirectionInfo": "This profile does not require a direction.",
|
||||
"mustBeLoggedIn": "You must be logged in to edit nodes. Please log in via Settings.",
|
||||
"sandboxModeWarning": "Cannot submit edits on production nodes to sandbox. Switch to Production mode in Settings to edit nodes.",
|
||||
"enableSubmittableProfile": "Enable a submittable profile in Settings to edit nodes.",
|
||||
"profileViewOnlyWarning": "This profile is for map viewing only. Please select a submittable profile to edit nodes.",
|
||||
"refineTags": "Refine Tags",
|
||||
"refineTagsWithProfile": "Refine Tags ({})"
|
||||
},
|
||||
"download": {
|
||||
"title": "Download Map Area",
|
||||
"maxZoomLevel": "Max zoom level",
|
||||
"storageEstimate": "Storage estimate:",
|
||||
"tilesAndSize": "{} tiles, {} MB",
|
||||
"minZoom": "Min zoom:",
|
||||
"maxRecommendedZoom": "Max recommended zoom: Z{}",
|
||||
"withinTileLimit": "Within {} tile limit",
|
||||
"exceedsTileLimit": "Current selection exceeds {} tile limit",
|
||||
"offlineModeWarning": "Downloads disabled while in offline mode. Disable offline mode to download new areas.",
|
||||
"downloadStarted": "Download started! Fetching tiles and cameras...",
|
||||
"downloadFailed": "Failed to start download: {}"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Upload Destination",
|
||||
"subtitle": "Choose where cameras are uploaded",
|
||||
"production": "Production",
|
||||
"sandbox": "Sandbox",
|
||||
"simulate": "Simulate",
|
||||
"productionDescription": "Upload to the live OSM database (visible to all users)",
|
||||
"sandboxDescription": "Uploads go to the OSM Sandbox (safe for testing, resets regularly).",
|
||||
"sandboxNote": "NOTE: Due to OpenStreetMap limitations, cameras submitted to the sandbox will NOT appear on the map in this app.",
|
||||
"simulateDescription": "Simulate uploads (does not contact OSM servers)"
|
||||
},
|
||||
"auth": {
|
||||
"loggedInAs": "Logged in as {}",
|
||||
"loginToOSM": "Log in to OpenStreetMap",
|
||||
"tapToLogout": "Tap to logout",
|
||||
"requiredToSubmit": "Required to submit camera data",
|
||||
"loggedOut": "Logged out",
|
||||
"testConnection": "Test Connection",
|
||||
"testConnectionSubtitle": "Verify OSM credentials are working",
|
||||
"connectionOK": "Connection OK - credentials are valid",
|
||||
"connectionFailed": "Connection failed - please re-login"
|
||||
},
|
||||
"queue": {
|
||||
"pendingUploads": "Pending uploads: {}",
|
||||
"simulateModeEnabled": "Simulate mode enabled – uploads simulated",
|
||||
"sandboxMode": "Sandbox mode – uploads go to OSM Sandbox",
|
||||
"tapToViewQueue": "Tap to view queue",
|
||||
"clearUploadQueue": "Clear Upload Queue",
|
||||
"removeAllPending": "Remove all {} pending uploads",
|
||||
"clearQueueTitle": "Clear Queue",
|
||||
"clearQueueConfirm": "Remove all {} pending uploads?",
|
||||
"queueCleared": "Queue cleared",
|
||||
"uploadQueueTitle": "Upload Queue ({} items)",
|
||||
"queueIsEmpty": "Queue is empty",
|
||||
"cameraWithIndex": "Camera {}",
|
||||
"error": " (Error)",
|
||||
"completing": " (Completing...)",
|
||||
"destination": "Dest: {}",
|
||||
"latitude": "Lat: {}",
|
||||
"longitude": "Lon: {}",
|
||||
"direction": "Direction: {}°",
|
||||
"attempts": "Attempts: {}",
|
||||
"uploadFailedRetry": "Upload failed. Tap retry to try again.",
|
||||
"retryUpload": "Retry upload",
|
||||
"clearAll": "Clear All"
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "Tile Providers",
|
||||
"noProvidersConfigured": "No tile providers configured",
|
||||
"tileTypesCount": "{} tile types",
|
||||
"apiKeyConfigured": "API Key configured",
|
||||
"needsApiKey": "Needs API key",
|
||||
"editProvider": "Edit Provider",
|
||||
"addProvider": "Add Provider",
|
||||
"deleteProvider": "Delete Provider",
|
||||
"deleteProviderConfirm": "Are you sure you want to delete \"{}\"?",
|
||||
"providerName": "Provider Name",
|
||||
"providerNameHint": "e.g., Custom Maps Inc.",
|
||||
"providerNameRequired": "Provider name is required",
|
||||
"apiKey": "API Key (Optional)",
|
||||
"apiKeyHint": "Enter API key if required by tile types",
|
||||
"tileTypes": "Tile Types",
|
||||
"addType": "Add Type",
|
||||
"noTileTypesConfigured": "No tile types configured",
|
||||
"atLeastOneTileTypeRequired": "At least one tile type is required",
|
||||
"manageTileProviders": "Manage Providers"
|
||||
},
|
||||
"tileTypeEditor": {
|
||||
"editTileType": "Edit Tile Type",
|
||||
"addTileType": "Add Tile Type",
|
||||
"name": "Name",
|
||||
"nameHint": "e.g., Satellite",
|
||||
"nameRequired": "Name is required",
|
||||
"urlTemplate": "URL Template",
|
||||
"urlTemplateHint": "https://example.com/{z}/{x}/{y}.png",
|
||||
"urlTemplateRequired": "URL template is required",
|
||||
"urlTemplatePlaceholders": "URL must contain {z}, {x}, and {y} placeholders",
|
||||
"attribution": "Attribution",
|
||||
"attributionHint": "© Map Provider",
|
||||
"attributionRequired": "Attribution is required",
|
||||
"fetchPreview": "Fetch Preview",
|
||||
"previewTileLoaded": "Preview tile loaded successfully",
|
||||
"previewTileFailed": "Failed to fetch preview: {}",
|
||||
"save": "Save"
|
||||
},
|
||||
"profiles": {
|
||||
"nodeProfiles": "Node Profiles",
|
||||
"newProfile": "New Profile",
|
||||
"builtIn": "Built-in",
|
||||
"custom": "Custom",
|
||||
"view": "View",
|
||||
"deleteProfile": "Delete Profile",
|
||||
"deleteProfileConfirm": "Are you sure you want to delete \"{}\"?",
|
||||
"profileDeleted": "Profile deleted"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Map Tiles",
|
||||
"manageProviders": "Manage Providers"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "View Profile",
|
||||
"newProfile": "New Profile",
|
||||
"editProfile": "Edit Profile",
|
||||
"profileName": "Profile name",
|
||||
"profileNameHint": "e.g., Custom ALPR Camera",
|
||||
"profileNameRequired": "Profile name is required",
|
||||
"requiresDirection": "Requires Direction",
|
||||
"requiresDirectionSubtitle": "Whether cameras of this type need a direction tag",
|
||||
"submittable": "Submittable",
|
||||
"submittableSubtitle": "Whether this profile can be used for camera submissions",
|
||||
"osmTags": "OSM Tags",
|
||||
"addTag": "Add tag",
|
||||
"saveProfile": "Save Profile",
|
||||
"keyHint": "key",
|
||||
"valueHint": "value",
|
||||
"atLeastOneTagRequired": "At least one tag is required",
|
||||
"profileSaved": "Profile \"{}\" saved"
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "New Operator Profile",
|
||||
"editOperatorProfile": "Edit Operator Profile",
|
||||
"operatorName": "Operator name",
|
||||
"operatorNameHint": "e.g., Austin Police Department",
|
||||
"operatorNameRequired": "Operator name is required",
|
||||
"operatorProfileSaved": "Operator profile \"{}\" saved"
|
||||
},
|
||||
"operatorProfiles": {
|
||||
"title": "Operator Profiles",
|
||||
"noProfilesMessage": "No operator profiles defined. Create one to apply operator tags to node submissions.",
|
||||
"tagsCount": "{} tags",
|
||||
"deleteOperatorProfile": "Delete Operator Profile",
|
||||
"deleteOperatorProfileConfirm": "Are you sure you want to delete \"{}\"?",
|
||||
"operatorProfileDeleted": "Operator profile deleted"
|
||||
}
|
||||
}
|
||||
215
lib/localizations/es.json
Normal file
215
lib/localizations/es.json
Normal file
@@ -0,0 +1,215 @@
|
||||
{
|
||||
"language": {
|
||||
"name": "Español"
|
||||
},
|
||||
"app": {
|
||||
"title": "DeFlock"
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "Etiquetar Nodo",
|
||||
"download": "Descargar",
|
||||
"settings": "Configuración",
|
||||
"edit": "Editar",
|
||||
"cancel": "Cancelar",
|
||||
"ok": "Aceptar",
|
||||
"close": "Cerrar",
|
||||
"submit": "Enviar",
|
||||
"saveEdit": "Guardar Edición",
|
||||
"clear": "Limpiar"
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Activar seguimiento (norte arriba)",
|
||||
"northUp": "Activar seguimiento (rotación)",
|
||||
"rotating": "Desactivar seguimiento"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Configuración",
|
||||
"language": "Idioma",
|
||||
"systemDefault": "Sistema por Defecto",
|
||||
"aboutInfo": "Acerca de / Información",
|
||||
"aboutThisApp": "Acerca de Esta App",
|
||||
"maxNodes": "Máx. nodos obtenidos/dibujados",
|
||||
"maxNodesSubtitle": "Establecer un límite superior para el número de nodos en el mapa (predeterminado: 250).",
|
||||
"maxNodesWarning": "Probablemente no quieras hacer eso a menos que estés absolutamente seguro de que tienes una buena razón para ello.",
|
||||
"offlineMode": "Modo Sin Conexión",
|
||||
"offlineModeSubtitle": "Deshabilitar todas las solicitudes de red excepto para áreas locales/sin conexión.",
|
||||
"offlineModeWarningTitle": "Descargas Activas",
|
||||
"offlineModeWarningMessage": "Habilitar el modo sin conexión cancelará cualquier descarga de área activa. ¿Desea continuar?",
|
||||
"enableOfflineMode": "Habilitar Modo Sin Conexión"
|
||||
},
|
||||
"node": {
|
||||
"title": "Nodo #{}",
|
||||
"tagSheetTitle": "Etiquetas del Dispositivo",
|
||||
"queuedForUpload": "Nodo en cola para subir",
|
||||
"editQueuedForUpload": "Edición de nodo en cola para subir"
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "Perfil",
|
||||
"direction": "Dirección {}°",
|
||||
"profileNoDirectionInfo": "Este perfil no requiere una dirección.",
|
||||
"mustBeLoggedIn": "Debe estar conectado para enviar nuevos nodos. Por favor, inicie sesión a través de Configuración.",
|
||||
"enableSubmittableProfile": "Habilite un perfil envíable en Configuración para enviar nuevos nodos.",
|
||||
"profileViewOnlyWarning": "Este perfil es solo para visualización del mapa. Por favor, seleccione un perfil envíable para enviar nuevos nodos.",
|
||||
"refineTags": "Refinar Etiquetas",
|
||||
"refineTagsWithProfile": "Refinar Etiquetas ({})"
|
||||
},
|
||||
"editNode": {
|
||||
"title": "Editar Nodo #{}",
|
||||
"profile": "Perfil",
|
||||
"direction": "Dirección {}°",
|
||||
"profileNoDirectionInfo": "Este perfil no requiere una dirección.",
|
||||
"mustBeLoggedIn": "Debe estar conectado para editar nodos. Por favor, inicie sesión a través de Configuración.",
|
||||
"sandboxModeWarning": "No se pueden enviar ediciones de nodos de producción al sandbox. Cambie al modo Producción en Configuración para editar nodos.",
|
||||
"enableSubmittableProfile": "Habilite un perfil envíable en Configuración para editar nodos.",
|
||||
"profileViewOnlyWarning": "Este perfil es solo para visualización del mapa. Por favor, seleccione un perfil envíable para editar nodos.",
|
||||
"refineTags": "Refinar Etiquetas",
|
||||
"refineTagsWithProfile": "Refinar Etiquetas ({})"
|
||||
},
|
||||
"download": {
|
||||
"title": "Descargar Área del Mapa",
|
||||
"maxZoomLevel": "Nivel máx. de zoom",
|
||||
"storageEstimate": "Estimación de almacenamiento:",
|
||||
"tilesAndSize": "{} mosaicos, {} MB",
|
||||
"minZoom": "Zoom mín.:",
|
||||
"maxRecommendedZoom": "Zoom máx. recomendado: Z{}",
|
||||
"withinTileLimit": "Dentro del límite de {} mosaicos",
|
||||
"exceedsTileLimit": "La selección actual excede el límite de {} mosaicos",
|
||||
"offlineModeWarning": "Descargas deshabilitadas en modo sin conexión. Deshabilite el modo sin conexión para descargar nuevas áreas.",
|
||||
"downloadStarted": "¡Descarga iniciada! Obteniendo mosaicos y cámaras...",
|
||||
"downloadFailed": "Error al iniciar la descarga: {}"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Destino de Subida",
|
||||
"subtitle": "Elige dónde se suben las cámaras",
|
||||
"production": "Producción",
|
||||
"sandbox": "Sandbox",
|
||||
"simulate": "Simular",
|
||||
"productionDescription": "Subir a la base de datos OSM en vivo (visible para todos los usuarios)",
|
||||
"sandboxDescription": "Las subidas van al Sandbox de OSM (seguro para pruebas, se reinicia regularmente).",
|
||||
"sandboxNote": "NOTA: Debido a las limitaciones de OpenStreetMap, las cámaras enviadas al sandbox NO aparecerán en el mapa de esta aplicación.",
|
||||
"simulateDescription": "Simular subidas (no contacta servidores OSM)"
|
||||
},
|
||||
"auth": {
|
||||
"loggedInAs": "Conectado como {}",
|
||||
"loginToOSM": "Iniciar sesión en OpenStreetMap",
|
||||
"tapToLogout": "Toque para cerrar sesión",
|
||||
"requiredToSubmit": "Requerido para enviar datos de cámaras",
|
||||
"loggedOut": "Sesión cerrada",
|
||||
"testConnection": "Probar Conexión",
|
||||
"testConnectionSubtitle": "Verificar que las credenciales de OSM funcionen",
|
||||
"connectionOK": "Conexión OK - las credenciales son válidas",
|
||||
"connectionFailed": "Conexión falló - por favor, inicie sesión nuevamente"
|
||||
},
|
||||
"queue": {
|
||||
"pendingUploads": "Subidas pendientes: {}",
|
||||
"simulateModeEnabled": "Modo simulación activado – subidas simuladas",
|
||||
"sandboxMode": "Modo sandbox – subidas van al Sandbox OSM",
|
||||
"tapToViewQueue": "Toque para ver cola",
|
||||
"clearUploadQueue": "Limpiar Cola de Subida",
|
||||
"removeAllPending": "Eliminar todas las {} subidas pendientes",
|
||||
"clearQueueTitle": "Limpiar Cola",
|
||||
"clearQueueConfirm": "¿Eliminar todas las {} subidas pendientes?",
|
||||
"queueCleared": "Cola limpiada",
|
||||
"uploadQueueTitle": "Cola de Subida ({} elementos)",
|
||||
"queueIsEmpty": "La cola está vacía",
|
||||
"cameraWithIndex": "Cámara {}",
|
||||
"error": " (Error)",
|
||||
"completing": " (Completando...)",
|
||||
"destination": "Dest: {}",
|
||||
"latitude": "Lat: {}",
|
||||
"longitude": "Lon: {}",
|
||||
"direction": "Dirección: {}°",
|
||||
"attempts": "Intentos: {}",
|
||||
"uploadFailedRetry": "Subida falló. Toque reintentar para intentar de nuevo.",
|
||||
"retryUpload": "Reintentar subida",
|
||||
"clearAll": "Limpiar Todo"
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "Proveedores de Tiles",
|
||||
"noProvidersConfigured": "No hay proveedores de tiles configurados",
|
||||
"tileTypesCount": "{} tipos de tiles",
|
||||
"apiKeyConfigured": "Clave API configurada",
|
||||
"needsApiKey": "Necesita clave API",
|
||||
"editProvider": "Editar Proveedor",
|
||||
"addProvider": "Agregar Proveedor",
|
||||
"deleteProvider": "Eliminar Proveedor",
|
||||
"deleteProviderConfirm": "¿Está seguro de que desea eliminar \"{}\"?",
|
||||
"providerName": "Nombre del Proveedor",
|
||||
"providerNameHint": "ej., Mapas Personalizados Inc.",
|
||||
"providerNameRequired": "El nombre del proveedor es requerido",
|
||||
"apiKey": "Clave API (Opcional)",
|
||||
"apiKeyHint": "Ingrese la clave API si es requerida por los tipos de tiles",
|
||||
"tileTypes": "Tipos de Tiles",
|
||||
"addType": "Agregar Tipo",
|
||||
"noTileTypesConfigured": "No hay tipos de tiles configurados",
|
||||
"atLeastOneTileTypeRequired": "Se requiere al menos un tipo de tile",
|
||||
"manageTileProviders": "Gestionar Proveedores"
|
||||
},
|
||||
"tileTypeEditor": {
|
||||
"editTileType": "Editar Tipo de Tile",
|
||||
"addTileType": "Agregar Tipo de Tile",
|
||||
"name": "Nombre",
|
||||
"nameHint": "ej., Satélite",
|
||||
"nameRequired": "El nombre es requerido",
|
||||
"urlTemplate": "Plantilla de URL",
|
||||
"urlTemplateHint": "https://ejemplo.com/{z}/{x}/{y}.png",
|
||||
"urlTemplateRequired": "La plantilla de URL es requerida",
|
||||
"urlTemplatePlaceholders": "La URL debe contener marcadores {z}, {x} y {y}",
|
||||
"attribution": "Atribución",
|
||||
"attributionHint": "© Proveedor de Mapas",
|
||||
"attributionRequired": "La atribución es requerida",
|
||||
"fetchPreview": "Obtener Vista Previa",
|
||||
"previewTileLoaded": "Tile de vista previa cargado exitosamente",
|
||||
"previewTileFailed": "Falló al obtener vista previa: {}",
|
||||
"save": "Guardar"
|
||||
},
|
||||
"profiles": {
|
||||
"nodeProfiles": "Perfiles de Nodos",
|
||||
"newProfile": "Nuevo Perfil",
|
||||
"builtIn": "Incorporado",
|
||||
"custom": "Personalizado",
|
||||
"view": "Ver",
|
||||
"deleteProfile": "Eliminar Perfil",
|
||||
"deleteProfileConfirm": "¿Está seguro de que desea eliminar \"{}\"?",
|
||||
"profileDeleted": "Perfil eliminado"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Tiles de Mapa",
|
||||
"manageProviders": "Gestionar Proveedores"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Ver Perfil",
|
||||
"newProfile": "Nuevo Perfil",
|
||||
"editProfile": "Editar Perfil",
|
||||
"profileName": "Nombre del perfil",
|
||||
"profileNameHint": "ej., Cámara ALPR Personalizada",
|
||||
"profileNameRequired": "El nombre del perfil es requerido",
|
||||
"requiresDirection": "Requiere Dirección",
|
||||
"requiresDirectionSubtitle": "Si las cámaras de este tipo necesitan una etiqueta de dirección",
|
||||
"submittable": "Envíable",
|
||||
"submittableSubtitle": "Si este perfil puede usarse para envíos de cámaras",
|
||||
"osmTags": "Etiquetas OSM",
|
||||
"addTag": "Agregar Etiqueta",
|
||||
"saveProfile": "Guardar Perfil",
|
||||
"keyHint": "clave",
|
||||
"valueHint": "valor",
|
||||
"atLeastOneTagRequired": "Se requiere al menos una etiqueta",
|
||||
"profileSaved": "Perfil \"{}\" guardado"
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "Nuevo Perfil de Operador",
|
||||
"editOperatorProfile": "Editar Perfil de Operador",
|
||||
"operatorName": "Nombre del operador",
|
||||
"operatorNameHint": "ej., Departamento de Policía de Austin",
|
||||
"operatorNameRequired": "El nombre del operador es requerido",
|
||||
"operatorProfileSaved": "Perfil de operador \"{}\" guardado"
|
||||
},
|
||||
"operatorProfiles": {
|
||||
"title": "Perfiles de Operador",
|
||||
"noProfilesMessage": "No hay perfiles de operador definidos. Cree uno para aplicar etiquetas de operador a los envíos de nodos.",
|
||||
"tagsCount": "{} etiquetas",
|
||||
"deleteOperatorProfile": "Eliminar Perfil de Operador",
|
||||
"deleteOperatorProfileConfirm": "¿Está seguro de que desea eliminar \"{}\"?",
|
||||
"operatorProfileDeleted": "Perfil de operador eliminado"
|
||||
}
|
||||
}
|
||||
215
lib/localizations/fr.json
Normal file
215
lib/localizations/fr.json
Normal file
@@ -0,0 +1,215 @@
|
||||
{
|
||||
"language": {
|
||||
"name": "Français"
|
||||
},
|
||||
"app": {
|
||||
"title": "DeFlock"
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "Marquer Nœud",
|
||||
"download": "Télécharger",
|
||||
"settings": "Paramètres",
|
||||
"edit": "Modifier",
|
||||
"cancel": "Annuler",
|
||||
"ok": "OK",
|
||||
"close": "Fermer",
|
||||
"submit": "Soumettre",
|
||||
"saveEdit": "Sauvegarder Modification",
|
||||
"clear": "Effacer"
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Activer le suivi (nord en haut)",
|
||||
"northUp": "Activer le suivi (rotation)",
|
||||
"rotating": "Désactiver le suivi"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Paramètres",
|
||||
"language": "Langue",
|
||||
"systemDefault": "Par Défaut du Système",
|
||||
"aboutInfo": "À Propos / Informations",
|
||||
"aboutThisApp": "À Propos de Cette App",
|
||||
"maxNodes": "Max. nœuds récupérés/dessinés",
|
||||
"maxNodesSubtitle": "Définir une limite supérieure pour le nombre de nœuds sur la carte (par défaut: 250).",
|
||||
"maxNodesWarning": "Vous ne voulez probablement pas faire cela à moins d'être absolument sûr d'avoir une bonne raison de le faire.",
|
||||
"offlineMode": "Mode Hors Ligne",
|
||||
"offlineModeSubtitle": "Désactiver toutes les requêtes réseau sauf pour les zones locales/hors ligne.",
|
||||
"offlineModeWarningTitle": "Téléchargements Actifs",
|
||||
"offlineModeWarningMessage": "L'activation du mode hors ligne annulera tous les téléchargements de zone actifs. Voulez-vous continuer?",
|
||||
"enableOfflineMode": "Activer le Mode Hors Ligne"
|
||||
},
|
||||
"node": {
|
||||
"title": "Nœud #{}",
|
||||
"tagSheetTitle": "Balises du Dispositif",
|
||||
"queuedForUpload": "Nœud mis en file pour envoi",
|
||||
"editQueuedForUpload": "Modification de nœud mise en file pour envoi"
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "Profil",
|
||||
"direction": "Direction {}°",
|
||||
"profileNoDirectionInfo": "Ce profil ne nécessite pas de direction.",
|
||||
"mustBeLoggedIn": "Vous devez être connecté pour soumettre de nouveaux nœuds. Veuillez vous connecter via les Paramètres.",
|
||||
"enableSubmittableProfile": "Activez un profil soumissible dans les Paramètres pour soumettre de nouveaux nœuds.",
|
||||
"profileViewOnlyWarning": "Ce profil est uniquement pour la visualisation de la carte. Veuillez sélectionner un profil soumissible pour soumettre de nouveaux nœuds.",
|
||||
"refineTags": "Affiner Balises",
|
||||
"refineTagsWithProfile": "Affiner Balises ({})"
|
||||
},
|
||||
"editNode": {
|
||||
"title": "Modifier Nœud #{}",
|
||||
"profile": "Profil",
|
||||
"direction": "Direction {}°",
|
||||
"profileNoDirectionInfo": "Ce profil ne nécessite pas de direction.",
|
||||
"mustBeLoggedIn": "Vous devez être connecté pour modifier les nœuds. Veuillez vous connecter via les Paramètres.",
|
||||
"sandboxModeWarning": "Impossible de soumettre des modifications de nœuds de production au sandbox. Passez au mode Production dans les Paramètres pour modifier les nœuds.",
|
||||
"enableSubmittableProfile": "Activez un profil soumissible dans les Paramètres pour modifier les nœuds.",
|
||||
"profileViewOnlyWarning": "Ce profil est uniquement pour la visualisation de la carte. Veuillez sélectionner un profil soumissible pour modifier les nœuds.",
|
||||
"refineTags": "Affiner Balises",
|
||||
"refineTagsWithProfile": "Affiner Balises ({})"
|
||||
},
|
||||
"download": {
|
||||
"title": "Télécharger Zone de Carte",
|
||||
"maxZoomLevel": "Niveau de zoom max.",
|
||||
"storageEstimate": "Estimation de stockage:",
|
||||
"tilesAndSize": "{} tuiles, {} MB",
|
||||
"minZoom": "Zoom min.:",
|
||||
"maxRecommendedZoom": "Zoom max. recommandé: Z{}",
|
||||
"withinTileLimit": "Dans la limite de {} tuiles",
|
||||
"exceedsTileLimit": "La sélection actuelle dépasse la limite de {} tuiles",
|
||||
"offlineModeWarning": "Téléchargements désactivés en mode hors ligne. Désactivez le mode hors ligne pour télécharger de nouvelles zones.",
|
||||
"downloadStarted": "Téléchargement démarré! Récupération des tuiles et caméras...",
|
||||
"downloadFailed": "Échec du démarrage du téléchargement: {}"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Destination de Téléchargement",
|
||||
"subtitle": "Choisir où les caméras sont téléchargées",
|
||||
"production": "Production",
|
||||
"sandbox": "Sandbox",
|
||||
"simulate": "Simuler",
|
||||
"productionDescription": "Télécharger vers la base de données OSM en direct (visible pour tous les utilisateurs)",
|
||||
"sandboxDescription": "Les téléchargements vont vers le Sandbox OSM (sûr pour les tests, réinitialisé régulièrement).",
|
||||
"sandboxNote": "NOTE: En raison des limitations d'OpenStreetMap, les caméras soumises au sandbox n'apparaîtront PAS sur la carte dans cette application.",
|
||||
"simulateDescription": "Simuler les téléchargements (ne contacte pas les serveurs OSM)"
|
||||
},
|
||||
"auth": {
|
||||
"loggedInAs": "Connecté en tant que {}",
|
||||
"loginToOSM": "Se connecter à OpenStreetMap",
|
||||
"tapToLogout": "Appuyer pour se déconnecter",
|
||||
"requiredToSubmit": "Requis pour soumettre des données de caméras",
|
||||
"loggedOut": "Déconnecté",
|
||||
"testConnection": "Tester Connexion",
|
||||
"testConnectionSubtitle": "Vérifier que les identifiants OSM fonctionnent",
|
||||
"connectionOK": "Connexion OK - les identifiants sont valides",
|
||||
"connectionFailed": "Connexion échouée - veuillez vous reconnecter"
|
||||
},
|
||||
"queue": {
|
||||
"pendingUploads": "Téléchargements en attente: {}",
|
||||
"simulateModeEnabled": "Mode simulation activé – téléchargements simulés",
|
||||
"sandboxMode": "Mode sandbox – téléchargements vont vers OSM Sandbox",
|
||||
"tapToViewQueue": "Appuyer pour voir la file",
|
||||
"clearUploadQueue": "Vider File de Téléchargement",
|
||||
"removeAllPending": "Supprimer tous les {} téléchargements en attente",
|
||||
"clearQueueTitle": "Vider File",
|
||||
"clearQueueConfirm": "Supprimer tous les {} téléchargements en attente?",
|
||||
"queueCleared": "File vidée",
|
||||
"uploadQueueTitle": "File de Téléchargement ({} éléments)",
|
||||
"queueIsEmpty": "La file est vide",
|
||||
"cameraWithIndex": "Caméra {}",
|
||||
"error": " (Erreur)",
|
||||
"completing": " (Finalisation...)",
|
||||
"destination": "Dest: {}",
|
||||
"latitude": "Lat: {}",
|
||||
"longitude": "Lon: {}",
|
||||
"direction": "Direction: {}°",
|
||||
"attempts": "Tentatives: {}",
|
||||
"uploadFailedRetry": "Téléchargement échoué. Appuyer pour réessayer.",
|
||||
"retryUpload": "Réessayer téléchargement",
|
||||
"clearAll": "Tout Vider"
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "Fournisseurs de Tuiles",
|
||||
"noProvidersConfigured": "Aucun fournisseur de tuiles configuré",
|
||||
"tileTypesCount": "{} types de tuiles",
|
||||
"apiKeyConfigured": "Clé API configurée",
|
||||
"needsApiKey": "Nécessite une clé API",
|
||||
"editProvider": "Modifier Fournisseur",
|
||||
"addProvider": "Ajouter Fournisseur",
|
||||
"deleteProvider": "Supprimer Fournisseur",
|
||||
"deleteProviderConfirm": "Êtes-vous sûr de vouloir supprimer \"{}\"?",
|
||||
"providerName": "Nom du Fournisseur",
|
||||
"providerNameHint": "ex., Cartes Personnalisées Inc.",
|
||||
"providerNameRequired": "Le nom du fournisseur est requis",
|
||||
"apiKey": "Clé API (Optionnel)",
|
||||
"apiKeyHint": "Entrez la clé API si requise par les types de tuiles",
|
||||
"tileTypes": "Types de Tuiles",
|
||||
"addType": "Ajouter Type",
|
||||
"noTileTypesConfigured": "Aucun type de tuile configuré",
|
||||
"atLeastOneTileTypeRequired": "Au moins un type de tuile est requis",
|
||||
"manageTileProviders": "Gérer Fournisseurs"
|
||||
},
|
||||
"tileTypeEditor": {
|
||||
"editTileType": "Modifier Type de Tuile",
|
||||
"addTileType": "Ajouter Type de Tuile",
|
||||
"name": "Nom",
|
||||
"nameHint": "ex., Satellite",
|
||||
"nameRequired": "Le nom est requis",
|
||||
"urlTemplate": "Modèle d'URL",
|
||||
"urlTemplateHint": "https://exemple.com/{z}/{x}/{y}.png",
|
||||
"urlTemplateRequired": "Le modèle d'URL est requis",
|
||||
"urlTemplatePlaceholders": "L'URL doit contenir les marqueurs {z}, {x} et {y}",
|
||||
"attribution": "Attribution",
|
||||
"attributionHint": "© Fournisseur de Cartes",
|
||||
"attributionRequired": "L'attribution est requise",
|
||||
"fetchPreview": "Récupérer Aperçu",
|
||||
"previewTileLoaded": "Tuile d'aperçu chargée avec succès",
|
||||
"previewTileFailed": "Échec de récupération de l'aperçu: {}",
|
||||
"save": "Sauvegarder"
|
||||
},
|
||||
"profiles": {
|
||||
"nodeProfiles": "Profils de Nœuds",
|
||||
"newProfile": "Nouveau Profil",
|
||||
"builtIn": "Intégré",
|
||||
"custom": "Personnalisé",
|
||||
"view": "Voir",
|
||||
"deleteProfile": "Supprimer Profil",
|
||||
"deleteProfileConfirm": "Êtes-vous sûr de vouloir supprimer \"{}\"?",
|
||||
"profileDeleted": "Profil supprimé"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Tuiles de Carte",
|
||||
"manageProviders": "Gérer Fournisseurs"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Voir Profil",
|
||||
"newProfile": "Nouveau Profil",
|
||||
"editProfile": "Modifier Profil",
|
||||
"profileName": "Nom du profil",
|
||||
"profileNameHint": "ex., Caméra ALPR Personnalisée",
|
||||
"profileNameRequired": "Le nom du profil est requis",
|
||||
"requiresDirection": "Nécessite Direction",
|
||||
"requiresDirectionSubtitle": "Si les caméras de ce type ont besoin d'une balise de direction",
|
||||
"submittable": "Soumissible",
|
||||
"submittableSubtitle": "Si ce profil peut être utilisé pour les soumissions de caméras",
|
||||
"osmTags": "Balises OSM",
|
||||
"addTag": "Ajouter Balise",
|
||||
"saveProfile": "Sauvegarder Profil",
|
||||
"keyHint": "clé",
|
||||
"valueHint": "valeur",
|
||||
"atLeastOneTagRequired": "Au moins une balise est requise",
|
||||
"profileSaved": "Profil \"{}\" sauvegardé"
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "Nouveau Profil d'Opérateur",
|
||||
"editOperatorProfile": "Modifier Profil d'Opérateur",
|
||||
"operatorName": "Nom de l'opérateur",
|
||||
"operatorNameHint": "ex., Département de Police d'Austin",
|
||||
"operatorNameRequired": "Le nom de l'opérateur est requis",
|
||||
"operatorProfileSaved": "Profil d'opérateur \"{}\" sauvegardé"
|
||||
},
|
||||
"operatorProfiles": {
|
||||
"title": "Profils d'Opérateur",
|
||||
"noProfilesMessage": "Aucun profil d'opérateur défini. Créez-en un pour appliquer des balises d'opérateur aux soumissions de nœuds.",
|
||||
"tagsCount": "{} balises",
|
||||
"deleteOperatorProfile": "Supprimer Profil d'Opérateur",
|
||||
"deleteOperatorProfileConfirm": "Êtes-vous sûr de vouloir supprimer \"{}\"?",
|
||||
"operatorProfileDeleted": "Profil d'opérateur supprimé"
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,15 @@ import 'package:provider/provider.dart';
|
||||
import 'app_state.dart';
|
||||
import 'screens/home_screen.dart';
|
||||
import 'screens/settings_screen.dart';
|
||||
import 'services/localization_service.dart';
|
||||
|
||||
|
||||
import 'widgets/tile_provider_with_cache.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Initialize localization service
|
||||
await LocalizationService.instance.init();
|
||||
|
||||
runApp(
|
||||
ChangeNotifierProvider(
|
||||
@@ -30,22 +34,25 @@ Future<void> main() async {
|
||||
),
|
||||
);
|
||||
}
|
||||
return const FlockMapApp();
|
||||
return const DeFlockApp();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class FlockMapApp extends StatelessWidget {
|
||||
const FlockMapApp({super.key});
|
||||
class DeFlockApp extends StatelessWidget {
|
||||
const DeFlockApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Flock Map',
|
||||
title: 'DeFlock',
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.green),
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: const Color(0xFF0080BC), // DeFlock blue
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
useMaterial3: true,
|
||||
),
|
||||
routes: {
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// A bundle of preset OSM tags that describe a particular camera model/type.
|
||||
class CameraProfile {
|
||||
final String id;
|
||||
final String name;
|
||||
final Map<String, String> tags;
|
||||
final bool builtin;
|
||||
|
||||
CameraProfile({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.tags,
|
||||
this.builtin = false,
|
||||
});
|
||||
|
||||
/// Built‑in default: Generic Flock ALPR camera
|
||||
factory CameraProfile.alpr() => CameraProfile(
|
||||
id: 'builtin-alpr',
|
||||
name: 'Generic Flock',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance:type': 'ALPR',
|
||||
'manufacturer': 'Flock Safety',
|
||||
'manufacturer:wikidata': 'Q108485435',
|
||||
},
|
||||
builtin: true,
|
||||
);
|
||||
|
||||
CameraProfile copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
Map<String, String>? tags,
|
||||
bool? builtin,
|
||||
}) =>
|
||||
CameraProfile(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
tags: tags ?? this.tags,
|
||||
builtin: builtin ?? this.builtin,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() =>
|
||||
{'id': id, 'name': name, 'tags': tags, 'builtin': builtin};
|
||||
|
||||
factory CameraProfile.fromJson(Map<String, dynamic> j) => CameraProfile(
|
||||
id: j['id'],
|
||||
name: j['name'],
|
||||
tags: Map<String, String>.from(j['tags']),
|
||||
builtin: j['builtin'] ?? false,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is CameraProfile &&
|
||||
runtimeType == other.runtimeType &&
|
||||
id == other.id;
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
}
|
||||
|
||||
231
lib/models/node_profile.dart
Normal file
231
lib/models/node_profile.dart
Normal file
@@ -0,0 +1,231 @@
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// A bundle of preset OSM tags that describe a particular surveillance node model/type.
|
||||
class NodeProfile {
|
||||
final String id;
|
||||
final String name;
|
||||
final Map<String, String> tags;
|
||||
final bool builtin;
|
||||
final bool requiresDirection;
|
||||
final bool submittable;
|
||||
final bool editable;
|
||||
|
||||
NodeProfile({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.tags,
|
||||
this.builtin = false,
|
||||
this.requiresDirection = true,
|
||||
this.submittable = true,
|
||||
this.editable = true,
|
||||
});
|
||||
|
||||
/// Built‑in default: Generic ALPR camera (customizable template, not submittable)
|
||||
factory NodeProfile.genericAlpr() => NodeProfile(
|
||||
id: 'builtin-generic-alpr',
|
||||
name: 'Generic ALPR',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance:type': 'ALPR',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: true,
|
||||
submittable: false,
|
||||
editable: false,
|
||||
);
|
||||
|
||||
/// Built‑in: Flock Safety ALPR camera
|
||||
factory NodeProfile.flock() => NodeProfile(
|
||||
id: 'builtin-flock',
|
||||
name: 'Flock',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'manufacturer': 'Flock Safety',
|
||||
'manufacturer:wikidata': 'Q108485435',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
);
|
||||
|
||||
/// Built‑in: Motorola Solutions/Vigilant ALPR camera
|
||||
factory NodeProfile.motorola() => NodeProfile(
|
||||
id: 'builtin-motorola',
|
||||
name: 'Motorola/Vigilant',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'manufacturer': 'Motorola Solutions',
|
||||
'manufacturer:wikidata': 'Q634815',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
);
|
||||
|
||||
/// Built‑in: Genetec ALPR camera
|
||||
factory NodeProfile.genetec() => NodeProfile(
|
||||
id: 'builtin-genetec',
|
||||
name: 'Genetec',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'manufacturer': 'Genetec',
|
||||
'manufacturer:wikidata': 'Q30295174',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
);
|
||||
|
||||
/// Built‑in: Leonardo/ELSAG ALPR camera
|
||||
factory NodeProfile.leonardo() => NodeProfile(
|
||||
id: 'builtin-leonardo',
|
||||
name: 'Leonardo/ELSAG',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'manufacturer': 'Leonardo',
|
||||
'manufacturer:wikidata': 'Q910379',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
);
|
||||
|
||||
/// Built‑in: Neology ALPR camera
|
||||
factory NodeProfile.neology() => NodeProfile(
|
||||
id: 'builtin-neology',
|
||||
name: 'Neology',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'manufacturer': 'Neology, Inc.',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
);
|
||||
|
||||
/// Built‑in: Generic gunshot detector (customizable template, not submittable)
|
||||
factory NodeProfile.genericGunshotDetector() => NodeProfile(
|
||||
id: 'builtin-generic-gunshot',
|
||||
name: 'Generic Gunshot Detector',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance:type': 'gunshot_detector',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: false,
|
||||
submittable: false,
|
||||
editable: false,
|
||||
);
|
||||
|
||||
/// Built‑in: ShotSpotter gunshot detector
|
||||
factory NodeProfile.shotspotter() => NodeProfile(
|
||||
id: 'builtin-shotspotter',
|
||||
name: 'ShotSpotter',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:type': 'gunshot_detector',
|
||||
'surveillance:brand': 'ShotSpotter',
|
||||
'surveillance:brand:wikidata': 'Q107740188',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: false,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
);
|
||||
|
||||
/// Built‑in: Flock Raven gunshot detector
|
||||
factory NodeProfile.flockRaven() => NodeProfile(
|
||||
id: 'builtin-flock-raven',
|
||||
name: 'Flock Raven',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:type': 'gunshot_detector',
|
||||
'brand': 'Flock Safety',
|
||||
'brand:wikidata': 'Q108485435',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: false,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
);
|
||||
|
||||
/// Returns true if this profile can be used for submissions
|
||||
bool get isSubmittable => submittable;
|
||||
|
||||
NodeProfile copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
Map<String, String>? tags,
|
||||
bool? builtin,
|
||||
bool? requiresDirection,
|
||||
bool? submittable,
|
||||
bool? editable,
|
||||
}) =>
|
||||
NodeProfile(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
tags: tags ?? this.tags,
|
||||
builtin: builtin ?? this.builtin,
|
||||
requiresDirection: requiresDirection ?? this.requiresDirection,
|
||||
submittable: submittable ?? this.submittable,
|
||||
editable: editable ?? this.editable,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'tags': tags,
|
||||
'builtin': builtin,
|
||||
'requiresDirection': requiresDirection,
|
||||
'submittable': submittable,
|
||||
'editable': editable,
|
||||
};
|
||||
|
||||
factory NodeProfile.fromJson(Map<String, dynamic> j) => NodeProfile(
|
||||
id: j['id'],
|
||||
name: j['name'],
|
||||
tags: Map<String, String>.from(j['tags']),
|
||||
builtin: j['builtin'] ?? false,
|
||||
requiresDirection: j['requiresDirection'] ?? true, // Default to true for backward compatibility
|
||||
submittable: j['submittable'] ?? true, // Default to true for backward compatibility
|
||||
editable: j['editable'] ?? true, // Default to true for backward compatibility
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is NodeProfile &&
|
||||
runtimeType == other.runtimeType &&
|
||||
id == other.id;
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
}
|
||||
|
||||
48
lib/models/operator_profile.dart
Normal file
48
lib/models/operator_profile.dart
Normal file
@@ -0,0 +1,48 @@
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// A bundle of OSM tags that describe a particular surveillance operator.
|
||||
/// These are applied on top of camera profile tags during submissions.
|
||||
class OperatorProfile {
|
||||
final String id;
|
||||
final String name;
|
||||
final Map<String, String> tags;
|
||||
|
||||
OperatorProfile({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.tags,
|
||||
});
|
||||
|
||||
OperatorProfile copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
Map<String, String>? tags,
|
||||
}) =>
|
||||
OperatorProfile(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
tags: tags ?? this.tags,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'tags': tags,
|
||||
};
|
||||
|
||||
factory OperatorProfile.fromJson(Map<String, dynamic> j) => OperatorProfile(
|
||||
id: j['id'],
|
||||
name: j['name'],
|
||||
tags: Map<String, String>.from(j['tags']),
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is OperatorProfile &&
|
||||
runtimeType == other.runtimeType &&
|
||||
id == other.id;
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
}
|
||||
@@ -1,38 +1,92 @@
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'camera_profile.dart';
|
||||
import 'node_profile.dart';
|
||||
import 'operator_profile.dart';
|
||||
import '../state/settings_state.dart';
|
||||
|
||||
class PendingUpload {
|
||||
final LatLng coord;
|
||||
final double direction;
|
||||
final CameraProfile profile;
|
||||
final NodeProfile profile;
|
||||
final OperatorProfile? operatorProfile;
|
||||
final UploadMode uploadMode; // Capture upload destination when queued
|
||||
final int? originalNodeId; // If this is an edit, the ID of the original OSM node
|
||||
int attempts;
|
||||
bool error;
|
||||
bool completing; // True when upload succeeded but item is showing checkmark briefly
|
||||
|
||||
PendingUpload({
|
||||
required this.coord,
|
||||
required this.direction,
|
||||
required this.profile,
|
||||
this.operatorProfile,
|
||||
required this.uploadMode,
|
||||
this.originalNodeId,
|
||||
this.attempts = 0,
|
||||
this.error = false,
|
||||
this.completing = false,
|
||||
});
|
||||
|
||||
// True if this is an edit of an existing camera, false if it's a new camera
|
||||
bool get isEdit => originalNodeId != null;
|
||||
|
||||
// Get display name for the upload destination
|
||||
String get uploadModeDisplayName {
|
||||
switch (uploadMode) {
|
||||
case UploadMode.production:
|
||||
return 'Production';
|
||||
case UploadMode.sandbox:
|
||||
return 'Sandbox';
|
||||
case UploadMode.simulate:
|
||||
return 'Simulate';
|
||||
}
|
||||
}
|
||||
|
||||
// Get combined tags from camera profile and operator profile
|
||||
Map<String, String> getCombinedTags() {
|
||||
final tags = Map<String, String>.from(profile.tags);
|
||||
|
||||
// Add operator profile tags (they override camera profile tags if there are conflicts)
|
||||
if (operatorProfile != null) {
|
||||
tags.addAll(operatorProfile!.tags);
|
||||
}
|
||||
|
||||
// Add direction if required
|
||||
if (profile.requiresDirection) {
|
||||
tags['direction'] = direction.toStringAsFixed(0);
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'lat': coord.latitude,
|
||||
'lon': coord.longitude,
|
||||
'dir': direction,
|
||||
'profile': profile.toJson(),
|
||||
'operatorProfile': operatorProfile?.toJson(),
|
||||
'uploadMode': uploadMode.index,
|
||||
'originalNodeId': originalNodeId,
|
||||
'attempts': attempts,
|
||||
'error': error,
|
||||
'completing': completing,
|
||||
};
|
||||
|
||||
factory PendingUpload.fromJson(Map<String, dynamic> j) => PendingUpload(
|
||||
coord: LatLng(j['lat'], j['lon']),
|
||||
direction: j['dir'],
|
||||
profile: j['profile'] is Map<String, dynamic>
|
||||
? CameraProfile.fromJson(j['profile'])
|
||||
: CameraProfile.alpr(),
|
||||
? NodeProfile.fromJson(j['profile'])
|
||||
: NodeProfile.genericAlpr(),
|
||||
operatorProfile: j['operatorProfile'] != null
|
||||
? OperatorProfile.fromJson(j['operatorProfile'])
|
||||
: null,
|
||||
uploadMode: j['uploadMode'] != null
|
||||
? UploadMode.values[j['uploadMode']]
|
||||
: UploadMode.production, // Default for legacy entries
|
||||
originalNodeId: j['originalNodeId'],
|
||||
attempts: j['attempts'] ?? 0,
|
||||
error: j['error'] ?? false,
|
||||
completing: j['completing'] ?? false, // Default to false for legacy entries
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
214
lib/models/tile_provider.dart
Normal file
214
lib/models/tile_provider.dart
Normal file
@@ -0,0 +1,214 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// A specific tile type within a provider
|
||||
class TileType {
|
||||
final String id;
|
||||
final String name;
|
||||
final String urlTemplate;
|
||||
final String attribution;
|
||||
final Uint8List? previewTile; // Single tile image data for preview
|
||||
|
||||
const TileType({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.urlTemplate,
|
||||
required this.attribution,
|
||||
this.previewTile,
|
||||
});
|
||||
|
||||
/// Create URL for a specific tile, replacing template variables
|
||||
String getTileUrl(int z, int x, int y, {String? apiKey}) {
|
||||
String url = urlTemplate
|
||||
.replaceAll('{z}', z.toString())
|
||||
.replaceAll('{x}', x.toString())
|
||||
.replaceAll('{y}', y.toString());
|
||||
|
||||
if (apiKey != null && apiKey.isNotEmpty) {
|
||||
url = url.replaceAll('{api_key}', apiKey);
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/// Check if this tile type needs an API key
|
||||
bool get requiresApiKey => urlTemplate.contains('{api_key}');
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'urlTemplate': urlTemplate,
|
||||
'attribution': attribution,
|
||||
'previewTile': previewTile != null ? base64Encode(previewTile!) : null,
|
||||
};
|
||||
|
||||
static TileType fromJson(Map<String, dynamic> json) => TileType(
|
||||
id: json['id'],
|
||||
name: json['name'],
|
||||
urlTemplate: json['urlTemplate'],
|
||||
attribution: json['attribution'],
|
||||
previewTile: json['previewTile'] != null
|
||||
? base64Decode(json['previewTile'])
|
||||
: null,
|
||||
);
|
||||
|
||||
TileType copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
String? urlTemplate,
|
||||
String? attribution,
|
||||
Uint8List? previewTile,
|
||||
}) => TileType(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
urlTemplate: urlTemplate ?? this.urlTemplate,
|
||||
attribution: attribution ?? this.attribution,
|
||||
previewTile: previewTile ?? this.previewTile,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is TileType && runtimeType == other.runtimeType && id == other.id;
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
}
|
||||
|
||||
/// A tile provider containing multiple tile types
|
||||
class TileProvider {
|
||||
final String id;
|
||||
final String name;
|
||||
final String? apiKey;
|
||||
final List<TileType> tileTypes;
|
||||
|
||||
const TileProvider({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.apiKey,
|
||||
required this.tileTypes,
|
||||
});
|
||||
|
||||
/// Check if this provider is usable (has API key if any tile types need it)
|
||||
bool get isUsable {
|
||||
final needsKey = tileTypes.any((type) => type.requiresApiKey);
|
||||
return !needsKey || (apiKey != null && apiKey!.isNotEmpty);
|
||||
}
|
||||
|
||||
/// Get available tile types (those that don't need API key or have one)
|
||||
List<TileType> get availableTileTypes {
|
||||
return tileTypes.where((type) => !type.requiresApiKey || isUsable).toList();
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'apiKey': apiKey,
|
||||
'tileTypes': tileTypes.map((type) => type.toJson()).toList(),
|
||||
};
|
||||
|
||||
static TileProvider fromJson(Map<String, dynamic> json) => TileProvider(
|
||||
id: json['id'],
|
||||
name: json['name'],
|
||||
apiKey: json['apiKey'],
|
||||
tileTypes: (json['tileTypes'] as List)
|
||||
.map((typeJson) => TileType.fromJson(typeJson))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
TileProvider copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
String? apiKey,
|
||||
List<TileType>? tileTypes,
|
||||
}) => TileProvider(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
apiKey: apiKey ?? this.apiKey,
|
||||
tileTypes: tileTypes ?? this.tileTypes,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is TileProvider && runtimeType == other.runtimeType && id == other.id;
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
}
|
||||
|
||||
/// Factory for creating default tile providers
|
||||
class DefaultTileProviders {
|
||||
/// Create the default set of tile providers
|
||||
static List<TileProvider> createDefaults() {
|
||||
return [
|
||||
TileProvider(
|
||||
id: 'openstreetmap',
|
||||
name: 'OpenStreetMap',
|
||||
tileTypes: [
|
||||
TileType(
|
||||
id: 'osm_street',
|
||||
name: 'Street Map',
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
),
|
||||
],
|
||||
),
|
||||
TileProvider(
|
||||
id: 'google',
|
||||
name: 'Google',
|
||||
tileTypes: [
|
||||
TileType(
|
||||
id: 'google_hybrid',
|
||||
name: 'Satellite + Roads',
|
||||
urlTemplate: 'https://mt1.google.com/vt/lyrs=y&x={x}&y={y}&z={z}',
|
||||
attribution: '© Google',
|
||||
),
|
||||
TileType(
|
||||
id: 'google_satellite',
|
||||
name: 'Satellite Only',
|
||||
urlTemplate: 'https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}',
|
||||
attribution: '© Google',
|
||||
),
|
||||
TileType(
|
||||
id: 'google_roadmap',
|
||||
name: 'Road Map',
|
||||
urlTemplate: 'https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}',
|
||||
attribution: '© Google',
|
||||
),
|
||||
],
|
||||
),
|
||||
TileProvider(
|
||||
id: 'esri',
|
||||
name: 'Esri',
|
||||
tileTypes: [
|
||||
TileType(
|
||||
id: 'esri_satellite',
|
||||
name: 'Satellite Imagery',
|
||||
urlTemplate: 'https://services.arcgisonline.com/ArcGis/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}.png',
|
||||
attribution: '© Esri © Maxar',
|
||||
),
|
||||
],
|
||||
),
|
||||
TileProvider(
|
||||
id: 'mapbox',
|
||||
name: 'Mapbox',
|
||||
tileTypes: [
|
||||
TileType(
|
||||
id: 'mapbox_satellite',
|
||||
name: 'Satellite',
|
||||
urlTemplate: 'https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}@2x.jpg90?access_token={api_key}',
|
||||
attribution: '© Mapbox © Maxar',
|
||||
),
|
||||
TileType(
|
||||
id: 'mapbox_streets',
|
||||
name: 'Streets',
|
||||
urlTemplate: 'https://api.mapbox.com/styles/v1/mapbox/streets-v12/tiles/{z}/{x}/{y}?access_token={api_key}',
|
||||
attribution: '© Mapbox © OpenStreetMap',
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import 'package:flock_map_app/dev_config.dart';
|
||||
import '../app_state.dart';
|
||||
import '../widgets/map_view.dart';
|
||||
import '../widgets/tile_provider_with_cache.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import '../services/offline_area_service.dart';
|
||||
import '../widgets/add_camera_sheet.dart';
|
||||
import 'package:flutter_map_animations/flutter_map_animations.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../dev_config.dart';
|
||||
import '../widgets/map_view.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
import '../widgets/add_node_sheet.dart';
|
||||
import '../widgets/edit_node_sheet.dart';
|
||||
import '../widgets/camera_provider_with_cache.dart';
|
||||
import '../services/offline_areas/offline_tile_utils.dart';
|
||||
import '../widgets/download_area_dialog.dart';
|
||||
import '../widgets/measured_sheet.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
@@ -19,280 +22,245 @@ class HomeScreen extends StatefulWidget {
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
|
||||
final MapController _mapController = MapController();
|
||||
bool _followMe = true;
|
||||
final GlobalKey<MapViewState> _mapViewKey = GlobalKey<MapViewState>();
|
||||
late final AnimatedMapController _mapController;
|
||||
bool _editSheetShown = false;
|
||||
|
||||
// Track sheet heights for map padding
|
||||
double _addSheetHeight = 0.0;
|
||||
double _editSheetHeight = 0.0;
|
||||
|
||||
void _openAddCameraSheet() {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_mapController = AnimatedMapController(vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_mapController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _getFollowMeTooltip(FollowMeMode mode) {
|
||||
final locService = LocalizationService.instance;
|
||||
switch (mode) {
|
||||
case FollowMeMode.off:
|
||||
return locService.t('followMe.off');
|
||||
case FollowMeMode.northUp:
|
||||
return locService.t('followMe.northUp');
|
||||
case FollowMeMode.rotating:
|
||||
return locService.t('followMe.rotating');
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getFollowMeIcon(FollowMeMode mode) {
|
||||
switch (mode) {
|
||||
case FollowMeMode.off:
|
||||
return Icons.gps_off;
|
||||
case FollowMeMode.northUp:
|
||||
return Icons.gps_fixed;
|
||||
case FollowMeMode.rotating:
|
||||
return Icons.navigation;
|
||||
}
|
||||
}
|
||||
|
||||
FollowMeMode _getNextFollowMeMode(FollowMeMode mode) {
|
||||
switch (mode) {
|
||||
case FollowMeMode.off:
|
||||
return FollowMeMode.northUp;
|
||||
case FollowMeMode.northUp:
|
||||
return FollowMeMode.rotating;
|
||||
case FollowMeMode.rotating:
|
||||
return FollowMeMode.off;
|
||||
}
|
||||
}
|
||||
|
||||
void _openAddNodeSheet() {
|
||||
final appState = context.read<AppState>();
|
||||
// Disable follow-me when adding a camera so the map doesn't jump around
|
||||
appState.setFollowMeMode(FollowMeMode.off);
|
||||
|
||||
appState.startAddSession();
|
||||
final session = appState.session!; // guaranteed non‑null now
|
||||
final session = appState.session!; // guaranteed non‑null now
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (ctx) => AddCameraSheet(session: session),
|
||||
final controller = _scaffoldKey.currentState!.showBottomSheet(
|
||||
(ctx) => MeasuredSheet(
|
||||
onHeightChanged: (height) {
|
||||
setState(() {
|
||||
_addSheetHeight = height;
|
||||
});
|
||||
},
|
||||
child: AddNodeSheet(session: session),
|
||||
),
|
||||
);
|
||||
|
||||
// Reset height when sheet is dismissed
|
||||
controller.closed.then((_) {
|
||||
setState(() {
|
||||
_addSheetHeight = 0.0;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _openEditNodeSheet() {
|
||||
final appState = context.read<AppState>();
|
||||
// Disable follow-me when editing a camera so the map doesn't jump around
|
||||
appState.setFollowMeMode(FollowMeMode.off);
|
||||
|
||||
final session = appState.editSession!; // should be non-null when this is called
|
||||
|
||||
final controller = _scaffoldKey.currentState!.showBottomSheet(
|
||||
(ctx) => MeasuredSheet(
|
||||
onHeightChanged: (height) {
|
||||
setState(() {
|
||||
_editSheetHeight = height;
|
||||
});
|
||||
},
|
||||
child: EditNodeSheet(session: session),
|
||||
),
|
||||
);
|
||||
|
||||
// Reset height when sheet is dismissed
|
||||
controller.closed.then((_) {
|
||||
setState(() {
|
||||
_editSheetHeight = 0.0;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
// Auto-open edit sheet when edit session starts
|
||||
if (appState.editSession != null && !_editSheetShown) {
|
||||
_editSheetShown = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _openEditNodeSheet());
|
||||
} else if (appState.editSession == null) {
|
||||
_editSheetShown = false;
|
||||
}
|
||||
|
||||
// Calculate bottom padding for map (90% of active sheet height)
|
||||
final activeSheetHeight = _addSheetHeight > 0 ? _addSheetHeight : _editSheetHeight;
|
||||
final mapBottomPadding = activeSheetHeight * 0.9;
|
||||
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<TileProviderWithCache>(create: (_) => TileProviderWithCache()),
|
||||
ChangeNotifierProvider<CameraProviderWithCache>(create: (_) => CameraProviderWithCache()),
|
||||
],
|
||||
child: Scaffold(
|
||||
key: _scaffoldKey,
|
||||
appBar: AppBar(
|
||||
title: const Text('Flock Map'),
|
||||
title: SvgPicture.asset(
|
||||
'assets/deflock-logo.svg',
|
||||
height: 28,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: _followMe ? 'Disable follow‑me' : 'Enable follow‑me',
|
||||
icon: Icon(_followMe ? Icons.gps_fixed : Icons.gps_off),
|
||||
onPressed: () => setState(() => _followMe = !_followMe),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings),
|
||||
onPressed: () => Navigator.pushNamed(context, '/settings'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: MapView(
|
||||
controller: _mapController,
|
||||
followMe: _followMe,
|
||||
onUserGesture: () {
|
||||
if (_followMe) setState(() => _followMe = false);
|
||||
},
|
||||
),
|
||||
bottomNavigationBar: BottomAppBar(
|
||||
elevation: 10,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
icon: const Icon(Icons.add_location_alt),
|
||||
label: const Text('Tag Camera'),
|
||||
onPressed: () {
|
||||
if (appState.session == null) {
|
||||
_openAddCameraSheet();
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(0, 48),
|
||||
textStyle: const TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
icon: const Icon(Icons.download_for_offline),
|
||||
label: const Text('Download'),
|
||||
onPressed: appState.session == null
|
||||
? () => showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => DownloadAreaDialog(controller: _mapController),
|
||||
)
|
||||
: null, // Disabled while camera sheet active
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(0, 48),
|
||||
textStyle: const TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Download area dialog ---
|
||||
class DownloadAreaDialog extends StatefulWidget {
|
||||
final MapController controller;
|
||||
const DownloadAreaDialog({super.key, required this.controller});
|
||||
|
||||
@override
|
||||
State<DownloadAreaDialog> createState() => _DownloadAreaDialogState();
|
||||
}
|
||||
|
||||
class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
|
||||
double _zoom = 15;
|
||||
int? _minZoom;
|
||||
int? _tileCount;
|
||||
double? _mbEstimate;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _recomputeEstimates());
|
||||
}
|
||||
|
||||
void _recomputeEstimates() {
|
||||
var bounds = widget.controller.camera.visibleBounds;
|
||||
// If the visible area is nearly zero, nudge the bounds for estimation
|
||||
const double epsilon = 0.0002;
|
||||
final latSpan = (bounds.north - bounds.south).abs();
|
||||
final lngSpan = (bounds.east - bounds.west).abs();
|
||||
if (latSpan < epsilon && lngSpan < epsilon) {
|
||||
bounds = LatLngBounds(
|
||||
LatLng(bounds.southWest.latitude - epsilon, bounds.southWest.longitude - epsilon),
|
||||
LatLng(bounds.northEast.latitude + epsilon, bounds.northEast.longitude + epsilon)
|
||||
);
|
||||
} else if (latSpan < epsilon) {
|
||||
bounds = LatLngBounds(
|
||||
LatLng(bounds.southWest.latitude - epsilon, bounds.southWest.longitude),
|
||||
LatLng(bounds.northEast.latitude + epsilon, bounds.northEast.longitude)
|
||||
);
|
||||
} else if (lngSpan < epsilon) {
|
||||
bounds = LatLngBounds(
|
||||
LatLng(bounds.southWest.latitude, bounds.southWest.longitude - epsilon),
|
||||
LatLng(bounds.northEast.latitude, bounds.northEast.longitude + epsilon)
|
||||
);
|
||||
}
|
||||
final minZoom = findDynamicMinZoom(bounds);
|
||||
final maxZoom = _zoom.toInt();
|
||||
final nTiles = computeTileList(bounds, minZoom, maxZoom).length;
|
||||
final totalMb = (nTiles * kTileEstimateKb) / 1024.0;
|
||||
setState(() {
|
||||
_minZoom = minZoom;
|
||||
_tileCount = nTiles;
|
||||
_mbEstimate = totalMb;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bounds = widget.controller.camera.visibleBounds;
|
||||
final maxZoom = _zoom.toInt();
|
||||
double sliderMin;
|
||||
double sliderMax;
|
||||
int sliderDivisions;
|
||||
double sliderValue;
|
||||
// Generate slider min/max/divisions with clarity
|
||||
if (_minZoom != null) {
|
||||
sliderMin = _minZoom!.toDouble();
|
||||
} else {
|
||||
sliderMin = 12.0; //fallback
|
||||
}
|
||||
if (_minZoom != null) {
|
||||
final candidateMax = _minZoom! + kMaxUserDownloadZoomSpan;
|
||||
sliderMax = candidateMax > 19 ? 19.0 : candidateMax.toDouble();
|
||||
} else {
|
||||
sliderMax = 19.0; //fallback
|
||||
}
|
||||
if (_minZoom != null) {
|
||||
final candidateMax = _minZoom! + kMaxUserDownloadZoomSpan;
|
||||
int diff = (candidateMax > 19 ? 19 : candidateMax) - _minZoom!;
|
||||
sliderDivisions = diff > 0 ? diff : 1;
|
||||
} else {
|
||||
sliderDivisions = 7; //fallback
|
||||
}
|
||||
sliderValue = _zoom.clamp(sliderMin, sliderMax);
|
||||
// We recompute estimates when the zoom slider changes
|
||||
|
||||
return AlertDialog(
|
||||
title: Row(
|
||||
children: const [
|
||||
Icon(Icons.download_for_offline),
|
||||
SizedBox(width: 10),
|
||||
Text("Download Map Area"),
|
||||
],
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 350,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Max zoom level'),
|
||||
Text('Z${_zoom.toStringAsFixed(0)}'),
|
||||
],
|
||||
),
|
||||
|
||||
Slider(
|
||||
min: sliderMin,
|
||||
max: sliderMax,
|
||||
divisions: sliderDivisions,
|
||||
label: 'Z${_zoom.toStringAsFixed(0)}',
|
||||
value: sliderValue,
|
||||
onChanged: (v) {
|
||||
setState(() => _zoom = v);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _recomputeEstimates());
|
||||
tooltip: _getFollowMeTooltip(appState.followMeMode),
|
||||
icon: Icon(_getFollowMeIcon(appState.followMeMode)),
|
||||
onPressed: () {
|
||||
final oldMode = appState.followMeMode;
|
||||
final newMode = _getNextFollowMeMode(oldMode);
|
||||
debugPrint('[HomeScreen] Follow mode changed: $oldMode → $newMode');
|
||||
appState.setFollowMeMode(newMode);
|
||||
// If enabling follow-me, retry location init in case permission was granted
|
||||
if (newMode != FollowMeMode.off) {
|
||||
_mapViewKey.currentState?.retryLocationInit();
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Storage estimate:'),
|
||||
Text(_mbEstimate == null
|
||||
? '…'
|
||||
: '${_tileCount} tiles, ${_mbEstimate!.toStringAsFixed(1)} MB'),
|
||||
],
|
||||
AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => IconButton(
|
||||
tooltip: LocalizationService.instance.settings,
|
||||
icon: const Icon(Icons.settings),
|
||||
onPressed: () => Navigator.pushNamed(context, '/settings'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
MapView(
|
||||
key: _mapViewKey,
|
||||
controller: _mapController,
|
||||
followMeMode: appState.followMeMode,
|
||||
bottomPadding: mapBottomPadding,
|
||||
onUserGesture: () {
|
||||
if (appState.followMeMode != FollowMeMode.off) {
|
||||
appState.setFollowMeMode(FollowMeMode.off);
|
||||
}
|
||||
},
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).padding.bottom + kBottomButtonBarMargin,
|
||||
left: 8,
|
||||
right: 8,
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Theme.of(context).shadowColor.withOpacity(0.3),
|
||||
blurRadius: 10,
|
||||
offset: Offset(0, -2),
|
||||
)
|
||||
],
|
||||
),
|
||||
margin: EdgeInsets.only(bottom: kBottomButtonBarMargin),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => ElevatedButton.icon(
|
||||
icon: Icon(Icons.add_location_alt),
|
||||
label: Text(LocalizationService.instance.tagNode),
|
||||
onPressed: _openAddNodeSheet,
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: Size(0, 48),
|
||||
textStyle: TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => ElevatedButton.icon(
|
||||
icon: Icon(Icons.download_for_offline),
|
||||
label: Text(LocalizationService.instance.download),
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: Size(0, 48),
|
||||
textStyle: TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_minZoom != null)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Min zoom:'),
|
||||
Text('Z$_minZoom'),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
try {
|
||||
final id = DateTime.now().toIso8601String().replaceAll(':', '-');
|
||||
final appDocDir = await OfflineAreaService().getOfflineAreaDir();
|
||||
final dir = "${appDocDir.path}/$id";
|
||||
// Fire and forget: don't await download, so dialog closes immediately
|
||||
// ignore: unawaited_futures
|
||||
OfflineAreaService().downloadArea(
|
||||
id: id,
|
||||
bounds: bounds,
|
||||
minZoom: _minZoom ?? 12,
|
||||
maxZoom: maxZoom,
|
||||
directory: dir,
|
||||
onProgress: (progress) {},
|
||||
onComplete: (status) {},
|
||||
);
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Download started!'),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to start download: $e'),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Download'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
170
lib/screens/operator_profile_editor.dart
Normal file
170
lib/screens/operator_profile_editor.dart
Normal file
@@ -0,0 +1,170 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/operator_profile.dart';
|
||||
import '../app_state.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
class OperatorProfileEditor extends StatefulWidget {
|
||||
const OperatorProfileEditor({super.key, required this.profile});
|
||||
|
||||
final OperatorProfile profile;
|
||||
|
||||
@override
|
||||
State<OperatorProfileEditor> createState() => _OperatorProfileEditorState();
|
||||
}
|
||||
|
||||
class _OperatorProfileEditorState extends State<OperatorProfileEditor> {
|
||||
late TextEditingController _nameCtrl;
|
||||
late List<MapEntry<String, String>> _tags;
|
||||
|
||||
static const _defaultTags = [
|
||||
MapEntry('operator', ''),
|
||||
MapEntry('operator:type', ''),
|
||||
MapEntry('operator:wikidata', ''),
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nameCtrl = TextEditingController(text: widget.profile.name);
|
||||
|
||||
if (widget.profile.tags.isEmpty) {
|
||||
// New profile → start with sensible defaults
|
||||
_tags = [..._defaultTags];
|
||||
} else {
|
||||
_tags = widget.profile.tags.entries.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.profile.name.isEmpty ? locService.t('operatorProfileEditor.newOperatorProfile') : locService.t('operatorProfileEditor.editOperatorProfile')),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
TextField(
|
||||
controller: _nameCtrl,
|
||||
decoration: InputDecoration(
|
||||
labelText: locService.t('operatorProfileEditor.operatorName'),
|
||||
hintText: locService.t('operatorProfileEditor.operatorNameHint'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(locService.t('profileEditor.osmTags'),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
TextButton.icon(
|
||||
onPressed: () => setState(() => _tags.add(const MapEntry('', ''))),
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(locService.t('profileEditor.addTag')),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
..._buildTagRows(),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: _save,
|
||||
child: Text(locService.t('profileEditor.saveProfile')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildTagRows() {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return List.generate(_tags.length, (i) {
|
||||
final keyController = TextEditingController(text: _tags[i].key);
|
||||
final valueController = TextEditingController(text: _tags[i].value);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: locService.t('profileEditor.keyHint'),
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
controller: keyController,
|
||||
onChanged: (v) => _tags[i] = MapEntry(v, _tags[i].value),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: locService.t('profileEditor.valueHint'),
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
controller: valueController,
|
||||
onChanged: (v) => _tags[i] = MapEntry(_tags[i].key, v),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete, color: Colors.red),
|
||||
onPressed: () => setState(() => _tags.removeAt(i)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _save() {
|
||||
final locService = LocalizationService.instance;
|
||||
final name = _nameCtrl.text.trim();
|
||||
|
||||
if (name.isEmpty) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(locService.t('operatorProfileEditor.operatorNameRequired'))));
|
||||
return;
|
||||
}
|
||||
|
||||
final tagMap = <String, String>{};
|
||||
for (final e in _tags) {
|
||||
if (e.key.trim().isEmpty || e.value.trim().isEmpty) continue;
|
||||
tagMap[e.key.trim()] = e.value.trim();
|
||||
}
|
||||
|
||||
final newProfile = widget.profile.copyWith(
|
||||
id: widget.profile.id.isEmpty ? const Uuid().v4() : widget.profile.id,
|
||||
name: name,
|
||||
tags: tagMap,
|
||||
);
|
||||
|
||||
context.read<AppState>().addOrUpdateOperatorProfile(newProfile);
|
||||
Navigator.pop(context);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(locService.t('operatorProfileEditor.operatorProfileSaved', params: [newProfile.name]))),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,14 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/camera_profile.dart';
|
||||
import '../models/node_profile.dart';
|
||||
import '../app_state.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
class ProfileEditor extends StatefulWidget {
|
||||
const ProfileEditor({super.key, required this.profile});
|
||||
|
||||
final CameraProfile profile;
|
||||
final NodeProfile profile;
|
||||
|
||||
@override
|
||||
State<ProfileEditor> createState() => _ProfileEditorState();
|
||||
@@ -17,6 +18,8 @@ class ProfileEditor extends StatefulWidget {
|
||||
class _ProfileEditorState extends State<ProfileEditor> {
|
||||
late TextEditingController _nameCtrl;
|
||||
late List<MapEntry<String, String>> _tags;
|
||||
late bool _requiresDirection;
|
||||
late bool _submittable;
|
||||
|
||||
static const _defaultTags = [
|
||||
MapEntry('man_made', 'surveillance'),
|
||||
@@ -33,6 +36,8 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nameCtrl = TextEditingController(text: widget.profile.name);
|
||||
_requiresDirection = widget.profile.requiresDirection;
|
||||
_submittable = widget.profile.submittable;
|
||||
|
||||
if (widget.profile.tags.isEmpty) {
|
||||
// New profile → start with sensible defaults
|
||||
@@ -50,47 +55,77 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title:
|
||||
Text(widget.profile.name.isEmpty ? 'New Profile' : 'Edit Profile'),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
TextField(
|
||||
controller: _nameCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Profile name',
|
||||
hintText: 'e.g., Custom ALPR Camera',
|
||||
),
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(!widget.profile.editable
|
||||
? locService.t('profileEditor.viewProfile')
|
||||
: (widget.profile.name.isEmpty ? locService.t('profileEditor.newProfile') : locService.t('profileEditor.editProfile'))),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
const Text('OSM Tags',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
TextButton.icon(
|
||||
onPressed: () => setState(() => _tags.add(const MapEntry('', ''))),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add tag'),
|
||||
TextField(
|
||||
controller: _nameCtrl,
|
||||
readOnly: !widget.profile.editable,
|
||||
decoration: InputDecoration(
|
||||
labelText: locService.t('profileEditor.profileName'),
|
||||
hintText: locService.t('profileEditor.profileNameHint'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (widget.profile.editable) ...[
|
||||
CheckboxListTile(
|
||||
title: Text(locService.t('profileEditor.requiresDirection')),
|
||||
subtitle: Text(locService.t('profileEditor.requiresDirectionSubtitle')),
|
||||
value: _requiresDirection,
|
||||
onChanged: (value) => setState(() => _requiresDirection = value ?? true),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
CheckboxListTile(
|
||||
title: Text(locService.t('profileEditor.submittable')),
|
||||
subtitle: Text(locService.t('profileEditor.submittableSubtitle')),
|
||||
value: _submittable,
|
||||
onChanged: (value) => setState(() => _submittable = value ?? true),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(locService.t('profileEditor.osmTags'),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
if (widget.profile.editable)
|
||||
TextButton.icon(
|
||||
onPressed: () => setState(() => _tags.add(const MapEntry('', ''))),
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(locService.t('profileEditor.addTag')),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
..._buildTagRows(),
|
||||
const SizedBox(height: 24),
|
||||
if (widget.profile.editable)
|
||||
ElevatedButton(
|
||||
onPressed: _save,
|
||||
child: Text(locService.t('profileEditor.saveProfile')),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
..._buildTagRows(),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: _save,
|
||||
child: const Text('Save Profile'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildTagRows() {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return List.generate(_tags.length, (i) {
|
||||
final keyController = TextEditingController(text: _tags[i].key);
|
||||
final valueController = TextEditingController(text: _tags[i].value);
|
||||
@@ -102,32 +137,39 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: TextField(
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'key',
|
||||
border: OutlineInputBorder(),
|
||||
decoration: InputDecoration(
|
||||
hintText: locService.t('profileEditor.keyHint'),
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
controller: keyController,
|
||||
onChanged: (v) => _tags[i] = MapEntry(v, _tags[i].value),
|
||||
readOnly: !widget.profile.editable,
|
||||
onChanged: !widget.profile.editable
|
||||
? null
|
||||
: (v) => _tags[i] = MapEntry(v, _tags[i].value),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: TextField(
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'value',
|
||||
border: OutlineInputBorder(),
|
||||
decoration: InputDecoration(
|
||||
hintText: locService.t('profileEditor.valueHint'),
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
controller: valueController,
|
||||
onChanged: (v) => _tags[i] = MapEntry(_tags[i].key, v),
|
||||
readOnly: !widget.profile.editable,
|
||||
onChanged: !widget.profile.editable
|
||||
? null
|
||||
: (v) => _tags[i] = MapEntry(_tags[i].key, v),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete, color: Colors.red),
|
||||
onPressed: () => setState(() => _tags.removeAt(i)),
|
||||
),
|
||||
if (widget.profile.editable)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete, color: Colors.red),
|
||||
onPressed: () => setState(() => _tags.removeAt(i)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -135,10 +177,12 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
}
|
||||
|
||||
void _save() {
|
||||
final locService = LocalizationService.instance;
|
||||
final name = _nameCtrl.text.trim();
|
||||
|
||||
if (name.isEmpty) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(content: Text('Profile name is required')));
|
||||
.showSnackBar(SnackBar(content: Text(locService.t('profileEditor.profileNameRequired'))));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -150,7 +194,7 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
|
||||
if (tagMap.isEmpty) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(content: Text('At least one tag is required')));
|
||||
.showSnackBar(SnackBar(content: Text(locService.t('profileEditor.atLeastOneTagRequired'))));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -159,13 +203,16 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
name: name,
|
||||
tags: tagMap,
|
||||
builtin: false,
|
||||
requiresDirection: _requiresDirection,
|
||||
submittable: _submittable,
|
||||
editable: true, // All custom profiles are editable by definition
|
||||
);
|
||||
|
||||
context.read<AppState>().addOrUpdateProfile(newProfile);
|
||||
Navigator.pop(context);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Profile "${newProfile.name}" saved')),
|
||||
SnackBar(content: Text(locService.t('profileEditor.profileSaved', params: [newProfile.name]))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,38 +2,51 @@ import 'package:flutter/material.dart';
|
||||
import 'settings_screen_sections/auth_section.dart';
|
||||
import 'settings_screen_sections/upload_mode_section.dart';
|
||||
import 'settings_screen_sections/profile_list_section.dart';
|
||||
import 'settings_screen_sections/operator_profile_list_section.dart';
|
||||
import 'settings_screen_sections/queue_section.dart';
|
||||
import 'settings_screen_sections/offline_areas_section.dart';
|
||||
import 'settings_screen_sections/offline_mode_section.dart';
|
||||
import 'settings_screen_sections/about_section.dart';
|
||||
import 'settings_screen_sections/max_cameras_section.dart';
|
||||
import 'settings_screen_sections/max_nodes_section.dart';
|
||||
import 'settings_screen_sections/tile_provider_section.dart';
|
||||
import 'settings_screen_sections/language_section.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
class SettingsScreen extends StatelessWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Settings')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: const [
|
||||
UploadModeSection(),
|
||||
Divider(),
|
||||
AuthSection(),
|
||||
Divider(),
|
||||
QueueSection(),
|
||||
Divider(),
|
||||
ProfileListSection(),
|
||||
Divider(),
|
||||
MaxCamerasSection(),
|
||||
Divider(),
|
||||
OfflineModeSection(),
|
||||
Divider(),
|
||||
OfflineAreasSection(),
|
||||
Divider(),
|
||||
AboutSection(),
|
||||
],
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => Scaffold(
|
||||
appBar: AppBar(title: Text(LocalizationService.instance.t('settings.title'))),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: const [
|
||||
UploadModeSection(),
|
||||
Divider(),
|
||||
AuthSection(),
|
||||
Divider(),
|
||||
QueueSection(),
|
||||
Divider(),
|
||||
ProfileListSection(),
|
||||
Divider(),
|
||||
OperatorProfileListSection(),
|
||||
Divider(),
|
||||
MaxNodesSection(),
|
||||
Divider(),
|
||||
TileProviderSection(),
|
||||
Divider(),
|
||||
OfflineModeSection(),
|
||||
Divider(),
|
||||
OfflineAreasSection(),
|
||||
Divider(),
|
||||
LanguageSection(),
|
||||
Divider(),
|
||||
AboutSection(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,35 +1,42 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../services/localization_service.dart';
|
||||
|
||||
class AboutSection extends StatelessWidget {
|
||||
const AboutSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.info_outline),
|
||||
title: const Text('About / Info'),
|
||||
onTap: () async {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => FutureBuilder<String>(
|
||||
future: DefaultAssetBundle.of(context).loadString('assets/info.txt'),
|
||||
builder: (context, snapshot) => AlertDialog(
|
||||
title: const Text('About This App'),
|
||||
content: SingleChildScrollView(
|
||||
child: Text(
|
||||
snapshot.connectionState == ConnectionState.done
|
||||
? (snapshot.data ?? 'No info available.')
|
||||
: 'Loading...',
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.info_outline),
|
||||
title: Text(locService.t('settings.aboutInfo')),
|
||||
onTap: () async {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => FutureBuilder<String>(
|
||||
future: DefaultAssetBundle.of(context).loadString('assets/info.txt'),
|
||||
builder: (context, snapshot) => AlertDialog(
|
||||
title: Text(locService.t('settings.aboutThisApp')),
|
||||
content: SingleChildScrollView(
|
||||
child: Text(
|
||||
snapshot.connectionState == ConnectionState.done
|
||||
? (snapshot.data ?? 'No info available.')
|
||||
: 'Loading...',
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(locService.ok),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,68 +1,77 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../../services/localization_service.dart';
|
||||
|
||||
class AuthSection extends StatelessWidget {
|
||||
const AuthSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
appState.isLoggedIn ? Icons.person : Icons.login,
|
||||
color: appState.isLoggedIn ? Colors.green : null,
|
||||
),
|
||||
title: Text(appState.isLoggedIn
|
||||
? 'Logged in as ${appState.username}'
|
||||
: 'Log in to OpenStreetMap'),
|
||||
subtitle: appState.isLoggedIn
|
||||
? const Text('Tap to logout')
|
||||
: const Text('Required to submit camera data'),
|
||||
onTap: () async {
|
||||
if (appState.isLoggedIn) {
|
||||
await appState.logout();
|
||||
} else {
|
||||
await appState.forceLogin();
|
||||
}
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(appState.isLoggedIn
|
||||
? 'Logged in as ${appState.username}'
|
||||
: 'Logged out'),
|
||||
backgroundColor: appState.isLoggedIn ? Colors.green : Colors.grey,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
if (appState.isLoggedIn)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.wifi_protected_setup),
|
||||
title: const Text('Test Connection'),
|
||||
subtitle: const Text('Verify OSM credentials are working'),
|
||||
onTap: () async {
|
||||
final isValid = await appState.validateToken();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(isValid
|
||||
? 'Connection OK - credentials are valid'
|
||||
: 'Connection failed - please re-login'),
|
||||
backgroundColor: isValid ? Colors.green : Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!isValid) {
|
||||
await appState.logout();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
appState.isLoggedIn ? Icons.person : Icons.login,
|
||||
color: appState.isLoggedIn ? Colors.green : null,
|
||||
),
|
||||
title: Text(appState.isLoggedIn
|
||||
? locService.t('auth.loggedInAs', params: [appState.username])
|
||||
: locService.t('auth.loginToOSM')),
|
||||
subtitle: appState.isLoggedIn
|
||||
? Text(locService.t('auth.tapToLogout'))
|
||||
: Text(locService.t('auth.requiredToSubmit')),
|
||||
onTap: () async {
|
||||
if (appState.isLoggedIn) {
|
||||
await appState.logout();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(locService.t('auth.loggedOut')),
|
||||
backgroundColor: Colors.grey,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Start login flow - the user will be redirected to browser
|
||||
await appState.forceLogin();
|
||||
|
||||
// Don't show immediate feedback - the UI will update automatically
|
||||
// when the OAuth callback completes and notifyListeners() is called
|
||||
}
|
||||
},
|
||||
),
|
||||
if (appState.isLoggedIn)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.wifi_protected_setup),
|
||||
title: Text(locService.t('auth.testConnection')),
|
||||
subtitle: Text(locService.t('auth.testConnectionSubtitle')),
|
||||
onTap: () async {
|
||||
final isValid = await appState.validateToken();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(isValid
|
||||
? locService.t('auth.connectionOK')
|
||||
: locService.t('auth.connectionFailed')),
|
||||
backgroundColor: isValid ? Colors.green : Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!isValid) {
|
||||
await appState.logout();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
90
lib/screens/settings_screen_sections/language_section.dart
Normal file
90
lib/screens/settings_screen_sections/language_section.dart
Normal file
@@ -0,0 +1,90 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../../services/localization_service.dart';
|
||||
|
||||
class LanguageSection extends StatefulWidget {
|
||||
const LanguageSection({super.key});
|
||||
|
||||
@override
|
||||
State<LanguageSection> createState() => _LanguageSectionState();
|
||||
}
|
||||
|
||||
class _LanguageSectionState extends State<LanguageSection> {
|
||||
String? _selectedLanguage;
|
||||
Map<String, String> _languageNames = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSelectedLanguage();
|
||||
_loadLanguageNames();
|
||||
}
|
||||
|
||||
_loadSelectedLanguage() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
setState(() {
|
||||
_selectedLanguage = prefs.getString('language_code');
|
||||
});
|
||||
}
|
||||
|
||||
_loadLanguageNames() async {
|
||||
final locService = LocalizationService.instance;
|
||||
final Map<String, String> names = {};
|
||||
|
||||
for (String langCode in locService.availableLanguages) {
|
||||
names[langCode] = await locService.getLanguageDisplayName(langCode);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_languageNames = names;
|
||||
});
|
||||
}
|
||||
|
||||
_setLanguage(String? languageCode) async {
|
||||
await LocalizationService.instance.setLanguage(languageCode);
|
||||
setState(() {
|
||||
_selectedLanguage = languageCode;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
locService.t('settings.language'),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
// System Default option
|
||||
RadioListTile<String?>(
|
||||
title: Text(locService.t('settings.systemDefault')),
|
||||
value: null,
|
||||
groupValue: _selectedLanguage,
|
||||
onChanged: _setLanguage,
|
||||
),
|
||||
// Dynamic language options
|
||||
...locService.availableLanguages.map((langCode) =>
|
||||
RadioListTile<String>(
|
||||
title: Text(_languageNames[langCode] ?? langCode.toUpperCase()),
|
||||
value: langCode,
|
||||
groupValue: _selectedLanguage,
|
||||
onChanged: _setLanguage,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../app_state.dart';
|
||||
|
||||
class MaxCamerasSection extends StatefulWidget {
|
||||
const MaxCamerasSection({super.key});
|
||||
|
||||
@override
|
||||
State<MaxCamerasSection> createState() => _MaxCamerasSectionState();
|
||||
}
|
||||
|
||||
class _MaxCamerasSectionState extends State<MaxCamerasSection> {
|
||||
late TextEditingController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final maxCameras = context.read<AppState>().maxCameras;
|
||||
_controller = TextEditingController(text: maxCameras.toString());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
final current = appState.maxCameras;
|
||||
final showWarning = current > 250;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.filter_alt),
|
||||
title: const Text('Max cameras fetched/drawn'),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Set an upper limit for the number of cameras on the map (default: 250).'),
|
||||
if (showWarning)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Row(
|
||||
children: const [
|
||||
Icon(Icons.warning, color: Colors.orange, size: 18),
|
||||
SizedBox(width: 6),
|
||||
Expanded(child: Text(
|
||||
'You probably don\'t want to do that unless you are absolutely sure you have a good reason for it.',
|
||||
style: TextStyle(color: Colors.orange),
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: SizedBox(
|
||||
width: 80,
|
||||
child: TextFormField(
|
||||
controller: _controller,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onFieldSubmitted: (value) {
|
||||
final n = int.tryParse(value) ?? 10;
|
||||
appState.maxCameras = n;
|
||||
_controller.text = appState.maxCameras.toString();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
88
lib/screens/settings_screen_sections/max_nodes_section.dart
Normal file
88
lib/screens/settings_screen_sections/max_nodes_section.dart
Normal file
@@ -0,0 +1,88 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../../services/localization_service.dart';
|
||||
|
||||
class MaxNodesSection extends StatefulWidget {
|
||||
const MaxNodesSection({super.key});
|
||||
|
||||
@override
|
||||
State<MaxNodesSection> createState() => _MaxNodesSectionState();
|
||||
}
|
||||
|
||||
class _MaxNodesSectionState extends State<MaxNodesSection> {
|
||||
late TextEditingController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final maxNodes = context.read<AppState>().maxCameras;
|
||||
_controller = TextEditingController(text: maxNodes.toString());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.watch<AppState>();
|
||||
final current = appState.maxCameras;
|
||||
final showWarning = current > 1000;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.filter_alt),
|
||||
title: Text(locService.t('settings.maxNodes')),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(locService.t('settings.maxNodesSubtitle')),
|
||||
if (showWarning)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.warning, color: Colors.orange, size: 18),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(child: Text(
|
||||
locService.t('settings.maxNodesWarning'),
|
||||
style: const TextStyle(color: Colors.orange),
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: SizedBox(
|
||||
width: 80,
|
||||
child: TextFormField(
|
||||
controller: _controller,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onFieldSubmitted: (value) {
|
||||
final n = int.tryParse(value) ?? 10;
|
||||
appState.maxCameras = n;
|
||||
_controller.text = appState.maxCameras.toString();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ class _OfflineAreasSectionState extends State<OfflineAreasSection> {
|
||||
: "${(area.sizeBytes / 1024).toStringAsFixed(1)} KB"
|
||||
: '--';
|
||||
String subtitle =
|
||||
'Provider: ${area.tileProviderDisplay}\n' +
|
||||
'Z${area.minZoom}-${area.maxZoom}\n' +
|
||||
'Lat: ${area.bounds.southWest.latitude.toStringAsFixed(3)}, ${area.bounds.southWest.longitude.toStringAsFixed(3)}\n' +
|
||||
'Lat: ${area.bounds.northEast.latitude.toStringAsFixed(3)}, ${area.bounds.northEast.longitude.toStringAsFixed(3)}';
|
||||
@@ -121,6 +122,10 @@ class _OfflineAreasSectionState extends State<OfflineAreasSection> {
|
||||
name: area.name,
|
||||
onProgress: (progress) {},
|
||||
onComplete: (status) {},
|
||||
tileProviderId: area.tileProviderId,
|
||||
tileProviderName: area.tileProviderName,
|
||||
tileTypeId: area.tileTypeId,
|
||||
tileTypeName: area.tileTypeName,
|
||||
);
|
||||
setState(() {});
|
||||
},
|
||||
|
||||
@@ -1,21 +1,76 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../../services/offline_area_service.dart';
|
||||
import '../../services/localization_service.dart';
|
||||
|
||||
class OfflineModeSection extends StatelessWidget {
|
||||
const OfflineModeSection({super.key});
|
||||
|
||||
Future<void> _handleOfflineModeChange(BuildContext context, AppState appState, bool value) async {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
// If enabling offline mode, check for active downloads
|
||||
if (value && !appState.offlineMode) {
|
||||
final offlineService = OfflineAreaService();
|
||||
if (offlineService.hasActiveDownloads) {
|
||||
// Show confirmation dialog
|
||||
final shouldProceed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
const Icon(Icons.warning, color: Colors.orange),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('settings.offlineModeWarningTitle')),
|
||||
],
|
||||
),
|
||||
content: Text(locService.t('settings.offlineModeWarningMessage')),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(locService.cancel),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.orange,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: Text(locService.t('settings.enableOfflineMode')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (shouldProceed != true) {
|
||||
return; // User cancelled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Proceed with the change
|
||||
await appState.setOfflineMode(value);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.wifi_off),
|
||||
title: const Text('Offline Mode'),
|
||||
subtitle: const Text('Disable all network requests except for local/offline areas.'),
|
||||
trailing: Switch(
|
||||
value: appState.offlineMode,
|
||||
onChanged: (value) async => await appState.setOfflineMode(value),
|
||||
),
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.wifi_off),
|
||||
title: Text(locService.t('settings.offlineMode')),
|
||||
subtitle: Text(locService.t('settings.offlineModeSubtitle')),
|
||||
trailing: Switch(
|
||||
value: appState.offlineMode,
|
||||
onChanged: (value) => _handleOfflineModeChange(context, appState, value),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../../models/operator_profile.dart';
|
||||
import '../../services/localization_service.dart';
|
||||
import '../operator_profile_editor.dart';
|
||||
|
||||
class OperatorProfileListSection extends StatelessWidget {
|
||||
const OperatorProfileListSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(locService.t('operatorProfiles.title'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
TextButton.icon(
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => OperatorProfileEditor(
|
||||
profile: OperatorProfile(
|
||||
id: const Uuid().v4(),
|
||||
name: '',
|
||||
tags: const {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(locService.t('profiles.newProfile')),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (appState.operatorProfiles.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
locService.t('operatorProfiles.noProfilesMessage'),
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
)
|
||||
else
|
||||
...appState.operatorProfiles.map(
|
||||
(p) => ListTile(
|
||||
title: Text(p.name),
|
||||
subtitle: Text(locService.t('operatorProfiles.tagsCount', params: [p.tags.length.toString()])),
|
||||
trailing: PopupMenuButton(
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.edit),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('actions.edit')),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.delete, color: Colors.red),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('operatorProfiles.deleteOperatorProfile'), style: const TextStyle(color: Colors.red)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onSelected: (value) {
|
||||
if (value == 'edit') {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => OperatorProfileEditor(profile: p),
|
||||
),
|
||||
);
|
||||
} else if (value == 'delete') {
|
||||
_showDeleteProfileDialog(context, p);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showDeleteProfileDialog(BuildContext context, OperatorProfile profile) {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.read<AppState>();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(locService.t('operatorProfiles.deleteOperatorProfile')),
|
||||
content: Text(locService.t('operatorProfiles.deleteOperatorProfileConfirm', params: [profile.name])),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(locService.t('actions.cancel')),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
appState.deleteOperatorProfile(profile);
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(locService.t('operatorProfiles.operatorProfileDeleted'))),
|
||||
);
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: Text(locService.t('operatorProfiles.deleteOperatorProfile')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../../models/camera_profile.dart';
|
||||
import '../../models/node_profile.dart';
|
||||
import '../../services/localization_service.dart';
|
||||
import '../profile_editor.dart';
|
||||
|
||||
class ProfileListSection extends StatelessWidget {
|
||||
@@ -10,104 +11,136 @@ class ProfileListSection extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
return Column(
|
||||
children: [
|
||||
const Text('Camera Profiles', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
TextButton.icon(
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ProfileEditor(
|
||||
profile: CameraProfile(
|
||||
id: const Uuid().v4(),
|
||||
name: '',
|
||||
tags: const {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('New Profile'),
|
||||
),
|
||||
],
|
||||
),
|
||||
...appState.profiles.map(
|
||||
(p) => ListTile(
|
||||
leading: Checkbox(
|
||||
value: appState.isEnabled(p),
|
||||
onChanged: (v) => appState.toggleProfile(p, v ?? false),
|
||||
),
|
||||
title: Text(p.name),
|
||||
subtitle: Text(p.builtin ? 'Built-in' : 'Custom'),
|
||||
trailing: p.builtin ? null : PopupMenuButton(
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.edit),
|
||||
SizedBox(width: 8),
|
||||
Text('Edit'),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.delete, color: Colors.red),
|
||||
SizedBox(width: 8),
|
||||
Text('Delete', style: TextStyle(color: Colors.red)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onSelected: (value) {
|
||||
if (value == 'edit') {
|
||||
Navigator.push(
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(locService.t('profiles.nodeProfiles'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
TextButton.icon(
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ProfileEditor(profile: p),
|
||||
builder: (_) => ProfileEditor(
|
||||
profile: NodeProfile(
|
||||
id: const Uuid().v4(),
|
||||
name: '',
|
||||
tags: const {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (value == 'delete') {
|
||||
_showDeleteProfileDialog(context, p);
|
||||
}
|
||||
},
|
||||
),
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(locService.t('profiles.newProfile')),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
...appState.profiles.map(
|
||||
(p) => ListTile(
|
||||
leading: Checkbox(
|
||||
value: appState.isEnabled(p),
|
||||
onChanged: (v) => appState.toggleProfile(p, v ?? false),
|
||||
),
|
||||
title: Text(p.name),
|
||||
subtitle: Text(p.builtin ? locService.t('profiles.builtIn') : locService.t('profiles.custom')),
|
||||
trailing: !p.editable
|
||||
? PopupMenuButton(
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'view',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.visibility),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('profiles.view')),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onSelected: (value) {
|
||||
if (value == 'view') {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ProfileEditor(profile: p),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
)
|
||||
: PopupMenuButton(
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.edit),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('actions.edit')),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.delete, color: Colors.red),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('profiles.deleteProfile'), style: const TextStyle(color: Colors.red)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onSelected: (value) {
|
||||
if (value == 'edit') {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ProfileEditor(profile: p),
|
||||
),
|
||||
);
|
||||
} else if (value == 'delete') {
|
||||
_showDeleteProfileDialog(context, p);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showDeleteProfileDialog(BuildContext context, CameraProfile profile) {
|
||||
void _showDeleteProfileDialog(BuildContext context, NodeProfile profile) {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.read<AppState>();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Delete Profile'),
|
||||
content: Text('Are you sure you want to delete "${profile.name}"?'),
|
||||
title: Text(locService.t('profiles.deleteProfile')),
|
||||
content: Text(locService.t('profiles.deleteProfileConfirm', params: [profile.name])),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(locService.t('actions.cancel')),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
appState.deleteProfile(profile);
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Profile deleted')),
|
||||
SnackBar(content: Text(locService.t('profiles.profileDeleted'))),
|
||||
);
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('Delete'),
|
||||
child: Text(locService.t('profiles.deleteProfile')),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,132 +1,176 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../../services/localization_service.dart';
|
||||
import '../../state/settings_state.dart';
|
||||
|
||||
class QueueSection extends StatelessWidget {
|
||||
const QueueSection({super.key});
|
||||
|
||||
String _getUploadModeDisplayName(UploadMode mode) {
|
||||
final locService = LocalizationService.instance;
|
||||
switch (mode) {
|
||||
case UploadMode.production:
|
||||
return locService.t('uploadMode.production');
|
||||
case UploadMode.sandbox:
|
||||
return locService.t('uploadMode.sandbox');
|
||||
case UploadMode.simulate:
|
||||
return locService.t('uploadMode.simulate');
|
||||
}
|
||||
}
|
||||
|
||||
Color _getUploadModeColor(UploadMode mode) {
|
||||
switch (mode) {
|
||||
case UploadMode.production:
|
||||
return Colors.green; // Green for production (real)
|
||||
case UploadMode.sandbox:
|
||||
return Colors.orange; // Orange for sandbox (testing)
|
||||
case UploadMode.simulate:
|
||||
return Colors.grey; // Grey for simulate (fake)
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.queue),
|
||||
title: Text('Pending uploads: ${appState.pendingCount}'),
|
||||
subtitle: appState.uploadMode == UploadMode.simulate
|
||||
? const Text('Simulate mode enabled – uploads simulated')
|
||||
: appState.uploadMode == UploadMode.sandbox
|
||||
? const Text('Sandbox mode – uploads go to OSM Sandbox')
|
||||
: const Text('Tap to view queue'),
|
||||
onTap: appState.pendingCount > 0
|
||||
? () => _showQueueDialog(context)
|
||||
: null,
|
||||
),
|
||||
if (appState.pendingCount > 0)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.clear_all),
|
||||
title: const Text('Clear Upload Queue'),
|
||||
subtitle: Text('Remove all ${appState.pendingCount} pending uploads'),
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Clear Queue'),
|
||||
content: Text('Remove all ${appState.pendingCount} pending uploads?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.queue),
|
||||
title: Text(locService.t('queue.pendingUploads', params: [appState.pendingCount.toString()])),
|
||||
subtitle: appState.uploadMode == UploadMode.simulate
|
||||
? Text(locService.t('queue.simulateModeEnabled'))
|
||||
: appState.uploadMode == UploadMode.sandbox
|
||||
? Text(locService.t('queue.sandboxMode'))
|
||||
: Text(locService.t('queue.tapToViewQueue')),
|
||||
onTap: appState.pendingCount > 0
|
||||
? () => _showQueueDialog(context)
|
||||
: null,
|
||||
),
|
||||
if (appState.pendingCount > 0)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.clear_all),
|
||||
title: Text(locService.t('queue.clearUploadQueue')),
|
||||
subtitle: Text(locService.t('queue.removeAllPending', params: [appState.pendingCount.toString()])),
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(locService.t('queue.clearQueueTitle')),
|
||||
content: Text(locService.t('queue.clearQueueConfirm', params: [appState.pendingCount.toString()])),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(locService.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
appState.clearQueue();
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(locService.t('queue.queueCleared'))),
|
||||
);
|
||||
},
|
||||
child: Text(locService.t('actions.clear')),
|
||||
),
|
||||
],
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
appState.clearQueue();
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Queue cleared')),
|
||||
);
|
||||
},
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showQueueDialog(BuildContext context) {
|
||||
final appState = context.read<AppState>();
|
||||
final locService = LocalizationService.instance;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('Upload Queue (${appState.pendingCount} items)'),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
height: 300,
|
||||
child: ListView.builder(
|
||||
itemCount: appState.pendingUploads.length,
|
||||
itemBuilder: (context, index) {
|
||||
final upload = appState.pendingUploads[index];
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
upload.error ? Icons.error : Icons.camera_alt,
|
||||
color: upload.error ? Colors.red : null,
|
||||
),
|
||||
title: Text('Camera ${index + 1}${upload.error ? " (Error)" : ""}'),
|
||||
subtitle: Text(
|
||||
'Lat: ${upload.coord.latitude.toStringAsFixed(6)}\n'
|
||||
'Lon: ${upload.coord.longitude.toStringAsFixed(6)}\n'
|
||||
'Direction: ${upload.direction.round()}°\n'
|
||||
'Attempts: ${upload.attempts}' +
|
||||
(upload.error ? "\nUpload failed. Tap retry to try again." : "")
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (upload.error)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
color: Colors.orange,
|
||||
tooltip: 'Retry upload',
|
||||
onPressed: () {
|
||||
appState.retryUpload(upload);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: () {
|
||||
appState.removeFromQueue(upload);
|
||||
if (appState.pendingCount == 0) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
builder: (context) => Consumer<AppState>(
|
||||
builder: (context, appState, child) => AlertDialog(
|
||||
title: Text(locService.t('queue.uploadQueueTitle', params: [appState.pendingCount.toString()])),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
height: 300,
|
||||
child: appState.pendingUploads.isEmpty
|
||||
? Center(child: Text(locService.t('queue.queueIsEmpty')))
|
||||
: ListView.builder(
|
||||
itemCount: appState.pendingUploads.length,
|
||||
itemBuilder: (context, index) {
|
||||
final upload = appState.pendingUploads[index];
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
upload.error ? Icons.error : Icons.camera_alt,
|
||||
color: upload.error
|
||||
? Colors.red
|
||||
: _getUploadModeColor(upload.uploadMode),
|
||||
),
|
||||
title: Text(locService.t('queue.cameraWithIndex', params: [(index + 1).toString()]) +
|
||||
(upload.error ? locService.t('queue.error') : "") +
|
||||
(upload.completing ? locService.t('queue.completing') : "")),
|
||||
subtitle: Text(
|
||||
locService.t('queue.destination', params: [_getUploadModeDisplayName(upload.uploadMode)]) + '\n' +
|
||||
locService.t('queue.latitude', params: [upload.coord.latitude.toStringAsFixed(6)]) + '\n' +
|
||||
locService.t('queue.longitude', params: [upload.coord.longitude.toStringAsFixed(6)]) + '\n' +
|
||||
locService.t('queue.direction', params: [upload.direction.round().toString()]) + '\n' +
|
||||
locService.t('queue.attempts', params: [upload.attempts.toString()]) +
|
||||
(upload.error ? "\n${locService.t('queue.uploadFailedRetry')}" : "")
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (upload.error && !upload.completing)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
color: Colors.orange,
|
||||
tooltip: locService.t('queue.retryUpload'),
|
||||
onPressed: () {
|
||||
appState.retryUpload(upload);
|
||||
},
|
||||
),
|
||||
if (upload.completing)
|
||||
const Icon(Icons.check_circle, color: Colors.green)
|
||||
else
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: () {
|
||||
appState.removeFromQueue(upload);
|
||||
if (appState.pendingCount == 0) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
if (appState.pendingCount > 1)
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
appState.clearQueue();
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Queue cleared')),
|
||||
);
|
||||
},
|
||||
child: const Text('Clear All'),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(locService.t('actions.close')),
|
||||
),
|
||||
],
|
||||
if (appState.pendingCount > 1)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
appState.clearQueue();
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(locService.t('queue.queueCleared'))),
|
||||
);
|
||||
},
|
||||
child: Text(locService.t('queue.clearAll')),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../app_state.dart';
|
||||
import '../../models/tile_provider.dart';
|
||||
import '../../services/localization_service.dart';
|
||||
import '../tile_provider_management_screen.dart';
|
||||
|
||||
class TileProviderSection extends StatelessWidget {
|
||||
const TileProviderSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('mapTiles.title'),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const TileProviderManagementScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.settings),
|
||||
label: Text(locService.t('mapTiles.manageProviders')),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +1,85 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../../services/localization_service.dart';
|
||||
|
||||
class UploadModeSection extends StatelessWidget {
|
||||
const UploadModeSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.cloud_upload),
|
||||
title: const Text('Upload Destination'),
|
||||
subtitle: const Text('Choose where cameras are uploaded'),
|
||||
trailing: DropdownButton<UploadMode>(
|
||||
value: appState.uploadMode,
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: UploadMode.production,
|
||||
child: Text('Production'),
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.cloud_upload),
|
||||
title: Text(locService.t('uploadMode.title')),
|
||||
subtitle: Text(locService.t('uploadMode.subtitle')),
|
||||
trailing: DropdownButton<UploadMode>(
|
||||
value: appState.uploadMode,
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: UploadMode.production,
|
||||
child: Text(locService.t('uploadMode.production')),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: UploadMode.sandbox,
|
||||
child: Text(locService.t('uploadMode.sandbox')),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: UploadMode.simulate,
|
||||
child: Text(locService.t('uploadMode.simulate')),
|
||||
),
|
||||
],
|
||||
onChanged: (mode) {
|
||||
if (mode != null) appState.setUploadMode(mode);
|
||||
},
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: UploadMode.sandbox,
|
||||
child: Text('Sandbox'),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 56, top: 2, right: 16, bottom: 12),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
switch (appState.uploadMode) {
|
||||
case UploadMode.production:
|
||||
return Text(
|
||||
locService.t('uploadMode.productionDescription'),
|
||||
style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7))
|
||||
);
|
||||
case UploadMode.sandbox:
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('uploadMode.sandboxDescription'),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.orange),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
locService.t('uploadMode.sandboxNote'),
|
||||
style: const TextStyle(fontSize: 11, color: Colors.redAccent),
|
||||
),
|
||||
],
|
||||
);
|
||||
case UploadMode.simulate:
|
||||
default:
|
||||
return Text(
|
||||
locService.t('uploadMode.simulateDescription'),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.deepPurple)
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: UploadMode.simulate,
|
||||
child: Text('Simulate'),
|
||||
),
|
||||
],
|
||||
onChanged: (mode) {
|
||||
if (mode != null) appState.setUploadMode(mode);
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 56, top: 2, right: 16, bottom: 12),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
switch (appState.uploadMode) {
|
||||
case UploadMode.production:
|
||||
return const Text('Upload to the live OSM database (visible to all users)', style: TextStyle(fontSize: 12, color: Colors.black87));
|
||||
case UploadMode.sandbox:
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Uploads go to the OSM Sandbox (safe for testing, resets regularly).',
|
||||
style: TextStyle(fontSize: 12, color: Colors.orange),
|
||||
),
|
||||
SizedBox(height: 2),
|
||||
Text(
|
||||
'NOTE: Due to OpenStreetMap limitations, cameras submitted to the sandbox will NOT appear on the map in this app.',
|
||||
style: TextStyle(fontSize: 11, color: Colors.redAccent),
|
||||
),
|
||||
],
|
||||
);
|
||||
case UploadMode.simulate:
|
||||
default:
|
||||
return const Text('Simulate uploads (does not contact OSM servers)', style: TextStyle(fontSize: 12, color: Colors.deepPurple));
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
432
lib/screens/tile_provider_editor_screen.dart
Normal file
432
lib/screens/tile_provider_editor_screen.dart
Normal file
@@ -0,0 +1,432 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../models/tile_provider.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
class TileProviderEditorScreen extends StatefulWidget {
|
||||
final TileProvider? provider; // null for adding new provider
|
||||
|
||||
const TileProviderEditorScreen({super.key, this.provider});
|
||||
|
||||
@override
|
||||
State<TileProviderEditorScreen> createState() => _TileProviderEditorScreenState();
|
||||
}
|
||||
|
||||
class _TileProviderEditorScreenState extends State<TileProviderEditorScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late final TextEditingController _nameController;
|
||||
late final TextEditingController _apiKeyController;
|
||||
late List<TileType> _tileTypes;
|
||||
|
||||
bool get _isEditing => widget.provider != null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final provider = widget.provider;
|
||||
_nameController = TextEditingController(text: provider?.name ?? '');
|
||||
_apiKeyController = TextEditingController(text: provider?.apiKey ?? '');
|
||||
_tileTypes = provider != null
|
||||
? List.from(provider.tileTypes)
|
||||
: <TileType>[];
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_apiKeyController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_isEditing ? locService.t('tileProviders.editProvider') : locService.t('tileProviders.addProvider')),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _saveProvider,
|
||||
child: Text(locService.t('tileTypeEditor.save')),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: locService.t('tileProviders.providerName'),
|
||||
hintText: locService.t('tileProviders.providerNameHint'),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return locService.t('tileProviders.providerNameRequired');
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _apiKeyController,
|
||||
decoration: InputDecoration(
|
||||
labelText: locService.t('tileProviders.apiKey'),
|
||||
hintText: locService.t('tileProviders.apiKeyHint'),
|
||||
),
|
||||
obscureText: true,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('tileProviders.tileTypes'),
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: _addTileType,
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(locService.t('tileProviders.addType')),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_tileTypes.isEmpty)
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(locService.t('tileProviders.noTileTypesConfigured')),
|
||||
),
|
||||
)
|
||||
else
|
||||
..._tileTypes.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final tileType = entry.value;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
title: Text(tileType.name),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(tileType.urlTemplate),
|
||||
Text(
|
||||
tileType.attribution,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: () => _editTileType(index),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: _tileTypes.length > 1
|
||||
? () => _deleteTileType(index)
|
||||
: null, // Can't delete last tile type
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () => _editTileType(index),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _addTileType() {
|
||||
_showTileTypeDialog();
|
||||
}
|
||||
|
||||
void _editTileType(int index) {
|
||||
_showTileTypeDialog(tileType: _tileTypes[index], index: index);
|
||||
}
|
||||
|
||||
void _deleteTileType(int index) {
|
||||
if (_tileTypes.length <= 1) return;
|
||||
|
||||
final tileTypeToDelete = _tileTypes[index];
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
setState(() {
|
||||
_tileTypes.removeAt(index);
|
||||
});
|
||||
|
||||
// If we're deleting the currently selected tile type, switch to another one
|
||||
if (appState.selectedTileType?.id == tileTypeToDelete.id) {
|
||||
// Find first remaining tile type in this provider or any other provider
|
||||
TileType? replacement;
|
||||
if (_tileTypes.isNotEmpty) {
|
||||
replacement = _tileTypes.first;
|
||||
} else {
|
||||
// Look in other providers
|
||||
for (final provider in appState.tileProviders) {
|
||||
if (provider.availableTileTypes.isNotEmpty) {
|
||||
replacement = provider.availableTileTypes.first;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (replacement != null) {
|
||||
appState.setSelectedTileType(replacement.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showTileTypeDialog({TileType? tileType, int? index}) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => _TileTypeDialog(
|
||||
tileType: tileType,
|
||||
onSave: (newTileType) {
|
||||
setState(() {
|
||||
if (index != null) {
|
||||
_tileTypes[index] = newTileType;
|
||||
} else {
|
||||
_tileTypes.add(newTileType);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _saveProvider() {
|
||||
final locService = LocalizationService.instance;
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
if (_tileTypes.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(locService.t('tileProviders.atLeastOneTileTypeRequired'))),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final providerId = widget.provider?.id ?? DateTime.now().millisecondsSinceEpoch.toString();
|
||||
final provider = TileProvider(
|
||||
id: providerId,
|
||||
name: _nameController.text.trim(),
|
||||
apiKey: _apiKeyController.text.trim().isEmpty ? null : _apiKeyController.text.trim(),
|
||||
tileTypes: _tileTypes,
|
||||
);
|
||||
|
||||
context.read<AppState>().addOrUpdateTileProvider(provider);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
class _TileTypeDialog extends StatefulWidget {
|
||||
final TileType? tileType;
|
||||
final Function(TileType) onSave;
|
||||
|
||||
const _TileTypeDialog({
|
||||
required this.onSave,
|
||||
this.tileType,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_TileTypeDialog> createState() => _TileTypeDialogState();
|
||||
}
|
||||
|
||||
class _TileTypeDialogState extends State<_TileTypeDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late final TextEditingController _nameController;
|
||||
late final TextEditingController _urlController;
|
||||
late final TextEditingController _attributionController;
|
||||
Uint8List? _previewTile;
|
||||
bool _isLoadingPreview = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final tileType = widget.tileType;
|
||||
_nameController = TextEditingController(text: tileType?.name ?? '');
|
||||
_urlController = TextEditingController(text: tileType?.urlTemplate ?? '');
|
||||
_attributionController = TextEditingController(text: tileType?.attribution ?? '');
|
||||
_previewTile = tileType?.previewTile;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_urlController.dispose();
|
||||
_attributionController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(widget.tileType != null ? locService.t('tileTypeEditor.editTileType') : locService.t('tileTypeEditor.addTileType')),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: locService.t('tileTypeEditor.name'),
|
||||
hintText: locService.t('tileTypeEditor.nameHint'),
|
||||
),
|
||||
validator: (value) => value?.trim().isEmpty == true ? locService.t('tileTypeEditor.nameRequired') : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _urlController,
|
||||
decoration: InputDecoration(
|
||||
labelText: locService.t('tileTypeEditor.urlTemplate'),
|
||||
hintText: locService.t('tileTypeEditor.urlTemplateHint'),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value?.trim().isEmpty == true) return locService.t('tileTypeEditor.urlTemplateRequired');
|
||||
if (!value!.contains('{z}') || !value.contains('{x}') || !value.contains('{y}')) {
|
||||
return locService.t('tileTypeEditor.urlTemplatePlaceholders');
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _attributionController,
|
||||
decoration: InputDecoration(
|
||||
labelText: locService.t('tileTypeEditor.attribution'),
|
||||
hintText: locService.t('tileTypeEditor.attributionHint'),
|
||||
),
|
||||
validator: (value) => value?.trim().isEmpty == true ? locService.t('tileTypeEditor.attributionRequired') : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: _isLoadingPreview ? null : _fetchPreviewTile,
|
||||
icon: _isLoadingPreview
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.preview),
|
||||
label: Text(locService.t('tileTypeEditor.fetchPreview')),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (_previewTile != null)
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey),
|
||||
),
|
||||
child: Image.memory(_previewTile!, fit: BoxFit.cover),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(locService.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _saveTileType,
|
||||
child: Text(locService.t('tileTypeEditor.save')),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _fetchPreviewTile() async {
|
||||
final locService = LocalizationService.instance;
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
setState(() {
|
||||
_isLoadingPreview = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// Use a sample tile (zoom 10, somewhere in the world)
|
||||
final url = _urlController.text
|
||||
.replaceAll('{z}', '10')
|
||||
.replaceAll('{x}', '512')
|
||||
.replaceAll('{y}', '384');
|
||||
|
||||
final response = await http.get(Uri.parse(url));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
setState(() {
|
||||
_previewTile = response.bodyBytes;
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(locService.t('tileTypeEditor.previewTileLoaded'))),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw Exception('HTTP ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(locService.t('tileTypeEditor.previewTileFailed', params: [e.toString()]))),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setState(() {
|
||||
_isLoadingPreview = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _saveTileType() {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
final tileTypeId = widget.tileType?.id ??
|
||||
'${_nameController.text.toLowerCase().replaceAll(' ', '_')}_${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
final tileType = TileType(
|
||||
id: tileTypeId,
|
||||
name: _nameController.text.trim(),
|
||||
urlTemplate: _urlController.text.trim(),
|
||||
attribution: _attributionController.text.trim(),
|
||||
previewTile: _previewTile,
|
||||
);
|
||||
|
||||
widget.onSave(tileType);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
168
lib/screens/tile_provider_management_screen.dart
Normal file
168
lib/screens/tile_provider_management_screen.dart
Normal file
@@ -0,0 +1,168 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../models/tile_provider.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import 'tile_provider_editor_screen.dart';
|
||||
|
||||
class TileProviderManagementScreen extends StatelessWidget {
|
||||
const TileProviderManagementScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.watch<AppState>();
|
||||
final providers = appState.tileProviders;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(locService.t('tileProviders.title')),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: () => _addProvider(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: providers.isEmpty
|
||||
? Center(
|
||||
child: Text(locService.t('tileProviders.noProvidersConfigured')),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: providers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final provider = providers[index];
|
||||
final isSelected = appState.selectedTileProvider?.id == provider.id;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: ListTile(
|
||||
title: Text(
|
||||
provider.name,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.bold : null,
|
||||
),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(locService.t('tileProviders.tileTypesCount', params: [provider.tileTypes.length.toString()])),
|
||||
if (provider.apiKey?.isNotEmpty == true)
|
||||
Text(
|
||||
locService.t('tileProviders.apiKeyConfigured'),
|
||||
style: const TextStyle(
|
||||
fontStyle: FontStyle.italic,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
if (!provider.isUsable)
|
||||
Text(
|
||||
locService.t('tileProviders.needsApiKey'),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.surfaceVariant,
|
||||
child: Icon(
|
||||
Icons.map,
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.onPrimary
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
trailing: providers.length > 1
|
||||
? PopupMenuButton<String>(
|
||||
onSelected: (action) {
|
||||
switch (action) {
|
||||
case 'edit':
|
||||
_editProvider(context, provider);
|
||||
break;
|
||||
case 'delete':
|
||||
_deleteProvider(context, provider);
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.edit),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('actions.edit')),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.delete),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('tileProviders.deleteProvider')),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: const Icon(Icons.lock, size: 16), // Can't delete last provider
|
||||
onTap: () => _editProvider(context, provider),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _addProvider(BuildContext context) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const TileProviderEditorScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _editProvider(BuildContext context, TileProvider provider) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => TileProviderEditorScreen(provider: provider),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _deleteProvider(BuildContext context, TileProvider provider) {
|
||||
final locService = LocalizationService.instance;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(locService.t('tileProviders.deleteProvider')),
|
||||
content: Text(locService.t('tileProviders.deleteProviderConfirm', params: [provider.name])),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(locService.t('actions.cancel')),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.read<AppState>().deleteTileProvider(provider.id);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(locService.t('tileProviders.deleteProvider')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,12 @@ import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Handles PKCE OAuth login with OpenStreetMap.
|
||||
import '../app_state.dart';
|
||||
|
||||
import '../keys.dart';
|
||||
import '../app_state.dart' show UploadMode;
|
||||
|
||||
class AuthService {
|
||||
// Both client IDs from keys.dart
|
||||
static const _redirect = 'flockmap://auth';
|
||||
static const _redirect = 'deflockapp://auth';
|
||||
|
||||
late OAuth2Helper _helper;
|
||||
String? _displayName;
|
||||
@@ -47,7 +46,7 @@ class AuthService {
|
||||
authorizeUrl: '$authBase/oauth2/authorize',
|
||||
tokenUrl: '$authBase/oauth2/token',
|
||||
redirectUri: _redirect,
|
||||
customUriScheme: 'flockmap',
|
||||
customUriScheme: 'deflockapp',
|
||||
);
|
||||
_helper = OAuth2Helper(
|
||||
client,
|
||||
@@ -56,7 +55,6 @@ class AuthService {
|
||||
enablePKCE: true,
|
||||
// tokenStorageKey: _tokenKey, // not supported by this package version
|
||||
);
|
||||
print('AuthService: Initialized for $mode with $authBase, clientId $clientId [manual token storage as needed]');
|
||||
}
|
||||
|
||||
Future<bool> isLoggedIn() async {
|
||||
@@ -81,17 +79,14 @@ class AuthService {
|
||||
|
||||
Future<String?> login() async {
|
||||
if (_mode == UploadMode.simulate) {
|
||||
print('AuthService: Simulate login (no OAuth)');
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_displayName = 'Demo User';
|
||||
await prefs.setBool('sim_user_logged_in', true);
|
||||
return _displayName;
|
||||
}
|
||||
try {
|
||||
print('AuthService: Starting OAuth login...');
|
||||
final token = await _helper.getToken();
|
||||
if (token?.accessToken == null) {
|
||||
print('AuthService: OAuth error - token null or missing accessToken');
|
||||
log('OAuth error: token null or missing accessToken');
|
||||
return null;
|
||||
}
|
||||
@@ -102,13 +97,7 @@ class AuthService {
|
||||
final tokenJson = jsonEncode(tokenMap);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_tokenKey, tokenJson); // Save token for current mode
|
||||
print('AuthService: Got access token, fetching username...');
|
||||
_displayName = await _fetchUsername(token!.accessToken!);
|
||||
if (_displayName != null) {
|
||||
print('AuthService: Successfully fetched username: $_displayName');
|
||||
} else {
|
||||
print('AuthService: Failed to fetch username from OSM API');
|
||||
}
|
||||
return _displayName;
|
||||
} catch (e) {
|
||||
print('AuthService: OAuth login failed: $e');
|
||||
@@ -132,7 +121,6 @@ class AuthService {
|
||||
|
||||
// Force a fresh login by clearing stored tokens
|
||||
Future<String?> forceLogin() async {
|
||||
print('AuthService: Forcing fresh login by clearing stored tokens...');
|
||||
await _helper.removeAllTokens();
|
||||
_displayName = null;
|
||||
return await login();
|
||||
@@ -163,37 +151,17 @@ class AuthService {
|
||||
|
||||
Future<String?> _fetchUsername(String accessToken) async {
|
||||
try {
|
||||
print('AuthService: Fetching username from OSM API ($_apiHost) ...');
|
||||
print('AuthService: Access token (first 20 chars): ${accessToken.substring(0, math.min(20, accessToken.length))}...');
|
||||
|
||||
final resp = await http.get(
|
||||
Uri.parse('$_apiHost/api/0.6/user/details.json'),
|
||||
headers: {'Authorization': 'Bearer $accessToken'},
|
||||
);
|
||||
print('AuthService: OSM API response status: ${resp.statusCode}');
|
||||
print('AuthService: Response headers: ${resp.headers}');
|
||||
|
||||
if (resp.statusCode != 200) {
|
||||
print('AuthService: fetchUsername failed with ${resp.statusCode}: ${resp.body}');
|
||||
log('fetchUsername response ${resp.statusCode}: ${resp.body}');
|
||||
|
||||
// Try to get more info about the token by checking permissions endpoint
|
||||
try {
|
||||
print('AuthService: Checking token permissions...');
|
||||
final permResp = await http.get(
|
||||
Uri.parse('$_apiHost/api/0.6/permissions.json'),
|
||||
headers: {'Authorization': 'Bearer $accessToken'},
|
||||
);
|
||||
print('AuthService: Permissions response ${permResp.statusCode}: ${permResp.body}');
|
||||
} catch (e) {
|
||||
print('AuthService: Error checking permissions: $e');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
final userData = jsonDecode(resp.body);
|
||||
final displayName = userData['user']?['display_name'];
|
||||
print('AuthService: Extracted display name: $displayName');
|
||||
return displayName;
|
||||
} catch (e) {
|
||||
print('AuthService: Error fetching username: $e');
|
||||
|
||||
@@ -13,7 +13,23 @@ class CameraCache {
|
||||
/// Add or update a batch of camera nodes in the cache.
|
||||
void addOrUpdate(List<OsmCameraNode> nodes) {
|
||||
for (var node in nodes) {
|
||||
_nodes[node.id] = node;
|
||||
final existing = _nodes[node.id];
|
||||
if (existing != null) {
|
||||
// Preserve any tags starting with underscore when updating existing nodes
|
||||
final mergedTags = Map<String, String>.from(node.tags);
|
||||
for (final entry in existing.tags.entries) {
|
||||
if (entry.key.startsWith('_')) {
|
||||
mergedTags[entry.key] = entry.value;
|
||||
}
|
||||
}
|
||||
_nodes[node.id] = OsmCameraNode(
|
||||
id: node.id,
|
||||
coord: node.coord,
|
||||
tags: mergedTags,
|
||||
);
|
||||
} else {
|
||||
_nodes[node.id] = node;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
131
lib/services/localization_service.dart
Normal file
131
lib/services/localization_service.dart
Normal file
@@ -0,0 +1,131 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class LocalizationService extends ChangeNotifier {
|
||||
static LocalizationService? _instance;
|
||||
static LocalizationService get instance => _instance ??= LocalizationService._();
|
||||
|
||||
LocalizationService._();
|
||||
|
||||
String _currentLanguage = 'en';
|
||||
Map<String, dynamic> _strings = {};
|
||||
List<String> _availableLanguages = [];
|
||||
|
||||
String get currentLanguage => _currentLanguage;
|
||||
List<String> get availableLanguages => _availableLanguages;
|
||||
|
||||
Future<void> init() async {
|
||||
await _discoverAvailableLanguages();
|
||||
await _loadSavedLanguage();
|
||||
await _loadStrings();
|
||||
}
|
||||
|
||||
Future<void> _discoverAvailableLanguages() async {
|
||||
// For now, we'll hardcode the languages we support
|
||||
// In the future, this could scan the assets directory
|
||||
_availableLanguages = ['en', 'es', 'fr', 'de'];
|
||||
}
|
||||
|
||||
Future<void> _loadSavedLanguage() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final savedLanguage = prefs.getString('language_code');
|
||||
|
||||
if (savedLanguage != null && _availableLanguages.contains(savedLanguage)) {
|
||||
_currentLanguage = savedLanguage;
|
||||
} else {
|
||||
// Use system default or fallback to English
|
||||
final systemLocale = Platform.localeName.split('_')[0];
|
||||
if (_availableLanguages.contains(systemLocale)) {
|
||||
_currentLanguage = systemLocale;
|
||||
} else {
|
||||
_currentLanguage = 'en';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadStrings() async {
|
||||
try {
|
||||
final String jsonString = await rootBundle.loadString('lib/localizations/$_currentLanguage.json');
|
||||
_strings = json.decode(jsonString);
|
||||
} catch (e) {
|
||||
// Fallback to English if the language file doesn't exist
|
||||
if (_currentLanguage != 'en') {
|
||||
try {
|
||||
final String fallbackString = await rootBundle.loadString('lib/localizations/en.json');
|
||||
_strings = json.decode(fallbackString);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to load fallback language file: $e');
|
||||
_strings = {};
|
||||
}
|
||||
} else {
|
||||
debugPrint('Failed to load language file for $_currentLanguage: $e');
|
||||
_strings = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setLanguage(String? languageCode) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
if (languageCode == null) {
|
||||
// System default
|
||||
await prefs.remove('language_code');
|
||||
final systemLocale = Platform.localeName.split('_')[0];
|
||||
_currentLanguage = _availableLanguages.contains(systemLocale) ? systemLocale : 'en';
|
||||
} else {
|
||||
await prefs.setString('language_code', languageCode);
|
||||
_currentLanguage = languageCode;
|
||||
}
|
||||
|
||||
await _loadStrings();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
String t(String key, {List<String>? params}) {
|
||||
List<String> keys = key.split('.');
|
||||
dynamic current = _strings;
|
||||
|
||||
for (String k in keys) {
|
||||
if (current is Map && current.containsKey(k)) {
|
||||
current = current[k];
|
||||
} else {
|
||||
// Return the key as fallback for missing translations
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
String result = current is String ? current : key;
|
||||
|
||||
// Replace parameters if provided
|
||||
if (params != null) {
|
||||
for (int i = 0; i < params.length; i++) {
|
||||
result = result.replaceAll('{}', params[i]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get display name for a specific language code
|
||||
Future<String> getLanguageDisplayName(String languageCode) async {
|
||||
try {
|
||||
final String jsonString = await rootBundle.loadString('lib/localizations/$languageCode.json');
|
||||
final Map<String, dynamic> langData = json.decode(jsonString);
|
||||
return langData['language']?['name'] ?? languageCode.toUpperCase();
|
||||
} catch (e) {
|
||||
return languageCode.toUpperCase(); // Fallback to language code
|
||||
}
|
||||
}
|
||||
|
||||
// Helper methods for common strings
|
||||
String get appTitle => t('app.title');
|
||||
String get settings => t('actions.settings');
|
||||
String get tagNode => t('actions.tagNode');
|
||||
String get download => t('actions.download');
|
||||
String get edit => t('actions.edit');
|
||||
String get cancel => t('actions.cancel');
|
||||
String get ok => t('actions.ok');
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/camera_profile.dart';
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/osm_camera_node.dart';
|
||||
import '../app_state.dart';
|
||||
import 'map_data_submodules/cameras_from_overpass.dart';
|
||||
import 'map_data_submodules/tiles_from_osm.dart';
|
||||
import 'map_data_submodules/cameras_from_local.dart';
|
||||
import 'map_data_submodules/nodes_from_overpass.dart';
|
||||
import 'map_data_submodules/tiles_from_remote.dart';
|
||||
import 'map_data_submodules/nodes_from_local.dart';
|
||||
import 'map_data_submodules/tiles_from_local.dart';
|
||||
|
||||
enum MapSource { local, remote, auto } // For future use
|
||||
@@ -30,35 +31,32 @@ class MapDataProvider {
|
||||
AppState.instance.setOfflineMode(enabled);
|
||||
}
|
||||
|
||||
/// Fetch cameras from OSM/Overpass or local storage.
|
||||
/// Fetch surveillance nodes from OSM/Overpass or local storage.
|
||||
/// Remote is default. If source is MapSource.auto, remote is tried first unless offline.
|
||||
Future<List<OsmCameraNode>> getCameras({
|
||||
Future<List<OsmCameraNode>> getNodes({
|
||||
required LatLngBounds bounds,
|
||||
required List<CameraProfile> profiles,
|
||||
required List<NodeProfile> profiles,
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
MapSource source = MapSource.auto,
|
||||
}) async {
|
||||
final offline = AppState.instance.offlineMode;
|
||||
print('[MapDataProvider] getCameras called, source=$source, offlineMode=$offline');
|
||||
|
||||
// Explicit remote request: error if offline, else always remote
|
||||
if (source == MapSource.remote) {
|
||||
if (offline) {
|
||||
print('[MapDataProvider] Overpass request BLOCKED because we are in offlineMode');
|
||||
throw OfflineModeException("Cannot fetch remote cameras in offline mode.");
|
||||
throw OfflineModeException("Cannot fetch remote nodes in offline mode.");
|
||||
}
|
||||
return camerasFromOverpass(
|
||||
return fetchOverpassNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
pageSize: AppState.instance.maxCameras,
|
||||
fetchAllPages: false,
|
||||
maxResults: AppState.instance.maxCameras,
|
||||
);
|
||||
}
|
||||
|
||||
// Explicit local request: always use local
|
||||
if (source == MapSource.local) {
|
||||
return fetchLocalCameras(
|
||||
return fetchLocalNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
);
|
||||
@@ -66,50 +64,49 @@ class MapDataProvider {
|
||||
|
||||
// AUTO: default = remote first, fallback to local only if offline
|
||||
if (offline) {
|
||||
return fetchLocalCameras(
|
||||
return fetchLocalNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
maxNodes: AppState.instance.maxCameras,
|
||||
);
|
||||
} else {
|
||||
// Try remote, fallback to local ONLY if remote throws (optional, could be removed for stricter behavior)
|
||||
try {
|
||||
return await camerasFromOverpass(
|
||||
return await fetchOverpassNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
pageSize: AppState.instance.maxCameras,
|
||||
maxResults: AppState.instance.maxCameras,
|
||||
);
|
||||
} catch (e) {
|
||||
print('[MapDataProvider] Remote camera fetch failed, error: $e. Falling back to local.');
|
||||
return fetchLocalCameras(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
maxCameras: AppState.instance.maxCameras,
|
||||
);
|
||||
debugPrint('[MapDataProvider] Remote node fetch failed, error: $e. Falling back to local.');
|
||||
return fetchLocalNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
maxNodes: AppState.instance.maxCameras,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bulk/paged camera fetch for offline downloads (handling paging, dedup, and Overpass retries)
|
||||
/// Bulk/paged node fetch for offline downloads (handling paging, dedup, and Overpass retries)
|
||||
/// Only use for offline area download, not for map browsing! Ignores maxCameras config.
|
||||
Future<List<OsmCameraNode>> getAllCamerasForDownload({
|
||||
Future<List<OsmCameraNode>> getAllNodesForDownload({
|
||||
required LatLngBounds bounds,
|
||||
required List<CameraProfile> profiles,
|
||||
required List<NodeProfile> profiles,
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
int pageSize = 500,
|
||||
int maxTries = 3,
|
||||
}) async {
|
||||
final offline = AppState.instance.offlineMode;
|
||||
if (offline) {
|
||||
throw OfflineModeException("Cannot fetch remote cameras for offline area download in offline mode.");
|
||||
throw OfflineModeException("Cannot fetch remote nodes for offline area download in offline mode.");
|
||||
}
|
||||
return camerasFromOverpass(
|
||||
return fetchOverpassNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
fetchAllPages: true,
|
||||
pageSize: pageSize,
|
||||
maxTries: maxTries,
|
||||
maxResults: pageSize,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -121,15 +118,13 @@ class MapDataProvider {
|
||||
MapSource source = MapSource.auto,
|
||||
}) async {
|
||||
final offline = AppState.instance.offlineMode;
|
||||
print('[MapDataProvider] getTile called for $z/$x/$y, source=$source, offlineMode=$offline');
|
||||
|
||||
// Explicitly remote
|
||||
if (source == MapSource.remote) {
|
||||
if (offline) {
|
||||
print('[MapDataProvider] BLOCKED by offlineMode for remote tile fetch');
|
||||
throw OfflineModeException("Cannot fetch remote tiles in offline mode.");
|
||||
}
|
||||
return fetchOSMTile(z: z, x: x, y: y);
|
||||
return _fetchRemoteTileFromCurrentProvider(z, x, y);
|
||||
}
|
||||
|
||||
// Explicitly local
|
||||
@@ -142,10 +137,30 @@ class MapDataProvider {
|
||||
return await fetchLocalTile(z: z, x: x, y: y);
|
||||
} catch (_) {
|
||||
if (!offline) {
|
||||
return fetchOSMTile(z: z, x: x, y: y);
|
||||
return _fetchRemoteTileFromCurrentProvider(z, x, y);
|
||||
} else {
|
||||
throw OfflineModeException("Tile $z/$x/$y not found in offline areas and offline mode is enabled.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch remote tile using current provider from AppState
|
||||
Future<List<int>> _fetchRemoteTileFromCurrentProvider(int z, int x, int y) async {
|
||||
final appState = AppState.instance;
|
||||
final selectedTileType = appState.selectedTileType;
|
||||
final selectedProvider = appState.selectedTileProvider;
|
||||
|
||||
// We guarantee that a provider and tile type are always selected
|
||||
if (selectedTileType == null || selectedProvider == null) {
|
||||
throw Exception('No tile provider selected - this should never happen');
|
||||
}
|
||||
|
||||
final tileUrl = selectedTileType.getTileUrl(z, x, y, apiKey: selectedProvider.apiKey);
|
||||
return fetchRemoteTile(z: z, x: x, y: y, url: tileUrl);
|
||||
}
|
||||
|
||||
/// Clear any queued tile requests (call when map view changes significantly)
|
||||
void clearTileQueue() {
|
||||
clearRemoteTileQueue();
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
|
||||
import '../../models/camera_profile.dart';
|
||||
import '../../models/osm_camera_node.dart';
|
||||
import '../../app_state.dart';
|
||||
|
||||
/// Fetches cameras from the Overpass OSM API for the given bounds and profiles.
|
||||
/// If fetchAllPages is true, returns all possible cameras using multiple API calls (paging with pageSize).
|
||||
/// If false (the default), returns only the first page of up to pageSize results.
|
||||
Future<List<OsmCameraNode>> camerasFromOverpass({
|
||||
required LatLngBounds bounds,
|
||||
required List<CameraProfile> profiles,
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
int pageSize = 500, // Used for both default limit and paging chunk
|
||||
bool fetchAllPages = false, // True for offline area download, else just grabs first chunk
|
||||
int maxTries = 3,
|
||||
}) async {
|
||||
if (profiles.isEmpty) return [];
|
||||
const String prodEndpoint = 'https://overpass-api.de/api/interpreter';
|
||||
|
||||
final nodeClauses = profiles.map((profile) {
|
||||
final tagFilters = profile.tags.entries
|
||||
.map((e) => '["${e.key}"="${e.value}"]')
|
||||
.join('\n ');
|
||||
return '''node\n $tagFilters\n (${bounds.southWest.latitude},${bounds.southWest.longitude},\n ${bounds.northEast.latitude},${bounds.northEast.longitude});''';
|
||||
}).join('\n ');
|
||||
|
||||
// Helper for one Overpass chunk fetch
|
||||
Future<List<OsmCameraNode>> fetchChunk() async {
|
||||
final outLine = fetchAllPages ? 'out body;' : 'out body $pageSize;';
|
||||
final query = '''
|
||||
[out:json][timeout:25];
|
||||
(
|
||||
$nodeClauses
|
||||
);
|
||||
$outLine
|
||||
''';
|
||||
try {
|
||||
print('[camerasFromOverpass] Querying Overpass...');
|
||||
print('[camerasFromOverpass] Query:\n$query');
|
||||
final resp = await http.post(Uri.parse(prodEndpoint), body: {'data': query.trim()});
|
||||
print('[camerasFromOverpass] Status: ${resp.statusCode}, Length: ${resp.body.length}');
|
||||
if (resp.statusCode != 200) {
|
||||
print('[camerasFromOverpass] Overpass failed: ${resp.body}');
|
||||
return [];
|
||||
}
|
||||
final data = jsonDecode(resp.body) as Map<String, dynamic>;
|
||||
final elements = data['elements'] as List<dynamic>;
|
||||
print('[camerasFromOverpass] Retrieved elements: ${elements.length}');
|
||||
return elements.whereType<Map<String, dynamic>>().map((e) {
|
||||
return OsmCameraNode(
|
||||
id: e['id'],
|
||||
coord: LatLng(e['lat'], e['lon']),
|
||||
tags: Map<String, String>.from(e['tags'] ?? {}),
|
||||
);
|
||||
}).toList();
|
||||
} catch (e) {
|
||||
print('[camerasFromOverpass] Overpass exception: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// All paths just use a single fetch now; paging logic no longer required.
|
||||
return await fetchChunk();
|
||||
}
|
||||
@@ -3,15 +3,15 @@ import 'dart:convert';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
import '../../models/osm_camera_node.dart';
|
||||
import '../../models/camera_profile.dart';
|
||||
import '../../models/node_profile.dart';
|
||||
import '../offline_area_service.dart';
|
||||
import '../offline_areas/offline_area_models.dart';
|
||||
|
||||
/// Fetch camera nodes from all offline areas intersecting the bounds/profile list.
|
||||
Future<List<OsmCameraNode>> fetchLocalCameras({
|
||||
/// Fetch surveillance nodes from all offline areas intersecting the bounds/profile list.
|
||||
Future<List<OsmCameraNode>> fetchLocalNodes({
|
||||
required LatLngBounds bounds,
|
||||
required List<CameraProfile> profiles,
|
||||
int? maxCameras,
|
||||
required List<NodeProfile> profiles,
|
||||
int? maxNodes,
|
||||
}) async {
|
||||
final areas = OfflineAreaService().offlineAreas;
|
||||
final Map<int, OsmCameraNode> deduped = {};
|
||||
@@ -20,24 +20,24 @@ Future<List<OsmCameraNode>> fetchLocalCameras({
|
||||
if (area.status != OfflineAreaStatus.complete) continue;
|
||||
if (!area.bounds.isOverlapping(bounds)) continue;
|
||||
|
||||
final nodes = await _loadAreaCameras(area);
|
||||
for (final cam in nodes) {
|
||||
// Deduplicate by camera ID, preferring the first occurrence
|
||||
if (deduped.containsKey(cam.id)) continue;
|
||||
final nodes = await _loadAreaNodes(area);
|
||||
for (final node in nodes) {
|
||||
// Deduplicate by node ID, preferring the first occurrence
|
||||
if (deduped.containsKey(node.id)) continue;
|
||||
// Within view bounds?
|
||||
if (!_pointInBounds(cam.coord, bounds)) continue;
|
||||
if (!_pointInBounds(node.coord, bounds)) continue;
|
||||
// Profile filter if used
|
||||
if (profiles.isNotEmpty && !_matchesAnyProfile(cam, profiles)) continue;
|
||||
deduped[cam.id] = cam;
|
||||
if (profiles.isNotEmpty && !_matchesAnyProfile(node, profiles)) continue;
|
||||
deduped[node.id] = node;
|
||||
}
|
||||
}
|
||||
|
||||
final out = deduped.values.take(maxCameras ?? deduped.length).toList();
|
||||
final out = deduped.values.take(maxNodes ?? deduped.length).toList();
|
||||
return out;
|
||||
}
|
||||
|
||||
// Try in-memory first, else load from disk
|
||||
Future<List<OsmCameraNode>> _loadAreaCameras(OfflineArea area) async {
|
||||
Future<List<OsmCameraNode>> _loadAreaNodes(OfflineArea area) async {
|
||||
if (area.cameras.isNotEmpty) {
|
||||
return area.cameras;
|
||||
}
|
||||
@@ -57,16 +57,16 @@ bool _pointInBounds(LatLng pt, LatLngBounds bounds) {
|
||||
pt.longitude <= bounds.northEast.longitude;
|
||||
}
|
||||
|
||||
bool _matchesAnyProfile(OsmCameraNode cam, List<CameraProfile> profiles) {
|
||||
bool _matchesAnyProfile(OsmCameraNode node, List<NodeProfile> profiles) {
|
||||
for (final prof in profiles) {
|
||||
if (_cameraMatchesProfile(cam, prof)) return true;
|
||||
if (_nodeMatchesProfile(node, prof)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _cameraMatchesProfile(OsmCameraNode cam, CameraProfile profile) {
|
||||
bool _nodeMatchesProfile(OsmCameraNode node, NodeProfile profile) {
|
||||
for (final e in profile.tags.entries) {
|
||||
if (cam.tags[e.key] != e.value) return false; // All profile tags must match
|
||||
if (node.tags[e.key] != e.value) return false; // All profile tags must match
|
||||
}
|
||||
return true;
|
||||
}
|
||||
92
lib/services/map_data_submodules/nodes_from_overpass.dart
Normal file
92
lib/services/map_data_submodules/nodes_from_overpass.dart
Normal file
@@ -0,0 +1,92 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
|
||||
import '../../models/node_profile.dart';
|
||||
import '../../models/osm_camera_node.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../network_status.dart';
|
||||
|
||||
/// Fetches surveillance nodes from the Overpass OSM API for the given bounds and profiles.
|
||||
Future<List<OsmCameraNode>> fetchOverpassNodes({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
required int maxResults,
|
||||
}) async {
|
||||
if (profiles.isEmpty) return [];
|
||||
|
||||
const String overpassEndpoint = 'https://overpass-api.de/api/interpreter';
|
||||
|
||||
// Build the Overpass query
|
||||
final query = _buildOverpassQuery(bounds, profiles, maxResults);
|
||||
|
||||
try {
|
||||
debugPrint('[fetchOverpassNodes] Querying Overpass for surveillance nodes...');
|
||||
debugPrint('[fetchOverpassNodes] Query:\n$query');
|
||||
|
||||
final response = await http.post(
|
||||
Uri.parse(overpassEndpoint),
|
||||
body: {'data': query.trim()}
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
debugPrint('[fetchOverpassNodes] Overpass API error: ${response.body}');
|
||||
NetworkStatus.instance.reportOverpassIssue();
|
||||
return [];
|
||||
}
|
||||
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final elements = data['elements'] as List<dynamic>;
|
||||
|
||||
if (elements.length > 20) {
|
||||
debugPrint('[fetchOverpassNodes] Retrieved ${elements.length} surveillance nodes');
|
||||
}
|
||||
|
||||
NetworkStatus.instance.reportOverpassSuccess();
|
||||
|
||||
return elements.whereType<Map<String, dynamic>>().map((element) {
|
||||
return OsmCameraNode(
|
||||
id: element['id'],
|
||||
coord: LatLng(element['lat'], element['lon']),
|
||||
tags: Map<String, String>.from(element['tags'] ?? {}),
|
||||
);
|
||||
}).toList();
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[fetchOverpassNodes] Exception: $e');
|
||||
|
||||
// Report network issues for connection errors
|
||||
if (e.toString().contains('Connection refused') ||
|
||||
e.toString().contains('Connection timed out') ||
|
||||
e.toString().contains('Connection reset')) {
|
||||
NetworkStatus.instance.reportOverpassIssue();
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds an Overpass API query for surveillance nodes matching the given profiles within bounds.
|
||||
String _buildOverpassQuery(LatLngBounds bounds, List<NodeProfile> profiles, int maxResults) {
|
||||
// Build node clauses for each profile
|
||||
final nodeClauses = profiles.map((profile) {
|
||||
// Convert profile tags to Overpass filter format
|
||||
final tagFilters = profile.tags.entries
|
||||
.map((entry) => '["${entry.key}"="${entry.value}"]')
|
||||
.join();
|
||||
|
||||
// Build the node query with tag filters and bounding box
|
||||
return 'node$tagFilters(${bounds.southWest.latitude},${bounds.southWest.longitude},${bounds.northEast.latitude},${bounds.northEast.longitude});';
|
||||
}).join('\n ');
|
||||
|
||||
return '''
|
||||
[out:json][timeout:25];
|
||||
(
|
||||
$nodeClauses
|
||||
);
|
||||
out body $maxResults;
|
||||
''';
|
||||
}
|
||||
@@ -3,15 +3,25 @@ import 'package:latlong2/latlong.dart';
|
||||
import '../offline_area_service.dart';
|
||||
import '../offline_areas/offline_area_models.dart';
|
||||
import '../offline_areas/offline_tile_utils.dart';
|
||||
import '../../app_state.dart';
|
||||
|
||||
/// Fetch a tile from the newest offline area that plausibly contains it, or throw if not found.
|
||||
/// Fetch a tile from the newest offline area that matches the current provider, or throw if not found.
|
||||
Future<List<int>> fetchLocalTile({required int z, required int x, required int y}) async {
|
||||
final areas = OfflineAreaService().offlineAreas;
|
||||
final appState = AppState.instance;
|
||||
final currentProvider = appState.selectedTileProvider;
|
||||
final currentTileType = appState.selectedTileType;
|
||||
|
||||
final offlineService = OfflineAreaService();
|
||||
await offlineService.ensureInitialized();
|
||||
final areas = offlineService.offlineAreas;
|
||||
final List<_AreaTileMatch> candidates = [];
|
||||
|
||||
for (final area in areas) {
|
||||
if (area.status != OfflineAreaStatus.complete) continue;
|
||||
if (z < area.minZoom || z > area.maxZoom) continue;
|
||||
|
||||
// Only consider areas that match the current provider/type
|
||||
if (area.tileProviderId != currentProvider?.id || area.tileTypeId != currentTileType?.id) continue;
|
||||
|
||||
// Get tile coverage for area at this zoom only
|
||||
final coveredTiles = computeTileList(area.bounds, z, z);
|
||||
@@ -26,7 +36,7 @@ Future<List<int>> fetchLocalTile({required int z, required int x, required int y
|
||||
}
|
||||
}
|
||||
if (candidates.isEmpty) {
|
||||
throw Exception('Tile $z/$x/$y not found in any offline area');
|
||||
throw Exception('Tile $z/$x/$y from current provider ${currentProvider?.id}/${currentTileType?.id} not found in any offline area');
|
||||
}
|
||||
candidates.sort((a, b) => b.modified.compareTo(a.modified)); // newest first
|
||||
return await candidates.first.file.readAsBytes();
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import 'dart:math';
|
||||
import 'dart:io';
|
||||
import 'dart:async';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flock_map_app/dev_config.dart';
|
||||
|
||||
/// Global semaphore to limit simultaneous tile fetches
|
||||
final _tileFetchSemaphore = _SimpleSemaphore(4); // Max 4 concurrent
|
||||
|
||||
/// Fetches a tile from OSM, with in-memory retries/backoff, and global concurrency limit.
|
||||
/// Returns tile image bytes, or throws on persistent failure.
|
||||
Future<List<int>> fetchOSMTile({
|
||||
required int z,
|
||||
required int x,
|
||||
required int y,
|
||||
}) async {
|
||||
final url = 'https://tile.openstreetmap.org/$z/$x/$y.png';
|
||||
const int maxAttempts = kTileFetchMaxAttempts;
|
||||
int attempt = 0;
|
||||
final random = Random();
|
||||
final delays = [
|
||||
kTileFetchInitialDelayMs + random.nextInt(kTileFetchJitter1Ms),
|
||||
kTileFetchSecondDelayMs + random.nextInt(kTileFetchJitter2Ms),
|
||||
kTileFetchThirdDelayMs + random.nextInt(kTileFetchJitter3Ms),
|
||||
];
|
||||
while (true) {
|
||||
await _tileFetchSemaphore.acquire();
|
||||
try {
|
||||
print('[fetchOSMTile] FETCH $z/$x/$y');
|
||||
attempt++;
|
||||
final resp = await http.get(Uri.parse(url));
|
||||
print('[fetchOSMTile] HTTP ${resp.statusCode} for $z/$x/$y, length=${resp.bodyBytes.length}');
|
||||
if (resp.statusCode == 200 && resp.bodyBytes.isNotEmpty) {
|
||||
print('[fetchOSMTile] SUCCESS $z/$x/$y');
|
||||
return resp.bodyBytes;
|
||||
} else {
|
||||
print('[fetchOSMTile] FAIL $z/$x/$y: code=${resp.statusCode}, bytes=${resp.bodyBytes.length}');
|
||||
throw HttpException('Failed to fetch tile $z/$x/$y: status ${resp.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
print('[fetchOSMTile] Exception $z/$x/$y: $e');
|
||||
if (attempt >= maxAttempts) {
|
||||
print("[fetchOSMTile] Failed for $z/$x/$y after $attempt attempts: $e");
|
||||
rethrow;
|
||||
}
|
||||
final delay = delays[attempt - 1].clamp(0, 60000);
|
||||
print("[fetchOSMTile] Attempt $attempt for $z/$x/$y failed: $e. Retrying in ${delay}ms.");
|
||||
await Future.delayed(Duration(milliseconds: delay));
|
||||
} finally {
|
||||
_tileFetchSemaphore.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple counting semaphore, suitable for single-thread Flutter concurrency
|
||||
class _SimpleSemaphore {
|
||||
final int _max;
|
||||
int _current = 0;
|
||||
final List<VoidCallback> _queue = [];
|
||||
_SimpleSemaphore(this._max);
|
||||
|
||||
Future<void> acquire() async {
|
||||
if (_current < _max) {
|
||||
_current++;
|
||||
return;
|
||||
} else {
|
||||
final c = Completer<void>();
|
||||
_queue.add(() => c.complete());
|
||||
await c.future;
|
||||
}
|
||||
}
|
||||
|
||||
void release() {
|
||||
if (_queue.isNotEmpty) {
|
||||
final callback = _queue.removeAt(0);
|
||||
callback();
|
||||
} else {
|
||||
_current--;
|
||||
}
|
||||
}
|
||||
}
|
||||
133
lib/services/map_data_submodules/tiles_from_remote.dart
Normal file
133
lib/services/map_data_submodules/tiles_from_remote.dart
Normal file
@@ -0,0 +1,133 @@
|
||||
import 'dart:math';
|
||||
import 'dart:io';
|
||||
import 'dart:async';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:deflockapp/dev_config.dart';
|
||||
import '../network_status.dart';
|
||||
|
||||
/// Global semaphore to limit simultaneous tile fetches
|
||||
final _tileFetchSemaphore = _SimpleSemaphore(4); // Max 4 concurrent
|
||||
|
||||
/// Clear queued tile requests when map view changes significantly
|
||||
void clearRemoteTileQueue() {
|
||||
final clearedCount = _tileFetchSemaphore.clearQueue();
|
||||
// Only log if we actually cleared something significant
|
||||
if (clearedCount > 5) {
|
||||
debugPrint('[RemoteTiles] Cleared $clearedCount queued tile requests');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Fetches a tile from any remote provider, with in-memory retries/backoff, and global concurrency limit.
|
||||
/// Returns tile image bytes, or throws on persistent failure.
|
||||
Future<List<int>> fetchRemoteTile({
|
||||
required int z,
|
||||
required int x,
|
||||
required int y,
|
||||
required String url,
|
||||
}) async {
|
||||
const int maxAttempts = kTileFetchMaxAttempts;
|
||||
int attempt = 0;
|
||||
final random = Random();
|
||||
final delays = [
|
||||
kTileFetchInitialDelayMs + random.nextInt(kTileFetchJitter1Ms),
|
||||
kTileFetchSecondDelayMs + random.nextInt(kTileFetchJitter2Ms),
|
||||
kTileFetchThirdDelayMs + random.nextInt(kTileFetchJitter3Ms),
|
||||
];
|
||||
|
||||
final hostInfo = Uri.parse(url).host; // For logging
|
||||
|
||||
while (true) {
|
||||
await _tileFetchSemaphore.acquire();
|
||||
try {
|
||||
// Only log on first attempt or errors
|
||||
if (attempt == 1) {
|
||||
debugPrint('[fetchRemoteTile] Fetching $z/$x/$y from $hostInfo');
|
||||
}
|
||||
attempt++;
|
||||
final resp = await http.get(Uri.parse(url));
|
||||
|
||||
if (resp.statusCode == 200 && resp.bodyBytes.isNotEmpty) {
|
||||
// Success - no logging for normal operation
|
||||
NetworkStatus.instance.reportOsmTileSuccess(); // Generic tile server reporting
|
||||
return resp.bodyBytes;
|
||||
} else {
|
||||
debugPrint('[fetchRemoteTile] FAIL $z/$x/$y from $hostInfo: code=${resp.statusCode}, bytes=${resp.bodyBytes.length}');
|
||||
NetworkStatus.instance.reportOsmTileIssue(); // Generic tile server reporting
|
||||
throw HttpException('Failed to fetch tile $z/$x/$y from $hostInfo: status ${resp.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
// Report network issues on connection errors
|
||||
if (e.toString().contains('Connection refused') ||
|
||||
e.toString().contains('Connection timed out') ||
|
||||
e.toString().contains('Connection reset')) {
|
||||
NetworkStatus.instance.reportOsmTileIssue(); // Generic tile server reporting
|
||||
}
|
||||
|
||||
if (attempt >= maxAttempts) {
|
||||
debugPrint("[fetchRemoteTile] Failed for $z/$x/$y from $hostInfo after $attempt attempts: $e");
|
||||
rethrow;
|
||||
}
|
||||
|
||||
final delay = delays[attempt - 1].clamp(0, 60000);
|
||||
if (attempt == 1) {
|
||||
debugPrint("[fetchRemoteTile] Attempt $attempt for $z/$x/$y from $hostInfo failed: $e. Retrying in ${delay}ms.");
|
||||
}
|
||||
await Future.delayed(Duration(milliseconds: delay));
|
||||
} finally {
|
||||
_tileFetchSemaphore.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy function for backward compatibility
|
||||
@Deprecated('Use fetchRemoteTile instead')
|
||||
Future<List<int>> fetchOSMTile({
|
||||
required int z,
|
||||
required int x,
|
||||
required int y,
|
||||
}) async {
|
||||
return fetchRemoteTile(
|
||||
z: z,
|
||||
x: x,
|
||||
y: y,
|
||||
url: 'https://tile.openstreetmap.org/$z/$x/$y.png',
|
||||
);
|
||||
}
|
||||
|
||||
/// Simple counting semaphore, suitable for single-thread Flutter concurrency
|
||||
class _SimpleSemaphore {
|
||||
final int _max;
|
||||
int _current = 0;
|
||||
final List<VoidCallback> _queue = [];
|
||||
_SimpleSemaphore(this._max);
|
||||
|
||||
Future<void> acquire() async {
|
||||
if (_current < _max) {
|
||||
_current++;
|
||||
return;
|
||||
} else {
|
||||
final c = Completer<void>();
|
||||
_queue.add(() => c.complete());
|
||||
await c.future;
|
||||
}
|
||||
}
|
||||
|
||||
void release() {
|
||||
if (_queue.isNotEmpty) {
|
||||
final callback = _queue.removeAt(0);
|
||||
callback();
|
||||
} else {
|
||||
_current--;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all queued requests (call when view changes significantly)
|
||||
int clearQueue() {
|
||||
final clearedCount = _queue.length;
|
||||
_queue.clear();
|
||||
return clearedCount;
|
||||
}
|
||||
}
|
||||
217
lib/services/network_status.dart
Normal file
217
lib/services/network_status.dart
Normal file
@@ -0,0 +1,217 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:async';
|
||||
|
||||
import '../app_state.dart';
|
||||
|
||||
enum NetworkIssueType { osmTiles, overpassApi, both }
|
||||
enum NetworkStatusType { waiting, issues, timedOut, noData, ready, success }
|
||||
|
||||
class NetworkStatus extends ChangeNotifier {
|
||||
static final NetworkStatus instance = NetworkStatus._();
|
||||
NetworkStatus._();
|
||||
|
||||
bool _osmTilesHaveIssues = false;
|
||||
bool _overpassHasIssues = false;
|
||||
bool _isWaitingForData = false;
|
||||
bool _isTimedOut = false;
|
||||
bool _hasNoData = false;
|
||||
bool _hasSuccess = false;
|
||||
int _recentOfflineMisses = 0;
|
||||
Timer? _osmRecoveryTimer;
|
||||
Timer? _overpassRecoveryTimer;
|
||||
Timer? _waitingTimer;
|
||||
Timer? _noDataResetTimer;
|
||||
Timer? _successResetTimer;
|
||||
|
||||
// Getters
|
||||
bool get hasAnyIssues => _osmTilesHaveIssues || _overpassHasIssues;
|
||||
bool get osmTilesHaveIssues => _osmTilesHaveIssues;
|
||||
bool get overpassHasIssues => _overpassHasIssues;
|
||||
bool get isWaitingForData => _isWaitingForData;
|
||||
bool get isTimedOut => _isTimedOut;
|
||||
bool get hasNoData => _hasNoData;
|
||||
bool get hasSuccess => _hasSuccess;
|
||||
|
||||
NetworkStatusType get currentStatus {
|
||||
if (hasAnyIssues) return NetworkStatusType.issues;
|
||||
if (_isWaitingForData) return NetworkStatusType.waiting;
|
||||
if (_isTimedOut) return NetworkStatusType.timedOut;
|
||||
if (_hasNoData) return NetworkStatusType.noData;
|
||||
if (_hasSuccess) return NetworkStatusType.success;
|
||||
return NetworkStatusType.ready;
|
||||
}
|
||||
|
||||
NetworkIssueType? get currentIssueType {
|
||||
if (_osmTilesHaveIssues && _overpassHasIssues) return NetworkIssueType.both;
|
||||
if (_osmTilesHaveIssues) return NetworkIssueType.osmTiles;
|
||||
if (_overpassHasIssues) return NetworkIssueType.overpassApi;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Report tile server issues (for any provider)
|
||||
void reportOsmTileIssue() {
|
||||
if (!_osmTilesHaveIssues) {
|
||||
_osmTilesHaveIssues = true;
|
||||
notifyListeners();
|
||||
debugPrint('[NetworkStatus] Tile server issues detected');
|
||||
}
|
||||
|
||||
// Reset recovery timer - if we keep getting errors, keep showing indicator
|
||||
_osmRecoveryTimer?.cancel();
|
||||
_osmRecoveryTimer = Timer(const Duration(minutes: 2), () {
|
||||
_osmTilesHaveIssues = false;
|
||||
notifyListeners();
|
||||
debugPrint('[NetworkStatus] Tile server issues cleared');
|
||||
});
|
||||
}
|
||||
|
||||
/// Report Overpass API issues
|
||||
void reportOverpassIssue() {
|
||||
if (!_overpassHasIssues) {
|
||||
_overpassHasIssues = true;
|
||||
notifyListeners();
|
||||
debugPrint('[NetworkStatus] Overpass API issues detected');
|
||||
}
|
||||
|
||||
// Reset recovery timer
|
||||
_overpassRecoveryTimer?.cancel();
|
||||
_overpassRecoveryTimer = Timer(const Duration(minutes: 2), () {
|
||||
_overpassHasIssues = false;
|
||||
notifyListeners();
|
||||
debugPrint('[NetworkStatus] Overpass API issues cleared');
|
||||
});
|
||||
}
|
||||
|
||||
/// Report successful operations to potentially clear issues faster
|
||||
void reportOsmTileSuccess() {
|
||||
// Clear issues immediately on success (they were likely temporary)
|
||||
if (_osmTilesHaveIssues) {
|
||||
// Quietly clear - don't log routine success
|
||||
_osmTilesHaveIssues = false;
|
||||
_osmRecoveryTimer?.cancel();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void reportOverpassSuccess() {
|
||||
if (_overpassHasIssues) {
|
||||
// Quietly clear - don't log routine success
|
||||
_overpassHasIssues = false;
|
||||
_overpassRecoveryTimer?.cancel();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Set waiting status (show when loading tiles/cameras)
|
||||
void setWaiting() {
|
||||
// Clear any previous timeout/no-data state when starting new wait
|
||||
_isTimedOut = false;
|
||||
_hasNoData = false;
|
||||
_recentOfflineMisses = 0;
|
||||
_noDataResetTimer?.cancel();
|
||||
|
||||
if (!_isWaitingForData) {
|
||||
_isWaitingForData = true;
|
||||
notifyListeners();
|
||||
// Don't log routine waiting - only log if we stay waiting too long
|
||||
}
|
||||
|
||||
// Set timeout for genuine network issues (not 404s)
|
||||
_waitingTimer?.cancel();
|
||||
_waitingTimer = Timer(const Duration(seconds: 8), () {
|
||||
_isWaitingForData = false;
|
||||
_isTimedOut = true;
|
||||
debugPrint('[NetworkStatus] Request timed out - likely network issues');
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
/// Show success status briefly when data loads
|
||||
void setSuccess() {
|
||||
_isWaitingForData = false;
|
||||
_isTimedOut = false;
|
||||
_hasNoData = false;
|
||||
_hasSuccess = true;
|
||||
_recentOfflineMisses = 0;
|
||||
_waitingTimer?.cancel();
|
||||
_noDataResetTimer?.cancel();
|
||||
notifyListeners();
|
||||
|
||||
// Auto-clear success status after 2 seconds
|
||||
_successResetTimer?.cancel();
|
||||
_successResetTimer = Timer(const Duration(seconds: 2), () {
|
||||
if (_hasSuccess) {
|
||||
_hasSuccess = false;
|
||||
notifyListeners();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Show no-data status briefly when tiles aren't available
|
||||
void setNoData() {
|
||||
_isWaitingForData = false;
|
||||
_isTimedOut = false;
|
||||
_hasSuccess = false;
|
||||
_hasNoData = true;
|
||||
_waitingTimer?.cancel();
|
||||
_successResetTimer?.cancel();
|
||||
notifyListeners();
|
||||
|
||||
// Auto-clear no-data status after 2 seconds
|
||||
_noDataResetTimer?.cancel();
|
||||
_noDataResetTimer = Timer(const Duration(seconds: 2), () {
|
||||
if (_hasNoData) {
|
||||
_hasNoData = false;
|
||||
notifyListeners();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Clear waiting/timeout/no-data status (legacy method for compatibility)
|
||||
void clearWaiting() {
|
||||
if (_isWaitingForData || _isTimedOut || _hasNoData || _hasSuccess) {
|
||||
_isWaitingForData = false;
|
||||
_isTimedOut = false;
|
||||
_hasNoData = false;
|
||||
_hasSuccess = false;
|
||||
_recentOfflineMisses = 0;
|
||||
_waitingTimer?.cancel();
|
||||
_noDataResetTimer?.cancel();
|
||||
_successResetTimer?.cancel();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Report that a tile was not available offline
|
||||
void reportOfflineMiss() {
|
||||
_recentOfflineMisses++;
|
||||
debugPrint('[NetworkStatus] Offline miss #$_recentOfflineMisses');
|
||||
|
||||
// If we get several misses in a short time, show "no data" status
|
||||
if (_recentOfflineMisses >= 3 && !_hasNoData) {
|
||||
_isWaitingForData = false;
|
||||
_isTimedOut = false;
|
||||
_hasNoData = true;
|
||||
_waitingTimer?.cancel();
|
||||
notifyListeners();
|
||||
debugPrint('[NetworkStatus] No offline data available for this area');
|
||||
}
|
||||
|
||||
// Reset the miss counter after some time
|
||||
_noDataResetTimer?.cancel();
|
||||
_noDataResetTimer = Timer(const Duration(seconds: 5), () {
|
||||
_recentOfflineMisses = 0;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_osmRecoveryTimer?.cancel();
|
||||
_overpassRecoveryTimer?.cancel();
|
||||
_waitingTimer?.cancel();
|
||||
_noDataResetTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -6,23 +6,63 @@ import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'offline_areas/offline_area_models.dart';
|
||||
import 'offline_areas/offline_tile_utils.dart';
|
||||
import 'offline_areas/offline_area_service_tile_fetch.dart'; // Only used for file IO during area downloads.
|
||||
import 'offline_areas/offline_area_downloader.dart';
|
||||
import 'offline_areas/world_area_manager.dart';
|
||||
import '../models/osm_camera_node.dart';
|
||||
import '../app_state.dart';
|
||||
import 'map_data_provider.dart';
|
||||
import 'map_data_submodules/cameras_from_overpass.dart';
|
||||
import 'package:flock_map_app/dev_config.dart';
|
||||
import 'package:deflockapp/dev_config.dart';
|
||||
|
||||
/// Service for managing download, storage, and retrieval of offline map areas and cameras.
|
||||
class OfflineAreaService {
|
||||
static final OfflineAreaService _instance = OfflineAreaService._();
|
||||
factory OfflineAreaService() => _instance;
|
||||
OfflineAreaService._() {
|
||||
_loadAreasFromDisk().then((_) => _ensureAndAutoDownloadWorldArea());
|
||||
}
|
||||
|
||||
bool _initialized = false;
|
||||
Future<void>? _initializationFuture;
|
||||
|
||||
OfflineAreaService._();
|
||||
|
||||
final List<OfflineArea> _areas = [];
|
||||
List<OfflineArea> get offlineAreas => List.unmodifiable(_areas);
|
||||
|
||||
/// Check if any areas are currently downloading
|
||||
bool get hasActiveDownloads => _areas.any((area) => area.status == OfflineAreaStatus.downloading);
|
||||
|
||||
/// Cancel all active downloads (used when enabling offline mode)
|
||||
Future<void> cancelActiveDownloads() async {
|
||||
final activeAreas = _areas.where((area) => area.status == OfflineAreaStatus.downloading).toList();
|
||||
for (final area in activeAreas) {
|
||||
area.status = OfflineAreaStatus.cancelled;
|
||||
if (!area.isPermanent) {
|
||||
// Clean up non-permanent areas
|
||||
final dir = Directory(area.directory);
|
||||
if (await dir.exists()) {
|
||||
await dir.delete(recursive: true);
|
||||
}
|
||||
_areas.remove(area);
|
||||
}
|
||||
}
|
||||
await saveAreasToDisk();
|
||||
debugPrint('OfflineAreaService: Cancelled ${activeAreas.length} active downloads due to offline mode');
|
||||
}
|
||||
|
||||
/// Ensure the service is initialized (areas loaded from disk)
|
||||
Future<void> ensureInitialized() async {
|
||||
if (_initialized) return;
|
||||
|
||||
_initializationFuture ??= _initialize();
|
||||
await _initializationFuture;
|
||||
}
|
||||
|
||||
Future<void> _initialize() async {
|
||||
if (_initialized) return;
|
||||
|
||||
await _loadAreasFromDisk();
|
||||
await WorldAreaManager.ensureWorldArea(_areas, getOfflineAreaDir, downloadArea);
|
||||
await saveAreasToDisk(); // Save any world area updates
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
Future<Directory> getOfflineAreaDir() async {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
@@ -56,7 +96,20 @@ class OfflineAreaService {
|
||||
Future<void> saveAreasToDisk() async {
|
||||
try {
|
||||
final file = await _getMetadataPath();
|
||||
final content = jsonEncode(_areas.map((a) => a.toJson()).toList());
|
||||
final offlineDir = await getOfflineAreaDir();
|
||||
|
||||
// Convert areas to JSON with relative paths for portability
|
||||
final areaJsonList = _areas.map((area) {
|
||||
final json = area.toJson();
|
||||
// Convert absolute path to relative path for storage
|
||||
if (json['directory'].toString().startsWith(offlineDir.path)) {
|
||||
final relativePath = json['directory'].toString().replaceFirst('${offlineDir.path}/', '');
|
||||
json['directory'] = relativePath;
|
||||
}
|
||||
return json;
|
||||
}).toList();
|
||||
|
||||
final content = jsonEncode(areaJsonList);
|
||||
await file.writeAsString(content);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to save offline areas: $e');
|
||||
@@ -77,11 +130,39 @@ class OfflineAreaService {
|
||||
return;
|
||||
}
|
||||
_areas.clear();
|
||||
|
||||
for (final areaJson in data) {
|
||||
// Migrate stored directory paths to be relative for portability
|
||||
String storedDir = areaJson['directory'];
|
||||
String relativePath = storedDir;
|
||||
|
||||
// If it's an absolute path, extract just the folder name
|
||||
if (storedDir.startsWith('/')) {
|
||||
if (storedDir.contains('/offline_areas/')) {
|
||||
final parts = storedDir.split('/offline_areas/');
|
||||
if (parts.length == 2) {
|
||||
relativePath = parts[1]; // Just the folder name (e.g., "world" or "2025-08-19...")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always construct absolute path at runtime
|
||||
final offlineDir = await getOfflineAreaDir();
|
||||
final fullPath = '${offlineDir.path}/$relativePath';
|
||||
|
||||
// Update the JSON to use the full path for this session
|
||||
areaJson['directory'] = fullPath;
|
||||
|
||||
final area = OfflineArea.fromJson(areaJson);
|
||||
|
||||
if (!Directory(area.directory).existsSync()) {
|
||||
area.status = OfflineAreaStatus.error;
|
||||
} else {
|
||||
// Reset error status if directory now exists (fixes areas that were previously broken due to path issues)
|
||||
if (area.status == OfflineAreaStatus.error) {
|
||||
area.status = OfflineAreaStatus.complete;
|
||||
}
|
||||
|
||||
getAreaSizeBytes(area);
|
||||
}
|
||||
_areas.add(area);
|
||||
@@ -91,77 +172,7 @@ class OfflineAreaService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _ensureAndAutoDownloadWorldArea() async {
|
||||
final dir = await getOfflineAreaDir();
|
||||
final worldDir = "${dir.path}/world";
|
||||
final LatLngBounds worldBounds = globalWorldBounds();
|
||||
OfflineArea? world;
|
||||
for (final a in _areas) {
|
||||
if (a.isPermanent) { world = a; break; }
|
||||
}
|
||||
final Set<List<int>> expectedTiles = computeTileList(worldBounds, kWorldMinZoom, kWorldMaxZoom);
|
||||
if (world != null) {
|
||||
int filesFound = 0;
|
||||
List<List<int>> missingTiles = [];
|
||||
for (final tile in expectedTiles) {
|
||||
final f = File('${world.directory}/tiles/${tile[0]}/${tile[1]}/${tile[2]}.png');
|
||||
if (f.existsSync()) {
|
||||
filesFound++;
|
||||
} else if (missingTiles.length < 10) {
|
||||
missingTiles.add(tile);
|
||||
}
|
||||
}
|
||||
if (filesFound != expectedTiles.length) {
|
||||
debugPrint('World area: missing ${expectedTiles.length - filesFound} tiles. First few: $missingTiles');
|
||||
} else {
|
||||
debugPrint('World area: all tiles accounted for.');
|
||||
}
|
||||
world.tilesTotal = expectedTiles.length;
|
||||
world.tilesDownloaded = filesFound;
|
||||
world.progress = (world.tilesTotal == 0) ? 0.0 : (filesFound / world.tilesTotal);
|
||||
if (filesFound == world.tilesTotal) {
|
||||
world.status = OfflineAreaStatus.complete;
|
||||
await saveAreasToDisk();
|
||||
return;
|
||||
} else {
|
||||
world.status = OfflineAreaStatus.downloading;
|
||||
await saveAreasToDisk();
|
||||
downloadArea(
|
||||
id: world.id,
|
||||
bounds: world.bounds,
|
||||
minZoom: world.minZoom,
|
||||
maxZoom: world.maxZoom,
|
||||
directory: world.directory,
|
||||
name: world.name,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// If not present, create and start download
|
||||
world = OfflineArea(
|
||||
id: 'permanent_world',
|
||||
name: 'World (required)',
|
||||
bounds: worldBounds,
|
||||
minZoom: kWorldMinZoom,
|
||||
maxZoom: kWorldMaxZoom,
|
||||
directory: worldDir,
|
||||
status: OfflineAreaStatus.downloading,
|
||||
progress: 0.0,
|
||||
isPermanent: true,
|
||||
tilesTotal: expectedTiles.length,
|
||||
tilesDownloaded: 0,
|
||||
);
|
||||
_areas.insert(0, world);
|
||||
await saveAreasToDisk();
|
||||
downloadArea(
|
||||
id: world.id,
|
||||
bounds: world.bounds,
|
||||
minZoom: world.minZoom,
|
||||
maxZoom: world.maxZoom,
|
||||
directory: world.directory,
|
||||
name: world.name,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Future<void> downloadArea({
|
||||
required String id,
|
||||
@@ -172,6 +183,10 @@ class OfflineAreaService {
|
||||
void Function(double progress)? onProgress,
|
||||
void Function(OfflineAreaStatus status)? onComplete,
|
||||
String? name,
|
||||
String? tileProviderId,
|
||||
String? tileProviderName,
|
||||
String? tileTypeId,
|
||||
String? tileTypeName,
|
||||
}) async {
|
||||
OfflineArea? area;
|
||||
for (final a in _areas) {
|
||||
@@ -192,78 +207,35 @@ class OfflineAreaService {
|
||||
maxZoom: maxZoom,
|
||||
directory: directory,
|
||||
isPermanent: area?.isPermanent ?? false,
|
||||
tileProviderId: tileProviderId,
|
||||
tileProviderName: tileProviderName,
|
||||
tileTypeId: tileTypeId,
|
||||
tileTypeName: tileTypeName,
|
||||
);
|
||||
_areas.add(area);
|
||||
await saveAreasToDisk();
|
||||
|
||||
try {
|
||||
Set<List<int>> allTiles;
|
||||
if (area.isPermanent) {
|
||||
allTiles = computeTileList(globalWorldBounds(), kWorldMinZoom, kWorldMaxZoom);
|
||||
} else {
|
||||
allTiles = computeTileList(bounds, minZoom, maxZoom);
|
||||
}
|
||||
area.tilesTotal = allTiles.length;
|
||||
const int maxPasses = 3;
|
||||
int pass = 0;
|
||||
Set<List<int>> allTilesSet = allTiles.toSet();
|
||||
Set<List<int>> tilesToFetch = allTilesSet;
|
||||
bool success = false;
|
||||
int totalDone = 0;
|
||||
while (pass < maxPasses && tilesToFetch.isNotEmpty) {
|
||||
pass++;
|
||||
int doneThisPass = 0;
|
||||
debugPrint('DownloadArea: pass #$pass for area $id. Need ${tilesToFetch.length} tiles.');
|
||||
for (final tile in tilesToFetch) {
|
||||
if (area.status == OfflineAreaStatus.cancelled) break;
|
||||
try {
|
||||
final bytes = await MapDataProvider().getTile(
|
||||
z: tile[0], x: tile[1], y: tile[2], source: MapSource.remote);
|
||||
if (bytes.isNotEmpty) {
|
||||
await saveTileBytes(tile[0], tile[1], tile[2], directory, bytes);
|
||||
}
|
||||
totalDone++;
|
||||
doneThisPass++;
|
||||
area.tilesDownloaded = totalDone;
|
||||
area.progress = area.tilesTotal == 0 ? 0.0 : ((area.tilesDownloaded) / area.tilesTotal);
|
||||
} catch (e) {
|
||||
debugPrint("Tile download failed for z=${tile[0]}, x=${tile[1]}, y=${tile[2]}: $e");
|
||||
}
|
||||
if (onProgress != null) onProgress(area.progress);
|
||||
}
|
||||
await getAreaSizeBytes(area);
|
||||
await saveAreasToDisk();
|
||||
Set<List<int>> missingTiles = {};
|
||||
for (final tile in allTilesSet) {
|
||||
final f = File('$directory/tiles/${tile[0]}/${tile[1]}/${tile[2]}.png');
|
||||
if (!f.existsSync()) missingTiles.add(tile);
|
||||
}
|
||||
if (missingTiles.isEmpty) {
|
||||
success = true;
|
||||
break;
|
||||
}
|
||||
tilesToFetch = missingTiles;
|
||||
}
|
||||
final success = await OfflineAreaDownloader.downloadArea(
|
||||
area: area,
|
||||
bounds: bounds,
|
||||
minZoom: minZoom,
|
||||
maxZoom: maxZoom,
|
||||
directory: directory,
|
||||
onProgress: onProgress,
|
||||
saveAreasToDisk: saveAreasToDisk,
|
||||
getAreaSizeBytes: getAreaSizeBytes,
|
||||
);
|
||||
|
||||
if (!area.isPermanent) {
|
||||
final cameras = await MapDataProvider().getAllCamerasForDownload(
|
||||
bounds: bounds,
|
||||
profiles: AppState.instance.enabledProfiles,
|
||||
);
|
||||
area.cameras = cameras;
|
||||
await saveCameras(cameras, directory);
|
||||
} else {
|
||||
area.cameras = [];
|
||||
}
|
||||
await getAreaSizeBytes(area);
|
||||
|
||||
if (success) {
|
||||
area.status = OfflineAreaStatus.complete;
|
||||
area.progress = 1.0;
|
||||
debugPrint('Area $id: all tiles accounted for and area marked complete.');
|
||||
debugPrint('Area $id: download completed successfully.');
|
||||
} else {
|
||||
area.status = OfflineAreaStatus.error;
|
||||
debugPrint('Area $id: MISSING tiles after $maxPasses passes. First 10: ${tilesToFetch.toList().take(10)}');
|
||||
debugPrint('Area $id: download failed after maximum retry attempts.');
|
||||
if (!area.isPermanent) {
|
||||
final dirObj = Directory(area.directory);
|
||||
if (await dirObj.exists()) {
|
||||
@@ -273,11 +245,11 @@ class OfflineAreaService {
|
||||
}
|
||||
}
|
||||
await saveAreasToDisk();
|
||||
if (onComplete != null) onComplete(area.status);
|
||||
onComplete?.call(area.status);
|
||||
} catch (e) {
|
||||
area.status = OfflineAreaStatus.error;
|
||||
await saveAreasToDisk();
|
||||
if (onComplete != null) onComplete(area.status);
|
||||
onComplete?.call(area.status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,7 +263,7 @@ class OfflineAreaService {
|
||||
_areas.remove(area);
|
||||
await saveAreasToDisk();
|
||||
if (area.isPermanent) {
|
||||
_ensureAndAutoDownloadWorldArea();
|
||||
await WorldAreaManager.ensureWorldArea(_areas, getOfflineAreaDir, downloadArea);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,4 +276,6 @@ class OfflineAreaService {
|
||||
_areas.remove(area);
|
||||
await saveAreasToDisk();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
196
lib/services/offline_areas/offline_area_downloader.dart
Normal file
196
lib/services/offline_areas/offline_area_downloader.dart
Normal file
@@ -0,0 +1,196 @@
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
|
||||
import '../../app_state.dart';
|
||||
import '../../models/osm_camera_node.dart';
|
||||
import '../map_data_provider.dart';
|
||||
import 'offline_area_models.dart';
|
||||
import 'offline_tile_utils.dart';
|
||||
import 'package:deflockapp/dev_config.dart';
|
||||
|
||||
/// Handles the actual downloading process for offline areas
|
||||
class OfflineAreaDownloader {
|
||||
static const int _maxRetryPasses = 3;
|
||||
|
||||
/// Download tiles and cameras for an offline area
|
||||
static Future<bool> downloadArea({
|
||||
required OfflineArea area,
|
||||
required LatLngBounds bounds,
|
||||
required int minZoom,
|
||||
required int maxZoom,
|
||||
required String directory,
|
||||
void Function(double progress)? onProgress,
|
||||
required Future<void> Function() saveAreasToDisk,
|
||||
required Future<void> Function(OfflineArea) getAreaSizeBytes,
|
||||
}) async {
|
||||
Set<List<int>> allTiles;
|
||||
if (area.isPermanent) {
|
||||
allTiles = computeTileList(globalWorldBounds(), kWorldMinZoom, kWorldMaxZoom);
|
||||
} else {
|
||||
allTiles = computeTileList(bounds, minZoom, maxZoom);
|
||||
}
|
||||
area.tilesTotal = allTiles.length;
|
||||
|
||||
// Download tiles with retry logic
|
||||
final success = await _downloadTilesWithRetry(
|
||||
area: area,
|
||||
allTiles: allTiles,
|
||||
directory: directory,
|
||||
onProgress: onProgress,
|
||||
saveAreasToDisk: saveAreasToDisk,
|
||||
getAreaSizeBytes: getAreaSizeBytes,
|
||||
);
|
||||
|
||||
// Download cameras for non-permanent areas
|
||||
if (!area.isPermanent) {
|
||||
await _downloadCameras(
|
||||
area: area,
|
||||
bounds: bounds,
|
||||
minZoom: minZoom,
|
||||
directory: directory,
|
||||
);
|
||||
} else {
|
||||
area.cameras = [];
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/// Download tiles with retry logic
|
||||
static Future<bool> _downloadTilesWithRetry({
|
||||
required OfflineArea area,
|
||||
required Set<List<int>> allTiles,
|
||||
required String directory,
|
||||
void Function(double progress)? onProgress,
|
||||
required Future<void> Function() saveAreasToDisk,
|
||||
required Future<void> Function(OfflineArea) getAreaSizeBytes,
|
||||
}) async {
|
||||
int pass = 0;
|
||||
Set<List<int>> tilesToFetch = allTiles;
|
||||
int totalDone = 0;
|
||||
|
||||
while (pass < _maxRetryPasses && tilesToFetch.isNotEmpty) {
|
||||
pass++;
|
||||
debugPrint('DownloadArea: pass #$pass for area ${area.id}. Need ${tilesToFetch.length} tiles.');
|
||||
|
||||
for (final tile in tilesToFetch) {
|
||||
if (area.status == OfflineAreaStatus.cancelled) break;
|
||||
|
||||
if (await _downloadSingleTile(tile, directory, area)) {
|
||||
totalDone++;
|
||||
area.tilesDownloaded = totalDone;
|
||||
area.progress = area.tilesTotal == 0 ? 0.0 : (totalDone / area.tilesTotal);
|
||||
onProgress?.call(area.progress);
|
||||
}
|
||||
}
|
||||
|
||||
await getAreaSizeBytes(area);
|
||||
await saveAreasToDisk();
|
||||
|
||||
// Check for missing tiles
|
||||
tilesToFetch = _findMissingTiles(allTiles, directory);
|
||||
if (tilesToFetch.isEmpty) {
|
||||
return true; // Success!
|
||||
}
|
||||
}
|
||||
|
||||
return false; // Failed after max retries
|
||||
}
|
||||
|
||||
/// Download a single tile using the unified MapDataProvider path
|
||||
static Future<bool> _downloadSingleTile(
|
||||
List<int> tile,
|
||||
String directory,
|
||||
OfflineArea area,
|
||||
) async {
|
||||
try {
|
||||
// Use the same unified path as live tiles: always go through MapDataProvider
|
||||
// MapDataProvider will use current AppState provider for downloads
|
||||
final bytes = await MapDataProvider().getTile(
|
||||
z: tile[0],
|
||||
x: tile[1],
|
||||
y: tile[2],
|
||||
source: MapSource.remote, // Force remote fetch for downloads
|
||||
);
|
||||
if (bytes.isNotEmpty) {
|
||||
await OfflineAreaDownloader.saveTileBytes(tile[0], tile[1], tile[2], directory, bytes);
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Tile download failed for z=${tile[0]}, x=${tile[1]}, y=${tile[2]}: $e");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Find tiles that are missing from disk
|
||||
static Set<List<int>> _findMissingTiles(Set<List<int>> allTiles, String directory) {
|
||||
final missingTiles = <List<int>>{};
|
||||
for (final tile in allTiles) {
|
||||
final file = File('$directory/tiles/${tile[0]}/${tile[1]}/${tile[2]}.png');
|
||||
if (!file.existsSync()) {
|
||||
missingTiles.add(tile);
|
||||
}
|
||||
}
|
||||
return missingTiles;
|
||||
}
|
||||
|
||||
/// Download cameras for the area with expanded bounds
|
||||
static Future<void> _downloadCameras({
|
||||
required OfflineArea area,
|
||||
required LatLngBounds bounds,
|
||||
required int minZoom,
|
||||
required String directory,
|
||||
}) async {
|
||||
// Calculate expanded camera bounds that cover the entire tile area at minimum zoom
|
||||
final cameraBounds = _calculateCameraBounds(bounds, minZoom);
|
||||
final cameras = await MapDataProvider().getAllNodesForDownload(
|
||||
bounds: cameraBounds,
|
||||
profiles: AppState.instance.profiles, // Use ALL profiles, not just enabled ones
|
||||
);
|
||||
area.cameras = cameras;
|
||||
await OfflineAreaDownloader.saveCameras(cameras, directory);
|
||||
debugPrint('Area ${area.id}: Downloaded ${cameras.length} cameras from expanded bounds (all profiles)');
|
||||
}
|
||||
|
||||
/// Calculate expanded bounds that cover the entire tile area at minimum zoom
|
||||
static LatLngBounds _calculateCameraBounds(LatLngBounds visibleBounds, int minZoom) {
|
||||
final tiles = computeTileList(visibleBounds, minZoom, minZoom);
|
||||
if (tiles.isEmpty) return visibleBounds;
|
||||
|
||||
// Find the bounding box of all these tiles
|
||||
double minLat = 90.0, maxLat = -90.0;
|
||||
double minLon = 180.0, maxLon = -180.0;
|
||||
|
||||
for (final tile in tiles) {
|
||||
final tileBounds = tileToLatLngBounds(tile[1], tile[2], tile[0]);
|
||||
|
||||
minLat = math.min(minLat, tileBounds.south);
|
||||
maxLat = math.max(maxLat, tileBounds.north);
|
||||
minLon = math.min(minLon, tileBounds.west);
|
||||
maxLon = math.max(maxLon, tileBounds.east);
|
||||
}
|
||||
|
||||
return LatLngBounds(
|
||||
LatLng(minLat, minLon),
|
||||
LatLng(maxLat, maxLon),
|
||||
);
|
||||
}
|
||||
|
||||
/// Save tile bytes to disk
|
||||
static Future<void> saveTileBytes(int z, int x, int y, String baseDir, List<int> bytes) async {
|
||||
final dir = Directory('$baseDir/tiles/$z/$x');
|
||||
await dir.create(recursive: true);
|
||||
final file = File('${dir.path}/$y.png');
|
||||
await file.writeAsBytes(bytes);
|
||||
}
|
||||
|
||||
/// Save cameras to disk as JSON
|
||||
static Future<void> saveCameras(List<OsmCameraNode> cams, String dir) async {
|
||||
final file = File('$dir/cameras.json');
|
||||
await file.writeAsString(jsonEncode(cams.map((c) => c.toJson()).toList()));
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,12 @@ class OfflineArea {
|
||||
List<OsmCameraNode> cameras;
|
||||
int sizeBytes; // Disk size in bytes
|
||||
final bool isPermanent; // Not user-deletable if true
|
||||
|
||||
// Tile provider metadata (null for legacy areas)
|
||||
final String? tileProviderId;
|
||||
final String? tileProviderName;
|
||||
final String? tileTypeId;
|
||||
final String? tileTypeName;
|
||||
|
||||
OfflineArea({
|
||||
required this.id,
|
||||
@@ -35,6 +41,10 @@ class OfflineArea {
|
||||
this.cameras = const [],
|
||||
this.sizeBytes = 0,
|
||||
this.isPermanent = false,
|
||||
this.tileProviderId,
|
||||
this.tileProviderName,
|
||||
this.tileTypeId,
|
||||
this.tileTypeName,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
@@ -54,6 +64,10 @@ class OfflineArea {
|
||||
'cameras': cameras.map((c) => c.toJson()).toList(),
|
||||
'sizeBytes': sizeBytes,
|
||||
'isPermanent': isPermanent,
|
||||
'tileProviderId': tileProviderId,
|
||||
'tileProviderName': tileProviderName,
|
||||
'tileTypeId': tileTypeId,
|
||||
'tileTypeName': tileTypeName,
|
||||
};
|
||||
|
||||
static OfflineArea fromJson(Map<String, dynamic> json) {
|
||||
@@ -77,6 +91,27 @@ class OfflineArea {
|
||||
.map((e) => OsmCameraNode.fromJson(e)).toList(),
|
||||
sizeBytes: json['sizeBytes'] ?? 0,
|
||||
isPermanent: json['isPermanent'] ?? false,
|
||||
tileProviderId: json['tileProviderId'],
|
||||
tileProviderName: json['tileProviderName'],
|
||||
tileTypeId: json['tileTypeId'],
|
||||
tileTypeName: json['tileTypeName'],
|
||||
);
|
||||
}
|
||||
|
||||
/// Get display text for the tile provider used in this area
|
||||
String get tileProviderDisplay {
|
||||
if (tileProviderName != null && tileTypeName != null) {
|
||||
return '$tileProviderName - $tileTypeName';
|
||||
} else if (tileTypeName != null) {
|
||||
return tileTypeName!;
|
||||
} else if (tileProviderName != null) {
|
||||
return tileProviderName!;
|
||||
} else {
|
||||
// Legacy area - assume OSM
|
||||
return 'OpenStreetMap (Legacy)';
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this area has tile provider metadata
|
||||
bool get hasTileProviderInfo => tileProviderId != null && tileTypeId != null;
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
import '../../models/osm_camera_node.dart';
|
||||
|
||||
/// Disk IO utilities for offline area file management ONLY. No network requests should occur here.
|
||||
|
||||
/// Save-to-disk for a tile that has already been fetched elsewhere.
|
||||
Future<void> saveTileBytes(int z, int x, int y, String baseDir, List<int> bytes) async {
|
||||
final dir = Directory('$baseDir/tiles/$z/$x');
|
||||
await dir.create(recursive: true);
|
||||
final file = File('${dir.path}/$y.png');
|
||||
await file.writeAsBytes(bytes);
|
||||
}
|
||||
|
||||
/// Save-to-disk for cameras.json; called only by OfflineAreaService during area download
|
||||
Future<void> saveCameras(List<OsmCameraNode> cams, String dir) async {
|
||||
final file = File('$dir/cameras.json');
|
||||
await file.writeAsString(jsonEncode(cams.map((c) => c.toJson()).toList()));
|
||||
}
|
||||
@@ -56,15 +56,30 @@ List<int> latLonToTile(double lat, double lon, int zoom) {
|
||||
return [xtile, ytile];
|
||||
}
|
||||
|
||||
int findDynamicMinZoom(LatLngBounds bounds, {int maxSearchZoom = 19}) {
|
||||
for (int z = 1; z <= maxSearchZoom; z++) {
|
||||
final swTile = latLonToTile(bounds.southWest.latitude, bounds.southWest.longitude, z);
|
||||
final neTile = latLonToTile(bounds.northEast.latitude, bounds.northEast.longitude, z);
|
||||
if (swTile[0] != neTile[0] || swTile[1] != neTile[1]) {
|
||||
return z - 1 > 0 ? z - 1 : 1;
|
||||
}
|
||||
}
|
||||
return maxSearchZoom;
|
||||
/// Convert tile coordinates back to LatLng bounds
|
||||
LatLngBounds tileToLatLngBounds(int x, int y, int z) {
|
||||
final n = pow(2, z);
|
||||
|
||||
// Calculate bounds for this tile
|
||||
final lonWest = x / n * 360.0 - 180.0;
|
||||
final lonEast = (x + 1) / n * 360.0 - 180.0;
|
||||
|
||||
// For latitude, we need to invert the mercator projection
|
||||
final latNorthRad = atan(sinh(pi * (1 - 2 * y / n)));
|
||||
final latSouthRad = atan(sinh(pi * (1 - 2 * (y + 1) / n)));
|
||||
|
||||
final latNorth = latNorthRad * 180.0 / pi;
|
||||
final latSouth = latSouthRad * 180.0 / pi;
|
||||
|
||||
return LatLngBounds(
|
||||
LatLng(latSouth, lonWest), // SW corner
|
||||
LatLng(latNorth, lonEast), // NE corner
|
||||
);
|
||||
}
|
||||
|
||||
/// Hyperbolic sine function: sinh(x) = (e^x - e^(-x)) / 2
|
||||
double sinh(double x) {
|
||||
return (exp(x) - exp(-x)) / 2;
|
||||
}
|
||||
|
||||
LatLngBounds globalWorldBounds() {
|
||||
|
||||
153
lib/services/offline_areas/world_area_manager.dart
Normal file
153
lib/services/offline_areas/world_area_manager.dart
Normal file
@@ -0,0 +1,153 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'offline_area_models.dart';
|
||||
import 'offline_tile_utils.dart';
|
||||
import 'package:deflockapp/dev_config.dart';
|
||||
|
||||
/// Manages the world area (permanent offline area for base map)
|
||||
class WorldAreaManager {
|
||||
static const String _worldAreaId = 'world';
|
||||
static const String _worldAreaName = 'World Base Map';
|
||||
|
||||
/// Ensure world area exists and check if download is needed
|
||||
static Future<OfflineArea> ensureWorldArea(
|
||||
List<OfflineArea> areas,
|
||||
Future<Directory> Function() getOfflineAreaDir,
|
||||
Future<void> Function({
|
||||
required String id,
|
||||
required LatLngBounds bounds,
|
||||
required int minZoom,
|
||||
required int maxZoom,
|
||||
required String directory,
|
||||
String? name,
|
||||
String? tileProviderId,
|
||||
String? tileProviderName,
|
||||
String? tileTypeId,
|
||||
String? tileTypeName,
|
||||
}) downloadArea,
|
||||
) async {
|
||||
// Find existing world area
|
||||
OfflineArea? world;
|
||||
for (final area in areas) {
|
||||
if (area.isPermanent) {
|
||||
world = area;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Create world area if it doesn't exist, or update existing area without provider info
|
||||
if (world == null) {
|
||||
final appDocDir = await getOfflineAreaDir();
|
||||
final dir = "${appDocDir.path}/$_worldAreaId";
|
||||
world = OfflineArea(
|
||||
id: _worldAreaId,
|
||||
name: _worldAreaName,
|
||||
bounds: globalWorldBounds(),
|
||||
minZoom: kWorldMinZoom,
|
||||
maxZoom: kWorldMaxZoom,
|
||||
directory: dir,
|
||||
status: OfflineAreaStatus.downloading,
|
||||
isPermanent: true,
|
||||
// World area always uses OpenStreetMap
|
||||
tileProviderId: 'openstreetmap',
|
||||
tileProviderName: 'OpenStreetMap',
|
||||
tileTypeId: 'osm_street',
|
||||
tileTypeName: 'Street Map',
|
||||
);
|
||||
areas.insert(0, world);
|
||||
} else if (world.tileProviderId == null || world.tileTypeId == null) {
|
||||
// Update existing world area that lacks provider metadata
|
||||
final updatedWorld = OfflineArea(
|
||||
id: world.id,
|
||||
name: world.name,
|
||||
bounds: world.bounds,
|
||||
minZoom: world.minZoom,
|
||||
maxZoom: world.maxZoom,
|
||||
directory: world.directory,
|
||||
status: world.status,
|
||||
progress: world.progress,
|
||||
tilesDownloaded: world.tilesDownloaded,
|
||||
tilesTotal: world.tilesTotal,
|
||||
cameras: world.cameras,
|
||||
sizeBytes: world.sizeBytes,
|
||||
isPermanent: world.isPermanent,
|
||||
// Add missing provider metadata
|
||||
tileProviderId: 'openstreetmap',
|
||||
tileProviderName: 'OpenStreetMap',
|
||||
tileTypeId: 'osm_street',
|
||||
tileTypeName: 'Street Map',
|
||||
);
|
||||
final index = areas.indexOf(world);
|
||||
areas[index] = updatedWorld;
|
||||
world = updatedWorld;
|
||||
}
|
||||
|
||||
// Check world area status and start download if needed
|
||||
await _checkAndStartWorldDownload(world, downloadArea);
|
||||
return world;
|
||||
}
|
||||
|
||||
/// Check world area download status and start if needed
|
||||
static Future<void> _checkAndStartWorldDownload(
|
||||
OfflineArea world,
|
||||
Future<void> Function({
|
||||
required String id,
|
||||
required LatLngBounds bounds,
|
||||
required int minZoom,
|
||||
required int maxZoom,
|
||||
required String directory,
|
||||
String? name,
|
||||
String? tileProviderId,
|
||||
String? tileProviderName,
|
||||
String? tileTypeId,
|
||||
String? tileTypeName,
|
||||
}) downloadArea,
|
||||
) async {
|
||||
if (world.status == OfflineAreaStatus.complete) return;
|
||||
|
||||
// Count existing tiles
|
||||
final expectedTiles = computeTileList(
|
||||
globalWorldBounds(),
|
||||
kWorldMinZoom,
|
||||
kWorldMaxZoom,
|
||||
);
|
||||
|
||||
int filesFound = 0;
|
||||
for (final tile in expectedTiles) {
|
||||
final file = File('${world.directory}/tiles/${tile[0]}/${tile[1]}/${tile[2]}.png');
|
||||
if (file.existsSync()) {
|
||||
filesFound++;
|
||||
}
|
||||
}
|
||||
|
||||
// Update world area stats
|
||||
world.tilesTotal = expectedTiles.length;
|
||||
world.tilesDownloaded = filesFound;
|
||||
world.progress = (world.tilesTotal == 0) ? 0.0 : (filesFound / world.tilesTotal);
|
||||
|
||||
if (filesFound == world.tilesTotal) {
|
||||
world.status = OfflineAreaStatus.complete;
|
||||
debugPrint('WorldAreaManager: World area download already complete.');
|
||||
} else {
|
||||
world.status = OfflineAreaStatus.downloading;
|
||||
debugPrint('WorldAreaManager: Starting world area download. ${world.tilesDownloaded}/${world.tilesTotal} tiles found.');
|
||||
|
||||
// Start download (fire and forget) - use OSM for world areas
|
||||
downloadArea(
|
||||
id: world.id,
|
||||
bounds: world.bounds,
|
||||
minZoom: world.minZoom,
|
||||
maxZoom: world.maxZoom,
|
||||
directory: world.directory,
|
||||
name: world.name,
|
||||
tileProviderId: 'openstreetmap',
|
||||
tileProviderName: 'OpenStreetMap',
|
||||
tileTypeId: 'osm_street',
|
||||
tileTypeName: 'Street Map',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
22
lib/services/operator_profile_service.dart
Normal file
22
lib/services/operator_profile_service.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/operator_profile.dart';
|
||||
|
||||
class OperatorProfileService {
|
||||
static const _key = 'operator_profiles';
|
||||
|
||||
Future<List<OperatorProfile>> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonStr = prefs.getString(_key);
|
||||
if (jsonStr == null) return [];
|
||||
final list = jsonDecode(jsonStr) as List<dynamic>;
|
||||
return list.map((e) => OperatorProfile.fromJson(e)).toList();
|
||||
}
|
||||
|
||||
Future<void> save(List<OperatorProfile> profiles) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final encodable = profiles.map((p) => p.toJson()).toList();
|
||||
await prefs.setString(_key, jsonEncode(encodable));
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,20 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/camera_profile.dart';
|
||||
import '../models/node_profile.dart';
|
||||
|
||||
class ProfileService {
|
||||
static const _key = 'custom_profiles';
|
||||
|
||||
Future<List<CameraProfile>> load() async {
|
||||
Future<List<NodeProfile>> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonStr = prefs.getString(_key);
|
||||
if (jsonStr == null) return [];
|
||||
final list = jsonDecode(jsonStr) as List<dynamic>;
|
||||
return list.map((e) => CameraProfile.fromJson(e)).toList();
|
||||
return list.map((e) => NodeProfile.fromJson(e)).toList();
|
||||
}
|
||||
|
||||
Future<void> save(List<CameraProfile> profiles) async {
|
||||
Future<void> save(List<NodeProfile> profiles) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// MUST convert to List before jsonEncode; the previous MappedIterable
|
||||
|
||||
114
lib/services/simple_tile_service.dart
Normal file
114
lib/services/simple_tile_service.dart
Normal file
@@ -0,0 +1,114 @@
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import 'map_data_provider.dart';
|
||||
import 'network_status.dart';
|
||||
|
||||
/// Simple HTTP client that routes tile requests through the centralized MapDataProvider.
|
||||
/// This ensures all tile fetching (offline/online routing, retries, etc.) is in one place.
|
||||
class SimpleTileHttpClient extends http.BaseClient {
|
||||
final http.Client _inner = http.Client();
|
||||
final MapDataProvider _mapDataProvider = MapDataProvider();
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
||||
// Extract tile coordinates from our custom URL scheme
|
||||
final tileCoords = _extractTileCoords(request.url);
|
||||
if (tileCoords != null) {
|
||||
final z = tileCoords['z']!;
|
||||
final x = tileCoords['x']!;
|
||||
final y = tileCoords['y']!;
|
||||
return _handleTileRequest(z, x, y);
|
||||
}
|
||||
|
||||
// Pass through non-tile requests
|
||||
return _inner.send(request);
|
||||
}
|
||||
|
||||
/// Extract z/x/y coordinates from our fake domain: https://tiles.local/provider/type/z/x/y
|
||||
/// We ignore the provider/type in the URL since we use current AppState for actual fetching
|
||||
Map<String, int>? _extractTileCoords(Uri url) {
|
||||
if (url.host != 'tiles.local') return null;
|
||||
|
||||
final pathSegments = url.pathSegments;
|
||||
if (pathSegments.length != 5) return null;
|
||||
|
||||
// pathSegments[0] = providerId (for cache separation only)
|
||||
// pathSegments[1] = tileTypeId (for cache separation only)
|
||||
final z = int.tryParse(pathSegments[2]);
|
||||
final x = int.tryParse(pathSegments[3]);
|
||||
final y = int.tryParse(pathSegments[4]);
|
||||
|
||||
if (z != null && x != null && y != null) {
|
||||
return {'z': z, 'x': x, 'y': y};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<http.StreamedResponse> _handleTileRequest(int z, int x, int y) async {
|
||||
try {
|
||||
// Always go through MapDataProvider - it handles offline/online routing
|
||||
// MapDataProvider will get current provider from AppState
|
||||
final tileBytes = await _mapDataProvider.getTile(z: z, x: x, y: y, source: MapSource.auto);
|
||||
|
||||
// Show success status briefly
|
||||
NetworkStatus.instance.setSuccess();
|
||||
|
||||
// Serve tile with proper cache headers
|
||||
return http.StreamedResponse(
|
||||
Stream.value(tileBytes),
|
||||
200,
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
'Cache-Control': 'public, max-age=604800',
|
||||
'Expires': _httpDateFormat(DateTime.now().add(Duration(days: 7))),
|
||||
'Last-Modified': _httpDateFormat(DateTime.now().subtract(Duration(hours: 1))),
|
||||
},
|
||||
);
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[SimpleTileService] Could not get tile $z/$x/$y: $e');
|
||||
|
||||
// 404 means no tiles available - show "no data" status briefly
|
||||
NetworkStatus.instance.setNoData();
|
||||
|
||||
// Return 404 and let flutter_map handle it gracefully
|
||||
return http.StreamedResponse(
|
||||
Stream.value(<int>[]),
|
||||
404,
|
||||
reasonPhrase: 'Tile unavailable: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear any queued tile requests when map view changes
|
||||
void clearTileQueue() {
|
||||
_mapDataProvider.clearTileQueue();
|
||||
}
|
||||
|
||||
/// Format date for HTTP headers (RFC 7231)
|
||||
String _httpDateFormat(DateTime date) {
|
||||
final utc = date.toUtc();
|
||||
final weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
final weekday = weekdays[utc.weekday - 1];
|
||||
final day = utc.day.toString().padLeft(2, '0');
|
||||
final month = months[utc.month - 1];
|
||||
final year = utc.year;
|
||||
final hour = utc.hour.toString().padLeft(2, '0');
|
||||
final minute = utc.minute.toString().padLeft(2, '0');
|
||||
final second = utc.second.toString().padLeft(2, '0');
|
||||
|
||||
return '$weekday, $day $month $year $hour:$minute:$second GMT';
|
||||
}
|
||||
|
||||
@override
|
||||
void close() {
|
||||
_inner.close();
|
||||
super.close();
|
||||
}
|
||||
}
|
||||
@@ -14,14 +14,15 @@ class Uploader {
|
||||
|
||||
Future<bool> upload(PendingUpload p) async {
|
||||
try {
|
||||
print('Uploader: Starting upload for camera at ${p.coord.latitude}, ${p.coord.longitude}');
|
||||
print('Uploader: Starting upload for node at ${p.coord.latitude}, ${p.coord.longitude}');
|
||||
|
||||
// 1. open changeset
|
||||
final action = p.isEdit ? 'Update' : 'Add';
|
||||
final csXml = '''
|
||||
<osm>
|
||||
<changeset>
|
||||
<tag k="created_by" v="$kClientName $kClientVersion"/>
|
||||
<tag k="comment" v="Add surveillance camera"/>
|
||||
<tag k="comment" v="$action ${p.profile.name} surveillance node"/>
|
||||
</changeset>
|
||||
</osm>''';
|
||||
print('Uploader: Creating changeset...');
|
||||
@@ -34,28 +35,63 @@ class Uploader {
|
||||
final csId = csResp.body.trim();
|
||||
print('Uploader: Created changeset ID: $csId');
|
||||
|
||||
// 2. create node
|
||||
// Merge tags: direction in PendingUpload should always be present,
|
||||
// and override any in the profile for upload purposes
|
||||
final mergedTags = Map<String, String>.from(p.profile.tags)
|
||||
..['direction'] = p.direction.round().toString();
|
||||
// 2. create or update node
|
||||
final mergedTags = p.getCombinedTags();
|
||||
final tagsXml = mergedTags.entries.map((e) =>
|
||||
'<tag k="${e.key}" v="${e.value}"/>').join('\n ');
|
||||
final nodeXml = '''
|
||||
|
||||
final http.Response nodeResp;
|
||||
final String nodeId;
|
||||
|
||||
if (p.isEdit) {
|
||||
// First, fetch the current node to get its version
|
||||
print('Uploader: Fetching current node ${p.originalNodeId} to get version...');
|
||||
final currentNodeResp = await _get('/api/0.6/node/${p.originalNodeId}');
|
||||
print('Uploader: Current node response: ${currentNodeResp.statusCode}');
|
||||
if (currentNodeResp.statusCode != 200) {
|
||||
print('Uploader: Failed to fetch current node');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse version from the response XML
|
||||
final currentNodeXml = currentNodeResp.body;
|
||||
final versionMatch = RegExp(r'version="(\d+)"').firstMatch(currentNodeXml);
|
||||
if (versionMatch == null) {
|
||||
print('Uploader: Could not parse version from current node XML');
|
||||
return false;
|
||||
}
|
||||
final currentVersion = versionMatch.group(1)!;
|
||||
print('Uploader: Current node version: $currentVersion');
|
||||
|
||||
// Update existing node with version
|
||||
final nodeXml = '''
|
||||
<osm>
|
||||
<node changeset="$csId" id="${p.originalNodeId}" version="$currentVersion" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
|
||||
$tagsXml
|
||||
</node>
|
||||
</osm>''';
|
||||
print('Uploader: Updating node ${p.originalNodeId}...');
|
||||
nodeResp = await _put('/api/0.6/node/${p.originalNodeId}', nodeXml);
|
||||
nodeId = p.originalNodeId.toString();
|
||||
} else {
|
||||
// Create new node
|
||||
final nodeXml = '''
|
||||
<osm>
|
||||
<node changeset="$csId" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
|
||||
$tagsXml
|
||||
</node>
|
||||
</osm>''';
|
||||
print('Uploader: Creating node...');
|
||||
final nodeResp = await _put('/api/0.6/node/create', nodeXml);
|
||||
print('Uploader: Creating new node...');
|
||||
nodeResp = await _put('/api/0.6/node/create', nodeXml);
|
||||
nodeId = nodeResp.body.trim();
|
||||
}
|
||||
|
||||
print('Uploader: Node response: ${nodeResp.statusCode} - ${nodeResp.body}');
|
||||
if (nodeResp.statusCode != 200) {
|
||||
print('Uploader: Failed to create node');
|
||||
print('Uploader: Failed to ${p.isEdit ? "update" : "create"} node');
|
||||
return false;
|
||||
}
|
||||
final nodeId = nodeResp.body.trim();
|
||||
print('Uploader: Created node ID: $nodeId');
|
||||
print('Uploader: ${p.isEdit ? "Updated" : "Created"} node ID: $nodeId');
|
||||
|
||||
// 3. close changeset
|
||||
print('Uploader: Closing changeset...');
|
||||
@@ -81,6 +117,11 @@ class Uploader {
|
||||
}
|
||||
}
|
||||
|
||||
Future<http.Response> _get(String path) => http.get(
|
||||
Uri.https(_host, path),
|
||||
headers: _headers,
|
||||
);
|
||||
|
||||
Future<http.Response> _post(String path, String body) => http.post(
|
||||
Uri.https(_host, path),
|
||||
headers: _headers,
|
||||
|
||||
103
lib/state/auth_state.dart
Normal file
103
lib/state/auth_state.dart
Normal file
@@ -0,0 +1,103 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../services/auth_service.dart';
|
||||
import 'settings_state.dart';
|
||||
|
||||
class AuthState extends ChangeNotifier {
|
||||
final AuthService _auth = AuthService();
|
||||
String? _username;
|
||||
|
||||
// Getters
|
||||
bool get isLoggedIn => _username != null;
|
||||
String get username => _username ?? '';
|
||||
AuthService get authService => _auth;
|
||||
|
||||
// Initialize auth state and check existing login
|
||||
Future<void> init(UploadMode uploadMode) async {
|
||||
_auth.setUploadMode(uploadMode);
|
||||
|
||||
try {
|
||||
if (await _auth.isLoggedIn()) {
|
||||
_username = await _auth.login();
|
||||
}
|
||||
} catch (e) {
|
||||
print("AuthState: Error during auth initialization: $e");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> login() async {
|
||||
try {
|
||||
_username = await _auth.login();
|
||||
} catch (e) {
|
||||
print("AuthState: Login error: $e");
|
||||
_username = null;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
await _auth.logout();
|
||||
_username = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> refreshAuthState() async {
|
||||
try {
|
||||
if (await _auth.isLoggedIn()) {
|
||||
_username = await _auth.login();
|
||||
} else {
|
||||
_username = null;
|
||||
}
|
||||
} catch (e) {
|
||||
print("AuthState: Auth refresh error: $e");
|
||||
_username = null;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> forceLogin() async {
|
||||
try {
|
||||
_username = await _auth.forceLogin();
|
||||
} catch (e) {
|
||||
print("AuthState: Forced login error: $e");
|
||||
_username = null;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<bool> validateToken() async {
|
||||
try {
|
||||
return await _auth.isLoggedIn();
|
||||
} catch (e) {
|
||||
print("AuthState: Token validation error: $e");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle upload mode changes
|
||||
Future<void> onUploadModeChanged(UploadMode mode) async {
|
||||
_auth.setUploadMode(mode);
|
||||
|
||||
// Refresh user display for active mode, validating token
|
||||
try {
|
||||
if (await _auth.isLoggedIn()) {
|
||||
final isValid = await validateToken();
|
||||
if (isValid) {
|
||||
_username = await _auth.login();
|
||||
} else {
|
||||
await logout(); // This clears _username also.
|
||||
}
|
||||
} else {
|
||||
_username = null;
|
||||
}
|
||||
} catch (e) {
|
||||
_username = null;
|
||||
print("AuthState: Mode change user restoration error: $e");
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<String?> getAccessToken() async {
|
||||
return await _auth.getAccessToken();
|
||||
}
|
||||
}
|
||||
31
lib/state/operator_profile_state.dart
Normal file
31
lib/state/operator_profile_state.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/operator_profile.dart';
|
||||
import '../services/operator_profile_service.dart';
|
||||
|
||||
class OperatorProfileState extends ChangeNotifier {
|
||||
final List<OperatorProfile> _profiles = [];
|
||||
|
||||
List<OperatorProfile> get profiles => List.unmodifiable(_profiles);
|
||||
|
||||
Future<void> init() async {
|
||||
_profiles.addAll(await OperatorProfileService().load());
|
||||
}
|
||||
|
||||
void addOrUpdateProfile(OperatorProfile p) {
|
||||
final idx = _profiles.indexWhere((x) => x.id == p.id);
|
||||
if (idx >= 0) {
|
||||
_profiles[idx] = p;
|
||||
} else {
|
||||
_profiles.add(p);
|
||||
}
|
||||
OperatorProfileService().save(_profiles);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void deleteProfile(OperatorProfile p) {
|
||||
_profiles.removeWhere((x) => x.id == p.id);
|
||||
OperatorProfileService().save(_profiles);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
95
lib/state/profile_state.dart
Normal file
95
lib/state/profile_state.dart
Normal file
@@ -0,0 +1,95 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/node_profile.dart';
|
||||
import '../services/profile_service.dart';
|
||||
|
||||
class ProfileState extends ChangeNotifier {
|
||||
static const String _enabledPrefsKey = 'enabled_profiles';
|
||||
|
||||
final List<NodeProfile> _profiles = [];
|
||||
final Set<NodeProfile> _enabled = {};
|
||||
|
||||
// Getters
|
||||
List<NodeProfile> get profiles => List.unmodifiable(_profiles);
|
||||
bool isEnabled(NodeProfile p) => _enabled.contains(p);
|
||||
List<NodeProfile> get enabledProfiles =>
|
||||
_profiles.where(isEnabled).toList(growable: false);
|
||||
|
||||
// Initialize profiles from built-in and custom sources
|
||||
Future<void> init() async {
|
||||
// Initialize profiles: built-in + custom
|
||||
_profiles.add(NodeProfile.genericAlpr());
|
||||
_profiles.add(NodeProfile.flock());
|
||||
_profiles.add(NodeProfile.motorola());
|
||||
_profiles.add(NodeProfile.genetec());
|
||||
_profiles.add(NodeProfile.leonardo());
|
||||
_profiles.add(NodeProfile.neology());
|
||||
_profiles.add(NodeProfile.genericGunshotDetector());
|
||||
_profiles.add(NodeProfile.shotspotter());
|
||||
_profiles.add(NodeProfile.flockRaven());
|
||||
_profiles.addAll(await ProfileService().load());
|
||||
|
||||
// Load enabled profile IDs from prefs
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final enabledIds = prefs.getStringList(_enabledPrefsKey);
|
||||
if (enabledIds != null && enabledIds.isNotEmpty) {
|
||||
// Restore enabled profiles by id
|
||||
_enabled.addAll(_profiles.where((p) => enabledIds.contains(p.id)));
|
||||
} else {
|
||||
// By default, all are enabled
|
||||
_enabled.addAll(_profiles);
|
||||
}
|
||||
}
|
||||
|
||||
void toggleProfile(NodeProfile p, bool e) {
|
||||
if (e) {
|
||||
_enabled.add(p);
|
||||
} else {
|
||||
_enabled.remove(p);
|
||||
// Safety: Always have at least one enabled profile
|
||||
if (_enabled.isEmpty) {
|
||||
final builtIn = _profiles.firstWhere((profile) => profile.builtin, orElse: () => _profiles.first);
|
||||
_enabled.add(builtIn);
|
||||
}
|
||||
}
|
||||
_saveEnabledProfiles();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void addOrUpdateProfile(NodeProfile p) {
|
||||
final idx = _profiles.indexWhere((x) => x.id == p.id);
|
||||
if (idx >= 0) {
|
||||
_profiles[idx] = p;
|
||||
} else {
|
||||
_profiles.add(p);
|
||||
_enabled.add(p);
|
||||
_saveEnabledProfiles();
|
||||
}
|
||||
ProfileService().save(_profiles);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void deleteProfile(NodeProfile p) {
|
||||
if (!p.editable) return;
|
||||
_enabled.remove(p);
|
||||
_profiles.removeWhere((x) => x.id == p.id);
|
||||
// Safety: Always have at least one enabled profile
|
||||
if (_enabled.isEmpty) {
|
||||
final builtIn = _profiles.firstWhere((profile) => profile.builtin, orElse: () => _profiles.first);
|
||||
_enabled.add(builtIn);
|
||||
}
|
||||
_saveEnabledProfiles();
|
||||
ProfileService().save(_profiles);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Save enabled profile IDs to disk
|
||||
Future<void> _saveEnabledProfiles() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList(
|
||||
_enabledPrefsKey,
|
||||
_enabled.map((p) => p.id).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
170
lib/state/session_state.dart
Normal file
170
lib/state/session_state.dart
Normal file
@@ -0,0 +1,170 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/operator_profile.dart';
|
||||
import '../models/osm_camera_node.dart';
|
||||
|
||||
// ------------------ AddNodeSession ------------------
|
||||
class AddNodeSession {
|
||||
AddNodeSession({required this.profile, this.directionDegrees = 0});
|
||||
NodeProfile profile;
|
||||
OperatorProfile? operatorProfile;
|
||||
double directionDegrees;
|
||||
LatLng? target;
|
||||
}
|
||||
|
||||
// ------------------ EditNodeSession ------------------
|
||||
class EditNodeSession {
|
||||
EditNodeSession({
|
||||
required this.originalNode,
|
||||
required this.profile,
|
||||
required this.directionDegrees,
|
||||
required this.target,
|
||||
});
|
||||
|
||||
final OsmCameraNode originalNode; // The original node being edited
|
||||
NodeProfile profile;
|
||||
OperatorProfile? operatorProfile;
|
||||
double directionDegrees;
|
||||
LatLng target; // Current position (can be dragged)
|
||||
}
|
||||
|
||||
class SessionState extends ChangeNotifier {
|
||||
AddNodeSession? _session;
|
||||
EditNodeSession? _editSession;
|
||||
|
||||
// Getters
|
||||
AddNodeSession? get session => _session;
|
||||
EditNodeSession? get editSession => _editSession;
|
||||
|
||||
void startAddSession(List<NodeProfile> enabledProfiles) {
|
||||
final submittableProfiles = enabledProfiles.where((p) => p.isSubmittable).toList();
|
||||
final defaultProfile = submittableProfiles.isNotEmpty
|
||||
? submittableProfiles.first
|
||||
: enabledProfiles.first; // Fallback to any enabled profile
|
||||
_session = AddNodeSession(profile: defaultProfile);
|
||||
_editSession = null; // Clear any edit session
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void startEditSession(OsmCameraNode node, List<NodeProfile> enabledProfiles) {
|
||||
final submittableProfiles = enabledProfiles.where((p) => p.isSubmittable).toList();
|
||||
|
||||
// Try to find a matching profile based on the node's tags
|
||||
NodeProfile matchingProfile = submittableProfiles.isNotEmpty
|
||||
? submittableProfiles.first
|
||||
: enabledProfiles.first;
|
||||
|
||||
// Attempt to find a better match by comparing tags
|
||||
for (final profile in submittableProfiles) {
|
||||
if (_profileMatchesTags(profile, node.tags)) {
|
||||
matchingProfile = profile;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_editSession = EditNodeSession(
|
||||
originalNode: node,
|
||||
profile: matchingProfile,
|
||||
directionDegrees: node.directionDeg ?? 0,
|
||||
target: node.coord,
|
||||
);
|
||||
_session = null; // Clear any add session
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool _profileMatchesTags(NodeProfile profile, Map<String, String> tags) {
|
||||
// Simple matching: check if all profile tags are present in node tags
|
||||
for (final entry in profile.tags.entries) {
|
||||
if (tags[entry.key] != entry.value) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void updateSession({
|
||||
double? directionDeg,
|
||||
NodeProfile? profile,
|
||||
OperatorProfile? operatorProfile,
|
||||
LatLng? target,
|
||||
}) {
|
||||
if (_session == null) return;
|
||||
|
||||
bool dirty = false;
|
||||
if (directionDeg != null && directionDeg != _session!.directionDegrees) {
|
||||
_session!.directionDegrees = directionDeg;
|
||||
dirty = true;
|
||||
}
|
||||
if (profile != null && profile != _session!.profile) {
|
||||
_session!.profile = profile;
|
||||
dirty = true;
|
||||
}
|
||||
if (operatorProfile != _session!.operatorProfile) {
|
||||
_session!.operatorProfile = operatorProfile;
|
||||
dirty = true;
|
||||
}
|
||||
if (target != null) {
|
||||
_session!.target = target;
|
||||
dirty = true;
|
||||
}
|
||||
if (dirty) notifyListeners();
|
||||
}
|
||||
|
||||
void updateEditSession({
|
||||
double? directionDeg,
|
||||
NodeProfile? profile,
|
||||
OperatorProfile? operatorProfile,
|
||||
LatLng? target,
|
||||
}) {
|
||||
if (_editSession == null) return;
|
||||
|
||||
bool dirty = false;
|
||||
if (directionDeg != null && directionDeg != _editSession!.directionDegrees) {
|
||||
_editSession!.directionDegrees = directionDeg;
|
||||
dirty = true;
|
||||
}
|
||||
if (profile != null && profile != _editSession!.profile) {
|
||||
_editSession!.profile = profile;
|
||||
dirty = true;
|
||||
}
|
||||
if (operatorProfile != _editSession!.operatorProfile) {
|
||||
_editSession!.operatorProfile = operatorProfile;
|
||||
dirty = true;
|
||||
}
|
||||
if (target != null && target != _editSession!.target) {
|
||||
_editSession!.target = target;
|
||||
dirty = true;
|
||||
}
|
||||
if (dirty) notifyListeners();
|
||||
}
|
||||
|
||||
void cancelSession() {
|
||||
_session = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void cancelEditSession() {
|
||||
_editSession = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
AddNodeSession? commitSession() {
|
||||
if (_session?.target == null) return null;
|
||||
|
||||
final session = _session!;
|
||||
_session = null;
|
||||
notifyListeners();
|
||||
return session;
|
||||
}
|
||||
|
||||
EditNodeSession? commitEditSession() {
|
||||
if (_editSession == null) return null;
|
||||
|
||||
final session = _editSession!;
|
||||
_editSession = null;
|
||||
notifyListeners();
|
||||
return session;
|
||||
}
|
||||
}
|
||||
242
lib/state/settings_state.dart
Normal file
242
lib/state/settings_state.dart
Normal file
@@ -0,0 +1,242 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
import '../models/tile_provider.dart';
|
||||
|
||||
// Enum for upload mode (Production, OSM Sandbox, Simulate)
|
||||
enum UploadMode { production, sandbox, simulate }
|
||||
|
||||
// Enum for follow-me mode (moved from HomeScreen to centralized state)
|
||||
enum FollowMeMode {
|
||||
off, // No following
|
||||
northUp, // Follow position, keep north up
|
||||
rotating, // Follow position and rotation
|
||||
}
|
||||
|
||||
class SettingsState extends ChangeNotifier {
|
||||
static const String _offlineModePrefsKey = 'offline_mode';
|
||||
static const String _maxCamerasPrefsKey = 'max_cameras';
|
||||
static const String _uploadModePrefsKey = 'upload_mode';
|
||||
static const String _tileProvidersPrefsKey = 'tile_providers';
|
||||
static const String _selectedTileTypePrefsKey = 'selected_tile_type';
|
||||
static const String _legacyTestModePrefsKey = 'test_mode';
|
||||
static const String _followMeModePrefsKey = 'follow_me_mode';
|
||||
|
||||
bool _offlineMode = false;
|
||||
int _maxCameras = 250;
|
||||
UploadMode _uploadMode = UploadMode.simulate;
|
||||
FollowMeMode _followMeMode = FollowMeMode.northUp;
|
||||
List<TileProvider> _tileProviders = [];
|
||||
String _selectedTileTypeId = '';
|
||||
|
||||
// Getters
|
||||
bool get offlineMode => _offlineMode;
|
||||
int get maxCameras => _maxCameras;
|
||||
UploadMode get uploadMode => _uploadMode;
|
||||
FollowMeMode get followMeMode => _followMeMode;
|
||||
List<TileProvider> get tileProviders => List.unmodifiable(_tileProviders);
|
||||
String get selectedTileTypeId => _selectedTileTypeId;
|
||||
|
||||
/// Get the currently selected tile type
|
||||
TileType? get selectedTileType {
|
||||
for (final provider in _tileProviders) {
|
||||
for (final tileType in provider.tileTypes) {
|
||||
if (tileType.id == _selectedTileTypeId) {
|
||||
return tileType;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get the provider that contains the selected tile type
|
||||
TileProvider? get selectedTileProvider {
|
||||
for (final provider in _tileProviders) {
|
||||
if (provider.tileTypes.any((type) => type.id == _selectedTileTypeId)) {
|
||||
return provider;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get all available tile types from all providers
|
||||
List<TileType> get allAvailableTileTypes {
|
||||
final types = <TileType>[];
|
||||
for (final provider in _tileProviders) {
|
||||
types.addAll(provider.availableTileTypes);
|
||||
}
|
||||
return types;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Initialize settings from preferences
|
||||
Future<void> init() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// Load offline mode
|
||||
_offlineMode = prefs.getBool(_offlineModePrefsKey) ?? false;
|
||||
|
||||
// Load max cameras
|
||||
if (prefs.containsKey(_maxCamerasPrefsKey)) {
|
||||
_maxCameras = prefs.getInt(_maxCamerasPrefsKey) ?? 250;
|
||||
}
|
||||
|
||||
// Load upload mode (including migration from old test_mode bool)
|
||||
if (prefs.containsKey(_uploadModePrefsKey)) {
|
||||
final idx = prefs.getInt(_uploadModePrefsKey) ?? 0;
|
||||
if (idx >= 0 && idx < UploadMode.values.length) {
|
||||
_uploadMode = UploadMode.values[idx];
|
||||
}
|
||||
} else if (prefs.containsKey(_legacyTestModePrefsKey)) {
|
||||
// migrate legacy test_mode (true->simulate, false->prod)
|
||||
final legacy = prefs.getBool(_legacyTestModePrefsKey) ?? false;
|
||||
_uploadMode = legacy ? UploadMode.simulate : UploadMode.production;
|
||||
await prefs.remove(_legacyTestModePrefsKey);
|
||||
await prefs.setInt(_uploadModePrefsKey, _uploadMode.index);
|
||||
}
|
||||
|
||||
// Load tile providers (default to built-in providers if none saved)
|
||||
await _loadTileProviders(prefs);
|
||||
|
||||
// Load follow-me mode
|
||||
if (prefs.containsKey(_followMeModePrefsKey)) {
|
||||
final modeIndex = prefs.getInt(_followMeModePrefsKey) ?? 0;
|
||||
if (modeIndex >= 0 && modeIndex < FollowMeMode.values.length) {
|
||||
_followMeMode = FollowMeMode.values[modeIndex];
|
||||
}
|
||||
}
|
||||
|
||||
// Load selected tile type (default to first available)
|
||||
_selectedTileTypeId = prefs.getString(_selectedTileTypePrefsKey) ?? '';
|
||||
if (_selectedTileTypeId.isEmpty || selectedTileType == null) {
|
||||
final firstType = allAvailableTileTypes.firstOrNull;
|
||||
if (firstType != null) {
|
||||
_selectedTileTypeId = firstType.id;
|
||||
await prefs.setString(_selectedTileTypePrefsKey, _selectedTileTypeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadTileProviders(SharedPreferences prefs) async {
|
||||
if (prefs.containsKey(_tileProvidersPrefsKey)) {
|
||||
try {
|
||||
final providersJson = prefs.getString(_tileProvidersPrefsKey);
|
||||
if (providersJson != null) {
|
||||
final providersList = jsonDecode(providersJson) as List;
|
||||
_tileProviders = providersList
|
||||
.map((json) => TileProvider.fromJson(json))
|
||||
.toList();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error loading tile providers: $e');
|
||||
// Fall back to defaults on error
|
||||
_tileProviders = DefaultTileProviders.createDefaults();
|
||||
}
|
||||
} else {
|
||||
// First time - use defaults
|
||||
_tileProviders = DefaultTileProviders.createDefaults();
|
||||
await _saveTileProviders(prefs);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveTileProviders(SharedPreferences prefs) async {
|
||||
try {
|
||||
final providersJson = jsonEncode(
|
||||
_tileProviders.map((provider) => provider.toJson()).toList(),
|
||||
);
|
||||
await prefs.setString(_tileProvidersPrefsKey, providersJson);
|
||||
} catch (e) {
|
||||
debugPrint('Error saving tile providers: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setOfflineMode(bool enabled) async {
|
||||
_offlineMode = enabled;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_offlineModePrefsKey, enabled);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
set maxCameras(int n) {
|
||||
if (n < 10) n = 10; // minimum
|
||||
_maxCameras = n;
|
||||
SharedPreferences.getInstance().then((prefs) {
|
||||
prefs.setInt(_maxCamerasPrefsKey, n);
|
||||
});
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setUploadMode(UploadMode mode) async {
|
||||
_uploadMode = mode;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_uploadModePrefsKey, mode.index);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Select a tile type by ID
|
||||
Future<void> setSelectedTileType(String tileTypeId) async {
|
||||
if (_selectedTileTypeId != tileTypeId) {
|
||||
_selectedTileTypeId = tileTypeId;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_selectedTileTypePrefsKey, tileTypeId);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Add or update a tile provider
|
||||
Future<void> addOrUpdateTileProvider(TileProvider provider) async {
|
||||
final existingIndex = _tileProviders.indexWhere((p) => p.id == provider.id);
|
||||
if (existingIndex >= 0) {
|
||||
_tileProviders[existingIndex] = provider;
|
||||
} else {
|
||||
_tileProviders.add(provider);
|
||||
}
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await _saveTileProviders(prefs);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Delete a tile provider
|
||||
Future<void> deleteTileProvider(String providerId) async {
|
||||
// Don't allow deleting all providers
|
||||
if (_tileProviders.length <= 1) return;
|
||||
|
||||
final providerToDelete = _tileProviders.firstWhereOrNull((p) => p.id == providerId);
|
||||
if (providerToDelete == null) return;
|
||||
|
||||
// If selected tile type belongs to this provider, switch to another
|
||||
if (providerToDelete.tileTypes.any((type) => type.id == _selectedTileTypeId)) {
|
||||
// Find first available tile type from remaining providers
|
||||
final remainingProviders = _tileProviders.where((p) => p.id != providerId).toList();
|
||||
final firstAvailable = remainingProviders
|
||||
.expand((p) => p.availableTileTypes)
|
||||
.firstOrNull;
|
||||
|
||||
if (firstAvailable != null) {
|
||||
_selectedTileTypeId = firstAvailable.id;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_selectedTileTypePrefsKey, _selectedTileTypeId);
|
||||
}
|
||||
}
|
||||
|
||||
_tileProviders.removeWhere((p) => p.id == providerId);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await _saveTileProviders(prefs);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Set follow-me mode
|
||||
Future<void> setFollowMeMode(FollowMeMode mode) async {
|
||||
if (_followMeMode != mode) {
|
||||
_followMeMode = mode;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_followMeModePrefsKey, mode.index);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
224
lib/state/upload_queue_state.dart
Normal file
224
lib/state/upload_queue_state.dart
Normal file
@@ -0,0 +1,224 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/pending_upload.dart';
|
||||
import '../models/osm_camera_node.dart';
|
||||
import '../services/camera_cache.dart';
|
||||
import '../services/uploader.dart';
|
||||
import '../widgets/camera_provider_with_cache.dart';
|
||||
import 'settings_state.dart';
|
||||
import 'session_state.dart';
|
||||
|
||||
class UploadQueueState extends ChangeNotifier {
|
||||
final List<PendingUpload> _queue = [];
|
||||
Timer? _uploadTimer;
|
||||
|
||||
// Getters
|
||||
int get pendingCount => _queue.length;
|
||||
List<PendingUpload> get pendingUploads => List.unmodifiable(_queue);
|
||||
|
||||
// Initialize by loading queue from storage
|
||||
Future<void> init() async {
|
||||
await _loadQueue();
|
||||
}
|
||||
|
||||
// Add a completed session to the upload queue
|
||||
void addFromSession(AddNodeSession session, {required UploadMode uploadMode}) {
|
||||
final upload = PendingUpload(
|
||||
coord: session.target!,
|
||||
direction: session.directionDegrees,
|
||||
profile: session.profile,
|
||||
operatorProfile: session.operatorProfile,
|
||||
uploadMode: uploadMode,
|
||||
);
|
||||
|
||||
_queue.add(upload);
|
||||
_saveQueue();
|
||||
|
||||
// Add to camera cache immediately so it shows on the map
|
||||
// Create a temporary node with a negative ID (to distinguish from real OSM nodes)
|
||||
// Using timestamp as negative ID to ensure uniqueness
|
||||
final tempId = -DateTime.now().millisecondsSinceEpoch;
|
||||
final tags = upload.getCombinedTags();
|
||||
tags['_pending_upload'] = 'true'; // Mark as pending for potential UI distinction
|
||||
|
||||
final tempNode = OsmCameraNode(
|
||||
id: tempId,
|
||||
coord: upload.coord,
|
||||
tags: tags,
|
||||
);
|
||||
|
||||
CameraCache.instance.addOrUpdate([tempNode]);
|
||||
// Notify camera provider to update the map
|
||||
CameraProviderWithCache.instance.notifyListeners();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Add a completed edit session to the upload queue
|
||||
void addFromEditSession(EditNodeSession session, {required UploadMode uploadMode}) {
|
||||
final upload = PendingUpload(
|
||||
coord: session.target,
|
||||
direction: session.directionDegrees,
|
||||
profile: session.profile,
|
||||
operatorProfile: session.operatorProfile,
|
||||
uploadMode: uploadMode,
|
||||
originalNodeId: session.originalNode.id, // Track which node we're editing
|
||||
);
|
||||
|
||||
_queue.add(upload);
|
||||
_saveQueue();
|
||||
|
||||
// Create two cache entries:
|
||||
|
||||
// 1. Mark the original camera with _pending_edit (grey ring) at original location
|
||||
final originalTags = Map<String, String>.from(session.originalNode.tags);
|
||||
originalTags['_pending_edit'] = 'true'; // Mark original as having pending edit
|
||||
|
||||
final originalNode = OsmCameraNode(
|
||||
id: session.originalNode.id,
|
||||
coord: session.originalNode.coord, // Keep at original location
|
||||
tags: originalTags,
|
||||
);
|
||||
|
||||
// 2. Create new temp node for the edited camera (purple ring) at new location
|
||||
final tempId = -DateTime.now().millisecondsSinceEpoch;
|
||||
final editedTags = upload.getCombinedTags();
|
||||
editedTags['_pending_upload'] = 'true'; // Mark as pending upload
|
||||
editedTags['_original_node_id'] = session.originalNode.id.toString(); // Track original for line drawing
|
||||
|
||||
final editedNode = OsmCameraNode(
|
||||
id: tempId,
|
||||
coord: upload.coord, // At new location
|
||||
tags: editedTags,
|
||||
);
|
||||
|
||||
CameraCache.instance.addOrUpdate([originalNode, editedNode]);
|
||||
// Notify camera provider to update the map
|
||||
CameraProviderWithCache.instance.notifyListeners();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearQueue() {
|
||||
_queue.clear();
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void removeFromQueue(PendingUpload upload) {
|
||||
_queue.remove(upload);
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void retryUpload(PendingUpload upload) {
|
||||
upload.error = false;
|
||||
upload.attempts = 0;
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Start the upload processing loop
|
||||
void startUploader({
|
||||
required bool offlineMode,
|
||||
required UploadMode uploadMode,
|
||||
required Future<String?> Function() getAccessToken,
|
||||
}) {
|
||||
_uploadTimer?.cancel();
|
||||
|
||||
// No uploads without queue, or if offline mode is enabled.
|
||||
if (_queue.isEmpty || offlineMode) return;
|
||||
|
||||
_uploadTimer = Timer.periodic(const Duration(seconds: 10), (t) async {
|
||||
if (_queue.isEmpty || offlineMode) {
|
||||
_uploadTimer?.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the first queue item that is NOT in error state and act on that
|
||||
final item = _queue.where((pu) => !pu.error).cast<PendingUpload?>().firstOrNull;
|
||||
if (item == null) return;
|
||||
|
||||
// Retrieve access after every tick (accounts for re-login)
|
||||
final access = await getAccessToken();
|
||||
if (access == null) return; // not logged in
|
||||
|
||||
bool ok;
|
||||
debugPrint('[UploadQueue] Processing item with uploadMode: ${item.uploadMode}');
|
||||
if (item.uploadMode == UploadMode.simulate) {
|
||||
// Simulate successful upload without calling real API
|
||||
debugPrint('[UploadQueue] Simulating upload (no real API call)');
|
||||
await Future.delayed(const Duration(seconds: 1)); // Simulate network delay
|
||||
ok = true;
|
||||
} else {
|
||||
// Real upload -- use the upload mode that was saved when this item was queued
|
||||
debugPrint('[UploadQueue] Real upload to: ${item.uploadMode}');
|
||||
final up = Uploader(access, () {
|
||||
_markAsCompleting(item);
|
||||
}, uploadMode: item.uploadMode);
|
||||
ok = await up.upload(item);
|
||||
}
|
||||
|
||||
if (ok && item.uploadMode == UploadMode.simulate) {
|
||||
// Mark as completing for simulate mode too
|
||||
_markAsCompleting(item);
|
||||
}
|
||||
if (!ok) {
|
||||
item.attempts++;
|
||||
if (item.attempts >= 3) {
|
||||
// Mark as error and stop the uploader. User can manually retry.
|
||||
item.error = true;
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
_uploadTimer?.cancel();
|
||||
} else {
|
||||
await Future.delayed(const Duration(seconds: 20));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void stopUploader() {
|
||||
_uploadTimer?.cancel();
|
||||
}
|
||||
|
||||
// Mark an item as completing (shows checkmark) and schedule removal after 1 second
|
||||
void _markAsCompleting(PendingUpload item) {
|
||||
item.completing = true;
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
|
||||
// Remove the item after 1 second
|
||||
Timer(const Duration(seconds: 1), () {
|
||||
_queue.remove(item);
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Queue persistence ----------
|
||||
Future<void> _saveQueue() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonList = _queue.map((e) => e.toJson()).toList();
|
||||
await prefs.setString('queue', jsonEncode(jsonList));
|
||||
}
|
||||
|
||||
Future<void> _loadQueue() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonStr = prefs.getString('queue');
|
||||
if (jsonStr == null) return;
|
||||
final list = jsonDecode(jsonStr) as List<dynamic>;
|
||||
_queue
|
||||
..clear()
|
||||
..addAll(list.map((e) => PendingUpload.fromJson(e)));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_uploadTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../models/camera_profile.dart';
|
||||
|
||||
class AddCameraSheet extends StatelessWidget {
|
||||
const AddCameraSheet({super.key, required this.session});
|
||||
|
||||
final AddCameraSession session;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
void _commit() {
|
||||
appState.commitSession();
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Camera queued for upload')),
|
||||
);
|
||||
}
|
||||
|
||||
void _cancel() {
|
||||
appState.cancelSession();
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
final customProfiles = appState.enabledProfiles.where((p) => !p.builtin).toList();
|
||||
final allowSubmit = customProfiles.isNotEmpty && !session.profile.builtin;
|
||||
|
||||
return Padding(
|
||||
padding:
|
||||
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade400,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
title: const Text('Profile'),
|
||||
trailing: DropdownButton<CameraProfile>(
|
||||
value: session.profile,
|
||||
items: appState.enabledProfiles
|
||||
.map((p) => DropdownMenuItem(value: p, child: Text(p.name)))
|
||||
.toList(),
|
||||
onChanged: (p) =>
|
||||
appState.updateSession(profile: p ?? session.profile),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Direction ${session.directionDegrees.round()}°'),
|
||||
subtitle: Slider(
|
||||
min: 0,
|
||||
max: 359,
|
||||
divisions: 359,
|
||||
value: session.directionDegrees,
|
||||
label: session.directionDegrees.round().toString(),
|
||||
onChanged: (v) => appState.updateSession(directionDeg: v),
|
||||
),
|
||||
),
|
||||
if (customProfiles.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: const [
|
||||
Icon(Icons.info_outline, color: Colors.red, size: 20),
|
||||
SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Enable or create a custom profile in Settings to submit new cameras.',
|
||||
style: TextStyle(color: Colors.red, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (session.profile.builtin)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: const [
|
||||
Icon(Icons.info_outline, color: Colors.orange, size: 20),
|
||||
SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'The built-in profile is for map viewing only. Please select a custom profile to submit new cameras.',
|
||||
style: TextStyle(color: Colors.orange, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: _cancel,
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: allowSubmit ? _commit : null,
|
||||
child: const Text('Submit'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
201
lib/widgets/add_node_sheet.dart
Normal file
201
lib/widgets/add_node_sheet.dart
Normal file
@@ -0,0 +1,201 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/operator_profile.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import 'refine_tags_sheet.dart';
|
||||
|
||||
class AddNodeSheet extends StatelessWidget {
|
||||
const AddNodeSheet({super.key, required this.session});
|
||||
|
||||
final AddNodeSession session;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
void _commit() {
|
||||
appState.commitSession();
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(locService.t('node.queuedForUpload'))),
|
||||
);
|
||||
}
|
||||
|
||||
void _cancel() {
|
||||
appState.cancelSession();
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList();
|
||||
final allowSubmit = appState.isLoggedIn && submittableProfiles.isNotEmpty && session.profile.isSubmittable;
|
||||
|
||||
void _openRefineTags() async {
|
||||
final result = await Navigator.push<OperatorProfile?>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RefineTagsSheet(
|
||||
selectedOperatorProfile: session.operatorProfile,
|
||||
),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
if (result != session.operatorProfile) {
|
||||
appState.updateSession(operatorProfile: result);
|
||||
}
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding:
|
||||
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade400,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
title: Text(locService.t('addNode.profile')),
|
||||
trailing: DropdownButton<NodeProfile>(
|
||||
value: session.profile,
|
||||
items: submittableProfiles
|
||||
.map((p) => DropdownMenuItem(value: p, child: Text(p.name)))
|
||||
.toList(),
|
||||
onChanged: (p) =>
|
||||
appState.updateSession(profile: p ?? session.profile),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: Text(locService.t('addNode.direction', params: [session.directionDegrees.round().toString()])),
|
||||
subtitle: Slider(
|
||||
min: 0,
|
||||
max: 359,
|
||||
divisions: 359,
|
||||
value: session.directionDegrees,
|
||||
label: session.directionDegrees.round().toString(),
|
||||
onChanged: session.profile.requiresDirection
|
||||
? (v) => appState.updateSession(directionDeg: v)
|
||||
: null, // Disables slider when requiresDirection is false
|
||||
),
|
||||
),
|
||||
if (!session.profile.requiresDirection)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, color: Colors.grey, size: 16),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
locService.t('addNode.profileNoDirectionInfo'),
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!appState.isLoggedIn)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, color: Colors.red, size: 20),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
locService.t('addNode.mustBeLoggedIn'),
|
||||
style: const TextStyle(color: Colors.red, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (submittableProfiles.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, color: Colors.red, size: 20),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
locService.t('addNode.enableSubmittableProfile'),
|
||||
style: const TextStyle(color: Colors.red, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (!session.profile.isSubmittable)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, color: Colors.orange, size: 20),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
locService.t('addNode.profileViewOnlyWarning'),
|
||||
style: const TextStyle(color: Colors.orange, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _openRefineTags,
|
||||
icon: const Icon(Icons.tune),
|
||||
label: Text(session.operatorProfile != null
|
||||
? locService.t('addNode.refineTagsWithProfile', params: [session.operatorProfile!.name])
|
||||
: locService.t('addNode.refineTags')),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: _cancel,
|
||||
child: Text(locService.cancel),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: allowSubmit ? _commit : null,
|
||||
child: Text(locService.t('actions.submit')),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
48
lib/widgets/camera_icon.dart
Normal file
48
lib/widgets/camera_icon.dart
Normal file
@@ -0,0 +1,48 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../dev_config.dart';
|
||||
|
||||
enum CameraIconType {
|
||||
real, // Blue ring - real cameras from OSM
|
||||
mock, // White ring - add camera mock point
|
||||
pending, // Purple ring - submitted/pending cameras
|
||||
editing, // Orange ring - camera being edited
|
||||
pendingEdit, // Grey ring - original camera with pending edit
|
||||
}
|
||||
|
||||
/// Simple camera icon with grey dot and colored ring
|
||||
class CameraIcon extends StatelessWidget {
|
||||
final CameraIconType type;
|
||||
|
||||
const CameraIcon({super.key, required this.type});
|
||||
|
||||
Color get _ringColor {
|
||||
switch (type) {
|
||||
case CameraIconType.real:
|
||||
return kCameraRingColorReal;
|
||||
case CameraIconType.mock:
|
||||
return kCameraRingColorMock;
|
||||
case CameraIconType.pending:
|
||||
return kCameraRingColorPending;
|
||||
case CameraIconType.editing:
|
||||
return kCameraRingColorEditing;
|
||||
case CameraIconType.pendingEdit:
|
||||
return kCameraRingColorPendingEdit;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: kCameraIconDiameter,
|
||||
height: kCameraIconDiameter,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.black.withOpacity(kCameraDotOpacity),
|
||||
border: Border.all(
|
||||
color: _ringColor,
|
||||
width: kCameraRingThickness,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@ import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
|
||||
import '../services/map_data_provider.dart';
|
||||
import '../services/camera_cache.dart';
|
||||
import '../models/camera_profile.dart';
|
||||
import '../services/network_status.dart';
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/osm_camera_node.dart';
|
||||
import '../app_state.dart';
|
||||
|
||||
@@ -19,15 +20,25 @@ class CameraProviderWithCache extends ChangeNotifier {
|
||||
Timer? _debounceTimer;
|
||||
|
||||
/// Call this to get (quickly) all cached overlays for the given view.
|
||||
/// Filters by currently enabled profiles.
|
||||
List<OsmCameraNode> getCachedCamerasForBounds(LatLngBounds bounds) {
|
||||
return CameraCache.instance.queryByBounds(bounds);
|
||||
final allCameras = CameraCache.instance.queryByBounds(bounds);
|
||||
final enabledProfiles = AppState.instance.enabledProfiles;
|
||||
|
||||
// If no profiles are enabled, show no cameras
|
||||
if (enabledProfiles.isEmpty) return [];
|
||||
|
||||
// Filter cameras to only show those matching enabled profiles
|
||||
return allCameras.where((camera) {
|
||||
return _matchesAnyProfile(camera, enabledProfiles);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Call this when the map view changes (bounds/profiles), triggers async fetch
|
||||
/// and notifies listeners/UI when new data is available.
|
||||
void fetchAndUpdate({
|
||||
required LatLngBounds bounds,
|
||||
required List<CameraProfile> profiles,
|
||||
required List<NodeProfile> profiles,
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
}) {
|
||||
// Fast: serve cached immediately
|
||||
@@ -35,24 +46,24 @@ class CameraProviderWithCache extends ChangeNotifier {
|
||||
// Debounce rapid panning/zooming
|
||||
_debounceTimer?.cancel();
|
||||
_debounceTimer = Timer(const Duration(milliseconds: 400), () async {
|
||||
final isOffline = AppState.instance.offlineMode;
|
||||
if (!isOffline) {
|
||||
try {
|
||||
final fresh = await MapDataProvider().getCameras(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
source: MapSource.remote,
|
||||
);
|
||||
if (fresh.isNotEmpty) {
|
||||
CameraCache.instance.addOrUpdate(fresh);
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[CameraProviderWithCache] Overpass fetch failed: $e');
|
||||
// Cache already holds whatever is available for the view
|
||||
try {
|
||||
// Use MapSource.auto to handle both offline and online modes appropriately
|
||||
final fresh = await MapDataProvider().getNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
source: MapSource.auto,
|
||||
);
|
||||
if (fresh.isNotEmpty) {
|
||||
CameraCache.instance.addOrUpdate(fresh);
|
||||
// Clear waiting status when camera data arrives
|
||||
NetworkStatus.instance.clearWaiting();
|
||||
notifyListeners();
|
||||
}
|
||||
} // else, only cache is used
|
||||
} catch (e) {
|
||||
debugPrint('[CameraProviderWithCache] Camera fetch failed: $e');
|
||||
// Cache already holds whatever is available for the view
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -61,4 +72,25 @@ class CameraProviderWithCache extends ChangeNotifier {
|
||||
CameraCache.instance.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Force refresh the display (useful when filters change but cache doesn't)
|
||||
void refreshDisplay() {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Check if a camera matches any of the provided profiles
|
||||
bool _matchesAnyProfile(OsmCameraNode camera, List<NodeProfile> profiles) {
|
||||
for (final profile in profiles) {
|
||||
if (_cameraMatchesProfile(camera, profile)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Check if a camera matches a specific profile (all profile tags must match)
|
||||
bool _cameraMatchesProfile(OsmCameraNode camera, NodeProfile profile) {
|
||||
for (final entry in profile.tags.entries) {
|
||||
if (camera.tags[entry.key] != entry.value) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/osm_camera_node.dart';
|
||||
|
||||
class CameraTagSheet extends StatelessWidget {
|
||||
final OsmCameraNode node;
|
||||
|
||||
const CameraTagSheet({super.key, required this.node});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Camera #${node.id}',
|
||||
style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 12),
|
||||
...node.tags.entries.map(
|
||||
(e) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
e.key,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
e.value,
|
||||
style: const TextStyle(
|
||||
color: Colors.black54,
|
||||
),
|
||||
softWrap: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
289
lib/widgets/download_area_dialog.dart
Normal file
289
lib/widgets/download_area_dialog.dart
Normal file
@@ -0,0 +1,289 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../dev_config.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../services/offline_area_service.dart';
|
||||
import '../services/offline_areas/offline_tile_utils.dart';
|
||||
|
||||
class DownloadAreaDialog extends StatefulWidget {
|
||||
final MapController controller;
|
||||
const DownloadAreaDialog({super.key, required this.controller});
|
||||
|
||||
@override
|
||||
State<DownloadAreaDialog> createState() => _DownloadAreaDialogState();
|
||||
}
|
||||
|
||||
class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
|
||||
double _zoom = 15;
|
||||
int? _minZoom;
|
||||
int? _maxPossibleZoom;
|
||||
int? _tileCount;
|
||||
double? _mbEstimate;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _recomputeEstimates());
|
||||
}
|
||||
|
||||
void _recomputeEstimates() {
|
||||
var bounds = widget.controller.camera.visibleBounds;
|
||||
|
||||
// If the visible area is nearly zero, nudge the bounds for estimation
|
||||
const double epsilon = 0.0002;
|
||||
final latSpan = (bounds.north - bounds.south).abs();
|
||||
final lngSpan = (bounds.east - bounds.west).abs();
|
||||
if (latSpan < epsilon && lngSpan < epsilon) {
|
||||
bounds = LatLngBounds(
|
||||
LatLng(bounds.southWest.latitude - epsilon, bounds.southWest.longitude - epsilon),
|
||||
LatLng(bounds.northEast.latitude + epsilon, bounds.northEast.longitude + epsilon)
|
||||
);
|
||||
} else if (latSpan < epsilon) {
|
||||
bounds = LatLngBounds(
|
||||
LatLng(bounds.southWest.latitude - epsilon, bounds.southWest.longitude),
|
||||
LatLng(bounds.northEast.latitude + epsilon, bounds.northEast.longitude)
|
||||
);
|
||||
} else if (lngSpan < epsilon) {
|
||||
bounds = LatLngBounds(
|
||||
LatLng(bounds.southWest.latitude, bounds.southWest.longitude - epsilon),
|
||||
LatLng(bounds.northEast.latitude, bounds.northEast.longitude + epsilon)
|
||||
);
|
||||
}
|
||||
|
||||
final minZoom = kWorldMaxZoom + 1;
|
||||
final maxZoom = _zoom.toInt();
|
||||
|
||||
// Calculate maximum possible zoom based on tile count limit
|
||||
final maxPossibleZoom = _calculateMaxZoomForTileLimit(bounds, minZoom);
|
||||
|
||||
final nTiles = computeTileList(bounds, minZoom, maxZoom).length;
|
||||
final totalMb = (nTiles * kTileEstimateKb) / 1024.0;
|
||||
|
||||
setState(() {
|
||||
_minZoom = minZoom;
|
||||
_maxPossibleZoom = maxPossibleZoom;
|
||||
_tileCount = nTiles;
|
||||
_mbEstimate = totalMb;
|
||||
});
|
||||
}
|
||||
|
||||
/// Calculate the maximum zoom level that keeps tile count under the limit
|
||||
int _calculateMaxZoomForTileLimit(LatLngBounds bounds, int minZoom) {
|
||||
for (int zoom = minZoom; zoom <= kAbsoluteMaxZoom; zoom++) {
|
||||
final tileCount = computeTileList(bounds, minZoom, zoom).length;
|
||||
if (tileCount > kMaxReasonableTileCount) {
|
||||
// Return the previous zoom level that was still under the limit
|
||||
return math.max(minZoom, zoom - 1);
|
||||
}
|
||||
}
|
||||
return kAbsoluteMaxZoom;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.watch<AppState>();
|
||||
final bounds = widget.controller.camera.visibleBounds;
|
||||
final maxZoom = _zoom.toInt();
|
||||
final isOfflineMode = appState.offlineMode;
|
||||
|
||||
// Use the calculated max possible zoom instead of fixed span
|
||||
final sliderMin = _minZoom?.toDouble() ?? 12.0;
|
||||
final sliderMax = _maxPossibleZoom?.toDouble() ?? 19.0;
|
||||
final sliderDivisions = math.max(1, (_maxPossibleZoom ?? 19) - (_minZoom ?? 12));
|
||||
final sliderValue = _zoom.clamp(sliderMin, sliderMax);
|
||||
|
||||
return AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
const Icon(Icons.download_for_offline),
|
||||
const SizedBox(width: 10),
|
||||
Text(locService.t('download.title')),
|
||||
],
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 350,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(locService.t('download.maxZoomLevel')),
|
||||
Text('Z${_zoom.toStringAsFixed(0)}'),
|
||||
],
|
||||
),
|
||||
|
||||
Slider(
|
||||
min: sliderMin,
|
||||
max: sliderMax,
|
||||
divisions: sliderDivisions,
|
||||
label: 'Z${_zoom.toStringAsFixed(0)}',
|
||||
value: sliderValue,
|
||||
onChanged: (v) {
|
||||
setState(() => _zoom = v);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _recomputeEstimates());
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(locService.t('download.storageEstimate')),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_mbEstimate == null
|
||||
? '…'
|
||||
: locService.t('download.tilesAndSize', params: [
|
||||
_tileCount.toString(),
|
||||
_mbEstimate!.toStringAsFixed(1)
|
||||
]),
|
||||
textAlign: TextAlign.end,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_minZoom != null)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(locService.t('download.minZoom')),
|
||||
Text('Z$_minZoom'),
|
||||
],
|
||||
),
|
||||
if (_maxPossibleZoom != null && _tileCount != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: _tileCount! > kMaxReasonableTileCount
|
||||
? Colors.orange.withOpacity(0.1)
|
||||
: Colors.green.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('download.maxRecommendedZoom', params: [_maxPossibleZoom.toString()]),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _tileCount! > kMaxReasonableTileCount
|
||||
? Colors.orange[700]
|
||||
: Colors.green[700],
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_tileCount! > kMaxReasonableTileCount
|
||||
? locService.t('download.exceedsTileLimit', params: [kMaxReasonableTileCount.toString()])
|
||||
: locService.t('download.withinTileLimit', params: [kMaxReasonableTileCount.toString()]),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: _tileCount! > kMaxReasonableTileCount
|
||||
? Colors.orange[600]
|
||||
: Colors.green[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isOfflineMode)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12.0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.orange.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.wifi_off, color: Colors.orange[700], size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
locService.t('download.offlineModeWarning'),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.orange[700],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(locService.cancel),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: isOfflineMode ? null : () async {
|
||||
try {
|
||||
final id = DateTime.now().toIso8601String().replaceAll(':', '-');
|
||||
final appDocDir = await OfflineAreaService().getOfflineAreaDir();
|
||||
final dir = "${appDocDir.path}/$id";
|
||||
|
||||
// Get current tile provider info
|
||||
final appState = context.read<AppState>();
|
||||
final selectedProvider = appState.selectedTileProvider;
|
||||
final selectedTileType = appState.selectedTileType;
|
||||
|
||||
// Fire and forget: don't await download, so dialog closes immediately
|
||||
// ignore: unawaited_futures
|
||||
OfflineAreaService().downloadArea(
|
||||
id: id,
|
||||
bounds: bounds,
|
||||
minZoom: _minZoom ?? 12,
|
||||
maxZoom: maxZoom,
|
||||
directory: dir,
|
||||
onProgress: (progress) {},
|
||||
onComplete: (status) {},
|
||||
tileProviderId: selectedProvider?.id,
|
||||
tileProviderName: selectedProvider?.name,
|
||||
tileTypeId: selectedTileType?.id,
|
||||
tileTypeName: selectedTileType?.name,
|
||||
);
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(locService.t('download.downloadStarted')),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(locService.t('download.downloadFailed', params: [e.toString()])),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Text(locService.download),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
223
lib/widgets/edit_node_sheet.dart
Normal file
223
lib/widgets/edit_node_sheet.dart
Normal file
@@ -0,0 +1,223 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/operator_profile.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../state/settings_state.dart';
|
||||
import 'refine_tags_sheet.dart';
|
||||
|
||||
class EditNodeSheet extends StatelessWidget {
|
||||
const EditNodeSheet({super.key, required this.session});
|
||||
|
||||
final EditNodeSession session;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
void _commit() {
|
||||
appState.commitEditSession();
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(locService.t('node.editQueuedForUpload'))),
|
||||
);
|
||||
}
|
||||
|
||||
void _cancel() {
|
||||
appState.cancelEditSession();
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList();
|
||||
final isSandboxMode = appState.uploadMode == UploadMode.sandbox;
|
||||
final allowSubmit = appState.isLoggedIn && submittableProfiles.isNotEmpty && session.profile.isSubmittable && !isSandboxMode;
|
||||
|
||||
void _openRefineTags() async {
|
||||
final result = await Navigator.push<OperatorProfile?>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RefineTagsSheet(
|
||||
selectedOperatorProfile: session.operatorProfile,
|
||||
),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
if (result != session.operatorProfile) {
|
||||
appState.updateEditSession(operatorProfile: result);
|
||||
}
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding:
|
||||
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade400,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
locService.t('editNode.title', params: [session.originalNode.id.toString()]),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
title: Text(locService.t('editNode.profile')),
|
||||
trailing: DropdownButton<NodeProfile>(
|
||||
value: session.profile,
|
||||
items: submittableProfiles
|
||||
.map((p) => DropdownMenuItem(value: p, child: Text(p.name)))
|
||||
.toList(),
|
||||
onChanged: (p) =>
|
||||
appState.updateEditSession(profile: p ?? session.profile),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: Text(locService.t('editNode.direction', params: [session.directionDegrees.round().toString()])),
|
||||
subtitle: Slider(
|
||||
min: 0,
|
||||
max: 359,
|
||||
divisions: 359,
|
||||
value: session.directionDegrees,
|
||||
label: session.directionDegrees.round().toString(),
|
||||
onChanged: session.profile.requiresDirection
|
||||
? (v) => appState.updateEditSession(directionDeg: v)
|
||||
: null, // Disables slider when requiresDirection is false
|
||||
),
|
||||
),
|
||||
if (!session.profile.requiresDirection)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, color: Colors.grey, size: 16),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
locService.t('editNode.profileNoDirectionInfo'),
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!appState.isLoggedIn)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, color: Colors.red, size: 20),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
locService.t('editNode.mustBeLoggedIn'),
|
||||
style: const TextStyle(color: Colors.red, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (isSandboxMode)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, color: Colors.blue, size: 20),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
locService.t('editNode.sandboxModeWarning'),
|
||||
style: const TextStyle(color: Colors.blue, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (submittableProfiles.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, color: Colors.red, size: 20),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
locService.t('editNode.enableSubmittableProfile'),
|
||||
style: const TextStyle(color: Colors.red, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (!session.profile.isSubmittable)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, color: Colors.orange, size: 20),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
locService.t('editNode.profileViewOnlyWarning'),
|
||||
style: const TextStyle(color: Colors.orange, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _openRefineTags,
|
||||
icon: const Icon(Icons.tune),
|
||||
label: Text(session.operatorProfile != null
|
||||
? locService.t('editNode.refineTagsWithProfile', params: [session.operatorProfile!.name])
|
||||
: locService.t('editNode.refineTags')),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: _cancel,
|
||||
child: Text(locService.cancel),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: allowSubmit ? _commit : null,
|
||||
child: Text(locService.t('actions.saveEdit')),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
108
lib/widgets/map/camera_markers.dart
Normal file
108
lib/widgets/map/camera_markers.dart
Normal file
@@ -0,0 +1,108 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../../dev_config.dart';
|
||||
import '../../models/osm_camera_node.dart';
|
||||
import '../node_tag_sheet.dart';
|
||||
import '../camera_icon.dart';
|
||||
|
||||
/// Smart marker widget for camera with single/double tap distinction
|
||||
class CameraMapMarker extends StatefulWidget {
|
||||
final OsmCameraNode node;
|
||||
final MapController mapController;
|
||||
const CameraMapMarker({required this.node, required this.mapController, Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<CameraMapMarker> createState() => _CameraMapMarkerState();
|
||||
}
|
||||
|
||||
class _CameraMapMarkerState extends State<CameraMapMarker> {
|
||||
Timer? _tapTimer;
|
||||
// From dev_config.dart for build-time parameters
|
||||
static const Duration tapTimeout = kMarkerTapTimeout;
|
||||
|
||||
void _onTap() {
|
||||
_tapTimer = Timer(tapTimeout, () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (_) => NodeTagSheet(node: widget.node),
|
||||
showDragHandle: true,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _onDoubleTap() {
|
||||
_tapTimer?.cancel();
|
||||
widget.mapController.move(widget.node.coord, widget.mapController.camera.zoom + 1);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tapTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Check camera state
|
||||
final isPendingUpload = widget.node.tags.containsKey('_pending_upload') &&
|
||||
widget.node.tags['_pending_upload'] == 'true';
|
||||
final isPendingEdit = widget.node.tags.containsKey('_pending_edit') &&
|
||||
widget.node.tags['_pending_edit'] == 'true';
|
||||
|
||||
CameraIconType iconType;
|
||||
if (isPendingUpload) {
|
||||
iconType = CameraIconType.pending;
|
||||
} else if (isPendingEdit) {
|
||||
iconType = CameraIconType.pendingEdit;
|
||||
} else {
|
||||
iconType = CameraIconType.real;
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: _onTap,
|
||||
onDoubleTap: _onDoubleTap,
|
||||
child: CameraIcon(type: iconType),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper class to build marker layers for cameras and user location
|
||||
class CameraMarkersBuilder {
|
||||
static List<Marker> buildCameraMarkers({
|
||||
required List<OsmCameraNode> cameras,
|
||||
required MapController mapController,
|
||||
LatLng? userLocation,
|
||||
}) {
|
||||
final markers = <Marker>[
|
||||
// Camera markers
|
||||
...cameras
|
||||
.where(_isValidCameraCoordinate)
|
||||
.map((n) => Marker(
|
||||
point: n.coord,
|
||||
width: kCameraIconDiameter,
|
||||
height: kCameraIconDiameter,
|
||||
child: CameraMapMarker(node: n, mapController: mapController),
|
||||
)),
|
||||
|
||||
// User location marker
|
||||
if (userLocation != null)
|
||||
Marker(
|
||||
point: userLocation,
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: const Icon(Icons.my_location, color: Colors.blue),
|
||||
),
|
||||
];
|
||||
|
||||
return markers;
|
||||
}
|
||||
|
||||
static bool _isValidCameraCoordinate(OsmCameraNode node) {
|
||||
return (node.coord.latitude != 0 || node.coord.longitude != 0) &&
|
||||
node.coord.latitude.abs() <= 90 &&
|
||||
node.coord.longitude.abs() <= 180;
|
||||
}
|
||||
}
|
||||
103
lib/widgets/map/camera_refresh_controller.dart
Normal file
103
lib/widgets/map/camera_refresh_controller.dart
Normal file
@@ -0,0 +1,103 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map_animations/flutter_map_animations.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../../models/node_profile.dart';
|
||||
import '../../app_state.dart' show UploadMode;
|
||||
import '../camera_provider_with_cache.dart';
|
||||
import '../../dev_config.dart';
|
||||
|
||||
/// Manages camera data refreshing, profile change detection, and camera cache operations.
|
||||
/// Handles debounced camera fetching and profile-based cache invalidation.
|
||||
class CameraRefreshController {
|
||||
late final CameraProviderWithCache _cameraProvider;
|
||||
List<NodeProfile>? _lastEnabledProfiles;
|
||||
VoidCallback? _onCamerasUpdated;
|
||||
|
||||
/// Initialize the camera refresh controller
|
||||
void initialize({required VoidCallback onCamerasUpdated}) {
|
||||
_cameraProvider = CameraProviderWithCache.instance;
|
||||
_onCamerasUpdated = onCamerasUpdated;
|
||||
_cameraProvider.addListener(_onCamerasUpdated!);
|
||||
}
|
||||
|
||||
/// Dispose of resources and listeners
|
||||
void dispose() {
|
||||
if (_onCamerasUpdated != null) {
|
||||
_cameraProvider.removeListener(_onCamerasUpdated!);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if camera profiles changed and handle cache clearing if needed.
|
||||
/// Returns true if profiles changed (triggering a refresh).
|
||||
bool checkAndHandleProfileChanges({
|
||||
required List<NodeProfile> currentEnabledProfiles,
|
||||
required VoidCallback onProfilesChanged,
|
||||
}) {
|
||||
if (_lastEnabledProfiles == null ||
|
||||
!_profileListsEqual(_lastEnabledProfiles!, currentEnabledProfiles)) {
|
||||
_lastEnabledProfiles = List.from(currentEnabledProfiles);
|
||||
|
||||
// Handle profile change with cache clearing and refresh
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// Clear camera cache to ensure fresh data for new profile combination
|
||||
_cameraProvider.clearCache();
|
||||
// Force display refresh first (for immediate UI update)
|
||||
_cameraProvider.refreshDisplay();
|
||||
// Notify that profiles changed (triggers camera refresh)
|
||||
onProfilesChanged();
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Refresh cameras from provider for the current map view
|
||||
void refreshCamerasFromProvider({
|
||||
required AnimatedMapController controller,
|
||||
required List<NodeProfile> enabledProfiles,
|
||||
required UploadMode uploadMode,
|
||||
required BuildContext context,
|
||||
}) {
|
||||
LatLngBounds? bounds;
|
||||
try {
|
||||
bounds = controller.mapController.camera.visibleBounds;
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
|
||||
final zoom = controller.mapController.camera.zoom;
|
||||
if (zoom < kCameraMinZoomLevel) {
|
||||
// Show a snackbar-style bubble warning
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Cameras not drawn below zoom level $kCameraMinZoomLevel'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_cameraProvider.fetchAndUpdate(
|
||||
bounds: bounds,
|
||||
profiles: enabledProfiles,
|
||||
uploadMode: uploadMode,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get the camera provider instance for external access
|
||||
CameraProviderWithCache get cameraProvider => _cameraProvider;
|
||||
|
||||
/// Helper to check if two profile lists are equal by comparing IDs
|
||||
bool _profileListsEqual(List<NodeProfile> list1, List<NodeProfile> list2) {
|
||||
if (list1.length != list2.length) return false;
|
||||
// Compare by profile IDs since profiles are value objects
|
||||
final ids1 = list1.map((p) => p.id).toSet();
|
||||
final ids2 = list2.map((p) => p.id).toSet();
|
||||
return ids1.length == ids2.length && ids1.containsAll(ids2);
|
||||
}
|
||||
}
|
||||
108
lib/widgets/map/direction_cones.dart
Normal file
108
lib/widgets/map/direction_cones.dart
Normal file
@@ -0,0 +1,108 @@
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../../app_state.dart';
|
||||
import '../../dev_config.dart';
|
||||
import '../../models/osm_camera_node.dart';
|
||||
|
||||
/// Helper class to build direction cone polygons for cameras
|
||||
class DirectionConesBuilder {
|
||||
static List<Polygon> buildDirectionCones({
|
||||
required List<OsmCameraNode> cameras,
|
||||
required double zoom,
|
||||
AddNodeSession? session,
|
||||
EditNodeSession? editSession,
|
||||
}) {
|
||||
final overlays = <Polygon>[];
|
||||
|
||||
// Add session cone if in add-camera mode and profile requires direction
|
||||
if (session != null && session.target != null && session.profile.requiresDirection) {
|
||||
overlays.add(_buildCone(
|
||||
session.target!,
|
||||
session.directionDegrees,
|
||||
zoom,
|
||||
isSession: true,
|
||||
));
|
||||
}
|
||||
|
||||
// Add edit session cone if in edit-camera mode and profile requires direction
|
||||
if (editSession != null && editSession.profile.requiresDirection) {
|
||||
overlays.add(_buildCone(
|
||||
editSession.target,
|
||||
editSession.directionDegrees,
|
||||
zoom,
|
||||
isSession: true,
|
||||
));
|
||||
}
|
||||
|
||||
// Add cones for cameras with direction (but exclude camera being edited)
|
||||
overlays.addAll(
|
||||
cameras
|
||||
.where((n) => _isValidCameraWithDirection(n) &&
|
||||
(editSession == null || n.id != editSession.originalNode.id))
|
||||
.map((n) => _buildCone(
|
||||
n.coord,
|
||||
n.directionDeg!,
|
||||
zoom,
|
||||
))
|
||||
);
|
||||
|
||||
return overlays;
|
||||
}
|
||||
|
||||
static bool _isValidCameraWithDirection(OsmCameraNode node) {
|
||||
return node.hasDirection &&
|
||||
node.directionDeg != null &&
|
||||
(node.coord.latitude != 0 || node.coord.longitude != 0) &&
|
||||
node.coord.latitude.abs() <= 90 &&
|
||||
node.coord.longitude.abs() <= 180;
|
||||
}
|
||||
|
||||
static bool _isPendingUpload(OsmCameraNode node) {
|
||||
return node.tags.containsKey('_pending_upload') &&
|
||||
node.tags['_pending_upload'] == 'true';
|
||||
}
|
||||
|
||||
static Polygon _buildCone(
|
||||
LatLng origin,
|
||||
double bearingDeg,
|
||||
double zoom, {
|
||||
bool isPending = false,
|
||||
bool isSession = false,
|
||||
}) {
|
||||
final halfAngle = kDirectionConeHalfAngle;
|
||||
final length = kDirectionConeBaseLength * math.pow(2, 15 - zoom);
|
||||
|
||||
// Number of points to create the arc (more = smoother curve)
|
||||
const int arcPoints = 12;
|
||||
|
||||
LatLng project(double deg) {
|
||||
final rad = deg * math.pi / 180;
|
||||
final dLat = length * math.cos(rad);
|
||||
final dLon =
|
||||
length * math.sin(rad) / math.cos(origin.latitude * math.pi / 180);
|
||||
return LatLng(origin.latitude + dLat, origin.longitude + dLon);
|
||||
}
|
||||
|
||||
// Build pizza slice with curved edge
|
||||
final points = <LatLng>[origin];
|
||||
|
||||
// Add arc points from left to right
|
||||
for (int i = 0; i <= arcPoints; i++) {
|
||||
final angle = bearingDeg - halfAngle + (i * 2 * halfAngle / arcPoints);
|
||||
points.add(project(angle));
|
||||
}
|
||||
|
||||
// Close the shape back to origin
|
||||
points.add(origin);
|
||||
|
||||
return Polygon(
|
||||
points: points,
|
||||
color: kDirectionConeColor.withOpacity(0.25),
|
||||
borderColor: kDirectionConeColor,
|
||||
borderStrokeWidth: 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
160
lib/widgets/map/gps_controller.dart
Normal file
160
lib/widgets/map/gps_controller.dart
Normal file
@@ -0,0 +1,160 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map_animations/flutter_map_animations.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../../dev_config.dart';
|
||||
import '../../app_state.dart' show FollowMeMode;
|
||||
|
||||
/// Manages GPS location tracking, follow-me modes, and location-based map animations.
|
||||
/// Handles GPS permissions, position streams, and follow-me behavior.
|
||||
class GpsController {
|
||||
StreamSubscription<Position>? _positionSub;
|
||||
LatLng? _currentLatLng;
|
||||
|
||||
/// Get the current GPS location (if available)
|
||||
LatLng? get currentLocation => _currentLatLng;
|
||||
|
||||
/// Initialize GPS location tracking
|
||||
Future<void> initializeLocation() async {
|
||||
final perm = await Geolocator.requestPermission();
|
||||
if (perm == LocationPermission.denied ||
|
||||
perm == LocationPermission.deniedForever) {
|
||||
debugPrint('[GpsController] Location permission denied');
|
||||
return;
|
||||
}
|
||||
|
||||
_positionSub = Geolocator.getPositionStream().listen((Position position) {
|
||||
final latLng = LatLng(position.latitude, position.longitude);
|
||||
_currentLatLng = latLng;
|
||||
debugPrint('[GpsController] GPS position updated: ${latLng.latitude}, ${latLng.longitude}');
|
||||
});
|
||||
}
|
||||
|
||||
/// Retry location initialization (e.g., after permission granted)
|
||||
Future<void> retryLocationInit() async {
|
||||
debugPrint('[GpsController] Retrying location initialization');
|
||||
await initializeLocation();
|
||||
}
|
||||
|
||||
/// Handle follow-me mode changes and animate map accordingly
|
||||
void handleFollowMeModeChange({
|
||||
required FollowMeMode newMode,
|
||||
required FollowMeMode oldMode,
|
||||
required AnimatedMapController controller,
|
||||
}) {
|
||||
debugPrint('[GpsController] Follow-me mode changed: $oldMode → $newMode');
|
||||
|
||||
// Only act when follow-me is first enabled and we have a current location
|
||||
if (newMode != FollowMeMode.off &&
|
||||
oldMode == FollowMeMode.off &&
|
||||
_currentLatLng != null) {
|
||||
|
||||
try {
|
||||
if (newMode == FollowMeMode.northUp) {
|
||||
controller.animateTo(
|
||||
dest: _currentLatLng!,
|
||||
zoom: controller.mapController.camera.zoom,
|
||||
duration: kFollowMeAnimationDuration,
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
} else if (newMode == FollowMeMode.rotating) {
|
||||
// When switching to rotating mode, reset to north-up first
|
||||
controller.animateTo(
|
||||
dest: _currentLatLng!,
|
||||
zoom: controller.mapController.camera.zoom,
|
||||
rotation: 0.0,
|
||||
duration: kFollowMeAnimationDuration,
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[GpsController] MapController not ready for follow-me change: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Process GPS position updates and handle follow-me animations
|
||||
void processPositionUpdate({
|
||||
required Position position,
|
||||
required FollowMeMode followMeMode,
|
||||
required AnimatedMapController controller,
|
||||
required VoidCallback onLocationUpdated,
|
||||
}) {
|
||||
final latLng = LatLng(position.latitude, position.longitude);
|
||||
_currentLatLng = latLng;
|
||||
|
||||
// Notify that location was updated (for setState, etc.)
|
||||
onLocationUpdated();
|
||||
|
||||
// Handle follow-me animations if enabled - use current mode from app state
|
||||
if (followMeMode != FollowMeMode.off) {
|
||||
debugPrint('[GpsController] GPS position update: ${latLng.latitude}, ${latLng.longitude}, follow-me: $followMeMode');
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
try {
|
||||
if (followMeMode == FollowMeMode.northUp) {
|
||||
// Follow position only, keep current rotation
|
||||
controller.animateTo(
|
||||
dest: latLng,
|
||||
zoom: controller.mapController.camera.zoom,
|
||||
duration: kFollowMeAnimationDuration,
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
} else if (followMeMode == FollowMeMode.rotating) {
|
||||
// Follow position and rotation based on heading
|
||||
final heading = position.heading;
|
||||
final speed = position.speed; // Speed in m/s
|
||||
|
||||
// Only apply rotation if moving fast enough to avoid wild spinning when stationary
|
||||
final shouldRotate = !speed.isNaN && speed >= kMinSpeedForRotationMps && !heading.isNaN;
|
||||
final rotation = shouldRotate ? -heading : controller.mapController.camera.rotation;
|
||||
|
||||
controller.animateTo(
|
||||
dest: latLng,
|
||||
zoom: controller.mapController.camera.zoom,
|
||||
rotation: rotation,
|
||||
duration: kFollowMeAnimationDuration,
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[GpsController] MapController not ready for position animation: $e');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize GPS with custom position processing callback
|
||||
Future<void> initializeWithCallback({
|
||||
required FollowMeMode followMeMode,
|
||||
required AnimatedMapController controller,
|
||||
required VoidCallback onLocationUpdated,
|
||||
required FollowMeMode Function() getCurrentFollowMeMode,
|
||||
}) async {
|
||||
final perm = await Geolocator.requestPermission();
|
||||
if (perm == LocationPermission.denied ||
|
||||
perm == LocationPermission.deniedForever) {
|
||||
debugPrint('[GpsController] Location permission denied');
|
||||
return;
|
||||
}
|
||||
|
||||
_positionSub = Geolocator.getPositionStream().listen((Position position) {
|
||||
// Get the current follow-me mode from the app state each time
|
||||
final currentFollowMeMode = getCurrentFollowMeMode();
|
||||
processPositionUpdate(
|
||||
position: position,
|
||||
followMeMode: currentFollowMeMode,
|
||||
controller: controller,
|
||||
onLocationUpdated: onLocationUpdated,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Dispose of GPS resources
|
||||
void dispose() {
|
||||
_positionSub?.cancel();
|
||||
_positionSub = null;
|
||||
debugPrint('[GpsController] GPS controller disposed');
|
||||
}
|
||||
}
|
||||
234
lib/widgets/map/layer_selector_button.dart
Normal file
234
lib/widgets/map/layer_selector_button.dart
Normal file
@@ -0,0 +1,234 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../app_state.dart';
|
||||
import '../../models/tile_provider.dart';
|
||||
import '../../services/offline_area_service.dart';
|
||||
|
||||
class LayerSelectorButton extends StatelessWidget {
|
||||
const LayerSelectorButton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FloatingActionButton(
|
||||
mini: true,
|
||||
onPressed: () => _showLayerSelector(context),
|
||||
child: const Icon(Icons.layers),
|
||||
);
|
||||
}
|
||||
|
||||
void _showLayerSelector(BuildContext context) {
|
||||
// Check if any downloads are active
|
||||
final offlineService = OfflineAreaService();
|
||||
if (offlineService.hasActiveDownloads) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Cannot change tile types while downloading offline areas'),
|
||||
duration: Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => const _LayerSelectorDialog(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LayerSelectorDialog extends StatefulWidget {
|
||||
const _LayerSelectorDialog();
|
||||
|
||||
@override
|
||||
State<_LayerSelectorDialog> createState() => _LayerSelectorDialogState();
|
||||
}
|
||||
|
||||
class _LayerSelectorDialogState extends State<_LayerSelectorDialog> {
|
||||
String? _selectedTileTypeId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final appState = context.read<AppState>();
|
||||
_selectedTileTypeId = appState.selectedTileType?.id;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
final providers = appState.tileProviders;
|
||||
|
||||
// Group tile types by provider for display
|
||||
final providerGroups = <TileProvider, List<TileType>>{};
|
||||
for (final provider in providers) {
|
||||
final availableTypes = provider.availableTileTypes;
|
||||
if (availableTypes.isNotEmpty) {
|
||||
providerGroups[provider] = availableTypes;
|
||||
}
|
||||
}
|
||||
|
||||
return Dialog(
|
||||
child: Container(
|
||||
width: double.maxFinite,
|
||||
constraints: const BoxConstraints(maxHeight: 500),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceVariant,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.layers),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'Select Map Layer',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Content
|
||||
Flexible(
|
||||
child: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
if (providerGroups.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: Text('No tile providers available'),
|
||||
),
|
||||
)
|
||||
else
|
||||
...providerGroups.entries.map((entry) {
|
||||
final provider = entry.key;
|
||||
final tileTypes = entry.value;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Provider header
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Text(
|
||||
provider.name,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Tile types
|
||||
...tileTypes.map((tileType) => _TileTypeListItem(
|
||||
tileType: tileType,
|
||||
provider: provider,
|
||||
isSelected: _selectedTileTypeId == tileType.id,
|
||||
onSelected: () {
|
||||
setState(() {
|
||||
_selectedTileTypeId = tileType.id;
|
||||
});
|
||||
appState.setSelectedTileType(tileType.id);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
)),
|
||||
],
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TileTypeListItem extends StatelessWidget {
|
||||
final TileType tileType;
|
||||
final TileProvider provider;
|
||||
final bool isSelected;
|
||||
final VoidCallback onSelected;
|
||||
|
||||
const _TileTypeListItem({
|
||||
required this.tileType,
|
||||
required this.provider,
|
||||
required this.isSelected,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
leading: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.grey.shade300,
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: tileType.previewTile != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
child: Image.memory(
|
||||
tileType.previewTile!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => _FallbackPreview(),
|
||||
),
|
||||
)
|
||||
: _FallbackPreview(),
|
||||
),
|
||||
title: Text(
|
||||
tileType.name,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.bold : null,
|
||||
color: isSelected ? Theme.of(context).colorScheme.primary : null,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
tileType.attribution,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
trailing: isSelected
|
||||
? Icon(
|
||||
Icons.check_circle,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
)
|
||||
: null,
|
||||
onTap: onSelected,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FallbackPreview extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: Colors.grey.shade200,
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.map,
|
||||
size: 24,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
145
lib/widgets/map/map_overlays.dart
Normal file
145
lib/widgets/map/map_overlays.dart
Normal file
@@ -0,0 +1,145 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
|
||||
import '../../app_state.dart';
|
||||
import '../../dev_config.dart';
|
||||
import '../camera_icon.dart';
|
||||
import 'layer_selector_button.dart';
|
||||
|
||||
/// Widget that renders all map overlay UI elements
|
||||
class MapOverlays extends StatelessWidget {
|
||||
final MapController mapController;
|
||||
final UploadMode uploadMode;
|
||||
final AddNodeSession? session;
|
||||
final EditNodeSession? editSession;
|
||||
final String? attribution; // Attribution for current tile provider
|
||||
|
||||
const MapOverlays({
|
||||
super.key,
|
||||
required this.mapController,
|
||||
required this.uploadMode,
|
||||
this.session,
|
||||
this.editSession,
|
||||
this.attribution,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
// MODE INDICATOR badge (top-right)
|
||||
if (uploadMode == UploadMode.sandbox || uploadMode == UploadMode.simulate)
|
||||
Positioned(
|
||||
top: 18,
|
||||
right: 14,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: uploadMode == UploadMode.sandbox
|
||||
? Colors.orange.withOpacity(0.90)
|
||||
: Colors.deepPurple.withOpacity(0.80),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: const [
|
||||
BoxShadow(color: Colors.black26, blurRadius: 5, offset: Offset(0,2)),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
uploadMode == UploadMode.sandbox
|
||||
? 'SANDBOX MODE'
|
||||
: 'SIMULATE',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13,
|
||||
letterSpacing: 1.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Zoom indicator, positioned above scale bar
|
||||
Positioned(
|
||||
left: 10,
|
||||
bottom: kZoomIndicatorBottomOffset,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.52),
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final zoom = mapController.camera.zoom;
|
||||
return Text(
|
||||
'Zoom: ${zoom.toStringAsFixed(2)}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Attribution overlay
|
||||
if (attribution != null)
|
||||
Positioned(
|
||||
bottom: kAttributionBottomOffset,
|
||||
left: 10,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface.withOpacity(0.9),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: Text(
|
||||
attribution!,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Zoom and layer controls (bottom-right)
|
||||
Positioned(
|
||||
bottom: 80,
|
||||
right: 16,
|
||||
child: Column(
|
||||
children: [
|
||||
// Layer selector button
|
||||
const LayerSelectorButton(),
|
||||
const SizedBox(height: 8),
|
||||
// Zoom in button
|
||||
FloatingActionButton(
|
||||
mini: true,
|
||||
heroTag: "zoom_in",
|
||||
onPressed: () {
|
||||
final zoom = mapController.camera.zoom;
|
||||
mapController.move(mapController.camera.center, zoom + 1);
|
||||
},
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Zoom out button
|
||||
FloatingActionButton(
|
||||
mini: true,
|
||||
heroTag: "zoom_out",
|
||||
onPressed: () {
|
||||
final zoom = mapController.camera.zoom;
|
||||
mapController.move(mapController.camera.center, zoom - 1);
|
||||
},
|
||||
child: const Icon(Icons.remove),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
122
lib/widgets/map/map_position_manager.dart
Normal file
122
lib/widgets/map/map_position_manager.dart
Normal file
@@ -0,0 +1,122 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map_animations/flutter_map_animations.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../dev_config.dart';
|
||||
|
||||
|
||||
/// Manages map position persistence and initial positioning.
|
||||
/// Handles saving/loading last map position and moving to initial locations.
|
||||
class MapPositionManager {
|
||||
LatLng? _initialLocation;
|
||||
double? _initialZoom;
|
||||
bool _hasMovedToInitialLocation = false;
|
||||
|
||||
/// Get the initial location (if any was loaded)
|
||||
LatLng? get initialLocation => _initialLocation;
|
||||
|
||||
/// Get the initial zoom (if any was loaded)
|
||||
double? get initialZoom => _initialZoom;
|
||||
|
||||
/// Whether we've already moved to the initial location
|
||||
bool get hasMovedToInitialLocation => _hasMovedToInitialLocation;
|
||||
|
||||
/// Load the last map position from persistent storage.
|
||||
/// Call this during initialization to set up initial location.
|
||||
Future<void> loadLastMapPosition() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final lat = prefs.getDouble(kLastMapLatKey);
|
||||
final lng = prefs.getDouble(kLastMapLngKey);
|
||||
final zoom = prefs.getDouble(kLastMapZoomKey);
|
||||
|
||||
if (lat != null && lng != null &&
|
||||
_isValidCoordinate(lat) && _isValidCoordinate(lng)) {
|
||||
final validZoom = zoom != null && _isValidZoom(zoom) ? zoom : 15.0;
|
||||
_initialLocation = LatLng(lat, lng);
|
||||
_initialZoom = validZoom;
|
||||
debugPrint('[MapPositionManager] Loaded last map position: ${_initialLocation!.latitude}, ${_initialLocation!.longitude}, zoom: $_initialZoom');
|
||||
} else {
|
||||
debugPrint('[MapPositionManager] Invalid saved coordinates, using defaults');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[MapPositionManager] Failed to load last map position: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Move to initial location if we have one and haven't moved yet.
|
||||
/// Call this after the map controller is ready.
|
||||
void moveToInitialLocationIfNeeded(AnimatedMapController controller) {
|
||||
if (!_hasMovedToInitialLocation && _initialLocation != null) {
|
||||
try {
|
||||
final zoom = _initialZoom ?? 15.0;
|
||||
// Double-check coordinates are valid before moving
|
||||
if (_isValidCoordinate(_initialLocation!.latitude) &&
|
||||
_isValidCoordinate(_initialLocation!.longitude) &&
|
||||
_isValidZoom(zoom)) {
|
||||
controller.mapController.move(_initialLocation!, zoom);
|
||||
_hasMovedToInitialLocation = true;
|
||||
debugPrint('[MapPositionManager] Moved to initial location: ${_initialLocation!.latitude}, ${_initialLocation!.longitude}');
|
||||
} else {
|
||||
debugPrint('[MapPositionManager] Invalid initial location, not moving: ${_initialLocation!.latitude}, ${_initialLocation!.longitude}, zoom: $zoom');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[MapPositionManager] Failed to move to initial location: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Save the current map position to persistent storage.
|
||||
/// Call this when the map position changes.
|
||||
Future<void> saveMapPosition(LatLng location, double zoom) async {
|
||||
try {
|
||||
// Validate coordinates and zoom before saving
|
||||
if (!_isValidCoordinate(location.latitude) ||
|
||||
!_isValidCoordinate(location.longitude) ||
|
||||
!_isValidZoom(zoom)) {
|
||||
debugPrint('[MapPositionManager] Invalid map position, not saving: lat=${location.latitude}, lng=${location.longitude}, zoom=$zoom');
|
||||
return;
|
||||
}
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setDouble(kLastMapLatKey, location.latitude);
|
||||
await prefs.setDouble(kLastMapLngKey, location.longitude);
|
||||
await prefs.setDouble(kLastMapZoomKey, zoom);
|
||||
debugPrint('[MapPositionManager] Saved last map position: ${location.latitude}, ${location.longitude}, zoom: $zoom');
|
||||
} catch (e) {
|
||||
debugPrint('[MapPositionManager] Failed to save last map position: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Clear any stored map position (useful for recovery from invalid data)
|
||||
static Future<void> clearStoredMapPosition() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(kLastMapLatKey);
|
||||
await prefs.remove(kLastMapLngKey);
|
||||
await prefs.remove(kLastMapZoomKey);
|
||||
debugPrint('[MapPositionManager] Cleared stored map position');
|
||||
} catch (e) {
|
||||
debugPrint('[MapPositionManager] Failed to clear stored map position: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate that a coordinate value is valid (not NaN, not infinite, within bounds)
|
||||
bool _isValidCoordinate(double value) {
|
||||
return !value.isNaN &&
|
||||
!value.isInfinite &&
|
||||
value >= -180.0 &&
|
||||
value <= 180.0;
|
||||
}
|
||||
|
||||
/// Validate that a zoom level is valid
|
||||
bool _isValidZoom(double zoom) {
|
||||
return !zoom.isNaN &&
|
||||
!zoom.isInfinite &&
|
||||
zoom >= 1.0 &&
|
||||
zoom <= 25.0;
|
||||
}
|
||||
}
|
||||
87
lib/widgets/map/tile_layer_manager.dart
Normal file
87
lib/widgets/map/tile_layer_manager.dart
Normal file
@@ -0,0 +1,87 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
|
||||
import '../../models/tile_provider.dart' as models;
|
||||
import '../../services/simple_tile_service.dart';
|
||||
|
||||
/// Manages tile layer creation, caching, and provider switching.
|
||||
/// Handles tile HTTP client lifecycle and cache invalidation.
|
||||
class TileLayerManager {
|
||||
late final SimpleTileHttpClient _tileHttpClient;
|
||||
int _mapRebuildKey = 0;
|
||||
String? _lastTileTypeId;
|
||||
bool? _lastOfflineMode;
|
||||
|
||||
/// Get the current map rebuild key for cache busting
|
||||
int get mapRebuildKey => _mapRebuildKey;
|
||||
|
||||
/// Initialize the tile layer manager
|
||||
void initialize() {
|
||||
_tileHttpClient = SimpleTileHttpClient();
|
||||
}
|
||||
|
||||
/// Dispose of resources
|
||||
void dispose() {
|
||||
_tileHttpClient.close();
|
||||
}
|
||||
|
||||
/// Check if cache should be cleared and increment rebuild key if needed.
|
||||
/// Returns true if cache was cleared (map should be rebuilt).
|
||||
bool checkAndClearCacheIfNeeded({
|
||||
required String? currentTileTypeId,
|
||||
required bool currentOfflineMode,
|
||||
}) {
|
||||
bool shouldClear = false;
|
||||
String? reason;
|
||||
|
||||
if ((_lastTileTypeId != null && _lastTileTypeId != currentTileTypeId)) {
|
||||
reason = 'tile type ($currentTileTypeId)';
|
||||
shouldClear = true;
|
||||
} else if ((_lastOfflineMode != null && _lastOfflineMode != currentOfflineMode)) {
|
||||
reason = 'offline mode ($currentOfflineMode)';
|
||||
shouldClear = true;
|
||||
}
|
||||
|
||||
if (shouldClear) {
|
||||
// Force map rebuild with new key to bust flutter_map cache
|
||||
_mapRebuildKey++;
|
||||
debugPrint('[TileLayerManager] *** CACHE CLEAR *** $reason changed - rebuilding map $_mapRebuildKey');
|
||||
}
|
||||
|
||||
_lastTileTypeId = currentTileTypeId;
|
||||
_lastOfflineMode = currentOfflineMode;
|
||||
|
||||
return shouldClear;
|
||||
}
|
||||
|
||||
/// Clear the tile request queue (call after cache clear)
|
||||
void clearTileQueue() {
|
||||
debugPrint('[TileLayerManager] Post-frame: Clearing tile request queue');
|
||||
_tileHttpClient.clearTileQueue();
|
||||
}
|
||||
|
||||
/// Clear tile queue immediately (for zoom changes, etc.)
|
||||
void clearTileQueueImmediate() {
|
||||
_tileHttpClient.clearTileQueue();
|
||||
}
|
||||
|
||||
/// Build tile layer widget with current provider and type.
|
||||
/// Uses fake domain that SimpleTileHttpClient can parse for cache separation.
|
||||
Widget buildTileLayer({
|
||||
required models.TileProvider? selectedProvider,
|
||||
required models.TileType? selectedTileType,
|
||||
}) {
|
||||
// Use fake domain with standard HTTPS scheme: https://tiles.local/provider/type/z/x/y
|
||||
// This naturally separates cache entries by provider and type while being HTTP-compatible
|
||||
final urlTemplate = 'https://tiles.local/${selectedProvider?.id ?? 'unknown'}/${selectedTileType?.id ?? 'unknown'}/{z}/{x}/{y}';
|
||||
|
||||
return TileLayer(
|
||||
urlTemplate: urlTemplate,
|
||||
userAgentPackageName: 'me.deflock.deflockapp',
|
||||
tileProvider: NetworkTileProvider(
|
||||
httpClient: _tileHttpClient,
|
||||
// Enable flutter_map caching - cache busting handled by URL changes and FlutterMap key
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,122 +1,118 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:http/io_client.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_map_animations/flutter_map_animations.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../services/map_data_provider.dart';
|
||||
import '../services/offline_area_service.dart';
|
||||
import '../services/network_status.dart';
|
||||
import '../models/osm_camera_node.dart';
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/tile_provider.dart';
|
||||
import 'debouncer.dart';
|
||||
import 'camera_tag_sheet.dart';
|
||||
import 'tile_provider_with_cache.dart';
|
||||
import 'camera_provider_with_cache.dart';
|
||||
import 'package:flock_map_app/dev_config.dart';
|
||||
|
||||
// --- Smart marker widget for camera with single/double tap distinction
|
||||
class _CameraMapMarker extends StatefulWidget {
|
||||
final OsmCameraNode node;
|
||||
final MapController mapController;
|
||||
const _CameraMapMarker({required this.node, required this.mapController, Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<_CameraMapMarker> createState() => _CameraMapMarkerState();
|
||||
}
|
||||
|
||||
class _CameraMapMarkerState extends State<_CameraMapMarker> {
|
||||
Timer? _tapTimer;
|
||||
// From dev_config.dart for build-time parameters
|
||||
static const Duration tapTimeout = kMarkerTapTimeout;
|
||||
|
||||
void _onTap() {
|
||||
_tapTimer = Timer(tapTimeout, () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (_) => CameraTagSheet(node: widget.node),
|
||||
showDragHandle: true,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _onDoubleTap() {
|
||||
_tapTimer?.cancel();
|
||||
widget.mapController.move(widget.node.coord, widget.mapController.camera.zoom + 1);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tapTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: _onTap,
|
||||
onDoubleTap: _onDoubleTap,
|
||||
child: const Icon(Icons.videocam, color: Colors.orange),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'camera_icon.dart';
|
||||
import 'map/camera_markers.dart';
|
||||
import 'map/direction_cones.dart';
|
||||
import 'map/map_overlays.dart';
|
||||
import 'map/map_position_manager.dart';
|
||||
import 'map/tile_layer_manager.dart';
|
||||
import 'map/camera_refresh_controller.dart';
|
||||
import 'map/gps_controller.dart';
|
||||
import 'network_status_indicator.dart';
|
||||
import '../dev_config.dart';
|
||||
import '../app_state.dart' show FollowMeMode;
|
||||
|
||||
class MapView extends StatefulWidget {
|
||||
final MapController controller;
|
||||
final AnimatedMapController controller;
|
||||
const MapView({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.followMe,
|
||||
required this.followMeMode,
|
||||
required this.onUserGesture,
|
||||
this.bottomPadding = 0.0,
|
||||
});
|
||||
|
||||
final bool followMe;
|
||||
final FollowMeMode followMeMode;
|
||||
final VoidCallback onUserGesture;
|
||||
final double bottomPadding;
|
||||
|
||||
@override
|
||||
State<MapView> createState() => _MapViewState();
|
||||
State<MapView> createState() => MapViewState();
|
||||
}
|
||||
|
||||
class _MapViewState extends State<MapView> {
|
||||
late final MapController _controller;
|
||||
final MapDataProvider _mapDataProvider = MapDataProvider();
|
||||
final Debouncer _debounce = Debouncer(kDebounceCameraRefresh);
|
||||
class MapViewState extends State<MapView> {
|
||||
late final AnimatedMapController _controller;
|
||||
final Debouncer _cameraDebounce = Debouncer(kDebounceCameraRefresh);
|
||||
final Debouncer _tileDebounce = Debouncer(const Duration(milliseconds: 150));
|
||||
final Debouncer _mapPositionDebounce = Debouncer(const Duration(milliseconds: 1000));
|
||||
|
||||
StreamSubscription<Position>? _positionSub;
|
||||
LatLng? _currentLatLng;
|
||||
|
||||
late final CameraProviderWithCache _cameraProvider;
|
||||
late final MapPositionManager _positionManager;
|
||||
late final TileLayerManager _tileManager;
|
||||
late final CameraRefreshController _cameraController;
|
||||
late final GpsController _gpsController;
|
||||
|
||||
// Track zoom to clear queue on zoom changes
|
||||
double? _lastZoom;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// _debounceTileLayerUpdate removed
|
||||
OfflineAreaService();
|
||||
_controller = widget.controller;
|
||||
_initLocation();
|
||||
_positionManager = MapPositionManager();
|
||||
_tileManager = TileLayerManager();
|
||||
_tileManager.initialize();
|
||||
_cameraController = CameraRefreshController();
|
||||
_cameraController.initialize(onCamerasUpdated: _onCamerasUpdated);
|
||||
_gpsController = GpsController();
|
||||
|
||||
// Load last map position before initializing GPS
|
||||
_positionManager.loadLastMapPosition().then((_) {
|
||||
// Move to last known position after loading and widget is built
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_positionManager.moveToInitialLocationIfNeeded(_controller);
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize GPS with callback for position updates and follow-me
|
||||
_gpsController.initializeWithCallback(
|
||||
followMeMode: widget.followMeMode,
|
||||
controller: _controller,
|
||||
onLocationUpdated: () => setState(() {}),
|
||||
getCurrentFollowMeMode: () {
|
||||
// Use mounted check to avoid calling context when widget is disposed
|
||||
if (mounted) {
|
||||
try {
|
||||
return context.read<AppState>().followMeMode;
|
||||
} catch (e) {
|
||||
debugPrint('[MapView] Could not read AppState, defaulting to off: $e');
|
||||
return FollowMeMode.off;
|
||||
}
|
||||
}
|
||||
return FollowMeMode.off;
|
||||
},
|
||||
);
|
||||
|
||||
// Set up camera overlay caching
|
||||
_cameraProvider = CameraProviderWithCache.instance;
|
||||
_cameraProvider.addListener(_onCamerasUpdated);
|
||||
// Ensure initial overlays are fetched
|
||||
// Fetch initial cameras
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_refreshCamerasFromProvider();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_positionSub?.cancel();
|
||||
_debounce.dispose();
|
||||
_cameraProvider.removeListener(_onCamerasUpdated);
|
||||
_cameraDebounce.dispose();
|
||||
_tileDebounce.dispose();
|
||||
_mapPositionDebounce.dispose();
|
||||
_cameraController.dispose();
|
||||
_tileManager.dispose();
|
||||
_gpsController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -124,90 +120,102 @@ class _MapViewState extends State<MapView> {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
/// Public method to retry location initialization (e.g., after permission granted)
|
||||
void retryLocationInit() {
|
||||
_gpsController.retryLocationInit();
|
||||
}
|
||||
|
||||
/// Expose static methods from MapPositionManager for external access
|
||||
static Future<void> clearStoredMapPosition() =>
|
||||
MapPositionManager.clearStoredMapPosition();
|
||||
|
||||
|
||||
|
||||
void _refreshCamerasFromProvider() {
|
||||
final appState = context.read<AppState>();
|
||||
LatLngBounds? bounds;
|
||||
try {
|
||||
bounds = _controller.camera.visibleBounds;
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
final zoom = _controller.camera.zoom;
|
||||
if (zoom < kCameraMinZoomLevel) {
|
||||
// Show a snackbar-style bubble, if desired
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Cameras not drawn below zoom level $kCameraMinZoomLevel'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
_cameraProvider.fetchAndUpdate(
|
||||
bounds: bounds,
|
||||
profiles: appState.enabledProfiles,
|
||||
_cameraController.refreshCamerasFromProvider(
|
||||
controller: _controller,
|
||||
enabledProfiles: appState.enabledProfiles,
|
||||
uploadMode: appState.uploadMode,
|
||||
context: context,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant MapView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.followMe && !oldWidget.followMe && _currentLatLng != null) {
|
||||
_controller.move(_currentLatLng!, _controller.camera.zoom);
|
||||
// Handle follow-me mode changes - only if it actually changed
|
||||
if (widget.followMeMode != oldWidget.followMeMode) {
|
||||
_gpsController.handleFollowMeModeChange(
|
||||
newMode: widget.followMeMode,
|
||||
oldMode: oldWidget.followMeMode,
|
||||
controller: _controller,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initLocation() async {
|
||||
final perm = await Geolocator.requestPermission();
|
||||
if (perm == LocationPermission.denied ||
|
||||
perm == LocationPermission.deniedForever) return;
|
||||
|
||||
_positionSub =
|
||||
Geolocator.getPositionStream().listen((Position position) {
|
||||
final latLng = LatLng(position.latitude, position.longitude);
|
||||
setState(() => _currentLatLng = latLng);
|
||||
if (widget.followMe) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
try {
|
||||
_controller.move(latLng, _controller.camera.zoom);
|
||||
} catch (e) {
|
||||
debugPrint('MapController not ready yet: $e');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
double _safeZoom() {
|
||||
try {
|
||||
return _controller.camera.zoom;
|
||||
return _controller.mapController.camera.zoom;
|
||||
} catch (_) {
|
||||
return 15.0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
final session = appState.session;
|
||||
final editSession = appState.editSession;
|
||||
|
||||
// Only update cameras when map moves or profiles/mode actually change (not every build!)
|
||||
// _refreshCamerasFromProvider() is now only called from map movement and relevant change handlers.
|
||||
// Check if enabled profiles changed and refresh cameras if needed
|
||||
_cameraController.checkAndHandleProfileChanges(
|
||||
currentEnabledProfiles: appState.enabledProfiles,
|
||||
onProfilesChanged: _refreshCamerasFromProvider,
|
||||
);
|
||||
|
||||
// Check if tile type OR offline mode changed and clear cache if needed
|
||||
final cacheCleared = _tileManager.checkAndClearCacheIfNeeded(
|
||||
currentTileTypeId: appState.selectedTileType?.id,
|
||||
currentOfflineMode: appState.offlineMode,
|
||||
);
|
||||
|
||||
if (cacheCleared) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_tileManager.clearTileQueue();
|
||||
});
|
||||
}
|
||||
|
||||
// Seed add‑mode target once, after first controller center is available.
|
||||
if (session != null && session.target == null) {
|
||||
try {
|
||||
final center = _controller.camera.center;
|
||||
final center = _controller.mapController.camera.center;
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => appState.updateSession(target: center),
|
||||
);
|
||||
} catch (_) {/* controller not ready yet */}
|
||||
}
|
||||
|
||||
// For edit sessions, center the map on the camera being edited initially
|
||||
if (editSession != null && _controller.mapController.camera.center != editSession.target) {
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) {
|
||||
try {
|
||||
_controller.mapController.move(editSession.target, _controller.mapController.camera.zoom);
|
||||
} catch (_) {/* controller not ready yet */}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final zoom = _safeZoom();
|
||||
// Fetch cached cameras for current map bounds (using Consumer so overlays redraw instantly)
|
||||
@@ -215,45 +223,55 @@ class _MapViewState extends State<MapView> {
|
||||
builder: (context, cameraProvider, child) {
|
||||
LatLngBounds? mapBounds;
|
||||
try {
|
||||
mapBounds = _controller.camera.visibleBounds;
|
||||
mapBounds = _controller.mapController.camera.visibleBounds;
|
||||
} catch (_) {
|
||||
mapBounds = null;
|
||||
}
|
||||
final cameras = (mapBounds != null)
|
||||
? cameraProvider.getCachedCamerasForBounds(mapBounds)
|
||||
: <OsmCameraNode>[];
|
||||
final markers = <Marker>[
|
||||
...cameras
|
||||
.where((n) => n.coord.latitude != 0 || n.coord.longitude != 0)
|
||||
.where((n) => n.coord.latitude.abs() <= 90 && n.coord.longitude.abs() <= 180)
|
||||
.map((n) => Marker(
|
||||
point: n.coord,
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: _CameraMapMarker(node: n, mapController: _controller),
|
||||
)),
|
||||
if (_currentLatLng != null)
|
||||
Marker(
|
||||
point: _currentLatLng!,
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: const Icon(Icons.my_location, color: Colors.blue),
|
||||
),
|
||||
];
|
||||
|
||||
final markers = CameraMarkersBuilder.buildCameraMarkers(
|
||||
cameras: cameras,
|
||||
mapController: _controller.mapController,
|
||||
userLocation: _gpsController.currentLocation,
|
||||
);
|
||||
|
||||
final overlays = DirectionConesBuilder.buildDirectionCones(
|
||||
cameras: cameras,
|
||||
zoom: zoom,
|
||||
session: session,
|
||||
editSession: editSession,
|
||||
);
|
||||
|
||||
// Build edit lines connecting original cameras to their edited positions
|
||||
final editLines = _buildEditLines(cameras);
|
||||
|
||||
// Build center marker for add/edit sessions
|
||||
final centerMarkers = <Marker>[];
|
||||
if (session != null || editSession != null) {
|
||||
try {
|
||||
final center = _controller.mapController.camera.center;
|
||||
centerMarkers.add(
|
||||
Marker(
|
||||
point: center,
|
||||
width: kCameraIconDiameter,
|
||||
height: kCameraIconDiameter,
|
||||
child: CameraIcon(
|
||||
type: editSession != null ? CameraIconType.editing : CameraIconType.mock,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
// Controller not ready yet
|
||||
}
|
||||
}
|
||||
|
||||
final overlays = <Polygon>[
|
||||
if (session != null && session.target != null)
|
||||
_buildCone(session.target!, session.directionDegrees, zoom),
|
||||
...cameras
|
||||
.where((n) => n.hasDirection && n.directionDeg != null)
|
||||
.where((n) => n.coord.latitude != 0 || n.coord.longitude != 0)
|
||||
.where((n) => n.coord.latitude.abs() <= 90 && n.coord.longitude.abs() <= 180)
|
||||
.map((n) => _buildCone(n.coord, n.directionDeg!, zoom)),
|
||||
];
|
||||
return Stack(
|
||||
children: [
|
||||
PolygonLayer(polygons: overlays),
|
||||
MarkerLayer(markers: markers),
|
||||
if (editLines.isNotEmpty) PolylineLayer(polylines: editLines),
|
||||
MarkerLayer(markers: [...markers, ...centerMarkers]),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -261,12 +279,16 @@ class _MapViewState extends State<MapView> {
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
FlutterMap(
|
||||
key: ValueKey(appState.offlineMode),
|
||||
mapController: _controller,
|
||||
options: MapOptions(
|
||||
initialCenter: _currentLatLng ?? LatLng(37.7749, -122.4194),
|
||||
initialZoom: 15,
|
||||
AnimatedPadding(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
padding: EdgeInsets.only(bottom: widget.bottomPadding),
|
||||
child: FlutterMap(
|
||||
key: ValueKey('map_${appState.offlineMode}_${appState.selectedTileType?.id ?? 'none'}_${_tileManager.mapRebuildKey}'),
|
||||
mapController: _controller.mapController,
|
||||
options: MapOptions(
|
||||
initialCenter: _gpsController.currentLocation ?? _positionManager.initialLocation ?? LatLng(37.7749, -122.4194),
|
||||
initialZoom: _positionManager.initialZoom ?? 15,
|
||||
maxZoom: 19,
|
||||
onPositionChanged: (pos, gesture) {
|
||||
setState(() {}); // Instant UI update for zoom, etc.
|
||||
@@ -274,156 +296,100 @@ class _MapViewState extends State<MapView> {
|
||||
if (session != null) {
|
||||
appState.updateSession(target: pos.center);
|
||||
}
|
||||
// Only request more cameras if the user navigated the map (and at valid zoom)
|
||||
if (gesture && pos.zoom >= 10) {
|
||||
_debounce(_refreshCamerasFromProvider);
|
||||
if (editSession != null) {
|
||||
appState.updateEditSession(target: pos.center);
|
||||
}
|
||||
|
||||
// Show waiting indicator when map moves (user is expecting new content)
|
||||
NetworkStatus.instance.setWaiting();
|
||||
|
||||
// Only clear tile queue on significant ZOOM changes (not panning)
|
||||
final currentZoom = pos.zoom;
|
||||
final zoomChanged = _lastZoom != null && (currentZoom - _lastZoom!).abs() > 0.5;
|
||||
|
||||
if (zoomChanged) {
|
||||
_tileDebounce(() {
|
||||
// Clear stale tile requests on zoom change (quietly)
|
||||
_tileManager.clearTileQueueImmediate();
|
||||
});
|
||||
}
|
||||
_lastZoom = currentZoom;
|
||||
|
||||
// Save map position (debounced to avoid excessive writes)
|
||||
_mapPositionDebounce(() {
|
||||
_positionManager.saveMapPosition(pos.center, pos.zoom);
|
||||
});
|
||||
|
||||
// Request more cameras on any map movement/zoom at valid zoom level (slower debounce)
|
||||
if (pos.zoom >= 10) {
|
||||
_cameraDebounce(_refreshCamerasFromProvider);
|
||||
}
|
||||
},
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
tileProvider: Provider.of<TileProviderWithCache>(context),
|
||||
urlTemplate: 'unused-{z}-{x}-{y}',
|
||||
tileSize: 256,
|
||||
tileBuilder: (ctx, tileWidget, tileImage) {
|
||||
try {
|
||||
final str = tileImage.toString();
|
||||
final regex = RegExp(r'TileCoordinate\((\d+), (\d+), (\d+)\)');
|
||||
final match = regex.firstMatch(str);
|
||||
if (match != null) {
|
||||
final x = match.group(1);
|
||||
final y = match.group(2);
|
||||
final z = match.group(3);
|
||||
final key = '$z/$x/$y';
|
||||
final bytes = TileProviderWithCache.tileCache[key];
|
||||
if (bytes != null && bytes.isNotEmpty) {
|
||||
return Image.memory(bytes, gaplessPlayback: true, fit: BoxFit.cover);
|
||||
}
|
||||
}
|
||||
return tileWidget;
|
||||
} catch (e) {
|
||||
print('tileBuilder error: $e for tileImage: ${tileImage.toString()}');
|
||||
return tileWidget;
|
||||
}
|
||||
}
|
||||
_tileManager.buildTileLayer(
|
||||
selectedProvider: appState.selectedTileProvider,
|
||||
selectedTileType: appState.selectedTileType,
|
||||
),
|
||||
cameraLayers,
|
||||
// Built-in scale bar from flutter_map
|
||||
Scalebar(
|
||||
alignment: Alignment.bottomLeft,
|
||||
padding: EdgeInsets.only(left: 8, bottom: kScaleBarBottom), // from dev_config, above attribution & BottomAppBar
|
||||
padding: EdgeInsets.only(left: 8, bottom: kScaleBarBottomOffset), // from dev_config
|
||||
textStyle: TextStyle(color: Colors.black, fontWeight: FontWeight.bold),
|
||||
lineColor: Colors.black,
|
||||
strokeWidth: 3,
|
||||
// backgroundColor removed in flutter_map >=8 (wrap in Container if needed)
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// MODE INDICATOR badge (top-right)
|
||||
if (appState.uploadMode == UploadMode.sandbox || appState.uploadMode == UploadMode.simulate)
|
||||
Positioned(
|
||||
top: 18,
|
||||
right: 14,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: appState.uploadMode == UploadMode.sandbox
|
||||
? Colors.orange.withOpacity(0.90)
|
||||
: Colors.deepPurple.withOpacity(0.80),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.black26, blurRadius: 5, offset: Offset(0,2)),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
appState.uploadMode == UploadMode.sandbox
|
||||
? 'SANDBOX MODE'
|
||||
: 'SIMULATE',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13,
|
||||
letterSpacing: 1.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Zoom indicator, positioned above scale bar
|
||||
Positioned(
|
||||
left: 10,
|
||||
bottom: kZoomIndicatorBottom, // from dev_config
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.52),
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final zoom = _controller.camera.zoom;
|
||||
return Text(
|
||||
'Zoom: ${zoom.toStringAsFixed(2)}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
// Attribution overlay
|
||||
Positioned(
|
||||
bottom: kAttributionBottom, // from dev_config
|
||||
left: 10,
|
||||
child: Container(
|
||||
color: Colors.white70,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: const Text(
|
||||
'© OpenStreetMap and contributors',
|
||||
style: TextStyle(fontSize: 11),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Fixed pin when adding camera
|
||||
if (session != null)
|
||||
IgnorePointer(
|
||||
child: Center(
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, kAddPinYOffset),
|
||||
child: Icon(Icons.place, size: 40, color: Colors.redAccent),
|
||||
),
|
||||
),
|
||||
),
|
||||
// All map overlays (mode indicator, zoom, attribution, add pin)
|
||||
MapOverlays(
|
||||
mapController: _controller.mapController,
|
||||
uploadMode: appState.uploadMode,
|
||||
session: session,
|
||||
editSession: editSession,
|
||||
attribution: appState.selectedTileType?.attribution,
|
||||
),
|
||||
|
||||
// Network status indicator (top-left)
|
||||
const NetworkStatusIndicator(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Polygon _buildCone(LatLng origin, double bearingDeg, double zoom) {
|
||||
final halfAngle = kDirectionConeHalfAngle;
|
||||
final length = kDirectionConeBaseLength * math.pow(2, 15 - zoom);
|
||||
|
||||
LatLng _project(double deg) {
|
||||
final rad = deg * math.pi / 180;
|
||||
final dLat = length * math.cos(rad);
|
||||
final dLon =
|
||||
length * math.sin(rad) / math.cos(origin.latitude * math.pi / 180);
|
||||
return LatLng(origin.latitude + dLat, origin.longitude + dLon);
|
||||
/// Build polylines connecting original cameras to their edited positions
|
||||
List<Polyline> _buildEditLines(List<OsmCameraNode> cameras) {
|
||||
final lines = <Polyline>[];
|
||||
|
||||
// Create a lookup map of original node IDs to their coordinates
|
||||
final originalNodes = <int, LatLng>{};
|
||||
for (final camera in cameras) {
|
||||
if (camera.tags['_pending_edit'] == 'true') {
|
||||
originalNodes[camera.id] = camera.coord;
|
||||
}
|
||||
}
|
||||
|
||||
final left = _project(bearingDeg - halfAngle);
|
||||
final right = _project(bearingDeg + halfAngle);
|
||||
|
||||
return Polygon(
|
||||
points: [origin, left, right, origin],
|
||||
color: Colors.redAccent.withOpacity(0.25),
|
||||
borderColor: Colors.redAccent,
|
||||
borderStrokeWidth: 1,
|
||||
);
|
||||
|
||||
// Find edited cameras and draw lines to their originals
|
||||
for (final camera in cameras) {
|
||||
final originalIdStr = camera.tags['_original_node_id'];
|
||||
if (originalIdStr != null && camera.tags['_pending_upload'] == 'true') {
|
||||
final originalId = int.tryParse(originalIdStr);
|
||||
final originalCoord = originalId != null ? originalNodes[originalId] : null;
|
||||
|
||||
if (originalCoord != null) {
|
||||
lines.add(Polyline(
|
||||
points: [originalCoord, camera.coord],
|
||||
color: kCameraRingColorPending,
|
||||
strokeWidth: 3.0,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
55
lib/widgets/measured_sheet.dart
Normal file
55
lib/widgets/measured_sheet.dart
Normal file
@@ -0,0 +1,55 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Wrapper widget that measures its child's height and reports changes via callback
|
||||
class MeasuredSheet extends StatefulWidget {
|
||||
final Widget child;
|
||||
final ValueChanged<double> onHeightChanged;
|
||||
|
||||
const MeasuredSheet({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.onHeightChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MeasuredSheet> createState() => _MeasuredSheetState();
|
||||
}
|
||||
|
||||
class _MeasuredSheetState extends State<MeasuredSheet> {
|
||||
final GlobalKey _key = GlobalKey();
|
||||
double _lastHeight = 0.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Schedule height measurement after first frame
|
||||
WidgetsBinding.instance.addPostFrameCallback(_measureHeight);
|
||||
}
|
||||
|
||||
void _measureHeight(Duration _) {
|
||||
final renderBox = _key.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (renderBox != null) {
|
||||
final height = renderBox.size.height;
|
||||
if (height != _lastHeight) {
|
||||
_lastHeight = height;
|
||||
widget.onHeightChanged(height);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return NotificationListener<SizeChangedLayoutNotification>(
|
||||
onNotification: (notification) {
|
||||
WidgetsBinding.instance.addPostFrameCallback(_measureHeight);
|
||||
return true;
|
||||
},
|
||||
child: SizeChangedLayoutNotifier(
|
||||
child: Container(
|
||||
key: _key,
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
104
lib/widgets/network_status_indicator.dart
Normal file
104
lib/widgets/network_status_indicator.dart
Normal file
@@ -0,0 +1,104 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../services/network_status.dart';
|
||||
|
||||
class NetworkStatusIndicator extends StatelessWidget {
|
||||
const NetworkStatusIndicator({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ChangeNotifierProvider.value(
|
||||
value: NetworkStatus.instance,
|
||||
child: Consumer<NetworkStatus>(
|
||||
builder: (context, networkStatus, child) {
|
||||
String message;
|
||||
IconData icon;
|
||||
Color color;
|
||||
|
||||
switch (networkStatus.currentStatus) {
|
||||
case NetworkStatusType.waiting:
|
||||
message = 'Loading...';
|
||||
icon = Icons.hourglass_empty;
|
||||
color = Colors.blue;
|
||||
break;
|
||||
|
||||
case NetworkStatusType.timedOut:
|
||||
message = 'Timed out';
|
||||
icon = Icons.hourglass_disabled;
|
||||
color = Colors.orange;
|
||||
break;
|
||||
|
||||
case NetworkStatusType.noData:
|
||||
message = 'No tiles here';
|
||||
icon = Icons.cloud_off;
|
||||
color = Colors.grey;
|
||||
break;
|
||||
|
||||
case NetworkStatusType.success:
|
||||
message = 'Tiles loaded';
|
||||
icon = Icons.check_circle;
|
||||
color = Colors.green;
|
||||
break;
|
||||
|
||||
case NetworkStatusType.issues:
|
||||
switch (networkStatus.currentIssueType) {
|
||||
case NetworkIssueType.osmTiles:
|
||||
message = 'OSM tiles slow';
|
||||
icon = Icons.map_outlined;
|
||||
color = Colors.orange;
|
||||
break;
|
||||
case NetworkIssueType.overpassApi:
|
||||
message = 'Camera data slow';
|
||||
icon = Icons.camera_alt_outlined;
|
||||
color = Colors.orange;
|
||||
break;
|
||||
case NetworkIssueType.both:
|
||||
message = 'Network issues';
|
||||
icon = Icons.cloud_off_outlined;
|
||||
color = Colors.red;
|
||||
break;
|
||||
default:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
break;
|
||||
|
||||
case NetworkStatusType.ready:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Positioned(
|
||||
top: MediaQuery.of(context).padding.top + 8,
|
||||
left: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black87,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: color, width: 1),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: color,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
message,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
100
lib/widgets/node_tag_sheet.dart
Normal file
100
lib/widgets/node_tag_sheet.dart
Normal file
@@ -0,0 +1,100 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/osm_camera_node.dart';
|
||||
import '../app_state.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
class NodeTagSheet extends StatelessWidget {
|
||||
final OsmCameraNode node;
|
||||
|
||||
const NodeTagSheet({super.key, required this.node});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final appState = context.watch<AppState>();
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
// Check if this device is editable (not a pending upload or pending edit)
|
||||
final isEditable = (!node.tags.containsKey('_pending_upload') ||
|
||||
node.tags['_pending_upload'] != 'true') &&
|
||||
(!node.tags.containsKey('_pending_edit') ||
|
||||
node.tags['_pending_edit'] != 'true');
|
||||
|
||||
void _openEditSheet() {
|
||||
Navigator.pop(context); // Close this sheet first
|
||||
appState.startEditSession(node); // HomeScreen will auto-show the edit sheet
|
||||
}
|
||||
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('node.title').replaceAll('{}', node.id.toString()),
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...node.tags.entries.map(
|
||||
(e) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
e.key,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
e.value,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
softWrap: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (isEditable) ...[
|
||||
ElevatedButton.icon(
|
||||
onPressed: _openEditSheet,
|
||||
icon: const Icon(Icons.edit, size: 18),
|
||||
label: Text(locService.edit),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(0, 36),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(locService.t('actions.close')),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
157
lib/widgets/refine_tags_sheet.dart
Normal file
157
lib/widgets/refine_tags_sheet.dart
Normal file
@@ -0,0 +1,157 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../models/operator_profile.dart';
|
||||
|
||||
class RefineTagsSheet extends StatefulWidget {
|
||||
const RefineTagsSheet({
|
||||
super.key,
|
||||
this.selectedOperatorProfile,
|
||||
});
|
||||
|
||||
final OperatorProfile? selectedOperatorProfile;
|
||||
|
||||
@override
|
||||
State<RefineTagsSheet> createState() => _RefineTagsSheetState();
|
||||
}
|
||||
|
||||
class _RefineTagsSheetState extends State<RefineTagsSheet> {
|
||||
OperatorProfile? _selectedOperatorProfile;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedOperatorProfile = widget.selectedOperatorProfile;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
final operatorProfiles = appState.operatorProfiles;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Refine Tags'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(context, widget.selectedOperatorProfile),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, _selectedOperatorProfile),
|
||||
child: const Text('Done'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
const Text(
|
||||
'Operator Profile',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (operatorProfiles.isEmpty)
|
||||
const Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Colors.grey, size: 48),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'No operator profiles defined',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
'Create operator profiles in Settings to apply additional tags to your node submissions.',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
Card(
|
||||
child: Column(
|
||||
children: [
|
||||
RadioListTile<OperatorProfile?>(
|
||||
title: const Text('None'),
|
||||
subtitle: const Text('No additional operator tags'),
|
||||
value: null,
|
||||
groupValue: _selectedOperatorProfile,
|
||||
onChanged: (value) => setState(() => _selectedOperatorProfile = value),
|
||||
),
|
||||
...operatorProfiles.map((profile) => RadioListTile<OperatorProfile?>(
|
||||
title: Text(profile.name),
|
||||
subtitle: Text('${profile.tags.length} additional tags'),
|
||||
value: profile,
|
||||
groupValue: _selectedOperatorProfile,
|
||||
onChanged: (value) => setState(() => _selectedOperatorProfile = value),
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_selectedOperatorProfile != null) ...[
|
||||
const Text(
|
||||
'Additional Tags',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_selectedOperatorProfile!.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (_selectedOperatorProfile!.tags.isEmpty)
|
||||
const Text(
|
||||
'No tags defined for this operator profile.',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
)
|
||||
else
|
||||
...(_selectedOperatorProfile!.tags.entries.map((entry) =>
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Text(
|
||||
entry.key,
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Text(
|
||||
entry.value,
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../services/map_data_provider.dart';
|
||||
import '../app_state.dart';
|
||||
|
||||
/// Singleton in-memory tile cache and async provider for custom tiles.
|
||||
class TileProviderWithCache extends TileProvider with ChangeNotifier {
|
||||
static final Map<String, Uint8List> _tileCache = {};
|
||||
static Map<String, Uint8List> get tileCache => _tileCache;
|
||||
|
||||
TileProviderWithCache();
|
||||
|
||||
@override
|
||||
ImageProvider getImage(TileCoordinates coords, TileLayer options, {MapSource source = MapSource.auto}) {
|
||||
final key = '${coords.z}/${coords.x}/${coords.y}';
|
||||
if (_tileCache.containsKey(key)) {
|
||||
final bytes = _tileCache[key]!;
|
||||
return MemoryImage(bytes);
|
||||
} else {
|
||||
_fetchAndCacheTile(coords, key, source: source);
|
||||
// Always return a placeholder until the real tile is cached
|
||||
return const AssetImage('assets/transparent_1x1.png');
|
||||
}
|
||||
}
|
||||
|
||||
static void clearCache() {
|
||||
_tileCache.clear();
|
||||
print('[TileProviderWithCache] Tile cache cleared');
|
||||
}
|
||||
|
||||
void _fetchAndCacheTile(TileCoordinates coords, String key, {MapSource source = MapSource.auto}) async {
|
||||
// Don't fire multiple fetches for the same tile simultaneously
|
||||
if (_tileCache.containsKey(key)) return;
|
||||
try {
|
||||
final bytes = await MapDataProvider().getTile(
|
||||
z: coords.z, x: coords.x, y: coords.y, source: source,
|
||||
);
|
||||
if (bytes.isNotEmpty) {
|
||||
_tileCache[key] = Uint8List.fromList(bytes);
|
||||
print('[TileProviderWithCache] Cached tile $key, bytes=${bytes.length}');
|
||||
notifyListeners(); // This updates any listening widgets
|
||||
}
|
||||
// If bytes were empty, don't cache (will re-attempt next time)
|
||||
} catch (e) {
|
||||
print('[TileProviderWithCache] Error fetching tile $key: $e');
|
||||
// Do NOT cache a failed or empty tile! Placeholder tiles will be evicted on online transition.
|
||||
}
|
||||
}
|
||||
}
|
||||
42
macos/Podfile
Normal file
42
macos/Podfile
Normal file
@@ -0,0 +1,42 @@
|
||||
platform :osx, '10.15'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
|
||||
project 'Runner', {
|
||||
'Debug' => :debug,
|
||||
'Profile' => :release,
|
||||
'Release' => :release,
|
||||
}
|
||||
|
||||
def flutter_root
|
||||
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__)
|
||||
unless File.exist?(generated_xcode_build_settings_path)
|
||||
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first"
|
||||
end
|
||||
|
||||
File.foreach(generated_xcode_build_settings_path) do |line|
|
||||
matches = line.match(/FLUTTER_ROOT\=(.*)/)
|
||||
return matches[1].strip if matches
|
||||
end
|
||||
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\""
|
||||
end
|
||||
|
||||
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
|
||||
|
||||
flutter_macos_podfile_setup
|
||||
|
||||
target 'Runner' do
|
||||
use_frameworks!
|
||||
|
||||
flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__))
|
||||
target 'RunnerTests' do
|
||||
inherit! :search_paths
|
||||
end
|
||||
end
|
||||
|
||||
post_install do |installer|
|
||||
installer.pods_project.targets.each do |target|
|
||||
flutter_additional_macos_build_settings(target)
|
||||
end
|
||||
end
|
||||
801
macos/Runner.xcodeproj/project.pbxproj
Normal file
801
macos/Runner.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,801 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 54;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXAggregateTarget section */
|
||||
33CC111A2044C6BA0003C045 /* Flutter Assemble */ = {
|
||||
isa = PBXAggregateTarget;
|
||||
buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */;
|
||||
buildPhases = (
|
||||
33CC111E2044C6BF0003C045 /* ShellScript */,
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = "Flutter Assemble";
|
||||
productName = FLX;
|
||||
};
|
||||
/* End PBXAggregateTarget section */
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; };
|
||||
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; };
|
||||
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; };
|
||||
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; };
|
||||
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
|
||||
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
|
||||
4ABD443377DEEA0E6ABDF041 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 14DE83B4CECC3B5785F26339 /* Pods_RunnerTests.framework */; };
|
||||
6C9CBD6E8FB459527EFAC650 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 787E01B101B3B87713551F4B /* Pods_Runner.framework */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 33CC10E52044A3C60003C045 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 33CC10EC2044A3C60003C045;
|
||||
remoteInfo = Runner;
|
||||
};
|
||||
33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 33CC10E52044A3C60003C045 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 33CC111A2044C6BA0003C045;
|
||||
remoteInfo = FLX;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
33CC110E2044A8840003C045 /* Bundle Framework */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 10;
|
||||
files = (
|
||||
);
|
||||
name = "Bundle Framework";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
14DE83B4CECC3B5785F26339 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||
333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
|
||||
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
|
||||
33CC10ED2044A3C60003C045 /* flock_map_app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = flock_map_app.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
|
||||
33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
|
||||
33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = "<group>"; };
|
||||
33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = "<group>"; };
|
||||
33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = "<group>"; };
|
||||
33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = "<group>"; };
|
||||
33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = "<group>"; };
|
||||
33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = "<group>"; };
|
||||
33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = "<group>"; };
|
||||
33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; };
|
||||
56FF786478D8CA9C8C96AA65 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
787E01B101B3B87713551F4B /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
|
||||
A5843D2F351DECB4BC4BEAAB /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
AB51B320061555571937E868 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
BD24094E8F1C40303547AFDE /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
|
||||
D27A8C599345B70419381EFA /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||
E7DC9C3D113BA5E9CC61AC3A /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
331C80D2294CF70F00263BE5 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
4ABD443377DEEA0E6ABDF041 /* Pods_RunnerTests.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
33CC10EA2044A3C60003C045 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
6C9CBD6E8FB459527EFAC650 /* Pods_Runner.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
331C80D6294CF71000263BE5 /* RunnerTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
331C80D7294CF71000263BE5 /* RunnerTests.swift */,
|
||||
);
|
||||
path = RunnerTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
33BA886A226E78AF003329D5 /* Configs */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
33E5194F232828860026EE4D /* AppInfo.xcconfig */,
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||
333000ED22D3DE5D00554162 /* Warnings.xcconfig */,
|
||||
);
|
||||
path = Configs;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
33CC10E42044A3C60003C045 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
33FAB671232836740065AC1E /* Runner */,
|
||||
33CEB47122A05771004F2AC0 /* Flutter */,
|
||||
331C80D6294CF71000263BE5 /* RunnerTests */,
|
||||
33CC10EE2044A3C60003C045 /* Products */,
|
||||
D73912EC22F37F3D000D13A0 /* Frameworks */,
|
||||
EDD70D25756DD7FE6827E9B4 /* Pods */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
33CC10EE2044A3C60003C045 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
33CC10ED2044A3C60003C045 /* flock_map_app.app */,
|
||||
331C80D5294CF71000263BE5 /* RunnerTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
33CC11242044D66E0003C045 /* Resources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
33CC10F22044A3C60003C045 /* Assets.xcassets */,
|
||||
33CC10F42044A3C60003C045 /* MainMenu.xib */,
|
||||
33CC10F72044A3C60003C045 /* Info.plist */,
|
||||
);
|
||||
name = Resources;
|
||||
path = ..;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
33CEB47122A05771004F2AC0 /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */,
|
||||
33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */,
|
||||
33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */,
|
||||
33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */,
|
||||
);
|
||||
path = Flutter;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
33FAB671232836740065AC1E /* Runner */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
33CC10F02044A3C60003C045 /* AppDelegate.swift */,
|
||||
33CC11122044BFA00003C045 /* MainFlutterWindow.swift */,
|
||||
33E51913231747F40026EE4D /* DebugProfile.entitlements */,
|
||||
33E51914231749380026EE4D /* Release.entitlements */,
|
||||
33CC11242044D66E0003C045 /* Resources */,
|
||||
33BA886A226E78AF003329D5 /* Configs */,
|
||||
);
|
||||
path = Runner;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D73912EC22F37F3D000D13A0 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
787E01B101B3B87713551F4B /* Pods_Runner.framework */,
|
||||
14DE83B4CECC3B5785F26339 /* Pods_RunnerTests.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
EDD70D25756DD7FE6827E9B4 /* Pods */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E7DC9C3D113BA5E9CC61AC3A /* Pods-Runner.debug.xcconfig */,
|
||||
D27A8C599345B70419381EFA /* Pods-Runner.release.xcconfig */,
|
||||
56FF786478D8CA9C8C96AA65 /* Pods-Runner.profile.xcconfig */,
|
||||
AB51B320061555571937E868 /* Pods-RunnerTests.debug.xcconfig */,
|
||||
BD24094E8F1C40303547AFDE /* Pods-RunnerTests.release.xcconfig */,
|
||||
A5843D2F351DECB4BC4BEAAB /* Pods-RunnerTests.profile.xcconfig */,
|
||||
);
|
||||
name = Pods;
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
331C80D4294CF70F00263BE5 /* RunnerTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||
buildPhases = (
|
||||
E7C7AB9BE4EB75C78762778A /* [CP] Check Pods Manifest.lock */,
|
||||
331C80D1294CF70F00263BE5 /* Sources */,
|
||||
331C80D2294CF70F00263BE5 /* Frameworks */,
|
||||
331C80D3294CF70F00263BE5 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
331C80DA294CF71000263BE5 /* PBXTargetDependency */,
|
||||
);
|
||||
name = RunnerTests;
|
||||
productName = RunnerTests;
|
||||
productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
33CC10EC2044A3C60003C045 /* Runner */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
95B0D858ED0CCD2F613E2F77 /* [CP] Check Pods Manifest.lock */,
|
||||
33CC10E92044A3C60003C045 /* Sources */,
|
||||
33CC10EA2044A3C60003C045 /* Frameworks */,
|
||||
33CC10EB2044A3C60003C045 /* Resources */,
|
||||
33CC110E2044A8840003C045 /* Bundle Framework */,
|
||||
3399D490228B24CF009A79C7 /* ShellScript */,
|
||||
910E141E4DDEB328B0A70A97 /* [CP] Embed Pods Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
33CC11202044C79F0003C045 /* PBXTargetDependency */,
|
||||
);
|
||||
name = Runner;
|
||||
productName = Runner;
|
||||
productReference = 33CC10ED2044A3C60003C045 /* flock_map_app.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
33CC10E52044A3C60003C045 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastSwiftUpdateCheck = 0920;
|
||||
LastUpgradeCheck = 1510;
|
||||
ORGANIZATIONNAME = "";
|
||||
TargetAttributes = {
|
||||
331C80D4294CF70F00263BE5 = {
|
||||
CreatedOnToolsVersion = 14.0;
|
||||
TestTargetID = 33CC10EC2044A3C60003C045;
|
||||
};
|
||||
33CC10EC2044A3C60003C045 = {
|
||||
CreatedOnToolsVersion = 9.2;
|
||||
LastSwiftMigration = 1100;
|
||||
ProvisioningStyle = Automatic;
|
||||
SystemCapabilities = {
|
||||
com.apple.Sandbox = {
|
||||
enabled = 1;
|
||||
};
|
||||
};
|
||||
};
|
||||
33CC111A2044C6BA0003C045 = {
|
||||
CreatedOnToolsVersion = 9.2;
|
||||
ProvisioningStyle = Manual;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */;
|
||||
compatibilityVersion = "Xcode 9.3";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 33CC10E42044A3C60003C045;
|
||||
productRefGroup = 33CC10EE2044A3C60003C045 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
33CC10EC2044A3C60003C045 /* Runner */,
|
||||
331C80D4294CF70F00263BE5 /* RunnerTests */,
|
||||
33CC111A2044C6BA0003C045 /* Flutter Assemble */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
331C80D3294CF70F00263BE5 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
33CC10EB2044A3C60003C045 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */,
|
||||
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
3399D490228B24CF009A79C7 /* ShellScript */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n";
|
||||
};
|
||||
33CC111E2044C6BF0003C045 /* ShellScript */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
Flutter/ephemeral/FlutterInputs.xcfilelist,
|
||||
);
|
||||
inputPaths = (
|
||||
Flutter/ephemeral/tripwire,
|
||||
);
|
||||
outputFileListPaths = (
|
||||
Flutter/ephemeral/FlutterOutputs.xcfilelist,
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire";
|
||||
};
|
||||
910E141E4DDEB328B0A70A97 /* [CP] Embed Pods Frameworks */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
95B0D858ED0CCD2F613E2F77 /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
E7C7AB9BE4EB75C78762778A /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
331C80D1294CF70F00263BE5 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
33CC10E92044A3C60003C045 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */,
|
||||
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */,
|
||||
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
331C80DA294CF71000263BE5 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 33CC10EC2044A3C60003C045 /* Runner */;
|
||||
targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */;
|
||||
};
|
||||
33CC11202044C79F0003C045 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */;
|
||||
targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
33CC10F42044A3C60003C045 /* MainMenu.xib */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
33CC10F52044A3C60003C045 /* Base */,
|
||||
);
|
||||
name = MainMenu.xib;
|
||||
path = Runner;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
331C80DB294CF71000263BE5 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = AB51B320061555571937E868 /* Pods-RunnerTests.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.flockMapApp.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/flock_map_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/flock_map_app";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
331C80DC294CF71000263BE5 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = BD24094E8F1C40303547AFDE /* Pods-RunnerTests.release.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.flockMapApp.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/flock_map_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/flock_map_app";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
331C80DD294CF71000263BE5 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = A5843D2F351DECB4BC4BEAAB /* Pods-RunnerTests.profile.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.flockMapApp.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/flock_map_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/flock_map_app";
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
338D0CE9231458BD00FA5F75 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CODE_SIGN_IDENTITY = "-";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.15;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
338D0CEA231458BD00FA5F75 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
338D0CEB231458BD00FA5F75 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
33CC10F92044A3C60003C045 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CODE_SIGN_IDENTITY = "-";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.15;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
33CC10FA2044A3C60003C045 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CODE_SIGN_IDENTITY = "-";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.15;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
33CC10FC2044A3C60003C045 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
33CC10FD2044A3C60003C045 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
33CC111C2044C6BA0003C045 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
33CC111D2044C6BA0003C045 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
331C80DB294CF71000263BE5 /* Debug */,
|
||||
331C80DC294CF71000263BE5 /* Release */,
|
||||
331C80DD294CF71000263BE5 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
33CC10F92044A3C60003C045 /* Debug */,
|
||||
33CC10FA2044A3C60003C045 /* Release */,
|
||||
338D0CE9231458BD00FA5F75 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
33CC10FC2044A3C60003C045 /* Debug */,
|
||||
33CC10FD2044A3C60003C045 /* Release */,
|
||||
338D0CEA231458BD00FA5F75 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
33CC111C2044C6BA0003C045 /* Debug */,
|
||||
33CC111D2044C6BA0003C045 /* Release */,
|
||||
338D0CEB231458BD00FA5F75 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 33CC10E52044A3C60003C045 /* Project object */;
|
||||
}
|
||||
48
pubspec.lock
48
pubspec.lock
@@ -158,6 +158,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.2.1"
|
||||
flutter_map_animations:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_map_animations
|
||||
sha256: bf583863561861aaaf4854ae7ed8940d79bea7d32918bf7a85d309b25235a09e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.0"
|
||||
flutter_native_splash:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -214,6 +222,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
flutter_svg:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_svg
|
||||
sha256: cd57f7969b4679317c17af6fd16ee233c1e60a82ed209d8a475c54fd6fd6f845
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
flutter_web_auth_2:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -403,6 +419,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_parsing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_parsing
|
||||
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -696,6 +720,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.1"
|
||||
vector_graphics:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics
|
||||
sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.19"
|
||||
vector_graphics_codec:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics_codec
|
||||
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.13"
|
||||
vector_graphics_compiler:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics_compiler
|
||||
sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.19"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
11
pubspec.yaml
11
pubspec.yaml
@@ -1,5 +1,5 @@
|
||||
name: flock_map_app
|
||||
description: Simple OSM camera‑mapping client
|
||||
name: deflockapp
|
||||
description: Map public surveillance infrastructure with OpenStreetMap
|
||||
publish_to: "none"
|
||||
version: 0.5.0
|
||||
|
||||
@@ -13,10 +13,11 @@ dependencies:
|
||||
# UI & Map
|
||||
provider: ^6.1.2
|
||||
flutter_map: ^8.2.1
|
||||
# (removed: using built-in Scalebar from flutter_map >= v6)
|
||||
flutter_map_animations: ^0.9.0
|
||||
latlong2: ^0.9.0
|
||||
geolocator: ^10.1.0
|
||||
http: ^1.2.1
|
||||
flutter_svg: ^2.0.10
|
||||
|
||||
# Auth, storage, prefs
|
||||
oauth2_client: ^4.2.0
|
||||
@@ -38,6 +39,8 @@ flutter:
|
||||
- assets/info.txt
|
||||
- assets/app_icon.png
|
||||
- assets/transparent_1x1.png
|
||||
- assets/deflock-logo.svg
|
||||
- lib/localizations/
|
||||
|
||||
flutter_native_splash:
|
||||
color: "#202020"
|
||||
@@ -49,4 +52,4 @@ flutter_icons:
|
||||
android: true
|
||||
ios: true
|
||||
image_path: "assets/app_icon.png"
|
||||
min_sdk_android: 21
|
||||
min_sdk_android: 21
|
||||
|
||||
83
test/models/pending_upload_test.dart
Normal file
83
test/models/pending_upload_test.dart
Normal file
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:deflockapp/models/pending_upload.dart';
|
||||
import 'package:deflockapp/models/node_profile.dart';
|
||||
import 'package:deflockapp/state/settings_state.dart';
|
||||
|
||||
void main() {
|
||||
group('PendingUpload', () {
|
||||
test('should serialize and deserialize upload mode correctly', () {
|
||||
// Test each upload mode
|
||||
final testModes = [
|
||||
UploadMode.production,
|
||||
UploadMode.sandbox,
|
||||
UploadMode.simulate,
|
||||
];
|
||||
|
||||
for (final mode in testModes) {
|
||||
final original = PendingUpload(
|
||||
coord: LatLng(37.7749, -122.4194),
|
||||
direction: 90.0,
|
||||
profile: NodeProfile.flock(),
|
||||
uploadMode: mode,
|
||||
);
|
||||
|
||||
// Serialize to JSON
|
||||
final json = original.toJson();
|
||||
|
||||
// Deserialize from JSON
|
||||
final restored = PendingUpload.fromJson(json);
|
||||
|
||||
// Verify upload mode is preserved
|
||||
expect(restored.uploadMode, equals(mode));
|
||||
expect(restored.uploadModeDisplayName, equals(original.uploadModeDisplayName));
|
||||
|
||||
// Verify other fields too
|
||||
expect(restored.coord.latitude, equals(original.coord.latitude));
|
||||
expect(restored.coord.longitude, equals(original.coord.longitude));
|
||||
expect(restored.direction, equals(original.direction));
|
||||
expect(restored.profile.id, equals(original.profile.id));
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle legacy JSON without uploadMode', () {
|
||||
// Simulate old JSON format without uploadMode field
|
||||
final legacyJson = {
|
||||
'lat': 37.7749,
|
||||
'lon': -122.4194,
|
||||
'dir': 90.0,
|
||||
'profile': NodeProfile.flock().toJson(),
|
||||
'originalNodeId': null,
|
||||
'attempts': 0,
|
||||
'error': false,
|
||||
// Note: no 'uploadMode' field
|
||||
};
|
||||
|
||||
final upload = PendingUpload.fromJson(legacyJson);
|
||||
|
||||
// Should default to production mode for legacy entries
|
||||
expect(upload.uploadMode, equals(UploadMode.production));
|
||||
expect(upload.uploadModeDisplayName, equals('Production'));
|
||||
});
|
||||
|
||||
test('should correctly identify edits vs new cameras', () {
|
||||
final newCamera = PendingUpload(
|
||||
coord: LatLng(37.7749, -122.4194),
|
||||
direction: 90.0,
|
||||
profile: NodeProfile.flock(),
|
||||
uploadMode: UploadMode.production,
|
||||
);
|
||||
|
||||
final editCamera = PendingUpload(
|
||||
coord: LatLng(37.7749, -122.4194),
|
||||
direction: 90.0,
|
||||
profile: CameraProfile.flock(),
|
||||
uploadMode: UploadMode.production,
|
||||
originalNodeId: 12345,
|
||||
);
|
||||
|
||||
expect(newCamera.isEdit, isFalse);
|
||||
expect(editCamera.isEdit, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:flock_map_app/main.dart';
|
||||
import 'package:deflockapp/main.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||
|
||||
Reference in New Issue
Block a user