diff --git a/README.md b/README.md
index 100e8a0..6dd1af9 100644
--- a/README.md
+++ b/README.md
@@ -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.
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 c96719d..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;
@@ -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();
}
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/screens/settings_screen.dart b/lib/screens/settings_screen.dart
index 6e4aa31..cbfc9be 100644
--- a/lib/screens/settings_screen.dart
+++ b/lib/screens/settings_screen.dart
@@ -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(),
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_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
index 94cbcee..00612f3 100644
--- a/lib/screens/settings_screen_sections/profile_list_section.dart
+++ b/lib/screens/settings_screen_sections/profile_list_section.dart
@@ -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();
+ 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
index 3899965..6d82405 100644
--- a/lib/screens/settings_screen_sections/queue_section.dart
+++ b/lib/screens/settings_screen_sections/queue_section.dart
@@ -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();
showDialog(
context: context,
builder: (context) => AlertDialog(
diff --git a/lib/services/map_data_provider.dart b/lib/services/map_data_provider.dart
new file mode 100644
index 0000000..6f93549
--- /dev/null
+++ b/lib/services/map_data_provider.dart
@@ -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> 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');
+ // 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> 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);
+ }
+ }
+}
\ 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..e69de29
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..d8339ea
--- /dev/null
+++ b/lib/services/map_data_submodules/cameras_from_overpass.dart
@@ -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> camerasFromOverpass({
+ required LatLngBounds bounds,
+ required List 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;
+ final elements = data['elements'] as List;
+ print('[camerasFromOverpass] Retrieved elements: ${elements.length}');
+ return elements.whereType