mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-02-13 09:12:56 +00:00
Compare commits
82 Commits
v0.8.3-bet
...
v0.9.7-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a17c50188e | ||
|
|
5c2bfbc76e | ||
|
|
a8ac237317 | ||
|
|
eeedbd7da7 | ||
|
|
3ddebd2664 | ||
|
|
b5c210d009 | ||
|
|
208b3486f3 | ||
|
|
04a6d129b7 | ||
|
|
944df59d7c | ||
|
|
29031b1372 | ||
|
|
6bcfef0caa | ||
|
|
d2a3e96a86 | ||
|
|
395ef77fe3 | ||
|
|
57acff8ae7 | ||
|
|
a437d9bf60 | ||
|
|
c4c1505253 | ||
|
|
42c03eca7d | ||
|
|
bcc4461621 | ||
|
|
3cb875b67a | ||
|
|
d03ef6b50d | ||
|
|
6db691dbeb | ||
|
|
5ccf215f4e | ||
|
|
deb9a4272b | ||
|
|
1b3c3e620c | ||
|
|
c42d3afd0b | ||
|
|
f4ae861bc6 | ||
|
|
07d18ae33c | ||
|
|
92255eb03e | ||
|
|
3026b88230 | ||
|
|
728cef22af | ||
|
|
d7fbfaaaeb | ||
|
|
9c05f1d7a9 | ||
|
|
2c275ec528 | ||
|
|
f8726880d7 | ||
|
|
497b9e52be | ||
|
|
d9f6c8c8e0 | ||
|
|
45bf73aeee | ||
|
|
7ff945e262 | ||
|
|
26d8eca312 | ||
|
|
efbb8765de | ||
|
|
fae1cac6e4 | ||
|
|
aee0dcf8b8 | ||
|
|
2db4f597dc | ||
|
|
376fa27736 | ||
|
|
24b20e8a57 | ||
|
|
2d0dc7fd66 | ||
|
|
b735283f27 | ||
|
|
ebf7f93dd5 | ||
|
|
d56a6e8e7c | ||
|
|
84e057c986 | ||
|
|
c1e25ec5b1 | ||
|
|
a3edcfc2de | ||
|
|
17c9ee0c5c | ||
|
|
9e620ef9e4 | ||
|
|
bedfdcca6e | ||
|
|
f1c73a5e55 | ||
|
|
4ee783793f | ||
|
|
aada97295b | ||
|
|
813f4f69ea | ||
|
|
2d615128aa | ||
|
|
024d3f09c3 | ||
|
|
e65b9f58a6 | ||
|
|
7bd6f68a99 | ||
|
|
f11bd6e238 | ||
|
|
f45279ecfe | ||
|
|
d6625ccc23 | ||
|
|
722e640a72 | ||
|
|
a21e807d88 | ||
|
|
a2bc3309c0 | ||
|
|
f6adffc84e | ||
|
|
01f73322c7 | ||
|
|
257aefb2fc | ||
|
|
63ebc2b682 | ||
|
|
1f3849cd84 | ||
|
|
e35266c160 | ||
|
|
05de16b2e2 | ||
|
|
32507e1646 | ||
|
|
1272eb9409 | ||
|
|
4cc8929378 | ||
|
|
44707bf064 | ||
|
|
ff9a052d3f | ||
|
|
df5e26f78d |
198
README.md
198
README.md
@@ -1,145 +1,131 @@
|
||||
# Flock Map App
|
||||
|
||||
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.
|
||||
A comprehensive Flutter app for viewing and mapping surveillance cameras with OpenStreetMap. Includes offline capabilities, editing ability, and an intuitive interface.
|
||||
|
||||
**Stop Flock** is a privacy-focused initiative to document the rapid expansion of ALPRs and AI surveillance cameras. This app aims to be the go-to tool for contributors to map cameras in their communities and upload the data to OpenStreetMap, making surveillance infrastructure visible and searchable.
|
||||
|
||||
**For complete documentation, tutorials, and community info, visit [stopflock.com/app](https://stopflock.com/app)**
|
||||
|
||||
---
|
||||
|
||||
## Code Organization (2025 Refactor)
|
||||
## What This App Does
|
||||
|
||||
- **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.**
|
||||
- **Map surveillance cameras** with precise location, direction, and manufacturer details
|
||||
- **Upload to OpenStreetMap** with OAuth2 integration (live or sandbox modes)
|
||||
- **Work completely offline** with downloadable map areas and camera data, plus upload queue
|
||||
- **Multiple map types** including satellite imagery from Google, Esri, Mapbox, and OpenStreetMap, plus custom map tile provider support
|
||||
- **Editing Ability** to update existing camera locations and properties
|
||||
- **Built-in camera profiles** for Flock Safety, Motorola, Genetec, Leonardo, and other major manufacturers, plus custom profiles for more specific tag sets
|
||||
|
||||
---
|
||||
|
||||
## 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 & Navigation
|
||||
- **Multi-source tiles**: Switch between OpenStreetMap, Google Satellite, Esri imagery, Mapbox, and any custom providers
|
||||
- **Offline-first design**: Download a region for complete offline operation
|
||||
- **Smooth UX**: Intuitive controls, follow-me mode with GPS rotation, and gesture-friendly interactions
|
||||
- **Camera visualization**: Color-coded markers showing real cameras (blue), pending uploads (purple), new cameras (white), edited cameras (grey), and cameras being edited (orange)
|
||||
|
||||
### 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 Management
|
||||
- **Comprehensive profiles**: Built-in profiles for major manufacturers (Flock, Motorola/Vigilant, Genetec, Leonardo/ELSAG, Neology) plus custom profile creation
|
||||
- **Editing capabilities**: Update location, direction, and tags of existing cameras
|
||||
- **Direction visualization**: Interactive field-of-view cones showing camera viewing angles
|
||||
- **Bulk operations**: Tag multiple cameras efficiently with profile-based workflow
|
||||
|
||||
### Camera Profiles & Upload Queue
|
||||
- Unchanged: creation/editing/enabling; see prior documentation.
|
||||
### Professional Upload & Sync
|
||||
- **OpenStreetMap integration**: Direct upload with full OAuth2 authentication
|
||||
- **Upload modes**: Production OSM, testing sandbox, or simulate-only mode
|
||||
- **Queue management**: Review, edit, retry, or cancel pending uploads
|
||||
- **Changeset tracking**: Automatic grouping and commenting for organized contributions
|
||||
|
||||
### 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.
|
||||
### Offline Operations
|
||||
- **Smart area downloads**: Automatically calculate tile counts and storage requirements
|
||||
- **Camera caching**: Offline areas include camera data for complete functionality without network
|
||||
- **Global base map**: Permanent worldwide coverage at low zoom levels
|
||||
- **Robust downloads**: Exponential backoff, retry logic, and progress tracking for reliable area downloads
|
||||
|
||||
### 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.**
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Install** the app on iOS or Android
|
||||
2. **Enable location** and grant camera permissions
|
||||
3. **Log into OpenStreetMap**: Choose upload mode and get OAuth2 credentials
|
||||
4. **Add your first camera**: Tap the "tag camera" button, position the pin, set direction, select a profile, and tap submit
|
||||
|
||||
**New to OpenStreetMap?** Visit [stopflock.com/app](https://stopflock.com/app) for complete setup instructions and community guidelines.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
### Architecture Highlights
|
||||
- **Unified data provider**: All map tiles and camera data route through `MapDataProvider` with pluggable remote/local sources
|
||||
- **Modular settings**: Each settings section is a separate widget for maintainability
|
||||
- **State management**: Provider pattern with clean separation of concerns
|
||||
- **Offline-first**: Network calls are optional; app functions fully offline with downloaded data and queues uploads until online
|
||||
|
||||
---
|
||||
### Build Setup
|
||||
**Prerequisites**: Flutter SDK, Xcode (iOS), Android Studio
|
||||
**OAuth Setup**: Register apps at [openstreetmap.org/oauth2](https://www.openstreetmap.org/oauth2/applications) and [OSM Sandbox](https://master.apis.dev.openstreetmap.org/oauth2/applications) to get a client ID
|
||||
|
||||
## Roadmap (2025+)
|
||||
```shell
|
||||
# Basic setup
|
||||
flutter pub get
|
||||
cp lib/keys.dart.example lib/keys.dart
|
||||
# Add your OAuth2 client IDs to keys.dart
|
||||
|
||||
- **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.
|
||||
# iOS additional setup
|
||||
cd ios && pod install
|
||||
|
||||
---
|
||||
|
||||
*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.
|
||||
# Run
|
||||
flutter run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
### v1 todo/bug List
|
||||
- Fix "tiles loaded" indicator accuracy across different providers
|
||||
- Generic tile provider error messages (not always "OSM tiles slow")
|
||||
- Optional custom icons for camera profiles
|
||||
- Camera deletions
|
||||
- Clean up cache when submitted changesets appear in Overpass results
|
||||
- Upgrade camera marker design (considering nullplate's svg)
|
||||
|
||||
### Future Features & Wishlist
|
||||
- Location-based notifications when approaching cameras
|
||||
- Red/yellow ring for cameras missing specific tag details
|
||||
- iOS/Android native themes and dark mode support
|
||||
- "Cache accumulating" offline areas?
|
||||
- "Offline areas" as tile provider?
|
||||
- Jump to location by coordinates, address, or POI name
|
||||
- Route planning that avoids surveillance cameras
|
||||
- Custom camera providers and OSM/Overpass alternatives
|
||||
|
||||
---
|
||||
|
||||
## Build Environment Quick Setup
|
||||
## Contributing & Community
|
||||
|
||||
# Install from GUI:
|
||||
Xcode, Android Studio.
|
||||
Xcode cmdline tools
|
||||
Android cmdline tools + NDK
|
||||
This app is part of the larger **Stop Flock** initiative. Join the community:
|
||||
|
||||
# Terminal
|
||||
brew install openjdk@17
|
||||
sudo ln -sfn /usr/local/opt/openjdk@17/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk.jdk
|
||||
- **Documentation & Guides**: [stopflock.com/app](https://stopflock.com/app)
|
||||
- **Community Discussion**: [stopflock.com](https://stopflock.com)
|
||||
- **Issues & Feature Requests**: GitHub Issues
|
||||
- **Development**: See developer setup above
|
||||
|
||||
brew install ruby
|
||||
---
|
||||
|
||||
gem install cocoapods
|
||||
## Privacy & Ethics
|
||||
|
||||
sdkmanager --install "ndk;27.0.12077973"
|
||||
This project helps make existing public surveillance infrastructure transparent and searchable. We only document cameras that are already installed and visible in public spaces.
|
||||
|
||||
export PATH="/Users/bob/.gem/ruby/3.4.0/bin:$PATH"
|
||||
export PATH=$HOME/development/flutter/bin:$PATH
|
||||
No user information is ever collected, and no data leaves your device except submissions to OSM and whatever data your tile provider can glean from your requests.
|
||||
|
||||
flutter clean
|
||||
flutter pub get
|
||||
flutter run
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
This project is open source. See [LICENSE](LICENSE) for details.
|
||||
|
||||
@@ -1,497 +1,296 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'models/camera_profile.dart';
|
||||
import 'models/pending_upload.dart';
|
||||
import 'models/node_profile.dart';
|
||||
import 'models/operator_profile.dart';
|
||||
import 'models/osm_camera_node.dart';
|
||||
import 'services/auth_service.dart';
|
||||
import 'services/uploader.dart';
|
||||
import 'services/profile_service.dart';
|
||||
import 'services/camera_cache.dart';
|
||||
import 'widgets/tile_provider_with_cache.dart';
|
||||
import 'widgets/camera_provider_with_cache.dart';
|
||||
|
||||
// Enum for upload mode (Production, OSM Sandbox, Simulate)
|
||||
enum UploadMode { production, sandbox, simulate }
|
||||
|
||||
// ------------------ AddCameraSession ------------------
|
||||
class AddCameraSession {
|
||||
AddCameraSession({required this.profile, this.directionDegrees = 0});
|
||||
CameraProfile profile;
|
||||
double directionDegrees;
|
||||
LatLng? target;
|
||||
}
|
||||
import 'models/pending_upload.dart';
|
||||
import 'models/tile_provider.dart';
|
||||
import 'services/offline_area_service.dart';
|
||||
import 'state/auth_state.dart';
|
||||
import 'state/operator_profile_state.dart';
|
||||
import 'state/profile_state.dart';
|
||||
import 'state/session_state.dart';
|
||||
import 'state/settings_state.dart';
|
||||
import 'state/upload_queue_state.dart';
|
||||
|
||||
// Re-export types
|
||||
export 'state/settings_state.dart' show UploadMode, FollowMeMode;
|
||||
export 'state/session_state.dart' show AddNodeSession, EditNodeSession;
|
||||
|
||||
// ------------------ AppState ------------------
|
||||
class AppState extends ChangeNotifier {
|
||||
static late AppState instance;
|
||||
|
||||
// State modules
|
||||
late final AuthState _authState;
|
||||
late final OperatorProfileState _operatorProfileState;
|
||||
late final ProfileState _profileState;
|
||||
late final SessionState _sessionState;
|
||||
late final SettingsState _settingsState;
|
||||
late final UploadQueueState _uploadQueueState;
|
||||
|
||||
bool _isInitialized = false;
|
||||
|
||||
AppState() {
|
||||
instance = this;
|
||||
_authState = AuthState();
|
||||
_operatorProfileState = OperatorProfileState();
|
||||
_profileState = ProfileState();
|
||||
_sessionState = SessionState();
|
||||
_settingsState = SettingsState();
|
||||
_uploadQueueState = UploadQueueState();
|
||||
|
||||
// Set up state change listeners
|
||||
_authState.addListener(_onStateChanged);
|
||||
_operatorProfileState.addListener(_onStateChanged);
|
||||
_profileState.addListener(_onStateChanged);
|
||||
_sessionState.addListener(_onStateChanged);
|
||||
_settingsState.addListener(_onStateChanged);
|
||||
_uploadQueueState.addListener(_onStateChanged);
|
||||
|
||||
_init();
|
||||
}
|
||||
|
||||
// ------------------- Offline Mode -------------------
|
||||
static const String _offlineModePrefsKey = 'offline_mode';
|
||||
bool _offlineMode = false;
|
||||
bool get offlineMode => _offlineMode;
|
||||
Future<void> setOfflineMode(bool enabled) async {
|
||||
final wasOffline = _offlineMode;
|
||||
_offlineMode = enabled;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_offlineModePrefsKey, enabled);
|
||||
if (wasOffline && !enabled) {
|
||||
// Transitioning from offline to online: clear tile cache!
|
||||
TileProviderWithCache.clearCache();
|
||||
_startUploader(); // Resume upload queue processing as we leave offline mode
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
final _auth = AuthService();
|
||||
String? _username;
|
||||
|
||||
bool _isInitialized = false;
|
||||
// Getters that delegate to individual state modules
|
||||
bool get isInitialized => _isInitialized;
|
||||
|
||||
// Auth state
|
||||
bool get isLoggedIn => _authState.isLoggedIn;
|
||||
String get username => _authState.username;
|
||||
|
||||
// Profile state
|
||||
List<NodeProfile> get profiles => _profileState.profiles;
|
||||
List<NodeProfile> get enabledProfiles => _profileState.enabledProfiles;
|
||||
bool isEnabled(NodeProfile p) => _profileState.isEnabled(p);
|
||||
|
||||
// Operator profile state
|
||||
List<OperatorProfile> get operatorProfiles => _operatorProfileState.profiles;
|
||||
|
||||
// Session state
|
||||
AddNodeSession? get session => _sessionState.session;
|
||||
EditNodeSession? get editSession => _sessionState.editSession;
|
||||
|
||||
// Settings state
|
||||
bool get offlineMode => _settingsState.offlineMode;
|
||||
int get maxCameras => _settingsState.maxCameras;
|
||||
UploadMode get uploadMode => _settingsState.uploadMode;
|
||||
FollowMeMode get followMeMode => _settingsState.followMeMode;
|
||||
|
||||
// Tile provider state
|
||||
List<TileProvider> get tileProviders => _settingsState.tileProviders;
|
||||
TileType? get selectedTileType => _settingsState.selectedTileType;
|
||||
TileProvider? get selectedTileProvider => _settingsState.selectedTileProvider;
|
||||
|
||||
|
||||
final List<CameraProfile> _profiles = [];
|
||||
final Set<CameraProfile> _enabled = {};
|
||||
static const String _enabledPrefsKey = 'enabled_profiles';
|
||||
static const String _maxCamerasPrefsKey = 'max_cameras';
|
||||
|
||||
// Upload queue state
|
||||
int get pendingCount => _uploadQueueState.pendingCount;
|
||||
List<PendingUpload> get pendingUploads => _uploadQueueState.pendingUploads;
|
||||
|
||||
// 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);
|
||||
});
|
||||
void _onStateChanged() {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Upload mode: production, sandbox, or simulate (in-memory, no uploads)
|
||||
UploadMode _uploadMode = UploadMode.simulate;
|
||||
static const String _uploadModePrefsKey = 'upload_mode';
|
||||
UploadMode get uploadMode => _uploadMode;
|
||||
Future<void> setUploadMode(UploadMode mode) async {
|
||||
_uploadMode = mode;
|
||||
// Update AuthService to match new mode
|
||||
_auth.setUploadMode(mode);
|
||||
// Refresh user display for active mode, validating token
|
||||
try {
|
||||
if (await _auth.isLoggedIn()) {
|
||||
print('AppState: Switching mode, token exists; validating...');
|
||||
final isValid = await validateToken();
|
||||
if (isValid) {
|
||||
print("AppState: Switching mode; fetching username for $mode...");
|
||||
_username = await _auth.login();
|
||||
if (_username != null) {
|
||||
print("AppState: Switched mode, now logged in as $_username");
|
||||
} else {
|
||||
print('AppState: Switched mode but failed to retrieve username');
|
||||
}
|
||||
} else {
|
||||
print('AppState: Switching mode, token invalid—auto-logout.');
|
||||
await logout(); // This clears _username also.
|
||||
}
|
||||
} else {
|
||||
_username = null;
|
||||
print("AppState: Mode change: not logged in in $mode");
|
||||
}
|
||||
} catch (e) {
|
||||
_username = null;
|
||||
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");
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// For legacy bool test mode
|
||||
static const String _legacyTestModePrefsKey = 'test_mode';
|
||||
|
||||
AddCameraSession? _session;
|
||||
AddCameraSession? get session => _session;
|
||||
final List<PendingUpload> _queue = [];
|
||||
Timer? _uploadTimer;
|
||||
|
||||
bool get isLoggedIn => _username != null;
|
||||
String get username => _username ?? '';
|
||||
|
||||
// ---------- Init ----------
|
||||
Future<void> _init() async {
|
||||
// Initialize profiles: built-in + custom
|
||||
_profiles.add(CameraProfile.alpr());
|
||||
_profiles.addAll(await ProfileService().load());
|
||||
|
||||
// Load enabled profile IDs and upload/test mode from prefs
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final enabledIds = prefs.getStringList(_enabledPrefsKey);
|
||||
if (enabledIds != null && enabledIds.isNotEmpty) {
|
||||
// Restore enabled profiles by id
|
||||
_enabled.addAll(_profiles.where((p) => enabledIds.contains(p.id)));
|
||||
} else {
|
||||
// By default, all are enabled
|
||||
_enabled.addAll(_profiles);
|
||||
}
|
||||
// Upload mode loading (including migration from old test_mode bool)
|
||||
if (prefs.containsKey(_uploadModePrefsKey)) {
|
||||
final idx = prefs.getInt(_uploadModePrefsKey) ?? 0;
|
||||
if (idx >= 0 && idx < UploadMode.values.length) {
|
||||
_uploadMode = UploadMode.values[idx];
|
||||
}
|
||||
} else if (prefs.containsKey(_legacyTestModePrefsKey)) {
|
||||
// migrate legacy test_mode (true->simulate, false->prod)
|
||||
final legacy = prefs.getBool(_legacyTestModePrefsKey) ?? false;
|
||||
_uploadMode = legacy ? UploadMode.simulate : UploadMode.production;
|
||||
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();
|
||||
// Initialize all state modules
|
||||
await _settingsState.init();
|
||||
await _operatorProfileState.init();
|
||||
await _profileState.init();
|
||||
await _uploadQueueState.init();
|
||||
await _authState.init(_settingsState.uploadMode);
|
||||
|
||||
// Check if we're already logged in and get username
|
||||
try {
|
||||
if (await _auth.isLoggedIn()) {
|
||||
print('AppState: User appears to be logged in, fetching username...');
|
||||
_username = await _auth.login();
|
||||
if (_username != null) {
|
||||
print("AppState: Successfully retrieved username: $_username");
|
||||
} else {
|
||||
print('AppState: Failed to retrieve username despite being logged in');
|
||||
}
|
||||
} else {
|
||||
print('AppState: User is not logged in');
|
||||
}
|
||||
} catch (e) {
|
||||
print("AppState: Error during auth initialization: $e");
|
||||
}
|
||||
// Initialize OfflineAreaService to ensure offline areas are loaded
|
||||
await OfflineAreaService().ensureInitialized();
|
||||
|
||||
// Start uploader if conditions are met
|
||||
_startUploader();
|
||||
|
||||
_isInitialized = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ---------- Auth ----------
|
||||
// ---------- Auth Methods ----------
|
||||
Future<void> login() async {
|
||||
try {
|
||||
print('AppState: Starting login process...');
|
||||
_username = await _auth.login();
|
||||
if (_username != null) {
|
||||
print("AppState: Login successful for user: $_username");
|
||||
} else {
|
||||
print('AppState: Login failed - no username returned');
|
||||
}
|
||||
} catch (e) {
|
||||
print("AppState: Login error: $e");
|
||||
_username = null;
|
||||
}
|
||||
notifyListeners();
|
||||
await _authState.login();
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
await _auth.logout();
|
||||
_username = null;
|
||||
notifyListeners();
|
||||
await _authState.logout();
|
||||
}
|
||||
|
||||
// Add method to refresh auth state
|
||||
Future<void> refreshAuthState() async {
|
||||
try {
|
||||
print('AppState: Refreshing auth state...');
|
||||
if (await _auth.isLoggedIn()) {
|
||||
print('AppState: Token exists, fetching username...');
|
||||
_username = await _auth.login();
|
||||
if (_username != null) {
|
||||
print("AppState: Auth refresh successful: $_username");
|
||||
} else {
|
||||
print('AppState: Auth refresh failed - no username');
|
||||
}
|
||||
} else {
|
||||
print('AppState: No valid token found');
|
||||
_username = null;
|
||||
}
|
||||
} catch (e) {
|
||||
print("AppState: Auth refresh error: $e");
|
||||
_username = null;
|
||||
}
|
||||
notifyListeners();
|
||||
await _authState.refreshAuthState();
|
||||
}
|
||||
|
||||
// Force a completely fresh login (clears stored tokens)
|
||||
Future<void> forceLogin() async {
|
||||
try {
|
||||
print('AppState: Starting forced fresh login...');
|
||||
_username = await _auth.forceLogin();
|
||||
if (_username != null) {
|
||||
print("AppState: Forced login successful: $_username");
|
||||
} else {
|
||||
print('AppState: Forced login failed - no username returned');
|
||||
}
|
||||
} catch (e) {
|
||||
print("AppState: Forced login error: $e");
|
||||
_username = null;
|
||||
}
|
||||
notifyListeners();
|
||||
await _authState.forceLogin();
|
||||
}
|
||||
|
||||
// Validate current token/credentials
|
||||
Future<bool> validateToken() async {
|
||||
try {
|
||||
return await _auth.isLoggedIn();
|
||||
} catch (e) {
|
||||
print("AppState: Token validation error: $e");
|
||||
return false;
|
||||
}
|
||||
return await _authState.validateToken();
|
||||
}
|
||||
|
||||
// ---------- Profiles ----------
|
||||
List<CameraProfile> get profiles => List.unmodifiable(_profiles);
|
||||
bool isEnabled(CameraProfile p) => _enabled.contains(p);
|
||||
List<CameraProfile> get enabledProfiles =>
|
||||
_profiles.where(isEnabled).toList(growable: false);
|
||||
void toggleProfile(CameraProfile p, bool e) {
|
||||
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();
|
||||
// ---------- Profile Methods ----------
|
||||
void toggleProfile(NodeProfile p, bool e) {
|
||||
_profileState.toggleProfile(p, e);
|
||||
}
|
||||
|
||||
void addOrUpdateProfile(CameraProfile p) {
|
||||
final idx = _profiles.indexWhere((x) => x.id == p.id);
|
||||
if (idx >= 0) {
|
||||
_profiles[idx] = p;
|
||||
} else {
|
||||
_profiles.add(p);
|
||||
_enabled.add(p);
|
||||
_saveEnabledProfiles();
|
||||
}
|
||||
ProfileService().save(_profiles);
|
||||
notifyListeners();
|
||||
void addOrUpdateProfile(NodeProfile p) {
|
||||
_profileState.addOrUpdateProfile(p);
|
||||
}
|
||||
|
||||
void deleteProfile(CameraProfile p) {
|
||||
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();
|
||||
void deleteProfile(NodeProfile p) {
|
||||
_profileState.deleteProfile(p);
|
||||
}
|
||||
|
||||
// Save enabled profile IDs to disk
|
||||
Future<void> _saveEnabledProfiles() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList(
|
||||
_enabledPrefsKey,
|
||||
_enabled.map((p) => p.id).toList(),
|
||||
);
|
||||
// ---------- Operator Profile Methods ----------
|
||||
void addOrUpdateOperatorProfile(OperatorProfile p) {
|
||||
_operatorProfileState.addOrUpdateProfile(p);
|
||||
}
|
||||
|
||||
// ---------- Add‑camera session ----------
|
||||
void deleteOperatorProfile(OperatorProfile p) {
|
||||
_operatorProfileState.deleteProfile(p);
|
||||
}
|
||||
|
||||
// ---------- Session Methods ----------
|
||||
void startAddSession() {
|
||||
_session = AddCameraSession(profile: enabledProfiles.first);
|
||||
notifyListeners();
|
||||
_sessionState.startAddSession(enabledProfiles);
|
||||
}
|
||||
|
||||
void startEditSession(OsmCameraNode node) {
|
||||
_sessionState.startEditSession(node, enabledProfiles);
|
||||
}
|
||||
|
||||
void updateSession({
|
||||
double? directionDeg,
|
||||
CameraProfile? profile,
|
||||
NodeProfile? profile,
|
||||
OperatorProfile? operatorProfile,
|
||||
LatLng? target,
|
||||
}) {
|
||||
if (_session == null) return;
|
||||
_sessionState.updateSession(
|
||||
directionDeg: directionDeg,
|
||||
profile: profile,
|
||||
operatorProfile: operatorProfile,
|
||||
target: target,
|
||||
);
|
||||
}
|
||||
|
||||
bool dirty = false;
|
||||
if (directionDeg != null && directionDeg != _session!.directionDegrees) {
|
||||
_session!.directionDegrees = directionDeg;
|
||||
dirty = true;
|
||||
}
|
||||
if (profile != null && profile != _session!.profile) {
|
||||
_session!.profile = profile;
|
||||
dirty = true;
|
||||
}
|
||||
if (target != null) {
|
||||
_session!.target = target;
|
||||
dirty = true;
|
||||
}
|
||||
if (dirty) notifyListeners(); // <-- slider & map update
|
||||
void updateEditSession({
|
||||
double? directionDeg,
|
||||
NodeProfile? profile,
|
||||
OperatorProfile? operatorProfile,
|
||||
LatLng? target,
|
||||
}) {
|
||||
_sessionState.updateEditSession(
|
||||
directionDeg: directionDeg,
|
||||
profile: profile,
|
||||
operatorProfile: operatorProfile,
|
||||
target: target,
|
||||
);
|
||||
}
|
||||
|
||||
void cancelSession() {
|
||||
_session = null;
|
||||
notifyListeners();
|
||||
_sessionState.cancelSession();
|
||||
}
|
||||
|
||||
void cancelEditSession() {
|
||||
_sessionState.cancelEditSession();
|
||||
}
|
||||
|
||||
void commitSession() {
|
||||
if (_session?.target == null) return;
|
||||
|
||||
// Create the pending upload
|
||||
final upload = PendingUpload(
|
||||
coord: _session!.target!,
|
||||
direction: _session!.directionDegrees,
|
||||
profile: _session!.profile,
|
||||
);
|
||||
|
||||
_queue.add(upload);
|
||||
_saveQueue();
|
||||
|
||||
// Add to camera cache immediately so it shows on the map
|
||||
// Create a temporary node with a negative ID (to distinguish from real OSM nodes)
|
||||
// Using timestamp as negative ID to ensure uniqueness
|
||||
final tempId = -DateTime.now().millisecondsSinceEpoch;
|
||||
final tags = Map<String, String>.from(upload.profile.tags);
|
||||
tags['direction'] = upload.direction.toStringAsFixed(0);
|
||||
tags['_pending_upload'] = 'true'; // Mark as pending for potential UI distinction
|
||||
|
||||
final tempNode = OsmCameraNode(
|
||||
id: tempId,
|
||||
coord: upload.coord,
|
||||
tags: tags,
|
||||
);
|
||||
|
||||
CameraCache.instance.addOrUpdate([tempNode]);
|
||||
// Notify camera provider to update the map
|
||||
CameraProviderWithCache.instance.notifyListeners();
|
||||
|
||||
_session = null;
|
||||
|
||||
// Restart uploader when new items are added
|
||||
_startUploader();
|
||||
|
||||
notifyListeners();
|
||||
final session = _sessionState.commitSession();
|
||||
if (session != null) {
|
||||
_uploadQueueState.addFromSession(session, uploadMode: uploadMode);
|
||||
_startUploader();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Queue persistence ----------
|
||||
Future<void> _saveQueue() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonList = _queue.map((e) => e.toJson()).toList();
|
||||
await prefs.setString('queue', jsonEncode(jsonList));
|
||||
void commitEditSession() {
|
||||
final session = _sessionState.commitEditSession();
|
||||
if (session != null) {
|
||||
_uploadQueueState.addFromEditSession(session, uploadMode: uploadMode);
|
||||
_startUploader();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadQueue() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonStr = prefs.getString('queue');
|
||||
if (jsonStr == null) return;
|
||||
final list = jsonDecode(jsonStr) as List<dynamic>;
|
||||
_queue
|
||||
..clear()
|
||||
..addAll(list.map((e) => PendingUpload.fromJson(e)));
|
||||
// ---------- Settings Methods ----------
|
||||
Future<void> setOfflineMode(bool enabled) async {
|
||||
await _settingsState.setOfflineMode(enabled);
|
||||
if (!enabled) {
|
||||
_startUploader(); // Resume upload queue processing as we leave offline mode
|
||||
} else {
|
||||
_uploadQueueState.stopUploader(); // Stop uploader in offline mode
|
||||
// Cancel any active area downloads
|
||||
await OfflineAreaService().cancelActiveDownloads();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Uploader ----------
|
||||
void _startUploader() {
|
||||
_uploadTimer?.cancel();
|
||||
|
||||
// No uploads without auth or queue, or if offline mode is enabled.
|
||||
if (_queue.isEmpty || _offlineMode) return;
|
||||
|
||||
_uploadTimer = Timer.periodic(const Duration(seconds: 10), (t) async {
|
||||
if (_queue.isEmpty || _offlineMode) {
|
||||
_uploadTimer?.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the first queue item that is NOT in error state and act on that
|
||||
final item = _queue.where((pu) => !pu.error).cast<PendingUpload?>().firstOrNull;
|
||||
if (item == null) return;
|
||||
|
||||
// Retrieve access after every tick (accounts for re-login)
|
||||
final access = await _auth.getAccessToken();
|
||||
if (access == null) return; // not logged in
|
||||
|
||||
bool ok;
|
||||
if (_uploadMode == UploadMode.simulate) {
|
||||
// Simulate successful upload without calling real API
|
||||
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');
|
||||
} else {
|
||||
// Real upload -- pass uploadMode so uploader can switch between prod and sandbox
|
||||
final up = Uploader(access, () {
|
||||
_queue.remove(item);
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
}, uploadMode: _uploadMode);
|
||||
ok = await up.upload(item);
|
||||
}
|
||||
|
||||
if (ok && _uploadMode == UploadMode.simulate) {
|
||||
// Remove manually for simulate mode
|
||||
_queue.remove(item);
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
}
|
||||
if (!ok) {
|
||||
item.attempts++;
|
||||
if (item.attempts >= 3) {
|
||||
// Mark as error and stop the uploader. User can manually retry.
|
||||
item.error = true;
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
_uploadTimer?.cancel();
|
||||
} else {
|
||||
await Future.delayed(const Duration(seconds: 20));
|
||||
}
|
||||
}
|
||||
});
|
||||
set maxCameras(int n) {
|
||||
_settingsState.maxCameras = n;
|
||||
}
|
||||
|
||||
// ---------- Exposed getters ----------
|
||||
int get pendingCount => _queue.length;
|
||||
List<PendingUpload> get pendingUploads => List.unmodifiable(_queue);
|
||||
|
||||
// ---------- Queue management ----------
|
||||
Future<void> setUploadMode(UploadMode mode) async {
|
||||
await _settingsState.setUploadMode(mode);
|
||||
await _authState.onUploadModeChanged(mode);
|
||||
_startUploader(); // Restart uploader with new mode
|
||||
}
|
||||
|
||||
/// Select a tile type by ID
|
||||
Future<void> setSelectedTileType(String tileTypeId) async {
|
||||
await _settingsState.setSelectedTileType(tileTypeId);
|
||||
}
|
||||
|
||||
/// Add or update a tile provider
|
||||
Future<void> addOrUpdateTileProvider(TileProvider provider) async {
|
||||
await _settingsState.addOrUpdateTileProvider(provider);
|
||||
}
|
||||
|
||||
/// Delete a tile provider
|
||||
Future<void> deleteTileProvider(String providerId) async {
|
||||
await _settingsState.deleteTileProvider(providerId);
|
||||
}
|
||||
|
||||
/// Set follow-me mode
|
||||
Future<void> setFollowMeMode(FollowMeMode mode) async {
|
||||
await _settingsState.setFollowMeMode(mode);
|
||||
}
|
||||
|
||||
// ---------- Queue Methods ----------
|
||||
void clearQueue() {
|
||||
print("AppState: Clearing upload queue (${_queue.length} items)");
|
||||
_queue.clear();
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
_uploadQueueState.clearQueue();
|
||||
}
|
||||
|
||||
void removeFromQueue(PendingUpload upload) {
|
||||
print("AppState: Removing upload from queue: ${upload.coord}");
|
||||
_queue.remove(upload);
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
_uploadQueueState.removeFromQueue(upload);
|
||||
}
|
||||
|
||||
// Retry a failed upload (clear error and attempts, then try uploading again)
|
||||
void retryUpload(PendingUpload upload) {
|
||||
upload.error = false;
|
||||
upload.attempts = 0;
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
_uploadQueueState.retryUpload(upload);
|
||||
_startUploader(); // resume uploader if not busy
|
||||
}
|
||||
|
||||
// ---------- Private Methods ----------
|
||||
void _startUploader() {
|
||||
_uploadQueueState.startUploader(
|
||||
offlineMode: offlineMode,
|
||||
uploadMode: uploadMode,
|
||||
getAccessToken: _authState.getAccessToken,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_authState.removeListener(_onStateChanged);
|
||||
_operatorProfileState.removeListener(_onStateChanged);
|
||||
_profileState.removeListener(_onStateChanged);
|
||||
_sessionState.removeListener(_onStateChanged);
|
||||
_settingsState.removeListener(_onStateChanged);
|
||||
_uploadQueueState.removeListener(_onStateChanged);
|
||||
|
||||
_uploadQueueState.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// lib/dev_config.dart
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Developer/build-time configuration for global/non-user-tunable constants.
|
||||
const int kWorldMinZoom = 1;
|
||||
const int kWorldMaxZoom = 5;
|
||||
@@ -7,8 +9,9 @@ const int kWorldMaxZoom = 5;
|
||||
const double kTileEstimateKb = 25.0;
|
||||
|
||||
// Direction cone for map view
|
||||
const double kDirectionConeHalfAngle = 20.0; // degrees
|
||||
const double kDirectionConeBaseLength = 0.0012; // multiplier
|
||||
const double kDirectionConeHalfAngle = 30.0; // degrees
|
||||
const double kDirectionConeBaseLength = 0.001; // multiplier
|
||||
const Color kDirectionConeColor = Color(0xFF000000); // FOV cone color
|
||||
|
||||
// Margin (bottom) for positioning the floating bottom button bar
|
||||
const double kBottomButtonBarMargin = 4.0;
|
||||
@@ -18,18 +21,27 @@ const double kAttributionBottomOffset = 110.0;
|
||||
const double kZoomIndicatorBottomOffset = 142.0;
|
||||
const double kScaleBarBottomOffset = 170.0;
|
||||
|
||||
// Add Camera pin vertical offset (for pin tip to match coordinate on map)
|
||||
const double kAddPinYOffset = -16.0;
|
||||
// Add Camera icon vertical offset (no offset needed since circle is centered)
|
||||
const double kAddPinYOffset = 0.0;
|
||||
|
||||
// Client name and version for OSM uploads ("created_by" tag)
|
||||
const String kClientName = 'FlockMap';
|
||||
const String kClientVersion = '0.8.3';
|
||||
const String kClientVersion = '0.9.7';
|
||||
|
||||
// Marker/camera interaction
|
||||
const int kCameraMinZoomLevel = 10; // Minimum zoom to show cameras or warning
|
||||
// Marker/node interaction
|
||||
const int kCameraMinZoomLevel = 10; // Minimum zoom to show nodes or warning
|
||||
const Duration kMarkerTapTimeout = Duration(milliseconds: 250);
|
||||
const Duration kDebounceCameraRefresh = Duration(milliseconds: 500);
|
||||
|
||||
// Follow-me mode smooth transitions
|
||||
const Duration kFollowMeAnimationDuration = Duration(milliseconds: 600);
|
||||
const double kMinSpeedForRotationMps = 1.0; // Minimum speed (m/s) to apply rotation
|
||||
|
||||
// Last map location and settings storage
|
||||
const String kLastMapLatKey = 'last_map_latitude';
|
||||
const String kLastMapLngKey = 'last_map_longitude';
|
||||
const String kLastMapZoomKey = 'last_map_zoom';
|
||||
|
||||
// Tile/OSM fetch retry parameters (for tunable backoff)
|
||||
const int kTileFetchMaxAttempts = 3;
|
||||
const int kTileFetchInitialDelayMs = 4000;
|
||||
@@ -41,3 +53,17 @@ const int kTileFetchJitter3Ms = 5000;
|
||||
|
||||
// User download max zoom span (user can download up to kMaxUserDownloadZoomSpan zooms above min)
|
||||
const int kMaxUserDownloadZoomSpan = 7;
|
||||
|
||||
// Download area limits and constants
|
||||
const int kMaxReasonableTileCount = 20000;
|
||||
const int kAbsoluteMaxZoom = 19;
|
||||
|
||||
// Camera icon configuration
|
||||
const double kCameraIconDiameter = 20.0;
|
||||
const double kCameraRingThickness = 4.0;
|
||||
const double kCameraDotOpacity = 0.4; // Opacity for the grey dot interior
|
||||
const Color kCameraRingColorReal = Color(0xC43F55F3); // Real nodes from OSM - blue
|
||||
const Color kCameraRingColorMock = Color(0xC4FFFFFF); // Add node mock point - white
|
||||
const Color kCameraRingColorPending = Color(0xC49C27B0); // Submitted/pending nodes - purple
|
||||
const Color kCameraRingColorEditing = Color(0xC4FF9800); // Node being edited - orange
|
||||
const Color kCameraRingColorPendingEdit = Color(0xC4757575); // Original node with pending edit - grey
|
||||
@@ -5,7 +5,7 @@ import 'app_state.dart';
|
||||
import 'screens/home_screen.dart';
|
||||
import 'screens/settings_screen.dart';
|
||||
|
||||
import 'widgets/tile_provider_with_cache.dart';
|
||||
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// A bundle of preset OSM tags that describe a particular camera model/type.
|
||||
class CameraProfile {
|
||||
final String id;
|
||||
final String name;
|
||||
final Map<String, String> tags;
|
||||
final bool builtin;
|
||||
|
||||
CameraProfile({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.tags,
|
||||
this.builtin = false,
|
||||
});
|
||||
|
||||
/// Built‑in default: Generic Flock ALPR camera
|
||||
factory CameraProfile.alpr() => CameraProfile(
|
||||
id: 'builtin-alpr',
|
||||
name: 'Generic Flock',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance:type': 'ALPR',
|
||||
'manufacturer': 'Flock Safety',
|
||||
'manufacturer:wikidata': 'Q108485435',
|
||||
},
|
||||
builtin: true,
|
||||
);
|
||||
|
||||
CameraProfile copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
Map<String, String>? tags,
|
||||
bool? builtin,
|
||||
}) =>
|
||||
CameraProfile(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
tags: tags ?? this.tags,
|
||||
builtin: builtin ?? this.builtin,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() =>
|
||||
{'id': id, 'name': name, 'tags': tags, 'builtin': builtin};
|
||||
|
||||
factory CameraProfile.fromJson(Map<String, dynamic> j) => CameraProfile(
|
||||
id: j['id'],
|
||||
name: j['name'],
|
||||
tags: Map<String, String>.from(j['tags']),
|
||||
builtin: j['builtin'] ?? false,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is CameraProfile &&
|
||||
runtimeType == other.runtimeType &&
|
||||
id == other.id;
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
}
|
||||
|
||||
231
lib/models/node_profile.dart
Normal file
231
lib/models/node_profile.dart
Normal file
@@ -0,0 +1,231 @@
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// A bundle of preset OSM tags that describe a particular surveillance node model/type.
|
||||
class NodeProfile {
|
||||
final String id;
|
||||
final String name;
|
||||
final Map<String, String> tags;
|
||||
final bool builtin;
|
||||
final bool requiresDirection;
|
||||
final bool submittable;
|
||||
final bool editable;
|
||||
|
||||
NodeProfile({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.tags,
|
||||
this.builtin = false,
|
||||
this.requiresDirection = true,
|
||||
this.submittable = true,
|
||||
this.editable = true,
|
||||
});
|
||||
|
||||
/// Built‑in default: Generic ALPR camera (customizable template, not submittable)
|
||||
factory NodeProfile.genericAlpr() => NodeProfile(
|
||||
id: 'builtin-generic-alpr',
|
||||
name: 'Generic ALPR',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance:type': 'ALPR',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: true,
|
||||
submittable: false,
|
||||
editable: false,
|
||||
);
|
||||
|
||||
/// Built‑in: Flock Safety ALPR camera
|
||||
factory NodeProfile.flock() => NodeProfile(
|
||||
id: 'builtin-flock',
|
||||
name: 'Flock',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'manufacturer': 'Flock Safety',
|
||||
'manufacturer:wikidata': 'Q108485435',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
);
|
||||
|
||||
/// Built‑in: Motorola Solutions/Vigilant ALPR camera
|
||||
factory NodeProfile.motorola() => NodeProfile(
|
||||
id: 'builtin-motorola',
|
||||
name: 'Motorola/Vigilant',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'manufacturer': 'Motorola Solutions',
|
||||
'manufacturer:wikidata': 'Q634815',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
);
|
||||
|
||||
/// Built‑in: Genetec ALPR camera
|
||||
factory NodeProfile.genetec() => NodeProfile(
|
||||
id: 'builtin-genetec',
|
||||
name: 'Genetec',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'manufacturer': 'Genetec',
|
||||
'manufacturer:wikidata': 'Q30295174',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
);
|
||||
|
||||
/// Built‑in: Leonardo/ELSAG ALPR camera
|
||||
factory NodeProfile.leonardo() => NodeProfile(
|
||||
id: 'builtin-leonardo',
|
||||
name: 'Leonardo/ELSAG',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'manufacturer': 'Leonardo',
|
||||
'manufacturer:wikidata': 'Q910379',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
);
|
||||
|
||||
/// Built‑in: Neology ALPR camera
|
||||
factory NodeProfile.neology() => NodeProfile(
|
||||
id: 'builtin-neology',
|
||||
name: 'Neology',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'manufacturer': 'Neology, Inc.',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
);
|
||||
|
||||
/// Built‑in: Generic gunshot detector (customizable template, not submittable)
|
||||
factory NodeProfile.genericGunshotDetector() => NodeProfile(
|
||||
id: 'builtin-generic-gunshot',
|
||||
name: 'Generic Gunshot Detector',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance:type': 'gunshot_detector',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: false,
|
||||
submittable: false,
|
||||
editable: false,
|
||||
);
|
||||
|
||||
/// Built‑in: ShotSpotter gunshot detector
|
||||
factory NodeProfile.shotspotter() => NodeProfile(
|
||||
id: 'builtin-shotspotter',
|
||||
name: 'ShotSpotter',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:type': 'gunshot_detector',
|
||||
'surveillance:brand': 'ShotSpotter',
|
||||
'surveillance:brand:wikidata': 'Q107740188',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: false,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
);
|
||||
|
||||
/// Built‑in: Flock Raven gunshot detector
|
||||
factory NodeProfile.flockRaven() => NodeProfile(
|
||||
id: 'builtin-flock-raven',
|
||||
name: 'Flock Raven',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:type': 'gunshot_detector',
|
||||
'brand': 'Flock Safety',
|
||||
'brand:wikidata': 'Q108485435',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: false,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
);
|
||||
|
||||
/// Returns true if this profile can be used for submissions
|
||||
bool get isSubmittable => submittable;
|
||||
|
||||
NodeProfile copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
Map<String, String>? tags,
|
||||
bool? builtin,
|
||||
bool? requiresDirection,
|
||||
bool? submittable,
|
||||
bool? editable,
|
||||
}) =>
|
||||
NodeProfile(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
tags: tags ?? this.tags,
|
||||
builtin: builtin ?? this.builtin,
|
||||
requiresDirection: requiresDirection ?? this.requiresDirection,
|
||||
submittable: submittable ?? this.submittable,
|
||||
editable: editable ?? this.editable,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'tags': tags,
|
||||
'builtin': builtin,
|
||||
'requiresDirection': requiresDirection,
|
||||
'submittable': submittable,
|
||||
'editable': editable,
|
||||
};
|
||||
|
||||
factory NodeProfile.fromJson(Map<String, dynamic> j) => NodeProfile(
|
||||
id: j['id'],
|
||||
name: j['name'],
|
||||
tags: Map<String, String>.from(j['tags']),
|
||||
builtin: j['builtin'] ?? false,
|
||||
requiresDirection: j['requiresDirection'] ?? true, // Default to true for backward compatibility
|
||||
submittable: j['submittable'] ?? true, // Default to true for backward compatibility
|
||||
editable: j['editable'] ?? true, // Default to true for backward compatibility
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is NodeProfile &&
|
||||
runtimeType == other.runtimeType &&
|
||||
id == other.id;
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
}
|
||||
|
||||
48
lib/models/operator_profile.dart
Normal file
48
lib/models/operator_profile.dart
Normal file
@@ -0,0 +1,48 @@
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// A bundle of OSM tags that describe a particular surveillance operator.
|
||||
/// These are applied on top of camera profile tags during submissions.
|
||||
class OperatorProfile {
|
||||
final String id;
|
||||
final String name;
|
||||
final Map<String, String> tags;
|
||||
|
||||
OperatorProfile({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.tags,
|
||||
});
|
||||
|
||||
OperatorProfile copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
Map<String, String>? tags,
|
||||
}) =>
|
||||
OperatorProfile(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
tags: tags ?? this.tags,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'tags': tags,
|
||||
};
|
||||
|
||||
factory OperatorProfile.fromJson(Map<String, dynamic> j) => OperatorProfile(
|
||||
id: j['id'],
|
||||
name: j['name'],
|
||||
tags: Map<String, String>.from(j['tags']),
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is OperatorProfile &&
|
||||
runtimeType == other.runtimeType &&
|
||||
id == other.id;
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
}
|
||||
@@ -1,38 +1,92 @@
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'camera_profile.dart';
|
||||
import 'node_profile.dart';
|
||||
import 'operator_profile.dart';
|
||||
import '../state/settings_state.dart';
|
||||
|
||||
class PendingUpload {
|
||||
final LatLng coord;
|
||||
final double direction;
|
||||
final CameraProfile profile;
|
||||
final NodeProfile profile;
|
||||
final OperatorProfile? operatorProfile;
|
||||
final UploadMode uploadMode; // Capture upload destination when queued
|
||||
final int? originalNodeId; // If this is an edit, the ID of the original OSM node
|
||||
int attempts;
|
||||
bool error;
|
||||
bool completing; // True when upload succeeded but item is showing checkmark briefly
|
||||
|
||||
PendingUpload({
|
||||
required this.coord,
|
||||
required this.direction,
|
||||
required this.profile,
|
||||
this.operatorProfile,
|
||||
required this.uploadMode,
|
||||
this.originalNodeId,
|
||||
this.attempts = 0,
|
||||
this.error = false,
|
||||
this.completing = false,
|
||||
});
|
||||
|
||||
// True if this is an edit of an existing camera, false if it's a new camera
|
||||
bool get isEdit => originalNodeId != null;
|
||||
|
||||
// Get display name for the upload destination
|
||||
String get uploadModeDisplayName {
|
||||
switch (uploadMode) {
|
||||
case UploadMode.production:
|
||||
return 'Production';
|
||||
case UploadMode.sandbox:
|
||||
return 'Sandbox';
|
||||
case UploadMode.simulate:
|
||||
return 'Simulate';
|
||||
}
|
||||
}
|
||||
|
||||
// Get combined tags from camera profile and operator profile
|
||||
Map<String, String> getCombinedTags() {
|
||||
final tags = Map<String, String>.from(profile.tags);
|
||||
|
||||
// Add operator profile tags (they override camera profile tags if there are conflicts)
|
||||
if (operatorProfile != null) {
|
||||
tags.addAll(operatorProfile!.tags);
|
||||
}
|
||||
|
||||
// Add direction if required
|
||||
if (profile.requiresDirection) {
|
||||
tags['direction'] = direction.toStringAsFixed(0);
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'lat': coord.latitude,
|
||||
'lon': coord.longitude,
|
||||
'dir': direction,
|
||||
'profile': profile.toJson(),
|
||||
'operatorProfile': operatorProfile?.toJson(),
|
||||
'uploadMode': uploadMode.index,
|
||||
'originalNodeId': originalNodeId,
|
||||
'attempts': attempts,
|
||||
'error': error,
|
||||
'completing': completing,
|
||||
};
|
||||
|
||||
factory PendingUpload.fromJson(Map<String, dynamic> j) => PendingUpload(
|
||||
coord: LatLng(j['lat'], j['lon']),
|
||||
direction: j['dir'],
|
||||
profile: j['profile'] is Map<String, dynamic>
|
||||
? CameraProfile.fromJson(j['profile'])
|
||||
: CameraProfile.alpr(),
|
||||
? NodeProfile.fromJson(j['profile'])
|
||||
: NodeProfile.genericAlpr(),
|
||||
operatorProfile: j['operatorProfile'] != null
|
||||
? OperatorProfile.fromJson(j['operatorProfile'])
|
||||
: null,
|
||||
uploadMode: j['uploadMode'] != null
|
||||
? UploadMode.values[j['uploadMode']]
|
||||
: UploadMode.production, // Default for legacy entries
|
||||
originalNodeId: j['originalNodeId'],
|
||||
attempts: j['attempts'] ?? 0,
|
||||
error: j['error'] ?? false,
|
||||
completing: j['completing'] ?? false, // Default to false for legacy entries
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
214
lib/models/tile_provider.dart
Normal file
214
lib/models/tile_provider.dart
Normal file
@@ -0,0 +1,214 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// A specific tile type within a provider
|
||||
class TileType {
|
||||
final String id;
|
||||
final String name;
|
||||
final String urlTemplate;
|
||||
final String attribution;
|
||||
final Uint8List? previewTile; // Single tile image data for preview
|
||||
|
||||
const TileType({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.urlTemplate,
|
||||
required this.attribution,
|
||||
this.previewTile,
|
||||
});
|
||||
|
||||
/// Create URL for a specific tile, replacing template variables
|
||||
String getTileUrl(int z, int x, int y, {String? apiKey}) {
|
||||
String url = urlTemplate
|
||||
.replaceAll('{z}', z.toString())
|
||||
.replaceAll('{x}', x.toString())
|
||||
.replaceAll('{y}', y.toString());
|
||||
|
||||
if (apiKey != null && apiKey.isNotEmpty) {
|
||||
url = url.replaceAll('{api_key}', apiKey);
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/// Check if this tile type needs an API key
|
||||
bool get requiresApiKey => urlTemplate.contains('{api_key}');
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'urlTemplate': urlTemplate,
|
||||
'attribution': attribution,
|
||||
'previewTile': previewTile != null ? base64Encode(previewTile!) : null,
|
||||
};
|
||||
|
||||
static TileType fromJson(Map<String, dynamic> json) => TileType(
|
||||
id: json['id'],
|
||||
name: json['name'],
|
||||
urlTemplate: json['urlTemplate'],
|
||||
attribution: json['attribution'],
|
||||
previewTile: json['previewTile'] != null
|
||||
? base64Decode(json['previewTile'])
|
||||
: null,
|
||||
);
|
||||
|
||||
TileType copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
String? urlTemplate,
|
||||
String? attribution,
|
||||
Uint8List? previewTile,
|
||||
}) => TileType(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
urlTemplate: urlTemplate ?? this.urlTemplate,
|
||||
attribution: attribution ?? this.attribution,
|
||||
previewTile: previewTile ?? this.previewTile,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is TileType && runtimeType == other.runtimeType && id == other.id;
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
}
|
||||
|
||||
/// A tile provider containing multiple tile types
|
||||
class TileProvider {
|
||||
final String id;
|
||||
final String name;
|
||||
final String? apiKey;
|
||||
final List<TileType> tileTypes;
|
||||
|
||||
const TileProvider({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.apiKey,
|
||||
required this.tileTypes,
|
||||
});
|
||||
|
||||
/// Check if this provider is usable (has API key if any tile types need it)
|
||||
bool get isUsable {
|
||||
final needsKey = tileTypes.any((type) => type.requiresApiKey);
|
||||
return !needsKey || (apiKey != null && apiKey!.isNotEmpty);
|
||||
}
|
||||
|
||||
/// Get available tile types (those that don't need API key or have one)
|
||||
List<TileType> get availableTileTypes {
|
||||
return tileTypes.where((type) => !type.requiresApiKey || isUsable).toList();
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'apiKey': apiKey,
|
||||
'tileTypes': tileTypes.map((type) => type.toJson()).toList(),
|
||||
};
|
||||
|
||||
static TileProvider fromJson(Map<String, dynamic> json) => TileProvider(
|
||||
id: json['id'],
|
||||
name: json['name'],
|
||||
apiKey: json['apiKey'],
|
||||
tileTypes: (json['tileTypes'] as List)
|
||||
.map((typeJson) => TileType.fromJson(typeJson))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
TileProvider copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
String? apiKey,
|
||||
List<TileType>? tileTypes,
|
||||
}) => TileProvider(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
apiKey: apiKey ?? this.apiKey,
|
||||
tileTypes: tileTypes ?? this.tileTypes,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is TileProvider && runtimeType == other.runtimeType && id == other.id;
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
}
|
||||
|
||||
/// Factory for creating default tile providers
|
||||
class DefaultTileProviders {
|
||||
/// Create the default set of tile providers
|
||||
static List<TileProvider> createDefaults() {
|
||||
return [
|
||||
TileProvider(
|
||||
id: 'openstreetmap',
|
||||
name: 'OpenStreetMap',
|
||||
tileTypes: [
|
||||
TileType(
|
||||
id: 'osm_street',
|
||||
name: 'Street Map',
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
),
|
||||
],
|
||||
),
|
||||
TileProvider(
|
||||
id: 'google',
|
||||
name: 'Google',
|
||||
tileTypes: [
|
||||
TileType(
|
||||
id: 'google_hybrid',
|
||||
name: 'Satellite + Roads',
|
||||
urlTemplate: 'https://mt1.google.com/vt/lyrs=y&x={x}&y={y}&z={z}',
|
||||
attribution: '© Google',
|
||||
),
|
||||
TileType(
|
||||
id: 'google_satellite',
|
||||
name: 'Satellite Only',
|
||||
urlTemplate: 'https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}',
|
||||
attribution: '© Google',
|
||||
),
|
||||
TileType(
|
||||
id: 'google_roadmap',
|
||||
name: 'Road Map',
|
||||
urlTemplate: 'https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}',
|
||||
attribution: '© Google',
|
||||
),
|
||||
],
|
||||
),
|
||||
TileProvider(
|
||||
id: 'esri',
|
||||
name: 'Esri',
|
||||
tileTypes: [
|
||||
TileType(
|
||||
id: 'esri_satellite',
|
||||
name: 'Satellite Imagery',
|
||||
urlTemplate: 'https://services.arcgisonline.com/ArcGis/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}.png',
|
||||
attribution: '© Esri © Maxar',
|
||||
),
|
||||
],
|
||||
),
|
||||
TileProvider(
|
||||
id: 'mapbox',
|
||||
name: 'Mapbox',
|
||||
tileTypes: [
|
||||
TileType(
|
||||
id: 'mapbox_satellite',
|
||||
name: 'Satellite',
|
||||
urlTemplate: 'https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}@2x.jpg90?access_token={api_key}',
|
||||
attribution: '© Mapbox © Maxar',
|
||||
),
|
||||
TileType(
|
||||
id: 'mapbox_streets',
|
||||
name: 'Streets',
|
||||
urlTemplate: 'https://api.mapbox.com/styles/v1/mapbox/streets-v12/tiles/{z}/{x}/{y}?access_token={api_key}',
|
||||
attribution: '© Mapbox © OpenStreetMap',
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
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 '../widgets/tile_provider_with_cache.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import '../services/offline_area_service.dart';
|
||||
import '../widgets/add_camera_sheet.dart';
|
||||
import 'package:flutter_map_animations/flutter_map_animations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../dev_config.dart';
|
||||
import '../widgets/map_view.dart';
|
||||
|
||||
import '../widgets/add_node_sheet.dart';
|
||||
import '../widgets/edit_node_sheet.dart';
|
||||
import '../widgets/camera_provider_with_cache.dart';
|
||||
import '../services/offline_areas/offline_tile_utils.dart';
|
||||
import '../widgets/download_area_dialog.dart';
|
||||
import '../widgets/measured_sheet.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
@@ -19,31 +20,132 @@ class HomeScreen extends StatefulWidget {
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
|
||||
final MapController _mapController = MapController();
|
||||
bool _followMe = true;
|
||||
final GlobalKey<MapViewState> _mapViewKey = GlobalKey<MapViewState>();
|
||||
late final AnimatedMapController _mapController;
|
||||
bool _editSheetShown = false;
|
||||
|
||||
// Track sheet heights for map padding
|
||||
double _addSheetHeight = 0.0;
|
||||
double _editSheetHeight = 0.0;
|
||||
|
||||
void _openAddCameraSheet() {
|
||||
// Disable follow-me when adding a camera so the map doesn't jump around
|
||||
setState(() => _followMe = false);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_mapController = AnimatedMapController(vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_mapController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _getFollowMeTooltip(FollowMeMode mode) {
|
||||
switch (mode) {
|
||||
case FollowMeMode.off:
|
||||
return 'Enable follow-me (north up)';
|
||||
case FollowMeMode.northUp:
|
||||
return 'Enable follow-me (rotating)';
|
||||
case FollowMeMode.rotating:
|
||||
return 'Disable follow-me';
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getFollowMeIcon(FollowMeMode mode) {
|
||||
switch (mode) {
|
||||
case FollowMeMode.off:
|
||||
return Icons.gps_off;
|
||||
case FollowMeMode.northUp:
|
||||
return Icons.gps_fixed;
|
||||
case FollowMeMode.rotating:
|
||||
return Icons.navigation;
|
||||
}
|
||||
}
|
||||
|
||||
FollowMeMode _getNextFollowMeMode(FollowMeMode mode) {
|
||||
switch (mode) {
|
||||
case FollowMeMode.off:
|
||||
return FollowMeMode.northUp;
|
||||
case FollowMeMode.northUp:
|
||||
return FollowMeMode.rotating;
|
||||
case FollowMeMode.rotating:
|
||||
return FollowMeMode.off;
|
||||
}
|
||||
}
|
||||
|
||||
void _openAddNodeSheet() {
|
||||
final appState = context.read<AppState>();
|
||||
// Disable follow-me when adding a camera so the map doesn't jump around
|
||||
appState.setFollowMeMode(FollowMeMode.off);
|
||||
|
||||
appState.startAddSession();
|
||||
final session = appState.session!; // guaranteed non‑null now
|
||||
|
||||
_scaffoldKey.currentState!.showBottomSheet(
|
||||
(ctx) => AddCameraSheet(session: session),
|
||||
final controller = _scaffoldKey.currentState!.showBottomSheet(
|
||||
(ctx) => MeasuredSheet(
|
||||
onHeightChanged: (height) {
|
||||
setState(() {
|
||||
_addSheetHeight = height;
|
||||
});
|
||||
},
|
||||
child: AddNodeSheet(session: session),
|
||||
),
|
||||
);
|
||||
|
||||
// Reset height when sheet is dismissed
|
||||
controller.closed.then((_) {
|
||||
setState(() {
|
||||
_addSheetHeight = 0.0;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _openEditNodeSheet() {
|
||||
final appState = context.read<AppState>();
|
||||
// Disable follow-me when editing a camera so the map doesn't jump around
|
||||
appState.setFollowMeMode(FollowMeMode.off);
|
||||
|
||||
final session = appState.editSession!; // should be non-null when this is called
|
||||
|
||||
final controller = _scaffoldKey.currentState!.showBottomSheet(
|
||||
(ctx) => MeasuredSheet(
|
||||
onHeightChanged: (height) {
|
||||
setState(() {
|
||||
_editSheetHeight = height;
|
||||
});
|
||||
},
|
||||
child: EditNodeSheet(session: session),
|
||||
),
|
||||
);
|
||||
|
||||
// Reset height when sheet is dismissed
|
||||
controller.closed.then((_) {
|
||||
setState(() {
|
||||
_editSheetHeight = 0.0;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
// Auto-open edit sheet when edit session starts
|
||||
if (appState.editSession != null && !_editSheetShown) {
|
||||
_editSheetShown = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _openEditNodeSheet());
|
||||
} else if (appState.editSession == null) {
|
||||
_editSheetShown = false;
|
||||
}
|
||||
|
||||
// Calculate bottom padding for map (90% of active sheet height)
|
||||
final activeSheetHeight = _addSheetHeight > 0 ? _addSheetHeight : _editSheetHeight;
|
||||
final mapBottomPadding = activeSheetHeight * 0.9;
|
||||
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<TileProviderWithCache>(create: (_) => TileProviderWithCache()),
|
||||
ChangeNotifierProvider<CameraProviderWithCache>(create: (_) => CameraProviderWithCache()),
|
||||
],
|
||||
child: Scaffold(
|
||||
@@ -52,9 +154,18 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
title: const Text('Flock Map'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: _followMe ? 'Disable follow‑me' : 'Enable follow‑me',
|
||||
icon: Icon(_followMe ? Icons.gps_fixed : Icons.gps_off),
|
||||
onPressed: () => setState(() => _followMe = !_followMe),
|
||||
tooltip: _getFollowMeTooltip(appState.followMeMode),
|
||||
icon: Icon(_getFollowMeIcon(appState.followMeMode)),
|
||||
onPressed: () {
|
||||
final oldMode = appState.followMeMode;
|
||||
final newMode = _getNextFollowMeMode(oldMode);
|
||||
debugPrint('[HomeScreen] Follow mode changed: $oldMode → $newMode');
|
||||
appState.setFollowMeMode(newMode);
|
||||
// If enabling follow-me, retry location init in case permission was granted
|
||||
if (newMode != FollowMeMode.off) {
|
||||
_mapViewKey.currentState?.retryLocationInit();
|
||||
}
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings),
|
||||
@@ -65,40 +176,16 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
body: Stack(
|
||||
children: [
|
||||
MapView(
|
||||
key: _mapViewKey,
|
||||
controller: _mapController,
|
||||
followMe: _followMe,
|
||||
followMeMode: appState.followMeMode,
|
||||
bottomPadding: mapBottomPadding,
|
||||
onUserGesture: () {
|
||||
if (_followMe) setState(() => _followMe = false);
|
||||
if (appState.followMeMode != FollowMeMode.off) {
|
||||
appState.setFollowMeMode(FollowMeMode.off);
|
||||
}
|
||||
},
|
||||
),
|
||||
// Zoom buttons
|
||||
Positioned(
|
||||
right: 10,
|
||||
bottom: MediaQuery.of(context).padding.bottom + kBottomButtonBarMargin + 120,
|
||||
child: Column(
|
||||
children: [
|
||||
FloatingActionButton(
|
||||
mini: true,
|
||||
onPressed: () {
|
||||
final currentZoom = _mapController.camera.zoom;
|
||||
_mapController.move(_mapController.camera.center, currentZoom + 0.5);
|
||||
},
|
||||
child: Icon(Icons.add),
|
||||
heroTag: 'zoom_in',
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
FloatingActionButton(
|
||||
mini: true,
|
||||
onPressed: () {
|
||||
final currentZoom = _mapController.camera.zoom;
|
||||
_mapController.move(_mapController.camera.center, currentZoom - 0.5);
|
||||
},
|
||||
child: Icon(Icons.remove),
|
||||
heroTag: 'zoom_out',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Padding(
|
||||
@@ -120,8 +207,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
icon: Icon(Icons.add_location_alt),
|
||||
label: Text('Tag Camera'),
|
||||
onPressed: _openAddCameraSheet,
|
||||
label: Text('Tag Node'),
|
||||
onPressed: _openAddNodeSheet,
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: Size(0, 48),
|
||||
textStyle: TextStyle(fontSize: 16),
|
||||
@@ -135,7 +222,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
label: Text('Download'),
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => DownloadAreaDialog(controller: _mapController),
|
||||
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: Size(0, 48),
|
||||
@@ -155,184 +242,3 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Download area dialog ---
|
||||
class DownloadAreaDialog extends StatefulWidget {
|
||||
final MapController controller;
|
||||
const DownloadAreaDialog({super.key, required this.controller});
|
||||
|
||||
@override
|
||||
State<DownloadAreaDialog> createState() => _DownloadAreaDialogState();
|
||||
}
|
||||
|
||||
class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
|
||||
double _zoom = 15;
|
||||
int? _minZoom;
|
||||
int? _tileCount;
|
||||
double? _mbEstimate;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _recomputeEstimates());
|
||||
}
|
||||
|
||||
void _recomputeEstimates() {
|
||||
var bounds = widget.controller.camera.visibleBounds;
|
||||
// If the visible area is nearly zero, nudge the bounds for estimation
|
||||
const double epsilon = 0.0002;
|
||||
final latSpan = (bounds.north - bounds.south).abs();
|
||||
final lngSpan = (bounds.east - bounds.west).abs();
|
||||
if (latSpan < epsilon && lngSpan < epsilon) {
|
||||
bounds = LatLngBounds(
|
||||
LatLng(bounds.southWest.latitude - epsilon, bounds.southWest.longitude - epsilon),
|
||||
LatLng(bounds.northEast.latitude + epsilon, bounds.northEast.longitude + epsilon)
|
||||
);
|
||||
} else if (latSpan < epsilon) {
|
||||
bounds = LatLngBounds(
|
||||
LatLng(bounds.southWest.latitude - epsilon, bounds.southWest.longitude),
|
||||
LatLng(bounds.northEast.latitude + epsilon, bounds.northEast.longitude)
|
||||
);
|
||||
} else if (lngSpan < epsilon) {
|
||||
bounds = LatLngBounds(
|
||||
LatLng(bounds.southWest.latitude, bounds.southWest.longitude - epsilon),
|
||||
LatLng(bounds.northEast.latitude, bounds.northEast.longitude + epsilon)
|
||||
);
|
||||
}
|
||||
final minZoom = kWorldMaxZoom + 1; // Use world max zoom + 1 for seamless zoom experience
|
||||
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();
|
||||
double sliderMin;
|
||||
double sliderMax;
|
||||
int sliderDivisions;
|
||||
double sliderValue;
|
||||
// Generate slider min/max/divisions with clarity
|
||||
if (_minZoom != null) {
|
||||
sliderMin = _minZoom!.toDouble();
|
||||
} else {
|
||||
sliderMin = 12.0; //fallback
|
||||
}
|
||||
if (_minZoom != null) {
|
||||
final candidateMax = _minZoom! + kMaxUserDownloadZoomSpan;
|
||||
sliderMax = candidateMax > 19 ? 19.0 : candidateMax.toDouble();
|
||||
} else {
|
||||
sliderMax = 19.0; //fallback
|
||||
}
|
||||
if (_minZoom != null) {
|
||||
final candidateMax = _minZoom! + kMaxUserDownloadZoomSpan;
|
||||
int diff = (candidateMax > 19 ? 19 : candidateMax) - _minZoom!;
|
||||
sliderDivisions = diff > 0 ? diff : 1;
|
||||
} else {
|
||||
sliderDivisions = 7; //fallback
|
||||
}
|
||||
sliderValue = _zoom.clamp(sliderMin, sliderMax);
|
||||
// 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: sliderMin,
|
||||
max: sliderMax,
|
||||
divisions: sliderDivisions,
|
||||
label: 'Z${_zoom.toStringAsFixed(0)}',
|
||||
value: sliderValue,
|
||||
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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
158
lib/screens/operator_profile_editor.dart
Normal file
158
lib/screens/operator_profile_editor.dart
Normal file
@@ -0,0 +1,158 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/operator_profile.dart';
|
||||
import '../app_state.dart';
|
||||
|
||||
class OperatorProfileEditor extends StatefulWidget {
|
||||
const OperatorProfileEditor({super.key, required this.profile});
|
||||
|
||||
final OperatorProfile profile;
|
||||
|
||||
@override
|
||||
State<OperatorProfileEditor> createState() => _OperatorProfileEditorState();
|
||||
}
|
||||
|
||||
class _OperatorProfileEditorState extends State<OperatorProfileEditor> {
|
||||
late TextEditingController _nameCtrl;
|
||||
late List<MapEntry<String, String>> _tags;
|
||||
|
||||
static const _defaultTags = [
|
||||
MapEntry('operator', ''),
|
||||
MapEntry('operator:type', ''),
|
||||
MapEntry('operator:wikidata', ''),
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nameCtrl = TextEditingController(text: widget.profile.name);
|
||||
|
||||
if (widget.profile.tags.isEmpty) {
|
||||
// New profile → start with sensible defaults
|
||||
_tags = [..._defaultTags];
|
||||
} else {
|
||||
_tags = widget.profile.tags.entries.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.profile.name.isEmpty ? 'New Operator Profile' : 'Edit Operator Profile'),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
TextField(
|
||||
controller: _nameCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Operator name',
|
||||
hintText: 'e.g., Austin Police Department',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('OSM Tags',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
TextButton.icon(
|
||||
onPressed: () => setState(() => _tags.add(const MapEntry('', ''))),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add tag'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
..._buildTagRows(),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: _save,
|
||||
child: const Text('Save Profile'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildTagRows() {
|
||||
return List.generate(_tags.length, (i) {
|
||||
final keyController = TextEditingController(text: _tags[i].key);
|
||||
final valueController = TextEditingController(text: _tags[i].value);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: TextField(
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'key',
|
||||
border: OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
controller: keyController,
|
||||
onChanged: (v) => _tags[i] = MapEntry(v, _tags[i].value),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: TextField(
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'value',
|
||||
border: OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
controller: valueController,
|
||||
onChanged: (v) => _tags[i] = MapEntry(_tags[i].key, v),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete, color: Colors.red),
|
||||
onPressed: () => setState(() => _tags.removeAt(i)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _save() {
|
||||
final name = _nameCtrl.text.trim();
|
||||
if (name.isEmpty) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(content: Text('Operator name is required')));
|
||||
return;
|
||||
}
|
||||
|
||||
final tagMap = <String, String>{};
|
||||
for (final e in _tags) {
|
||||
if (e.key.trim().isEmpty || e.value.trim().isEmpty) continue;
|
||||
tagMap[e.key.trim()] = e.value.trim();
|
||||
}
|
||||
|
||||
final newProfile = widget.profile.copyWith(
|
||||
id: widget.profile.id.isEmpty ? const Uuid().v4() : widget.profile.id,
|
||||
name: name,
|
||||
tags: tagMap,
|
||||
);
|
||||
|
||||
context.read<AppState>().addOrUpdateOperatorProfile(newProfile);
|
||||
Navigator.pop(context);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Operator profile "${newProfile.name}" saved')),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,13 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/camera_profile.dart';
|
||||
import '../models/node_profile.dart';
|
||||
import '../app_state.dart';
|
||||
|
||||
class ProfileEditor extends StatefulWidget {
|
||||
const ProfileEditor({super.key, required this.profile});
|
||||
|
||||
final CameraProfile profile;
|
||||
final NodeProfile profile;
|
||||
|
||||
@override
|
||||
State<ProfileEditor> createState() => _ProfileEditorState();
|
||||
@@ -17,6 +17,8 @@ class ProfileEditor extends StatefulWidget {
|
||||
class _ProfileEditorState extends State<ProfileEditor> {
|
||||
late TextEditingController _nameCtrl;
|
||||
late List<MapEntry<String, String>> _tags;
|
||||
late bool _requiresDirection;
|
||||
late bool _submittable;
|
||||
|
||||
static const _defaultTags = [
|
||||
MapEntry('man_made', 'surveillance'),
|
||||
@@ -33,6 +35,8 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nameCtrl = TextEditingController(text: widget.profile.name);
|
||||
_requiresDirection = widget.profile.requiresDirection;
|
||||
_submittable = widget.profile.submittable;
|
||||
|
||||
if (widget.profile.tags.isEmpty) {
|
||||
// New profile → start with sensible defaults
|
||||
@@ -52,39 +56,60 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title:
|
||||
Text(widget.profile.name.isEmpty ? 'New Profile' : 'Edit Profile'),
|
||||
title: Text(!widget.profile.editable
|
||||
? 'View Profile'
|
||||
: (widget.profile.name.isEmpty ? 'New Profile' : 'Edit Profile')),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
TextField(
|
||||
controller: _nameCtrl,
|
||||
readOnly: !widget.profile.editable,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Profile name',
|
||||
hintText: 'e.g., Custom ALPR Camera',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('OSM Tags',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
TextButton.icon(
|
||||
onPressed: () => setState(() => _tags.add(const MapEntry('', ''))),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add tag'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (widget.profile.editable) ...[
|
||||
CheckboxListTile(
|
||||
title: const Text('Requires Direction'),
|
||||
subtitle: const Text('Whether cameras of this type need a direction tag'),
|
||||
value: _requiresDirection,
|
||||
onChanged: (value) => setState(() => _requiresDirection = value ?? true),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
CheckboxListTile(
|
||||
title: const Text('Submittable'),
|
||||
subtitle: const Text('Whether this profile can be used for camera submissions'),
|
||||
value: _submittable,
|
||||
onChanged: (value) => setState(() => _submittable = value ?? true),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('OSM Tags',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
if (widget.profile.editable)
|
||||
TextButton.icon(
|
||||
onPressed: () => setState(() => _tags.add(const MapEntry('', ''))),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add tag'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
..._buildTagRows(),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: _save,
|
||||
child: const Text('Save Profile'),
|
||||
),
|
||||
if (widget.profile.editable)
|
||||
ElevatedButton(
|
||||
onPressed: _save,
|
||||
child: const Text('Save Profile'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -108,7 +133,10 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
isDense: true,
|
||||
),
|
||||
controller: keyController,
|
||||
onChanged: (v) => _tags[i] = MapEntry(v, _tags[i].value),
|
||||
readOnly: !widget.profile.editable,
|
||||
onChanged: !widget.profile.editable
|
||||
? null
|
||||
: (v) => _tags[i] = MapEntry(v, _tags[i].value),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -121,13 +149,17 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
isDense: true,
|
||||
),
|
||||
controller: valueController,
|
||||
onChanged: (v) => _tags[i] = MapEntry(_tags[i].key, v),
|
||||
readOnly: !widget.profile.editable,
|
||||
onChanged: !widget.profile.editable
|
||||
? null
|
||||
: (v) => _tags[i] = MapEntry(_tags[i].key, v),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete, color: Colors.red),
|
||||
onPressed: () => setState(() => _tags.removeAt(i)),
|
||||
),
|
||||
if (widget.profile.editable)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete, color: Colors.red),
|
||||
onPressed: () => setState(() => _tags.removeAt(i)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -159,6 +191,9 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
name: name,
|
||||
tags: tagMap,
|
||||
builtin: false,
|
||||
requiresDirection: _requiresDirection,
|
||||
submittable: _submittable,
|
||||
editable: true, // All custom profiles are editable by definition
|
||||
);
|
||||
|
||||
context.read<AppState>().addOrUpdateProfile(newProfile);
|
||||
|
||||
@@ -2,11 +2,13 @@ import 'package:flutter/material.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/operator_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';
|
||||
import 'settings_screen_sections/max_nodes_section.dart';
|
||||
import 'settings_screen_sections/tile_provider_section.dart';
|
||||
|
||||
class SettingsScreen extends StatelessWidget {
|
||||
const SettingsScreen({super.key});
|
||||
@@ -26,7 +28,11 @@ class SettingsScreen extends StatelessWidget {
|
||||
Divider(),
|
||||
ProfileListSection(),
|
||||
Divider(),
|
||||
MaxCamerasSection(),
|
||||
OperatorProfileListSection(),
|
||||
Divider(),
|
||||
MaxNodesSection(),
|
||||
Divider(),
|
||||
TileProviderSection(),
|
||||
Divider(),
|
||||
OfflineModeSection(),
|
||||
Divider(),
|
||||
|
||||
@@ -2,21 +2,21 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../app_state.dart';
|
||||
|
||||
class MaxCamerasSection extends StatefulWidget {
|
||||
const MaxCamerasSection({super.key});
|
||||
class MaxNodesSection extends StatefulWidget {
|
||||
const MaxNodesSection({super.key});
|
||||
|
||||
@override
|
||||
State<MaxCamerasSection> createState() => _MaxCamerasSectionState();
|
||||
State<MaxNodesSection> createState() => _MaxNodesSectionState();
|
||||
}
|
||||
|
||||
class _MaxCamerasSectionState extends State<MaxCamerasSection> {
|
||||
class _MaxNodesSectionState extends State<MaxNodesSection> {
|
||||
late TextEditingController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final maxCameras = context.read<AppState>().maxCameras;
|
||||
_controller = TextEditingController(text: maxCameras.toString());
|
||||
final maxNodes = context.read<AppState>().maxCameras;
|
||||
_controller = TextEditingController(text: maxNodes.toString());
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -29,17 +29,17 @@ class _MaxCamerasSectionState extends State<MaxCamerasSection> {
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
final current = appState.maxCameras;
|
||||
final showWarning = current > 250;
|
||||
final showWarning = current > 1000;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.filter_alt),
|
||||
title: const Text('Max cameras fetched/drawn'),
|
||||
title: const Text('Max nodes fetched/drawn'),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Set an upper limit for the number of cameras on the map (default: 250).'),
|
||||
const Text('Set an upper limit for the number of nodes on the map (default: 250).'),
|
||||
if (showWarning)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
@@ -41,6 +41,7 @@ class _OfflineAreasSectionState extends State<OfflineAreasSection> {
|
||||
: "${(area.sizeBytes / 1024).toStringAsFixed(1)} KB"
|
||||
: '--';
|
||||
String subtitle =
|
||||
'Provider: ${area.tileProviderDisplay}\n' +
|
||||
'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)}';
|
||||
@@ -121,6 +122,10 @@ class _OfflineAreasSectionState extends State<OfflineAreasSection> {
|
||||
name: area.name,
|
||||
onProgress: (progress) {},
|
||||
onComplete: (status) {},
|
||||
tileProviderId: area.tileProviderId,
|
||||
tileProviderName: area.tileProviderName,
|
||||
tileTypeId: area.tileTypeId,
|
||||
tileTypeName: area.tileTypeName,
|
||||
);
|
||||
setState(() {});
|
||||
},
|
||||
|
||||
@@ -1,10 +1,57 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../../services/offline_area_service.dart';
|
||||
|
||||
class OfflineModeSection extends StatelessWidget {
|
||||
const OfflineModeSection({super.key});
|
||||
|
||||
Future<void> _handleOfflineModeChange(BuildContext context, AppState appState, bool value) async {
|
||||
// If enabling offline mode, check for active downloads
|
||||
if (value && !appState.offlineMode) {
|
||||
final offlineService = OfflineAreaService();
|
||||
if (offlineService.hasActiveDownloads) {
|
||||
// Show confirmation dialog
|
||||
final shouldProceed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Row(
|
||||
children: const [
|
||||
Icon(Icons.warning, color: Colors.orange),
|
||||
SizedBox(width: 8),
|
||||
Text('Active Downloads'),
|
||||
],
|
||||
),
|
||||
content: const Text(
|
||||
'Enabling offline mode will cancel any active area downloads. Do you want to continue?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.orange,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('Enable Offline Mode'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (shouldProceed != true) {
|
||||
return; // User cancelled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Proceed with the change
|
||||
await appState.setOfflineMode(value);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
@@ -14,7 +61,7 @@ class OfflineModeSection extends StatelessWidget {
|
||||
subtitle: const Text('Disable all network requests except for local/offline areas.'),
|
||||
trailing: Switch(
|
||||
value: appState.offlineMode,
|
||||
onChanged: (value) async => await appState.setOfflineMode(value),
|
||||
onChanged: (value) => _handleOfflineModeChange(context, appState, value),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../../models/operator_profile.dart';
|
||||
import '../operator_profile_editor.dart';
|
||||
|
||||
class OperatorProfileListSection extends StatelessWidget {
|
||||
const OperatorProfileListSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Operator Profiles', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
TextButton.icon(
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => OperatorProfileEditor(
|
||||
profile: OperatorProfile(
|
||||
id: const Uuid().v4(),
|
||||
name: '',
|
||||
tags: const {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('New Profile'),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (appState.operatorProfiles.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
'No operator profiles defined. Create one to apply operator tags to node submissions.',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
)
|
||||
else
|
||||
...appState.operatorProfiles.map(
|
||||
(p) => ListTile(
|
||||
title: Text(p.name),
|
||||
subtitle: Text('${p.tags.length} tags'),
|
||||
trailing: 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: (_) => OperatorProfileEditor(profile: p),
|
||||
),
|
||||
);
|
||||
} else if (value == 'delete') {
|
||||
_showDeleteProfileDialog(context, p);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _showDeleteProfileDialog(BuildContext context, OperatorProfile profile) {
|
||||
final appState = context.read<AppState>();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Delete Operator 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.deleteOperatorProfile(profile);
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Operator profile deleted')),
|
||||
);
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ 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 '../../models/node_profile.dart';
|
||||
import '../profile_editor.dart';
|
||||
|
||||
class ProfileListSection extends StatelessWidget {
|
||||
@@ -17,13 +17,13 @@ class ProfileListSection extends StatelessWidget {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Camera Profiles', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
const Text('Node Profiles', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
TextButton.icon(
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ProfileEditor(
|
||||
profile: CameraProfile(
|
||||
profile: NodeProfile(
|
||||
id: const Uuid().v4(),
|
||||
name: '',
|
||||
tags: const {},
|
||||
@@ -44,49 +44,74 @@ class ProfileListSection extends StatelessWidget {
|
||||
),
|
||||
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),
|
||||
trailing: !p.editable
|
||||
? PopupMenuButton(
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'view',
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.visibility),
|
||||
SizedBox(width: 8),
|
||||
Text('View'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (value == 'delete') {
|
||||
_showDeleteProfileDialog(context, p);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
onSelected: (value) {
|
||||
if (value == 'view') {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ProfileEditor(profile: p),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
)
|
||||
: 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) {
|
||||
void _showDeleteProfileDialog(BuildContext context, NodeProfile profile) {
|
||||
final appState = context.read<AppState>();
|
||||
showDialog(
|
||||
context: context,
|
||||
|
||||
@@ -1,10 +1,33 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../../state/settings_state.dart';
|
||||
|
||||
class QueueSection extends StatelessWidget {
|
||||
const QueueSection({super.key});
|
||||
|
||||
String _getUploadModeDisplayName(UploadMode mode) {
|
||||
switch (mode) {
|
||||
case UploadMode.production:
|
||||
return 'Production';
|
||||
case UploadMode.sandbox:
|
||||
return 'Sandbox';
|
||||
case UploadMode.simulate:
|
||||
return 'Simulate';
|
||||
}
|
||||
}
|
||||
|
||||
Color _getUploadModeColor(UploadMode mode) {
|
||||
switch (mode) {
|
||||
case UploadMode.production:
|
||||
return Colors.green; // Green for production (real)
|
||||
case UploadMode.sandbox:
|
||||
return Colors.orange; // Orange for sandbox (testing)
|
||||
case UploadMode.simulate:
|
||||
return Colors.grey; // Grey for simulate (fake)
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
@@ -58,75 +81,86 @@ class QueueSection extends StatelessWidget {
|
||||
}
|
||||
|
||||
void _showQueueDialog(BuildContext context) {
|
||||
final appState = context.read<AppState>();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('Upload Queue (${appState.pendingCount} items)'),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
height: 300,
|
||||
child: ListView.builder(
|
||||
itemCount: appState.pendingUploads.length,
|
||||
itemBuilder: (context, index) {
|
||||
final upload = appState.pendingUploads[index];
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
upload.error ? Icons.error : Icons.camera_alt,
|
||||
color: upload.error ? Colors.red : null,
|
||||
),
|
||||
title: Text('Camera ${index + 1}${upload.error ? " (Error)" : ""}'),
|
||||
subtitle: Text(
|
||||
'Lat: ${upload.coord.latitude.toStringAsFixed(6)}\n'
|
||||
'Lon: ${upload.coord.longitude.toStringAsFixed(6)}\n'
|
||||
'Direction: ${upload.direction.round()}°\n'
|
||||
'Attempts: ${upload.attempts}' +
|
||||
(upload.error ? "\nUpload failed. Tap retry to try again." : "")
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (upload.error)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
color: Colors.orange,
|
||||
tooltip: 'Retry upload',
|
||||
onPressed: () {
|
||||
appState.retryUpload(upload);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: () {
|
||||
appState.removeFromQueue(upload);
|
||||
if (appState.pendingCount == 0) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
builder: (context) => Consumer<AppState>(
|
||||
builder: (context, appState, child) => AlertDialog(
|
||||
title: Text('Upload Queue (${appState.pendingCount} items)'),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
height: 300,
|
||||
child: appState.pendingUploads.isEmpty
|
||||
? const Center(child: Text('Queue is empty'))
|
||||
: ListView.builder(
|
||||
itemCount: appState.pendingUploads.length,
|
||||
itemBuilder: (context, index) {
|
||||
final upload = appState.pendingUploads[index];
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
upload.error ? Icons.error : Icons.camera_alt,
|
||||
color: upload.error
|
||||
? Colors.red
|
||||
: _getUploadModeColor(upload.uploadMode),
|
||||
),
|
||||
title: Text('Camera ${index + 1}'
|
||||
'${upload.error ? " (Error)" : ""}'
|
||||
'${upload.completing ? " (Completing...)" : ""}'),
|
||||
subtitle: Text(
|
||||
'Dest: ${_getUploadModeDisplayName(upload.uploadMode)}\n'
|
||||
'Lat: ${upload.coord.latitude.toStringAsFixed(6)}\n'
|
||||
'Lon: ${upload.coord.longitude.toStringAsFixed(6)}\n'
|
||||
'Direction: ${upload.direction.round()}°\n'
|
||||
'Attempts: ${upload.attempts}' +
|
||||
(upload.error ? "\nUpload failed. Tap retry to try again." : "")
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (upload.error && !upload.completing)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
color: Colors.orange,
|
||||
tooltip: 'Retry upload',
|
||||
onPressed: () {
|
||||
appState.retryUpload(upload);
|
||||
},
|
||||
),
|
||||
if (upload.completing)
|
||||
const Icon(Icons.check_circle, color: Colors.green)
|
||||
else
|
||||
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)
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
appState.clearQueue();
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Queue cleared')),
|
||||
);
|
||||
},
|
||||
child: const Text('Clear All'),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
if (appState.pendingCount > 1)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
appState.clearQueue();
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Queue cleared')),
|
||||
);
|
||||
},
|
||||
child: const Text('Clear All'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../app_state.dart';
|
||||
import '../../models/tile_provider.dart';
|
||||
import '../tile_provider_management_screen.dart';
|
||||
|
||||
class TileProviderSection extends StatelessWidget {
|
||||
const TileProviderSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Map Tiles',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const TileProviderManagementScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.settings),
|
||||
label: const Text('Manage Providers'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
415
lib/screens/tile_provider_editor_screen.dart
Normal file
415
lib/screens/tile_provider_editor_screen.dart
Normal file
@@ -0,0 +1,415 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../models/tile_provider.dart';
|
||||
|
||||
class TileProviderEditorScreen extends StatefulWidget {
|
||||
final TileProvider? provider; // null for adding new provider
|
||||
|
||||
const TileProviderEditorScreen({super.key, this.provider});
|
||||
|
||||
@override
|
||||
State<TileProviderEditorScreen> createState() => _TileProviderEditorScreenState();
|
||||
}
|
||||
|
||||
class _TileProviderEditorScreenState extends State<TileProviderEditorScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late final TextEditingController _nameController;
|
||||
late final TextEditingController _apiKeyController;
|
||||
late List<TileType> _tileTypes;
|
||||
|
||||
bool get _isEditing => widget.provider != null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final provider = widget.provider;
|
||||
_nameController = TextEditingController(text: provider?.name ?? '');
|
||||
_apiKeyController = TextEditingController(text: provider?.apiKey ?? '');
|
||||
_tileTypes = provider != null
|
||||
? List.from(provider.tileTypes)
|
||||
: <TileType>[];
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_apiKeyController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_isEditing ? 'Edit Provider' : 'Add Provider'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _saveProvider,
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Provider Name',
|
||||
hintText: 'e.g., Custom Maps Inc.',
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Provider name is required';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _apiKeyController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'API Key (Optional)',
|
||||
hintText: 'Enter API key if required by tile types',
|
||||
),
|
||||
obscureText: true,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Tile Types',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: _addTileType,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add Type'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_tileTypes.isEmpty)
|
||||
const Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text('No tile types configured'),
|
||||
),
|
||||
)
|
||||
else
|
||||
..._tileTypes.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final tileType = entry.value;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
title: Text(tileType.name),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(tileType.urlTemplate),
|
||||
Text(
|
||||
tileType.attribution,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: () => _editTileType(index),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: _tileTypes.length > 1
|
||||
? () => _deleteTileType(index)
|
||||
: null, // Can't delete last tile type
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () => _editTileType(index),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _addTileType() {
|
||||
_showTileTypeDialog();
|
||||
}
|
||||
|
||||
void _editTileType(int index) {
|
||||
_showTileTypeDialog(tileType: _tileTypes[index], index: index);
|
||||
}
|
||||
|
||||
void _deleteTileType(int index) {
|
||||
if (_tileTypes.length <= 1) return;
|
||||
|
||||
final tileTypeToDelete = _tileTypes[index];
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
setState(() {
|
||||
_tileTypes.removeAt(index);
|
||||
});
|
||||
|
||||
// If we're deleting the currently selected tile type, switch to another one
|
||||
if (appState.selectedTileType?.id == tileTypeToDelete.id) {
|
||||
// Find first remaining tile type in this provider or any other provider
|
||||
TileType? replacement;
|
||||
if (_tileTypes.isNotEmpty) {
|
||||
replacement = _tileTypes.first;
|
||||
} else {
|
||||
// Look in other providers
|
||||
for (final provider in appState.tileProviders) {
|
||||
if (provider.availableTileTypes.isNotEmpty) {
|
||||
replacement = provider.availableTileTypes.first;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (replacement != null) {
|
||||
appState.setSelectedTileType(replacement.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showTileTypeDialog({TileType? tileType, int? index}) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => _TileTypeDialog(
|
||||
tileType: tileType,
|
||||
onSave: (newTileType) {
|
||||
setState(() {
|
||||
if (index != null) {
|
||||
_tileTypes[index] = newTileType;
|
||||
} else {
|
||||
_tileTypes.add(newTileType);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _saveProvider() {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
if (_tileTypes.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('At least one tile type is required')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final providerId = widget.provider?.id ?? DateTime.now().millisecondsSinceEpoch.toString();
|
||||
final provider = TileProvider(
|
||||
id: providerId,
|
||||
name: _nameController.text.trim(),
|
||||
apiKey: _apiKeyController.text.trim().isEmpty ? null : _apiKeyController.text.trim(),
|
||||
tileTypes: _tileTypes,
|
||||
);
|
||||
|
||||
context.read<AppState>().addOrUpdateTileProvider(provider);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
class _TileTypeDialog extends StatefulWidget {
|
||||
final TileType? tileType;
|
||||
final Function(TileType) onSave;
|
||||
|
||||
const _TileTypeDialog({
|
||||
required this.onSave,
|
||||
this.tileType,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_TileTypeDialog> createState() => _TileTypeDialogState();
|
||||
}
|
||||
|
||||
class _TileTypeDialogState extends State<_TileTypeDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late final TextEditingController _nameController;
|
||||
late final TextEditingController _urlController;
|
||||
late final TextEditingController _attributionController;
|
||||
Uint8List? _previewTile;
|
||||
bool _isLoadingPreview = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final tileType = widget.tileType;
|
||||
_nameController = TextEditingController(text: tileType?.name ?? '');
|
||||
_urlController = TextEditingController(text: tileType?.urlTemplate ?? '');
|
||||
_attributionController = TextEditingController(text: tileType?.attribution ?? '');
|
||||
_previewTile = tileType?.previewTile;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_urlController.dispose();
|
||||
_attributionController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(widget.tileType != null ? 'Edit Tile Type' : 'Add Tile Type'),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Name',
|
||||
hintText: 'e.g., Satellite',
|
||||
),
|
||||
validator: (value) => value?.trim().isEmpty == true ? 'Name is required' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _urlController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'URL Template',
|
||||
hintText: 'https://example.com/{z}/{x}/{y}.png',
|
||||
),
|
||||
validator: (value) {
|
||||
if (value?.trim().isEmpty == true) return 'URL template is required';
|
||||
if (!value!.contains('{z}') || !value.contains('{x}') || !value.contains('{y}')) {
|
||||
return 'URL must contain {z}, {x}, and {y} placeholders';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _attributionController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Attribution',
|
||||
hintText: '© Map Provider',
|
||||
),
|
||||
validator: (value) => value?.trim().isEmpty == true ? 'Attribution is required' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: _isLoadingPreview ? null : _fetchPreviewTile,
|
||||
icon: _isLoadingPreview
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.preview),
|
||||
label: const Text('Fetch Preview'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (_previewTile != null)
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey),
|
||||
),
|
||||
child: Image.memory(_previewTile!, fit: BoxFit.cover),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _saveTileType,
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _fetchPreviewTile() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
setState(() {
|
||||
_isLoadingPreview = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// Use a sample tile (zoom 10, somewhere in the world)
|
||||
final url = _urlController.text
|
||||
.replaceAll('{z}', '10')
|
||||
.replaceAll('{x}', '512')
|
||||
.replaceAll('{y}', '384');
|
||||
|
||||
final response = await http.get(Uri.parse(url));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
setState(() {
|
||||
_previewTile = response.bodyBytes;
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Preview tile loaded successfully')),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw Exception('HTTP ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to fetch preview: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setState(() {
|
||||
_isLoadingPreview = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _saveTileType() {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
final tileTypeId = widget.tileType?.id ??
|
||||
'${_nameController.text.toLowerCase().replaceAll(' ', '_')}_${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
final tileType = TileType(
|
||||
id: tileTypeId,
|
||||
name: _nameController.text.trim(),
|
||||
urlTemplate: _urlController.text.trim(),
|
||||
attribution: _attributionController.text.trim(),
|
||||
previewTile: _previewTile,
|
||||
);
|
||||
|
||||
widget.onSave(tileType);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
160
lib/screens/tile_provider_management_screen.dart
Normal file
160
lib/screens/tile_provider_management_screen.dart
Normal file
@@ -0,0 +1,160 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../models/tile_provider.dart';
|
||||
import 'tile_provider_editor_screen.dart';
|
||||
|
||||
class TileProviderManagementScreen extends StatelessWidget {
|
||||
const TileProviderManagementScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
final providers = appState.tileProviders;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Tile Providers'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: () => _addProvider(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: providers.isEmpty
|
||||
? const Center(
|
||||
child: Text('No tile providers configured'),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: providers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final provider = providers[index];
|
||||
final isSelected = appState.selectedTileProvider?.id == provider.id;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: ListTile(
|
||||
title: Text(
|
||||
provider.name,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.bold : null,
|
||||
),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${provider.tileTypes.length} tile types'),
|
||||
if (provider.apiKey?.isNotEmpty == true)
|
||||
const Text(
|
||||
'API Key configured',
|
||||
style: TextStyle(
|
||||
fontStyle: FontStyle.italic,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
if (!provider.isUsable)
|
||||
Text(
|
||||
'Needs API key',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.surfaceVariant,
|
||||
child: Icon(
|
||||
Icons.map,
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.onPrimary
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
trailing: providers.length > 1
|
||||
? PopupMenuButton<String>(
|
||||
onSelected: (action) {
|
||||
switch (action) {
|
||||
case 'edit':
|
||||
_editProvider(context, provider);
|
||||
break;
|
||||
case 'delete':
|
||||
_deleteProvider(context, provider);
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.edit),
|
||||
SizedBox(width: 8),
|
||||
Text('Edit'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.delete),
|
||||
SizedBox(width: 8),
|
||||
Text('Delete'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: const Icon(Icons.lock, size: 16), // Can't delete last provider
|
||||
onTap: () => _editProvider(context, provider),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _addProvider(BuildContext context) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const TileProviderEditorScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _editProvider(BuildContext context, TileProvider provider) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => TileProviderEditorScreen(provider: provider),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _deleteProvider(BuildContext context, TileProvider provider) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Delete Provider'),
|
||||
content: Text('Are you sure you want to delete "${provider.name}"?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.read<AppState>().deleteTileProvider(provider.id);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,8 @@ import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Handles PKCE OAuth login with OpenStreetMap.
|
||||
import '../app_state.dart';
|
||||
|
||||
import '../keys.dart';
|
||||
import '../app_state.dart' show UploadMode;
|
||||
|
||||
class AuthService {
|
||||
// Both client IDs from keys.dart
|
||||
@@ -56,7 +55,6 @@ class AuthService {
|
||||
enablePKCE: true,
|
||||
// tokenStorageKey: _tokenKey, // not supported by this package version
|
||||
);
|
||||
print('AuthService: Initialized for $mode with $authBase, clientId $clientId [manual token storage as needed]');
|
||||
}
|
||||
|
||||
Future<bool> isLoggedIn() async {
|
||||
@@ -81,17 +79,14 @@ class AuthService {
|
||||
|
||||
Future<String?> login() async {
|
||||
if (_mode == UploadMode.simulate) {
|
||||
print('AuthService: Simulate login (no OAuth)');
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_displayName = 'Demo User';
|
||||
await prefs.setBool('sim_user_logged_in', true);
|
||||
return _displayName;
|
||||
}
|
||||
try {
|
||||
print('AuthService: Starting OAuth login...');
|
||||
final token = await _helper.getToken();
|
||||
if (token?.accessToken == null) {
|
||||
print('AuthService: OAuth error - token null or missing accessToken');
|
||||
log('OAuth error: token null or missing accessToken');
|
||||
return null;
|
||||
}
|
||||
@@ -102,13 +97,7 @@ class AuthService {
|
||||
final tokenJson = jsonEncode(tokenMap);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_tokenKey, tokenJson); // Save token for current mode
|
||||
print('AuthService: Got access token, fetching username...');
|
||||
_displayName = await _fetchUsername(token!.accessToken!);
|
||||
if (_displayName != null) {
|
||||
print('AuthService: Successfully fetched username: $_displayName');
|
||||
} else {
|
||||
print('AuthService: Failed to fetch username from OSM API');
|
||||
}
|
||||
return _displayName;
|
||||
} catch (e) {
|
||||
print('AuthService: OAuth login failed: $e');
|
||||
@@ -132,7 +121,6 @@ class AuthService {
|
||||
|
||||
// Force a fresh login by clearing stored tokens
|
||||
Future<String?> forceLogin() async {
|
||||
print('AuthService: Forcing fresh login by clearing stored tokens...');
|
||||
await _helper.removeAllTokens();
|
||||
_displayName = null;
|
||||
return await login();
|
||||
@@ -163,37 +151,17 @@ class AuthService {
|
||||
|
||||
Future<String?> _fetchUsername(String accessToken) async {
|
||||
try {
|
||||
print('AuthService: Fetching username from OSM API ($_apiHost) ...');
|
||||
print('AuthService: Access token (first 20 chars): ${accessToken.substring(0, math.min(20, accessToken.length))}...');
|
||||
|
||||
final resp = await http.get(
|
||||
Uri.parse('$_apiHost/api/0.6/user/details.json'),
|
||||
headers: {'Authorization': 'Bearer $accessToken'},
|
||||
);
|
||||
print('AuthService: OSM API response status: ${resp.statusCode}');
|
||||
print('AuthService: Response headers: ${resp.headers}');
|
||||
|
||||
if (resp.statusCode != 200) {
|
||||
print('AuthService: fetchUsername failed with ${resp.statusCode}: ${resp.body}');
|
||||
log('fetchUsername response ${resp.statusCode}: ${resp.body}');
|
||||
|
||||
// Try to get more info about the token by checking permissions endpoint
|
||||
try {
|
||||
print('AuthService: Checking token permissions...');
|
||||
final permResp = await http.get(
|
||||
Uri.parse('$_apiHost/api/0.6/permissions.json'),
|
||||
headers: {'Authorization': 'Bearer $accessToken'},
|
||||
);
|
||||
print('AuthService: Permissions response ${permResp.statusCode}: ${permResp.body}');
|
||||
} catch (e) {
|
||||
print('AuthService: Error checking permissions: $e');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
final userData = jsonDecode(resp.body);
|
||||
final displayName = userData['user']?['display_name'];
|
||||
print('AuthService: Extracted display name: $displayName');
|
||||
return displayName;
|
||||
} catch (e) {
|
||||
print('AuthService: Error fetching username: $e');
|
||||
|
||||
@@ -13,7 +13,23 @@ class CameraCache {
|
||||
/// Add or update a batch of camera nodes in the cache.
|
||||
void addOrUpdate(List<OsmCameraNode> nodes) {
|
||||
for (var node in nodes) {
|
||||
_nodes[node.id] = node;
|
||||
final existing = _nodes[node.id];
|
||||
if (existing != null) {
|
||||
// Preserve any tags starting with underscore when updating existing nodes
|
||||
final mergedTags = Map<String, String>.from(node.tags);
|
||||
for (final entry in existing.tags.entries) {
|
||||
if (entry.key.startsWith('_')) {
|
||||
mergedTags[entry.key] = entry.value;
|
||||
}
|
||||
}
|
||||
_nodes[node.id] = OsmCameraNode(
|
||||
id: node.id,
|
||||
coord: node.coord,
|
||||
tags: mergedTags,
|
||||
);
|
||||
} else {
|
||||
_nodes[node.id] = node;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/camera_profile.dart';
|
||||
import '../models/node_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/nodes_from_overpass.dart';
|
||||
import 'map_data_submodules/tiles_from_remote.dart';
|
||||
import 'map_data_submodules/nodes_from_local.dart';
|
||||
import 'map_data_submodules/tiles_from_local.dart';
|
||||
|
||||
enum MapSource { local, remote, auto } // For future use
|
||||
@@ -30,35 +31,32 @@ class MapDataProvider {
|
||||
AppState.instance.setOfflineMode(enabled);
|
||||
}
|
||||
|
||||
/// Fetch cameras from OSM/Overpass or local storage.
|
||||
/// Fetch surveillance nodes from OSM/Overpass or local storage.
|
||||
/// Remote is default. If source is MapSource.auto, remote is tried first unless offline.
|
||||
Future<List<OsmCameraNode>> getCameras({
|
||||
Future<List<OsmCameraNode>> getNodes({
|
||||
required LatLngBounds bounds,
|
||||
required List<CameraProfile> profiles,
|
||||
required List<NodeProfile> 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.");
|
||||
throw OfflineModeException("Cannot fetch remote nodes in offline mode.");
|
||||
}
|
||||
return camerasFromOverpass(
|
||||
return fetchOverpassNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
pageSize: AppState.instance.maxCameras,
|
||||
fetchAllPages: false,
|
||||
maxResults: AppState.instance.maxCameras,
|
||||
);
|
||||
}
|
||||
|
||||
// Explicit local request: always use local
|
||||
if (source == MapSource.local) {
|
||||
return fetchLocalCameras(
|
||||
return fetchLocalNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
);
|
||||
@@ -66,50 +64,49 @@ class MapDataProvider {
|
||||
|
||||
// AUTO: default = remote first, fallback to local only if offline
|
||||
if (offline) {
|
||||
return fetchLocalCameras(
|
||||
return fetchLocalNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
maxNodes: AppState.instance.maxCameras,
|
||||
);
|
||||
} else {
|
||||
// Try remote, fallback to local ONLY if remote throws (optional, could be removed for stricter behavior)
|
||||
try {
|
||||
return await camerasFromOverpass(
|
||||
return await fetchOverpassNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
pageSize: AppState.instance.maxCameras,
|
||||
maxResults: 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,
|
||||
);
|
||||
debugPrint('[MapDataProvider] Remote node fetch failed, error: $e. Falling back to local.');
|
||||
return fetchLocalNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
maxNodes: AppState.instance.maxCameras,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bulk/paged camera fetch for offline downloads (handling paging, dedup, and Overpass retries)
|
||||
/// Bulk/paged node fetch for offline downloads (handling paging, dedup, and Overpass retries)
|
||||
/// Only use for offline area download, not for map browsing! Ignores maxCameras config.
|
||||
Future<List<OsmCameraNode>> getAllCamerasForDownload({
|
||||
Future<List<OsmCameraNode>> getAllNodesForDownload({
|
||||
required LatLngBounds bounds,
|
||||
required List<CameraProfile> profiles,
|
||||
required List<NodeProfile> 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.");
|
||||
throw OfflineModeException("Cannot fetch remote nodes for offline area download in offline mode.");
|
||||
}
|
||||
return camerasFromOverpass(
|
||||
return fetchOverpassNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
fetchAllPages: true,
|
||||
pageSize: pageSize,
|
||||
maxTries: maxTries,
|
||||
maxResults: pageSize,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -121,15 +118,13 @@ class MapDataProvider {
|
||||
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);
|
||||
return _fetchRemoteTileFromCurrentProvider(z, x, y);
|
||||
}
|
||||
|
||||
// Explicitly local
|
||||
@@ -142,10 +137,30 @@ class MapDataProvider {
|
||||
return await fetchLocalTile(z: z, x: x, y: y);
|
||||
} catch (_) {
|
||||
if (!offline) {
|
||||
return fetchOSMTile(z: z, x: x, y: y);
|
||||
return _fetchRemoteTileFromCurrentProvider(z, x, y);
|
||||
} else {
|
||||
throw OfflineModeException("Tile $z/$x/$y not found in offline areas and offline mode is enabled.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch remote tile using current provider from AppState
|
||||
Future<List<int>> _fetchRemoteTileFromCurrentProvider(int z, int x, int y) async {
|
||||
final appState = AppState.instance;
|
||||
final selectedTileType = appState.selectedTileType;
|
||||
final selectedProvider = appState.selectedTileProvider;
|
||||
|
||||
// We guarantee that a provider and tile type are always selected
|
||||
if (selectedTileType == null || selectedProvider == null) {
|
||||
throw Exception('No tile provider selected - this should never happen');
|
||||
}
|
||||
|
||||
final tileUrl = selectedTileType.getTileUrl(z, x, y, apiKey: selectedProvider.apiKey);
|
||||
return fetchRemoteTile(z: z, x: x, y: y, url: tileUrl);
|
||||
}
|
||||
|
||||
/// Clear any queued tile requests (call when map view changes significantly)
|
||||
void clearTileQueue() {
|
||||
clearRemoteTileQueue();
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
|
||||
import '../../models/camera_profile.dart';
|
||||
import '../../models/osm_camera_node.dart';
|
||||
import '../../app_state.dart';
|
||||
|
||||
/// Fetches cameras from the Overpass OSM API for the given bounds and profiles.
|
||||
/// If fetchAllPages is true, returns all possible cameras using multiple API calls (paging with pageSize).
|
||||
/// If false (the default), returns only the first page of up to pageSize results.
|
||||
Future<List<OsmCameraNode>> camerasFromOverpass({
|
||||
required LatLngBounds bounds,
|
||||
required List<CameraProfile> profiles,
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
int pageSize = 500, // Used for both default limit and paging chunk
|
||||
bool fetchAllPages = false, // True for offline area download, else just grabs first chunk
|
||||
int maxTries = 3,
|
||||
}) async {
|
||||
if (profiles.isEmpty) return [];
|
||||
const String prodEndpoint = 'https://overpass-api.de/api/interpreter';
|
||||
|
||||
final nodeClauses = profiles.map((profile) {
|
||||
final tagFilters = profile.tags.entries
|
||||
.map((e) => '["${e.key}"="${e.value}"]')
|
||||
.join('\n ');
|
||||
return '''node\n $tagFilters\n (${bounds.southWest.latitude},${bounds.southWest.longitude},\n ${bounds.northEast.latitude},${bounds.northEast.longitude});''';
|
||||
}).join('\n ');
|
||||
|
||||
// Helper for one Overpass chunk fetch
|
||||
Future<List<OsmCameraNode>> fetchChunk() async {
|
||||
final outLine = fetchAllPages ? 'out body;' : 'out body $pageSize;';
|
||||
final query = '''
|
||||
[out:json][timeout:25];
|
||||
(
|
||||
$nodeClauses
|
||||
);
|
||||
$outLine
|
||||
''';
|
||||
try {
|
||||
print('[camerasFromOverpass] Querying Overpass...');
|
||||
print('[camerasFromOverpass] Query:\n$query');
|
||||
final resp = await http.post(Uri.parse(prodEndpoint), body: {'data': query.trim()});
|
||||
print('[camerasFromOverpass] Status: ${resp.statusCode}, Length: ${resp.body.length}');
|
||||
if (resp.statusCode != 200) {
|
||||
print('[camerasFromOverpass] Overpass failed: ${resp.body}');
|
||||
return [];
|
||||
}
|
||||
final data = jsonDecode(resp.body) as Map<String, dynamic>;
|
||||
final elements = data['elements'] as List<dynamic>;
|
||||
print('[camerasFromOverpass] Retrieved elements: ${elements.length}');
|
||||
return elements.whereType<Map<String, dynamic>>().map((e) {
|
||||
return OsmCameraNode(
|
||||
id: e['id'],
|
||||
coord: LatLng(e['lat'], e['lon']),
|
||||
tags: Map<String, String>.from(e['tags'] ?? {}),
|
||||
);
|
||||
}).toList();
|
||||
} catch (e) {
|
||||
print('[camerasFromOverpass] Overpass exception: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// All paths just use a single fetch now; paging logic no longer required.
|
||||
return await fetchChunk();
|
||||
}
|
||||
@@ -3,15 +3,15 @@ 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 '../../models/node_profile.dart';
|
||||
import '../offline_area_service.dart';
|
||||
import '../offline_areas/offline_area_models.dart';
|
||||
|
||||
/// Fetch camera nodes from all offline areas intersecting the bounds/profile list.
|
||||
Future<List<OsmCameraNode>> fetchLocalCameras({
|
||||
/// Fetch surveillance nodes from all offline areas intersecting the bounds/profile list.
|
||||
Future<List<OsmCameraNode>> fetchLocalNodes({
|
||||
required LatLngBounds bounds,
|
||||
required List<CameraProfile> profiles,
|
||||
int? maxCameras,
|
||||
required List<NodeProfile> profiles,
|
||||
int? maxNodes,
|
||||
}) async {
|
||||
final areas = OfflineAreaService().offlineAreas;
|
||||
final Map<int, OsmCameraNode> deduped = {};
|
||||
@@ -20,24 +20,24 @@ Future<List<OsmCameraNode>> fetchLocalCameras({
|
||||
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;
|
||||
final nodes = await _loadAreaNodes(area);
|
||||
for (final node in nodes) {
|
||||
// Deduplicate by node ID, preferring the first occurrence
|
||||
if (deduped.containsKey(node.id)) continue;
|
||||
// Within view bounds?
|
||||
if (!_pointInBounds(cam.coord, bounds)) continue;
|
||||
if (!_pointInBounds(node.coord, bounds)) continue;
|
||||
// Profile filter if used
|
||||
if (profiles.isNotEmpty && !_matchesAnyProfile(cam, profiles)) continue;
|
||||
deduped[cam.id] = cam;
|
||||
if (profiles.isNotEmpty && !_matchesAnyProfile(node, profiles)) continue;
|
||||
deduped[node.id] = node;
|
||||
}
|
||||
}
|
||||
|
||||
final out = deduped.values.take(maxCameras ?? deduped.length).toList();
|
||||
final out = deduped.values.take(maxNodes ?? deduped.length).toList();
|
||||
return out;
|
||||
}
|
||||
|
||||
// Try in-memory first, else load from disk
|
||||
Future<List<OsmCameraNode>> _loadAreaCameras(OfflineArea area) async {
|
||||
Future<List<OsmCameraNode>> _loadAreaNodes(OfflineArea area) async {
|
||||
if (area.cameras.isNotEmpty) {
|
||||
return area.cameras;
|
||||
}
|
||||
@@ -57,16 +57,16 @@ bool _pointInBounds(LatLng pt, LatLngBounds bounds) {
|
||||
pt.longitude <= bounds.northEast.longitude;
|
||||
}
|
||||
|
||||
bool _matchesAnyProfile(OsmCameraNode cam, List<CameraProfile> profiles) {
|
||||
bool _matchesAnyProfile(OsmCameraNode node, List<NodeProfile> profiles) {
|
||||
for (final prof in profiles) {
|
||||
if (_cameraMatchesProfile(cam, prof)) return true;
|
||||
if (_nodeMatchesProfile(node, prof)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _cameraMatchesProfile(OsmCameraNode cam, CameraProfile profile) {
|
||||
bool _nodeMatchesProfile(OsmCameraNode node, NodeProfile profile) {
|
||||
for (final e in profile.tags.entries) {
|
||||
if (cam.tags[e.key] != e.value) return false; // All profile tags must match
|
||||
if (node.tags[e.key] != e.value) return false; // All profile tags must match
|
||||
}
|
||||
return true;
|
||||
}
|
||||
92
lib/services/map_data_submodules/nodes_from_overpass.dart
Normal file
92
lib/services/map_data_submodules/nodes_from_overpass.dart
Normal file
@@ -0,0 +1,92 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
|
||||
import '../../models/node_profile.dart';
|
||||
import '../../models/osm_camera_node.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../network_status.dart';
|
||||
|
||||
/// Fetches surveillance nodes from the Overpass OSM API for the given bounds and profiles.
|
||||
Future<List<OsmCameraNode>> fetchOverpassNodes({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
required int maxResults,
|
||||
}) async {
|
||||
if (profiles.isEmpty) return [];
|
||||
|
||||
const String overpassEndpoint = 'https://overpass-api.de/api/interpreter';
|
||||
|
||||
// Build the Overpass query
|
||||
final query = _buildOverpassQuery(bounds, profiles, maxResults);
|
||||
|
||||
try {
|
||||
debugPrint('[fetchOverpassNodes] Querying Overpass for surveillance nodes...');
|
||||
debugPrint('[fetchOverpassNodes] Query:\n$query');
|
||||
|
||||
final response = await http.post(
|
||||
Uri.parse(overpassEndpoint),
|
||||
body: {'data': query.trim()}
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
debugPrint('[fetchOverpassNodes] Overpass API error: ${response.body}');
|
||||
NetworkStatus.instance.reportOverpassIssue();
|
||||
return [];
|
||||
}
|
||||
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final elements = data['elements'] as List<dynamic>;
|
||||
|
||||
if (elements.length > 20) {
|
||||
debugPrint('[fetchOverpassNodes] Retrieved ${elements.length} surveillance nodes');
|
||||
}
|
||||
|
||||
NetworkStatus.instance.reportOverpassSuccess();
|
||||
|
||||
return elements.whereType<Map<String, dynamic>>().map((element) {
|
||||
return OsmCameraNode(
|
||||
id: element['id'],
|
||||
coord: LatLng(element['lat'], element['lon']),
|
||||
tags: Map<String, String>.from(element['tags'] ?? {}),
|
||||
);
|
||||
}).toList();
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[fetchOverpassNodes] Exception: $e');
|
||||
|
||||
// Report network issues for connection errors
|
||||
if (e.toString().contains('Connection refused') ||
|
||||
e.toString().contains('Connection timed out') ||
|
||||
e.toString().contains('Connection reset')) {
|
||||
NetworkStatus.instance.reportOverpassIssue();
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds an Overpass API query for surveillance nodes matching the given profiles within bounds.
|
||||
String _buildOverpassQuery(LatLngBounds bounds, List<NodeProfile> profiles, int maxResults) {
|
||||
// Build node clauses for each profile
|
||||
final nodeClauses = profiles.map((profile) {
|
||||
// Convert profile tags to Overpass filter format
|
||||
final tagFilters = profile.tags.entries
|
||||
.map((entry) => '["${entry.key}"="${entry.value}"]')
|
||||
.join();
|
||||
|
||||
// Build the node query with tag filters and bounding box
|
||||
return 'node$tagFilters(${bounds.southWest.latitude},${bounds.southWest.longitude},${bounds.northEast.latitude},${bounds.northEast.longitude});';
|
||||
}).join('\n ');
|
||||
|
||||
return '''
|
||||
[out:json][timeout:25];
|
||||
(
|
||||
$nodeClauses
|
||||
);
|
||||
out body $maxResults;
|
||||
''';
|
||||
}
|
||||
@@ -3,15 +3,25 @@ import 'package:latlong2/latlong.dart';
|
||||
import '../offline_area_service.dart';
|
||||
import '../offline_areas/offline_area_models.dart';
|
||||
import '../offline_areas/offline_tile_utils.dart';
|
||||
import '../../app_state.dart';
|
||||
|
||||
/// Fetch a tile from the newest offline area that plausibly contains it, or throw if not found.
|
||||
/// Fetch a tile from the newest offline area that matches the current provider, or throw if not found.
|
||||
Future<List<int>> fetchLocalTile({required int z, required int x, required int y}) async {
|
||||
final areas = OfflineAreaService().offlineAreas;
|
||||
final appState = AppState.instance;
|
||||
final currentProvider = appState.selectedTileProvider;
|
||||
final currentTileType = appState.selectedTileType;
|
||||
|
||||
final offlineService = OfflineAreaService();
|
||||
await offlineService.ensureInitialized();
|
||||
final areas = offlineService.offlineAreas;
|
||||
final List<_AreaTileMatch> candidates = [];
|
||||
|
||||
for (final area in areas) {
|
||||
if (area.status != OfflineAreaStatus.complete) continue;
|
||||
if (z < area.minZoom || z > area.maxZoom) continue;
|
||||
|
||||
// Only consider areas that match the current provider/type
|
||||
if (area.tileProviderId != currentProvider?.id || area.tileTypeId != currentTileType?.id) continue;
|
||||
|
||||
// Get tile coverage for area at this zoom only
|
||||
final coveredTiles = computeTileList(area.bounds, z, z);
|
||||
@@ -26,7 +36,7 @@ Future<List<int>> fetchLocalTile({required int z, required int x, required int y
|
||||
}
|
||||
}
|
||||
if (candidates.isEmpty) {
|
||||
throw Exception('Tile $z/$x/$y not found in any offline area');
|
||||
throw Exception('Tile $z/$x/$y from current provider ${currentProvider?.id}/${currentTileType?.id} not found in any offline area');
|
||||
}
|
||||
candidates.sort((a, b) => b.modified.compareTo(a.modified)); // newest first
|
||||
return await candidates.first.file.readAsBytes();
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import 'dart:math';
|
||||
import 'dart:io';
|
||||
import 'dart:async';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flock_map_app/dev_config.dart';
|
||||
|
||||
/// Global semaphore to limit simultaneous tile fetches
|
||||
final _tileFetchSemaphore = _SimpleSemaphore(4); // Max 4 concurrent
|
||||
|
||||
/// Fetches a tile from OSM, with in-memory retries/backoff, and global concurrency limit.
|
||||
/// Returns tile image bytes, or throws on persistent failure.
|
||||
Future<List<int>> fetchOSMTile({
|
||||
required int z,
|
||||
required int x,
|
||||
required int y,
|
||||
}) async {
|
||||
final url = 'https://tile.openstreetmap.org/$z/$x/$y.png';
|
||||
const int maxAttempts = kTileFetchMaxAttempts;
|
||||
int attempt = 0;
|
||||
final random = Random();
|
||||
final delays = [
|
||||
kTileFetchInitialDelayMs + random.nextInt(kTileFetchJitter1Ms),
|
||||
kTileFetchSecondDelayMs + random.nextInt(kTileFetchJitter2Ms),
|
||||
kTileFetchThirdDelayMs + random.nextInt(kTileFetchJitter3Ms),
|
||||
];
|
||||
while (true) {
|
||||
await _tileFetchSemaphore.acquire();
|
||||
try {
|
||||
print('[fetchOSMTile] FETCH $z/$x/$y');
|
||||
attempt++;
|
||||
final resp = await http.get(Uri.parse(url));
|
||||
print('[fetchOSMTile] HTTP ${resp.statusCode} for $z/$x/$y, length=${resp.bodyBytes.length}');
|
||||
if (resp.statusCode == 200 && resp.bodyBytes.isNotEmpty) {
|
||||
print('[fetchOSMTile] SUCCESS $z/$x/$y');
|
||||
return resp.bodyBytes;
|
||||
} else {
|
||||
print('[fetchOSMTile] FAIL $z/$x/$y: code=${resp.statusCode}, bytes=${resp.bodyBytes.length}');
|
||||
throw HttpException('Failed to fetch tile $z/$x/$y: status ${resp.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
print('[fetchOSMTile] Exception $z/$x/$y: $e');
|
||||
if (attempt >= maxAttempts) {
|
||||
print("[fetchOSMTile] Failed for $z/$x/$y after $attempt attempts: $e");
|
||||
rethrow;
|
||||
}
|
||||
final delay = delays[attempt - 1].clamp(0, 60000);
|
||||
print("[fetchOSMTile] Attempt $attempt for $z/$x/$y failed: $e. Retrying in ${delay}ms.");
|
||||
await Future.delayed(Duration(milliseconds: delay));
|
||||
} finally {
|
||||
_tileFetchSemaphore.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple counting semaphore, suitable for single-thread Flutter concurrency
|
||||
class _SimpleSemaphore {
|
||||
final int _max;
|
||||
int _current = 0;
|
||||
final List<VoidCallback> _queue = [];
|
||||
_SimpleSemaphore(this._max);
|
||||
|
||||
Future<void> acquire() async {
|
||||
if (_current < _max) {
|
||||
_current++;
|
||||
return;
|
||||
} else {
|
||||
final c = Completer<void>();
|
||||
_queue.add(() => c.complete());
|
||||
await c.future;
|
||||
}
|
||||
}
|
||||
|
||||
void release() {
|
||||
if (_queue.isNotEmpty) {
|
||||
final callback = _queue.removeAt(0);
|
||||
callback();
|
||||
} else {
|
||||
_current--;
|
||||
}
|
||||
}
|
||||
}
|
||||
133
lib/services/map_data_submodules/tiles_from_remote.dart
Normal file
133
lib/services/map_data_submodules/tiles_from_remote.dart
Normal file
@@ -0,0 +1,133 @@
|
||||
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';
|
||||
import '../network_status.dart';
|
||||
|
||||
/// Global semaphore to limit simultaneous tile fetches
|
||||
final _tileFetchSemaphore = _SimpleSemaphore(4); // Max 4 concurrent
|
||||
|
||||
/// Clear queued tile requests when map view changes significantly
|
||||
void clearRemoteTileQueue() {
|
||||
final clearedCount = _tileFetchSemaphore.clearQueue();
|
||||
// Only log if we actually cleared something significant
|
||||
if (clearedCount > 5) {
|
||||
debugPrint('[RemoteTiles] Cleared $clearedCount queued tile requests');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Fetches a tile from any remote provider, with in-memory retries/backoff, and global concurrency limit.
|
||||
/// Returns tile image bytes, or throws on persistent failure.
|
||||
Future<List<int>> fetchRemoteTile({
|
||||
required int z,
|
||||
required int x,
|
||||
required int y,
|
||||
required String url,
|
||||
}) async {
|
||||
const int maxAttempts = kTileFetchMaxAttempts;
|
||||
int attempt = 0;
|
||||
final random = Random();
|
||||
final delays = [
|
||||
kTileFetchInitialDelayMs + random.nextInt(kTileFetchJitter1Ms),
|
||||
kTileFetchSecondDelayMs + random.nextInt(kTileFetchJitter2Ms),
|
||||
kTileFetchThirdDelayMs + random.nextInt(kTileFetchJitter3Ms),
|
||||
];
|
||||
|
||||
final hostInfo = Uri.parse(url).host; // For logging
|
||||
|
||||
while (true) {
|
||||
await _tileFetchSemaphore.acquire();
|
||||
try {
|
||||
// Only log on first attempt or errors
|
||||
if (attempt == 1) {
|
||||
debugPrint('[fetchRemoteTile] Fetching $z/$x/$y from $hostInfo');
|
||||
}
|
||||
attempt++;
|
||||
final resp = await http.get(Uri.parse(url));
|
||||
|
||||
if (resp.statusCode == 200 && resp.bodyBytes.isNotEmpty) {
|
||||
// Success - no logging for normal operation
|
||||
NetworkStatus.instance.reportOsmTileSuccess(); // Generic tile server reporting
|
||||
return resp.bodyBytes;
|
||||
} else {
|
||||
debugPrint('[fetchRemoteTile] FAIL $z/$x/$y from $hostInfo: code=${resp.statusCode}, bytes=${resp.bodyBytes.length}');
|
||||
NetworkStatus.instance.reportOsmTileIssue(); // Generic tile server reporting
|
||||
throw HttpException('Failed to fetch tile $z/$x/$y from $hostInfo: status ${resp.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
// Report network issues on connection errors
|
||||
if (e.toString().contains('Connection refused') ||
|
||||
e.toString().contains('Connection timed out') ||
|
||||
e.toString().contains('Connection reset')) {
|
||||
NetworkStatus.instance.reportOsmTileIssue(); // Generic tile server reporting
|
||||
}
|
||||
|
||||
if (attempt >= maxAttempts) {
|
||||
debugPrint("[fetchRemoteTile] Failed for $z/$x/$y from $hostInfo after $attempt attempts: $e");
|
||||
rethrow;
|
||||
}
|
||||
|
||||
final delay = delays[attempt - 1].clamp(0, 60000);
|
||||
if (attempt == 1) {
|
||||
debugPrint("[fetchRemoteTile] Attempt $attempt for $z/$x/$y from $hostInfo failed: $e. Retrying in ${delay}ms.");
|
||||
}
|
||||
await Future.delayed(Duration(milliseconds: delay));
|
||||
} finally {
|
||||
_tileFetchSemaphore.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy function for backward compatibility
|
||||
@Deprecated('Use fetchRemoteTile instead')
|
||||
Future<List<int>> fetchOSMTile({
|
||||
required int z,
|
||||
required int x,
|
||||
required int y,
|
||||
}) async {
|
||||
return fetchRemoteTile(
|
||||
z: z,
|
||||
x: x,
|
||||
y: y,
|
||||
url: 'https://tile.openstreetmap.org/$z/$x/$y.png',
|
||||
);
|
||||
}
|
||||
|
||||
/// Simple counting semaphore, suitable for single-thread Flutter concurrency
|
||||
class _SimpleSemaphore {
|
||||
final int _max;
|
||||
int _current = 0;
|
||||
final List<VoidCallback> _queue = [];
|
||||
_SimpleSemaphore(this._max);
|
||||
|
||||
Future<void> acquire() async {
|
||||
if (_current < _max) {
|
||||
_current++;
|
||||
return;
|
||||
} else {
|
||||
final c = Completer<void>();
|
||||
_queue.add(() => c.complete());
|
||||
await c.future;
|
||||
}
|
||||
}
|
||||
|
||||
void release() {
|
||||
if (_queue.isNotEmpty) {
|
||||
final callback = _queue.removeAt(0);
|
||||
callback();
|
||||
} else {
|
||||
_current--;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all queued requests (call when view changes significantly)
|
||||
int clearQueue() {
|
||||
final clearedCount = _queue.length;
|
||||
_queue.clear();
|
||||
return clearedCount;
|
||||
}
|
||||
}
|
||||
217
lib/services/network_status.dart
Normal file
217
lib/services/network_status.dart
Normal file
@@ -0,0 +1,217 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:async';
|
||||
|
||||
import '../app_state.dart';
|
||||
|
||||
enum NetworkIssueType { osmTiles, overpassApi, both }
|
||||
enum NetworkStatusType { waiting, issues, timedOut, noData, ready, success }
|
||||
|
||||
class NetworkStatus extends ChangeNotifier {
|
||||
static final NetworkStatus instance = NetworkStatus._();
|
||||
NetworkStatus._();
|
||||
|
||||
bool _osmTilesHaveIssues = false;
|
||||
bool _overpassHasIssues = false;
|
||||
bool _isWaitingForData = false;
|
||||
bool _isTimedOut = false;
|
||||
bool _hasNoData = false;
|
||||
bool _hasSuccess = false;
|
||||
int _recentOfflineMisses = 0;
|
||||
Timer? _osmRecoveryTimer;
|
||||
Timer? _overpassRecoveryTimer;
|
||||
Timer? _waitingTimer;
|
||||
Timer? _noDataResetTimer;
|
||||
Timer? _successResetTimer;
|
||||
|
||||
// Getters
|
||||
bool get hasAnyIssues => _osmTilesHaveIssues || _overpassHasIssues;
|
||||
bool get osmTilesHaveIssues => _osmTilesHaveIssues;
|
||||
bool get overpassHasIssues => _overpassHasIssues;
|
||||
bool get isWaitingForData => _isWaitingForData;
|
||||
bool get isTimedOut => _isTimedOut;
|
||||
bool get hasNoData => _hasNoData;
|
||||
bool get hasSuccess => _hasSuccess;
|
||||
|
||||
NetworkStatusType get currentStatus {
|
||||
if (hasAnyIssues) return NetworkStatusType.issues;
|
||||
if (_isWaitingForData) return NetworkStatusType.waiting;
|
||||
if (_isTimedOut) return NetworkStatusType.timedOut;
|
||||
if (_hasNoData) return NetworkStatusType.noData;
|
||||
if (_hasSuccess) return NetworkStatusType.success;
|
||||
return NetworkStatusType.ready;
|
||||
}
|
||||
|
||||
NetworkIssueType? get currentIssueType {
|
||||
if (_osmTilesHaveIssues && _overpassHasIssues) return NetworkIssueType.both;
|
||||
if (_osmTilesHaveIssues) return NetworkIssueType.osmTiles;
|
||||
if (_overpassHasIssues) return NetworkIssueType.overpassApi;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Report tile server issues (for any provider)
|
||||
void reportOsmTileIssue() {
|
||||
if (!_osmTilesHaveIssues) {
|
||||
_osmTilesHaveIssues = true;
|
||||
notifyListeners();
|
||||
debugPrint('[NetworkStatus] Tile server issues detected');
|
||||
}
|
||||
|
||||
// Reset recovery timer - if we keep getting errors, keep showing indicator
|
||||
_osmRecoveryTimer?.cancel();
|
||||
_osmRecoveryTimer = Timer(const Duration(minutes: 2), () {
|
||||
_osmTilesHaveIssues = false;
|
||||
notifyListeners();
|
||||
debugPrint('[NetworkStatus] Tile server issues cleared');
|
||||
});
|
||||
}
|
||||
|
||||
/// Report Overpass API issues
|
||||
void reportOverpassIssue() {
|
||||
if (!_overpassHasIssues) {
|
||||
_overpassHasIssues = true;
|
||||
notifyListeners();
|
||||
debugPrint('[NetworkStatus] Overpass API issues detected');
|
||||
}
|
||||
|
||||
// Reset recovery timer
|
||||
_overpassRecoveryTimer?.cancel();
|
||||
_overpassRecoveryTimer = Timer(const Duration(minutes: 2), () {
|
||||
_overpassHasIssues = false;
|
||||
notifyListeners();
|
||||
debugPrint('[NetworkStatus] Overpass API issues cleared');
|
||||
});
|
||||
}
|
||||
|
||||
/// Report successful operations to potentially clear issues faster
|
||||
void reportOsmTileSuccess() {
|
||||
// Clear issues immediately on success (they were likely temporary)
|
||||
if (_osmTilesHaveIssues) {
|
||||
// Quietly clear - don't log routine success
|
||||
_osmTilesHaveIssues = false;
|
||||
_osmRecoveryTimer?.cancel();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void reportOverpassSuccess() {
|
||||
if (_overpassHasIssues) {
|
||||
// Quietly clear - don't log routine success
|
||||
_overpassHasIssues = false;
|
||||
_overpassRecoveryTimer?.cancel();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Set waiting status (show when loading tiles/cameras)
|
||||
void setWaiting() {
|
||||
// Clear any previous timeout/no-data state when starting new wait
|
||||
_isTimedOut = false;
|
||||
_hasNoData = false;
|
||||
_recentOfflineMisses = 0;
|
||||
_noDataResetTimer?.cancel();
|
||||
|
||||
if (!_isWaitingForData) {
|
||||
_isWaitingForData = true;
|
||||
notifyListeners();
|
||||
// Don't log routine waiting - only log if we stay waiting too long
|
||||
}
|
||||
|
||||
// Set timeout for genuine network issues (not 404s)
|
||||
_waitingTimer?.cancel();
|
||||
_waitingTimer = Timer(const Duration(seconds: 8), () {
|
||||
_isWaitingForData = false;
|
||||
_isTimedOut = true;
|
||||
debugPrint('[NetworkStatus] Request timed out - likely network issues');
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
/// Show success status briefly when data loads
|
||||
void setSuccess() {
|
||||
_isWaitingForData = false;
|
||||
_isTimedOut = false;
|
||||
_hasNoData = false;
|
||||
_hasSuccess = true;
|
||||
_recentOfflineMisses = 0;
|
||||
_waitingTimer?.cancel();
|
||||
_noDataResetTimer?.cancel();
|
||||
notifyListeners();
|
||||
|
||||
// Auto-clear success status after 2 seconds
|
||||
_successResetTimer?.cancel();
|
||||
_successResetTimer = Timer(const Duration(seconds: 2), () {
|
||||
if (_hasSuccess) {
|
||||
_hasSuccess = false;
|
||||
notifyListeners();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Show no-data status briefly when tiles aren't available
|
||||
void setNoData() {
|
||||
_isWaitingForData = false;
|
||||
_isTimedOut = false;
|
||||
_hasSuccess = false;
|
||||
_hasNoData = true;
|
||||
_waitingTimer?.cancel();
|
||||
_successResetTimer?.cancel();
|
||||
notifyListeners();
|
||||
|
||||
// Auto-clear no-data status after 2 seconds
|
||||
_noDataResetTimer?.cancel();
|
||||
_noDataResetTimer = Timer(const Duration(seconds: 2), () {
|
||||
if (_hasNoData) {
|
||||
_hasNoData = false;
|
||||
notifyListeners();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Clear waiting/timeout/no-data status (legacy method for compatibility)
|
||||
void clearWaiting() {
|
||||
if (_isWaitingForData || _isTimedOut || _hasNoData || _hasSuccess) {
|
||||
_isWaitingForData = false;
|
||||
_isTimedOut = false;
|
||||
_hasNoData = false;
|
||||
_hasSuccess = false;
|
||||
_recentOfflineMisses = 0;
|
||||
_waitingTimer?.cancel();
|
||||
_noDataResetTimer?.cancel();
|
||||
_successResetTimer?.cancel();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Report that a tile was not available offline
|
||||
void reportOfflineMiss() {
|
||||
_recentOfflineMisses++;
|
||||
debugPrint('[NetworkStatus] Offline miss #$_recentOfflineMisses');
|
||||
|
||||
// If we get several misses in a short time, show "no data" status
|
||||
if (_recentOfflineMisses >= 3 && !_hasNoData) {
|
||||
_isWaitingForData = false;
|
||||
_isTimedOut = false;
|
||||
_hasNoData = true;
|
||||
_waitingTimer?.cancel();
|
||||
notifyListeners();
|
||||
debugPrint('[NetworkStatus] No offline data available for this area');
|
||||
}
|
||||
|
||||
// Reset the miss counter after some time
|
||||
_noDataResetTimer?.cancel();
|
||||
_noDataResetTimer = Timer(const Duration(seconds: 5), () {
|
||||
_recentOfflineMisses = 0;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_osmRecoveryTimer?.cancel();
|
||||
_overpassRecoveryTimer?.cancel();
|
||||
_waitingTimer?.cancel();
|
||||
_noDataResetTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -6,23 +6,63 @@ 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 'offline_areas/offline_area_downloader.dart';
|
||||
import 'offline_areas/world_area_manager.dart';
|
||||
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());
|
||||
}
|
||||
|
||||
bool _initialized = false;
|
||||
Future<void>? _initializationFuture;
|
||||
|
||||
OfflineAreaService._();
|
||||
|
||||
final List<OfflineArea> _areas = [];
|
||||
List<OfflineArea> get offlineAreas => List.unmodifiable(_areas);
|
||||
|
||||
/// Check if any areas are currently downloading
|
||||
bool get hasActiveDownloads => _areas.any((area) => area.status == OfflineAreaStatus.downloading);
|
||||
|
||||
/// Cancel all active downloads (used when enabling offline mode)
|
||||
Future<void> cancelActiveDownloads() async {
|
||||
final activeAreas = _areas.where((area) => area.status == OfflineAreaStatus.downloading).toList();
|
||||
for (final area in activeAreas) {
|
||||
area.status = OfflineAreaStatus.cancelled;
|
||||
if (!area.isPermanent) {
|
||||
// Clean up non-permanent areas
|
||||
final dir = Directory(area.directory);
|
||||
if (await dir.exists()) {
|
||||
await dir.delete(recursive: true);
|
||||
}
|
||||
_areas.remove(area);
|
||||
}
|
||||
}
|
||||
await saveAreasToDisk();
|
||||
debugPrint('OfflineAreaService: Cancelled ${activeAreas.length} active downloads due to offline mode');
|
||||
}
|
||||
|
||||
/// Ensure the service is initialized (areas loaded from disk)
|
||||
Future<void> ensureInitialized() async {
|
||||
if (_initialized) return;
|
||||
|
||||
_initializationFuture ??= _initialize();
|
||||
await _initializationFuture;
|
||||
}
|
||||
|
||||
Future<void> _initialize() async {
|
||||
if (_initialized) return;
|
||||
|
||||
await _loadAreasFromDisk();
|
||||
await WorldAreaManager.ensureWorldArea(_areas, getOfflineAreaDir, downloadArea);
|
||||
await saveAreasToDisk(); // Save any world area updates
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
Future<Directory> getOfflineAreaDir() async {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
@@ -56,7 +96,20 @@ class OfflineAreaService {
|
||||
Future<void> saveAreasToDisk() async {
|
||||
try {
|
||||
final file = await _getMetadataPath();
|
||||
final content = jsonEncode(_areas.map((a) => a.toJson()).toList());
|
||||
final offlineDir = await getOfflineAreaDir();
|
||||
|
||||
// Convert areas to JSON with relative paths for portability
|
||||
final areaJsonList = _areas.map((area) {
|
||||
final json = area.toJson();
|
||||
// Convert absolute path to relative path for storage
|
||||
if (json['directory'].toString().startsWith(offlineDir.path)) {
|
||||
final relativePath = json['directory'].toString().replaceFirst('${offlineDir.path}/', '');
|
||||
json['directory'] = relativePath;
|
||||
}
|
||||
return json;
|
||||
}).toList();
|
||||
|
||||
final content = jsonEncode(areaJsonList);
|
||||
await file.writeAsString(content);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to save offline areas: $e');
|
||||
@@ -77,11 +130,39 @@ class OfflineAreaService {
|
||||
return;
|
||||
}
|
||||
_areas.clear();
|
||||
|
||||
for (final areaJson in data) {
|
||||
// Migrate stored directory paths to be relative for portability
|
||||
String storedDir = areaJson['directory'];
|
||||
String relativePath = storedDir;
|
||||
|
||||
// If it's an absolute path, extract just the folder name
|
||||
if (storedDir.startsWith('/')) {
|
||||
if (storedDir.contains('/offline_areas/')) {
|
||||
final parts = storedDir.split('/offline_areas/');
|
||||
if (parts.length == 2) {
|
||||
relativePath = parts[1]; // Just the folder name (e.g., "world" or "2025-08-19...")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always construct absolute path at runtime
|
||||
final offlineDir = await getOfflineAreaDir();
|
||||
final fullPath = '${offlineDir.path}/$relativePath';
|
||||
|
||||
// Update the JSON to use the full path for this session
|
||||
areaJson['directory'] = fullPath;
|
||||
|
||||
final area = OfflineArea.fromJson(areaJson);
|
||||
|
||||
if (!Directory(area.directory).existsSync()) {
|
||||
area.status = OfflineAreaStatus.error;
|
||||
} else {
|
||||
// Reset error status if directory now exists (fixes areas that were previously broken due to path issues)
|
||||
if (area.status == OfflineAreaStatus.error) {
|
||||
area.status = OfflineAreaStatus.complete;
|
||||
}
|
||||
|
||||
getAreaSizeBytes(area);
|
||||
}
|
||||
_areas.add(area);
|
||||
@@ -91,77 +172,7 @@ class OfflineAreaService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _ensureAndAutoDownloadWorldArea() async {
|
||||
final dir = await getOfflineAreaDir();
|
||||
final worldDir = "${dir.path}/world";
|
||||
final LatLngBounds worldBounds = globalWorldBounds();
|
||||
OfflineArea? world;
|
||||
for (final a in _areas) {
|
||||
if (a.isPermanent) { world = a; break; }
|
||||
}
|
||||
final Set<List<int>> expectedTiles = computeTileList(worldBounds, kWorldMinZoom, kWorldMaxZoom);
|
||||
if (world != null) {
|
||||
int filesFound = 0;
|
||||
List<List<int>> missingTiles = [];
|
||||
for (final tile in expectedTiles) {
|
||||
final f = File('${world.directory}/tiles/${tile[0]}/${tile[1]}/${tile[2]}.png');
|
||||
if (f.existsSync()) {
|
||||
filesFound++;
|
||||
} else if (missingTiles.length < 10) {
|
||||
missingTiles.add(tile);
|
||||
}
|
||||
}
|
||||
if (filesFound != expectedTiles.length) {
|
||||
debugPrint('World area: missing ${expectedTiles.length - filesFound} tiles. First few: $missingTiles');
|
||||
} else {
|
||||
debugPrint('World area: all tiles accounted for.');
|
||||
}
|
||||
world.tilesTotal = expectedTiles.length;
|
||||
world.tilesDownloaded = filesFound;
|
||||
world.progress = (world.tilesTotal == 0) ? 0.0 : (filesFound / world.tilesTotal);
|
||||
if (filesFound == world.tilesTotal) {
|
||||
world.status = OfflineAreaStatus.complete;
|
||||
await saveAreasToDisk();
|
||||
return;
|
||||
} else {
|
||||
world.status = OfflineAreaStatus.downloading;
|
||||
await saveAreasToDisk();
|
||||
downloadArea(
|
||||
id: world.id,
|
||||
bounds: world.bounds,
|
||||
minZoom: world.minZoom,
|
||||
maxZoom: world.maxZoom,
|
||||
directory: world.directory,
|
||||
name: world.name,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// If not present, create and start download
|
||||
world = OfflineArea(
|
||||
id: 'permanent_world',
|
||||
name: 'World (required)',
|
||||
bounds: worldBounds,
|
||||
minZoom: kWorldMinZoom,
|
||||
maxZoom: kWorldMaxZoom,
|
||||
directory: worldDir,
|
||||
status: OfflineAreaStatus.downloading,
|
||||
progress: 0.0,
|
||||
isPermanent: true,
|
||||
tilesTotal: expectedTiles.length,
|
||||
tilesDownloaded: 0,
|
||||
);
|
||||
_areas.insert(0, world);
|
||||
await saveAreasToDisk();
|
||||
downloadArea(
|
||||
id: world.id,
|
||||
bounds: world.bounds,
|
||||
minZoom: world.minZoom,
|
||||
maxZoom: world.maxZoom,
|
||||
directory: world.directory,
|
||||
name: world.name,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Future<void> downloadArea({
|
||||
required String id,
|
||||
@@ -172,6 +183,10 @@ class OfflineAreaService {
|
||||
void Function(double progress)? onProgress,
|
||||
void Function(OfflineAreaStatus status)? onComplete,
|
||||
String? name,
|
||||
String? tileProviderId,
|
||||
String? tileProviderName,
|
||||
String? tileTypeId,
|
||||
String? tileTypeName,
|
||||
}) async {
|
||||
OfflineArea? area;
|
||||
for (final a in _areas) {
|
||||
@@ -192,78 +207,35 @@ class OfflineAreaService {
|
||||
maxZoom: maxZoom,
|
||||
directory: directory,
|
||||
isPermanent: area?.isPermanent ?? false,
|
||||
tileProviderId: tileProviderId,
|
||||
tileProviderName: tileProviderName,
|
||||
tileTypeId: tileTypeId,
|
||||
tileTypeName: tileTypeName,
|
||||
);
|
||||
_areas.add(area);
|
||||
await saveAreasToDisk();
|
||||
|
||||
try {
|
||||
Set<List<int>> allTiles;
|
||||
if (area.isPermanent) {
|
||||
allTiles = computeTileList(globalWorldBounds(), kWorldMinZoom, kWorldMaxZoom);
|
||||
} else {
|
||||
allTiles = computeTileList(bounds, minZoom, maxZoom);
|
||||
}
|
||||
area.tilesTotal = allTiles.length;
|
||||
const int maxPasses = 3;
|
||||
int pass = 0;
|
||||
Set<List<int>> allTilesSet = allTiles.toSet();
|
||||
Set<List<int>> tilesToFetch = allTilesSet;
|
||||
bool success = false;
|
||||
int totalDone = 0;
|
||||
while (pass < maxPasses && tilesToFetch.isNotEmpty) {
|
||||
pass++;
|
||||
int doneThisPass = 0;
|
||||
debugPrint('DownloadArea: pass #$pass for area $id. Need ${tilesToFetch.length} tiles.');
|
||||
for (final tile in tilesToFetch) {
|
||||
if (area.status == OfflineAreaStatus.cancelled) break;
|
||||
try {
|
||||
final bytes = await MapDataProvider().getTile(
|
||||
z: tile[0], x: tile[1], y: tile[2], source: MapSource.remote);
|
||||
if (bytes.isNotEmpty) {
|
||||
await saveTileBytes(tile[0], tile[1], tile[2], directory, bytes);
|
||||
}
|
||||
totalDone++;
|
||||
doneThisPass++;
|
||||
area.tilesDownloaded = totalDone;
|
||||
area.progress = area.tilesTotal == 0 ? 0.0 : ((area.tilesDownloaded) / area.tilesTotal);
|
||||
} catch (e) {
|
||||
debugPrint("Tile download failed for z=${tile[0]}, x=${tile[1]}, y=${tile[2]}: $e");
|
||||
}
|
||||
if (onProgress != null) onProgress(area.progress);
|
||||
}
|
||||
await getAreaSizeBytes(area);
|
||||
await saveAreasToDisk();
|
||||
Set<List<int>> missingTiles = {};
|
||||
for (final tile in allTilesSet) {
|
||||
final f = File('$directory/tiles/${tile[0]}/${tile[1]}/${tile[2]}.png');
|
||||
if (!f.existsSync()) missingTiles.add(tile);
|
||||
}
|
||||
if (missingTiles.isEmpty) {
|
||||
success = true;
|
||||
break;
|
||||
}
|
||||
tilesToFetch = missingTiles;
|
||||
}
|
||||
final success = await OfflineAreaDownloader.downloadArea(
|
||||
area: area,
|
||||
bounds: bounds,
|
||||
minZoom: minZoom,
|
||||
maxZoom: maxZoom,
|
||||
directory: directory,
|
||||
onProgress: onProgress,
|
||||
saveAreasToDisk: saveAreasToDisk,
|
||||
getAreaSizeBytes: getAreaSizeBytes,
|
||||
);
|
||||
|
||||
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.');
|
||||
debugPrint('Area $id: download completed successfully.');
|
||||
} else {
|
||||
area.status = OfflineAreaStatus.error;
|
||||
debugPrint('Area $id: MISSING tiles after $maxPasses passes. First 10: ${tilesToFetch.toList().take(10)}');
|
||||
debugPrint('Area $id: download failed after maximum retry attempts.');
|
||||
if (!area.isPermanent) {
|
||||
final dirObj = Directory(area.directory);
|
||||
if (await dirObj.exists()) {
|
||||
@@ -273,11 +245,11 @@ class OfflineAreaService {
|
||||
}
|
||||
}
|
||||
await saveAreasToDisk();
|
||||
if (onComplete != null) onComplete(area.status);
|
||||
onComplete?.call(area.status);
|
||||
} catch (e) {
|
||||
area.status = OfflineAreaStatus.error;
|
||||
await saveAreasToDisk();
|
||||
if (onComplete != null) onComplete(area.status);
|
||||
onComplete?.call(area.status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,7 +263,7 @@ class OfflineAreaService {
|
||||
_areas.remove(area);
|
||||
await saveAreasToDisk();
|
||||
if (area.isPermanent) {
|
||||
_ensureAndAutoDownloadWorldArea();
|
||||
await WorldAreaManager.ensureWorldArea(_areas, getOfflineAreaDir, downloadArea);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,4 +276,6 @@ class OfflineAreaService {
|
||||
_areas.remove(area);
|
||||
await saveAreasToDisk();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
196
lib/services/offline_areas/offline_area_downloader.dart
Normal file
196
lib/services/offline_areas/offline_area_downloader.dart
Normal file
@@ -0,0 +1,196 @@
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
|
||||
import '../../app_state.dart';
|
||||
import '../../models/osm_camera_node.dart';
|
||||
import '../map_data_provider.dart';
|
||||
import 'offline_area_models.dart';
|
||||
import 'offline_tile_utils.dart';
|
||||
import 'package:flock_map_app/dev_config.dart';
|
||||
|
||||
/// Handles the actual downloading process for offline areas
|
||||
class OfflineAreaDownloader {
|
||||
static const int _maxRetryPasses = 3;
|
||||
|
||||
/// Download tiles and cameras for an offline area
|
||||
static Future<bool> downloadArea({
|
||||
required OfflineArea area,
|
||||
required LatLngBounds bounds,
|
||||
required int minZoom,
|
||||
required int maxZoom,
|
||||
required String directory,
|
||||
void Function(double progress)? onProgress,
|
||||
required Future<void> Function() saveAreasToDisk,
|
||||
required Future<void> Function(OfflineArea) getAreaSizeBytes,
|
||||
}) async {
|
||||
Set<List<int>> allTiles;
|
||||
if (area.isPermanent) {
|
||||
allTiles = computeTileList(globalWorldBounds(), kWorldMinZoom, kWorldMaxZoom);
|
||||
} else {
|
||||
allTiles = computeTileList(bounds, minZoom, maxZoom);
|
||||
}
|
||||
area.tilesTotal = allTiles.length;
|
||||
|
||||
// Download tiles with retry logic
|
||||
final success = await _downloadTilesWithRetry(
|
||||
area: area,
|
||||
allTiles: allTiles,
|
||||
directory: directory,
|
||||
onProgress: onProgress,
|
||||
saveAreasToDisk: saveAreasToDisk,
|
||||
getAreaSizeBytes: getAreaSizeBytes,
|
||||
);
|
||||
|
||||
// Download cameras for non-permanent areas
|
||||
if (!area.isPermanent) {
|
||||
await _downloadCameras(
|
||||
area: area,
|
||||
bounds: bounds,
|
||||
minZoom: minZoom,
|
||||
directory: directory,
|
||||
);
|
||||
} else {
|
||||
area.cameras = [];
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/// Download tiles with retry logic
|
||||
static Future<bool> _downloadTilesWithRetry({
|
||||
required OfflineArea area,
|
||||
required Set<List<int>> allTiles,
|
||||
required String directory,
|
||||
void Function(double progress)? onProgress,
|
||||
required Future<void> Function() saveAreasToDisk,
|
||||
required Future<void> Function(OfflineArea) getAreaSizeBytes,
|
||||
}) async {
|
||||
int pass = 0;
|
||||
Set<List<int>> tilesToFetch = allTiles;
|
||||
int totalDone = 0;
|
||||
|
||||
while (pass < _maxRetryPasses && tilesToFetch.isNotEmpty) {
|
||||
pass++;
|
||||
debugPrint('DownloadArea: pass #$pass for area ${area.id}. Need ${tilesToFetch.length} tiles.');
|
||||
|
||||
for (final tile in tilesToFetch) {
|
||||
if (area.status == OfflineAreaStatus.cancelled) break;
|
||||
|
||||
if (await _downloadSingleTile(tile, directory, area)) {
|
||||
totalDone++;
|
||||
area.tilesDownloaded = totalDone;
|
||||
area.progress = area.tilesTotal == 0 ? 0.0 : (totalDone / area.tilesTotal);
|
||||
onProgress?.call(area.progress);
|
||||
}
|
||||
}
|
||||
|
||||
await getAreaSizeBytes(area);
|
||||
await saveAreasToDisk();
|
||||
|
||||
// Check for missing tiles
|
||||
tilesToFetch = _findMissingTiles(allTiles, directory);
|
||||
if (tilesToFetch.isEmpty) {
|
||||
return true; // Success!
|
||||
}
|
||||
}
|
||||
|
||||
return false; // Failed after max retries
|
||||
}
|
||||
|
||||
/// Download a single tile using the unified MapDataProvider path
|
||||
static Future<bool> _downloadSingleTile(
|
||||
List<int> tile,
|
||||
String directory,
|
||||
OfflineArea area,
|
||||
) async {
|
||||
try {
|
||||
// Use the same unified path as live tiles: always go through MapDataProvider
|
||||
// MapDataProvider will use current AppState provider for downloads
|
||||
final bytes = await MapDataProvider().getTile(
|
||||
z: tile[0],
|
||||
x: tile[1],
|
||||
y: tile[2],
|
||||
source: MapSource.remote, // Force remote fetch for downloads
|
||||
);
|
||||
if (bytes.isNotEmpty) {
|
||||
await OfflineAreaDownloader.saveTileBytes(tile[0], tile[1], tile[2], directory, bytes);
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Tile download failed for z=${tile[0]}, x=${tile[1]}, y=${tile[2]}: $e");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Find tiles that are missing from disk
|
||||
static Set<List<int>> _findMissingTiles(Set<List<int>> allTiles, String directory) {
|
||||
final missingTiles = <List<int>>{};
|
||||
for (final tile in allTiles) {
|
||||
final file = File('$directory/tiles/${tile[0]}/${tile[1]}/${tile[2]}.png');
|
||||
if (!file.existsSync()) {
|
||||
missingTiles.add(tile);
|
||||
}
|
||||
}
|
||||
return missingTiles;
|
||||
}
|
||||
|
||||
/// Download cameras for the area with expanded bounds
|
||||
static Future<void> _downloadCameras({
|
||||
required OfflineArea area,
|
||||
required LatLngBounds bounds,
|
||||
required int minZoom,
|
||||
required String directory,
|
||||
}) async {
|
||||
// Calculate expanded camera bounds that cover the entire tile area at minimum zoom
|
||||
final cameraBounds = _calculateCameraBounds(bounds, minZoom);
|
||||
final cameras = await MapDataProvider().getAllNodesForDownload(
|
||||
bounds: cameraBounds,
|
||||
profiles: AppState.instance.profiles, // Use ALL profiles, not just enabled ones
|
||||
);
|
||||
area.cameras = cameras;
|
||||
await OfflineAreaDownloader.saveCameras(cameras, directory);
|
||||
debugPrint('Area ${area.id}: Downloaded ${cameras.length} cameras from expanded bounds (all profiles)');
|
||||
}
|
||||
|
||||
/// Calculate expanded bounds that cover the entire tile area at minimum zoom
|
||||
static LatLngBounds _calculateCameraBounds(LatLngBounds visibleBounds, int minZoom) {
|
||||
final tiles = computeTileList(visibleBounds, minZoom, minZoom);
|
||||
if (tiles.isEmpty) return visibleBounds;
|
||||
|
||||
// Find the bounding box of all these tiles
|
||||
double minLat = 90.0, maxLat = -90.0;
|
||||
double minLon = 180.0, maxLon = -180.0;
|
||||
|
||||
for (final tile in tiles) {
|
||||
final tileBounds = tileToLatLngBounds(tile[1], tile[2], tile[0]);
|
||||
|
||||
minLat = math.min(minLat, tileBounds.south);
|
||||
maxLat = math.max(maxLat, tileBounds.north);
|
||||
minLon = math.min(minLon, tileBounds.west);
|
||||
maxLon = math.max(maxLon, tileBounds.east);
|
||||
}
|
||||
|
||||
return LatLngBounds(
|
||||
LatLng(minLat, minLon),
|
||||
LatLng(maxLat, maxLon),
|
||||
);
|
||||
}
|
||||
|
||||
/// Save tile bytes to disk
|
||||
static Future<void> saveTileBytes(int z, int x, int y, String baseDir, List<int> bytes) async {
|
||||
final dir = Directory('$baseDir/tiles/$z/$x');
|
||||
await dir.create(recursive: true);
|
||||
final file = File('${dir.path}/$y.png');
|
||||
await file.writeAsBytes(bytes);
|
||||
}
|
||||
|
||||
/// Save cameras to disk as JSON
|
||||
static Future<void> saveCameras(List<OsmCameraNode> cams, String dir) async {
|
||||
final file = File('$dir/cameras.json');
|
||||
await file.writeAsString(jsonEncode(cams.map((c) => c.toJson()).toList()));
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,12 @@ class OfflineArea {
|
||||
List<OsmCameraNode> cameras;
|
||||
int sizeBytes; // Disk size in bytes
|
||||
final bool isPermanent; // Not user-deletable if true
|
||||
|
||||
// Tile provider metadata (null for legacy areas)
|
||||
final String? tileProviderId;
|
||||
final String? tileProviderName;
|
||||
final String? tileTypeId;
|
||||
final String? tileTypeName;
|
||||
|
||||
OfflineArea({
|
||||
required this.id,
|
||||
@@ -35,6 +41,10 @@ class OfflineArea {
|
||||
this.cameras = const [],
|
||||
this.sizeBytes = 0,
|
||||
this.isPermanent = false,
|
||||
this.tileProviderId,
|
||||
this.tileProviderName,
|
||||
this.tileTypeId,
|
||||
this.tileTypeName,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
@@ -54,6 +64,10 @@ class OfflineArea {
|
||||
'cameras': cameras.map((c) => c.toJson()).toList(),
|
||||
'sizeBytes': sizeBytes,
|
||||
'isPermanent': isPermanent,
|
||||
'tileProviderId': tileProviderId,
|
||||
'tileProviderName': tileProviderName,
|
||||
'tileTypeId': tileTypeId,
|
||||
'tileTypeName': tileTypeName,
|
||||
};
|
||||
|
||||
static OfflineArea fromJson(Map<String, dynamic> json) {
|
||||
@@ -77,6 +91,27 @@ class OfflineArea {
|
||||
.map((e) => OsmCameraNode.fromJson(e)).toList(),
|
||||
sizeBytes: json['sizeBytes'] ?? 0,
|
||||
isPermanent: json['isPermanent'] ?? false,
|
||||
tileProviderId: json['tileProviderId'],
|
||||
tileProviderName: json['tileProviderName'],
|
||||
tileTypeId: json['tileTypeId'],
|
||||
tileTypeName: json['tileTypeName'],
|
||||
);
|
||||
}
|
||||
|
||||
/// Get display text for the tile provider used in this area
|
||||
String get tileProviderDisplay {
|
||||
if (tileProviderName != null && tileTypeName != null) {
|
||||
return '$tileProviderName - $tileTypeName';
|
||||
} else if (tileTypeName != null) {
|
||||
return tileTypeName!;
|
||||
} else if (tileProviderName != null) {
|
||||
return tileProviderName!;
|
||||
} else {
|
||||
// Legacy area - assume OSM
|
||||
return 'OpenStreetMap (Legacy)';
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this area has tile provider metadata
|
||||
bool get hasTileProviderInfo => tileProviderId != null && tileTypeId != null;
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
import '../../models/osm_camera_node.dart';
|
||||
|
||||
/// Disk IO utilities for offline area file management ONLY. No network requests should occur here.
|
||||
|
||||
/// Save-to-disk for a tile that has already been fetched elsewhere.
|
||||
Future<void> saveTileBytes(int z, int x, int y, String baseDir, List<int> bytes) async {
|
||||
final dir = Directory('$baseDir/tiles/$z/$x');
|
||||
await dir.create(recursive: true);
|
||||
final file = File('${dir.path}/$y.png');
|
||||
await file.writeAsBytes(bytes);
|
||||
}
|
||||
|
||||
/// Save-to-disk for cameras.json; called only by OfflineAreaService during area download
|
||||
Future<void> saveCameras(List<OsmCameraNode> cams, String dir) async {
|
||||
final file = File('$dir/cameras.json');
|
||||
await file.writeAsString(jsonEncode(cams.map((c) => c.toJson()).toList()));
|
||||
}
|
||||
@@ -56,6 +56,32 @@ List<int> latLonToTile(double lat, double lon, int zoom) {
|
||||
return [xtile, ytile];
|
||||
}
|
||||
|
||||
/// Convert tile coordinates back to LatLng bounds
|
||||
LatLngBounds tileToLatLngBounds(int x, int y, int z) {
|
||||
final n = pow(2, z);
|
||||
|
||||
// Calculate bounds for this tile
|
||||
final lonWest = x / n * 360.0 - 180.0;
|
||||
final lonEast = (x + 1) / n * 360.0 - 180.0;
|
||||
|
||||
// For latitude, we need to invert the mercator projection
|
||||
final latNorthRad = atan(sinh(pi * (1 - 2 * y / n)));
|
||||
final latSouthRad = atan(sinh(pi * (1 - 2 * (y + 1) / n)));
|
||||
|
||||
final latNorth = latNorthRad * 180.0 / pi;
|
||||
final latSouth = latSouthRad * 180.0 / pi;
|
||||
|
||||
return LatLngBounds(
|
||||
LatLng(latSouth, lonWest), // SW corner
|
||||
LatLng(latNorth, lonEast), // NE corner
|
||||
);
|
||||
}
|
||||
|
||||
/// Hyperbolic sine function: sinh(x) = (e^x - e^(-x)) / 2
|
||||
double sinh(double x) {
|
||||
return (exp(x) - exp(-x)) / 2;
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
153
lib/services/offline_areas/world_area_manager.dart
Normal file
153
lib/services/offline_areas/world_area_manager.dart
Normal file
@@ -0,0 +1,153 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'offline_area_models.dart';
|
||||
import 'offline_tile_utils.dart';
|
||||
import 'package:flock_map_app/dev_config.dart';
|
||||
|
||||
/// Manages the world area (permanent offline area for base map)
|
||||
class WorldAreaManager {
|
||||
static const String _worldAreaId = 'world';
|
||||
static const String _worldAreaName = 'World Base Map';
|
||||
|
||||
/// Ensure world area exists and check if download is needed
|
||||
static Future<OfflineArea> ensureWorldArea(
|
||||
List<OfflineArea> areas,
|
||||
Future<Directory> Function() getOfflineAreaDir,
|
||||
Future<void> Function({
|
||||
required String id,
|
||||
required LatLngBounds bounds,
|
||||
required int minZoom,
|
||||
required int maxZoom,
|
||||
required String directory,
|
||||
String? name,
|
||||
String? tileProviderId,
|
||||
String? tileProviderName,
|
||||
String? tileTypeId,
|
||||
String? tileTypeName,
|
||||
}) downloadArea,
|
||||
) async {
|
||||
// Find existing world area
|
||||
OfflineArea? world;
|
||||
for (final area in areas) {
|
||||
if (area.isPermanent) {
|
||||
world = area;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Create world area if it doesn't exist, or update existing area without provider info
|
||||
if (world == null) {
|
||||
final appDocDir = await getOfflineAreaDir();
|
||||
final dir = "${appDocDir.path}/$_worldAreaId";
|
||||
world = OfflineArea(
|
||||
id: _worldAreaId,
|
||||
name: _worldAreaName,
|
||||
bounds: globalWorldBounds(),
|
||||
minZoom: kWorldMinZoom,
|
||||
maxZoom: kWorldMaxZoom,
|
||||
directory: dir,
|
||||
status: OfflineAreaStatus.downloading,
|
||||
isPermanent: true,
|
||||
// World area always uses OpenStreetMap
|
||||
tileProviderId: 'openstreetmap',
|
||||
tileProviderName: 'OpenStreetMap',
|
||||
tileTypeId: 'osm_street',
|
||||
tileTypeName: 'Street Map',
|
||||
);
|
||||
areas.insert(0, world);
|
||||
} else if (world.tileProviderId == null || world.tileTypeId == null) {
|
||||
// Update existing world area that lacks provider metadata
|
||||
final updatedWorld = OfflineArea(
|
||||
id: world.id,
|
||||
name: world.name,
|
||||
bounds: world.bounds,
|
||||
minZoom: world.minZoom,
|
||||
maxZoom: world.maxZoom,
|
||||
directory: world.directory,
|
||||
status: world.status,
|
||||
progress: world.progress,
|
||||
tilesDownloaded: world.tilesDownloaded,
|
||||
tilesTotal: world.tilesTotal,
|
||||
cameras: world.cameras,
|
||||
sizeBytes: world.sizeBytes,
|
||||
isPermanent: world.isPermanent,
|
||||
// Add missing provider metadata
|
||||
tileProviderId: 'openstreetmap',
|
||||
tileProviderName: 'OpenStreetMap',
|
||||
tileTypeId: 'osm_street',
|
||||
tileTypeName: 'Street Map',
|
||||
);
|
||||
final index = areas.indexOf(world);
|
||||
areas[index] = updatedWorld;
|
||||
world = updatedWorld;
|
||||
}
|
||||
|
||||
// Check world area status and start download if needed
|
||||
await _checkAndStartWorldDownload(world, downloadArea);
|
||||
return world;
|
||||
}
|
||||
|
||||
/// Check world area download status and start if needed
|
||||
static Future<void> _checkAndStartWorldDownload(
|
||||
OfflineArea world,
|
||||
Future<void> Function({
|
||||
required String id,
|
||||
required LatLngBounds bounds,
|
||||
required int minZoom,
|
||||
required int maxZoom,
|
||||
required String directory,
|
||||
String? name,
|
||||
String? tileProviderId,
|
||||
String? tileProviderName,
|
||||
String? tileTypeId,
|
||||
String? tileTypeName,
|
||||
}) downloadArea,
|
||||
) async {
|
||||
if (world.status == OfflineAreaStatus.complete) return;
|
||||
|
||||
// Count existing tiles
|
||||
final expectedTiles = computeTileList(
|
||||
globalWorldBounds(),
|
||||
kWorldMinZoom,
|
||||
kWorldMaxZoom,
|
||||
);
|
||||
|
||||
int filesFound = 0;
|
||||
for (final tile in expectedTiles) {
|
||||
final file = File('${world.directory}/tiles/${tile[0]}/${tile[1]}/${tile[2]}.png');
|
||||
if (file.existsSync()) {
|
||||
filesFound++;
|
||||
}
|
||||
}
|
||||
|
||||
// Update world area stats
|
||||
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;
|
||||
debugPrint('WorldAreaManager: World area download already complete.');
|
||||
} else {
|
||||
world.status = OfflineAreaStatus.downloading;
|
||||
debugPrint('WorldAreaManager: Starting world area download. ${world.tilesDownloaded}/${world.tilesTotal} tiles found.');
|
||||
|
||||
// Start download (fire and forget) - use OSM for world areas
|
||||
downloadArea(
|
||||
id: world.id,
|
||||
bounds: world.bounds,
|
||||
minZoom: world.minZoom,
|
||||
maxZoom: world.maxZoom,
|
||||
directory: world.directory,
|
||||
name: world.name,
|
||||
tileProviderId: 'openstreetmap',
|
||||
tileProviderName: 'OpenStreetMap',
|
||||
tileTypeId: 'osm_street',
|
||||
tileTypeName: 'Street Map',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
22
lib/services/operator_profile_service.dart
Normal file
22
lib/services/operator_profile_service.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/operator_profile.dart';
|
||||
|
||||
class OperatorProfileService {
|
||||
static const _key = 'operator_profiles';
|
||||
|
||||
Future<List<OperatorProfile>> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonStr = prefs.getString(_key);
|
||||
if (jsonStr == null) return [];
|
||||
final list = jsonDecode(jsonStr) as List<dynamic>;
|
||||
return list.map((e) => OperatorProfile.fromJson(e)).toList();
|
||||
}
|
||||
|
||||
Future<void> save(List<OperatorProfile> profiles) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final encodable = profiles.map((p) => p.toJson()).toList();
|
||||
await prefs.setString(_key, jsonEncode(encodable));
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,20 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/camera_profile.dart';
|
||||
import '../models/node_profile.dart';
|
||||
|
||||
class ProfileService {
|
||||
static const _key = 'custom_profiles';
|
||||
|
||||
Future<List<CameraProfile>> load() async {
|
||||
Future<List<NodeProfile>> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonStr = prefs.getString(_key);
|
||||
if (jsonStr == null) return [];
|
||||
final list = jsonDecode(jsonStr) as List<dynamic>;
|
||||
return list.map((e) => CameraProfile.fromJson(e)).toList();
|
||||
return list.map((e) => NodeProfile.fromJson(e)).toList();
|
||||
}
|
||||
|
||||
Future<void> save(List<CameraProfile> profiles) async {
|
||||
Future<void> save(List<NodeProfile> profiles) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// MUST convert to List before jsonEncode; the previous MappedIterable
|
||||
|
||||
114
lib/services/simple_tile_service.dart
Normal file
114
lib/services/simple_tile_service.dart
Normal file
@@ -0,0 +1,114 @@
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import 'map_data_provider.dart';
|
||||
import 'network_status.dart';
|
||||
|
||||
/// Simple HTTP client that routes tile requests through the centralized MapDataProvider.
|
||||
/// This ensures all tile fetching (offline/online routing, retries, etc.) is in one place.
|
||||
class SimpleTileHttpClient extends http.BaseClient {
|
||||
final http.Client _inner = http.Client();
|
||||
final MapDataProvider _mapDataProvider = MapDataProvider();
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
||||
// Extract tile coordinates from our custom URL scheme
|
||||
final tileCoords = _extractTileCoords(request.url);
|
||||
if (tileCoords != null) {
|
||||
final z = tileCoords['z']!;
|
||||
final x = tileCoords['x']!;
|
||||
final y = tileCoords['y']!;
|
||||
return _handleTileRequest(z, x, y);
|
||||
}
|
||||
|
||||
// Pass through non-tile requests
|
||||
return _inner.send(request);
|
||||
}
|
||||
|
||||
/// Extract z/x/y coordinates from our fake domain: https://tiles.local/provider/type/z/x/y
|
||||
/// We ignore the provider/type in the URL since we use current AppState for actual fetching
|
||||
Map<String, int>? _extractTileCoords(Uri url) {
|
||||
if (url.host != 'tiles.local') return null;
|
||||
|
||||
final pathSegments = url.pathSegments;
|
||||
if (pathSegments.length != 5) return null;
|
||||
|
||||
// pathSegments[0] = providerId (for cache separation only)
|
||||
// pathSegments[1] = tileTypeId (for cache separation only)
|
||||
final z = int.tryParse(pathSegments[2]);
|
||||
final x = int.tryParse(pathSegments[3]);
|
||||
final y = int.tryParse(pathSegments[4]);
|
||||
|
||||
if (z != null && x != null && y != null) {
|
||||
return {'z': z, 'x': x, 'y': y};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<http.StreamedResponse> _handleTileRequest(int z, int x, int y) async {
|
||||
try {
|
||||
// Always go through MapDataProvider - it handles offline/online routing
|
||||
// MapDataProvider will get current provider from AppState
|
||||
final tileBytes = await _mapDataProvider.getTile(z: z, x: x, y: y, source: MapSource.auto);
|
||||
|
||||
// Show success status briefly
|
||||
NetworkStatus.instance.setSuccess();
|
||||
|
||||
// Serve tile with proper cache headers
|
||||
return http.StreamedResponse(
|
||||
Stream.value(tileBytes),
|
||||
200,
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
'Cache-Control': 'public, max-age=604800',
|
||||
'Expires': _httpDateFormat(DateTime.now().add(Duration(days: 7))),
|
||||
'Last-Modified': _httpDateFormat(DateTime.now().subtract(Duration(hours: 1))),
|
||||
},
|
||||
);
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[SimpleTileService] Could not get tile $z/$x/$y: $e');
|
||||
|
||||
// 404 means no tiles available - show "no data" status briefly
|
||||
NetworkStatus.instance.setNoData();
|
||||
|
||||
// Return 404 and let flutter_map handle it gracefully
|
||||
return http.StreamedResponse(
|
||||
Stream.value(<int>[]),
|
||||
404,
|
||||
reasonPhrase: 'Tile unavailable: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear any queued tile requests when map view changes
|
||||
void clearTileQueue() {
|
||||
_mapDataProvider.clearTileQueue();
|
||||
}
|
||||
|
||||
/// Format date for HTTP headers (RFC 7231)
|
||||
String _httpDateFormat(DateTime date) {
|
||||
final utc = date.toUtc();
|
||||
final weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
final weekday = weekdays[utc.weekday - 1];
|
||||
final day = utc.day.toString().padLeft(2, '0');
|
||||
final month = months[utc.month - 1];
|
||||
final year = utc.year;
|
||||
final hour = utc.hour.toString().padLeft(2, '0');
|
||||
final minute = utc.minute.toString().padLeft(2, '0');
|
||||
final second = utc.second.toString().padLeft(2, '0');
|
||||
|
||||
return '$weekday, $day $month $year $hour:$minute:$second GMT';
|
||||
}
|
||||
|
||||
@override
|
||||
void close() {
|
||||
_inner.close();
|
||||
super.close();
|
||||
}
|
||||
}
|
||||
@@ -14,14 +14,15 @@ class Uploader {
|
||||
|
||||
Future<bool> upload(PendingUpload p) async {
|
||||
try {
|
||||
print('Uploader: Starting upload for camera at ${p.coord.latitude}, ${p.coord.longitude}');
|
||||
print('Uploader: Starting upload for node at ${p.coord.latitude}, ${p.coord.longitude}');
|
||||
|
||||
// 1. open changeset
|
||||
final action = p.isEdit ? 'Update' : 'Add';
|
||||
final csXml = '''
|
||||
<osm>
|
||||
<changeset>
|
||||
<tag k="created_by" v="$kClientName $kClientVersion"/>
|
||||
<tag k="comment" v="Add surveillance camera"/>
|
||||
<tag k="comment" v="$action ${p.profile.name} surveillance node"/>
|
||||
</changeset>
|
||||
</osm>''';
|
||||
print('Uploader: Creating changeset...');
|
||||
@@ -34,28 +35,63 @@ class Uploader {
|
||||
final csId = csResp.body.trim();
|
||||
print('Uploader: Created changeset ID: $csId');
|
||||
|
||||
// 2. create node
|
||||
// Merge tags: direction in PendingUpload should always be present,
|
||||
// and override any in the profile for upload purposes
|
||||
final mergedTags = Map<String, String>.from(p.profile.tags)
|
||||
..['direction'] = p.direction.round().toString();
|
||||
// 2. create or update node
|
||||
final mergedTags = p.getCombinedTags();
|
||||
final tagsXml = mergedTags.entries.map((e) =>
|
||||
'<tag k="${e.key}" v="${e.value}"/>').join('\n ');
|
||||
final nodeXml = '''
|
||||
|
||||
final http.Response nodeResp;
|
||||
final String nodeId;
|
||||
|
||||
if (p.isEdit) {
|
||||
// First, fetch the current node to get its version
|
||||
print('Uploader: Fetching current node ${p.originalNodeId} to get version...');
|
||||
final currentNodeResp = await _get('/api/0.6/node/${p.originalNodeId}');
|
||||
print('Uploader: Current node response: ${currentNodeResp.statusCode}');
|
||||
if (currentNodeResp.statusCode != 200) {
|
||||
print('Uploader: Failed to fetch current node');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse version from the response XML
|
||||
final currentNodeXml = currentNodeResp.body;
|
||||
final versionMatch = RegExp(r'version="(\d+)"').firstMatch(currentNodeXml);
|
||||
if (versionMatch == null) {
|
||||
print('Uploader: Could not parse version from current node XML');
|
||||
return false;
|
||||
}
|
||||
final currentVersion = versionMatch.group(1)!;
|
||||
print('Uploader: Current node version: $currentVersion');
|
||||
|
||||
// Update existing node with version
|
||||
final nodeXml = '''
|
||||
<osm>
|
||||
<node changeset="$csId" id="${p.originalNodeId}" version="$currentVersion" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
|
||||
$tagsXml
|
||||
</node>
|
||||
</osm>''';
|
||||
print('Uploader: Updating node ${p.originalNodeId}...');
|
||||
nodeResp = await _put('/api/0.6/node/${p.originalNodeId}', nodeXml);
|
||||
nodeId = p.originalNodeId.toString();
|
||||
} else {
|
||||
// Create new node
|
||||
final nodeXml = '''
|
||||
<osm>
|
||||
<node changeset="$csId" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
|
||||
$tagsXml
|
||||
</node>
|
||||
</osm>''';
|
||||
print('Uploader: Creating node...');
|
||||
final nodeResp = await _put('/api/0.6/node/create', nodeXml);
|
||||
print('Uploader: Creating new node...');
|
||||
nodeResp = await _put('/api/0.6/node/create', nodeXml);
|
||||
nodeId = nodeResp.body.trim();
|
||||
}
|
||||
|
||||
print('Uploader: Node response: ${nodeResp.statusCode} - ${nodeResp.body}');
|
||||
if (nodeResp.statusCode != 200) {
|
||||
print('Uploader: Failed to create node');
|
||||
print('Uploader: Failed to ${p.isEdit ? "update" : "create"} node');
|
||||
return false;
|
||||
}
|
||||
final nodeId = nodeResp.body.trim();
|
||||
print('Uploader: Created node ID: $nodeId');
|
||||
print('Uploader: ${p.isEdit ? "Updated" : "Created"} node ID: $nodeId');
|
||||
|
||||
// 3. close changeset
|
||||
print('Uploader: Closing changeset...');
|
||||
@@ -81,6 +117,11 @@ class Uploader {
|
||||
}
|
||||
}
|
||||
|
||||
Future<http.Response> _get(String path) => http.get(
|
||||
Uri.https(_host, path),
|
||||
headers: _headers,
|
||||
);
|
||||
|
||||
Future<http.Response> _post(String path, String body) => http.post(
|
||||
Uri.https(_host, path),
|
||||
headers: _headers,
|
||||
|
||||
103
lib/state/auth_state.dart
Normal file
103
lib/state/auth_state.dart
Normal file
@@ -0,0 +1,103 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../services/auth_service.dart';
|
||||
import 'settings_state.dart';
|
||||
|
||||
class AuthState extends ChangeNotifier {
|
||||
final AuthService _auth = AuthService();
|
||||
String? _username;
|
||||
|
||||
// Getters
|
||||
bool get isLoggedIn => _username != null;
|
||||
String get username => _username ?? '';
|
||||
AuthService get authService => _auth;
|
||||
|
||||
// Initialize auth state and check existing login
|
||||
Future<void> init(UploadMode uploadMode) async {
|
||||
_auth.setUploadMode(uploadMode);
|
||||
|
||||
try {
|
||||
if (await _auth.isLoggedIn()) {
|
||||
_username = await _auth.login();
|
||||
}
|
||||
} catch (e) {
|
||||
print("AuthState: Error during auth initialization: $e");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> login() async {
|
||||
try {
|
||||
_username = await _auth.login();
|
||||
} catch (e) {
|
||||
print("AuthState: Login error: $e");
|
||||
_username = null;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
await _auth.logout();
|
||||
_username = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> refreshAuthState() async {
|
||||
try {
|
||||
if (await _auth.isLoggedIn()) {
|
||||
_username = await _auth.login();
|
||||
} else {
|
||||
_username = null;
|
||||
}
|
||||
} catch (e) {
|
||||
print("AuthState: Auth refresh error: $e");
|
||||
_username = null;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> forceLogin() async {
|
||||
try {
|
||||
_username = await _auth.forceLogin();
|
||||
} catch (e) {
|
||||
print("AuthState: Forced login error: $e");
|
||||
_username = null;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<bool> validateToken() async {
|
||||
try {
|
||||
return await _auth.isLoggedIn();
|
||||
} catch (e) {
|
||||
print("AuthState: Token validation error: $e");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle upload mode changes
|
||||
Future<void> onUploadModeChanged(UploadMode mode) async {
|
||||
_auth.setUploadMode(mode);
|
||||
|
||||
// Refresh user display for active mode, validating token
|
||||
try {
|
||||
if (await _auth.isLoggedIn()) {
|
||||
final isValid = await validateToken();
|
||||
if (isValid) {
|
||||
_username = await _auth.login();
|
||||
} else {
|
||||
await logout(); // This clears _username also.
|
||||
}
|
||||
} else {
|
||||
_username = null;
|
||||
}
|
||||
} catch (e) {
|
||||
_username = null;
|
||||
print("AuthState: Mode change user restoration error: $e");
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<String?> getAccessToken() async {
|
||||
return await _auth.getAccessToken();
|
||||
}
|
||||
}
|
||||
31
lib/state/operator_profile_state.dart
Normal file
31
lib/state/operator_profile_state.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/operator_profile.dart';
|
||||
import '../services/operator_profile_service.dart';
|
||||
|
||||
class OperatorProfileState extends ChangeNotifier {
|
||||
final List<OperatorProfile> _profiles = [];
|
||||
|
||||
List<OperatorProfile> get profiles => List.unmodifiable(_profiles);
|
||||
|
||||
Future<void> init() async {
|
||||
_profiles.addAll(await OperatorProfileService().load());
|
||||
}
|
||||
|
||||
void addOrUpdateProfile(OperatorProfile p) {
|
||||
final idx = _profiles.indexWhere((x) => x.id == p.id);
|
||||
if (idx >= 0) {
|
||||
_profiles[idx] = p;
|
||||
} else {
|
||||
_profiles.add(p);
|
||||
}
|
||||
OperatorProfileService().save(_profiles);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void deleteProfile(OperatorProfile p) {
|
||||
_profiles.removeWhere((x) => x.id == p.id);
|
||||
OperatorProfileService().save(_profiles);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
95
lib/state/profile_state.dart
Normal file
95
lib/state/profile_state.dart
Normal file
@@ -0,0 +1,95 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/node_profile.dart';
|
||||
import '../services/profile_service.dart';
|
||||
|
||||
class ProfileState extends ChangeNotifier {
|
||||
static const String _enabledPrefsKey = 'enabled_profiles';
|
||||
|
||||
final List<NodeProfile> _profiles = [];
|
||||
final Set<NodeProfile> _enabled = {};
|
||||
|
||||
// Getters
|
||||
List<NodeProfile> get profiles => List.unmodifiable(_profiles);
|
||||
bool isEnabled(NodeProfile p) => _enabled.contains(p);
|
||||
List<NodeProfile> get enabledProfiles =>
|
||||
_profiles.where(isEnabled).toList(growable: false);
|
||||
|
||||
// Initialize profiles from built-in and custom sources
|
||||
Future<void> init() async {
|
||||
// Initialize profiles: built-in + custom
|
||||
_profiles.add(NodeProfile.genericAlpr());
|
||||
_profiles.add(NodeProfile.flock());
|
||||
_profiles.add(NodeProfile.motorola());
|
||||
_profiles.add(NodeProfile.genetec());
|
||||
_profiles.add(NodeProfile.leonardo());
|
||||
_profiles.add(NodeProfile.neology());
|
||||
_profiles.add(NodeProfile.genericGunshotDetector());
|
||||
_profiles.add(NodeProfile.shotspotter());
|
||||
_profiles.add(NodeProfile.flockRaven());
|
||||
_profiles.addAll(await ProfileService().load());
|
||||
|
||||
// Load enabled profile IDs from prefs
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final enabledIds = prefs.getStringList(_enabledPrefsKey);
|
||||
if (enabledIds != null && enabledIds.isNotEmpty) {
|
||||
// Restore enabled profiles by id
|
||||
_enabled.addAll(_profiles.where((p) => enabledIds.contains(p.id)));
|
||||
} else {
|
||||
// By default, all are enabled
|
||||
_enabled.addAll(_profiles);
|
||||
}
|
||||
}
|
||||
|
||||
void toggleProfile(NodeProfile p, bool e) {
|
||||
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();
|
||||
}
|
||||
|
||||
void addOrUpdateProfile(NodeProfile p) {
|
||||
final idx = _profiles.indexWhere((x) => x.id == p.id);
|
||||
if (idx >= 0) {
|
||||
_profiles[idx] = p;
|
||||
} else {
|
||||
_profiles.add(p);
|
||||
_enabled.add(p);
|
||||
_saveEnabledProfiles();
|
||||
}
|
||||
ProfileService().save(_profiles);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void deleteProfile(NodeProfile p) {
|
||||
if (!p.editable) 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();
|
||||
}
|
||||
|
||||
// Save enabled profile IDs to disk
|
||||
Future<void> _saveEnabledProfiles() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList(
|
||||
_enabledPrefsKey,
|
||||
_enabled.map((p) => p.id).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
170
lib/state/session_state.dart
Normal file
170
lib/state/session_state.dart
Normal file
@@ -0,0 +1,170 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/operator_profile.dart';
|
||||
import '../models/osm_camera_node.dart';
|
||||
|
||||
// ------------------ AddNodeSession ------------------
|
||||
class AddNodeSession {
|
||||
AddNodeSession({required this.profile, this.directionDegrees = 0});
|
||||
NodeProfile profile;
|
||||
OperatorProfile? operatorProfile;
|
||||
double directionDegrees;
|
||||
LatLng? target;
|
||||
}
|
||||
|
||||
// ------------------ EditNodeSession ------------------
|
||||
class EditNodeSession {
|
||||
EditNodeSession({
|
||||
required this.originalNode,
|
||||
required this.profile,
|
||||
required this.directionDegrees,
|
||||
required this.target,
|
||||
});
|
||||
|
||||
final OsmCameraNode originalNode; // The original node being edited
|
||||
NodeProfile profile;
|
||||
OperatorProfile? operatorProfile;
|
||||
double directionDegrees;
|
||||
LatLng target; // Current position (can be dragged)
|
||||
}
|
||||
|
||||
class SessionState extends ChangeNotifier {
|
||||
AddNodeSession? _session;
|
||||
EditNodeSession? _editSession;
|
||||
|
||||
// Getters
|
||||
AddNodeSession? get session => _session;
|
||||
EditNodeSession? get editSession => _editSession;
|
||||
|
||||
void startAddSession(List<NodeProfile> enabledProfiles) {
|
||||
final submittableProfiles = enabledProfiles.where((p) => p.isSubmittable).toList();
|
||||
final defaultProfile = submittableProfiles.isNotEmpty
|
||||
? submittableProfiles.first
|
||||
: enabledProfiles.first; // Fallback to any enabled profile
|
||||
_session = AddNodeSession(profile: defaultProfile);
|
||||
_editSession = null; // Clear any edit session
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void startEditSession(OsmCameraNode node, List<NodeProfile> enabledProfiles) {
|
||||
final submittableProfiles = enabledProfiles.where((p) => p.isSubmittable).toList();
|
||||
|
||||
// Try to find a matching profile based on the node's tags
|
||||
NodeProfile matchingProfile = submittableProfiles.isNotEmpty
|
||||
? submittableProfiles.first
|
||||
: enabledProfiles.first;
|
||||
|
||||
// Attempt to find a better match by comparing tags
|
||||
for (final profile in submittableProfiles) {
|
||||
if (_profileMatchesTags(profile, node.tags)) {
|
||||
matchingProfile = profile;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_editSession = EditNodeSession(
|
||||
originalNode: node,
|
||||
profile: matchingProfile,
|
||||
directionDegrees: node.directionDeg ?? 0,
|
||||
target: node.coord,
|
||||
);
|
||||
_session = null; // Clear any add session
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool _profileMatchesTags(NodeProfile profile, Map<String, String> tags) {
|
||||
// Simple matching: check if all profile tags are present in node tags
|
||||
for (final entry in profile.tags.entries) {
|
||||
if (tags[entry.key] != entry.value) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void updateSession({
|
||||
double? directionDeg,
|
||||
NodeProfile? profile,
|
||||
OperatorProfile? operatorProfile,
|
||||
LatLng? target,
|
||||
}) {
|
||||
if (_session == null) return;
|
||||
|
||||
bool dirty = false;
|
||||
if (directionDeg != null && directionDeg != _session!.directionDegrees) {
|
||||
_session!.directionDegrees = directionDeg;
|
||||
dirty = true;
|
||||
}
|
||||
if (profile != null && profile != _session!.profile) {
|
||||
_session!.profile = profile;
|
||||
dirty = true;
|
||||
}
|
||||
if (operatorProfile != _session!.operatorProfile) {
|
||||
_session!.operatorProfile = operatorProfile;
|
||||
dirty = true;
|
||||
}
|
||||
if (target != null) {
|
||||
_session!.target = target;
|
||||
dirty = true;
|
||||
}
|
||||
if (dirty) notifyListeners();
|
||||
}
|
||||
|
||||
void updateEditSession({
|
||||
double? directionDeg,
|
||||
NodeProfile? profile,
|
||||
OperatorProfile? operatorProfile,
|
||||
LatLng? target,
|
||||
}) {
|
||||
if (_editSession == null) return;
|
||||
|
||||
bool dirty = false;
|
||||
if (directionDeg != null && directionDeg != _editSession!.directionDegrees) {
|
||||
_editSession!.directionDegrees = directionDeg;
|
||||
dirty = true;
|
||||
}
|
||||
if (profile != null && profile != _editSession!.profile) {
|
||||
_editSession!.profile = profile;
|
||||
dirty = true;
|
||||
}
|
||||
if (operatorProfile != _editSession!.operatorProfile) {
|
||||
_editSession!.operatorProfile = operatorProfile;
|
||||
dirty = true;
|
||||
}
|
||||
if (target != null && target != _editSession!.target) {
|
||||
_editSession!.target = target;
|
||||
dirty = true;
|
||||
}
|
||||
if (dirty) notifyListeners();
|
||||
}
|
||||
|
||||
void cancelSession() {
|
||||
_session = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void cancelEditSession() {
|
||||
_editSession = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
AddNodeSession? commitSession() {
|
||||
if (_session?.target == null) return null;
|
||||
|
||||
final session = _session!;
|
||||
_session = null;
|
||||
notifyListeners();
|
||||
return session;
|
||||
}
|
||||
|
||||
EditNodeSession? commitEditSession() {
|
||||
if (_editSession == null) return null;
|
||||
|
||||
final session = _editSession!;
|
||||
_editSession = null;
|
||||
notifyListeners();
|
||||
return session;
|
||||
}
|
||||
}
|
||||
242
lib/state/settings_state.dart
Normal file
242
lib/state/settings_state.dart
Normal file
@@ -0,0 +1,242 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
import '../models/tile_provider.dart';
|
||||
|
||||
// Enum for upload mode (Production, OSM Sandbox, Simulate)
|
||||
enum UploadMode { production, sandbox, simulate }
|
||||
|
||||
// Enum for follow-me mode (moved from HomeScreen to centralized state)
|
||||
enum FollowMeMode {
|
||||
off, // No following
|
||||
northUp, // Follow position, keep north up
|
||||
rotating, // Follow position and rotation
|
||||
}
|
||||
|
||||
class SettingsState extends ChangeNotifier {
|
||||
static const String _offlineModePrefsKey = 'offline_mode';
|
||||
static const String _maxCamerasPrefsKey = 'max_cameras';
|
||||
static const String _uploadModePrefsKey = 'upload_mode';
|
||||
static const String _tileProvidersPrefsKey = 'tile_providers';
|
||||
static const String _selectedTileTypePrefsKey = 'selected_tile_type';
|
||||
static const String _legacyTestModePrefsKey = 'test_mode';
|
||||
static const String _followMeModePrefsKey = 'follow_me_mode';
|
||||
|
||||
bool _offlineMode = false;
|
||||
int _maxCameras = 250;
|
||||
UploadMode _uploadMode = UploadMode.simulate;
|
||||
FollowMeMode _followMeMode = FollowMeMode.northUp;
|
||||
List<TileProvider> _tileProviders = [];
|
||||
String _selectedTileTypeId = '';
|
||||
|
||||
// Getters
|
||||
bool get offlineMode => _offlineMode;
|
||||
int get maxCameras => _maxCameras;
|
||||
UploadMode get uploadMode => _uploadMode;
|
||||
FollowMeMode get followMeMode => _followMeMode;
|
||||
List<TileProvider> get tileProviders => List.unmodifiable(_tileProviders);
|
||||
String get selectedTileTypeId => _selectedTileTypeId;
|
||||
|
||||
/// Get the currently selected tile type
|
||||
TileType? get selectedTileType {
|
||||
for (final provider in _tileProviders) {
|
||||
for (final tileType in provider.tileTypes) {
|
||||
if (tileType.id == _selectedTileTypeId) {
|
||||
return tileType;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get the provider that contains the selected tile type
|
||||
TileProvider? get selectedTileProvider {
|
||||
for (final provider in _tileProviders) {
|
||||
if (provider.tileTypes.any((type) => type.id == _selectedTileTypeId)) {
|
||||
return provider;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get all available tile types from all providers
|
||||
List<TileType> get allAvailableTileTypes {
|
||||
final types = <TileType>[];
|
||||
for (final provider in _tileProviders) {
|
||||
types.addAll(provider.availableTileTypes);
|
||||
}
|
||||
return types;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Initialize settings from preferences
|
||||
Future<void> init() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// Load offline mode
|
||||
_offlineMode = prefs.getBool(_offlineModePrefsKey) ?? false;
|
||||
|
||||
// Load max cameras
|
||||
if (prefs.containsKey(_maxCamerasPrefsKey)) {
|
||||
_maxCameras = prefs.getInt(_maxCamerasPrefsKey) ?? 250;
|
||||
}
|
||||
|
||||
// Load upload mode (including migration from old test_mode bool)
|
||||
if (prefs.containsKey(_uploadModePrefsKey)) {
|
||||
final idx = prefs.getInt(_uploadModePrefsKey) ?? 0;
|
||||
if (idx >= 0 && idx < UploadMode.values.length) {
|
||||
_uploadMode = UploadMode.values[idx];
|
||||
}
|
||||
} else if (prefs.containsKey(_legacyTestModePrefsKey)) {
|
||||
// migrate legacy test_mode (true->simulate, false->prod)
|
||||
final legacy = prefs.getBool(_legacyTestModePrefsKey) ?? false;
|
||||
_uploadMode = legacy ? UploadMode.simulate : UploadMode.production;
|
||||
await prefs.remove(_legacyTestModePrefsKey);
|
||||
await prefs.setInt(_uploadModePrefsKey, _uploadMode.index);
|
||||
}
|
||||
|
||||
// Load tile providers (default to built-in providers if none saved)
|
||||
await _loadTileProviders(prefs);
|
||||
|
||||
// Load follow-me mode
|
||||
if (prefs.containsKey(_followMeModePrefsKey)) {
|
||||
final modeIndex = prefs.getInt(_followMeModePrefsKey) ?? 0;
|
||||
if (modeIndex >= 0 && modeIndex < FollowMeMode.values.length) {
|
||||
_followMeMode = FollowMeMode.values[modeIndex];
|
||||
}
|
||||
}
|
||||
|
||||
// Load selected tile type (default to first available)
|
||||
_selectedTileTypeId = prefs.getString(_selectedTileTypePrefsKey) ?? '';
|
||||
if (_selectedTileTypeId.isEmpty || selectedTileType == null) {
|
||||
final firstType = allAvailableTileTypes.firstOrNull;
|
||||
if (firstType != null) {
|
||||
_selectedTileTypeId = firstType.id;
|
||||
await prefs.setString(_selectedTileTypePrefsKey, _selectedTileTypeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadTileProviders(SharedPreferences prefs) async {
|
||||
if (prefs.containsKey(_tileProvidersPrefsKey)) {
|
||||
try {
|
||||
final providersJson = prefs.getString(_tileProvidersPrefsKey);
|
||||
if (providersJson != null) {
|
||||
final providersList = jsonDecode(providersJson) as List;
|
||||
_tileProviders = providersList
|
||||
.map((json) => TileProvider.fromJson(json))
|
||||
.toList();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error loading tile providers: $e');
|
||||
// Fall back to defaults on error
|
||||
_tileProviders = DefaultTileProviders.createDefaults();
|
||||
}
|
||||
} else {
|
||||
// First time - use defaults
|
||||
_tileProviders = DefaultTileProviders.createDefaults();
|
||||
await _saveTileProviders(prefs);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveTileProviders(SharedPreferences prefs) async {
|
||||
try {
|
||||
final providersJson = jsonEncode(
|
||||
_tileProviders.map((provider) => provider.toJson()).toList(),
|
||||
);
|
||||
await prefs.setString(_tileProvidersPrefsKey, providersJson);
|
||||
} catch (e) {
|
||||
debugPrint('Error saving tile providers: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setOfflineMode(bool enabled) async {
|
||||
_offlineMode = enabled;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_offlineModePrefsKey, enabled);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
set maxCameras(int n) {
|
||||
if (n < 10) n = 10; // minimum
|
||||
_maxCameras = n;
|
||||
SharedPreferences.getInstance().then((prefs) {
|
||||
prefs.setInt(_maxCamerasPrefsKey, n);
|
||||
});
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setUploadMode(UploadMode mode) async {
|
||||
_uploadMode = mode;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_uploadModePrefsKey, mode.index);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Select a tile type by ID
|
||||
Future<void> setSelectedTileType(String tileTypeId) async {
|
||||
if (_selectedTileTypeId != tileTypeId) {
|
||||
_selectedTileTypeId = tileTypeId;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_selectedTileTypePrefsKey, tileTypeId);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Add or update a tile provider
|
||||
Future<void> addOrUpdateTileProvider(TileProvider provider) async {
|
||||
final existingIndex = _tileProviders.indexWhere((p) => p.id == provider.id);
|
||||
if (existingIndex >= 0) {
|
||||
_tileProviders[existingIndex] = provider;
|
||||
} else {
|
||||
_tileProviders.add(provider);
|
||||
}
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await _saveTileProviders(prefs);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Delete a tile provider
|
||||
Future<void> deleteTileProvider(String providerId) async {
|
||||
// Don't allow deleting all providers
|
||||
if (_tileProviders.length <= 1) return;
|
||||
|
||||
final providerToDelete = _tileProviders.firstWhereOrNull((p) => p.id == providerId);
|
||||
if (providerToDelete == null) return;
|
||||
|
||||
// If selected tile type belongs to this provider, switch to another
|
||||
if (providerToDelete.tileTypes.any((type) => type.id == _selectedTileTypeId)) {
|
||||
// Find first available tile type from remaining providers
|
||||
final remainingProviders = _tileProviders.where((p) => p.id != providerId).toList();
|
||||
final firstAvailable = remainingProviders
|
||||
.expand((p) => p.availableTileTypes)
|
||||
.firstOrNull;
|
||||
|
||||
if (firstAvailable != null) {
|
||||
_selectedTileTypeId = firstAvailable.id;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_selectedTileTypePrefsKey, _selectedTileTypeId);
|
||||
}
|
||||
}
|
||||
|
||||
_tileProviders.removeWhere((p) => p.id == providerId);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await _saveTileProviders(prefs);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Set follow-me mode
|
||||
Future<void> setFollowMeMode(FollowMeMode mode) async {
|
||||
if (_followMeMode != mode) {
|
||||
_followMeMode = mode;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_followMeModePrefsKey, mode.index);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
224
lib/state/upload_queue_state.dart
Normal file
224
lib/state/upload_queue_state.dart
Normal file
@@ -0,0 +1,224 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/pending_upload.dart';
|
||||
import '../models/osm_camera_node.dart';
|
||||
import '../services/camera_cache.dart';
|
||||
import '../services/uploader.dart';
|
||||
import '../widgets/camera_provider_with_cache.dart';
|
||||
import 'settings_state.dart';
|
||||
import 'session_state.dart';
|
||||
|
||||
class UploadQueueState extends ChangeNotifier {
|
||||
final List<PendingUpload> _queue = [];
|
||||
Timer? _uploadTimer;
|
||||
|
||||
// Getters
|
||||
int get pendingCount => _queue.length;
|
||||
List<PendingUpload> get pendingUploads => List.unmodifiable(_queue);
|
||||
|
||||
// Initialize by loading queue from storage
|
||||
Future<void> init() async {
|
||||
await _loadQueue();
|
||||
}
|
||||
|
||||
// Add a completed session to the upload queue
|
||||
void addFromSession(AddNodeSession session, {required UploadMode uploadMode}) {
|
||||
final upload = PendingUpload(
|
||||
coord: session.target!,
|
||||
direction: session.directionDegrees,
|
||||
profile: session.profile,
|
||||
operatorProfile: session.operatorProfile,
|
||||
uploadMode: uploadMode,
|
||||
);
|
||||
|
||||
_queue.add(upload);
|
||||
_saveQueue();
|
||||
|
||||
// Add to camera cache immediately so it shows on the map
|
||||
// Create a temporary node with a negative ID (to distinguish from real OSM nodes)
|
||||
// Using timestamp as negative ID to ensure uniqueness
|
||||
final tempId = -DateTime.now().millisecondsSinceEpoch;
|
||||
final tags = upload.getCombinedTags();
|
||||
tags['_pending_upload'] = 'true'; // Mark as pending for potential UI distinction
|
||||
|
||||
final tempNode = OsmCameraNode(
|
||||
id: tempId,
|
||||
coord: upload.coord,
|
||||
tags: tags,
|
||||
);
|
||||
|
||||
CameraCache.instance.addOrUpdate([tempNode]);
|
||||
// Notify camera provider to update the map
|
||||
CameraProviderWithCache.instance.notifyListeners();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Add a completed edit session to the upload queue
|
||||
void addFromEditSession(EditNodeSession session, {required UploadMode uploadMode}) {
|
||||
final upload = PendingUpload(
|
||||
coord: session.target,
|
||||
direction: session.directionDegrees,
|
||||
profile: session.profile,
|
||||
operatorProfile: session.operatorProfile,
|
||||
uploadMode: uploadMode,
|
||||
originalNodeId: session.originalNode.id, // Track which node we're editing
|
||||
);
|
||||
|
||||
_queue.add(upload);
|
||||
_saveQueue();
|
||||
|
||||
// Create two cache entries:
|
||||
|
||||
// 1. Mark the original camera with _pending_edit (grey ring) at original location
|
||||
final originalTags = Map<String, String>.from(session.originalNode.tags);
|
||||
originalTags['_pending_edit'] = 'true'; // Mark original as having pending edit
|
||||
|
||||
final originalNode = OsmCameraNode(
|
||||
id: session.originalNode.id,
|
||||
coord: session.originalNode.coord, // Keep at original location
|
||||
tags: originalTags,
|
||||
);
|
||||
|
||||
// 2. Create new temp node for the edited camera (purple ring) at new location
|
||||
final tempId = -DateTime.now().millisecondsSinceEpoch;
|
||||
final editedTags = upload.getCombinedTags();
|
||||
editedTags['_pending_upload'] = 'true'; // Mark as pending upload
|
||||
editedTags['_original_node_id'] = session.originalNode.id.toString(); // Track original for line drawing
|
||||
|
||||
final editedNode = OsmCameraNode(
|
||||
id: tempId,
|
||||
coord: upload.coord, // At new location
|
||||
tags: editedTags,
|
||||
);
|
||||
|
||||
CameraCache.instance.addOrUpdate([originalNode, editedNode]);
|
||||
// Notify camera provider to update the map
|
||||
CameraProviderWithCache.instance.notifyListeners();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearQueue() {
|
||||
_queue.clear();
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void removeFromQueue(PendingUpload upload) {
|
||||
_queue.remove(upload);
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void retryUpload(PendingUpload upload) {
|
||||
upload.error = false;
|
||||
upload.attempts = 0;
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Start the upload processing loop
|
||||
void startUploader({
|
||||
required bool offlineMode,
|
||||
required UploadMode uploadMode,
|
||||
required Future<String?> Function() getAccessToken,
|
||||
}) {
|
||||
_uploadTimer?.cancel();
|
||||
|
||||
// No uploads without queue, or if offline mode is enabled.
|
||||
if (_queue.isEmpty || offlineMode) return;
|
||||
|
||||
_uploadTimer = Timer.periodic(const Duration(seconds: 10), (t) async {
|
||||
if (_queue.isEmpty || offlineMode) {
|
||||
_uploadTimer?.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the first queue item that is NOT in error state and act on that
|
||||
final item = _queue.where((pu) => !pu.error).cast<PendingUpload?>().firstOrNull;
|
||||
if (item == null) return;
|
||||
|
||||
// Retrieve access after every tick (accounts for re-login)
|
||||
final access = await getAccessToken();
|
||||
if (access == null) return; // not logged in
|
||||
|
||||
bool ok;
|
||||
debugPrint('[UploadQueue] Processing item with uploadMode: ${item.uploadMode}');
|
||||
if (item.uploadMode == UploadMode.simulate) {
|
||||
// Simulate successful upload without calling real API
|
||||
debugPrint('[UploadQueue] Simulating upload (no real API call)');
|
||||
await Future.delayed(const Duration(seconds: 1)); // Simulate network delay
|
||||
ok = true;
|
||||
} else {
|
||||
// Real upload -- use the upload mode that was saved when this item was queued
|
||||
debugPrint('[UploadQueue] Real upload to: ${item.uploadMode}');
|
||||
final up = Uploader(access, () {
|
||||
_markAsCompleting(item);
|
||||
}, uploadMode: item.uploadMode);
|
||||
ok = await up.upload(item);
|
||||
}
|
||||
|
||||
if (ok && item.uploadMode == UploadMode.simulate) {
|
||||
// Mark as completing for simulate mode too
|
||||
_markAsCompleting(item);
|
||||
}
|
||||
if (!ok) {
|
||||
item.attempts++;
|
||||
if (item.attempts >= 3) {
|
||||
// Mark as error and stop the uploader. User can manually retry.
|
||||
item.error = true;
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
_uploadTimer?.cancel();
|
||||
} else {
|
||||
await Future.delayed(const Duration(seconds: 20));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void stopUploader() {
|
||||
_uploadTimer?.cancel();
|
||||
}
|
||||
|
||||
// Mark an item as completing (shows checkmark) and schedule removal after 1 second
|
||||
void _markAsCompleting(PendingUpload item) {
|
||||
item.completing = true;
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
|
||||
// Remove the item after 1 second
|
||||
Timer(const Duration(seconds: 1), () {
|
||||
_queue.remove(item);
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Queue persistence ----------
|
||||
Future<void> _saveQueue() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonList = _queue.map((e) => e.toJson()).toList();
|
||||
await prefs.setString('queue', jsonEncode(jsonList));
|
||||
}
|
||||
|
||||
Future<void> _loadQueue() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonStr = prefs.getString('queue');
|
||||
if (jsonStr == null) return;
|
||||
final list = jsonDecode(jsonStr) as List<dynamic>;
|
||||
_queue
|
||||
..clear()
|
||||
..addAll(list.map((e) => PendingUpload.fromJson(e)));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_uploadTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../models/camera_profile.dart';
|
||||
|
||||
class AddCameraSheet extends StatelessWidget {
|
||||
const AddCameraSheet({super.key, required this.session});
|
||||
|
||||
final AddCameraSession session;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
void _commit() {
|
||||
appState.commitSession();
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Camera queued for upload')),
|
||||
);
|
||||
}
|
||||
|
||||
void _cancel() {
|
||||
appState.cancelSession();
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
final customProfiles = appState.enabledProfiles.where((p) => !p.builtin).toList();
|
||||
final allowSubmit = customProfiles.isNotEmpty && !session.profile.builtin;
|
||||
|
||||
return Padding(
|
||||
padding:
|
||||
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade400,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
title: const Text('Profile'),
|
||||
trailing: DropdownButton<CameraProfile>(
|
||||
value: session.profile,
|
||||
items: appState.enabledProfiles
|
||||
.map((p) => DropdownMenuItem(value: p, child: Text(p.name)))
|
||||
.toList(),
|
||||
onChanged: (p) =>
|
||||
appState.updateSession(profile: p ?? session.profile),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Direction ${session.directionDegrees.round()}°'),
|
||||
subtitle: Slider(
|
||||
min: 0,
|
||||
max: 359,
|
||||
divisions: 359,
|
||||
value: session.directionDegrees,
|
||||
label: session.directionDegrees.round().toString(),
|
||||
onChanged: (v) => appState.updateSession(directionDeg: v),
|
||||
),
|
||||
),
|
||||
if (customProfiles.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: const [
|
||||
Icon(Icons.info_outline, color: Colors.red, size: 20),
|
||||
SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Enable or create a custom profile in Settings to submit new cameras.',
|
||||
style: TextStyle(color: Colors.red, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (session.profile.builtin)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: const [
|
||||
Icon(Icons.info_outline, color: Colors.orange, size: 20),
|
||||
SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'The built-in profile is for map viewing only. Please select a custom profile to submit new cameras.',
|
||||
style: TextStyle(color: Colors.orange, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: _cancel,
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: allowSubmit ? _commit : null,
|
||||
child: const Text('Submit'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
194
lib/widgets/add_node_sheet.dart
Normal file
194
lib/widgets/add_node_sheet.dart
Normal file
@@ -0,0 +1,194 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/operator_profile.dart';
|
||||
import 'refine_tags_sheet.dart';
|
||||
|
||||
class AddNodeSheet extends StatelessWidget {
|
||||
const AddNodeSheet({super.key, required this.session});
|
||||
|
||||
final AddNodeSession session;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
void _commit() {
|
||||
appState.commitSession();
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Node queued for upload')),
|
||||
);
|
||||
}
|
||||
|
||||
void _cancel() {
|
||||
appState.cancelSession();
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList();
|
||||
final allowSubmit = appState.isLoggedIn && submittableProfiles.isNotEmpty && session.profile.isSubmittable;
|
||||
|
||||
void _openRefineTags() async {
|
||||
final result = await Navigator.push<OperatorProfile?>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RefineTagsSheet(
|
||||
selectedOperatorProfile: session.operatorProfile,
|
||||
),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
if (result != session.operatorProfile) {
|
||||
appState.updateSession(operatorProfile: result);
|
||||
}
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding:
|
||||
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade400,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
title: const Text('Profile'),
|
||||
trailing: DropdownButton<NodeProfile>(
|
||||
value: session.profile,
|
||||
items: submittableProfiles
|
||||
.map((p) => DropdownMenuItem(value: p, child: Text(p.name)))
|
||||
.toList(),
|
||||
onChanged: (p) =>
|
||||
appState.updateSession(profile: p ?? session.profile),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Direction ${session.directionDegrees.round()}°'),
|
||||
subtitle: Slider(
|
||||
min: 0,
|
||||
max: 359,
|
||||
divisions: 359,
|
||||
value: session.directionDegrees,
|
||||
label: session.directionDegrees.round().toString(),
|
||||
onChanged: session.profile.requiresDirection
|
||||
? (v) => appState.updateSession(directionDeg: v)
|
||||
: null, // Disables slider when requiresDirection is false
|
||||
),
|
||||
),
|
||||
if (!session.profile.requiresDirection)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Colors.grey, size: 16),
|
||||
SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'This profile does not require a direction.',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!appState.isLoggedIn)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Colors.red, size: 20),
|
||||
SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'You must be logged in to submit new nodes. Please log in via Settings.',
|
||||
style: TextStyle(color: Colors.red, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (submittableProfiles.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Colors.red, size: 20),
|
||||
SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Enable a submittable profile in Settings to submit new nodes.',
|
||||
style: TextStyle(color: Colors.red, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (!session.profile.isSubmittable)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Colors.orange, size: 20),
|
||||
SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'This profile is for map viewing only. Please select a submittable profile to submit new nodes.',
|
||||
style: TextStyle(color: Colors.orange, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _openRefineTags,
|
||||
icon: const Icon(Icons.tune),
|
||||
label: Text(session.operatorProfile != null
|
||||
? 'Refine Tags (${session.operatorProfile!.name})'
|
||||
: 'Refine Tags'),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: _cancel,
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: allowSubmit ? _commit : null,
|
||||
child: const Text('Submit'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
48
lib/widgets/camera_icon.dart
Normal file
48
lib/widgets/camera_icon.dart
Normal file
@@ -0,0 +1,48 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../dev_config.dart';
|
||||
|
||||
enum CameraIconType {
|
||||
real, // Blue ring - real cameras from OSM
|
||||
mock, // White ring - add camera mock point
|
||||
pending, // Purple ring - submitted/pending cameras
|
||||
editing, // Orange ring - camera being edited
|
||||
pendingEdit, // Grey ring - original camera with pending edit
|
||||
}
|
||||
|
||||
/// Simple camera icon with grey dot and colored ring
|
||||
class CameraIcon extends StatelessWidget {
|
||||
final CameraIconType type;
|
||||
|
||||
const CameraIcon({super.key, required this.type});
|
||||
|
||||
Color get _ringColor {
|
||||
switch (type) {
|
||||
case CameraIconType.real:
|
||||
return kCameraRingColorReal;
|
||||
case CameraIconType.mock:
|
||||
return kCameraRingColorMock;
|
||||
case CameraIconType.pending:
|
||||
return kCameraRingColorPending;
|
||||
case CameraIconType.editing:
|
||||
return kCameraRingColorEditing;
|
||||
case CameraIconType.pendingEdit:
|
||||
return kCameraRingColorPendingEdit;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: kCameraIconDiameter,
|
||||
height: kCameraIconDiameter,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.black.withOpacity(kCameraDotOpacity),
|
||||
border: Border.all(
|
||||
color: _ringColor,
|
||||
width: kCameraRingThickness,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@ import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
|
||||
import '../services/map_data_provider.dart';
|
||||
import '../services/camera_cache.dart';
|
||||
import '../models/camera_profile.dart';
|
||||
import '../services/network_status.dart';
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/osm_camera_node.dart';
|
||||
import '../app_state.dart';
|
||||
|
||||
@@ -19,15 +20,25 @@ class CameraProviderWithCache extends ChangeNotifier {
|
||||
Timer? _debounceTimer;
|
||||
|
||||
/// Call this to get (quickly) all cached overlays for the given view.
|
||||
/// Filters by currently enabled profiles.
|
||||
List<OsmCameraNode> getCachedCamerasForBounds(LatLngBounds bounds) {
|
||||
return CameraCache.instance.queryByBounds(bounds);
|
||||
final allCameras = CameraCache.instance.queryByBounds(bounds);
|
||||
final enabledProfiles = AppState.instance.enabledProfiles;
|
||||
|
||||
// If no profiles are enabled, show no cameras
|
||||
if (enabledProfiles.isEmpty) return [];
|
||||
|
||||
// Filter cameras to only show those matching enabled profiles
|
||||
return allCameras.where((camera) {
|
||||
return _matchesAnyProfile(camera, enabledProfiles);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Call this when the map view changes (bounds/profiles), triggers async fetch
|
||||
/// and notifies listeners/UI when new data is available.
|
||||
void fetchAndUpdate({
|
||||
required LatLngBounds bounds,
|
||||
required List<CameraProfile> profiles,
|
||||
required List<NodeProfile> profiles,
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
}) {
|
||||
// Fast: serve cached immediately
|
||||
@@ -37,7 +48,7 @@ class CameraProviderWithCache extends ChangeNotifier {
|
||||
_debounceTimer = Timer(const Duration(milliseconds: 400), () async {
|
||||
try {
|
||||
// Use MapSource.auto to handle both offline and online modes appropriately
|
||||
final fresh = await MapDataProvider().getCameras(
|
||||
final fresh = await MapDataProvider().getNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
@@ -45,6 +56,8 @@ class CameraProviderWithCache extends ChangeNotifier {
|
||||
);
|
||||
if (fresh.isNotEmpty) {
|
||||
CameraCache.instance.addOrUpdate(fresh);
|
||||
// Clear waiting status when camera data arrives
|
||||
NetworkStatus.instance.clearWaiting();
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -59,4 +72,25 @@ class CameraProviderWithCache extends ChangeNotifier {
|
||||
CameraCache.instance.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Force refresh the display (useful when filters change but cache doesn't)
|
||||
void refreshDisplay() {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Check if a camera matches any of the provided profiles
|
||||
bool _matchesAnyProfile(OsmCameraNode camera, List<NodeProfile> profiles) {
|
||||
for (final profile in profiles) {
|
||||
if (_cameraMatchesProfile(camera, profile)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Check if a camera matches a specific profile (all profile tags must match)
|
||||
bool _cameraMatchesProfile(OsmCameraNode camera, NodeProfile profile) {
|
||||
for (final entry in profile.tags.entries) {
|
||||
if (camera.tags[entry.key] != entry.value) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/osm_camera_node.dart';
|
||||
import '../app_state.dart';
|
||||
|
||||
class CameraTagSheet extends StatelessWidget {
|
||||
final OsmCameraNode node;
|
||||
@@ -8,13 +10,27 @@ class CameraTagSheet extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
// Check if this camera is editable (not a pending upload or pending edit)
|
||||
final isEditable = (!node.tags.containsKey('_pending_upload') ||
|
||||
node.tags['_pending_upload'] != 'true') &&
|
||||
(!node.tags.containsKey('_pending_edit') ||
|
||||
node.tags['_pending_edit'] != 'true');
|
||||
|
||||
void _openEditSheet() {
|
||||
Navigator.pop(context); // Close this sheet first
|
||||
appState.startEditSession(node); // HomeScreen will auto-show the edit sheet
|
||||
}
|
||||
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Camera #${node.id}',
|
||||
style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 12),
|
||||
@@ -45,16 +61,30 @@ class CameraTagSheet extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (isEditable) ...[
|
||||
ElevatedButton.icon(
|
||||
onPressed: _openEditSheet,
|
||||
icon: const Icon(Icons.edit, size: 18),
|
||||
label: const Text('Edit'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(0, 36),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
279
lib/widgets/download_area_dialog.dart
Normal file
279
lib/widgets/download_area_dialog.dart
Normal file
@@ -0,0 +1,279 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../dev_config.dart';
|
||||
import '../services/offline_area_service.dart';
|
||||
import '../services/offline_areas/offline_tile_utils.dart';
|
||||
|
||||
class DownloadAreaDialog extends StatefulWidget {
|
||||
final MapController controller;
|
||||
const DownloadAreaDialog({super.key, required this.controller});
|
||||
|
||||
@override
|
||||
State<DownloadAreaDialog> createState() => _DownloadAreaDialogState();
|
||||
}
|
||||
|
||||
class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
|
||||
double _zoom = 15;
|
||||
int? _minZoom;
|
||||
int? _maxPossibleZoom;
|
||||
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 = kWorldMaxZoom + 1;
|
||||
final maxZoom = _zoom.toInt();
|
||||
|
||||
// Calculate maximum possible zoom based on tile count limit
|
||||
final maxPossibleZoom = _calculateMaxZoomForTileLimit(bounds, minZoom);
|
||||
|
||||
final nTiles = computeTileList(bounds, minZoom, maxZoom).length;
|
||||
final totalMb = (nTiles * kTileEstimateKb) / 1024.0;
|
||||
|
||||
setState(() {
|
||||
_minZoom = minZoom;
|
||||
_maxPossibleZoom = maxPossibleZoom;
|
||||
_tileCount = nTiles;
|
||||
_mbEstimate = totalMb;
|
||||
});
|
||||
}
|
||||
|
||||
/// Calculate the maximum zoom level that keeps tile count under the limit
|
||||
int _calculateMaxZoomForTileLimit(LatLngBounds bounds, int minZoom) {
|
||||
for (int zoom = minZoom; zoom <= kAbsoluteMaxZoom; zoom++) {
|
||||
final tileCount = computeTileList(bounds, minZoom, zoom).length;
|
||||
if (tileCount > kMaxReasonableTileCount) {
|
||||
// Return the previous zoom level that was still under the limit
|
||||
return math.max(minZoom, zoom - 1);
|
||||
}
|
||||
}
|
||||
return kAbsoluteMaxZoom;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
final bounds = widget.controller.camera.visibleBounds;
|
||||
final maxZoom = _zoom.toInt();
|
||||
final isOfflineMode = appState.offlineMode;
|
||||
|
||||
// Use the calculated max possible zoom instead of fixed span
|
||||
final sliderMin = _minZoom?.toDouble() ?? 12.0;
|
||||
final sliderMax = _maxPossibleZoom?.toDouble() ?? 19.0;
|
||||
final sliderDivisions = math.max(1, (_maxPossibleZoom ?? 19) - (_minZoom ?? 12));
|
||||
final sliderValue = _zoom.clamp(sliderMin, sliderMax);
|
||||
|
||||
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: sliderMin,
|
||||
max: sliderMax,
|
||||
divisions: sliderDivisions,
|
||||
label: 'Z${_zoom.toStringAsFixed(0)}',
|
||||
value: sliderValue,
|
||||
onChanged: (v) {
|
||||
setState(() => _zoom = v);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _recomputeEstimates());
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Storage estimate:'),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_mbEstimate == null
|
||||
? '…'
|
||||
: '${_tileCount} tiles, ${_mbEstimate!.toStringAsFixed(1)} MB',
|
||||
textAlign: TextAlign.end,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_minZoom != null)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Min zoom:'),
|
||||
Text('Z$_minZoom'),
|
||||
],
|
||||
),
|
||||
if (_maxPossibleZoom != null && _tileCount != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: _tileCount! > kMaxReasonableTileCount
|
||||
? Colors.orange.withOpacity(0.1)
|
||||
: Colors.green.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Max recommended zoom: Z$_maxPossibleZoom',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _tileCount! > kMaxReasonableTileCount
|
||||
? Colors.orange[700]
|
||||
: Colors.green[700],
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_tileCount! > kMaxReasonableTileCount
|
||||
? 'Current selection exceeds ${kMaxReasonableTileCount.toString()} tile limit'
|
||||
: 'Within ${kMaxReasonableTileCount.toString()} tile limit',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: _tileCount! > kMaxReasonableTileCount
|
||||
? Colors.orange[600]
|
||||
: Colors.green[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isOfflineMode)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12.0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.orange.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.wifi_off, color: Colors.orange[700], size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Downloads disabled while in offline mode. Disable offline mode to download new areas.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.orange[700],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: isOfflineMode ? null : () async {
|
||||
try {
|
||||
final id = DateTime.now().toIso8601String().replaceAll(':', '-');
|
||||
final appDocDir = await OfflineAreaService().getOfflineAreaDir();
|
||||
final dir = "${appDocDir.path}/$id";
|
||||
|
||||
// Get current tile provider info
|
||||
final appState = context.read<AppState>();
|
||||
final selectedProvider = appState.selectedTileProvider;
|
||||
final selectedTileType = appState.selectedTileType;
|
||||
|
||||
// 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) {},
|
||||
tileProviderId: selectedProvider?.id,
|
||||
tileProviderName: selectedProvider?.name,
|
||||
tileTypeId: selectedTileType?.id,
|
||||
tileTypeName: selectedTileType?.name,
|
||||
);
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Download started! Fetching tiles and cameras...'),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to start download: $e'),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Download'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
216
lib/widgets/edit_node_sheet.dart
Normal file
216
lib/widgets/edit_node_sheet.dart
Normal file
@@ -0,0 +1,216 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/operator_profile.dart';
|
||||
import '../state/settings_state.dart';
|
||||
import 'refine_tags_sheet.dart';
|
||||
|
||||
class EditNodeSheet extends StatelessWidget {
|
||||
const EditNodeSheet({super.key, required this.session});
|
||||
|
||||
final EditNodeSession session;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
void _commit() {
|
||||
appState.commitEditSession();
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Node edit queued for upload')),
|
||||
);
|
||||
}
|
||||
|
||||
void _cancel() {
|
||||
appState.cancelEditSession();
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList();
|
||||
final isSandboxMode = appState.uploadMode == UploadMode.sandbox;
|
||||
final allowSubmit = appState.isLoggedIn && submittableProfiles.isNotEmpty && session.profile.isSubmittable && !isSandboxMode;
|
||||
|
||||
void _openRefineTags() async {
|
||||
final result = await Navigator.push<OperatorProfile?>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RefineTagsSheet(
|
||||
selectedOperatorProfile: session.operatorProfile,
|
||||
),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
if (result != session.operatorProfile) {
|
||||
appState.updateEditSession(operatorProfile: result);
|
||||
}
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding:
|
||||
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade400,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Edit Node #${session.originalNode.id}',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
title: const Text('Profile'),
|
||||
trailing: DropdownButton<NodeProfile>(
|
||||
value: session.profile,
|
||||
items: submittableProfiles
|
||||
.map((p) => DropdownMenuItem(value: p, child: Text(p.name)))
|
||||
.toList(),
|
||||
onChanged: (p) =>
|
||||
appState.updateEditSession(profile: p ?? session.profile),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Direction ${session.directionDegrees.round()}°'),
|
||||
subtitle: Slider(
|
||||
min: 0,
|
||||
max: 359,
|
||||
divisions: 359,
|
||||
value: session.directionDegrees,
|
||||
label: session.directionDegrees.round().toString(),
|
||||
onChanged: session.profile.requiresDirection
|
||||
? (v) => appState.updateEditSession(directionDeg: v)
|
||||
: null, // Disables slider when requiresDirection is false
|
||||
),
|
||||
),
|
||||
if (!session.profile.requiresDirection)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Colors.grey, size: 16),
|
||||
SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'This profile does not require a direction.',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!appState.isLoggedIn)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Colors.red, size: 20),
|
||||
SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'You must be logged in to edit nodes. Please log in via Settings.',
|
||||
style: TextStyle(color: Colors.red, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (isSandboxMode)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Colors.blue, size: 20),
|
||||
SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Cannot submit edits on production nodes to sandbox. Switch to Production mode in Settings to edit nodes.',
|
||||
style: TextStyle(color: Colors.blue, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (submittableProfiles.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Colors.red, size: 20),
|
||||
SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Enable a submittable profile in Settings to edit nodes.',
|
||||
style: TextStyle(color: Colors.red, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (!session.profile.isSubmittable)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Colors.orange, size: 20),
|
||||
SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'This profile is for map viewing only. Please select a submittable profile to edit nodes.',
|
||||
style: TextStyle(color: Colors.orange, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _openRefineTags,
|
||||
icon: const Icon(Icons.tune),
|
||||
label: Text(session.operatorProfile != null
|
||||
? 'Refine Tags (${session.operatorProfile!.name})'
|
||||
: 'Refine Tags'),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: _cancel,
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: allowSubmit ? _commit : null,
|
||||
child: const Text('Save Edit'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
108
lib/widgets/map/camera_markers.dart
Normal file
108
lib/widgets/map/camera_markers.dart
Normal file
@@ -0,0 +1,108 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../../dev_config.dart';
|
||||
import '../../models/osm_camera_node.dart';
|
||||
import '../camera_tag_sheet.dart';
|
||||
import '../camera_icon.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) {
|
||||
// Check camera state
|
||||
final isPendingUpload = widget.node.tags.containsKey('_pending_upload') &&
|
||||
widget.node.tags['_pending_upload'] == 'true';
|
||||
final isPendingEdit = widget.node.tags.containsKey('_pending_edit') &&
|
||||
widget.node.tags['_pending_edit'] == 'true';
|
||||
|
||||
CameraIconType iconType;
|
||||
if (isPendingUpload) {
|
||||
iconType = CameraIconType.pending;
|
||||
} else if (isPendingEdit) {
|
||||
iconType = CameraIconType.pendingEdit;
|
||||
} else {
|
||||
iconType = CameraIconType.real;
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: _onTap,
|
||||
onDoubleTap: _onDoubleTap,
|
||||
child: CameraIcon(type: iconType),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper class to build marker layers for cameras and user location
|
||||
class CameraMarkersBuilder {
|
||||
static List<Marker> buildCameraMarkers({
|
||||
required List<OsmCameraNode> cameras,
|
||||
required MapController mapController,
|
||||
LatLng? userLocation,
|
||||
}) {
|
||||
final markers = <Marker>[
|
||||
// Camera markers
|
||||
...cameras
|
||||
.where(_isValidCameraCoordinate)
|
||||
.map((n) => Marker(
|
||||
point: n.coord,
|
||||
width: kCameraIconDiameter,
|
||||
height: kCameraIconDiameter,
|
||||
child: CameraMapMarker(node: n, mapController: mapController),
|
||||
)),
|
||||
|
||||
// User location marker
|
||||
if (userLocation != null)
|
||||
Marker(
|
||||
point: userLocation,
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: const Icon(Icons.my_location, color: Colors.blue),
|
||||
),
|
||||
];
|
||||
|
||||
return markers;
|
||||
}
|
||||
|
||||
static bool _isValidCameraCoordinate(OsmCameraNode node) {
|
||||
return (node.coord.latitude != 0 || node.coord.longitude != 0) &&
|
||||
node.coord.latitude.abs() <= 90 &&
|
||||
node.coord.longitude.abs() <= 180;
|
||||
}
|
||||
}
|
||||
103
lib/widgets/map/camera_refresh_controller.dart
Normal file
103
lib/widgets/map/camera_refresh_controller.dart
Normal file
@@ -0,0 +1,103 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map_animations/flutter_map_animations.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../../models/node_profile.dart';
|
||||
import '../../app_state.dart' show UploadMode;
|
||||
import '../camera_provider_with_cache.dart';
|
||||
import '../../dev_config.dart';
|
||||
|
||||
/// Manages camera data refreshing, profile change detection, and camera cache operations.
|
||||
/// Handles debounced camera fetching and profile-based cache invalidation.
|
||||
class CameraRefreshController {
|
||||
late final CameraProviderWithCache _cameraProvider;
|
||||
List<NodeProfile>? _lastEnabledProfiles;
|
||||
VoidCallback? _onCamerasUpdated;
|
||||
|
||||
/// Initialize the camera refresh controller
|
||||
void initialize({required VoidCallback onCamerasUpdated}) {
|
||||
_cameraProvider = CameraProviderWithCache.instance;
|
||||
_onCamerasUpdated = onCamerasUpdated;
|
||||
_cameraProvider.addListener(_onCamerasUpdated!);
|
||||
}
|
||||
|
||||
/// Dispose of resources and listeners
|
||||
void dispose() {
|
||||
if (_onCamerasUpdated != null) {
|
||||
_cameraProvider.removeListener(_onCamerasUpdated!);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if camera profiles changed and handle cache clearing if needed.
|
||||
/// Returns true if profiles changed (triggering a refresh).
|
||||
bool checkAndHandleProfileChanges({
|
||||
required List<NodeProfile> currentEnabledProfiles,
|
||||
required VoidCallback onProfilesChanged,
|
||||
}) {
|
||||
if (_lastEnabledProfiles == null ||
|
||||
!_profileListsEqual(_lastEnabledProfiles!, currentEnabledProfiles)) {
|
||||
_lastEnabledProfiles = List.from(currentEnabledProfiles);
|
||||
|
||||
// Handle profile change with cache clearing and refresh
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// Clear camera cache to ensure fresh data for new profile combination
|
||||
_cameraProvider.clearCache();
|
||||
// Force display refresh first (for immediate UI update)
|
||||
_cameraProvider.refreshDisplay();
|
||||
// Notify that profiles changed (triggers camera refresh)
|
||||
onProfilesChanged();
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Refresh cameras from provider for the current map view
|
||||
void refreshCamerasFromProvider({
|
||||
required AnimatedMapController controller,
|
||||
required List<NodeProfile> enabledProfiles,
|
||||
required UploadMode uploadMode,
|
||||
required BuildContext context,
|
||||
}) {
|
||||
LatLngBounds? bounds;
|
||||
try {
|
||||
bounds = controller.mapController.camera.visibleBounds;
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
|
||||
final zoom = controller.mapController.camera.zoom;
|
||||
if (zoom < kCameraMinZoomLevel) {
|
||||
// Show a snackbar-style bubble warning
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Cameras not drawn below zoom level $kCameraMinZoomLevel'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_cameraProvider.fetchAndUpdate(
|
||||
bounds: bounds,
|
||||
profiles: enabledProfiles,
|
||||
uploadMode: uploadMode,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get the camera provider instance for external access
|
||||
CameraProviderWithCache get cameraProvider => _cameraProvider;
|
||||
|
||||
/// Helper to check if two profile lists are equal by comparing IDs
|
||||
bool _profileListsEqual(List<NodeProfile> list1, List<NodeProfile> list2) {
|
||||
if (list1.length != list2.length) return false;
|
||||
// Compare by profile IDs since profiles are value objects
|
||||
final ids1 = list1.map((p) => p.id).toSet();
|
||||
final ids2 = list2.map((p) => p.id).toSet();
|
||||
return ids1.length == ids2.length && ids1.containsAll(ids2);
|
||||
}
|
||||
}
|
||||
108
lib/widgets/map/direction_cones.dart
Normal file
108
lib/widgets/map/direction_cones.dart
Normal file
@@ -0,0 +1,108 @@
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../../app_state.dart';
|
||||
import '../../dev_config.dart';
|
||||
import '../../models/osm_camera_node.dart';
|
||||
|
||||
/// Helper class to build direction cone polygons for cameras
|
||||
class DirectionConesBuilder {
|
||||
static List<Polygon> buildDirectionCones({
|
||||
required List<OsmCameraNode> cameras,
|
||||
required double zoom,
|
||||
AddNodeSession? session,
|
||||
EditNodeSession? editSession,
|
||||
}) {
|
||||
final overlays = <Polygon>[];
|
||||
|
||||
// Add session cone if in add-camera mode and profile requires direction
|
||||
if (session != null && session.target != null && session.profile.requiresDirection) {
|
||||
overlays.add(_buildCone(
|
||||
session.target!,
|
||||
session.directionDegrees,
|
||||
zoom,
|
||||
isSession: true,
|
||||
));
|
||||
}
|
||||
|
||||
// Add edit session cone if in edit-camera mode and profile requires direction
|
||||
if (editSession != null && editSession.profile.requiresDirection) {
|
||||
overlays.add(_buildCone(
|
||||
editSession.target,
|
||||
editSession.directionDegrees,
|
||||
zoom,
|
||||
isSession: true,
|
||||
));
|
||||
}
|
||||
|
||||
// Add cones for cameras with direction (but exclude camera being edited)
|
||||
overlays.addAll(
|
||||
cameras
|
||||
.where((n) => _isValidCameraWithDirection(n) &&
|
||||
(editSession == null || n.id != editSession.originalNode.id))
|
||||
.map((n) => _buildCone(
|
||||
n.coord,
|
||||
n.directionDeg!,
|
||||
zoom,
|
||||
))
|
||||
);
|
||||
|
||||
return overlays;
|
||||
}
|
||||
|
||||
static bool _isValidCameraWithDirection(OsmCameraNode node) {
|
||||
return node.hasDirection &&
|
||||
node.directionDeg != null &&
|
||||
(node.coord.latitude != 0 || node.coord.longitude != 0) &&
|
||||
node.coord.latitude.abs() <= 90 &&
|
||||
node.coord.longitude.abs() <= 180;
|
||||
}
|
||||
|
||||
static bool _isPendingUpload(OsmCameraNode node) {
|
||||
return node.tags.containsKey('_pending_upload') &&
|
||||
node.tags['_pending_upload'] == 'true';
|
||||
}
|
||||
|
||||
static Polygon _buildCone(
|
||||
LatLng origin,
|
||||
double bearingDeg,
|
||||
double zoom, {
|
||||
bool isPending = false,
|
||||
bool isSession = false,
|
||||
}) {
|
||||
final halfAngle = kDirectionConeHalfAngle;
|
||||
final length = kDirectionConeBaseLength * math.pow(2, 15 - zoom);
|
||||
|
||||
// Number of points to create the arc (more = smoother curve)
|
||||
const int arcPoints = 12;
|
||||
|
||||
LatLng project(double deg) {
|
||||
final rad = deg * math.pi / 180;
|
||||
final dLat = length * math.cos(rad);
|
||||
final dLon =
|
||||
length * math.sin(rad) / math.cos(origin.latitude * math.pi / 180);
|
||||
return LatLng(origin.latitude + dLat, origin.longitude + dLon);
|
||||
}
|
||||
|
||||
// Build pizza slice with curved edge
|
||||
final points = <LatLng>[origin];
|
||||
|
||||
// Add arc points from left to right
|
||||
for (int i = 0; i <= arcPoints; i++) {
|
||||
final angle = bearingDeg - halfAngle + (i * 2 * halfAngle / arcPoints);
|
||||
points.add(project(angle));
|
||||
}
|
||||
|
||||
// Close the shape back to origin
|
||||
points.add(origin);
|
||||
|
||||
return Polygon(
|
||||
points: points,
|
||||
color: kDirectionConeColor.withOpacity(0.25),
|
||||
borderColor: kDirectionConeColor,
|
||||
borderStrokeWidth: 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
160
lib/widgets/map/gps_controller.dart
Normal file
160
lib/widgets/map/gps_controller.dart
Normal file
@@ -0,0 +1,160 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map_animations/flutter_map_animations.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../../dev_config.dart';
|
||||
import '../../app_state.dart' show FollowMeMode;
|
||||
|
||||
/// Manages GPS location tracking, follow-me modes, and location-based map animations.
|
||||
/// Handles GPS permissions, position streams, and follow-me behavior.
|
||||
class GpsController {
|
||||
StreamSubscription<Position>? _positionSub;
|
||||
LatLng? _currentLatLng;
|
||||
|
||||
/// Get the current GPS location (if available)
|
||||
LatLng? get currentLocation => _currentLatLng;
|
||||
|
||||
/// Initialize GPS location tracking
|
||||
Future<void> initializeLocation() async {
|
||||
final perm = await Geolocator.requestPermission();
|
||||
if (perm == LocationPermission.denied ||
|
||||
perm == LocationPermission.deniedForever) {
|
||||
debugPrint('[GpsController] Location permission denied');
|
||||
return;
|
||||
}
|
||||
|
||||
_positionSub = Geolocator.getPositionStream().listen((Position position) {
|
||||
final latLng = LatLng(position.latitude, position.longitude);
|
||||
_currentLatLng = latLng;
|
||||
debugPrint('[GpsController] GPS position updated: ${latLng.latitude}, ${latLng.longitude}');
|
||||
});
|
||||
}
|
||||
|
||||
/// Retry location initialization (e.g., after permission granted)
|
||||
Future<void> retryLocationInit() async {
|
||||
debugPrint('[GpsController] Retrying location initialization');
|
||||
await initializeLocation();
|
||||
}
|
||||
|
||||
/// Handle follow-me mode changes and animate map accordingly
|
||||
void handleFollowMeModeChange({
|
||||
required FollowMeMode newMode,
|
||||
required FollowMeMode oldMode,
|
||||
required AnimatedMapController controller,
|
||||
}) {
|
||||
debugPrint('[GpsController] Follow-me mode changed: $oldMode → $newMode');
|
||||
|
||||
// Only act when follow-me is first enabled and we have a current location
|
||||
if (newMode != FollowMeMode.off &&
|
||||
oldMode == FollowMeMode.off &&
|
||||
_currentLatLng != null) {
|
||||
|
||||
try {
|
||||
if (newMode == FollowMeMode.northUp) {
|
||||
controller.animateTo(
|
||||
dest: _currentLatLng!,
|
||||
zoom: controller.mapController.camera.zoom,
|
||||
duration: kFollowMeAnimationDuration,
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
} else if (newMode == FollowMeMode.rotating) {
|
||||
// When switching to rotating mode, reset to north-up first
|
||||
controller.animateTo(
|
||||
dest: _currentLatLng!,
|
||||
zoom: controller.mapController.camera.zoom,
|
||||
rotation: 0.0,
|
||||
duration: kFollowMeAnimationDuration,
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[GpsController] MapController not ready for follow-me change: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Process GPS position updates and handle follow-me animations
|
||||
void processPositionUpdate({
|
||||
required Position position,
|
||||
required FollowMeMode followMeMode,
|
||||
required AnimatedMapController controller,
|
||||
required VoidCallback onLocationUpdated,
|
||||
}) {
|
||||
final latLng = LatLng(position.latitude, position.longitude);
|
||||
_currentLatLng = latLng;
|
||||
|
||||
// Notify that location was updated (for setState, etc.)
|
||||
onLocationUpdated();
|
||||
|
||||
// Handle follow-me animations if enabled - use current mode from app state
|
||||
if (followMeMode != FollowMeMode.off) {
|
||||
debugPrint('[GpsController] GPS position update: ${latLng.latitude}, ${latLng.longitude}, follow-me: $followMeMode');
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
try {
|
||||
if (followMeMode == FollowMeMode.northUp) {
|
||||
// Follow position only, keep current rotation
|
||||
controller.animateTo(
|
||||
dest: latLng,
|
||||
zoom: controller.mapController.camera.zoom,
|
||||
duration: kFollowMeAnimationDuration,
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
} else if (followMeMode == FollowMeMode.rotating) {
|
||||
// Follow position and rotation based on heading
|
||||
final heading = position.heading;
|
||||
final speed = position.speed; // Speed in m/s
|
||||
|
||||
// Only apply rotation if moving fast enough to avoid wild spinning when stationary
|
||||
final shouldRotate = !speed.isNaN && speed >= kMinSpeedForRotationMps && !heading.isNaN;
|
||||
final rotation = shouldRotate ? -heading : controller.mapController.camera.rotation;
|
||||
|
||||
controller.animateTo(
|
||||
dest: latLng,
|
||||
zoom: controller.mapController.camera.zoom,
|
||||
rotation: rotation,
|
||||
duration: kFollowMeAnimationDuration,
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[GpsController] MapController not ready for position animation: $e');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize GPS with custom position processing callback
|
||||
Future<void> initializeWithCallback({
|
||||
required FollowMeMode followMeMode,
|
||||
required AnimatedMapController controller,
|
||||
required VoidCallback onLocationUpdated,
|
||||
required FollowMeMode Function() getCurrentFollowMeMode,
|
||||
}) async {
|
||||
final perm = await Geolocator.requestPermission();
|
||||
if (perm == LocationPermission.denied ||
|
||||
perm == LocationPermission.deniedForever) {
|
||||
debugPrint('[GpsController] Location permission denied');
|
||||
return;
|
||||
}
|
||||
|
||||
_positionSub = Geolocator.getPositionStream().listen((Position position) {
|
||||
// Get the current follow-me mode from the app state each time
|
||||
final currentFollowMeMode = getCurrentFollowMeMode();
|
||||
processPositionUpdate(
|
||||
position: position,
|
||||
followMeMode: currentFollowMeMode,
|
||||
controller: controller,
|
||||
onLocationUpdated: onLocationUpdated,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Dispose of GPS resources
|
||||
void dispose() {
|
||||
_positionSub?.cancel();
|
||||
_positionSub = null;
|
||||
debugPrint('[GpsController] GPS controller disposed');
|
||||
}
|
||||
}
|
||||
234
lib/widgets/map/layer_selector_button.dart
Normal file
234
lib/widgets/map/layer_selector_button.dart
Normal file
@@ -0,0 +1,234 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../app_state.dart';
|
||||
import '../../models/tile_provider.dart';
|
||||
import '../../services/offline_area_service.dart';
|
||||
|
||||
class LayerSelectorButton extends StatelessWidget {
|
||||
const LayerSelectorButton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FloatingActionButton(
|
||||
mini: true,
|
||||
onPressed: () => _showLayerSelector(context),
|
||||
child: const Icon(Icons.layers),
|
||||
);
|
||||
}
|
||||
|
||||
void _showLayerSelector(BuildContext context) {
|
||||
// Check if any downloads are active
|
||||
final offlineService = OfflineAreaService();
|
||||
if (offlineService.hasActiveDownloads) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Cannot change tile types while downloading offline areas'),
|
||||
duration: Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => const _LayerSelectorDialog(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LayerSelectorDialog extends StatefulWidget {
|
||||
const _LayerSelectorDialog();
|
||||
|
||||
@override
|
||||
State<_LayerSelectorDialog> createState() => _LayerSelectorDialogState();
|
||||
}
|
||||
|
||||
class _LayerSelectorDialogState extends State<_LayerSelectorDialog> {
|
||||
String? _selectedTileTypeId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final appState = context.read<AppState>();
|
||||
_selectedTileTypeId = appState.selectedTileType?.id;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
final providers = appState.tileProviders;
|
||||
|
||||
// Group tile types by provider for display
|
||||
final providerGroups = <TileProvider, List<TileType>>{};
|
||||
for (final provider in providers) {
|
||||
final availableTypes = provider.availableTileTypes;
|
||||
if (availableTypes.isNotEmpty) {
|
||||
providerGroups[provider] = availableTypes;
|
||||
}
|
||||
}
|
||||
|
||||
return Dialog(
|
||||
child: Container(
|
||||
width: double.maxFinite,
|
||||
constraints: const BoxConstraints(maxHeight: 500),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceVariant,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.layers),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'Select Map Layer',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Content
|
||||
Flexible(
|
||||
child: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
if (providerGroups.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: Text('No tile providers available'),
|
||||
),
|
||||
)
|
||||
else
|
||||
...providerGroups.entries.map((entry) {
|
||||
final provider = entry.key;
|
||||
final tileTypes = entry.value;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Provider header
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Text(
|
||||
provider.name,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Tile types
|
||||
...tileTypes.map((tileType) => _TileTypeListItem(
|
||||
tileType: tileType,
|
||||
provider: provider,
|
||||
isSelected: _selectedTileTypeId == tileType.id,
|
||||
onSelected: () {
|
||||
setState(() {
|
||||
_selectedTileTypeId = tileType.id;
|
||||
});
|
||||
appState.setSelectedTileType(tileType.id);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
)),
|
||||
],
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TileTypeListItem extends StatelessWidget {
|
||||
final TileType tileType;
|
||||
final TileProvider provider;
|
||||
final bool isSelected;
|
||||
final VoidCallback onSelected;
|
||||
|
||||
const _TileTypeListItem({
|
||||
required this.tileType,
|
||||
required this.provider,
|
||||
required this.isSelected,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
leading: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.grey.shade300,
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: tileType.previewTile != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
child: Image.memory(
|
||||
tileType.previewTile!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => _FallbackPreview(),
|
||||
),
|
||||
)
|
||||
: _FallbackPreview(),
|
||||
),
|
||||
title: Text(
|
||||
tileType.name,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.bold : null,
|
||||
color: isSelected ? Theme.of(context).colorScheme.primary : null,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
tileType.attribution,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
trailing: isSelected
|
||||
? Icon(
|
||||
Icons.check_circle,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
)
|
||||
: null,
|
||||
onTap: onSelected,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FallbackPreview extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: Colors.grey.shade200,
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.map,
|
||||
size: 24,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
139
lib/widgets/map/map_overlays.dart
Normal file
139
lib/widgets/map/map_overlays.dart
Normal file
@@ -0,0 +1,139 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
|
||||
import '../../app_state.dart';
|
||||
import '../../dev_config.dart';
|
||||
import '../camera_icon.dart';
|
||||
import 'layer_selector_button.dart';
|
||||
|
||||
/// Widget that renders all map overlay UI elements
|
||||
class MapOverlays extends StatelessWidget {
|
||||
final MapController mapController;
|
||||
final UploadMode uploadMode;
|
||||
final AddNodeSession? session;
|
||||
final EditNodeSession? editSession;
|
||||
final String? attribution; // Attribution for current tile provider
|
||||
|
||||
const MapOverlays({
|
||||
super.key,
|
||||
required this.mapController,
|
||||
required this.uploadMode,
|
||||
this.session,
|
||||
this.editSession,
|
||||
this.attribution,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
// MODE INDICATOR badge (top-right)
|
||||
if (uploadMode == UploadMode.sandbox || uploadMode == UploadMode.simulate)
|
||||
Positioned(
|
||||
top: 18,
|
||||
right: 14,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: uploadMode == UploadMode.sandbox
|
||||
? Colors.orange.withOpacity(0.90)
|
||||
: Colors.deepPurple.withOpacity(0.80),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: const [
|
||||
BoxShadow(color: Colors.black26, blurRadius: 5, offset: Offset(0,2)),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
uploadMode == UploadMode.sandbox
|
||||
? 'SANDBOX MODE'
|
||||
: 'SIMULATE',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13,
|
||||
letterSpacing: 1.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Zoom indicator, positioned above scale bar
|
||||
Positioned(
|
||||
left: 10,
|
||||
bottom: kZoomIndicatorBottomOffset,
|
||||
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 = mapController.camera.zoom;
|
||||
return Text(
|
||||
'Zoom: ${zoom.toStringAsFixed(2)}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Attribution overlay
|
||||
if (attribution != null)
|
||||
Positioned(
|
||||
bottom: kAttributionBottomOffset,
|
||||
left: 10,
|
||||
child: Container(
|
||||
color: Colors.white70,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: Text(
|
||||
attribution!,
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Zoom and layer controls (bottom-right)
|
||||
Positioned(
|
||||
bottom: 80,
|
||||
right: 16,
|
||||
child: Column(
|
||||
children: [
|
||||
// Layer selector button
|
||||
const LayerSelectorButton(),
|
||||
const SizedBox(height: 8),
|
||||
// Zoom in button
|
||||
FloatingActionButton(
|
||||
mini: true,
|
||||
heroTag: "zoom_in",
|
||||
onPressed: () {
|
||||
final zoom = mapController.camera.zoom;
|
||||
mapController.move(mapController.camera.center, zoom + 1);
|
||||
},
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Zoom out button
|
||||
FloatingActionButton(
|
||||
mini: true,
|
||||
heroTag: "zoom_out",
|
||||
onPressed: () {
|
||||
final zoom = mapController.camera.zoom;
|
||||
mapController.move(mapController.camera.center, zoom - 1);
|
||||
},
|
||||
child: const Icon(Icons.remove),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
122
lib/widgets/map/map_position_manager.dart
Normal file
122
lib/widgets/map/map_position_manager.dart
Normal file
@@ -0,0 +1,122 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map_animations/flutter_map_animations.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../dev_config.dart';
|
||||
|
||||
|
||||
/// Manages map position persistence and initial positioning.
|
||||
/// Handles saving/loading last map position and moving to initial locations.
|
||||
class MapPositionManager {
|
||||
LatLng? _initialLocation;
|
||||
double? _initialZoom;
|
||||
bool _hasMovedToInitialLocation = false;
|
||||
|
||||
/// Get the initial location (if any was loaded)
|
||||
LatLng? get initialLocation => _initialLocation;
|
||||
|
||||
/// Get the initial zoom (if any was loaded)
|
||||
double? get initialZoom => _initialZoom;
|
||||
|
||||
/// Whether we've already moved to the initial location
|
||||
bool get hasMovedToInitialLocation => _hasMovedToInitialLocation;
|
||||
|
||||
/// Load the last map position from persistent storage.
|
||||
/// Call this during initialization to set up initial location.
|
||||
Future<void> loadLastMapPosition() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final lat = prefs.getDouble(kLastMapLatKey);
|
||||
final lng = prefs.getDouble(kLastMapLngKey);
|
||||
final zoom = prefs.getDouble(kLastMapZoomKey);
|
||||
|
||||
if (lat != null && lng != null &&
|
||||
_isValidCoordinate(lat) && _isValidCoordinate(lng)) {
|
||||
final validZoom = zoom != null && _isValidZoom(zoom) ? zoom : 15.0;
|
||||
_initialLocation = LatLng(lat, lng);
|
||||
_initialZoom = validZoom;
|
||||
debugPrint('[MapPositionManager] Loaded last map position: ${_initialLocation!.latitude}, ${_initialLocation!.longitude}, zoom: $_initialZoom');
|
||||
} else {
|
||||
debugPrint('[MapPositionManager] Invalid saved coordinates, using defaults');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[MapPositionManager] Failed to load last map position: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Move to initial location if we have one and haven't moved yet.
|
||||
/// Call this after the map controller is ready.
|
||||
void moveToInitialLocationIfNeeded(AnimatedMapController controller) {
|
||||
if (!_hasMovedToInitialLocation && _initialLocation != null) {
|
||||
try {
|
||||
final zoom = _initialZoom ?? 15.0;
|
||||
// Double-check coordinates are valid before moving
|
||||
if (_isValidCoordinate(_initialLocation!.latitude) &&
|
||||
_isValidCoordinate(_initialLocation!.longitude) &&
|
||||
_isValidZoom(zoom)) {
|
||||
controller.mapController.move(_initialLocation!, zoom);
|
||||
_hasMovedToInitialLocation = true;
|
||||
debugPrint('[MapPositionManager] Moved to initial location: ${_initialLocation!.latitude}, ${_initialLocation!.longitude}');
|
||||
} else {
|
||||
debugPrint('[MapPositionManager] Invalid initial location, not moving: ${_initialLocation!.latitude}, ${_initialLocation!.longitude}, zoom: $zoom');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[MapPositionManager] Failed to move to initial location: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Save the current map position to persistent storage.
|
||||
/// Call this when the map position changes.
|
||||
Future<void> saveMapPosition(LatLng location, double zoom) async {
|
||||
try {
|
||||
// Validate coordinates and zoom before saving
|
||||
if (!_isValidCoordinate(location.latitude) ||
|
||||
!_isValidCoordinate(location.longitude) ||
|
||||
!_isValidZoom(zoom)) {
|
||||
debugPrint('[MapPositionManager] Invalid map position, not saving: lat=${location.latitude}, lng=${location.longitude}, zoom=$zoom');
|
||||
return;
|
||||
}
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setDouble(kLastMapLatKey, location.latitude);
|
||||
await prefs.setDouble(kLastMapLngKey, location.longitude);
|
||||
await prefs.setDouble(kLastMapZoomKey, zoom);
|
||||
debugPrint('[MapPositionManager] Saved last map position: ${location.latitude}, ${location.longitude}, zoom: $zoom');
|
||||
} catch (e) {
|
||||
debugPrint('[MapPositionManager] Failed to save last map position: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Clear any stored map position (useful for recovery from invalid data)
|
||||
static Future<void> clearStoredMapPosition() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(kLastMapLatKey);
|
||||
await prefs.remove(kLastMapLngKey);
|
||||
await prefs.remove(kLastMapZoomKey);
|
||||
debugPrint('[MapPositionManager] Cleared stored map position');
|
||||
} catch (e) {
|
||||
debugPrint('[MapPositionManager] Failed to clear stored map position: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate that a coordinate value is valid (not NaN, not infinite, within bounds)
|
||||
bool _isValidCoordinate(double value) {
|
||||
return !value.isNaN &&
|
||||
!value.isInfinite &&
|
||||
value >= -180.0 &&
|
||||
value <= 180.0;
|
||||
}
|
||||
|
||||
/// Validate that a zoom level is valid
|
||||
bool _isValidZoom(double zoom) {
|
||||
return !zoom.isNaN &&
|
||||
!zoom.isInfinite &&
|
||||
zoom >= 1.0 &&
|
||||
zoom <= 25.0;
|
||||
}
|
||||
}
|
||||
87
lib/widgets/map/tile_layer_manager.dart
Normal file
87
lib/widgets/map/tile_layer_manager.dart
Normal file
@@ -0,0 +1,87 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
|
||||
import '../../models/tile_provider.dart' as models;
|
||||
import '../../services/simple_tile_service.dart';
|
||||
|
||||
/// Manages tile layer creation, caching, and provider switching.
|
||||
/// Handles tile HTTP client lifecycle and cache invalidation.
|
||||
class TileLayerManager {
|
||||
late final SimpleTileHttpClient _tileHttpClient;
|
||||
int _mapRebuildKey = 0;
|
||||
String? _lastTileTypeId;
|
||||
bool? _lastOfflineMode;
|
||||
|
||||
/// Get the current map rebuild key for cache busting
|
||||
int get mapRebuildKey => _mapRebuildKey;
|
||||
|
||||
/// Initialize the tile layer manager
|
||||
void initialize() {
|
||||
_tileHttpClient = SimpleTileHttpClient();
|
||||
}
|
||||
|
||||
/// Dispose of resources
|
||||
void dispose() {
|
||||
_tileHttpClient.close();
|
||||
}
|
||||
|
||||
/// Check if cache should be cleared and increment rebuild key if needed.
|
||||
/// Returns true if cache was cleared (map should be rebuilt).
|
||||
bool checkAndClearCacheIfNeeded({
|
||||
required String? currentTileTypeId,
|
||||
required bool currentOfflineMode,
|
||||
}) {
|
||||
bool shouldClear = false;
|
||||
String? reason;
|
||||
|
||||
if ((_lastTileTypeId != null && _lastTileTypeId != currentTileTypeId)) {
|
||||
reason = 'tile type ($currentTileTypeId)';
|
||||
shouldClear = true;
|
||||
} else if ((_lastOfflineMode != null && _lastOfflineMode != currentOfflineMode)) {
|
||||
reason = 'offline mode ($currentOfflineMode)';
|
||||
shouldClear = true;
|
||||
}
|
||||
|
||||
if (shouldClear) {
|
||||
// Force map rebuild with new key to bust flutter_map cache
|
||||
_mapRebuildKey++;
|
||||
debugPrint('[TileLayerManager] *** CACHE CLEAR *** $reason changed - rebuilding map $_mapRebuildKey');
|
||||
}
|
||||
|
||||
_lastTileTypeId = currentTileTypeId;
|
||||
_lastOfflineMode = currentOfflineMode;
|
||||
|
||||
return shouldClear;
|
||||
}
|
||||
|
||||
/// Clear the tile request queue (call after cache clear)
|
||||
void clearTileQueue() {
|
||||
debugPrint('[TileLayerManager] Post-frame: Clearing tile request queue');
|
||||
_tileHttpClient.clearTileQueue();
|
||||
}
|
||||
|
||||
/// Clear tile queue immediately (for zoom changes, etc.)
|
||||
void clearTileQueueImmediate() {
|
||||
_tileHttpClient.clearTileQueue();
|
||||
}
|
||||
|
||||
/// Build tile layer widget with current provider and type.
|
||||
/// Uses fake domain that SimpleTileHttpClient can parse for cache separation.
|
||||
Widget buildTileLayer({
|
||||
required models.TileProvider? selectedProvider,
|
||||
required models.TileType? selectedTileType,
|
||||
}) {
|
||||
// Use fake domain with standard HTTPS scheme: https://tiles.local/provider/type/z/x/y
|
||||
// This naturally separates cache entries by provider and type while being HTTP-compatible
|
||||
final urlTemplate = 'https://tiles.local/${selectedProvider?.id ?? 'unknown'}/${selectedTileType?.id ?? 'unknown'}/{z}/{x}/{y}';
|
||||
|
||||
return TileLayer(
|
||||
urlTemplate: urlTemplate,
|
||||
userAgentPackageName: 'com.stopflock.flock_map_app',
|
||||
tileProvider: NetworkTileProvider(
|
||||
httpClient: _tileHttpClient,
|
||||
// Enable flutter_map caching - cache busting handled by URL changes and FlutterMap key
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,129 +1,118 @@
|
||||
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:flutter_map_animations/flutter_map_animations.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../services/map_data_provider.dart';
|
||||
import '../services/offline_area_service.dart';
|
||||
import '../services/network_status.dart';
|
||||
import '../models/osm_camera_node.dart';
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/tile_provider.dart';
|
||||
import 'debouncer.dart';
|
||||
import 'camera_tag_sheet.dart';
|
||||
import 'tile_provider_with_cache.dart';
|
||||
import 'camera_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) {
|
||||
// Check if this is a pending upload
|
||||
final isPending = widget.node.tags.containsKey('_pending_upload') &&
|
||||
widget.node.tags['_pending_upload'] == 'true';
|
||||
|
||||
return GestureDetector(
|
||||
onTap: _onTap,
|
||||
onDoubleTap: _onDoubleTap,
|
||||
child: Icon(
|
||||
Icons.videocam,
|
||||
color: isPending ? Colors.purple : Colors.orange,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'camera_icon.dart';
|
||||
import 'map/camera_markers.dart';
|
||||
import 'map/direction_cones.dart';
|
||||
import 'map/map_overlays.dart';
|
||||
import 'map/map_position_manager.dart';
|
||||
import 'map/tile_layer_manager.dart';
|
||||
import 'map/camera_refresh_controller.dart';
|
||||
import 'map/gps_controller.dart';
|
||||
import 'network_status_indicator.dart';
|
||||
import '../dev_config.dart';
|
||||
import '../app_state.dart' show FollowMeMode;
|
||||
|
||||
class MapView extends StatefulWidget {
|
||||
final MapController controller;
|
||||
final AnimatedMapController controller;
|
||||
const MapView({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.followMe,
|
||||
required this.followMeMode,
|
||||
required this.onUserGesture,
|
||||
this.bottomPadding = 0.0,
|
||||
});
|
||||
|
||||
final bool followMe;
|
||||
final FollowMeMode followMeMode;
|
||||
final VoidCallback onUserGesture;
|
||||
final double bottomPadding;
|
||||
|
||||
@override
|
||||
State<MapView> createState() => _MapViewState();
|
||||
State<MapView> createState() => MapViewState();
|
||||
}
|
||||
|
||||
class _MapViewState extends State<MapView> {
|
||||
late final MapController _controller;
|
||||
final MapDataProvider _mapDataProvider = MapDataProvider();
|
||||
final Debouncer _debounce = Debouncer(kDebounceCameraRefresh);
|
||||
class MapViewState extends State<MapView> {
|
||||
late final AnimatedMapController _controller;
|
||||
final Debouncer _cameraDebounce = Debouncer(kDebounceCameraRefresh);
|
||||
final Debouncer _tileDebounce = Debouncer(const Duration(milliseconds: 150));
|
||||
final Debouncer _mapPositionDebounce = Debouncer(const Duration(milliseconds: 1000));
|
||||
|
||||
StreamSubscription<Position>? _positionSub;
|
||||
LatLng? _currentLatLng;
|
||||
|
||||
late final CameraProviderWithCache _cameraProvider;
|
||||
late final MapPositionManager _positionManager;
|
||||
late final TileLayerManager _tileManager;
|
||||
late final CameraRefreshController _cameraController;
|
||||
late final GpsController _gpsController;
|
||||
|
||||
// Track zoom to clear queue on zoom changes
|
||||
double? _lastZoom;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// _debounceTileLayerUpdate removed
|
||||
OfflineAreaService();
|
||||
_controller = widget.controller;
|
||||
_initLocation();
|
||||
_positionManager = MapPositionManager();
|
||||
_tileManager = TileLayerManager();
|
||||
_tileManager.initialize();
|
||||
_cameraController = CameraRefreshController();
|
||||
_cameraController.initialize(onCamerasUpdated: _onCamerasUpdated);
|
||||
_gpsController = GpsController();
|
||||
|
||||
// Load last map position before initializing GPS
|
||||
_positionManager.loadLastMapPosition().then((_) {
|
||||
// Move to last known position after loading and widget is built
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_positionManager.moveToInitialLocationIfNeeded(_controller);
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize GPS with callback for position updates and follow-me
|
||||
_gpsController.initializeWithCallback(
|
||||
followMeMode: widget.followMeMode,
|
||||
controller: _controller,
|
||||
onLocationUpdated: () => setState(() {}),
|
||||
getCurrentFollowMeMode: () {
|
||||
// Use mounted check to avoid calling context when widget is disposed
|
||||
if (mounted) {
|
||||
try {
|
||||
return context.read<AppState>().followMeMode;
|
||||
} catch (e) {
|
||||
debugPrint('[MapView] Could not read AppState, defaulting to off: $e');
|
||||
return FollowMeMode.off;
|
||||
}
|
||||
}
|
||||
return FollowMeMode.off;
|
||||
},
|
||||
);
|
||||
|
||||
// Set up camera overlay caching
|
||||
_cameraProvider = CameraProviderWithCache.instance;
|
||||
_cameraProvider.addListener(_onCamerasUpdated);
|
||||
// Ensure initial overlays are fetched
|
||||
// Fetch initial cameras
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_refreshCamerasFromProvider();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_positionSub?.cancel();
|
||||
_debounce.dispose();
|
||||
_cameraProvider.removeListener(_onCamerasUpdated);
|
||||
_cameraDebounce.dispose();
|
||||
_tileDebounce.dispose();
|
||||
_mapPositionDebounce.dispose();
|
||||
_cameraController.dispose();
|
||||
_tileManager.dispose();
|
||||
_gpsController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -131,90 +120,102 @@ class _MapViewState extends State<MapView> {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
/// Public method to retry location initialization (e.g., after permission granted)
|
||||
void retryLocationInit() {
|
||||
_gpsController.retryLocationInit();
|
||||
}
|
||||
|
||||
/// Expose static methods from MapPositionManager for external access
|
||||
static Future<void> clearStoredMapPosition() =>
|
||||
MapPositionManager.clearStoredMapPosition();
|
||||
|
||||
|
||||
|
||||
void _refreshCamerasFromProvider() {
|
||||
final appState = context.read<AppState>();
|
||||
LatLngBounds? bounds;
|
||||
try {
|
||||
bounds = _controller.camera.visibleBounds;
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
final zoom = _controller.camera.zoom;
|
||||
if (zoom < kCameraMinZoomLevel) {
|
||||
// Show a snackbar-style bubble, if desired
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Cameras not drawn below zoom level $kCameraMinZoomLevel'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
_cameraProvider.fetchAndUpdate(
|
||||
bounds: bounds,
|
||||
profiles: appState.enabledProfiles,
|
||||
_cameraController.refreshCamerasFromProvider(
|
||||
controller: _controller,
|
||||
enabledProfiles: appState.enabledProfiles,
|
||||
uploadMode: appState.uploadMode,
|
||||
context: context,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant MapView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.followMe && !oldWidget.followMe && _currentLatLng != null) {
|
||||
_controller.move(_currentLatLng!, _controller.camera.zoom);
|
||||
// Handle follow-me mode changes - only if it actually changed
|
||||
if (widget.followMeMode != oldWidget.followMeMode) {
|
||||
_gpsController.handleFollowMeModeChange(
|
||||
newMode: widget.followMeMode,
|
||||
oldMode: oldWidget.followMeMode,
|
||||
controller: _controller,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initLocation() async {
|
||||
final perm = await Geolocator.requestPermission();
|
||||
if (perm == LocationPermission.denied ||
|
||||
perm == LocationPermission.deniedForever) return;
|
||||
|
||||
_positionSub =
|
||||
Geolocator.getPositionStream().listen((Position position) {
|
||||
final latLng = LatLng(position.latitude, position.longitude);
|
||||
setState(() => _currentLatLng = latLng);
|
||||
if (widget.followMe) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
try {
|
||||
_controller.move(latLng, _controller.camera.zoom);
|
||||
} catch (e) {
|
||||
debugPrint('MapController not ready yet: $e');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
double _safeZoom() {
|
||||
try {
|
||||
return _controller.camera.zoom;
|
||||
return _controller.mapController.camera.zoom;
|
||||
} catch (_) {
|
||||
return 15.0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
final session = appState.session;
|
||||
final editSession = appState.editSession;
|
||||
|
||||
// Only update cameras when map moves or profiles/mode actually change (not every build!)
|
||||
// _refreshCamerasFromProvider() is now only called from map movement and relevant change handlers.
|
||||
// Check if enabled profiles changed and refresh cameras if needed
|
||||
_cameraController.checkAndHandleProfileChanges(
|
||||
currentEnabledProfiles: appState.enabledProfiles,
|
||||
onProfilesChanged: _refreshCamerasFromProvider,
|
||||
);
|
||||
|
||||
// Check if tile type OR offline mode changed and clear cache if needed
|
||||
final cacheCleared = _tileManager.checkAndClearCacheIfNeeded(
|
||||
currentTileTypeId: appState.selectedTileType?.id,
|
||||
currentOfflineMode: appState.offlineMode,
|
||||
);
|
||||
|
||||
if (cacheCleared) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_tileManager.clearTileQueue();
|
||||
});
|
||||
}
|
||||
|
||||
// Seed add‑mode target once, after first controller center is available.
|
||||
if (session != null && session.target == null) {
|
||||
try {
|
||||
final center = _controller.camera.center;
|
||||
final center = _controller.mapController.camera.center;
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => appState.updateSession(target: center),
|
||||
);
|
||||
} catch (_) {/* controller not ready yet */}
|
||||
}
|
||||
|
||||
// For edit sessions, center the map on the camera being edited initially
|
||||
if (editSession != null && _controller.mapController.camera.center != editSession.target) {
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) {
|
||||
try {
|
||||
_controller.mapController.move(editSession.target, _controller.mapController.camera.zoom);
|
||||
} catch (_) {/* controller not ready yet */}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final zoom = _safeZoom();
|
||||
// Fetch cached cameras for current map bounds (using Consumer so overlays redraw instantly)
|
||||
@@ -222,50 +223,55 @@ class _MapViewState extends State<MapView> {
|
||||
builder: (context, cameraProvider, child) {
|
||||
LatLngBounds? mapBounds;
|
||||
try {
|
||||
mapBounds = _controller.camera.visibleBounds;
|
||||
mapBounds = _controller.mapController.camera.visibleBounds;
|
||||
} catch (_) {
|
||||
mapBounds = null;
|
||||
}
|
||||
final cameras = (mapBounds != null)
|
||||
? cameraProvider.getCachedCamerasForBounds(mapBounds)
|
||||
: <OsmCameraNode>[];
|
||||
final markers = <Marker>[
|
||||
...cameras
|
||||
.where((n) => n.coord.latitude != 0 || n.coord.longitude != 0)
|
||||
.where((n) => n.coord.latitude.abs() <= 90 && n.coord.longitude.abs() <= 180)
|
||||
.map((n) => Marker(
|
||||
point: n.coord,
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: _CameraMapMarker(node: n, mapController: _controller),
|
||||
)),
|
||||
if (_currentLatLng != null)
|
||||
Marker(
|
||||
point: _currentLatLng!,
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: const Icon(Icons.my_location, color: Colors.blue),
|
||||
),
|
||||
];
|
||||
|
||||
final markers = CameraMarkersBuilder.buildCameraMarkers(
|
||||
cameras: cameras,
|
||||
mapController: _controller.mapController,
|
||||
userLocation: _gpsController.currentLocation,
|
||||
);
|
||||
|
||||
final overlays = DirectionConesBuilder.buildDirectionCones(
|
||||
cameras: cameras,
|
||||
zoom: zoom,
|
||||
session: session,
|
||||
editSession: editSession,
|
||||
);
|
||||
|
||||
// Build edit lines connecting original cameras to their edited positions
|
||||
final editLines = _buildEditLines(cameras);
|
||||
|
||||
// Build center marker for add/edit sessions
|
||||
final centerMarkers = <Marker>[];
|
||||
if (session != null || editSession != null) {
|
||||
try {
|
||||
final center = _controller.mapController.camera.center;
|
||||
centerMarkers.add(
|
||||
Marker(
|
||||
point: center,
|
||||
width: kCameraIconDiameter,
|
||||
height: kCameraIconDiameter,
|
||||
child: CameraIcon(
|
||||
type: editSession != null ? CameraIconType.editing : CameraIconType.mock,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
// Controller not ready yet
|
||||
}
|
||||
}
|
||||
|
||||
final overlays = <Polygon>[
|
||||
if (session != null && session.target != null)
|
||||
_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,
|
||||
isPending: n.tags.containsKey('_pending_upload') && n.tags['_pending_upload'] == 'true',
|
||||
)),
|
||||
];
|
||||
return Stack(
|
||||
children: [
|
||||
PolygonLayer(polygons: overlays),
|
||||
MarkerLayer(markers: markers),
|
||||
if (editLines.isNotEmpty) PolylineLayer(polylines: editLines),
|
||||
MarkerLayer(markers: [...markers, ...centerMarkers]),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -273,12 +279,16 @@ class _MapViewState extends State<MapView> {
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
FlutterMap(
|
||||
key: ValueKey(appState.offlineMode),
|
||||
mapController: _controller,
|
||||
options: MapOptions(
|
||||
initialCenter: _currentLatLng ?? LatLng(37.7749, -122.4194),
|
||||
initialZoom: 15,
|
||||
AnimatedPadding(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
padding: EdgeInsets.only(bottom: widget.bottomPadding),
|
||||
child: FlutterMap(
|
||||
key: ValueKey('map_${appState.offlineMode}_${appState.selectedTileType?.id ?? 'none'}_${_tileManager.mapRebuildKey}'),
|
||||
mapController: _controller.mapController,
|
||||
options: MapOptions(
|
||||
initialCenter: _gpsController.currentLocation ?? _positionManager.initialLocation ?? LatLng(37.7749, -122.4194),
|
||||
initialZoom: _positionManager.initialZoom ?? 15,
|
||||
maxZoom: 19,
|
||||
onPositionChanged: (pos, gesture) {
|
||||
setState(() {}); // Instant UI update for zoom, etc.
|
||||
@@ -286,38 +296,40 @@ class _MapViewState extends State<MapView> {
|
||||
if (session != null) {
|
||||
appState.updateSession(target: pos.center);
|
||||
}
|
||||
// Only request more cameras if the user navigated the map (and at valid zoom)
|
||||
if (gesture && pos.zoom >= 10) {
|
||||
_debounce(_refreshCamerasFromProvider);
|
||||
if (editSession != null) {
|
||||
appState.updateEditSession(target: pos.center);
|
||||
}
|
||||
|
||||
// Show waiting indicator when map moves (user is expecting new content)
|
||||
NetworkStatus.instance.setWaiting();
|
||||
|
||||
// Only clear tile queue on significant ZOOM changes (not panning)
|
||||
final currentZoom = pos.zoom;
|
||||
final zoomChanged = _lastZoom != null && (currentZoom - _lastZoom!).abs() > 0.5;
|
||||
|
||||
if (zoomChanged) {
|
||||
_tileDebounce(() {
|
||||
// Clear stale tile requests on zoom change (quietly)
|
||||
_tileManager.clearTileQueueImmediate();
|
||||
});
|
||||
}
|
||||
_lastZoom = currentZoom;
|
||||
|
||||
// Save map position (debounced to avoid excessive writes)
|
||||
_mapPositionDebounce(() {
|
||||
_positionManager.saveMapPosition(pos.center, pos.zoom);
|
||||
});
|
||||
|
||||
// Request more cameras on any map movement/zoom at valid zoom level (slower debounce)
|
||||
if (pos.zoom >= 10) {
|
||||
_cameraDebounce(_refreshCamerasFromProvider);
|
||||
}
|
||||
},
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
tileProvider: Provider.of<TileProviderWithCache>(context),
|
||||
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;
|
||||
}
|
||||
}
|
||||
_tileManager.buildTileLayer(
|
||||
selectedProvider: appState.selectedTileProvider,
|
||||
selectedTileType: appState.selectedTileType,
|
||||
),
|
||||
cameraLayers,
|
||||
// Built-in scale bar from flutter_map
|
||||
@@ -330,115 +342,54 @@ class _MapViewState extends State<MapView> {
|
||||
// backgroundColor removed in flutter_map >=8 (wrap in Container if needed)
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// MODE INDICATOR badge (top-right)
|
||||
if (appState.uploadMode == UploadMode.sandbox || appState.uploadMode == UploadMode.simulate)
|
||||
Positioned(
|
||||
top: 18,
|
||||
right: 14,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: appState.uploadMode == UploadMode.sandbox
|
||||
? Colors.orange.withOpacity(0.90)
|
||||
: Colors.deepPurple.withOpacity(0.80),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.black26, blurRadius: 5, offset: Offset(0,2)),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
appState.uploadMode == UploadMode.sandbox
|
||||
? 'SANDBOX MODE'
|
||||
: 'SIMULATE',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13,
|
||||
letterSpacing: 1.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Zoom indicator, positioned above scale bar
|
||||
Positioned(
|
||||
left: 10,
|
||||
bottom: kZoomIndicatorBottomOffset,
|
||||
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: kAttributionBottomOffset,
|
||||
left: 10,
|
||||
child: Container(
|
||||
color: Colors.white70,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: const Text(
|
||||
'© OpenStreetMap and contributors',
|
||||
style: TextStyle(fontSize: 11),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Fixed pin when adding camera
|
||||
if (session != null)
|
||||
IgnorePointer(
|
||||
child: Center(
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, kAddPinYOffset),
|
||||
child: Icon(Icons.place, size: 40, color: Colors.redAccent),
|
||||
),
|
||||
),
|
||||
),
|
||||
// All map overlays (mode indicator, zoom, attribution, add pin)
|
||||
MapOverlays(
|
||||
mapController: _controller.mapController,
|
||||
uploadMode: appState.uploadMode,
|
||||
session: session,
|
||||
editSession: editSession,
|
||||
attribution: appState.selectedTileType?.attribution,
|
||||
),
|
||||
|
||||
// Network status indicator (top-left)
|
||||
const NetworkStatusIndicator(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Polygon _buildCone(LatLng origin, double bearingDeg, double zoom, {bool isPending = false}) {
|
||||
final halfAngle = kDirectionConeHalfAngle;
|
||||
final length = kDirectionConeBaseLength * math.pow(2, 15 - zoom);
|
||||
|
||||
LatLng _project(double deg) {
|
||||
final rad = deg * math.pi / 180;
|
||||
final dLat = length * math.cos(rad);
|
||||
final dLon =
|
||||
length * math.sin(rad) / math.cos(origin.latitude * math.pi / 180);
|
||||
return LatLng(origin.latitude + dLat, origin.longitude + dLon);
|
||||
/// Build polylines connecting original cameras to their edited positions
|
||||
List<Polyline> _buildEditLines(List<OsmCameraNode> cameras) {
|
||||
final lines = <Polyline>[];
|
||||
|
||||
// Create a lookup map of original node IDs to their coordinates
|
||||
final originalNodes = <int, LatLng>{};
|
||||
for (final camera in cameras) {
|
||||
if (camera.tags['_pending_edit'] == 'true') {
|
||||
originalNodes[camera.id] = camera.coord;
|
||||
}
|
||||
}
|
||||
|
||||
final left = _project(bearingDeg - halfAngle);
|
||||
final right = _project(bearingDeg + halfAngle);
|
||||
|
||||
// Use purple color for pending uploads
|
||||
final color = isPending ? Colors.purple : Colors.redAccent;
|
||||
|
||||
return Polygon(
|
||||
points: [origin, left, right, origin],
|
||||
color: color.withOpacity(0.25),
|
||||
borderColor: color,
|
||||
borderStrokeWidth: 1,
|
||||
);
|
||||
|
||||
// Find edited cameras and draw lines to their originals
|
||||
for (final camera in cameras) {
|
||||
final originalIdStr = camera.tags['_original_node_id'];
|
||||
if (originalIdStr != null && camera.tags['_pending_upload'] == 'true') {
|
||||
final originalId = int.tryParse(originalIdStr);
|
||||
final originalCoord = originalId != null ? originalNodes[originalId] : null;
|
||||
|
||||
if (originalCoord != null) {
|
||||
lines.add(Polyline(
|
||||
points: [originalCoord, camera.coord],
|
||||
color: kCameraRingColorPending,
|
||||
strokeWidth: 3.0,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
55
lib/widgets/measured_sheet.dart
Normal file
55
lib/widgets/measured_sheet.dart
Normal file
@@ -0,0 +1,55 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Wrapper widget that measures its child's height and reports changes via callback
|
||||
class MeasuredSheet extends StatefulWidget {
|
||||
final Widget child;
|
||||
final ValueChanged<double> onHeightChanged;
|
||||
|
||||
const MeasuredSheet({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.onHeightChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MeasuredSheet> createState() => _MeasuredSheetState();
|
||||
}
|
||||
|
||||
class _MeasuredSheetState extends State<MeasuredSheet> {
|
||||
final GlobalKey _key = GlobalKey();
|
||||
double _lastHeight = 0.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Schedule height measurement after first frame
|
||||
WidgetsBinding.instance.addPostFrameCallback(_measureHeight);
|
||||
}
|
||||
|
||||
void _measureHeight(Duration _) {
|
||||
final renderBox = _key.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (renderBox != null) {
|
||||
final height = renderBox.size.height;
|
||||
if (height != _lastHeight) {
|
||||
_lastHeight = height;
|
||||
widget.onHeightChanged(height);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return NotificationListener<SizeChangedLayoutNotification>(
|
||||
onNotification: (notification) {
|
||||
WidgetsBinding.instance.addPostFrameCallback(_measureHeight);
|
||||
return true;
|
||||
},
|
||||
child: SizeChangedLayoutNotifier(
|
||||
child: Container(
|
||||
key: _key,
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
104
lib/widgets/network_status_indicator.dart
Normal file
104
lib/widgets/network_status_indicator.dart
Normal file
@@ -0,0 +1,104 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../services/network_status.dart';
|
||||
|
||||
class NetworkStatusIndicator extends StatelessWidget {
|
||||
const NetworkStatusIndicator({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ChangeNotifierProvider.value(
|
||||
value: NetworkStatus.instance,
|
||||
child: Consumer<NetworkStatus>(
|
||||
builder: (context, networkStatus, child) {
|
||||
String message;
|
||||
IconData icon;
|
||||
Color color;
|
||||
|
||||
switch (networkStatus.currentStatus) {
|
||||
case NetworkStatusType.waiting:
|
||||
message = 'Loading...';
|
||||
icon = Icons.hourglass_empty;
|
||||
color = Colors.blue;
|
||||
break;
|
||||
|
||||
case NetworkStatusType.timedOut:
|
||||
message = 'Timed out';
|
||||
icon = Icons.hourglass_disabled;
|
||||
color = Colors.orange;
|
||||
break;
|
||||
|
||||
case NetworkStatusType.noData:
|
||||
message = 'No tiles here';
|
||||
icon = Icons.cloud_off;
|
||||
color = Colors.grey;
|
||||
break;
|
||||
|
||||
case NetworkStatusType.success:
|
||||
message = 'Tiles loaded';
|
||||
icon = Icons.check_circle;
|
||||
color = Colors.green;
|
||||
break;
|
||||
|
||||
case NetworkStatusType.issues:
|
||||
switch (networkStatus.currentIssueType) {
|
||||
case NetworkIssueType.osmTiles:
|
||||
message = 'OSM tiles slow';
|
||||
icon = Icons.map_outlined;
|
||||
color = Colors.orange;
|
||||
break;
|
||||
case NetworkIssueType.overpassApi:
|
||||
message = 'Camera data slow';
|
||||
icon = Icons.camera_alt_outlined;
|
||||
color = Colors.orange;
|
||||
break;
|
||||
case NetworkIssueType.both:
|
||||
message = 'Network issues';
|
||||
icon = Icons.cloud_off_outlined;
|
||||
color = Colors.red;
|
||||
break;
|
||||
default:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
break;
|
||||
|
||||
case NetworkStatusType.ready:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Positioned(
|
||||
top: MediaQuery.of(context).padding.top + 8,
|
||||
left: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black87,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: color, width: 1),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: color,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
message,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
157
lib/widgets/refine_tags_sheet.dart
Normal file
157
lib/widgets/refine_tags_sheet.dart
Normal file
@@ -0,0 +1,157 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../models/operator_profile.dart';
|
||||
|
||||
class RefineTagsSheet extends StatefulWidget {
|
||||
const RefineTagsSheet({
|
||||
super.key,
|
||||
this.selectedOperatorProfile,
|
||||
});
|
||||
|
||||
final OperatorProfile? selectedOperatorProfile;
|
||||
|
||||
@override
|
||||
State<RefineTagsSheet> createState() => _RefineTagsSheetState();
|
||||
}
|
||||
|
||||
class _RefineTagsSheetState extends State<RefineTagsSheet> {
|
||||
OperatorProfile? _selectedOperatorProfile;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedOperatorProfile = widget.selectedOperatorProfile;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
final operatorProfiles = appState.operatorProfiles;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Refine Tags'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(context, widget.selectedOperatorProfile),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, _selectedOperatorProfile),
|
||||
child: const Text('Done'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
const Text(
|
||||
'Operator Profile',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (operatorProfiles.isEmpty)
|
||||
const Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Colors.grey, size: 48),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'No operator profiles defined',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
'Create operator profiles in Settings to apply additional tags to your node submissions.',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
Card(
|
||||
child: Column(
|
||||
children: [
|
||||
RadioListTile<OperatorProfile?>(
|
||||
title: const Text('None'),
|
||||
subtitle: const Text('No additional operator tags'),
|
||||
value: null,
|
||||
groupValue: _selectedOperatorProfile,
|
||||
onChanged: (value) => setState(() => _selectedOperatorProfile = value),
|
||||
),
|
||||
...operatorProfiles.map((profile) => RadioListTile<OperatorProfile?>(
|
||||
title: Text(profile.name),
|
||||
subtitle: Text('${profile.tags.length} additional tags'),
|
||||
value: profile,
|
||||
groupValue: _selectedOperatorProfile,
|
||||
onChanged: (value) => setState(() => _selectedOperatorProfile = value),
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_selectedOperatorProfile != null) ...[
|
||||
const Text(
|
||||
'Additional Tags',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_selectedOperatorProfile!.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (_selectedOperatorProfile!.tags.isEmpty)
|
||||
const Text(
|
||||
'No tags defined for this operator profile.',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
)
|
||||
else
|
||||
...(_selectedOperatorProfile!.tags.entries.map((entry) =>
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Text(
|
||||
entry.key,
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Text(
|
||||
entry.value,
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.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 with ChangeNotifier {
|
||||
static final Map<String, Uint8List> _tileCache = {};
|
||||
static Map<String, Uint8List> get tileCache => _tileCache;
|
||||
|
||||
TileProviderWithCache();
|
||||
|
||||
@override
|
||||
ImageProvider getImage(TileCoordinates coords, TileLayer options, {MapSource source = MapSource.auto}) {
|
||||
final key = '${coords.z}/${coords.x}/${coords.y}';
|
||||
if (_tileCache.containsKey(key)) {
|
||||
final bytes = _tileCache[key]!;
|
||||
return MemoryImage(bytes);
|
||||
} else {
|
||||
_fetchAndCacheTile(coords, key, source: source);
|
||||
// Always return a placeholder until the real tile is cached
|
||||
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}');
|
||||
notifyListeners(); // This updates any listening widgets
|
||||
}
|
||||
// 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -158,6 +158,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.2.1"
|
||||
flutter_map_animations:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_map_animations
|
||||
sha256: bf583863561861aaaf4854ae7ed8940d79bea7d32918bf7a85d309b25235a09e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.0"
|
||||
flutter_native_splash:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
|
||||
@@ -13,7 +13,7 @@ dependencies:
|
||||
# UI & Map
|
||||
provider: ^6.1.2
|
||||
flutter_map: ^8.2.1
|
||||
# (removed: using built-in Scalebar from flutter_map >= v6)
|
||||
flutter_map_animations: ^0.9.0
|
||||
latlong2: ^0.9.0
|
||||
geolocator: ^10.1.0
|
||||
http: ^1.2.1
|
||||
@@ -49,4 +49,4 @@ flutter_icons:
|
||||
android: true
|
||||
ios: true
|
||||
image_path: "assets/app_icon.png"
|
||||
min_sdk_android: 21
|
||||
min_sdk_android: 21
|
||||
|
||||
83
test/models/pending_upload_test.dart
Normal file
83
test/models/pending_upload_test.dart
Normal file
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flock_map_app/models/pending_upload.dart';
|
||||
import 'package:flock_map_app/models/camera_profile.dart';
|
||||
import 'package:flock_map_app/state/settings_state.dart';
|
||||
|
||||
void main() {
|
||||
group('PendingUpload', () {
|
||||
test('should serialize and deserialize upload mode correctly', () {
|
||||
// Test each upload mode
|
||||
final testModes = [
|
||||
UploadMode.production,
|
||||
UploadMode.sandbox,
|
||||
UploadMode.simulate,
|
||||
];
|
||||
|
||||
for (final mode in testModes) {
|
||||
final original = PendingUpload(
|
||||
coord: LatLng(37.7749, -122.4194),
|
||||
direction: 90.0,
|
||||
profile: CameraProfile.flock(),
|
||||
uploadMode: mode,
|
||||
);
|
||||
|
||||
// Serialize to JSON
|
||||
final json = original.toJson();
|
||||
|
||||
// Deserialize from JSON
|
||||
final restored = PendingUpload.fromJson(json);
|
||||
|
||||
// Verify upload mode is preserved
|
||||
expect(restored.uploadMode, equals(mode));
|
||||
expect(restored.uploadModeDisplayName, equals(original.uploadModeDisplayName));
|
||||
|
||||
// Verify other fields too
|
||||
expect(restored.coord.latitude, equals(original.coord.latitude));
|
||||
expect(restored.coord.longitude, equals(original.coord.longitude));
|
||||
expect(restored.direction, equals(original.direction));
|
||||
expect(restored.profile.id, equals(original.profile.id));
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle legacy JSON without uploadMode', () {
|
||||
// Simulate old JSON format without uploadMode field
|
||||
final legacyJson = {
|
||||
'lat': 37.7749,
|
||||
'lon': -122.4194,
|
||||
'dir': 90.0,
|
||||
'profile': CameraProfile.flock().toJson(),
|
||||
'originalNodeId': null,
|
||||
'attempts': 0,
|
||||
'error': false,
|
||||
// Note: no 'uploadMode' field
|
||||
};
|
||||
|
||||
final upload = PendingUpload.fromJson(legacyJson);
|
||||
|
||||
// Should default to production mode for legacy entries
|
||||
expect(upload.uploadMode, equals(UploadMode.production));
|
||||
expect(upload.uploadModeDisplayName, equals('Production'));
|
||||
});
|
||||
|
||||
test('should correctly identify edits vs new cameras', () {
|
||||
final newCamera = PendingUpload(
|
||||
coord: LatLng(37.7749, -122.4194),
|
||||
direction: 90.0,
|
||||
profile: CameraProfile.flock(),
|
||||
uploadMode: UploadMode.production,
|
||||
);
|
||||
|
||||
final editCamera = PendingUpload(
|
||||
coord: LatLng(37.7749, -122.4194),
|
||||
direction: 90.0,
|
||||
profile: CameraProfile.flock(),
|
||||
uploadMode: UploadMode.production,
|
||||
originalNodeId: 12345,
|
||||
);
|
||||
|
||||
expect(newCamera.isEdit, isFalse);
|
||||
expect(editCamera.isEdit, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user