diff --git a/README.md b/README.md
index 8bdcb9c..6dd1af9 100644
--- a/README.md
+++ b/README.md
@@ -1,56 +1,127 @@
-# Flock Map App (Stage 1)
+# Flock Map App
-A minimal Flutter scaffold for mapping and tagging Flock‑style 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 1–4 are permanently available (via a protected offline area). All downloads include accurate tile and storage estimates, and never request duplicate or unnecessary tiles.
+- **Robust Downloading:** All tile/download logic uses serial fetching and exponential backoff for network failures, minimizing risk of OSM rate-limits and always respecting API etiquette.
+- **No Duplicates:** Only one world area; can be re-downloaded (refreshed) but never deleted or renamed.
+- **Camera Cache:** Download areas keep camera points in sync for full offline visibility—except the global area, which never attempts to fetch all world cameras.
+- **Settings Management:** Cancel, refresh, or remove downloads as needed. Progress, tile count, storage consumption, and cached camera count always displayed.
+
+### Polished UX & Settings Architecture
+- **Permanent global base map:** Coverage for the entire world at zooms 1–4, always present.
+- **Smooth map gestures:** Double-tap to zoom even on markers; pinch zoom; camera popups distinguished from zoom.
+- **Modular Settings:** All major settings/queue/offline/camera management UI sections are cleanly separated for extensibility and rapid development.
+- **Order-preserving overlays:** Your location is always drawn on top for easy visibility.
+- **No more dead ends:** Disabling all profiles is impossible; canceling downloads is clean and instant.
+
+---
+
+## OAuth & Build Setup
+
+**Before uploading to OSM:**
+- Register OAuth2 applications on both [Production OSM](https://www.openstreetmap.org/oauth2/applications) and [Sandbox OSM](https://master.apis.dev.openstreetmap.org/oauth2/applications).
+- Copy generated client IDs to `lib/keys.dart` (see template `.example` file).
+
+### Build Environment Notes
+- Requires Xcode, Android Studio, and standard Flutter dependencies. See notes at the end of this file for CLI setup details.
+
+---
+
+## 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
diff --git a/android/app/src/main/res/drawable-hdpi/splash.png b/android/app/src/main/res/drawable-hdpi/splash.png
new file mode 100644
index 0000000..342225a
Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-mdpi/splash.png b/android/app/src/main/res/drawable-mdpi/splash.png
new file mode 100644
index 0000000..5058638
Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-v21/background.png b/android/app/src/main/res/drawable-v21/background.png
new file mode 100644
index 0000000..72b7566
Binary files /dev/null and b/android/app/src/main/res/drawable-v21/background.png differ
diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml
index f74085f..3cc4948 100644
--- a/android/app/src/main/res/drawable-v21/launch_background.xml
+++ b/android/app/src/main/res/drawable-v21/launch_background.xml
@@ -1,12 +1,9 @@
-
-
-
-
-
+ -
+
+
+ -
+
+
diff --git a/android/app/src/main/res/drawable-xhdpi/splash.png b/android/app/src/main/res/drawable-xhdpi/splash.png
new file mode 100644
index 0000000..88007cc
Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-xxhdpi/splash.png b/android/app/src/main/res/drawable-xxhdpi/splash.png
new file mode 100644
index 0000000..23a7632
Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-xxxhdpi/splash.png b/android/app/src/main/res/drawable-xxxhdpi/splash.png
new file mode 100644
index 0000000..7231139
Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable/background.png b/android/app/src/main/res/drawable/background.png
new file mode 100644
index 0000000..72b7566
Binary files /dev/null and b/android/app/src/main/res/drawable/background.png differ
diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml
index 304732f..3cc4948 100644
--- a/android/app/src/main/res/drawable/launch_background.xml
+++ b/android/app/src/main/res/drawable/launch_background.xml
@@ -1,12 +1,9 @@
-
-
-
-
-
+ -
+
+
+ -
+
+
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
index db77bb4..98e79f7 100644
Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
index 17987b7..96baa1c 100644
Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
index 09d4391..2e62a59 100644
Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
index d5f1c8d..c17a2f5 100644
Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
index 4d6372e..bea0ec8 100644
Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/values-night-v31/styles.xml b/android/app/src/main/res/values-night-v31/styles.xml
new file mode 100644
index 0000000..5fef228
--- /dev/null
+++ b/android/app/src/main/res/values-night-v31/styles.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml
index 06952be..dbc9ea9 100644
--- a/android/app/src/main/res/values-night/styles.xml
+++ b/android/app/src/main/res/values-night/styles.xml
@@ -5,6 +5,10 @@
- @drawable/launch_background
+ - false
+ - false
+ - false
+ - shortEdges
+
+
+
+
diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml
index cb1ef88..0d1fa8f 100644
--- a/android/app/src/main/res/values/styles.xml
+++ b/android/app/src/main/res/values/styles.xml
@@ -5,6 +5,10 @@
- @drawable/launch_background
+ - false
+ - false
+ - false
+ - shortEdges
- CFBundleURLTypes
-
-
- CFBundleTypeRole
- None
- CFBundleURLSchemes
-
- flockmap
-
-
-
-
-
- LSApplicationQueriesSchemes
-
- https
-
-
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Flock Map App
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ flock_map_app
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ $(FLUTTER_BUILD_NAME)
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ $(FLUTTER_BUILD_NUMBER)
+ LSRequiresIPhoneOS
+
+ NSLocationWhenInUseUsageDescription
+ This app needs your location to show nearby cameras.
+ UILaunchStoryboardName
+ LaunchScreen
+ UIMainStoryboardFile
+ Main
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ CADisableMinimumFrameDurationOnPhone
+
+ UIApplicationSupportsIndirectInputEvents
+
+
+ CFBundleURLTypes
+
+
+ CFBundleTypeRole
+ None
+ CFBundleURLSchemes
+
+ flockmap
+
+
+
+
+ LSApplicationQueriesSchemes
+
+ https
+
+ UIStatusBarHidden
+
+
diff --git a/lib/app_state.dart b/lib/app_state.dart
index 252248b..c12c987 100644
--- a/lib/app_state.dart
+++ b/lib/app_state.dart
@@ -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 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 _profiles = [];
final Set _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 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();
diff --git a/lib/dev_config.dart b/lib/dev_config.dart
new file mode 100644
index 0000000..21ac82d
--- /dev/null
+++ b/lib/dev_config.dart
@@ -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;
diff --git a/lib/main.dart b/lib/main.dart
index 9d85692..db56c14 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -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 main() async {
+ WidgetsFlutterBinding.ensureInitialized();
+
runApp(
ChangeNotifierProvider(
create: (_) => AppState(),
- child: const FlockMapApp(),
+ child: Consumer(
+ 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();
+ },
+ ),
),
);
}
diff --git a/lib/models/osm_camera_node.dart b/lib/models/osm_camera_node.dart
index 7e994b4..dab9cd5 100644
--- a/lib/models/osm_camera_node.dart
+++ b/lib/models/osm_camera_node.dart
@@ -11,6 +11,27 @@ class OsmCameraNode {
required this.tags,
});
+ Map toJson() => {
+ 'id': id,
+ 'lat': coord.latitude,
+ 'lon': coord.longitude,
+ 'tags': tags,
+ };
+
+ factory OsmCameraNode.fromJson(Map json) {
+ final tags = {};
+ if (json['tags'] != null) {
+ (json['tags'] as Map).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');
diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart
index 3ad7a90..ae30e45 100644
--- a/lib/screens/home_screen.dart
+++ b/lib/screens/home_screen.dart
@@ -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 {
final GlobalKey _scaffoldKey = GlobalKey();
+ final MapController _mapController = MapController();
bool _followMe = true;
void _openAddCameraSheet() {
@@ -47,18 +53,192 @@ class _HomeScreenState extends State {
],
),
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 createState() => _DownloadAreaDialogState();
+}
+
+class _DownloadAreaDialogState extends State {
+ 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'),
+ ),
+ ],
);
}
}
diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart
index f62159f..cbfc9be 100644
--- a/lib/screens/settings_screen.dart
+++ b/lib/screens/settings_screen.dart
@@ -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();
-
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(
- 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(),
],
),
);
diff --git a/lib/screens/settings_screen_sections/about_section.dart b/lib/screens/settings_screen_sections/about_section.dart
new file mode 100644
index 0000000..4803cbb
--- /dev/null
+++ b/lib/screens/settings_screen_sections/about_section.dart
@@ -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(
+ 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'),
+ ),
+ ],
+ ),
+ ),
+ );
+ },
+ );
+ }
+}
diff --git a/lib/screens/settings_screen_sections/auth_section.dart b/lib/screens/settings_screen_sections/auth_section.dart
new file mode 100644
index 0000000..2fd794d
--- /dev/null
+++ b/lib/screens/settings_screen_sections/auth_section.dart
@@ -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();
+
+ 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();
+ }
+ },
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/screens/settings_screen_sections/max_cameras_section.dart b/lib/screens/settings_screen_sections/max_cameras_section.dart
new file mode 100644
index 0000000..410acfc
--- /dev/null
+++ b/lib/screens/settings_screen_sections/max_cameras_section.dart
@@ -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 createState() => _MaxCamerasSectionState();
+}
+
+class _MaxCamerasSectionState extends State {
+ late TextEditingController _controller;
+
+ @override
+ void initState() {
+ super.initState();
+ final maxCameras = context.read().maxCameras;
+ _controller = TextEditingController(text: maxCameras.toString());
+ }
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final appState = context.watch();
+ 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();
+ },
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/screens/settings_screen_sections/offline_areas_section.dart b/lib/screens/settings_screen_sections/offline_areas_section.dart
new file mode 100644
index 0000000..0e7f741
--- /dev/null
+++ b/lib/screens/settings_screen_sections/offline_areas_section.dart
@@ -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 createState() => _OfflineAreasSectionState();
+}
+
+class _OfflineAreasSectionState extends State {
+ 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(
+ 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(),
+ );
+ }
+}
diff --git a/lib/screens/settings_screen_sections/offline_mode_section.dart b/lib/screens/settings_screen_sections/offline_mode_section.dart
new file mode 100644
index 0000000..36a734a
--- /dev/null
+++ b/lib/screens/settings_screen_sections/offline_mode_section.dart
@@ -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();
+ 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),
+ ),
+ );
+ }
+}
diff --git a/lib/screens/settings_screen_sections/profile_list_section.dart b/lib/screens/settings_screen_sections/profile_list_section.dart
new file mode 100644
index 0000000..00612f3
--- /dev/null
+++ b/lib/screens/settings_screen_sections/profile_list_section.dart
@@ -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();
+
+ 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();
+ 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'),
+ ),
+ ],
+ ),
+ );
+}
+}
diff --git a/lib/screens/settings_screen_sections/queue_section.dart b/lib/screens/settings_screen_sections/queue_section.dart
new file mode 100644
index 0000000..6d82405
--- /dev/null
+++ b/lib/screens/settings_screen_sections/queue_section.dart
@@ -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();
+ 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();
+ 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'),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/screens/settings_screen_sections/upload_mode_section.dart b/lib/screens/settings_screen_sections/upload_mode_section.dart
new file mode 100644
index 0000000..20557af
--- /dev/null
+++ b/lib/screens/settings_screen_sections/upload_mode_section.dart
@@ -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();
+ 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(
+ 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));
+ }
+ },
+ ),
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/services/map_data_provider.dart b/lib/services/map_data_provider.dart
new file mode 100644
index 0000000..0ab5028
--- /dev/null
+++ b/lib/services/map_data_provider.dart
@@ -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> getCameras({
+ required LatLngBounds bounds,
+ required List 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> getAllCamerasForDownload({
+ required LatLngBounds bounds,
+ required List 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> 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.");
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/lib/services/map_data_submodules/cameras_from_local.dart b/lib/services/map_data_submodules/cameras_from_local.dart
new file mode 100644
index 0000000..86f4d54
--- /dev/null
+++ b/lib/services/map_data_submodules/cameras_from_local.dart
@@ -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> fetchLocalCameras({
+ required LatLngBounds bounds,
+ required List profiles,
+ int? maxCameras,
+}) async {
+ final areas = OfflineAreaService().offlineAreas;
+ final Map 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> _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 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;
+}
diff --git a/lib/services/map_data_submodules/cameras_from_overpass.dart b/lib/services/map_data_submodules/cameras_from_overpass.dart
new file mode 100644
index 0000000..d283398
--- /dev/null
+++ b/lib/services/map_data_submodules/cameras_from_overpass.dart
@@ -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> camerasFromOverpass({
+ required LatLngBounds bounds,
+ required List 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> 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;
+ final elements = data['elements'] as List;
+ print('[camerasFromOverpass] Retrieved elements: ${elements.length}');
+ return elements.whereType