@@ -4,16 +4,63 @@ A Flutter app for mapping and tagging ALPR-style cameras (and other surveillance
|
||||
|
||||
---
|
||||
|
||||
## Code Organization
|
||||
## Code Organization (2025 Refactor)
|
||||
|
||||
This project uses a modular file/folder structure for maintainability:
|
||||
- **Settings sections** each live in their own file under `lib/screens/settings_screen_sections/`.
|
||||
- **Offline map area models, tile logic, and network/camera helpers** are grouped under `lib/services/offline_areas/`.
|
||||
- The main Settings and OfflineAreaService files are now slim front-ends that delegate logic to these modules.
|
||||
- **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.**
|
||||
|
||||
---
|
||||
|
||||
## User Experience & Features
|
||||
## 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.
|
||||
|
||||
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
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>
|
||||
|
||||
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 435 KiB |
|
After Width: | Height: | Size: 805 KiB |
|
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>
|
||||
|
||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 14 KiB |
|
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
|
||||
|
||||
|
After Width: | Height: | Size: 757 KiB |
|
After Width: | Height: | Size: 95 B |
@@ -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
|
||||
@@ -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"}}
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 805 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 15 KiB |
|
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
|
||||
}
|
||||
}
|
||||
|
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
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 165 KiB |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 435 KiB |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
|
||||
@@ -137,6 +181,7 @@ class AppState extends ChangeNotifier {
|
||||
}
|
||||
|
||||
_startUploader();
|
||||
_isInitialized = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ 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});
|
||||
@@ -16,18 +18,18 @@ class SettingsScreen extends StatelessWidget {
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: const [
|
||||
AuthSection(),
|
||||
Divider(),
|
||||
UploadModeSection(),
|
||||
Divider(),
|
||||
AuthSection(),
|
||||
Divider(),
|
||||
QueueSection(),
|
||||
Divider(),
|
||||
ProfileListSection(),
|
||||
Divider(),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: 8.0),
|
||||
child: Text('Offline Areas', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
MaxCamerasSection(),
|
||||
Divider(),
|
||||
OfflineModeSection(),
|
||||
Divider(),
|
||||
OfflineAreasSection(),
|
||||
Divider(),
|
||||
AboutSection(),
|
||||
|
||||
@@ -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,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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,7 @@ class ProfileListSection extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
} else if (value == 'delete') {
|
||||
_showDeleteProfileDialog(context, appState, p);
|
||||
_showDeleteProfileDialog(context, p);
|
||||
}
|
||||
},
|
||||
),
|
||||
@@ -86,30 +86,31 @@ class ProfileListSection extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
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 _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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ class QueueSection extends StatelessWidget {
|
||||
? const Text('Sandbox mode – uploads go to OSM Sandbox')
|
||||
: const Text('Tap to view queue'),
|
||||
onTap: appState.pendingCount > 0
|
||||
? () => _showQueueDialog(context, appState)
|
||||
? () => _showQueueDialog(context)
|
||||
: null,
|
||||
),
|
||||
if (appState.pendingCount > 0)
|
||||
@@ -57,7 +57,8 @@ class QueueSection extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _showQueueDialog(BuildContext context, AppState appState) {
|
||||
void _showQueueDialog(BuildContext context) {
|
||||
final appState = context.read<AppState>();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
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';
|
||||
|
||||
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, depending on source/offline mode.
|
||||
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');
|
||||
// Resolve source:
|
||||
if (offline && source != MapSource.local) {
|
||||
print('[MapDataProvider] BLOCKED by offlineMode for getCameras');
|
||||
throw OfflineModeException("Cannot fetch remote cameras in offline mode.");
|
||||
}
|
||||
if (source == MapSource.local) {
|
||||
// TODO: implement local camera loading
|
||||
throw UnimplementedError('Local camera loading not yet implemented.');
|
||||
} else {
|
||||
// Use Overpass remote fetch, from submodule:
|
||||
return camerasFromOverpass(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxCameras: AppState.instance.maxCameras,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// Fetch tile image bytes from OSM or local (future). Only fetches, does not save!
|
||||
Future<List<int>> getTile({
|
||||
required int z,
|
||||
required int x,
|
||||
required int y,
|
||||
MapSource source = MapSource.auto,
|
||||
}) async {
|
||||
print('[MapDataProvider] getTile called for $z/$x/$y, source=$source');
|
||||
if (source == MapSource.local) {
|
||||
// TODO: implement local tile loading
|
||||
throw UnimplementedError('Local tile loading not yet implemented.');
|
||||
} else {
|
||||
// Use OSM remote fetch from submodule:
|
||||
return fetchOSMTile(z: z, x: x, y: y);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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.
|
||||
Future<List<OsmCameraNode>> camerasFromOverpass({
|
||||
required LatLngBounds bounds,
|
||||
required List<CameraProfile> profiles,
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
int? maxCameras,
|
||||
}) async {
|
||||
if (profiles.isEmpty) return [];
|
||||
|
||||
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 ');
|
||||
|
||||
const String prodEndpoint = 'https://overpass-api.de/api/interpreter';
|
||||
|
||||
final limit = maxCameras ?? AppState.instance.maxCameras;
|
||||
final query = '''
|
||||
[out:json][timeout:25];
|
||||
(
|
||||
$nodeClauses
|
||||
);
|
||||
out body $limit;
|
||||
''';
|
||||
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'dart:math';
|
||||
import 'dart:io';
|
||||
import 'dart:async';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter/foundation.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 = 3;
|
||||
int attempt = 0;
|
||||
final random = Random();
|
||||
final delays = [
|
||||
4000 + random.nextInt(1000), // 4-5s after 1st failure
|
||||
15000 + random.nextInt(4000), // 15-19s after 2nd
|
||||
60000 + random.nextInt(5000), // 60-65s after 3rd
|
||||
];
|
||||
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--;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,11 @@ 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';
|
||||
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';
|
||||
|
||||
/// Service for managing download, storage, and retrieval of offline map areas and cameras.
|
||||
class OfflineAreaService {
|
||||
@@ -213,7 +216,11 @@ class OfflineAreaService {
|
||||
for (final tile in tilesToFetch) {
|
||||
if (area.status == OfflineAreaStatus.cancelled) break;
|
||||
try {
|
||||
await downloadTile(tile[0], tile[1], tile[2], directory);
|
||||
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;
|
||||
@@ -238,7 +245,10 @@ class OfflineAreaService {
|
||||
}
|
||||
|
||||
if (!area.isPermanent) {
|
||||
final cameras = await downloadAllCameras(bounds);
|
||||
final cameras = await camerasFromOverpass(
|
||||
bounds: bounds,
|
||||
profiles: AppState.instance.enabledProfiles,
|
||||
);
|
||||
area.cameras = cameras;
|
||||
await saveCameras(cameras, directory);
|
||||
} else {
|
||||
|
||||
@@ -1,55 +1,18 @@
|
||||
import 'dart:math';
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import '../../models/osm_camera_node.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
|
||||
Future<void> downloadTile(int z, int x, int y, String baseDir) async {
|
||||
final url = 'https://tile.openstreetmap.org/$z/$x/$y.png';
|
||||
/// 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');
|
||||
if (await file.exists()) return; // already downloaded
|
||||
const int maxAttempts = 3;
|
||||
int attempt = 0;
|
||||
final random = Random();
|
||||
final delays = [0, 3000 + random.nextInt(1000) - 500, 10000 + random.nextInt(4000) - 2000];
|
||||
while (true) {
|
||||
try {
|
||||
attempt++;
|
||||
final resp = await http.get(Uri.parse(url));
|
||||
if (resp.statusCode == 200) {
|
||||
await file.writeAsBytes(resp.bodyBytes);
|
||||
return;
|
||||
} else {
|
||||
throw Exception('Failed to download tile $z/$x/$y (status \\${resp.statusCode})');
|
||||
}
|
||||
} catch (e) {
|
||||
if (attempt >= maxAttempts) {
|
||||
throw Exception("Failed to download tile $z/$x/$y after $attempt attempts: $e");
|
||||
}
|
||||
final delay = delays[attempt-1].clamp(0, 60000);
|
||||
await Future.delayed(Duration(milliseconds: delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<OsmCameraNode>> downloadAllCameras(LatLngBounds bounds) async {
|
||||
final sw = bounds.southWest;
|
||||
final ne = bounds.northEast;
|
||||
final bbox = [sw.latitude, sw.longitude, ne.latitude, ne.longitude].join(',');
|
||||
final query = '[out:json][timeout:60];node["man_made"="surveillance"]["camera:mount"="pole"]($bbox);out body;';
|
||||
final url = 'https://overpass-api.de/api/interpreter';
|
||||
final resp = await http.post(Uri.parse(url), body: { 'data': query });
|
||||
if (resp.statusCode != 200) {
|
||||
throw Exception('Failed to fetch cameras');
|
||||
}
|
||||
final data = jsonDecode(resp.body);
|
||||
return (data['elements'] as List<dynamic>?)?.map((e) => OsmCameraNode.fromJson(e)).toList() ?? [];
|
||||
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()));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
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';
|
||||
|
||||
// --- Smart marker widget for camera with single/double tap distinction
|
||||
class _CameraMapMarker extends StatefulWidget {
|
||||
@@ -79,8 +84,9 @@ class MapView extends StatefulWidget {
|
||||
|
||||
class _MapViewState extends State<MapView> {
|
||||
late final MapController _controller;
|
||||
final OverpassService _overpass = OverpassService();
|
||||
final MapDataProvider _mapDataProvider = MapDataProvider();
|
||||
final Debouncer _debounce = Debouncer(const Duration(milliseconds: 500));
|
||||
Debouncer? _debounceTileLayerUpdate;
|
||||
|
||||
StreamSubscription<Position>? _positionSub;
|
||||
LatLng? _currentLatLng;
|
||||
@@ -89,15 +95,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;
|
||||
}
|
||||
@@ -106,6 +113,7 @@ class _MapViewState extends State<MapView> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_debounceTileLayerUpdate = Debouncer(const Duration(milliseconds: 50),);
|
||||
// Kick off offline area loading as soon as map loads
|
||||
OfflineAreaService();
|
||||
_controller = widget.controller;
|
||||
@@ -142,19 +150,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() {
|
||||
@@ -173,7 +203,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 add‑mode target once, after first controller center is available.
|
||||
if (session != null && session.target == null) {
|
||||
@@ -189,14 +219,15 @@ class _MapViewState extends State<MapView> {
|
||||
|
||||
// Camera markers first, then GPS dot, so blue dot is always on top
|
||||
final markers = <Marker>[
|
||||
..._cameras.map(
|
||||
(n) => 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!,
|
||||
@@ -211,38 +242,59 @@ 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(
|
||||
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: () {
|
||||
if (_debounceTileLayerUpdate != null) _debounceTileLayerUpdate!(() { 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),
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
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;
|
||||
// Only block REMOTE fetch in offline mode, but allow local/offline sources in the future.
|
||||
if (AppState.instance.offlineMode && source != MapSource.local) {
|
||||
print('[TileProviderWithCache] BLOCKED tile $key due to offline mode');
|
||||
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) {
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,14 @@ 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:
|
||||
@@ -94,6 +142,14 @@ 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:
|
||||
@@ -102,6 +158,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
@@ -219,6 +283,14 @@ 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:
|
||||
@@ -235,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:
|
||||
@@ -243,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:
|
||||
@@ -363,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:
|
||||
@@ -379,6 +475,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
posix:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: posix
|
||||
sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.3"
|
||||
proj4dart:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -512,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:
|
||||
@@ -632,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"
|
||||
|
||||
@@ -27,8 +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
|
||||