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>().map((e) { + return OsmCameraNode( + id: e['id'], + coord: LatLng(e['lat'], e['lon']), + tags: Map.from(e['tags'] ?? {}), + ); + }).toList(); + } catch (e) { + print('[camerasFromOverpass] Overpass exception: $e'); + return []; + } + } + + if (!fetchAllPages) { + // Just one page + return await fetchChunk(); + } else { + // Fetch all possible data, paging with deduplication and backoff + final seenIds = {}; + final allCameras = []; + int page = 0; + while (true) { + page++; + List pageCameras = []; + int tries = 0; + while (tries < maxTries) { + try { + final cams = await fetchChunk(); + pageCameras = cams.where((c) => !seenIds.contains(c.id)).toList(); + break; + } catch (e) { + tries++; + final delayMs = 400 * (1 << tries); + print('[camerasFromOverpass][paged] Error on page $page try $tries: $e. Retrying in ${delayMs}ms.'); + await Future.delayed(Duration(milliseconds: delayMs)); + } + } + if (pageCameras.isEmpty) break; + print('[camerasFromOverpass][paged] Page $page: got ${pageCameras.length} new cameras.'); + allCameras.addAll(pageCameras); + seenIds.addAll(pageCameras.map((c) => c.id)); + if (pageCameras.length < pageSize) break; + } + print('[camerasFromOverpass][paged] DONE. Found ${allCameras.length} cameras for download.'); + return allCameras; + } +} diff --git a/lib/services/map_data_submodules/tiles_from_local.dart b/lib/services/map_data_submodules/tiles_from_local.dart new file mode 100644 index 0000000..16d78f9 --- /dev/null +++ b/lib/services/map_data_submodules/tiles_from_local.dart @@ -0,0 +1,43 @@ +import 'dart:io'; +import 'package:latlong2/latlong.dart'; +import '../offline_area_service.dart'; +import '../offline_areas/offline_area_models.dart'; +import '../offline_areas/offline_tile_utils.dart'; + +/// Fetch a tile from the newest offline area that plausibly contains it, or throw if not found. +Future> fetchLocalTile({required int z, required int x, required int y}) async { + final areas = OfflineAreaService().offlineAreas; + final List<_AreaTileMatch> candidates = []; + + for (final area in areas) { + if (area.status != OfflineAreaStatus.complete) continue; + if (z < area.minZoom || z > area.maxZoom) continue; + + // Get tile coverage for area at this zoom only + final coveredTiles = computeTileList(area.bounds, z, z); + final hasTile = coveredTiles.any((tile) => tile[0] == z && tile[1] == x && tile[2] == y); + if (hasTile) { + final tilePath = _tilePath(area.directory, z, x, y); + final file = File(tilePath); + if (await file.exists()) { + final stat = await file.stat(); + candidates.add(_AreaTileMatch(area: area, file: file, modified: stat.modified)); + } + } + } + if (candidates.isEmpty) { + throw Exception('Tile $z/$x/$y not found in any offline area'); + } + candidates.sort((a, b) => b.modified.compareTo(a.modified)); // newest first + return await candidates.first.file.readAsBytes(); +} + +String _tilePath(String areaDir, int z, int x, int y) => + '$areaDir/tiles/$z/$x/$y.png'; + +class _AreaTileMatch { + final OfflineArea area; + final File file; + final DateTime modified; + _AreaTileMatch({required this.area, required this.file, required this.modified}); +} diff --git a/lib/services/map_data_submodules/tiles_from_osm.dart b/lib/services/map_data_submodules/tiles_from_osm.dart new file mode 100644 index 0000000..d5870a1 --- /dev/null +++ b/lib/services/map_data_submodules/tiles_from_osm.dart @@ -0,0 +1,82 @@ +import 'dart:math'; +import 'dart:io'; +import 'dart:async'; +import 'package:http/http.dart' as http; +import 'package:flutter/foundation.dart'; +import 'package:flock_map_app/dev_config.dart'; + +/// Global semaphore to limit simultaneous tile fetches +final _tileFetchSemaphore = _SimpleSemaphore(4); // Max 4 concurrent + +/// Fetches a tile from OSM, with in-memory retries/backoff, and global concurrency limit. +/// Returns tile image bytes, or throws on persistent failure. +Future> fetchOSMTile({ + required int z, + required int x, + required int y, +}) async { + final url = 'https://tile.openstreetmap.org/$z/$x/$y.png'; + const int maxAttempts = kTileFetchMaxAttempts; + int attempt = 0; + final random = Random(); + final delays = [ + kTileFetchInitialDelayMs + random.nextInt(kTileFetchJitter1Ms), + kTileFetchSecondDelayMs + random.nextInt(kTileFetchJitter2Ms), + kTileFetchThirdDelayMs + random.nextInt(kTileFetchJitter3Ms), + ]; + while (true) { + await _tileFetchSemaphore.acquire(); + try { + print('[fetchOSMTile] FETCH $z/$x/$y'); + attempt++; + final resp = await http.get(Uri.parse(url)); + print('[fetchOSMTile] HTTP ${resp.statusCode} for $z/$x/$y, length=${resp.bodyBytes.length}'); + if (resp.statusCode == 200 && resp.bodyBytes.isNotEmpty) { + print('[fetchOSMTile] SUCCESS $z/$x/$y'); + return resp.bodyBytes; + } else { + print('[fetchOSMTile] FAIL $z/$x/$y: code=${resp.statusCode}, bytes=${resp.bodyBytes.length}'); + throw HttpException('Failed to fetch tile $z/$x/$y: status ${resp.statusCode}'); + } + } catch (e) { + print('[fetchOSMTile] Exception $z/$x/$y: $e'); + if (attempt >= maxAttempts) { + print("[fetchOSMTile] Failed for $z/$x/$y after $attempt attempts: $e"); + rethrow; + } + final delay = delays[attempt - 1].clamp(0, 60000); + print("[fetchOSMTile] Attempt $attempt for $z/$x/$y failed: $e. Retrying in ${delay}ms."); + await Future.delayed(Duration(milliseconds: delay)); + } finally { + _tileFetchSemaphore.release(); + } + } +} + +/// Simple counting semaphore, suitable for single-thread Flutter concurrency +class _SimpleSemaphore { + final int _max; + int _current = 0; + final List _queue = []; + _SimpleSemaphore(this._max); + + Future acquire() async { + if (_current < _max) { + _current++; + return; + } else { + final c = Completer(); + _queue.add(() => c.complete()); + await c.future; + } + } + + void release() { + if (_queue.isNotEmpty) { + final callback = _queue.removeAt(0); + callback(); + } else { + _current--; + } + } +} \ No newline at end of file diff --git a/lib/services/offline_area_service.dart b/lib/services/offline_area_service.dart new file mode 100644 index 0000000..8064722 --- /dev/null +++ b/lib/services/offline_area_service.dart @@ -0,0 +1,307 @@ +import 'dart:io'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:flutter_map/flutter_map.dart' show LatLngBounds; +import 'package:path_provider/path_provider.dart'; +import 'offline_areas/offline_area_models.dart'; +import 'offline_areas/offline_tile_utils.dart'; +import 'offline_areas/offline_area_service_tile_fetch.dart'; // Only used for file IO during area downloads. +import '../models/osm_camera_node.dart'; +import '../app_state.dart'; +import 'map_data_provider.dart'; +import 'map_data_submodules/cameras_from_overpass.dart'; +import 'package:flock_map_app/dev_config.dart'; + +/// Service for managing download, storage, and retrieval of offline map areas and cameras. +class OfflineAreaService { + static final OfflineAreaService _instance = OfflineAreaService._(); + factory OfflineAreaService() => _instance; + OfflineAreaService._() { + _loadAreasFromDisk().then((_) => _ensureAndAutoDownloadWorldArea()); + } + + final List _areas = []; + List get offlineAreas => List.unmodifiable(_areas); + + Future getOfflineAreaDir() async { + final dir = await getApplicationDocumentsDirectory(); + final areaRoot = Directory("${dir.path}/offline_areas"); + if (!areaRoot.existsSync()) { + areaRoot.createSync(recursive: true); + } + return areaRoot; + } + + Future _getMetadataPath() async { + final dir = await getOfflineAreaDir(); + return File("${dir.path}/offline_areas.json"); + } + + Future getAreaSizeBytes(OfflineArea area) async { + int total = 0; + final dir = Directory(area.directory); + if (await dir.exists()) { + await for (var fse in dir.list(recursive: true)) { + if (fse is File) { + total += await fse.length(); + } + } + } + area.sizeBytes = total; + await saveAreasToDisk(); + return total; + } + + Future saveAreasToDisk() async { + try { + final file = await _getMetadataPath(); + final content = jsonEncode(_areas.map((a) => a.toJson()).toList()); + await file.writeAsString(content); + } catch (e) { + debugPrint('Failed to save offline areas: $e'); + } + } + + Future _loadAreasFromDisk() async { + try { + final file = await _getMetadataPath(); + if (!(await file.exists())) return; + final str = await file.readAsString(); + if (str.trim().isEmpty) return; + late final List data; + try { + data = jsonDecode(str); + } catch (e) { + debugPrint('Failed to parse offline areas json: $e'); + return; + } + _areas.clear(); + for (final areaJson in data) { + final area = OfflineArea.fromJson(areaJson); + if (!Directory(area.directory).existsSync()) { + area.status = OfflineAreaStatus.error; + } else { + getAreaSizeBytes(area); + } + _areas.add(area); + } + } catch (e) { + debugPrint('Failed to load offline areas: $e'); + } + } + + Future _ensureAndAutoDownloadWorldArea() async { + final dir = await getOfflineAreaDir(); + final worldDir = "${dir.path}/world"; + final LatLngBounds worldBounds = globalWorldBounds(); + OfflineArea? world; + for (final a in _areas) { + if (a.isPermanent) { world = a; break; } + } + final Set> expectedTiles = computeTileList(worldBounds, kWorldMinZoom, kWorldMaxZoom); + if (world != null) { + int filesFound = 0; + List> missingTiles = []; + for (final tile in expectedTiles) { + final f = File('${world.directory}/tiles/${tile[0]}/${tile[1]}/${tile[2]}.png'); + if (f.existsSync()) { + filesFound++; + } else if (missingTiles.length < 10) { + missingTiles.add(tile); + } + } + if (filesFound != expectedTiles.length) { + debugPrint('World area: missing ${expectedTiles.length - filesFound} tiles. First few: $missingTiles'); + } else { + debugPrint('World area: all tiles accounted for.'); + } + world.tilesTotal = expectedTiles.length; + world.tilesDownloaded = filesFound; + world.progress = (world.tilesTotal == 0) ? 0.0 : (filesFound / world.tilesTotal); + if (filesFound == world.tilesTotal) { + world.status = OfflineAreaStatus.complete; + await saveAreasToDisk(); + return; + } else { + world.status = OfflineAreaStatus.downloading; + await saveAreasToDisk(); + downloadArea( + id: world.id, + bounds: world.bounds, + minZoom: world.minZoom, + maxZoom: world.maxZoom, + directory: world.directory, + name: world.name, + ); + return; + } + } + // If not present, create and start download + world = OfflineArea( + id: 'permanent_world', + name: 'World (required)', + bounds: worldBounds, + minZoom: kWorldMinZoom, + maxZoom: kWorldMaxZoom, + directory: worldDir, + status: OfflineAreaStatus.downloading, + progress: 0.0, + isPermanent: true, + tilesTotal: expectedTiles.length, + tilesDownloaded: 0, + ); + _areas.insert(0, world); + await saveAreasToDisk(); + downloadArea( + id: world.id, + bounds: world.bounds, + minZoom: world.minZoom, + maxZoom: world.maxZoom, + directory: world.directory, + name: world.name, + ); + } + + Future downloadArea({ + required String id, + required LatLngBounds bounds, + required int minZoom, + required int maxZoom, + required String directory, + void Function(double progress)? onProgress, + void Function(OfflineAreaStatus status)? onComplete, + String? name, + }) async { + OfflineArea? area; + for (final a in _areas) { + if (a.id == id) { area = a; break; } + } + if (area != null) { + _areas.remove(area); + final dirObj = Directory(area.directory); + if (await dirObj.exists()) { + await dirObj.delete(recursive: true); + } + } + area = OfflineArea( + id: id, + name: name ?? area?.name ?? '', + bounds: bounds, + minZoom: minZoom, + maxZoom: maxZoom, + directory: directory, + isPermanent: area?.isPermanent ?? false, + ); + _areas.add(area); + await saveAreasToDisk(); + + try { + Set> allTiles; + if (area.isPermanent) { + allTiles = computeTileList(globalWorldBounds(), kWorldMinZoom, kWorldMaxZoom); + } else { + allTiles = computeTileList(bounds, minZoom, maxZoom); + } + area.tilesTotal = allTiles.length; + const int maxPasses = 3; + int pass = 0; + Set> allTilesSet = allTiles.toSet(); + Set> tilesToFetch = allTilesSet; + bool success = false; + int totalDone = 0; + while (pass < maxPasses && tilesToFetch.isNotEmpty) { + pass++; + int doneThisPass = 0; + debugPrint('DownloadArea: pass #$pass for area $id. Need ${tilesToFetch.length} tiles.'); + for (final tile in tilesToFetch) { + if (area.status == OfflineAreaStatus.cancelled) break; + try { + final bytes = await MapDataProvider().getTile( + z: tile[0], x: tile[1], y: tile[2], source: MapSource.remote); + if (bytes.isNotEmpty) { + await saveTileBytes(tile[0], tile[1], tile[2], directory, bytes); + } + totalDone++; + doneThisPass++; + area.tilesDownloaded = totalDone; + area.progress = area.tilesTotal == 0 ? 0.0 : ((area.tilesDownloaded) / area.tilesTotal); + } catch (e) { + debugPrint("Tile download failed for z=${tile[0]}, x=${tile[1]}, y=${tile[2]}: $e"); + } + if (onProgress != null) onProgress(area.progress); + } + await getAreaSizeBytes(area); + await saveAreasToDisk(); + Set> missingTiles = {}; + for (final tile in allTilesSet) { + final f = File('$directory/tiles/${tile[0]}/${tile[1]}/${tile[2]}.png'); + if (!f.existsSync()) missingTiles.add(tile); + } + if (missingTiles.isEmpty) { + success = true; + break; + } + tilesToFetch = missingTiles; + } + + if (!area.isPermanent) { + final cameras = await MapDataProvider().getAllCamerasForDownload( + bounds: bounds, + profiles: AppState.instance.enabledProfiles, + ); + area.cameras = cameras; + await saveCameras(cameras, directory); + } else { + area.cameras = []; + } + await getAreaSizeBytes(area); + + if (success) { + area.status = OfflineAreaStatus.complete; + area.progress = 1.0; + debugPrint('Area $id: all tiles accounted for and area marked complete.'); + } else { + area.status = OfflineAreaStatus.error; + debugPrint('Area $id: MISSING tiles after $maxPasses passes. First 10: ${tilesToFetch.toList().take(10)}'); + if (!area.isPermanent) { + final dirObj = Directory(area.directory); + if (await dirObj.exists()) { + await dirObj.delete(recursive: true); + } + _areas.remove(area); + } + } + await saveAreasToDisk(); + if (onComplete != null) onComplete(area.status); + } catch (e) { + area.status = OfflineAreaStatus.error; + await saveAreasToDisk(); + if (onComplete != null) onComplete(area.status); + } + } + + void cancelDownload(String id) async { + final area = _areas.firstWhere((a) => a.id == id, orElse: () => throw 'Area not found'); + area.status = OfflineAreaStatus.cancelled; + final dir = Directory(area.directory); + if (await dir.exists()) { + await dir.delete(recursive: true); + } + _areas.remove(area); + await saveAreasToDisk(); + if (area.isPermanent) { + _ensureAndAutoDownloadWorldArea(); + } + } + + void deleteArea(String id) async { + final area = _areas.firstWhere((a) => a.id == id, orElse: () => throw 'Area not found'); + final dir = Directory(area.directory); + if (await dir.exists()) { + await dir.delete(recursive: true); + } + _areas.remove(area); + await saveAreasToDisk(); + } +} diff --git a/lib/services/offline_areas/offline_area_models.dart b/lib/services/offline_areas/offline_area_models.dart new file mode 100644 index 0000000..9dca081 --- /dev/null +++ b/lib/services/offline_areas/offline_area_models.dart @@ -0,0 +1,82 @@ +import 'package:latlong2/latlong.dart'; +import 'package:flutter_map/flutter_map.dart' show LatLngBounds; +import '../../models/osm_camera_node.dart'; + +/// Status of an offline area +enum OfflineAreaStatus { downloading, complete, error, cancelled } + +/// Model class describing an offline area for map/camera caching +class OfflineArea { + final String id; + String name; + final LatLngBounds bounds; + final int minZoom; + final int maxZoom; + final String directory; // base dir for area storage + OfflineAreaStatus status; + double progress; // 0.0 - 1.0 + int tilesDownloaded; + int tilesTotal; + List cameras; + int sizeBytes; // Disk size in bytes + final bool isPermanent; // Not user-deletable if true + + OfflineArea({ + required this.id, + this.name = '', + required this.bounds, + required this.minZoom, + required this.maxZoom, + required this.directory, + this.status = OfflineAreaStatus.downloading, + this.progress = 0, + this.tilesDownloaded = 0, + this.tilesTotal = 0, + this.cameras = const [], + this.sizeBytes = 0, + this.isPermanent = false, + }); + + Map toJson() => { + 'id': id, + 'name': name, + 'bounds': { + 'sw': {'lat': bounds.southWest.latitude, 'lng': bounds.southWest.longitude}, + 'ne': {'lat': bounds.northEast.latitude, 'lng': bounds.northEast.longitude}, + }, + 'minZoom': minZoom, + 'maxZoom': maxZoom, + 'directory': directory, + 'status': status.name, + 'progress': progress, + 'tilesDownloaded': tilesDownloaded, + 'tilesTotal': tilesTotal, + 'cameras': cameras.map((c) => c.toJson()).toList(), + 'sizeBytes': sizeBytes, + 'isPermanent': isPermanent, + }; + + static OfflineArea fromJson(Map json) { + final bounds = LatLngBounds( + LatLng(json['bounds']['sw']['lat'], json['bounds']['sw']['lng']), + LatLng(json['bounds']['ne']['lat'], json['bounds']['ne']['lng']), + ); + return OfflineArea( + id: json['id'], + name: json['name'] ?? '', + bounds: bounds, + minZoom: json['minZoom'], + maxZoom: json['maxZoom'], + directory: json['directory'], + status: OfflineAreaStatus.values.firstWhere( + (e) => e.name == json['status'], orElse: () => OfflineAreaStatus.error), + progress: (json['progress'] ?? 0).toDouble(), + tilesDownloaded: json['tilesDownloaded'] ?? 0, + tilesTotal: json['tilesTotal'] ?? 0, + cameras: (json['cameras'] as List? ?? []) + .map((e) => OsmCameraNode.fromJson(e)).toList(), + sizeBytes: json['sizeBytes'] ?? 0, + isPermanent: json['isPermanent'] ?? false, + ); + } +} diff --git a/lib/services/offline_areas/offline_area_service_tile_fetch.dart b/lib/services/offline_areas/offline_area_service_tile_fetch.dart new file mode 100644 index 0000000..6da9410 --- /dev/null +++ b/lib/services/offline_areas/offline_area_service_tile_fetch.dart @@ -0,0 +1,19 @@ +import 'dart:io'; +import 'dart:convert'; +import '../../models/osm_camera_node.dart'; + +/// Disk IO utilities for offline area file management ONLY. No network requests should occur here. + +/// Save-to-disk for a tile that has already been fetched elsewhere. +Future saveTileBytes(int z, int x, int y, String baseDir, List bytes) async { + final dir = Directory('$baseDir/tiles/$z/$x'); + await dir.create(recursive: true); + final file = File('${dir.path}/$y.png'); + await file.writeAsBytes(bytes); +} + +/// Save-to-disk for cameras.json; called only by OfflineAreaService during area download +Future saveCameras(List cams, String dir) async { + final file = File('$dir/cameras.json'); + await file.writeAsString(jsonEncode(cams.map((c) => c.toJson()).toList())); +} diff --git a/lib/services/offline_areas/offline_tile_utils.dart b/lib/services/offline_areas/offline_tile_utils.dart new file mode 100644 index 0000000..b532cba --- /dev/null +++ b/lib/services/offline_areas/offline_tile_utils.dart @@ -0,0 +1,69 @@ +import 'dart:math'; +import 'package:latlong2/latlong.dart'; +import 'package:flutter_map/flutter_map.dart' show LatLngBounds; + +/// Utility for tile calculations and lat/lon conversions for OSM offline logic + +Set> computeTileList(LatLngBounds bounds, int zMin, int zMax) { + Set> tiles = {}; + const double epsilon = 1e-7; + double latMin = min(bounds.southWest.latitude, bounds.northEast.latitude); + double latMax = max(bounds.southWest.latitude, bounds.northEast.latitude); + double lonMin = min(bounds.southWest.longitude, bounds.northEast.longitude); + double lonMax = max(bounds.southWest.longitude, bounds.northEast.longitude); + // Expand degenerate/flat areas a hair + if ((latMax - latMin).abs() < epsilon) { + latMin -= epsilon; + latMax += epsilon; + } + if ((lonMax - lonMin).abs() < epsilon) { + lonMin -= epsilon; + lonMax += epsilon; + } + for (int z = zMin; z <= zMax; z++) { + final n = pow(2, z).toInt(); + final minTile = latLonToTile(latMin, lonMin, z); + final maxTile = latLonToTile(latMax, lonMax, z); + final minX = min(minTile[0], maxTile[0]); + final maxX = max(minTile[0], maxTile[0]); + final minY = min(minTile[1], maxTile[1]); + final maxY = max(minTile[1], maxTile[1]); + for (int x = minX; x <= maxX; x++) { + for (int y = minY; y <= maxY; y++) { + tiles.add([z, x, y]); + } + } + } + return tiles; +} + +List latLonToTileRaw(double lat, double lon, int zoom) { + final n = pow(2.0, zoom); + final xtile = (lon + 180.0) / 360.0 * n; + final ytile = (1.0 - + log(tan(lat * pi / 180.0) + 1.0 / cos(lat * pi / 180.0)) / pi) / 2.0 * n; + return [xtile, ytile]; +} + +List latLonToTile(double lat, double lon, int zoom) { + final n = pow(2.0, zoom); + final xtile = ((lon + 180.0) / 360.0 * n).floor(); + final ytile = ((1.0 - log(tan(lat * pi / 180.0) + 1.0 / cos(lat * pi / 180.0)) / pi) / 2.0 * n).floor(); + return [xtile, ytile]; +} + +int findDynamicMinZoom(LatLngBounds bounds, {int maxSearchZoom = 19}) { + for (int z = 1; z <= maxSearchZoom; z++) { + final swTile = latLonToTile(bounds.southWest.latitude, bounds.southWest.longitude, z); + final neTile = latLonToTile(bounds.northEast.latitude, bounds.northEast.longitude, z); + if (swTile[0] != neTile[0] || swTile[1] != neTile[1]) { + return z - 1 > 0 ? z - 1 : 1; + } + } + return maxSearchZoom; +} + +LatLngBounds globalWorldBounds() { + // Use slightly shrunken bounds to avoid tile index overflow at extreme coordinates + return LatLngBounds(LatLng(-85.0, -179.9), LatLng(85.0, 179.9)); +} diff --git a/lib/services/overpass_service.dart b/lib/services/overpass_service.dart deleted file mode 100644 index 10db57e..0000000 --- a/lib/services/overpass_service.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'dart:convert'; -import 'package:http/http.dart' as http; -import 'package:flutter_map/flutter_map.dart'; -import 'package:latlong2/latlong.dart'; - -import '../models/camera_profile.dart'; -import '../models/osm_camera_node.dart'; - -import '../app_state.dart'; - -class OverpassService { - static const _prodEndpoint = 'https://overpass-api.de/api/interpreter'; - static const _sandboxEndpoint = 'https://overpass-api.dev.openstreetmap.org/api/interpreter'; - - // You can pass UploadMode, or use production by default - Future> fetchCameras( - LatLngBounds bbox, - List profiles, - {UploadMode uploadMode = UploadMode.production} - ) async { - if (profiles.isEmpty) return []; - - // Build one node query per enabled profile (each with all its tags required) - final nodeClauses = profiles.map((profile) { - final tagFilters = profile.tags.entries - .map((e) => '["${e.key}"="${e.value}"]') - .join('\n '); - return '''node\n $tagFilters\n (${bbox.southWest.latitude},${bbox.southWest.longitude},\n ${bbox.northEast.latitude},${bbox.northEast.longitude});'''; - }).join('\n '); - - final query = ''' - [out:json][timeout:25]; - ( - $nodeClauses - ); - out body 250; - '''; - - Future> fetchFromUri(String endpoint, String query) async { - try { - print('[Overpass] Querying $endpoint'); - print('[Overpass] Query:\n$query'); - final resp = await http.post(Uri.parse(endpoint), body: {'data': query.trim()}); - print('[Overpass] Status: \\${resp.statusCode}, Length: \\${resp.body.length}'); - if (resp.statusCode != 200) { - print('[Overpass] Failed: \\${resp.body}'); - return []; - } - final data = jsonDecode(resp.body) as Map; - final elements = data['elements'] as List; - print('[Overpass] Retrieved elements: \\${elements.length}'); - return elements.whereType>().map((e) { - return OsmCameraNode( - id: e['id'], - coord: LatLng(e['lat'], e['lon']), - tags: Map.from(e['tags'] ?? {}), - ); - }).toList(); - } catch (e) { - print('[Overpass] Exception: \\${e}'); - // Network error – return empty list silently - return []; - } - } - - // Fetch from production Overpass for all modes. - return await fetchFromUri(_prodEndpoint, query); - } -} - diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index b335321..5b9bb37 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -1,23 +1,78 @@ import 'dart:async'; import 'dart:math' as math; import 'dart:io'; +import 'dart:typed_data'; +import 'dart:ui' as ui; import 'package:http/io_client.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:latlong2/latlong.dart'; import 'package:geolocator/geolocator.dart'; import 'package:provider/provider.dart'; import '../app_state.dart'; -import '../services/overpass_service.dart'; +import '../services/map_data_provider.dart'; +import '../services/offline_area_service.dart'; import '../models/osm_camera_node.dart'; import 'debouncer.dart'; import 'camera_tag_sheet.dart'; +import 'tile_provider_with_cache.dart'; +import 'package:flock_map_app/dev_config.dart'; + +// --- Smart marker widget for camera with single/double tap distinction +class _CameraMapMarker extends StatefulWidget { + final OsmCameraNode node; + final MapController mapController; + const _CameraMapMarker({required this.node, required this.mapController, Key? key}) : super(key: key); + + @override + State<_CameraMapMarker> createState() => _CameraMapMarkerState(); +} + +class _CameraMapMarkerState extends State<_CameraMapMarker> { + Timer? _tapTimer; + // From dev_config.dart for build-time parameters + static const Duration tapTimeout = kMarkerTapTimeout; + + void _onTap() { + _tapTimer = Timer(tapTimeout, () { + showModalBottomSheet( + context: context, + builder: (_) => CameraTagSheet(node: widget.node), + showDragHandle: true, + ); + }); + } + + void _onDoubleTap() { + _tapTimer?.cancel(); + widget.mapController.move(widget.node.coord, widget.mapController.camera.zoom + 1); + } + + @override + void dispose() { + _tapTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: _onTap, + onDoubleTap: _onDoubleTap, + child: const Icon(Icons.videocam, color: Colors.orange), + ); + } +} class MapView extends StatefulWidget { + final MapController controller; const MapView({ super.key, + required this.controller, required this.followMe, required this.onUserGesture, }); @@ -30,9 +85,10 @@ class MapView extends StatefulWidget { } class _MapViewState extends State { - final MapController _controller = MapController(); - final OverpassService _overpass = OverpassService(); - final Debouncer _debounce = Debouncer(const Duration(milliseconds: 500)); + late final MapController _controller; + final MapDataProvider _mapDataProvider = MapDataProvider(); + final Debouncer _debounce = Debouncer(kDebounceCameraRefresh); + Debouncer? _debounceTileLayerUpdate; StreamSubscription? _positionSub; LatLng? _currentLatLng; @@ -41,15 +97,16 @@ class _MapViewState extends State { List _lastProfileIds = []; UploadMode? _lastUploadMode; - void _maybeRefreshCameras(AppState appState) { + void _maybeRefreshCameras() { + final appState = context.read(); final currProfileIds = appState.enabledProfiles.map((p) => p.id).toList(); final currMode = appState.uploadMode; - if (_lastProfileIds.isEmpty || + if (_lastProfileIds.isEmpty || currProfileIds.length != _lastProfileIds.length || !_lastProfileIds.asMap().entries.every((entry) => currProfileIds[entry.key] == entry.value) || _lastUploadMode != currMode) { // If this is first load, or list/ids/mode changed, refetch - _debounce(() => _refreshCameras(appState)); + _debounce(_refreshCameras); _lastProfileIds = List.from(currProfileIds); _lastUploadMode = currMode; } @@ -58,6 +115,10 @@ class _MapViewState extends State { @override void initState() { super.initState(); + _debounceTileLayerUpdate = Debouncer(kDebounceTileLayerUpdate); + // Kick off offline area loading as soon as map loads + OfflineAreaService(); + _controller = widget.controller; _initLocation(); } @@ -91,19 +152,41 @@ class _MapViewState extends State { }); } - Future _refreshCameras(AppState appState) async { + Future _refreshCameras() async { + final appState = context.read(); LatLngBounds? bounds; try { bounds = _controller.camera.visibleBounds; } catch (_) { return; // controller not ready yet } - final cams = await _overpass.fetchCameras( - bounds, - appState.enabledProfiles, - uploadMode: appState.uploadMode, - ); - if (mounted) setState(() => _cameras = cams); + // If too zoomed out, do NOT fetch cameras; show info + final zoom = _controller.camera.zoom; + if (zoom < 10) { + if (mounted) setState(() => _cameras = []); + // Show a snackbar-style bubble, if desired + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Cameras not drawn below zoom level 10'), + duration: Duration(seconds: 2), + ), + ); + } + return; + } + try { + final cams = await _mapDataProvider.getCameras( + bounds: bounds, + profiles: appState.enabledProfiles, + uploadMode: appState.uploadMode, + // MapSource.auto (default) will prefer Overpass for now + ); + if (mounted) setState(() => _cameras = cams); + } on OfflineModeException catch (_) { + // Swallow the error in offline mode + if (mounted) setState(() => _cameras = []); + } } double _safeZoom() { @@ -122,7 +205,7 @@ class _MapViewState extends State { // Refetch only if profiles or mode changed // This avoids repeated fetches on every build // We track last seen values (local to the State class) - _maybeRefreshCameras(appState); + _maybeRefreshCameras(); // Seed add‑mode target once, after first controller center is available. if (session != null && session.target == null) { @@ -136,7 +219,17 @@ class _MapViewState extends State { final zoom = _safeZoom(); - final markers = [ + // Camera markers first, then GPS dot, so blue dot is always on top + final markers = [ + ..._cameras + .where((n) => n.coord.latitude != 0 || n.coord.longitude != 0) + .where((n) => n.coord.latitude.abs() <= 90 && n.coord.longitude.abs() <= 180) + .map((n) => Marker( + point: n.coord, + width: 24, + height: 24, + child: _CameraMapMarker(node: n, mapController: _controller), + )), if (_currentLatLng != null) Marker( point: _currentLatLng!, @@ -144,23 +237,6 @@ class _MapViewState extends State { height: 16, child: const Icon(Icons.my_location, color: Colors.blue), ), - ..._cameras.map( - (n) => Marker( - point: n.coord, - width: 24, - height: 24, - child: GestureDetector( - onTap: () { - showModalBottomSheet( - context: context, - builder: (_) => CameraTagSheet(node: n), - showDragHandle: true, // for better UX on Material3 - ); - }, - child: const Icon(Icons.videocam, color: Colors.orange), - ), - ), - ), ]; final overlays = [ @@ -168,41 +244,75 @@ class _MapViewState extends State { _buildCone(session.target!, session.directionDegrees, zoom), ..._cameras .where((n) => n.hasDirection && n.directionDeg != null) + .where((n) => n.coord.latitude != 0 || n.coord.longitude != 0) + .where((n) => n.coord.latitude.abs() <= 90 && n.coord.longitude.abs() <= 180) .map((n) => _buildCone(n.coord, n.directionDeg!, zoom)), ]; return Stack( children: [ FlutterMap( + key: ValueKey(appState.offlineMode), mapController: _controller, options: MapOptions( - center: _currentLatLng ?? LatLng(37.7749, -122.4194), - zoom: 15, + initialCenter: _currentLatLng ?? LatLng(37.7749, -122.4194), + initialZoom: 15, maxZoom: 19, onPositionChanged: (pos, gesture) { + setState(() {}); // Instant UI update for zoom, etc. if (gesture) widget.onUserGesture(); if (session != null) { appState.updateSession(target: pos.center); } - _debounce(() => _refreshCameras(appState)); + _debounce(_refreshCameras); }, ), children: [ TileLayer( - urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - tileProvider: NetworkTileProvider( - headers: { - 'User-Agent': - 'FlockMap/0.4 (+https://github.com/yourrepo)', + tileProvider: TileProviderWithCache( + onTileCacheUpdated: () { + print('[MapView] onTileCacheUpdated fired (tile loaded)'); + if (_debounceTileLayerUpdate != null) _debounceTileLayerUpdate!(() { + print('[MapView] Running debounced setState due to tile cache update'); + if (mounted) setState(() {}); + }); }, - httpClient: IOClient( - HttpClient()..maxConnectionsPerHost = 4, - ), ), - userAgentPackageName: 'com.example.flock_map_app', + urlTemplate: 'unused-{z}-{x}-{y}', + tileSize: 256, + tileBuilder: (ctx, tileWidget, tileImage) { + try { + final str = tileImage.toString(); + final regex = RegExp(r'TileCoordinate\((\d+), (\d+), (\d+)\)'); + final match = regex.firstMatch(str); + if (match != null) { + final x = match.group(1); + final y = match.group(2); + final z = match.group(3); + final key = '$z/$x/$y'; + final bytes = TileProviderWithCache.tileCache[key]; + if (bytes != null && bytes.isNotEmpty) { + return Image.memory(bytes, gaplessPlayback: true, fit: BoxFit.cover); + } + } + return tileWidget; + } catch (e) { + print('tileBuilder error: $e for tileImage: ${tileImage.toString()}'); + return tileWidget; + } + } ), PolygonLayer(polygons: overlays), MarkerLayer(markers: markers), + // Built-in scale bar from flutter_map + Scalebar( + alignment: Alignment.bottomLeft, + padding: EdgeInsets.only(left: 8, bottom: 54), // above attribution + textStyle: TextStyle(color: Colors.black, fontWeight: FontWeight.bold), + lineColor: Colors.black, + strokeWidth: 3, + // backgroundColor removed in flutter_map >=8 (wrap in Container if needed) + ), ], ), @@ -236,6 +346,31 @@ class _MapViewState extends State { ), ), + // Zoom indicator, positioned above scale bar + Positioned( + left: 10, + bottom: 92, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.52), + borderRadius: BorderRadius.circular(7), + ), + child: Builder( + builder: (context) { + final zoom = _controller.camera.zoom; + return Text( + 'Zoom: ${zoom.toStringAsFixed(2)}', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ); + }, + ), + ), + ), // Attribution overlay Positioned( bottom: 20, @@ -262,8 +397,8 @@ class _MapViewState extends State { } Polygon _buildCone(LatLng origin, double bearingDeg, double zoom) { - const halfAngle = 15.0; - final length = 0.0012 * math.pow(2, 15 - zoom); + final halfAngle = kDirectionConeHalfAngle; + final length = kDirectionConeBaseLength * math.pow(2, 15 - zoom); LatLng _project(double deg) { final rad = deg * math.pi / 180; @@ -278,7 +413,6 @@ class _MapViewState extends State { return Polygon( points: [origin, left, right, origin], - isFilled: true, color: Colors.redAccent.withOpacity(0.25), borderColor: Colors.redAccent, borderStrokeWidth: 1, diff --git a/lib/widgets/tile_provider_with_cache.dart b/lib/widgets/tile_provider_with_cache.dart new file mode 100644 index 0000000..d354b2a --- /dev/null +++ b/lib/widgets/tile_provider_with_cache.dart @@ -0,0 +1,55 @@ +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import '../services/map_data_provider.dart'; +import '../app_state.dart'; + +/// Singleton in-memory tile cache and async provider for custom tiles. +class TileProviderWithCache extends TileProvider { + static final Map _tileCache = {}; + static Map get tileCache => _tileCache; + final VoidCallback? onTileCacheUpdated; + + TileProviderWithCache({this.onTileCacheUpdated}); + + @override + ImageProvider getImage(TileCoordinates coords, TileLayer options, {MapSource source = MapSource.auto}) { + final key = '${coords.z}/${coords.x}/${coords.y}'; + if (_tileCache.containsKey(key)) { + return MemoryImage(_tileCache[key]!); + } else { + _fetchAndCacheTile(coords, key, source: source); + // Always return a placeholder until the real tile is cached, regardless of source/offline/online. + return const AssetImage('assets/transparent_1x1.png'); + } + } + + static void clearCache() { + _tileCache.clear(); + print('[TileProviderWithCache] Tile cache cleared'); + } + + void _fetchAndCacheTile(TileCoordinates coords, String key, {MapSource source = MapSource.auto}) async { + // Don't fire multiple fetches for the same tile simultaneously + if (_tileCache.containsKey(key)) return; + try { + final bytes = await MapDataProvider().getTile( + z: coords.z, x: coords.x, y: coords.y, source: source, + ); + if (bytes.isNotEmpty) { + _tileCache[key] = Uint8List.fromList(bytes); + print('[TileProviderWithCache] Cached tile $key, bytes=${bytes.length}'); + if (onTileCacheUpdated != null) { + print('[TileProviderWithCache] Calling onTileCacheUpdated for $key'); + SchedulerBinding.instance.addPostFrameCallback((_) => onTileCacheUpdated!()); + } + } + // If bytes were empty, don't cache (will re-attempt next time) + } catch (e) { + print('[TileProviderWithCache] Error fetching tile $key: $e'); + // Do NOT cache a failed or empty tile! Placeholder tiles will be evicted on online transition. + } + } +} diff --git a/pubspec.lock b/pubspec.lock index f62b0ad..29982b5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,30 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + ansicolor: + dependency: transitive + description: + name: ansicolor + sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f" + url: "https://pub.dev" + source: hosted + version: "2.0.3" + archive: + dependency: transitive + description: + name: archive + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + url: "https://pub.dev" + source: hosted + version: "4.0.7" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -17,6 +41,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" clock: dependency: transitive description: @@ -41,6 +81,30 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.6" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + dart_earcut: + dependency: transitive + description: + name: dart_earcut + sha256: e485001bfc05dcbc437d7bfb666316182e3522d4c3f9668048e004d0eb2ce43b + url: "https://pub.dev" + source: hosted + version: "1.2.0" + dart_polylabel2: + dependency: transitive + description: + name: dart_polylabel2 + sha256: "7eeab15ce72894e4bdba6a8765712231fc81be0bd95247de4ad9966abc57adc6" + url: "https://pub.dev" + source: hosted + version: "1.0.0" desktop_webview_window: dependency: transitive description: @@ -78,14 +142,30 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" + url: "https://pub.dev" + source: hosted + version: "0.14.4" flutter_map: dependency: "direct main" description: name: flutter_map - sha256: "87cc8349b8fa5dccda5af50018c7374b6645334a0d680931c1fe11bce88fa5bb" + sha256: df33e784b09fae857c6261a5521dd42bd4d3342cb6200884bb70730638af5fd5 url: "https://pub.dev" source: hosted - version: "6.2.1" + version: "8.2.1" + flutter_native_splash: + dependency: "direct dev" + description: + name: flutter_native_splash + sha256: "8321a6d11a8d13977fa780c89de8d257cce3d841eecfb7a4cadffcc4f12d82dc" + url: "https://pub.dev" + source: hosted + version: "2.4.6" flutter_secure_storage: dependency: "direct main" description: @@ -203,14 +283,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.5" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" http: dependency: "direct main" description: name: http - sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" + sha256: "85ab0074f9bf2b24625906d8382bbec84d3d6919d285ba9c106b07b65791fb99" url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.5.0-beta.2" http_parser: dependency: transitive description: @@ -219,6 +307,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" + url: "https://pub.dev" + source: hosted + version: "4.5.4" intl: dependency: transitive description: @@ -227,6 +323,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.20.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" latlong2: dependency: "direct main" description: @@ -347,6 +451,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" + url: "https://pub.dev" + source: hosted + version: "6.1.0" platform: dependency: transitive description: @@ -363,14 +475,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" - polylabel: + posix: dependency: transitive description: - name: polylabel - sha256: "41b9099afb2aa6c1730bdd8a0fab1400d287694ec7615dd8516935fa3144214b" + name: posix + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "6.0.3" proj4dart: dependency: transitive description: @@ -504,6 +616,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.1" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" + url: "https://pub.dev" + source: hosted + version: "2.2.2" url_launcher: dependency: transitive description: @@ -624,6 +744,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.dev" + source: hosted + version: "6.5.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: dart: ">=3.8.0 <4.0.0" flutter: ">=3.27.0" diff --git a/pubspec.yaml b/pubspec.yaml index 9a5d591..7da3e55 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,7 +12,8 @@ dependencies: # UI & Map provider: ^6.1.2 - flutter_map: ^6.2.1 + flutter_map: ^8.2.1 + # (removed: using built-in Scalebar from flutter_map >= v6) latlong2: ^0.9.0 geolocator: ^10.1.0 http: ^1.2.1 @@ -26,5 +27,26 @@ dependencies: shared_preferences: ^2.2.2 uuid: ^4.0.0 +dev_dependencies: + flutter_launcher_icons: ^0.14.4 + flutter_native_splash: ^2.4.6 + flutter: uses-material-design: true + + assets: + - assets/info.txt + - assets/app_icon.png + - assets/transparent_1x1.png + +flutter_native_splash: + color: "#202020" + image: assets/app_icon.png + android: true + ios: true + +flutter_icons: + android: true + ios: true + image_path: "assets/app_icon.png" + min_sdk_android: 21 \ No newline at end of file