Merge pull request #7 from stopflock/offline-areas

Offline areas working, and many other fixes and improvements along the way. Couple new features. See commits.
This commit is contained in:
stopflock
2025-08-11 00:33:10 -05:00
committed by GitHub
83 changed files with 2670 additions and 731 deletions
+116 -46
View File
@@ -1,56 +1,127 @@
# Flock Map App (Stage 1)
# Flock Map App
A minimal Flutter scaffold for mapping and tagging Flockstyle ALPR cameras in OpenStreetMap.
# OAuth Setup
Before you can upload to OpenStreetMap (production **or sandbox**), you must register your own OAuth2 application on each OSM API you wish to support:
- [Production OSM register page](https://www.openstreetmap.org/oauth2/applications)
- [Sandbox OSM register page](https://master.apis.dev.openstreetmap.org/oauth2/applications)
Copy your generated client IDs into a new file:
```dart
// lib/keys.dart
const String kOsmProdClientId = 'YOUR_PROD_CLIENT_ID_HERE';
const String kOsmSandboxClientId = 'YOUR_SANDBOX_CLIENT_ID_HERE';
```
For open source: use `lib/keys.dart.example` as a template and do **not** commit your real secrets.
If you discover a bug that causes bad behavior w/rt OSM API, register a new OAuth client to distinguish patched versions and, if needed, delete the old app to prevent misuse.
# Upload Modes
In Settings, you can now choose your "Upload Destination":
- **Production**: Live OSM database (visible to all users).
- **Sandbox**: OSM's dedicated test database; safe for development/testing. [More info](https://wiki.openstreetmap.org/wiki/Sandbox).
- **Simulate**: Does not contact any server. Actions are fully offline for testing UI/flows.
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.
---
## TODO for Beta/RC Release
## Code Organization (2025 Refactor)
### COMPLETED
- Queue view/retry/clear - Implemented with test mode support
- Fix login not opening browser - Fixed OAuth scope and client ID issues
- Add "new profile" text to button in settings - Enhanced profile management UI
- Profile management (create/edit/delete) - Full CRUD operations integrated
- **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.**
### 🔄 REMAINING FOR BETA/RC
- Better icons for cameras, prettier/wider FOV cones
- North up mode, satellite view mode
- Error handling when clicking "add camera" but no profiles enabled
- Camera point details popup (tap to view full details, edit if user-submitted)
- One-time popup about "this app trusts the user to know what they are doing" + credits/attributions
- Optional height tag for cameras
- Direction should be optional actually (for things like gunshot detectors) - maybe a profile setting?
- More (unspecified items)
---
### FUTURE (Post-Beta)
- Wayfinding to avoid cameras
## 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 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.
### Camera Profiles & Upload Queue
- Unchanged: creation/editing/enabling; see prior documentation.
### 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.
### 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.**
---
## 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.
---
## Roadmap (2025+)
- **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.
---
*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.
---
## 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.
---
## Build Environment Quick Setup
## Stuff for build env
# Install from GUI:
Xcode, Android Studio.
Xcode cmdline tools
@@ -67,7 +138,6 @@ gem install cocoapods
sdkmanager --install "ndk;27.0.12077973"
export PATH="/Users/bob/.gem/ruby/3.4.0/bin:$PATH"
export PATH=$HOME/development/flutter/bin:$PATH
flutter clean
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

@@ -1,12 +1,9 @@
<?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="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 435 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 805 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

@@ -1,12 +1,9 @@
<?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="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 23 KiB

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -5,6 +5,10 @@
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -5,6 +5,10 @@
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Binary file not shown.

After

Width:  |  Height:  |  Size: 757 KiB

+7
View File
@@ -0,0 +1,7 @@
Flock Map App
Built with Flutter.
Offline areas, privacy-respecting, designed for OpenStreetMap camera tagging.
This text is loaded from assets/info.txt.
Binary file not shown.

After

Width:  |  Height:  |  Size: 95 B

+3
View File
@@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
echo "Generate splash screens..."
flutter pub run flutter_native_splash:create
echo
echo
echo "Generate icons..."
flutter pub run flutter_launcher_icons:main
+2 -2
View File
@@ -539,7 +539,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
@@ -596,7 +596,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
@@ -1,122 +1 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 805 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 18 KiB

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "background.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

@@ -1,23 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 435 KiB

+12 -5
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="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="1024" height="1024"/>
<image name="LaunchBackground" width="1" height="1"/>
</resources>
</document>
+66 -65
View File
@@ -1,69 +1,70 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Flock Map App</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>flock_map_app</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSLocationWhenInUseUsageDescription</key>
<string>This app needs your location to show nearby cameras.</string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<!-- OAuth2 redirect handler -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>None</string>
<key>CFBundleURLSchemes</key>
<array>
<string>flockmap</string>
</array>
</dict>
</array>
<!-- (Optional) allow opening the system browser and returning -->
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
</array>
</dict>
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Flock Map App</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>flock_map_app</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSLocationWhenInUseUsageDescription</key>
<string>This app needs your location to show nearby cameras.</string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<!-- OAuth2 redirect handler -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>None</string>
<key>CFBundleURLSchemes</key>
<array>
<string>flockmap</string>
</array>
</dict>
</array>
<!-- (Optional) allow opening the system browser and returning -->
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
</array>
<key>UIStatusBarHidden</key>
<false/>
</dict>
</plist>
+77 -18
View File
@@ -9,6 +9,7 @@ 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 }
@@ -24,16 +25,50 @@ class AddCameraSession {
// ------------------ AppState ------------------
class AppState extends ChangeNotifier {
static late AppState instance;
AppState() {
instance = this;
_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();
}
notifyListeners();
}
final _auth = AuthService();
String? _username;
bool _isInitialized = false;
bool get isInitialized => _isInitialized;
final List<CameraProfile> _profiles = [];
final Set<CameraProfile> _enabled = {};
static const String _enabledPrefsKey = 'enabled_profiles';
static const String _maxCamerasPrefsKey = 'max_cameras';
// 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);
});
notifyListeners();
}
// Upload mode: production, sandbox, or simulate (in-memory, no uploads)
UploadMode _uploadMode = UploadMode.production;
@@ -49,10 +84,10 @@ class AppState extends ChangeNotifier {
print('AppState: Switching mode, token exists; validating...');
final isValid = await validateToken();
if (isValid) {
print('AppState: Switching mode; fetching username for $mode...');
print("AppState: Switching mode; fetching username for $mode...");
_username = await _auth.login();
if (_username != null) {
print('AppState: Switched mode, now logged in as $_username');
print("AppState: Switched mode, now logged in as $_username");
} else {
print('AppState: Switched mode but failed to retrieve username');
}
@@ -62,15 +97,15 @@ class AppState extends ChangeNotifier {
}
} else {
_username = null;
print('AppState: Mode change: not logged in in $mode');
print("AppState: Mode change: not logged in in $mode");
}
} catch (e) {
_username = null;
print('AppState: Mode change user restoration error: $e');
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');
print("AppState: Upload mode set to $mode");
notifyListeners();
}
@@ -114,8 +149,17 @@ class AppState extends ChangeNotifier {
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();
@@ -125,7 +169,7 @@ class AppState extends ChangeNotifier {
print('AppState: User appears to be logged in, fetching username...');
_username = await _auth.login();
if (_username != null) {
print('AppState: Successfully retrieved username: $_username');
print("AppState: Successfully retrieved username: $_username");
} else {
print('AppState: Failed to retrieve username despite being logged in');
}
@@ -133,10 +177,11 @@ class AppState extends ChangeNotifier {
print('AppState: User is not logged in');
}
} catch (e) {
print('AppState: Error during auth initialization: $e');
print("AppState: Error during auth initialization: $e");
}
_startUploader();
_isInitialized = true;
notifyListeners();
}
@@ -146,12 +191,12 @@ class AppState extends ChangeNotifier {
print('AppState: Starting login process...');
_username = await _auth.login();
if (_username != null) {
print('AppState: Login successful for user: $_username');
print("AppState: Login successful for user: $_username");
} else {
print('AppState: Login failed - no username returned');
}
} catch (e) {
print('AppState: Login error: $e');
print("AppState: Login error: $e");
_username = null;
}
notifyListeners();
@@ -171,7 +216,7 @@ class AppState extends ChangeNotifier {
print('AppState: Token exists, fetching username...');
_username = await _auth.login();
if (_username != null) {
print('AppState: Auth refresh successful: $_username');
print("AppState: Auth refresh successful: $_username");
} else {
print('AppState: Auth refresh failed - no username');
}
@@ -180,7 +225,7 @@ class AppState extends ChangeNotifier {
_username = null;
}
} catch (e) {
print('AppState: Auth refresh error: $e');
print("AppState: Auth refresh error: $e");
_username = null;
}
notifyListeners();
@@ -192,12 +237,12 @@ class AppState extends ChangeNotifier {
print('AppState: Starting forced fresh login...');
_username = await _auth.forceLogin();
if (_username != null) {
print('AppState: Forced login successful: $_username');
print("AppState: Forced login successful: $_username");
} else {
print('AppState: Forced login failed - no username returned');
}
} catch (e) {
print('AppState: Forced login error: $e');
print("AppState: Forced login error: $e");
_username = null;
}
notifyListeners();
@@ -208,7 +253,7 @@ class AppState extends ChangeNotifier {
try {
return await _auth.isLoggedIn();
} catch (e) {
print('AppState: Token validation error: $e');
print("AppState: Token validation error: $e");
return false;
}
}
@@ -219,7 +264,16 @@ class AppState extends ChangeNotifier {
List<CameraProfile> get enabledProfiles =>
_profiles.where(isEnabled).toList(growable: false);
void toggleProfile(CameraProfile p, bool e) {
e ? _enabled.add(p) : _enabled.remove(p);
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();
}
@@ -241,6 +295,11 @@ class AppState extends ChangeNotifier {
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();
@@ -342,7 +401,7 @@ class AppState extends ChangeNotifier {
bool ok;
if (_uploadMode == UploadMode.simulate) {
// Simulate successful upload without calling real API
print('AppState: UploadMode.simulate - simulating upload for ${item.coord}');
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');
@@ -380,14 +439,14 @@ class AppState extends ChangeNotifier {
// ---------- Queue management ----------
void clearQueue() {
print('AppState: Clearing upload queue (${_queue.length} items)');
print("AppState: Clearing upload queue (${_queue.length} items)");
_queue.clear();
_saveQueue();
notifyListeners();
}
void removeFromQueue(PendingUpload upload) {
print('AppState: Removing upload from queue: ${upload.coord}');
print("AppState: Removing upload from queue: ${upload.coord}");
_queue.remove(upload);
_saveQueue();
notifyListeners();
+25
View File
@@ -0,0 +1,25 @@
// lib/dev_config.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
// Marker/camera interaction
const Duration kMarkerTapTimeout = Duration(milliseconds: 250);
const Duration kDebounceCameraRefresh = Duration(milliseconds: 500);
const Duration kDebounceTileLayerUpdate = Duration(milliseconds: 50);
// Tile/OSM fetch retry parameters (for tunable backoff)
const int kTileFetchMaxAttempts = 3;
const int kTileFetchInitialDelayMs = 4000;
const int kTileFetchJitter1Ms = 1000;
const int kTileFetchSecondDelayMs = 15000;
const int kTileFetchJitter2Ms = 4000;
const int kTileFetchThirdDelayMs = 60000;
const int kTileFetchJitter3Ms = 5000;
+25 -2
View File
@@ -5,11 +5,34 @@ import 'app_state.dart';
import 'screens/home_screen.dart';
import 'screens/settings_screen.dart';
void main() {
import 'widgets/tile_provider_with_cache.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
runApp(
ChangeNotifierProvider(
create: (_) => AppState(),
child: const FlockMapApp(),
child: Consumer<AppState>(
builder: (context, appState, _) {
if (!appState.isInitialized) {
// You can customize this splash/loading screen as needed
return MaterialApp(
home: Scaffold(
backgroundColor: Color(0xFF202020),
body: Center(
child: Image.asset(
'assets/app_icon.png',
width: 240,
height: 240,
),
),
),
);
}
return const FlockMapApp();
},
),
),
);
}
+21
View File
@@ -11,6 +11,27 @@ class OsmCameraNode {
required this.tags,
});
Map<String, dynamic> toJson() => {
'id': id,
'lat': coord.latitude,
'lon': coord.longitude,
'tags': tags,
};
factory OsmCameraNode.fromJson(Map<String, dynamic> json) {
final tags = <String, String>{};
if (json['tags'] != null) {
(json['tags'] as Map<String, dynamic>).forEach((k, v) {
tags[k.toString()] = v.toString();
});
}
return OsmCameraNode(
id: json['id'] is int ? json['id'] as int : int.tryParse(json['id'].toString()) ?? 0,
coord: LatLng((json['lat'] as num).toDouble(), (json['lon'] as num).toDouble()),
tags: tags,
);
}
bool get hasDirection =>
tags.containsKey('direction') || tags.containsKey('camera:direction');
+184 -4
View File
@@ -1,9 +1,14 @@
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 'package:flutter_map/flutter_map.dart';
import '../services/offline_area_service.dart';
import '../widgets/add_camera_sheet.dart';
import '../services/offline_areas/offline_tile_utils.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@@ -14,6 +19,7 @@ class HomeScreen extends StatefulWidget {
class _HomeScreenState extends State<HomeScreen> {
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
final MapController _mapController = MapController();
bool _followMe = true;
void _openAddCameraSheet() {
@@ -47,18 +53,192 @@ class _HomeScreenState extends State<HomeScreen> {
],
),
body: MapView(
controller: _mapController,
followMe: _followMe,
onUserGesture: () {
if (_followMe) setState(() => _followMe = false);
},
),
floatingActionButton: appState.session == null
? FloatingActionButton.extended(
onPressed: _openAddCameraSheet,
icon: const Icon(Icons.add_location_alt),
label: const Text('Tag Camera'),
? 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();
// 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: 12,
max: 19,
divisions: 7,
label: 'Z${_zoom.toStringAsFixed(0)}',
value: _zoom,
onChanged: (v) {
setState(() => _zoom = v);
WidgetsBinding.instance.addPostFrameCallback((_) => _recomputeEstimates());
},
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Storage estimate:'),
Text(_mbEstimate == null
? ''
: '${_tileCount} tiles, ${_mbEstimate!.toStringAsFixed(1)} MB'),
],
),
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'),
),
],
);
}
}
+24 -317
View File
@@ -1,331 +1,38 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:uuid/uuid.dart';
import '../app_state.dart';
import '../models/camera_profile.dart';
import 'profile_editor.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/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';
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
// Authentication section
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(); // Use force login as the primary method
}
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,
),
);
}
},
),
// Test connection (only when logged in)
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) {
// Auto-logout if token is invalid
await appState.logout();
}
},
),
const Divider(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
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(
context,
MaterialPageRoute(
builder: (_) => ProfileEditor(profile: p),
),
);
} else if (value == 'delete') {
_showDeleteProfileDialog(context, appState, p);
}
},
),
),
),
const Divider(),
// Upload mode selector - Production/Sandbox/Simulate
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'),
),
DropdownMenuItem(
value: UploadMode.sandbox,
child: Text('Sandbox'),
),
DropdownMenuItem(
value: UploadMode.simulate,
child: Text('Simulate'),
),
],
onChanged: (mode) {
if (mode != null) appState.setUploadMode(mode);
},
),
),
// Help text
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));
}
},
),
),
const Divider(),
// Queue management
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, appState);
} : 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'),
),
TextButton(
onPressed: () {
appState.clearQueue();
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Queue cleared')),
);
},
child: const Text('Clear'),
),
],
),
);
},
),
],
),
);
}
void _showDeleteProfileDialog(BuildContext context, AppState appState, CameraProfile profile) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Profile'),
content: Text('Are you sure you want to delete "${profile.name}"?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
appState.deleteProfile(profile);
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Profile deleted')),
);
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Delete'),
),
],
),
);
}
void _showQueueDialog(BuildContext context, AppState appState) {
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: const Icon(Icons.camera_alt),
title: Text('Camera ${index + 1}'),
subtitle: Text(
'Lat: ${upload.coord.latitude.toStringAsFixed(6)}\n'
'Lon: ${upload.coord.longitude.toStringAsFixed(6)}\n'
'Direction: ${upload.direction.round()}°\n'
'Attempts: ${upload.attempts}'
),
trailing: 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)
TextButton(
onPressed: () {
appState.clearQueue();
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Queue cleared')),
);
},
child: const Text('Clear All'),
),
children: const [
UploadModeSection(),
Divider(),
AuthSection(),
Divider(),
QueueSection(),
Divider(),
ProfileListSection(),
Divider(),
MaxCamerasSection(),
Divider(),
OfflineModeSection(),
Divider(),
OfflineAreasSection(),
Divider(),
AboutSection(),
],
),
);
@@ -0,0 +1,37 @@
import 'package:flutter/material.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...',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('OK'),
),
],
),
),
);
},
);
}
}
@@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../app_state.dart';
class AuthSection extends StatelessWidget {
const AuthSection({super.key});
@override
Widget build(BuildContext context) {
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();
}
},
),
],
);
}
}
@@ -0,0 +1,80 @@
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();
},
),
),
),
],
);
}
}
@@ -0,0 +1,180 @@
import 'package:flutter/material.dart';
import '../../services/offline_area_service.dart';
import '../../services/offline_areas/offline_area_models.dart';
class OfflineAreasSection extends StatefulWidget {
const OfflineAreasSection({super.key});
@override
State<OfflineAreasSection> createState() => _OfflineAreasSectionState();
}
class _OfflineAreasSectionState extends State<OfflineAreasSection> {
OfflineAreaService get service => OfflineAreaService();
@override
void initState() {
super.initState();
Future.doWhile(() async {
await Future.delayed(const Duration(seconds: 1));
if (!mounted) return false;
setState(() {});
return true;
});
}
@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 Card(
child: ListTile(
leading: Icon(area.status == OfflineAreaStatus.complete
? Icons.cloud_done
: area.status == OfflineAreaStatus.error
? Icons.error
: Icons.download_for_offline),
title: Row(
children: [
Expanded(
child: Text(area.name.isNotEmpty
? area.name
: 'Area ${area.id.substring(0, 6)}...'),
),
if (!area.isPermanent)
IconButton(
icon: const Icon(Icons.edit, size: 20),
tooltip: 'Rename area',
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'),
content: TextField(
controller: ctrl,
maxLength: 40,
decoration: const InputDecoration(labelText: 'Area Name'),
autofocus: true,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () {
Navigator.pop(ctx, ctrl.text.trim());
},
child: const Text('Rename'),
),
],
);
},
);
if (newName != null && newName.trim().isNotEmpty) {
setState(() {
area.name = newName.trim();
service.saveAreasToDisk();
});
}
},
),
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)
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
tooltip: 'Delete offline area',
onPressed: () async {
service.deleteArea(area.id);
setState(() {});
},
),
],
),
subtitle: Text(subtitle),
isThreeLine: true,
trailing: area.status == OfflineAreaStatus.downloading
? Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 64,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
LinearProgressIndicator(value: area.progress),
Text(
'${(area.progress * 100).toStringAsFixed(0)}%',
style: const TextStyle(fontSize: 12),
)
],
),
),
IconButton(
icon: const Icon(Icons.cancel, color: Colors.orange),
tooltip: 'Cancel download',
onPressed: () {
service.cancelDownload(area.id);
setState(() {});
},
)
],
)
: null,
onLongPress: area.status == OfflineAreaStatus.downloading
? () {
service.cancelDownload(area.id);
setState(() {});
}
: null,
),
);
}).toList(),
);
}
}
@@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../app_state.dart';
class OfflineModeSection extends StatelessWidget {
const OfflineModeSection({super.key});
@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),
),
);
}
}
@@ -0,0 +1,116 @@
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 '../profile_editor.dart';
class ProfileListSection extends StatelessWidget {
const ProfileListSection({super.key});
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
return Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
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(
context,
MaterialPageRoute(
builder: (_) => ProfileEditor(profile: p),
),
);
} else if (value == 'delete') {
_showDeleteProfileDialog(context, p);
}
},
),
),
),
],
);
}
void _showDeleteProfileDialog(BuildContext context, CameraProfile profile) {
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}"?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
appState.deleteProfile(profile);
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Profile deleted')),
);
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Delete'),
),
],
),
);
}
}
@@ -0,0 +1,115 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../app_state.dart';
class QueueSection extends StatelessWidget {
const QueueSection({super.key});
@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'),
),
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>();
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: const Icon(Icons.camera_alt),
title: Text('Camera ${index + 1}'),
subtitle: Text(
'Lat: ${upload.coord.latitude.toStringAsFixed(6)}\n'
'Lon: ${upload.coord.longitude.toStringAsFixed(6)}\n'
'Direction: ${upload.direction.round()}°\n'
'Attempts: ${upload.attempts}'
),
trailing: 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)
TextButton(
onPressed: () {
appState.clearQueue();
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Queue cleared')),
);
},
child: const Text('Clear All'),
),
],
),
);
}
}
@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../app_state.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'),
),
DropdownMenuItem(
value: UploadMode.sandbox,
child: Text('Sandbox'),
),
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));
}
},
),
),
],
);
}
}
+151
View File
@@ -0,0 +1,151 @@
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';
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/tiles_from_local.dart';
enum MapSource { local, remote, auto } // For future use
class OfflineModeException implements Exception {
final String message;
OfflineModeException(this.message);
@override
String toString() => 'OfflineModeException: $message';
}
class MapDataProvider {
static final MapDataProvider _instance = MapDataProvider._();
factory MapDataProvider() => _instance;
MapDataProvider._();
// REMOVED: AppState get _appState => AppState();
bool get isOfflineMode => AppState.instance.offlineMode;
void setOfflineMode(bool enabled) {
AppState.instance.setOfflineMode(enabled);
}
/// Fetch cameras from OSM/Overpass or local storage.
/// Remote is default. If source is MapSource.auto, remote is tried first unless offline.
Future<List<OsmCameraNode>> getCameras({
required LatLngBounds bounds,
required List<CameraProfile> profiles,
UploadMode uploadMode = UploadMode.production,
MapSource source = MapSource.auto,
}) async {
final offline = AppState.instance.offlineMode;
print('[MapDataProvider] getCameras called, source=$source, offlineMode=$offline');
// Explicit remote request: error if offline, else always remote
if (source == MapSource.remote) {
if (offline) {
print('[MapDataProvider] Overpass request BLOCKED because we are in offlineMode');
throw OfflineModeException("Cannot fetch remote cameras in offline mode.");
}
return camerasFromOverpass(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
pageSize: AppState.instance.maxCameras,
fetchAllPages: false,
);
}
// Explicit local request: always use local
if (source == MapSource.local) {
return fetchLocalCameras(
bounds: bounds,
profiles: profiles,
);
}
// AUTO: default = remote first, fallback to local only if offline
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(
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,
);
}
}
}
/// Bulk/paged camera 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({
required LatLngBounds bounds,
required List<CameraProfile> profiles,
UploadMode uploadMode = UploadMode.production,
int pageSize = 500,
int maxTries = 3,
}) async {
final offline = AppState.instance.offlineMode;
if (offline) {
throw OfflineModeException("Cannot fetch remote cameras for offline area download in offline mode.");
}
return camerasFromOverpass(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
fetchAllPages: true,
pageSize: pageSize,
maxTries: maxTries,
);
}
/// Fetch tile image bytes. Default is to try local first, then remote if not offline. Honors explicit source.
Future<List<int>> getTile({
required int z,
required int x,
required int y,
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);
}
// Explicitly local
if (source == MapSource.local) {
return fetchLocalTile(z: z, x: x, y: y);
}
// AUTO (default): try local first, then remote if not offline
try {
return await fetchLocalTile(z: z, x: x, y: y);
} catch (_) {
if (!offline) {
return fetchOSMTile(z: z, x: x, y: y);
} else {
throw OfflineModeException("Tile $z/$x/$y not found in offline areas and offline mode is enabled.");
}
}
}
}
@@ -0,0 +1,72 @@
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;
}
@@ -0,0 +1,98 @@
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 query = '''
[out:json][timeout:25];
(
$nodeClauses
);
out body $pageSize;
''';
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 [];
}
}
if (!fetchAllPages) {
// Just one page
return await fetchChunk();
} else {
// Fetch all possible data, paging with deduplication and backoff
final seenIds = <int>{};
final allCameras = <OsmCameraNode>[];
int page = 0;
while (true) {
page++;
List<OsmCameraNode> pageCameras = [];
int tries = 0;
while (tries < maxTries) {
try {
final cams = await fetchChunk();
pageCameras = cams.where((c) => !seenIds.contains(c.id)).toList();
break;
} catch (e) {
tries++;
final delayMs = 400 * (1 << tries);
print('[camerasFromOverpass][paged] Error on page $page try $tries: $e. Retrying in ${delayMs}ms.');
await Future.delayed(Duration(milliseconds: delayMs));
}
}
if (pageCameras.isEmpty) break;
print('[camerasFromOverpass][paged] Page $page: got ${pageCameras.length} new cameras.');
allCameras.addAll(pageCameras);
seenIds.addAll(pageCameras.map((c) => c.id));
if (pageCameras.length < pageSize) break;
}
print('[camerasFromOverpass][paged] DONE. Found ${allCameras.length} cameras for download.');
return allCameras;
}
}
@@ -0,0 +1,43 @@
import 'dart:io';
import 'package:latlong2/latlong.dart';
import '../offline_area_service.dart';
import '../offline_areas/offline_area_models.dart';
import '../offline_areas/offline_tile_utils.dart';
/// Fetch a tile from the newest offline area that plausibly contains it, or throw if not found.
Future<List<int>> fetchLocalTile({required int z, required int x, required int y}) async {
final areas = OfflineAreaService().offlineAreas;
final List<_AreaTileMatch> candidates = [];
for (final area in areas) {
if (area.status != OfflineAreaStatus.complete) continue;
if (z < area.minZoom || z > area.maxZoom) continue;
// Get tile coverage for area at this zoom only
final coveredTiles = computeTileList(area.bounds, z, z);
final hasTile = coveredTiles.any((tile) => tile[0] == z && tile[1] == x && tile[2] == y);
if (hasTile) {
final tilePath = _tilePath(area.directory, z, x, y);
final file = File(tilePath);
if (await file.exists()) {
final stat = await file.stat();
candidates.add(_AreaTileMatch(area: area, file: file, modified: stat.modified));
}
}
}
if (candidates.isEmpty) {
throw Exception('Tile $z/$x/$y not found in any offline area');
}
candidates.sort((a, b) => b.modified.compareTo(a.modified)); // newest first
return await candidates.first.file.readAsBytes();
}
String _tilePath(String areaDir, int z, int x, int y) =>
'$areaDir/tiles/$z/$x/$y.png';
class _AreaTileMatch {
final OfflineArea area;
final File file;
final DateTime modified;
_AreaTileMatch({required this.area, required this.file, required this.modified});
}
@@ -0,0 +1,82 @@
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--;
}
}
}
+307
View File
@@ -0,0 +1,307 @@
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 '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 '../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';
/// 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());
}
final List<OfflineArea> _areas = [];
List<OfflineArea> get offlineAreas => List.unmodifiable(_areas);
Future<Directory> getOfflineAreaDir() async {
final dir = await getApplicationDocumentsDirectory();
final areaRoot = Directory("${dir.path}/offline_areas");
if (!areaRoot.existsSync()) {
areaRoot.createSync(recursive: true);
}
return areaRoot;
}
Future<File> _getMetadataPath() async {
final dir = await getOfflineAreaDir();
return File("${dir.path}/offline_areas.json");
}
Future<int> getAreaSizeBytes(OfflineArea area) async {
int total = 0;
final dir = Directory(area.directory);
if (await dir.exists()) {
await for (var fse in dir.list(recursive: true)) {
if (fse is File) {
total += await fse.length();
}
}
}
area.sizeBytes = total;
await saveAreasToDisk();
return total;
}
Future<void> saveAreasToDisk() async {
try {
final file = await _getMetadataPath();
final content = jsonEncode(_areas.map((a) => a.toJson()).toList());
await file.writeAsString(content);
} catch (e) {
debugPrint('Failed to save offline areas: $e');
}
}
Future<void> _loadAreasFromDisk() async {
try {
final file = await _getMetadataPath();
if (!(await file.exists())) return;
final str = await file.readAsString();
if (str.trim().isEmpty) return;
late final List data;
try {
data = jsonDecode(str);
} catch (e) {
debugPrint('Failed to parse offline areas json: $e');
return;
}
_areas.clear();
for (final areaJson in data) {
final area = OfflineArea.fromJson(areaJson);
if (!Directory(area.directory).existsSync()) {
area.status = OfflineAreaStatus.error;
} else {
getAreaSizeBytes(area);
}
_areas.add(area);
}
} catch (e) {
debugPrint('Failed to load offline areas: $e');
}
}
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,
required LatLngBounds bounds,
required int minZoom,
required int maxZoom,
required String directory,
void Function(double progress)? onProgress,
void Function(OfflineAreaStatus status)? onComplete,
String? name,
}) async {
OfflineArea? area;
for (final a in _areas) {
if (a.id == id) { area = a; break; }
}
if (area != null) {
_areas.remove(area);
final dirObj = Directory(area.directory);
if (await dirObj.exists()) {
await dirObj.delete(recursive: true);
}
}
area = OfflineArea(
id: id,
name: name ?? area?.name ?? '',
bounds: bounds,
minZoom: minZoom,
maxZoom: maxZoom,
directory: directory,
isPermanent: area?.isPermanent ?? false,
);
_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;
}
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.');
} else {
area.status = OfflineAreaStatus.error;
debugPrint('Area $id: MISSING tiles after $maxPasses passes. First 10: ${tilesToFetch.toList().take(10)}');
if (!area.isPermanent) {
final dirObj = Directory(area.directory);
if (await dirObj.exists()) {
await dirObj.delete(recursive: true);
}
_areas.remove(area);
}
}
await saveAreasToDisk();
if (onComplete != null) onComplete(area.status);
} catch (e) {
area.status = OfflineAreaStatus.error;
await saveAreasToDisk();
if (onComplete != null) onComplete(area.status);
}
}
void cancelDownload(String id) async {
final area = _areas.firstWhere((a) => a.id == id, orElse: () => throw 'Area not found');
area.status = OfflineAreaStatus.cancelled;
final dir = Directory(area.directory);
if (await dir.exists()) {
await dir.delete(recursive: true);
}
_areas.remove(area);
await saveAreasToDisk();
if (area.isPermanent) {
_ensureAndAutoDownloadWorldArea();
}
}
void deleteArea(String id) async {
final area = _areas.firstWhere((a) => a.id == id, orElse: () => throw 'Area not found');
final dir = Directory(area.directory);
if (await dir.exists()) {
await dir.delete(recursive: true);
}
_areas.remove(area);
await saveAreasToDisk();
}
}
@@ -0,0 +1,82 @@
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
import '../../models/osm_camera_node.dart';
/// Status of an offline area
enum OfflineAreaStatus { downloading, complete, error, cancelled }
/// Model class describing an offline area for map/camera caching
class OfflineArea {
final String id;
String name;
final LatLngBounds bounds;
final int minZoom;
final int maxZoom;
final String directory; // base dir for area storage
OfflineAreaStatus status;
double progress; // 0.0 - 1.0
int tilesDownloaded;
int tilesTotal;
List<OsmCameraNode> cameras;
int sizeBytes; // Disk size in bytes
final bool isPermanent; // Not user-deletable if true
OfflineArea({
required this.id,
this.name = '',
required this.bounds,
required this.minZoom,
required this.maxZoom,
required this.directory,
this.status = OfflineAreaStatus.downloading,
this.progress = 0,
this.tilesDownloaded = 0,
this.tilesTotal = 0,
this.cameras = const [],
this.sizeBytes = 0,
this.isPermanent = false,
});
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'bounds': {
'sw': {'lat': bounds.southWest.latitude, 'lng': bounds.southWest.longitude},
'ne': {'lat': bounds.northEast.latitude, 'lng': bounds.northEast.longitude},
},
'minZoom': minZoom,
'maxZoom': maxZoom,
'directory': directory,
'status': status.name,
'progress': progress,
'tilesDownloaded': tilesDownloaded,
'tilesTotal': tilesTotal,
'cameras': cameras.map((c) => c.toJson()).toList(),
'sizeBytes': sizeBytes,
'isPermanent': isPermanent,
};
static OfflineArea fromJson(Map<String, dynamic> json) {
final bounds = LatLngBounds(
LatLng(json['bounds']['sw']['lat'], json['bounds']['sw']['lng']),
LatLng(json['bounds']['ne']['lat'], json['bounds']['ne']['lng']),
);
return OfflineArea(
id: json['id'],
name: json['name'] ?? '',
bounds: bounds,
minZoom: json['minZoom'],
maxZoom: json['maxZoom'],
directory: json['directory'],
status: OfflineAreaStatus.values.firstWhere(
(e) => e.name == json['status'], orElse: () => OfflineAreaStatus.error),
progress: (json['progress'] ?? 0).toDouble(),
tilesDownloaded: json['tilesDownloaded'] ?? 0,
tilesTotal: json['tilesTotal'] ?? 0,
cameras: (json['cameras'] as List? ?? [])
.map((e) => OsmCameraNode.fromJson(e)).toList(),
sizeBytes: json['sizeBytes'] ?? 0,
isPermanent: json['isPermanent'] ?? false,
);
}
}
@@ -0,0 +1,19 @@
import 'dart:io';
import 'dart:convert';
import '../../models/osm_camera_node.dart';
/// Disk IO utilities for offline area file management ONLY. No network requests should occur here.
/// Save-to-disk for a tile that has already been fetched elsewhere.
Future<void> saveTileBytes(int z, int x, int y, String baseDir, List<int> bytes) async {
final dir = Directory('$baseDir/tiles/$z/$x');
await dir.create(recursive: true);
final file = File('${dir.path}/$y.png');
await file.writeAsBytes(bytes);
}
/// Save-to-disk for cameras.json; called only by OfflineAreaService during area download
Future<void> saveCameras(List<OsmCameraNode> cams, String dir) async {
final file = File('$dir/cameras.json');
await file.writeAsString(jsonEncode(cams.map((c) => c.toJson()).toList()));
}
@@ -0,0 +1,69 @@
import 'dart:math';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
/// Utility for tile calculations and lat/lon conversions for OSM offline logic
Set<List<int>> computeTileList(LatLngBounds bounds, int zMin, int zMax) {
Set<List<int>> tiles = {};
const double epsilon = 1e-7;
double latMin = min(bounds.southWest.latitude, bounds.northEast.latitude);
double latMax = max(bounds.southWest.latitude, bounds.northEast.latitude);
double lonMin = min(bounds.southWest.longitude, bounds.northEast.longitude);
double lonMax = max(bounds.southWest.longitude, bounds.northEast.longitude);
// Expand degenerate/flat areas a hair
if ((latMax - latMin).abs() < epsilon) {
latMin -= epsilon;
latMax += epsilon;
}
if ((lonMax - lonMin).abs() < epsilon) {
lonMin -= epsilon;
lonMax += epsilon;
}
for (int z = zMin; z <= zMax; z++) {
final n = pow(2, z).toInt();
final minTile = latLonToTile(latMin, lonMin, z);
final maxTile = latLonToTile(latMax, lonMax, z);
final minX = min(minTile[0], maxTile[0]);
final maxX = max(minTile[0], maxTile[0]);
final minY = min(minTile[1], maxTile[1]);
final maxY = max(minTile[1], maxTile[1]);
for (int x = minX; x <= maxX; x++) {
for (int y = minY; y <= maxY; y++) {
tiles.add([z, x, y]);
}
}
}
return tiles;
}
List<double> latLonToTileRaw(double lat, double lon, int zoom) {
final n = pow(2.0, zoom);
final xtile = (lon + 180.0) / 360.0 * n;
final ytile = (1.0 -
log(tan(lat * pi / 180.0) + 1.0 / cos(lat * pi / 180.0)) / pi) / 2.0 * n;
return [xtile, ytile];
}
List<int> latLonToTile(double lat, double lon, int zoom) {
final n = pow(2.0, zoom);
final xtile = ((lon + 180.0) / 360.0 * n).floor();
final ytile = ((1.0 - log(tan(lat * pi / 180.0) + 1.0 / cos(lat * pi / 180.0)) / pi) / 2.0 * n).floor();
return [xtile, ytile];
}
int findDynamicMinZoom(LatLngBounds bounds, {int maxSearchZoom = 19}) {
for (int z = 1; z <= maxSearchZoom; z++) {
final swTile = latLonToTile(bounds.southWest.latitude, bounds.southWest.longitude, z);
final neTile = latLonToTile(bounds.northEast.latitude, bounds.northEast.longitude, z);
if (swTile[0] != neTile[0] || swTile[1] != neTile[1]) {
return z - 1 > 0 ? z - 1 : 1;
}
}
return maxSearchZoom;
}
LatLngBounds globalWorldBounds() {
// Use slightly shrunken bounds to avoid tile index overflow at extreme coordinates
return LatLngBounds(LatLng(-85.0, -179.9), LatLng(85.0, 179.9));
}
-70
View File
@@ -1,70 +0,0 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import '../models/camera_profile.dart';
import '../models/osm_camera_node.dart';
import '../app_state.dart';
class OverpassService {
static const _prodEndpoint = 'https://overpass-api.de/api/interpreter';
static const _sandboxEndpoint = 'https://overpass-api.dev.openstreetmap.org/api/interpreter';
// You can pass UploadMode, or use production by default
Future<List<OsmCameraNode>> fetchCameras(
LatLngBounds bbox,
List<CameraProfile> profiles,
{UploadMode uploadMode = UploadMode.production}
) async {
if (profiles.isEmpty) return [];
// Build one node query per enabled profile (each with all its tags required)
final nodeClauses = profiles.map((profile) {
final tagFilters = profile.tags.entries
.map((e) => '["${e.key}"="${e.value}"]')
.join('\n ');
return '''node\n $tagFilters\n (${bbox.southWest.latitude},${bbox.southWest.longitude},\n ${bbox.northEast.latitude},${bbox.northEast.longitude});''';
}).join('\n ');
final query = '''
[out:json][timeout:25];
(
$nodeClauses
);
out body 250;
''';
Future<List<OsmCameraNode>> fetchFromUri(String endpoint, String query) async {
try {
print('[Overpass] Querying $endpoint');
print('[Overpass] Query:\n$query');
final resp = await http.post(Uri.parse(endpoint), body: {'data': query.trim()});
print('[Overpass] Status: \\${resp.statusCode}, Length: \\${resp.body.length}');
if (resp.statusCode != 200) {
print('[Overpass] Failed: \\${resp.body}');
return [];
}
final data = jsonDecode(resp.body) as Map<String, dynamic>;
final elements = data['elements'] as List<dynamic>;
print('[Overpass] 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('[Overpass] Exception: \\${e}');
// Network error return empty list silently
return [];
}
}
// Fetch from production Overpass for all modes.
return await fetchFromUri(_prodEndpoint, query);
}
}
+182 -48
View File
@@ -1,23 +1,78 @@
import 'dart:async';
import 'dart:math' as math;
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:http/io_client.dart';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:latlong2/latlong.dart';
import 'package:geolocator/geolocator.dart';
import 'package:provider/provider.dart';
import '../app_state.dart';
import '../services/overpass_service.dart';
import '../services/map_data_provider.dart';
import '../services/offline_area_service.dart';
import '../models/osm_camera_node.dart';
import 'debouncer.dart';
import 'camera_tag_sheet.dart';
import 'tile_provider_with_cache.dart';
import 'package:flock_map_app/dev_config.dart';
// --- Smart marker widget for camera with single/double tap distinction
class _CameraMapMarker extends StatefulWidget {
final OsmCameraNode node;
final MapController mapController;
const _CameraMapMarker({required this.node, required this.mapController, Key? key}) : super(key: key);
@override
State<_CameraMapMarker> createState() => _CameraMapMarkerState();
}
class _CameraMapMarkerState extends State<_CameraMapMarker> {
Timer? _tapTimer;
// From dev_config.dart for build-time parameters
static const Duration tapTimeout = kMarkerTapTimeout;
void _onTap() {
_tapTimer = Timer(tapTimeout, () {
showModalBottomSheet(
context: context,
builder: (_) => CameraTagSheet(node: widget.node),
showDragHandle: true,
);
});
}
void _onDoubleTap() {
_tapTimer?.cancel();
widget.mapController.move(widget.node.coord, widget.mapController.camera.zoom + 1);
}
@override
void dispose() {
_tapTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _onTap,
onDoubleTap: _onDoubleTap,
child: const Icon(Icons.videocam, color: Colors.orange),
);
}
}
class MapView extends StatefulWidget {
final MapController controller;
const MapView({
super.key,
required this.controller,
required this.followMe,
required this.onUserGesture,
});
@@ -30,9 +85,10 @@ class MapView extends StatefulWidget {
}
class _MapViewState extends State<MapView> {
final MapController _controller = MapController();
final OverpassService _overpass = OverpassService();
final Debouncer _debounce = Debouncer(const Duration(milliseconds: 500));
late final MapController _controller;
final MapDataProvider _mapDataProvider = MapDataProvider();
final Debouncer _debounce = Debouncer(kDebounceCameraRefresh);
Debouncer? _debounceTileLayerUpdate;
StreamSubscription<Position>? _positionSub;
LatLng? _currentLatLng;
@@ -41,15 +97,16 @@ class _MapViewState extends State<MapView> {
List<String> _lastProfileIds = [];
UploadMode? _lastUploadMode;
void _maybeRefreshCameras(AppState appState) {
void _maybeRefreshCameras() {
final appState = context.read<AppState>();
final currProfileIds = appState.enabledProfiles.map((p) => p.id).toList();
final currMode = appState.uploadMode;
if (_lastProfileIds.isEmpty ||
if (_lastProfileIds.isEmpty ||
currProfileIds.length != _lastProfileIds.length ||
!_lastProfileIds.asMap().entries.every((entry) => currProfileIds[entry.key] == entry.value) ||
_lastUploadMode != currMode) {
// If this is first load, or list/ids/mode changed, refetch
_debounce(() => _refreshCameras(appState));
_debounce(_refreshCameras);
_lastProfileIds = List.from(currProfileIds);
_lastUploadMode = currMode;
}
@@ -58,6 +115,10 @@ class _MapViewState extends State<MapView> {
@override
void initState() {
super.initState();
_debounceTileLayerUpdate = Debouncer(kDebounceTileLayerUpdate);
// Kick off offline area loading as soon as map loads
OfflineAreaService();
_controller = widget.controller;
_initLocation();
}
@@ -91,19 +152,41 @@ class _MapViewState extends State<MapView> {
});
}
Future<void> _refreshCameras(AppState appState) async {
Future<void> _refreshCameras() async {
final appState = context.read<AppState>();
LatLngBounds? bounds;
try {
bounds = _controller.camera.visibleBounds;
} catch (_) {
return; // controller not ready yet
}
final cams = await _overpass.fetchCameras(
bounds,
appState.enabledProfiles,
uploadMode: appState.uploadMode,
);
if (mounted) setState(() => _cameras = cams);
// If too zoomed out, do NOT fetch cameras; show info
final zoom = _controller.camera.zoom;
if (zoom < 10) {
if (mounted) setState(() => _cameras = []);
// Show a snackbar-style bubble, if desired
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Cameras not drawn below zoom level 10'),
duration: Duration(seconds: 2),
),
);
}
return;
}
try {
final cams = await _mapDataProvider.getCameras(
bounds: bounds,
profiles: appState.enabledProfiles,
uploadMode: appState.uploadMode,
// MapSource.auto (default) will prefer Overpass for now
);
if (mounted) setState(() => _cameras = cams);
} on OfflineModeException catch (_) {
// Swallow the error in offline mode
if (mounted) setState(() => _cameras = []);
}
}
double _safeZoom() {
@@ -122,7 +205,7 @@ class _MapViewState extends State<MapView> {
// Refetch only if profiles or mode changed
// This avoids repeated fetches on every build
// We track last seen values (local to the State class)
_maybeRefreshCameras(appState);
_maybeRefreshCameras();
// Seed addmode target once, after first controller center is available.
if (session != null && session.target == null) {
@@ -136,7 +219,17 @@ class _MapViewState extends State<MapView> {
final zoom = _safeZoom();
final markers = <Marker>[
// Camera markers first, then GPS dot, so blue dot is always on top
final markers = <Marker>[
..._cameras
.where((n) => n.coord.latitude != 0 || n.coord.longitude != 0)
.where((n) => n.coord.latitude.abs() <= 90 && n.coord.longitude.abs() <= 180)
.map((n) => Marker(
point: n.coord,
width: 24,
height: 24,
child: _CameraMapMarker(node: n, mapController: _controller),
)),
if (_currentLatLng != null)
Marker(
point: _currentLatLng!,
@@ -144,23 +237,6 @@ class _MapViewState extends State<MapView> {
height: 16,
child: const Icon(Icons.my_location, color: Colors.blue),
),
..._cameras.map(
(n) => Marker(
point: n.coord,
width: 24,
height: 24,
child: GestureDetector(
onTap: () {
showModalBottomSheet(
context: context,
builder: (_) => CameraTagSheet(node: n),
showDragHandle: true, // for better UX on Material3
);
},
child: const Icon(Icons.videocam, color: Colors.orange),
),
),
),
];
final overlays = <Polygon>[
@@ -168,41 +244,75 @@ class _MapViewState extends State<MapView> {
_buildCone(session.target!, session.directionDegrees, zoom),
..._cameras
.where((n) => n.hasDirection && n.directionDeg != null)
.where((n) => n.coord.latitude != 0 || n.coord.longitude != 0)
.where((n) => n.coord.latitude.abs() <= 90 && n.coord.longitude.abs() <= 180)
.map((n) => _buildCone(n.coord, n.directionDeg!, zoom)),
];
return Stack(
children: [
FlutterMap(
key: ValueKey(appState.offlineMode),
mapController: _controller,
options: MapOptions(
center: _currentLatLng ?? LatLng(37.7749, -122.4194),
zoom: 15,
initialCenter: _currentLatLng ?? LatLng(37.7749, -122.4194),
initialZoom: 15,
maxZoom: 19,
onPositionChanged: (pos, gesture) {
setState(() {}); // Instant UI update for zoom, etc.
if (gesture) widget.onUserGesture();
if (session != null) {
appState.updateSession(target: pos.center);
}
_debounce(() => _refreshCameras(appState));
_debounce(_refreshCameras);
},
),
children: [
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
tileProvider: NetworkTileProvider(
headers: {
'User-Agent':
'FlockMap/0.4 (+https://github.com/yourrepo)',
tileProvider: TileProviderWithCache(
onTileCacheUpdated: () {
print('[MapView] onTileCacheUpdated fired (tile loaded)');
if (_debounceTileLayerUpdate != null) _debounceTileLayerUpdate!(() {
print('[MapView] Running debounced setState due to tile cache update');
if (mounted) setState(() {});
});
},
httpClient: IOClient(
HttpClient()..maxConnectionsPerHost = 4,
),
),
userAgentPackageName: 'com.example.flock_map_app',
urlTemplate: 'unused-{z}-{x}-{y}',
tileSize: 256,
tileBuilder: (ctx, tileWidget, tileImage) {
try {
final str = tileImage.toString();
final regex = RegExp(r'TileCoordinate\((\d+), (\d+), (\d+)\)');
final match = regex.firstMatch(str);
if (match != null) {
final x = match.group(1);
final y = match.group(2);
final z = match.group(3);
final key = '$z/$x/$y';
final bytes = TileProviderWithCache.tileCache[key];
if (bytes != null && bytes.isNotEmpty) {
return Image.memory(bytes, gaplessPlayback: true, fit: BoxFit.cover);
}
}
return tileWidget;
} catch (e) {
print('tileBuilder error: $e for tileImage: ${tileImage.toString()}');
return tileWidget;
}
}
),
PolygonLayer(polygons: overlays),
MarkerLayer(markers: markers),
// Built-in scale bar from flutter_map
Scalebar(
alignment: Alignment.bottomLeft,
padding: EdgeInsets.only(left: 8, bottom: 54), // above attribution
textStyle: TextStyle(color: Colors.black, fontWeight: FontWeight.bold),
lineColor: Colors.black,
strokeWidth: 3,
// backgroundColor removed in flutter_map >=8 (wrap in Container if needed)
),
],
),
@@ -236,6 +346,31 @@ class _MapViewState extends State<MapView> {
),
),
// Zoom indicator, positioned above scale bar
Positioned(
left: 10,
bottom: 92,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.52),
borderRadius: BorderRadius.circular(7),
),
child: Builder(
builder: (context) {
final zoom = _controller.camera.zoom;
return Text(
'Zoom: ${zoom.toStringAsFixed(2)}',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600,
),
);
},
),
),
),
// Attribution overlay
Positioned(
bottom: 20,
@@ -262,8 +397,8 @@ class _MapViewState extends State<MapView> {
}
Polygon _buildCone(LatLng origin, double bearingDeg, double zoom) {
const halfAngle = 15.0;
final length = 0.0012 * math.pow(2, 15 - zoom);
final halfAngle = kDirectionConeHalfAngle;
final length = kDirectionConeBaseLength * math.pow(2, 15 - zoom);
LatLng _project(double deg) {
final rad = deg * math.pi / 180;
@@ -278,7 +413,6 @@ class _MapViewState extends State<MapView> {
return Polygon(
points: [origin, left, right, origin],
isFilled: true,
color: Colors.redAccent.withOpacity(0.25),
borderColor: Colors.redAccent,
borderStrokeWidth: 1,
+55
View File
@@ -0,0 +1,55 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import '../services/map_data_provider.dart';
import '../app_state.dart';
/// Singleton in-memory tile cache and async provider for custom tiles.
class TileProviderWithCache extends TileProvider {
static final Map<String, Uint8List> _tileCache = {};
static Map<String, Uint8List> get tileCache => _tileCache;
final VoidCallback? onTileCacheUpdated;
TileProviderWithCache({this.onTileCacheUpdated});
@override
ImageProvider getImage(TileCoordinates coords, TileLayer options, {MapSource source = MapSource.auto}) {
final key = '${coords.z}/${coords.x}/${coords.y}';
if (_tileCache.containsKey(key)) {
return MemoryImage(_tileCache[key]!);
} else {
_fetchAndCacheTile(coords, key, source: source);
// Always return a placeholder until the real tile is cached, regardless of source/offline/online.
return const AssetImage('assets/transparent_1x1.png');
}
}
static void clearCache() {
_tileCache.clear();
print('[TileProviderWithCache] Tile cache cleared');
}
void _fetchAndCacheTile(TileCoordinates coords, String key, {MapSource source = MapSource.auto}) async {
// Don't fire multiple fetches for the same tile simultaneously
if (_tileCache.containsKey(key)) return;
try {
final bytes = await MapDataProvider().getTile(
z: coords.z, x: coords.x, y: coords.y, source: source,
);
if (bytes.isNotEmpty) {
_tileCache[key] = Uint8List.fromList(bytes);
print('[TileProviderWithCache] Cached tile $key, bytes=${bytes.length}');
if (onTileCacheUpdated != null) {
print('[TileProviderWithCache] Calling onTileCacheUpdated for $key');
SchedulerBinding.instance.addPostFrameCallback((_) => onTileCacheUpdated!());
}
}
// If bytes were empty, don't cache (will re-attempt next time)
} catch (e) {
print('[TileProviderWithCache] Error fetching tile $key: $e');
// Do NOT cache a failed or empty tile! Placeholder tiles will be evicted on online transition.
}
}
}
+144 -8
View File
@@ -1,6 +1,30 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
ansicolor:
dependency: transitive
description:
name: ansicolor
sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f"
url: "https://pub.dev"
source: hosted
version: "2.0.3"
archive:
dependency: transitive
description:
name: archive
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
url: "https://pub.dev"
source: hosted
version: "4.0.7"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
@@ -17,6 +41,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev"
source: hosted
version: "0.4.2"
clock:
dependency: transitive
description:
@@ -41,6 +81,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.6"
csslib:
dependency: transitive
description:
name: csslib
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
dart_earcut:
dependency: transitive
description:
name: dart_earcut
sha256: e485001bfc05dcbc437d7bfb666316182e3522d4c3f9668048e004d0eb2ce43b
url: "https://pub.dev"
source: hosted
version: "1.2.0"
dart_polylabel2:
dependency: transitive
description:
name: dart_polylabel2
sha256: "7eeab15ce72894e4bdba6a8765712231fc81be0bd95247de4ad9966abc57adc6"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
desktop_webview_window:
dependency: transitive
description:
@@ -78,14 +142,30 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_launcher_icons:
dependency: "direct dev"
description:
name: flutter_launcher_icons
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
url: "https://pub.dev"
source: hosted
version: "0.14.4"
flutter_map:
dependency: "direct main"
description:
name: flutter_map
sha256: "87cc8349b8fa5dccda5af50018c7374b6645334a0d680931c1fe11bce88fa5bb"
sha256: df33e784b09fae857c6261a5521dd42bd4d3342cb6200884bb70730638af5fd5
url: "https://pub.dev"
source: hosted
version: "6.2.1"
version: "8.2.1"
flutter_native_splash:
dependency: "direct dev"
description:
name: flutter_native_splash
sha256: "8321a6d11a8d13977fa780c89de8d257cce3d841eecfb7a4cadffcc4f12d82dc"
url: "https://pub.dev"
source: hosted
version: "2.4.6"
flutter_secure_storage:
dependency: "direct main"
description:
@@ -203,14 +283,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.2.5"
html:
dependency: transitive
description:
name: html
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
url: "https://pub.dev"
source: hosted
version: "0.15.6"
http:
dependency: "direct main"
description:
name: http
sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b"
sha256: "85ab0074f9bf2b24625906d8382bbec84d3d6919d285ba9c106b07b65791fb99"
url: "https://pub.dev"
source: hosted
version: "1.4.0"
version: "1.5.0-beta.2"
http_parser:
dependency: transitive
description:
@@ -219,6 +307,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928"
url: "https://pub.dev"
source: hosted
version: "4.5.4"
intl:
dependency: transitive
description:
@@ -227,6 +323,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.20.2"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
url: "https://pub.dev"
source: hosted
version: "4.9.0"
latlong2:
dependency: "direct main"
description:
@@ -347,6 +451,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
platform:
dependency: transitive
description:
@@ -363,14 +475,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.8"
polylabel:
posix:
dependency: transitive
description:
name: polylabel
sha256: "41b9099afb2aa6c1730bdd8a0fab1400d287694ec7615dd8516935fa3144214b"
name: posix
sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
version: "6.0.3"
proj4dart:
dependency: transitive
description:
@@ -504,6 +616,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.3.1"
universal_io:
dependency: transitive
description:
name: universal_io
sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad"
url: "https://pub.dev"
source: hosted
version: "2.2.2"
url_launcher:
dependency: transitive
description:
@@ -624,6 +744,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
url: "https://pub.dev"
source: hosted
version: "6.5.0"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.8.0 <4.0.0"
flutter: ">=3.27.0"
+23 -1
View File
@@ -12,7 +12,8 @@ dependencies:
# UI & Map
provider: ^6.1.2
flutter_map: ^6.2.1
flutter_map: ^8.2.1
# (removed: using built-in Scalebar from flutter_map >= v6)
latlong2: ^0.9.0
geolocator: ^10.1.0
http: ^1.2.1
@@ -26,5 +27,26 @@ dependencies:
shared_preferences: ^2.2.2
uuid: ^4.0.0
dev_dependencies:
flutter_launcher_icons: ^0.14.4
flutter_native_splash: ^2.4.6
flutter:
uses-material-design: true
assets:
- assets/info.txt
- assets/app_icon.png
- assets/transparent_1x1.png
flutter_native_splash:
color: "#202020"
image: assets/app_icon.png
android: true
ios: true
flutter_icons:
android: true
ios: true
image_path: "assets/app_icon.png"
min_sdk_android: 21