Compare commits

...

128 Commits

Author SHA1 Message Date
stopflock
5c525900f1 bump version, disable dev mode 2025-09-28 23:18:51 -05:00
stopflock
28828fbac0 Merge pull request #15 from FoggedLens/sandbox-enhancements
Sandbox enhancements
2025-09-28 23:17:52 -05:00
stopflock
9bf46721f0 Clear node cache when switching to/from sandbox 2025-09-28 23:16:01 -05:00
stopflock
363439f712 Edits and deletes now working in sandbox 2025-09-28 22:49:07 -05:00
stopflock
38f15a1f8b fetch from sandbox 2025-09-28 22:18:59 -05:00
stopflock
a05abd8bd8 Deletions! 2025-09-28 21:44:28 -05:00
stopflock
c8a8d4c81f update readme todos 2025-09-28 20:44:30 -05:00
stopflock
63e8934490 turn dev mode back off - oops 2025-09-28 20:20:28 -05:00
stopflock
4053c9b39b use real node id for pending uploads, more camera -> node 2025-09-28 20:20:00 -05:00
stopflock
4ad33d17e0 Remove world map area 2025-09-28 19:00:07 -05:00
stopflock
c9f1ecf7d0 give do_build --ios and --android options. bump version. 2025-09-28 18:14:08 -05:00
stopflock
7c49b38230 Fix offline area size estimates, zoom level metadata display 2025-09-28 17:43:03 -05:00
stopflock
25f0e358a3 clean up debug logging 2025-09-28 17:02:58 -05:00
stopflock
0cbcec7017 improve data indicator, offline fetching, offline area loading 2025-09-28 17:00:34 -05:00
stopflock
68289135bd bump version 2025-09-26 17:21:54 -05:00
stopflock
23b7586e25 tag node -> add node, better element offsets, fix splash background on iOS 2025-09-26 15:40:30 -05:00
stopflock
a2b842fb67 offline areas localization 2025-09-26 14:43:57 -05:00
stopflock
175bc8831a new icons, remove upload dests 2025-09-26 14:31:50 -05:00
stopflock
99ce659064 macos specific files for permissions etc, bundle id fix for ios 2025-09-01 11:02:27 -05:00
stopflock
2b1b98ae0f Merge pull request #13 from FoggedLens/deflock-rebrand
Deflock rebrand, localizations
2025-08-31 21:42:06 -05:00
stopflock
ec063e9c27 handle async browser login better 2025-08-31 21:17:37 -05:00
stopflock
bdddbb5d8e finish localizations. prev commit also fixed client id / deflockapp auth 2025-08-31 14:26:05 -05:00
stopflock
f05a31f40b more localization 2025-08-31 14:01:03 -05:00
stopflock
3150297bb0 stopflock -> deflock osm client ids 2025-08-31 11:35:27 -05:00
stopflock
988516c040 bump version 2025-08-31 11:16:53 -05:00
stopflock
fa6b6ffcda localizations, dark mode touchups 2025-08-31 11:16:20 -05:00
stopflock
8381388ffa svg logo, dark button bar 2025-08-30 23:55:54 -05:00
stopflock
fa16e3c299 phase 1 2025-08-30 23:17:08 -05:00
stopflock
a17c50188e direction cones only when directional profile selected 2025-08-29 20:26:21 -05:00
stopflock
5c2bfbc76e Adjust map view when adding/editing to account for bottom sheet 2025-08-29 20:09:42 -05:00
stopflock
a8ac237317 more cameras -> nodes 2025-08-29 18:20:42 -05:00
stopflock
eeedbd7da7 clean up overpass fetching 2025-08-29 16:52:50 -05:00
stopflock
3ddebd2664 nodes, not cameras 2025-08-29 16:44:34 -05:00
stopflock
b5c210d009 fix extra follow-me state updates 2025-08-29 15:33:06 -05:00
stopflock
208b3486f3 first pass at operator profiles 2025-08-29 15:09:19 -05:00
stopflock
04a6d129b7 version bump 2025-08-29 14:21:06 -05:00
stopflock
944df59d7c remove todos from readme 2025-08-29 14:16:50 -05:00
stopflock
29031b1372 dont let user edit editable....... lol and make some builtins editable 2025-08-29 14:08:47 -05:00
stopflock
6bcfef0caa submittable is now an option on editable profiles 2025-08-29 13:53:41 -05:00
stopflock
d2a3e96a86 allow editing of certain builtin profiles 2025-08-29 13:48:08 -05:00
stopflock
395ef77fe3 gunshot detection - direction optional as defined by profile 2025-08-29 13:47:32 -05:00
stopflock
57acff8ae7 update readme 2025-08-29 12:00:18 -05:00
stopflock
a437d9bf60 1000 camera warning limit 2025-08-29 11:29:52 -05:00
stopflock
c4c1505253 refactor follow me mode state handling 2025-08-29 11:08:50 -05:00
stopflock
42c03eca7d fix follow me not turning off 2025-08-29 10:37:19 -05:00
stopflock
bcc4461621 add todo 2025-08-28 23:53:18 -05:00
stopflock
3cb875b67a bump version again 2025-08-28 23:51:21 -05:00
stopflock
d03ef6b50d update readme 2025-08-28 23:50:31 -05:00
stopflock
6db691dbeb Break out follow-me / gps stuff from map_view 2025-08-28 23:50:08 -05:00
stopflock
5ccf215f4e Separate camera_refresh from map view 2025-08-28 23:49:47 -05:00
stopflock
deb9a4272b pull out the tile layer manager 2025-08-28 23:49:19 -05:00
stopflock
1b3c3e620c put map position save/restore into its own file 2025-08-28 23:48:57 -05:00
stopflock
c42d3afd0b Fix edit submission (addnode version to xml changeset) and improve queue UI upon successful submission. And bump version - patch for 0.9.4. 2025-08-28 23:29:34 -05:00
stopflock
f4ae861bc6 de-vibe readme 2025-08-28 17:32:05 -05:00
stopflock
07d18ae33c update readme 2025-08-28 16:34:10 -05:00
stopflock
92255eb03e bump version, add roadmap 2025-08-28 15:59:07 -05:00
stopflock
3026b88230 reopen to last location 2025-08-28 15:36:31 -05:00
stopflock
728cef22af smooth transitions 2025-08-28 15:07:30 -05:00
stopflock
d7fbfaaaeb add profile name to changeset comment 2025-08-28 14:00:40 -05:00
stopflock
9c05f1d7a9 add upload mode to queue entries to prevent cross-submission 2025-08-28 13:51:44 -05:00
stopflock
2c275ec528 prevent edits in sandbox mode 2025-08-28 13:23:12 -05:00
stopflock
f8726880d7 more tags on builtin profiles 2025-08-28 13:18:10 -05:00
stopflock
497b9e52be Merge pull request #12 from stopflock/edits
Edits
2025-08-28 12:40:47 -05:00
stopflock
d9f6c8c8e0 Update existing node instead of creating new. DNU ANY COMMIT ON THIS BRANCH PRIOR TO HERE!!!!111!!!1!! 2025-08-28 12:39:30 -05:00
stopflock
45bf73aeee preserve _tags in cam cache, make line purple and thicker 2025-08-28 12:35:40 -05:00
stopflock
7ff945e262 edit line 2025-08-28 12:15:26 -05:00
stopflock
26d8eca312 UX working 2025-08-28 11:32:53 -05:00
stopflock
efbb8765de location editable 2025-08-28 11:24:22 -05:00
stopflock
fae1cac6e4 still not able to refine location 2025-08-28 10:49:02 -05:00
stopflock
aee0dcf8b8 builds, needs work 2025-08-28 10:22:57 -05:00
stopflock
2db4f597dc allow viewing builtin profiles 2025-08-27 22:13:51 -05:00
stopflock
376fa27736 better builtin profiles 2025-08-27 21:24:22 -05:00
stopflock
24b20e8a57 fix gps on android 2025-08-27 18:24:47 -05:00
stopflock
2d0dc7fd66 ternary follow me 2025-08-26 23:46:38 -05:00
stopflock
b735283f27 smoother follow me 2025-08-26 22:58:24 -05:00
stopflock
ebf7f93dd5 deflock-ify icons 2025-08-26 20:52:03 -05:00
stopflock
d56a6e8e7c more status indicator improvements 2025-08-26 19:37:27 -05:00
stopflock
84e057c986 fix camera caching and filtering from offline areas - in theory 2025-08-26 19:17:45 -05:00
stopflock
c1e25ec5b1 improve network status indicator 2025-08-26 18:35:52 -05:00
stopflock
a3edcfc2de finalize code paths for offline areas, caching, in light of multiple tile providers 2025-08-26 17:52:14 -05:00
stopflock
17c9ee0c5c idk but it's better - cache busting works. 2025-08-24 19:38:42 -05:00
stopflock
9e620ef9e4 Consolidate / dedupe some code 2025-08-24 17:46:58 -05:00
stopflock
bedfdcca6e fetch tiles from selected provider 2025-08-24 16:13:50 -05:00
stopflock
f1c73a5e55 settings area metadata, offline areas support for tile types 2025-08-24 15:31:02 -05:00
stopflock
4ee783793f genericize tiles_from submodule, simpletileservice. 2025-08-24 15:08:36 -05:00
stopflock
aada97295b move layer selection to map page, improve settings, dynamic attribution 2025-08-24 14:41:29 -05:00
stopflock
813f4f69ea cusstom providers settings 2025-08-24 14:18:04 -05:00
stopflock
2d615128aa generic providers 2025-08-24 14:08:15 -05:00
stopflock
024d3f09c3 fix arcgis satellite source 2025-08-23 21:51:22 -05:00
stopflock
e65b9f58a6 holy crap tile types 2025-08-23 21:40:36 -05:00
stopflock
7bd6f68a99 add no offline data status indicator 2025-08-23 20:06:46 -05:00
stopflock
f11bd6e238 add timed out status indicator 2025-08-23 19:49:19 -05:00
stopflock
f45279ecfe retry for location permission when follow-me enabled 2025-08-23 18:46:21 -05:00
stopflock
d6625ccc23 holy crap I think this is working. 2025-08-23 18:23:18 -05:00
stopflock
722e640a72 tile fetching and caching much improved 2025-08-23 18:08:25 -05:00
stopflock
a21e807d88 lot of changes, got rid of custom cache stuff, now stepping in the way of http fetch instead of screwing with flutter map. 2025-08-23 17:42:53 -05:00
stopflock
a2bc3309c0 chasing excess tile fetching and lack of correct cache clearing - NOT WORKING 2025-08-23 12:27:04 -05:00
stopflock
f6adffc84e fixes tile re-rendering after long rate limit periods without having to zoom/pan 2025-08-22 23:44:49 -05:00
stopflock
01f73322c7 fixes offline -> online transition for tile cache re-fetching, also display of camera profiles only when enabled 2025-08-22 23:27:01 -05:00
stopflock
257aefb2fc cleanup 2025-08-22 21:16:59 -05:00
stopflock
63ebc2b682 ho lee shet 2025-08-22 21:04:30 -05:00
stopflock
1f3849cd84 break up offlin areas 2025-08-21 21:50:30 -05:00
stopflock
e35266c160 better offline area max calculation, and added guardrails 2025-08-21 20:22:13 -05:00
stopflock
05de16b2e2 cleanup round 2025-08-21 19:42:02 -05:00
stopflock
32507e1646 fix offlines cameras loading on zoom out 2025-08-21 19:20:45 -05:00
stopflock
1272eb9409 break up map_view 2025-08-21 19:15:59 -05:00
stopflock
4cc8929378 break up home screen - separate out download dialog 2025-08-21 19:04:16 -05:00
stopflock
44707bf064 fix download dialog 2025-08-21 18:54:07 -05:00
stopflock
ff9a052d3f broke download dialog, cleaned up debug stuff 2025-08-21 18:52:53 -05:00
stopflock
df5e26f78d breakup app_state 2025-08-21 18:39:09 -05:00
stopflock
865f91ea55 bump version 2025-08-20 00:35:49 -05:00
stopflock
268c9ebb3a min zoom for offline areas is now max world zoom + 1 2025-08-19 17:54:27 -05:00
stopflock
7875fd0d58 fix loading cameras from offline areas 2025-08-19 17:29:32 -05:00
stopflock
4bb57580cd pending uploads in purple 2025-08-18 23:49:10 -05:00
stopflock
5521da28c4 add submitted cameras to cache immediately 2025-08-18 23:41:29 -05:00
stopflock
e5d00803f7 add zoom in/out buttons 2025-08-18 23:12:16 -05:00
stopflock
a73605cc53 disable follow me when adding a camera 2025-08-18 23:07:34 -05:00
stopflock
7aa0c9dff4 fix macos builds - 10.15+ only 2025-08-17 23:09:16 -05:00
stopflock
e2830a189b Merge pull request #11 from stopflock/camera-edits
Everything But Camera Edits
2025-08-17 12:16:42 -05:00
stopflock
d9beeb9d83 Revert "Move tag camera and download buttons to a bar instead of floating"
This reverts commit 6aaddb4fe2.
2025-08-17 12:13:57 -05:00
stopflock
446b70eaff move buttons to fake bottom bar 2025-08-17 11:57:51 -05:00
stopflock
2829730705 add a really dumb script to build apk and ipa locally 2025-08-17 11:57:43 -05:00
stopflock
a131fb61e0 Merge pull request #10 from stopflock/camera-edits
Everything but camera edits
2025-08-16 22:17:02 -05:00
stopflock
6aaddb4fe2 Move tag camera and download buttons to a bar instead of floating 2025-08-16 20:55:55 -05:00
stopflock
e2adbf85ad Make default profile less specific 2025-08-16 20:00:19 -05:00
stopflock
6d079b3c34 forgot to import dev_config to uploader 2025-08-16 19:56:27 -05:00
stopflock
e293727ec8 Put version reported to OSM into dev_config 2025-08-16 19:53:47 -05:00
stopflock
9375f48a07 fix default upload dest (should be simulate) and add dev_config param for "tag camera" pin v. offset 2025-08-16 19:45:48 -05:00
144 changed files with 10774 additions and 2492 deletions

7
.gitignore vendored
View File

@@ -80,3 +80,10 @@ Thumbs.db
*.keystore
.env
# ───────────────────────────────
# For now - not targeting these
# ───────────────────────────────
linux/
macos/
web/
windows/

198
README.md
View File

@@ -1,145 +1,129 @@
# 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 14 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 14, 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
- Update offline area nodes while browsing?
- Camera deletions
- Optional custom icons for camera profiles
- Upgrade device marker design (considering nullplate's svg)
### Future Features & Wishlist
- Jump to location by coordinates, address, or POI name
- Route planning that avoids surveillance devices (alprwatch.com/directions)
- Suspected locations toggle (alprwatch.com/flock/utilities)
- Location-based notifications when approaching surveillance devices
- Red/yellow ring for devices missing specific tag details
- "Cache accumulating" offline area?
- "Offline areas" as tile provider?
- 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.

View File

@@ -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

View File

@@ -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>

View File

@@ -1,5 +0,0 @@
package com.example.flock_map_app
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View File

@@ -0,0 +1,5 @@
package me.deflock.deflockapp
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 435 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 805 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 69 B

View File

@@ -1,7 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/launch_background" />
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 757 KiB

After

Width:  |  Height:  |  Size: 96 KiB

27
assets/deflock-logo.svg Normal file
View 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

View File

@@ -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

53
do_builds.sh Executable file
View File

@@ -0,0 +1,53 @@
#!/bin/bash
# Default options
BUILD_IOS=true
BUILD_ANDROID=true
# Parse arguments
for arg in "$@"; do
case $arg in
--ios)
BUILD_ANDROID=false
;;
--android)
BUILD_IOS=false
;;
*)
echo "Usage: $0 [--ios | --android]"
echo " --ios Build only iOS"
echo " --android Build only Android"
echo " (default builds both)"
exit 1
;;
esac
done
appver=$(grep "kClientVersion" lib/dev_config.dart | cut -d '=' -f 2 | tr -d ';' | tr -d "\'" | tr -d " ")
echo
echo "Building app version ${appver}..."
echo
if [ "$BUILD_IOS" = true ]; then
echo "Building iOS..."
flutter build ios --no-codesign || exit 1
echo "Converting .app to .ipa..."
./app2ipa.sh build/ios/iphoneos/Runner.app || exit 1
echo "Moving iOS files..."
mv Runner.ipa "../deflock_v${appver}.ipa" || exit 1
echo
fi
if [ "$BUILD_ANDROID" = true ]; then
echo "Building Android..."
flutter build apk || exit 1
echo "Moving Android files..."
cp build/app/outputs/flutter-apk/app-release.apk "../deflock_v${appver}.apk" || exit 1
echo
fi
echo "Done."

View File

@@ -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";
@@ -512,7 +512,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_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -659,7 +659,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_OPTIMIZATION_LEVEL = "-Onone";

Binary file not shown.

Before

Width:  |  Height:  |  Size: 805 KiB

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 875 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 435 KiB

After

Width:  |  Height:  |  Size: 57 KiB

View File

@@ -16,13 +16,19 @@
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleToFill" image="LaunchBackground" translatesAutoresizingMaskIntoConstraints="NO" id="tWc-Dq-wcI"/>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"></imageView>
</subviews>
<color key="backgroundColor" red="0.125" green="0.125" blue="0.125" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="3T2-ad-Qdv"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="RPx-PI-7Xg"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="SdS-ul-q2q"/>
<constraint firstAttribute="trailing" secondItem="tWc-Dq-wcI" secondAttribute="trailing" id="Swv-Gf-Rwn"/>
<constraint firstAttribute="trailing" secondItem="YRO-k0-Ey4" secondAttribute="trailing" id="TQA-XW-tRk"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="duK-uY-Gun"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="kV7-tw-vXt"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="xPn-NY-SIU"/>
</constraints>
</view>
</viewController>
@@ -32,6 +38,7 @@
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
<image name="LaunchImage" width="512" height="512"/>
<image name="LaunchBackground" width="1" height="1"/>
</resources>
</document>

View File

@@ -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>

View File

@@ -1,473 +1,308 @@
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 'services/node_cache.dart';
import 'widgets/camera_provider_with_cache.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.production;
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);
}
// ---------- Addcamera 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
final session = _sessionState.commitSession();
if (session != null) {
_uploadQueueState.addFromSession(session, uploadMode: uploadMode);
_startUploader();
}
}
void commitEditSession() {
final session = _sessionState.commitEditSession();
if (session != null) {
_uploadQueueState.addFromEditSession(session, uploadMode: uploadMode);
_startUploader();
}
}
void deleteNode(OsmCameraNode node) {
_uploadQueueState.addFromNodeDeletion(node, uploadMode: uploadMode);
_startUploader();
}
// ---------- 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();
}
}
set maxCameras(int n) {
_settingsState.maxCameras = n;
}
Future<void> setUploadMode(UploadMode mode) async {
// Clear node cache when switching upload modes to prevent mixing production/sandbox data
NodeCache.instance.clear();
CameraProviderWithCache.instance.notifyListeners();
debugPrint('[AppState] Cleared node cache due to upload mode change');
notifyListeners();
await _settingsState.setUploadMode(mode);
await _authState.onUploadModeChanged(mode);
_startUploader(); // Restart uploader with new mode
}
// ---------- 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));
/// Select a tile type by ID
Future<void> setSelectedTileType(String tileTypeId) async {
await _settingsState.setSelectedTileType(tileTypeId);
}
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)));
/// Add or update a tile provider
Future<void> addOrUpdateTileProvider(TileProvider provider) async {
await _settingsState.addOrUpdateTileProvider(provider);
}
// ---------- 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));
}
}
});
/// Delete a tile provider
Future<void> deleteTileProvider(String providerId) async {
await _settingsState.deleteTileProvider(providerId);
}
// ---------- Exposed getters ----------
int get pendingCount => _queue.length;
List<PendingUpload> get pendingUploads => List.unmodifiable(_queue);
// ---------- Queue management ----------
/// 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();
}
}

View File

@@ -1,20 +1,56 @@
// 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;
// Example: Default tile storage estimate (KB per tile), for size estimates
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
// Marker/camera interaction
const int kCameraMinZoomLevel = 10; // Minimum zoom to show cameras or warning
// Bottom button bar positioning
const double kBottomButtonBarOffset = 4.0; // Distance from screen bottom (above safe area)
const double kButtonBarHeight = 60.0; // Button height (48) + padding (12)
// Map overlay spacing relative to button bar top
const double kAttributionSpacingAboveButtonBar = 10.0; // Attribution above button bar top
const double kZoomIndicatorSpacingAboveButtonBar = 40.0; // Zoom indicator above button bar top
const double kScaleBarSpacingAboveButtonBar = 70.0; // Scale bar above button bar top
const double kZoomControlsSpacingAboveButtonBar = 20.0; // Zoom controls above button bar top
// Helper to calculate bottom position relative to button bar
double bottomPositionFromButtonBar(double spacingAboveButtonBar, double safeAreaBottom) {
return safeAreaBottom + kBottomButtonBarOffset + kButtonBarHeight + spacingAboveButtonBar;
}
// 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 = 'DeFlock';
const String kClientVersion = '0.9.12';
// Development/testing features - set to false for production builds
const bool kEnableDevelopmentModes = false; // Set to false to hide sandbox/simulate modes and force production mode
// Marker/node interaction
const int kCameraMinZoomLevel = 10; // Minimum zoom to show nodes (Overpass)
const int kOsmApiMinZoomLevel = 13; // Minimum zoom for OSM API bbox queries (sandbox mode)
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;
@@ -26,3 +62,19 @@ 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 kAbsoluteMaxTileCount = 50000;
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
const Color kCameraRingColorPendingDeletion = Color(0xA4F44336); // Node pending deletion - red, slightly transparent

View File

@@ -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

View 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.

241
lib/localizations/de.json Normal file
View File

@@ -0,0 +1,241 @@
{
"language": {
"name": "Deutsch"
},
"app": {
"title": "DeFlock"
},
"actions": {
"tagNode": "Neuer Knoten",
"download": "Herunterladen",
"settings": "Einstellungen",
"edit": "Bearbeiten",
"delete": "Löschen",
"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",
"deleteQueuedForUpload": "Knoten-Löschung zum Upload eingereiht",
"confirmDeleteTitle": "Knoten löschen",
"confirmDeleteMessage": "Sind Sie sicher, dass Sie Knoten #{} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden."
},
"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"
},
"offlineAreas": {
"noAreasTitle": "Keine Offline-Bereiche",
"noAreasSubtitle": "Laden Sie einen Kartenbereich für die Offline-Nutzung herunter.",
"provider": "Anbieter",
"zoomLevels": "Z{}-{}",
"latitude": "Breite",
"longitude": "Länge",
"tiles": "Kacheln",
"size": "Größe",
"cameras": "Kameras",
"areaIdFallback": "Bereich {}...",
"renameArea": "Bereich umbenennen",
"refreshWorldTiles": "Welt-Kacheln aktualisieren/neu herunterladen",
"deleteOfflineArea": "Offline-Bereich löschen",
"cancelDownload": "Download abbrechen",
"renameAreaDialogTitle": "Offline-Bereich Umbenennen",
"areaNameLabel": "Bereichsname",
"renameButton": "Umbenennen",
"megabytes": "MB",
"kilobytes": "KB",
"progress": "{}%"
}
}

241
lib/localizations/en.json Normal file
View File

@@ -0,0 +1,241 @@
{
"language": {
"name": "English"
},
"app": {
"title": "DeFlock"
},
"actions": {
"tagNode": "New Node",
"download": "Download",
"settings": "Settings",
"edit": "Edit",
"delete": "Delete",
"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",
"deleteQueuedForUpload": "Node deletion queued for upload",
"confirmDeleteTitle": "Delete Node",
"confirmDeleteMessage": "Are you sure you want to delete node #{}? This action cannot be undone."
},
"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"
},
"offlineAreas": {
"noAreasTitle": "No offline areas",
"noAreasSubtitle": "Download a map area for offline use.",
"provider": "Provider",
"zoomLevels": "Z{}-{}",
"latitude": "Lat",
"longitude": "Lon",
"tiles": "Tiles",
"size": "Size",
"cameras": "Cameras",
"areaIdFallback": "Area {}...",
"renameArea": "Rename area",
"refreshWorldTiles": "Refresh/re-download world tiles",
"deleteOfflineArea": "Delete offline area",
"cancelDownload": "Cancel download",
"renameAreaDialogTitle": "Rename Offline Area",
"areaNameLabel": "Area Name",
"renameButton": "Rename",
"megabytes": "MB",
"kilobytes": "KB",
"progress": "{}%"
}
}

241
lib/localizations/es.json Normal file
View File

@@ -0,0 +1,241 @@
{
"language": {
"name": "Español"
},
"app": {
"title": "DeFlock"
},
"actions": {
"tagNode": "Nuevo Nodo",
"download": "Descargar",
"settings": "Configuración",
"edit": "Editar",
"delete": "Eliminar",
"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",
"deleteQueuedForUpload": "Eliminación de nodo en cola para subir",
"confirmDeleteTitle": "Eliminar Nodo",
"confirmDeleteMessage": "¿Estás seguro de que quieres eliminar el nodo #{}? Esta acción no se puede deshacer."
},
"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"
},
"offlineAreas": {
"noAreasTitle": "Sin áreas sin conexión",
"noAreasSubtitle": "Descarga un área del mapa para uso sin conexión.",
"provider": "Proveedor",
"zoomLevels": "Z{}-{}",
"latitude": "Lat",
"longitude": "Lon",
"tiles": "Teselas",
"size": "Tamaño",
"cameras": "Cámaras",
"areaIdFallback": "Área {}...",
"renameArea": "Renombrar área",
"refreshWorldTiles": "Actualizar/re-descargar teselas mundiales",
"deleteOfflineArea": "Eliminar área sin conexión",
"cancelDownload": "Cancelar descarga",
"renameAreaDialogTitle": "Renombrar Área Sin Conexión",
"areaNameLabel": "Nombre del Área",
"renameButton": "Renombrar",
"megabytes": "MB",
"kilobytes": "KB",
"progress": "{}%"
}
}

241
lib/localizations/fr.json Normal file
View File

@@ -0,0 +1,241 @@
{
"language": {
"name": "Français"
},
"app": {
"title": "DeFlock"
},
"actions": {
"tagNode": "Nouveau Nœud",
"download": "Télécharger",
"settings": "Paramètres",
"edit": "Modifier",
"delete": "Supprimer",
"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",
"deleteQueuedForUpload": "Suppression de nœud mise en file pour envoi",
"confirmDeleteTitle": "Supprimer le Nœud",
"confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer le nœud #{} ? Cette action ne peut pas être annulée."
},
"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é"
},
"offlineAreas": {
"noAreasTitle": "Aucune zone hors ligne",
"noAreasSubtitle": "Téléchargez une zone de carte pour utilisation hors ligne.",
"provider": "Fournisseur",
"zoomLevels": "Z{}-{}",
"latitude": "Lat",
"longitude": "Lon",
"tiles": "Tuiles",
"size": "Taille",
"cameras": "Caméras",
"areaIdFallback": "Zone {}...",
"renameArea": "Renommer la zone",
"refreshWorldTiles": "Actualiser/re-télécharger les tuiles mondiales",
"deleteOfflineArea": "Supprimer la zone hors ligne",
"cancelDownload": "Annuler le téléchargement",
"renameAreaDialogTitle": "Renommer la Zone Hors Ligne",
"areaNameLabel": "Nom de la Zone",
"renameButton": "Renommer",
"megabytes": "Mo",
"kilobytes": "Ko",
"progress": "{}%"
}
}

View File

@@ -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(
@@ -19,7 +23,7 @@ Future<void> main() async {
// You can customize this splash/loading screen as needed
return MaterialApp(
home: Scaffold(
backgroundColor: Color(0xFF202020),
backgroundColor: Color(0xFF152131),
body: Center(
child: Image.asset(
'assets/app_icon.png',
@@ -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: {

View File

@@ -1,66 +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,
});
/// Builtin default: Generic Flock ALPR camera
factory CameraProfile.alpr() => CameraProfile(
id: 'builtin-alpr',
name: 'Generic Flock',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:zone': 'traffic',
'surveillance:type': 'ALPR', // left for backward compatibility — you may want to revisit per OSM best practice
'camera:type': 'fixed',
'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;
}

View 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,
});
/// Builtin 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,
);
/// Builtin: 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,
);
/// Builtin: 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,
);
/// Builtin: 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,
);
/// Builtin: 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,
);
/// Builtin: 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,
);
/// Builtin: 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,
);
/// Builtin: 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,
);
/// Builtin: 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;
}

View 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;
}

View File

@@ -1,38 +1,111 @@
import 'package:latlong2/latlong.dart';
import 'camera_profile.dart';
import 'node_profile.dart';
import 'operator_profile.dart';
import '../state/settings_state.dart';
enum UploadOperation { create, modify, delete }
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 UploadOperation operation; // Type of operation: create, modify, or delete
final int? originalNodeId; // If this is modify/delete, the ID of the original OSM node
int? submittedNodeId; // The actual node ID returned by OSM after successful submission
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,
required this.operation,
this.originalNodeId,
this.submittedNodeId,
this.attempts = 0,
this.error = false,
});
this.completing = false,
}) : assert(
(operation == UploadOperation.create && originalNodeId == null) ||
(operation != UploadOperation.create && originalNodeId != null),
'originalNodeId must be null for create operations and non-null for modify/delete operations'
);
// True if this is an edit of an existing node, false if it's a new node
bool get isEdit => operation == UploadOperation.modify;
// True if this is a deletion of an existing node
bool get isDeletion => operation == UploadOperation.delete;
// 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 node profile and operator profile
Map<String, String> getCombinedTags() {
final tags = Map<String, String>.from(profile.tags);
// Add operator profile tags (they override node 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,
'operation': operation.index,
'originalNodeId': originalNodeId,
'submittedNodeId': submittedNodeId,
'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
operation: j['operation'] != null
? UploadOperation.values[j['operation']]
: (j['originalNodeId'] != null ? UploadOperation.modify : UploadOperation.create), // Legacy compatibility
originalNodeId: j['originalNodeId'],
submittedNodeId: j['submittedNodeId'],
attempts: j['attempts'] ?? 0,
error: j['error'] ?? false,
completing: j['completing'] ?? false, // Default to false for legacy entries
);
}

View 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',
),
],
),
];
}
}

View File

@@ -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,260 +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 nonnull now
_scaffoldKey.currentState!.showBottomSheet(
(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 followme' : 'Enable followme',
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);
},
),
floatingActionButton: appState.session == null
? Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
FloatingActionButton.extended(
onPressed: _openAddCameraSheet,
icon: const Icon(Icons.add_location_alt),
label: const Text('Tag Camera'),
heroTag: 'tag_camera_fab',
),
const SizedBox(height: 12),
FloatingActionButton.extended(
onPressed: () => showDialog(
context: context,
builder: (ctx) => DownloadAreaDialog(controller: _mapController),
),
icon: const Icon(Icons.download_for_offline),
label: const Text('Download'),
heroTag: 'download_fab',
),
],
)
: null,
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
),
);
}
}
// --- 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 + kBottomButtonBarOffset,
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: kBottomButtonBarOffset),
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'),
),
],
);
}
}

View 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]))),
);
}
}

View File

@@ -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]))),
);
}
}

View File

@@ -2,38 +2,55 @@ 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';
import '../dev_config.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: [
// Only show upload mode section in development builds
if (kEnableDevelopmentModes) ...[
const UploadModeSection(),
const Divider(),
],
const AuthSection(),
const Divider(),
const QueueSection(),
const Divider(),
const ProfileListSection(),
const Divider(),
const OperatorProfileListSection(),
const Divider(),
const MaxNodesSection(),
const Divider(),
const TileProviderSection(),
const Divider(),
const OfflineModeSection(),
const Divider(),
const OfflineAreasSection(),
const Divider(),
const LanguageSection(),
const Divider(),
const AboutSection(),
],
),
),
);
}

View File

@@ -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'),
),
],
),
),
);
},
);
},
);

View File

@@ -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();
}
},
),
],
);
},
);
}
}

View 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,
),
),
],
);
},
);
}
}

View File

@@ -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();
},
),
),
),
],
);
}
}

View 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();
},
),
),
),
],
);
},
);
}
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../../services/offline_area_service.dart';
import '../../services/offline_areas/offline_area_models.dart';
import '../../services/localization_service.dart';
class OfflineAreasSection extends StatefulWidget {
const OfflineAreasSection({super.key});
@@ -25,34 +26,40 @@ class _OfflineAreasSectionState extends State<OfflineAreasSection> {
@override
Widget build(BuildContext context) {
final areas = service.offlineAreas;
if (areas.isEmpty) {
return const ListTile(
leading: Icon(Icons.download_for_offline),
title: Text('No offline areas'),
subtitle: Text('Download a map area for offline use.'),
);
}
return Column(
children: areas.map((area) {
String diskStr = area.sizeBytes > 0
? area.sizeBytes > 1024 * 1024
? "${(area.sizeBytes / (1024 * 1024)).toStringAsFixed(2)} MB"
: "${(area.sizeBytes / 1024).toStringAsFixed(1)} KB"
: '--';
String subtitle =
'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)}';
if (area.status == OfflineAreaStatus.downloading) {
subtitle += '\nTiles: ${area.tilesDownloaded} / ${area.tilesTotal}';
} else {
subtitle += '\nTiles: ${area.tilesTotal}';
}
subtitle += '\nSize: $diskStr';
if (!area.isPermanent) {
subtitle += '\nCameras: ${area.cameras.length}';
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
final areas = service.offlineAreas;
if (areas.isEmpty) {
return ListTile(
leading: const Icon(Icons.download_for_offline),
title: Text(locService.t('offlineAreas.noAreasTitle')),
subtitle: Text(locService.t('offlineAreas.noAreasSubtitle')),
);
}
return Column(
children: areas.map((area) {
String diskStr = area.sizeBytes > 0
? area.sizeBytes > 1024 * 1024
? "${(area.sizeBytes / (1024 * 1024)).toStringAsFixed(2)} ${locService.t('offlineAreas.megabytes')}"
: "${(area.sizeBytes / 1024).toStringAsFixed(1)} ${locService.t('offlineAreas.kilobytes')}"
: '--';
String subtitle = '${locService.t('offlineAreas.provider')}: ${area.tileProviderDisplay}\n' +
'Max zoom: Z${area.maxZoom}' + '\n' +
'${locService.t('offlineAreas.latitude')}: ${area.bounds.southWest.latitude.toStringAsFixed(3)}, ${area.bounds.southWest.longitude.toStringAsFixed(3)}\n' +
'${locService.t('offlineAreas.latitude')}: ${area.bounds.northEast.latitude.toStringAsFixed(3)}, ${area.bounds.northEast.longitude.toStringAsFixed(3)}';
if (area.status == OfflineAreaStatus.downloading) {
subtitle += '\n${locService.t('offlineAreas.tiles')}: ${area.tilesDownloaded} / ${area.tilesTotal}';
} else {
subtitle += '\n${locService.t('offlineAreas.tiles')}: ${area.tilesTotal}';
}
subtitle += '\n${locService.t('offlineAreas.size')}: $diskStr';
subtitle += '\n${locService.t('offlineAreas.cameras')}: ${area.nodes.length}';
return Card(
child: ListTile(
leading: Icon(area.status == OfflineAreaStatus.complete
@@ -65,35 +72,34 @@ class _OfflineAreasSectionState extends State<OfflineAreasSection> {
Expanded(
child: Text(area.name.isNotEmpty
? area.name
: 'Area ${area.id.substring(0, 6)}...'),
: locService.t('offlineAreas.areaIdFallback', params: [area.id.substring(0, 6)])),
),
if (!area.isPermanent)
IconButton(
icon: const Icon(Icons.edit, size: 20),
tooltip: 'Rename area',
IconButton(
icon: const Icon(Icons.edit, size: 20),
tooltip: locService.t('offlineAreas.renameArea'),
onPressed: () async {
String? newName = await showDialog<String>(
context: context,
builder: (ctx) {
final ctrl = TextEditingController(text: area.name);
return AlertDialog(
title: const Text('Rename Offline Area'),
title: Text(locService.t('offlineAreas.renameAreaDialogTitle')),
content: TextField(
controller: ctrl,
maxLength: 40,
decoration: const InputDecoration(labelText: 'Area Name'),
decoration: InputDecoration(labelText: locService.t('offlineAreas.areaNameLabel')),
autofocus: true,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
child: Text(locService.t('actions.cancel')),
),
ElevatedButton(
onPressed: () {
Navigator.pop(ctx, ctrl.text.trim());
},
child: const Text('Rename'),
child: Text(locService.t('offlineAreas.renameButton')),
),
],
);
@@ -107,28 +113,10 @@ class _OfflineAreasSectionState extends State<OfflineAreasSection> {
}
},
),
if (area.isPermanent && area.status != OfflineAreaStatus.downloading)
IconButton(
icon: const Icon(Icons.refresh, color: Colors.blue),
tooltip: 'Refresh/re-download world tiles',
onPressed: () async {
await service.downloadArea(
id: area.id,
bounds: area.bounds,
minZoom: area.minZoom,
maxZoom: area.maxZoom,
directory: area.directory,
name: area.name,
onProgress: (progress) {},
onComplete: (status) {},
);
setState(() {});
},
)
else if (!area.isPermanent && area.status != OfflineAreaStatus.downloading)
if (area.status != OfflineAreaStatus.downloading)
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
tooltip: 'Delete offline area',
tooltip: locService.t('offlineAreas.deleteOfflineArea'),
onPressed: () async {
service.deleteArea(area.id);
setState(() {});
@@ -149,7 +137,7 @@ class _OfflineAreasSectionState extends State<OfflineAreasSection> {
children: [
LinearProgressIndicator(value: area.progress),
Text(
'${(area.progress * 100).toStringAsFixed(0)}%',
locService.t('offlineAreas.progress', params: [(area.progress * 100).toStringAsFixed(0)]),
style: const TextStyle(fontSize: 12),
)
],
@@ -157,7 +145,7 @@ class _OfflineAreasSectionState extends State<OfflineAreasSection> {
),
IconButton(
icon: const Icon(Icons.cancel, color: Colors.orange),
tooltip: 'Cancel download',
tooltip: locService.t('offlineAreas.cancelDownload'),
onPressed: () {
service.cancelDownload(area.id);
setState(() {});
@@ -173,8 +161,10 @@ class _OfflineAreasSectionState extends State<OfflineAreasSection> {
}
: null,
),
);
}).toList(),
);
}).toList(),
);
},
);
}
}

View File

@@ -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),
),
);
},
);
}
}

View File

@@ -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')),
),
],
),
);
}
}

View File

@@ -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')),
),
],
),

View File

@@ -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')),
),
],
),
),
);
}

View File

@@ -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')),
),
),
],
);
},
);
}
}

View File

@@ -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));
}
},
),
),
],
),
],
);
},
);
}
}

View 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();
}
}

View 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')),
),
],
),
);
}
}

View File

@@ -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');
@@ -117,6 +106,36 @@ class AuthService {
}
}
// Restore login state from stored token (for app startup)
Future<String?> restoreLogin() async {
if (_mode == UploadMode.simulate) {
final prefs = await SharedPreferences.getInstance();
final isLoggedIn = prefs.getBool('sim_user_logged_in') ?? false;
if (isLoggedIn) {
_displayName = 'Demo User';
return _displayName;
}
return null;
}
// Get stored token directly from SharedPreferences
final accessToken = await getAccessToken();
if (accessToken == null) {
return null;
}
try {
_displayName = await _fetchUsername(accessToken);
return _displayName;
} catch (e) {
print('AuthService: Error restoring login with stored token: $e');
log('Error restoring login with stored token: $e');
// Token might be expired or invalid, clear it
await logout();
return null;
}
}
Future<void> logout() async {
if (_mode == UploadMode.simulate) {
final prefs = await SharedPreferences.getInstance();
@@ -132,7 +151,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 +181,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');

View File

@@ -1,40 +0,0 @@
import 'package:latlong2/latlong.dart';
import '../models/osm_camera_node.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
class CameraCache {
// Singleton instance
static final CameraCache instance = CameraCache._internal();
factory CameraCache() => instance;
CameraCache._internal();
final Map<int, OsmCameraNode> _nodes = {};
/// 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;
}
}
/// Query for all cached cameras currently within the given LatLngBounds.
List<OsmCameraNode> queryByBounds(LatLngBounds bounds) {
return _nodes.values
.where((node) => _inBounds(node.coord, bounds))
.toList();
}
/// Retrieve all cached cameras.
List<OsmCameraNode> getAll() => _nodes.values.toList();
/// Optionally clear the cache (rarely needed)
void clear() => _nodes.clear();
/// Utility: point-in-bounds for coordinates
bool _inBounds(LatLng coord, LatLngBounds bounds) {
return coord.latitude >= bounds.southWest.latitude &&
coord.latitude <= bounds.northEast.latitude &&
coord.longitude >= bounds.southWest.longitude &&
coord.longitude <= bounds.northEast.longitude;
}
}

View 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 - replace first occurrence only for each parameter
if (params != null) {
for (int i = 0; i < params.length; i++) {
result = result.replaceFirst('{}', 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');
}

View File

@@ -1,13 +1,16 @@
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/nodes_from_osm_api.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';
import 'network_status.dart';
enum MapSource { local, remote, auto } // For future use
@@ -30,86 +33,129 @@ 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 {
try {
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 _fetchRemoteNodes(
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,
);
}
// AUTO: default = remote first, fallback to local only if offline
// AUTO: In offline mode, behavior depends on upload mode
if (offline) {
return fetchLocalCameras(
bounds: bounds,
profiles: profiles,
);
} else {
// Try remote, fallback to local ONLY if remote throws (optional, could be removed for stricter behavior)
try {
return await camerasFromOverpass(
if (uploadMode == UploadMode.sandbox) {
// Offline + Sandbox = no nodes (local cache is production data)
debugPrint('[MapDataProvider] Offline + Sandbox mode: returning no nodes (local cache is production data)');
return <OsmCameraNode>[];
} else {
// Offline + Production = use local cache
return fetchLocalNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
pageSize: 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,
maxNodes: AppState.instance.maxCameras,
);
}
} else if (uploadMode == UploadMode.sandbox) {
// Sandbox mode: Only fetch from sandbox API, ignore local production nodes
debugPrint('[MapDataProvider] Sandbox mode: fetching only from sandbox API, ignoring local cache');
return _fetchRemoteNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: AppState.instance.maxCameras,
);
} else {
// Production mode: fetch both remote and local, then merge with deduplication
final List<Future<List<OsmCameraNode>>> futures = [];
// Always try to get local nodes (fast, cached)
futures.add(fetchLocalNodes(
bounds: bounds,
profiles: profiles,
maxNodes: AppState.instance.maxCameras,
));
// Always try to get remote nodes (slower, fresh data)
futures.add(_fetchRemoteNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: AppState.instance.maxCameras,
).catchError((e) {
debugPrint('[MapDataProvider] Remote node fetch failed, error: $e. Continuing with local only.');
return <OsmCameraNode>[]; // Return empty list on remote failure
}));
// Wait for both, then merge with deduplication by node ID
final results = await Future.wait(futures);
final localNodes = results[0];
final remoteNodes = results[1];
// Merge with deduplication - prefer remote data over local for same node ID
final Map<int, OsmCameraNode> mergedNodes = {};
// Add local nodes first
for (final node in localNodes) {
mergedNodes[node.id] = node;
}
// Add remote nodes, overwriting any local duplicates
for (final node in remoteNodes) {
mergedNodes[node.id] = node;
}
// Apply maxCameras limit to the merged result
final finalNodes = mergedNodes.values.take(AppState.instance.maxCameras).toList();
return finalNodes;
}
} finally {
// Always report node completion, regardless of success or failure
NetworkStatus.instance.reportNodeComplete();
}
}
/// 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 maxResults = 0, // 0 = no limit for offline downloads
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 _fetchRemoteNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
fetchAllPages: true,
pageSize: pageSize,
maxTries: maxTries,
maxResults: maxResults, // Pass 0 for unlimited
);
}
@@ -121,15 +167,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 +186,84 @@ 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();
}
/// Fetch remote nodes with Overpass first, OSM API fallback
Future<List<OsmCameraNode>> _fetchRemoteNodes({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
UploadMode uploadMode = UploadMode.production,
required int maxResults,
}) async {
// For sandbox mode, skip Overpass and go directly to OSM API
// (Overpass doesn't have sandbox data)
if (uploadMode == UploadMode.sandbox) {
debugPrint('[MapDataProvider] Sandbox mode detected, using OSM API directly');
return fetchOsmApiNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: maxResults,
);
}
// For production mode, try Overpass first, then fallback to OSM API
try {
final nodes = await fetchOverpassNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: maxResults,
);
// If Overpass returns nodes, we're good
if (nodes.isNotEmpty) {
return nodes;
}
// If Overpass returns empty (could be no data or could be an issue),
// try OSM API as well to be thorough
debugPrint('[MapDataProvider] Overpass returned no nodes, trying OSM API fallback');
return fetchOsmApiNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: maxResults,
);
} catch (e) {
debugPrint('[MapDataProvider] Overpass failed ($e), trying OSM API fallback');
return fetchOsmApiNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: maxResults,
);
}
}
}

View File

@@ -1,72 +0,0 @@
import 'dart:io';
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 '../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({
required LatLngBounds bounds,
required List<CameraProfile> profiles,
int? maxCameras,
}) async {
final areas = OfflineAreaService().offlineAreas;
final Map<int, OsmCameraNode> deduped = {};
for (final area in areas) {
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;
// Within view bounds?
if (!_pointInBounds(cam.coord, bounds)) continue;
// Profile filter if used
if (profiles.isNotEmpty && !_matchesAnyProfile(cam, profiles)) continue;
deduped[cam.id] = cam;
}
}
final out = deduped.values.take(maxCameras ?? deduped.length).toList();
return out;
}
// Try in-memory first, else load from disk
Future<List<OsmCameraNode>> _loadAreaCameras(OfflineArea area) async {
if (area.cameras.isNotEmpty) {
return area.cameras;
}
final file = File('${area.directory}/cameras.json');
if (await file.exists()) {
final str = await file.readAsString();
final jsonList = jsonDecode(str) as List;
return jsonList.map((e) => OsmCameraNode.fromJson(e)).toList();
}
return [];
}
bool _pointInBounds(LatLng pt, LatLngBounds bounds) {
return pt.latitude >= bounds.southWest.latitude &&
pt.latitude <= bounds.northEast.latitude &&
pt.longitude >= bounds.southWest.longitude &&
pt.longitude <= bounds.northEast.longitude;
}
bool _matchesAnyProfile(OsmCameraNode cam, List<CameraProfile> profiles) {
for (final prof in profiles) {
if (_cameraMatchesProfile(cam, prof)) return true;
}
return false;
}
bool _cameraMatchesProfile(OsmCameraNode cam, CameraProfile profile) {
for (final e in profile.tags.entries) {
if (cam.tags[e.key] != e.value) return false; // All profile tags must match
}
return true;
}

View File

@@ -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();
}

View File

@@ -0,0 +1,89 @@
import 'dart:io';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
import '../../models/osm_camera_node.dart';
import '../../models/node_profile.dart';
import '../offline_area_service.dart';
import '../offline_areas/offline_area_models.dart';
/// Fetch surveillance nodes from all offline areas intersecting the bounds/profile list.
Future<List<OsmCameraNode>> fetchLocalNodes({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
int? maxNodes,
}) async {
final areas = OfflineAreaService().offlineAreas;
final Map<int, OsmCameraNode> deduped = {};
for (final area in areas) {
if (area.status != OfflineAreaStatus.complete) continue;
if (!area.bounds.isOverlapping(bounds)) 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(node.coord, bounds)) continue;
// Profile filter if used
if (profiles.isNotEmpty && !_matchesAnyProfile(node, profiles)) continue;
deduped[node.id] = node;
}
}
final out = deduped.values.take(maxNodes ?? deduped.length).toList();
return out;
}
// Try in-memory first, else load from disk
Future<List<OsmCameraNode>> _loadAreaNodes(OfflineArea area) async {
if (area.nodes.isNotEmpty) {
return area.nodes;
}
// Try new nodes.json first, fall back to legacy cameras.json for backward compatibility
final nodeFile = File('${area.directory}/nodes.json');
final legacyCameraFile = File('${area.directory}/cameras.json');
File? fileToLoad;
if (await nodeFile.exists()) {
fileToLoad = nodeFile;
} else if (await legacyCameraFile.exists()) {
fileToLoad = legacyCameraFile;
}
if (fileToLoad != null) {
try {
final str = await fileToLoad.readAsString();
final jsonList = jsonDecode(str) as List;
return jsonList.map((e) => OsmCameraNode.fromJson(e)).toList();
} catch (e) {
debugPrint('[_loadAreaNodes] Error loading nodes from ${fileToLoad.path}: $e');
}
}
return [];
}
bool _pointInBounds(LatLng pt, LatLngBounds bounds) {
return pt.latitude >= bounds.southWest.latitude &&
pt.latitude <= bounds.northEast.latitude &&
pt.longitude >= bounds.southWest.longitude &&
pt.longitude <= bounds.northEast.longitude;
}
bool _matchesAnyProfile(OsmCameraNode node, List<NodeProfile> profiles) {
for (final prof in profiles) {
if (_nodeMatchesProfile(node, prof)) return true;
}
return false;
}
bool _nodeMatchesProfile(OsmCameraNode node, NodeProfile profile) {
for (final e in profile.tags.entries) {
if (node.tags[e.key] != e.value) return false; // All profile tags must match
}
return true;
}

View File

@@ -0,0 +1,129 @@
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 'package:xml/xml.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 direct OSM API using bbox query.
/// This is a fallback for when Overpass is not available (e.g., sandbox mode).
Future<List<OsmCameraNode>> fetchOsmApiNodes({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
UploadMode uploadMode = UploadMode.production,
required int maxResults,
}) async {
if (profiles.isEmpty) return [];
// Choose API endpoint based on upload mode
final String apiHost = uploadMode == UploadMode.sandbox
? 'api06.dev.openstreetmap.org'
: 'api.openstreetmap.org';
// Build the map query URL - fetches all data in bounding box
final left = bounds.southWest.longitude;
final bottom = bounds.southWest.latitude;
final right = bounds.northEast.longitude;
final top = bounds.northEast.latitude;
final url = 'https://$apiHost/api/0.6/map?bbox=$left,$bottom,$right,$top';
try {
debugPrint('[fetchOsmApiNodes] Querying OSM API for nodes in bbox...');
debugPrint('[fetchOsmApiNodes] URL: $url');
final response = await http.get(Uri.parse(url));
if (response.statusCode != 200) {
debugPrint('[fetchOsmApiNodes] OSM API error: ${response.statusCode} - ${response.body}');
NetworkStatus.instance.reportOverpassIssue(); // Reuse same status tracking
return [];
}
// Parse XML response
final document = XmlDocument.parse(response.body);
final nodes = <OsmCameraNode>[];
// Find all node elements
for (final nodeElement in document.findAllElements('node')) {
final id = int.tryParse(nodeElement.getAttribute('id') ?? '');
final latStr = nodeElement.getAttribute('lat');
final lonStr = nodeElement.getAttribute('lon');
if (id == null || latStr == null || lonStr == null) continue;
final lat = double.tryParse(latStr);
final lon = double.tryParse(lonStr);
if (lat == null || lon == null) continue;
// Parse tags
final tags = <String, String>{};
for (final tagElement in nodeElement.findElements('tag')) {
final key = tagElement.getAttribute('k');
final value = tagElement.getAttribute('v');
if (key != null && value != null) {
tags[key] = value;
}
}
// Check if this node matches any of our profiles
if (_nodeMatchesProfiles(tags, profiles)) {
nodes.add(OsmCameraNode(
id: id,
coord: LatLng(lat, lon),
tags: tags,
));
}
// Respect maxResults limit if set
if (maxResults > 0 && nodes.length >= maxResults) {
break;
}
}
if (nodes.isNotEmpty) {
debugPrint('[fetchOsmApiNodes] Retrieved ${nodes.length} matching surveillance nodes');
}
NetworkStatus.instance.reportOverpassSuccess(); // Reuse same status tracking
return nodes;
} catch (e) {
debugPrint('[fetchOsmApiNodes] 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 [];
}
}
/// Check if a node's tags match any of the given profiles
bool _nodeMatchesProfiles(Map<String, String> nodeTags, List<NodeProfile> profiles) {
for (final profile in profiles) {
if (_nodeMatchesProfile(nodeTags, profile)) {
return true;
}
}
return false;
}
/// Check if a node's tags match a specific profile
bool _nodeMatchesProfile(Map<String, String> nodeTags, NodeProfile profile) {
// All profile tags must be present in the node for it to match
for (final entry in profile.tags.entries) {
if (nodeTags[entry.key] != entry.value) {
return false;
}
}
return true;
}

View File

@@ -0,0 +1,137 @@
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 '../../models/pending_upload.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();
final nodes = 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();
// Clean up any pending uploads that now appear in Overpass results
_cleanupCompletedUploads(nodes);
return nodes;
} 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 ');
// Use unlimited output if maxResults is 0
final outputClause = maxResults > 0 ? 'out body $maxResults;' : 'out body;';
return '''
[out:json][timeout:25];
(
$nodeClauses
);
$outputClause
''';
}
/// Clean up pending uploads that now appear in Overpass results
void _cleanupCompletedUploads(List<OsmCameraNode> overpassNodes) {
try {
final appState = AppState.instance;
final pendingUploads = appState.pendingUploads;
if (pendingUploads.isEmpty) return;
final overpassNodeIds = overpassNodes.map((n) => n.id).toSet();
// Find pending uploads whose submitted node IDs now appear in Overpass results
final uploadsToRemove = <PendingUpload>[];
for (final upload in pendingUploads) {
if (upload.submittedNodeId != null &&
overpassNodeIds.contains(upload.submittedNodeId!)) {
uploadsToRemove.add(upload);
debugPrint('[OverpassCleanup] Found submitted node ${upload.submittedNodeId} in Overpass results, removing from pending queue');
}
}
// Remove the completed uploads from the queue
for (final upload in uploadsToRemove) {
appState.removeFromQueue(upload);
}
if (uploadsToRemove.isNotEmpty) {
debugPrint('[OverpassCleanup] Cleaned up ${uploadsToRemove.length} completed uploads');
}
} catch (e) {
debugPrint('[OverpassCleanup] Error during cleanup: $e');
// Don't let cleanup errors break the main functionality
}
}

View File

@@ -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();

View File

@@ -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--;
}
}
}

View 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;
}
}

View File

@@ -0,0 +1,321 @@
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 }
/// Simple loading state for dual-source async operations (brutalist approach)
enum LoadingState { ready, waiting, success, timeout }
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;
// New dual-source loading state (brutalist approach)
LoadingState _tileLoadingState = LoadingState.ready;
LoadingState _nodeLoadingState = LoadingState.ready;
Timer? _tileTimeoutTimer;
Timer? _nodeTimeoutTimer;
Timer? _successDisplayTimer;
// 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;
// New dual-source getters (brutalist approach)
LoadingState get tileLoadingState => _tileLoadingState;
LoadingState get nodeLoadingState => _nodeLoadingState;
/// Derive overall loading status from dual sources
bool get isDualSourceLoading => _tileLoadingState == LoadingState.waiting || _nodeLoadingState == LoadingState.waiting;
bool get isDualSourceTimeout => _tileLoadingState == LoadingState.timeout || _nodeLoadingState == LoadingState.timeout;
bool get isDualSourceSuccess => _tileLoadingState == LoadingState.success && _nodeLoadingState == LoadingState.success;
NetworkStatusType get currentStatus {
// Check new dual-source states first
if (isDualSourceTimeout) return NetworkStatusType.timedOut;
if (isDualSourceLoading) return NetworkStatusType.waiting;
if (isDualSourceSuccess) return NetworkStatusType.success;
// Fall back to legacy states for compatibility
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;
});
}
// New dual-source loading methods (brutalist approach)
/// Start waiting for both tiles and nodes
void setDualSourceWaiting() {
_tileLoadingState = LoadingState.waiting;
_nodeLoadingState = LoadingState.waiting;
// Set timeout timers for both
_tileTimeoutTimer?.cancel();
_tileTimeoutTimer = Timer(const Duration(seconds: 8), () {
if (_tileLoadingState == LoadingState.waiting) {
_tileLoadingState = LoadingState.timeout;
debugPrint('[NetworkStatus] Tile loading timed out');
notifyListeners();
}
});
_nodeTimeoutTimer?.cancel();
_nodeTimeoutTimer = Timer(const Duration(seconds: 8), () {
if (_nodeLoadingState == LoadingState.waiting) {
_nodeLoadingState = LoadingState.timeout;
debugPrint('[NetworkStatus] Node loading timed out');
notifyListeners();
}
});
notifyListeners();
}
/// Report tile loading completion
void reportTileComplete() {
if (_tileLoadingState == LoadingState.waiting) {
_tileLoadingState = LoadingState.success;
_tileTimeoutTimer?.cancel();
_checkDualSourceComplete();
}
}
/// Report node loading completion
void reportNodeComplete() {
if (_nodeLoadingState == LoadingState.waiting) {
_nodeLoadingState = LoadingState.success;
_nodeTimeoutTimer?.cancel();
_checkDualSourceComplete();
}
}
/// Check if both sources are complete and show success briefly
void _checkDualSourceComplete() {
if (_tileLoadingState == LoadingState.success && _nodeLoadingState == LoadingState.success) {
debugPrint('[NetworkStatus] Both tiles and nodes loaded successfully');
notifyListeners();
// Auto-reset to ready after showing success briefly
_successDisplayTimer?.cancel();
_successDisplayTimer = Timer(const Duration(seconds: 2), () {
_tileLoadingState = LoadingState.ready;
_nodeLoadingState = LoadingState.ready;
notifyListeners();
});
} else {
// Just notify if one completed but not both yet
notifyListeners();
}
}
/// Reset dual-source state to ready
void resetDualSourceState() {
_tileLoadingState = LoadingState.ready;
_nodeLoadingState = LoadingState.ready;
_tileTimeoutTimer?.cancel();
_nodeTimeoutTimer?.cancel();
_successDisplayTimer?.cancel();
notifyListeners();
}
@override
void dispose() {
_osmRecoveryTimer?.cancel();
_overpassRecoveryTimer?.cancel();
_waitingTimer?.cancel();
_noDataResetTimer?.cancel();
_tileTimeoutTimer?.cancel();
_nodeTimeoutTimer?.cancel();
_successDisplayTimer?.cancel();
super.dispose();
}
}

View File

@@ -0,0 +1,110 @@
import 'package:latlong2/latlong.dart';
import '../models/osm_camera_node.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
class NodeCache {
// Singleton instance
static final NodeCache instance = NodeCache._internal();
factory NodeCache() => instance;
NodeCache._internal();
final Map<int, OsmCameraNode> _nodes = {};
/// Add or update a batch of nodes in the cache.
void addOrUpdate(List<OsmCameraNode> nodes) {
for (var node in nodes) {
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;
}
}
}
/// Query for all cached nodes currently within the given LatLngBounds.
List<OsmCameraNode> queryByBounds(LatLngBounds bounds) {
return _nodes.values
.where((node) => _inBounds(node.coord, bounds))
.toList();
}
/// Retrieve all cached nodes.
List<OsmCameraNode> getAll() => _nodes.values.toList();
/// Optionally clear the cache (rarely needed)
void clear() => _nodes.clear();
/// Remove the _pending_edit marker from a specific node
void removePendingEditMarker(int nodeId) {
final node = _nodes[nodeId];
if (node != null && node.tags.containsKey('_pending_edit')) {
final cleanTags = Map<String, String>.from(node.tags);
cleanTags.remove('_pending_edit');
_nodes[nodeId] = OsmCameraNode(
id: node.id,
coord: node.coord,
tags: cleanTags,
);
}
}
/// Remove a node by ID from the cache (used for successful deletions)
void removeNodeById(int nodeId) {
if (_nodes.remove(nodeId) != null) {
print('[NodeCache] Removed node $nodeId from cache (successful deletion)');
}
}
/// Remove temporary nodes (negative IDs) with _pending_upload marker at the given coordinate
/// This is used when a real node ID is assigned to clean up temp placeholders
void removeTempNodesByCoordinate(LatLng coord, {double tolerance = 0.00001}) {
final nodesToRemove = <int>[];
for (final entry in _nodes.entries) {
final nodeId = entry.key;
final node = entry.value;
// Only consider temp nodes (negative IDs) with pending upload marker
if (nodeId < 0 &&
node.tags.containsKey('_pending_upload') &&
_coordsMatch(node.coord, coord, tolerance)) {
nodesToRemove.add(nodeId);
}
}
for (final nodeId in nodesToRemove) {
_nodes.remove(nodeId);
}
if (nodesToRemove.isNotEmpty) {
print('[NodeCache] Removed ${nodesToRemove.length} temp nodes at coordinate ${coord.latitude}, ${coord.longitude}');
}
}
/// Check if two coordinates match within tolerance
bool _coordsMatch(LatLng coord1, LatLng coord2, double tolerance) {
return (coord1.latitude - coord2.latitude).abs() < tolerance &&
(coord1.longitude - coord2.longitude).abs() < tolerance;
}
/// Utility: point-in-bounds for coordinates
bool _inBounds(LatLng coord, LatLngBounds bounds) {
return coord.latitude >= bounds.southWest.latitude &&
coord.latitude <= bounds.northEast.latitude &&
coord.longitude >= bounds.southWest.longitude &&
coord.longitude <= bounds.northEast.longitude;
}
}

View File

@@ -6,23 +6,62 @@ 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 '../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 _cleanupLegacyWorldAreas();
_initialized = true;
}
Future<Directory> getOfflineAreaDir() async {
final dir = await getApplicationDocumentsDirectory();
@@ -56,7 +95,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 +129,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 +171,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 +182,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 +206,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 +244,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);
}
}
@@ -290,9 +261,6 @@ class OfflineAreaService {
}
_areas.remove(area);
await saveAreasToDisk();
if (area.isPermanent) {
_ensureAndAutoDownloadWorldArea();
}
}
void deleteArea(String id) async {
@@ -304,4 +272,26 @@ class OfflineAreaService {
_areas.remove(area);
await saveAreasToDisk();
}
/// Remove any legacy world areas from previous versions
Future<void> _cleanupLegacyWorldAreas() async {
final worldAreas = _areas.where((area) => area.isPermanent || area.id == 'world').toList();
if (worldAreas.isNotEmpty) {
debugPrint('OfflineAreaService: Cleaning up ${worldAreas.length} legacy world area(s)');
for (final area in worldAreas) {
final dir = Directory(area.directory);
if (await dir.exists()) {
await dir.delete(recursive: true);
debugPrint('OfflineAreaService: Deleted world area directory: ${area.directory}');
}
_areas.remove(area);
}
await saveAreasToDisk();
debugPrint('OfflineAreaService: Legacy world area cleanup complete');
}
}
}

View File

@@ -0,0 +1,189 @@
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';
/// 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 = 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 nodes for all areas
await _downloadNodes(
area: area,
bounds: bounds,
minZoom: minZoom,
directory: directory,
);
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 nodes for the area with modest expansion (one zoom level lower)
static Future<void> _downloadNodes({
required OfflineArea area,
required LatLngBounds bounds,
required int minZoom,
required String directory,
}) async {
// Modest expansion: use tiles at minZoom + 1 instead of minZoom
// This gives a reasonable buffer without capturing entire states
final nodeZoom = (minZoom + 1).clamp(8, 16); // Reasonable bounds for node fetching
final expandedNodeBounds = _calculateNodeBounds(bounds, nodeZoom);
final nodes = await MapDataProvider().getAllNodesForDownload(
bounds: expandedNodeBounds,
profiles: AppState.instance.profiles, // Use ALL profiles, not just enabled ones
);
area.nodes = nodes;
await OfflineAreaDownloader.saveNodes(nodes, directory);
debugPrint('Area ${area.id}: Downloaded ${nodes.length} nodes from modestly expanded bounds (all profiles)');
}
/// Calculate expanded bounds that cover the entire tile area at minimum zoom
static LatLngBounds _calculateNodeBounds(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 nodes to disk as JSON
static Future<void> saveNodes(List<OsmCameraNode> nodes, String dir) async {
final file = File('$dir/nodes.json');
await file.writeAsString(jsonEncode(nodes.map((n) => n.toJson()).toList()));
}
}

View File

@@ -17,9 +17,15 @@ class OfflineArea {
double progress; // 0.0 - 1.0
int tilesDownloaded;
int tilesTotal;
List<OsmCameraNode> cameras;
List<OsmCameraNode> nodes;
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,
@@ -32,9 +38,13 @@ class OfflineArea {
this.progress = 0,
this.tilesDownloaded = 0,
this.tilesTotal = 0,
this.cameras = const [],
this.nodes = const [],
this.sizeBytes = 0,
this.isPermanent = false,
this.tileProviderId,
this.tileProviderName,
this.tileTypeId,
this.tileTypeName,
});
Map<String, dynamic> toJson() => {
@@ -51,9 +61,13 @@ class OfflineArea {
'progress': progress,
'tilesDownloaded': tilesDownloaded,
'tilesTotal': tilesTotal,
'cameras': cameras.map((c) => c.toJson()).toList(),
'nodes': nodes.map((n) => n.toJson()).toList(),
'sizeBytes': sizeBytes,
'isPermanent': isPermanent,
'tileProviderId': tileProviderId,
'tileProviderName': tileProviderName,
'tileTypeId': tileTypeId,
'tileTypeName': tileTypeName,
};
static OfflineArea fromJson(Map<String, dynamic> json) {
@@ -73,10 +87,31 @@ class OfflineArea {
progress: (json['progress'] ?? 0).toDouble(),
tilesDownloaded: json['tilesDownloaded'] ?? 0,
tilesTotal: json['tilesTotal'] ?? 0,
cameras: (json['cameras'] as List? ?? [])
nodes: (json['nodes'] as List? ?? json['cameras'] as List? ?? [])
.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;
}

Some files were not shown because too many files have changed in this diff Show More