Compare commits

..

75 Commits

Author SHA1 Message Date
stopflock
f4ae861bc6 de-vibe readme 2025-08-28 17:32:05 -05:00
stopflock
07d18ae33c update readme 2025-08-28 16:34:10 -05:00
stopflock
92255eb03e bump version, add roadmap 2025-08-28 15:59:07 -05:00
stopflock
3026b88230 reopen to last location 2025-08-28 15:36:31 -05:00
stopflock
728cef22af smooth transitions 2025-08-28 15:07:30 -05:00
stopflock
d7fbfaaaeb add profile name to changeset comment 2025-08-28 14:00:40 -05:00
stopflock
9c05f1d7a9 add upload mode to queue entries to prevent cross-submission 2025-08-28 13:51:44 -05:00
stopflock
2c275ec528 prevent edits in sandbox mode 2025-08-28 13:23:12 -05:00
stopflock
f8726880d7 more tags on builtin profiles 2025-08-28 13:18:10 -05:00
stopflock
497b9e52be Merge pull request #12 from stopflock/edits
Edits
2025-08-28 12:40:47 -05:00
stopflock
d9f6c8c8e0 Update existing node instead of creating new. DNU ANY COMMIT ON THIS BRANCH PRIOR TO HERE!!!!111!!!1!! 2025-08-28 12:39:30 -05:00
stopflock
45bf73aeee preserve _tags in cam cache, make line purple and thicker 2025-08-28 12:35:40 -05:00
stopflock
7ff945e262 edit line 2025-08-28 12:15:26 -05:00
stopflock
26d8eca312 UX working 2025-08-28 11:32:53 -05:00
stopflock
efbb8765de location editable 2025-08-28 11:24:22 -05:00
stopflock
fae1cac6e4 still not able to refine location 2025-08-28 10:49:02 -05:00
stopflock
aee0dcf8b8 builds, needs work 2025-08-28 10:22:57 -05:00
stopflock
2db4f597dc allow viewing builtin profiles 2025-08-27 22:13:51 -05:00
stopflock
376fa27736 better builtin profiles 2025-08-27 21:24:22 -05:00
stopflock
24b20e8a57 fix gps on android 2025-08-27 18:24:47 -05:00
stopflock
2d0dc7fd66 ternary follow me 2025-08-26 23:46:38 -05:00
stopflock
b735283f27 smoother follow me 2025-08-26 22:58:24 -05:00
stopflock
ebf7f93dd5 deflock-ify icons 2025-08-26 20:52:03 -05:00
stopflock
d56a6e8e7c more status indicator improvements 2025-08-26 19:37:27 -05:00
stopflock
84e057c986 fix camera caching and filtering from offline areas - in theory 2025-08-26 19:17:45 -05:00
stopflock
c1e25ec5b1 improve network status indicator 2025-08-26 18:35:52 -05:00
stopflock
a3edcfc2de finalize code paths for offline areas, caching, in light of multiple tile providers 2025-08-26 17:52:14 -05:00
stopflock
17c9ee0c5c idk but it's better - cache busting works. 2025-08-24 19:38:42 -05:00
stopflock
9e620ef9e4 Consolidate / dedupe some code 2025-08-24 17:46:58 -05:00
stopflock
bedfdcca6e fetch tiles from selected provider 2025-08-24 16:13:50 -05:00
stopflock
f1c73a5e55 settings area metadata, offline areas support for tile types 2025-08-24 15:31:02 -05:00
stopflock
4ee783793f genericize tiles_from submodule, simpletileservice. 2025-08-24 15:08:36 -05:00
stopflock
aada97295b move layer selection to map page, improve settings, dynamic attribution 2025-08-24 14:41:29 -05:00
stopflock
813f4f69ea cusstom providers settings 2025-08-24 14:18:04 -05:00
stopflock
2d615128aa generic providers 2025-08-24 14:08:15 -05:00
stopflock
024d3f09c3 fix arcgis satellite source 2025-08-23 21:51:22 -05:00
stopflock
e65b9f58a6 holy crap tile types 2025-08-23 21:40:36 -05:00
stopflock
7bd6f68a99 add no offline data status indicator 2025-08-23 20:06:46 -05:00
stopflock
f11bd6e238 add timed out status indicator 2025-08-23 19:49:19 -05:00
stopflock
f45279ecfe retry for location permission when follow-me enabled 2025-08-23 18:46:21 -05:00
stopflock
d6625ccc23 holy crap I think this is working. 2025-08-23 18:23:18 -05:00
stopflock
722e640a72 tile fetching and caching much improved 2025-08-23 18:08:25 -05:00
stopflock
a21e807d88 lot of changes, got rid of custom cache stuff, now stepping in the way of http fetch instead of screwing with flutter map. 2025-08-23 17:42:53 -05:00
stopflock
a2bc3309c0 chasing excess tile fetching and lack of correct cache clearing - NOT WORKING 2025-08-23 12:27:04 -05:00
stopflock
f6adffc84e fixes tile re-rendering after long rate limit periods without having to zoom/pan 2025-08-22 23:44:49 -05:00
stopflock
01f73322c7 fixes offline -> online transition for tile cache re-fetching, also display of camera profiles only when enabled 2025-08-22 23:27:01 -05:00
stopflock
257aefb2fc cleanup 2025-08-22 21:16:59 -05:00
stopflock
63ebc2b682 ho lee shet 2025-08-22 21:04:30 -05:00
stopflock
1f3849cd84 break up offlin areas 2025-08-21 21:50:30 -05:00
stopflock
e35266c160 better offline area max calculation, and added guardrails 2025-08-21 20:22:13 -05:00
stopflock
05de16b2e2 cleanup round 2025-08-21 19:42:02 -05:00
stopflock
32507e1646 fix offlines cameras loading on zoom out 2025-08-21 19:20:45 -05:00
stopflock
1272eb9409 break up map_view 2025-08-21 19:15:59 -05:00
stopflock
4cc8929378 break up home screen - separate out download dialog 2025-08-21 19:04:16 -05:00
stopflock
44707bf064 fix download dialog 2025-08-21 18:54:07 -05:00
stopflock
ff9a052d3f broke download dialog, cleaned up debug stuff 2025-08-21 18:52:53 -05:00
stopflock
df5e26f78d breakup app_state 2025-08-21 18:39:09 -05:00
stopflock
865f91ea55 bump version 2025-08-20 00:35:49 -05:00
stopflock
268c9ebb3a min zoom for offline areas is now max world zoom + 1 2025-08-19 17:54:27 -05:00
stopflock
7875fd0d58 fix loading cameras from offline areas 2025-08-19 17:29:32 -05:00
stopflock
4bb57580cd pending uploads in purple 2025-08-18 23:49:10 -05:00
stopflock
5521da28c4 add submitted cameras to cache immediately 2025-08-18 23:41:29 -05:00
stopflock
e5d00803f7 add zoom in/out buttons 2025-08-18 23:12:16 -05:00
stopflock
a73605cc53 disable follow me when adding a camera 2025-08-18 23:07:34 -05:00
stopflock
7aa0c9dff4 fix macos builds - 10.15+ only 2025-08-17 23:09:16 -05:00
stopflock
e2830a189b Merge pull request #11 from stopflock/camera-edits
Everything But Camera Edits
2025-08-17 12:16:42 -05:00
stopflock
d9beeb9d83 Revert "Move tag camera and download buttons to a bar instead of floating"
This reverts commit 6aaddb4fe2.
2025-08-17 12:13:57 -05:00
stopflock
446b70eaff move buttons to fake bottom bar 2025-08-17 11:57:51 -05:00
stopflock
2829730705 add a really dumb script to build apk and ipa locally 2025-08-17 11:57:43 -05:00
stopflock
a131fb61e0 Merge pull request #10 from stopflock/camera-edits
Everything but camera edits
2025-08-16 22:17:02 -05:00
stopflock
6aaddb4fe2 Move tag camera and download buttons to a bar instead of floating 2025-08-16 20:55:55 -05:00
stopflock
e2adbf85ad Make default profile less specific 2025-08-16 20:00:19 -05:00
stopflock
6d079b3c34 forgot to import dev_config to uploader 2025-08-16 19:56:27 -05:00
stopflock
e293727ec8 Put version reported to OSM into dev_config 2025-08-16 19:53:47 -05:00
stopflock
9375f48a07 fix default upload dest (should be simulate) and add dev_config param for "tag camera" pin v. offset 2025-08-16 19:45:48 -05:00
58 changed files with 6134 additions and 1411 deletions

7
.gitignore vendored
View File

@@ -80,3 +80,10 @@ Thumbs.db
*.keystore
.env
# ───────────────────────────────
# For now - not targeting these
# ───────────────────────────────
linux/
macos/
web/
windows/

206
README.md
View File

@@ -1,145 +1,139 @@
# 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 14 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 14, 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.
### Current Todo List
- **Performance**: 1000+ camera warning threshold for large datasets
- **UX Polish**:
- Fix "tiles loaded" indicator accuracy across different providers
- Generic tile provider error messages (not always "OSM tiles slow")
- Optional custom icons for camera profiles
- **Data Management**: Clean up cache when submitted changesets appear in Overpass results
- **Visual Improvements**: Upgrade camera marker design (considering nullplate's svg)
### Future Features & Wishlist
- **Operator Profiles**:
- Additional tag sets for different surveillance operators
- **Announcement Mode**:
- Location-based notifications when approaching cameras
- **Enhanced Visualizations**:
- Red/yellow ring for cameras missing specific tag details
- iOS/Android native themes and dark mode support
- **Advanced Offline**:
- "Cache accumulating" offline areas with size estimates per area
- "Offline areas" as tile provider?
- **Navigation & Search**:
- Jump to location by coordinates, address, or POI name
- Route planning that avoids surveillance cameras
- **Data Sources**:
- 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.

16
do_builds.sh Executable file
View File

@@ -0,0 +1,16 @@
#!/bin/bash
appver=$(cat lib/dev_config.dart | grep "kClientVersion" | cut -d '=' -f 2 | tr -d ';' | tr -d "\'" | tr -d " ")
echo
echo "Building app version ${appver}..."
flutter build ios --no-codesign
flutter build apk
echo
echo "Converting .app to .ipa..."
./app2ipa.sh build/ios/iphoneos/Runner.app
echo
echo "Moving files..."
cp build/app/outputs/flutter-apk/app-release.apk ../flockmap_v${appver}.apk
mv Runner.ipa ../flockmap_v${appver}.ipa
echo
echo "Done."

View File

@@ -1,324 +1,148 @@
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/osm_camera_node.dart';
import 'models/pending_upload.dart';
import 'services/auth_service.dart';
import 'services/uploader.dart';
import 'services/profile_service.dart';
import 'widgets/tile_provider_with_cache.dart';
// Enum for upload mode (Production, OSM Sandbox, Simulate)
enum UploadMode { production, sandbox, simulate }
// ------------------ AddCameraSession ------------------
class AddCameraSession {
AddCameraSession({required this.profile, this.directionDegrees = 0});
CameraProfile profile;
double directionDegrees;
LatLng? target;
}
import 'models/tile_provider.dart';
import 'services/offline_area_service.dart';
import 'state/auth_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;
export 'state/session_state.dart' show AddCameraSession, EditCameraSession;
// ------------------ AppState ------------------
class AppState extends ChangeNotifier {
static late AppState instance;
// State modules
late final AuthState _authState;
late final ProfileState _profileState;
late final SessionState _sessionState;
late final SettingsState _settingsState;
late final UploadQueueState _uploadQueueState;
bool _isInitialized = false;
AppState() {
instance = this;
_authState = AuthState();
_profileState = ProfileState();
_sessionState = SessionState();
_settingsState = SettingsState();
_uploadQueueState = UploadQueueState();
// Set up state change listeners
_authState.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<CameraProfile> get profiles => _profileState.profiles;
List<CameraProfile> get enabledProfiles => _profileState.enabledProfiles;
bool isEnabled(CameraProfile p) => _profileState.isEnabled(p);
// Session state
AddCameraSession? get session => _sessionState.session;
EditCameraSession? get editSession => _sessionState.editSession;
// Settings state
bool get offlineMode => _settingsState.offlineMode;
int get maxCameras => _settingsState.maxCameras;
UploadMode get uploadMode => _settingsState.uploadMode;
// 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.production;
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 _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);
// ---------- Profile Methods ----------
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();
_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();
_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();
_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(),
);
}
// ---------- Addcamera session ----------
// ---------- Session Methods ----------
void startAddSession() {
_session = AddCameraSession(profile: enabledProfiles.first);
notifyListeners();
_sessionState.startAddSession(enabledProfiles);
}
void startEditSession(OsmCameraNode node) {
_sessionState.startEditSession(node, enabledProfiles);
}
void updateSession({
@@ -326,148 +150,120 @@ class AppState extends ChangeNotifier {
CameraProfile? profile,
LatLng? target,
}) {
if (_session == null) return;
_sessionState.updateSession(
directionDeg: directionDeg,
profile: profile,
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,
CameraProfile? profile,
LatLng? target,
}) {
_sessionState.updateEditSession(
directionDeg: directionDeg,
profile: profile,
target: target,
);
}
void cancelSession() {
_session = null;
notifyListeners();
_sessionState.cancelSession();
}
void cancelEditSession() {
_sessionState.cancelEditSession();
}
void commitSession() {
if (_session?.target == null) return;
_queue.add(
PendingUpload(
coord: _session!.target!,
direction: _session!.directionDegrees,
profile: _session!.profile,
),
);
_saveQueue();
_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);
}
// ---------- 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);
_profileState.removeListener(_onStateChanged);
_sessionState.removeListener(_onStateChanged);
_settingsState.removeListener(_onStateChanged);
_uploadQueueState.removeListener(_onStateChanged);
_uploadQueueState.dispose();
super.dispose();
}
}

View File

@@ -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,14 +9,40 @@ 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(0xFF111111); // FOV cone color
// Margin (bottom) for positioning the floating bottom button bar
const double kBottomButtonBarMargin = 4.0;
// Map overlay (attribution, scale bar, zoom) vertical offset from bottom edge
const double kAttributionBottomOffset = 110.0;
const double kZoomIndicatorBottomOffset = 142.0;
const double kScaleBarBottomOffset = 170.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.9.4';
// Marker/camera interaction
const int kCameraMinZoomLevel = 10; // Minimum zoom to show cameras 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';
const String kFollowMeModeKey = 'follow_me_mode';
// Tile/OSM fetch retry parameters (for tunable backoff)
const int kTileFetchMaxAttempts = 3;
const int kTileFetchInitialDelayMs = 4000;
@@ -26,3 +54,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 cameras from OSM - blue
const Color kCameraRingColorMock = Color(0xC4FFFFFF); // Add camera mock point - white
const Color kCameraRingColorPending = Color(0xC49C27B0); // Submitted/pending cameras - purple
const Color kCameraRingColorEditing = Color(0xC4FF9800); // Camera being edited - orange
const Color kCameraRingColorPendingEdit = Color(0xC4757575); // Original camera with pending edit - grey

View File

@@ -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();

View File

@@ -14,15 +14,26 @@ class CameraProfile {
this.builtin = false,
});
/// Builtin default: Generic Flock ALPR camera
factory CameraProfile.alpr() => CameraProfile(
id: 'builtin-alpr',
name: 'Generic Flock',
/// Builtin default: Generic ALPR camera (view-only)
factory CameraProfile.genericAlpr() => CameraProfile(
id: 'builtin-generic-alpr',
name: 'Generic ALPR',
tags: const {
'man_made': 'surveillance',
'surveillance:type': 'ALPR',
},
builtin: true,
);
/// Builtin: Flock Safety ALPR camera
factory CameraProfile.flock() => CameraProfile(
id: 'builtin-flock',
name: 'Flock',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'ALPR',
'surveillance:zone': 'traffic',
'surveillance:type': 'ALPR', // left for backward compatibility — you may want to revisit per OSM best practice
'camera:type': 'fixed',
'manufacturer': 'Flock Safety',
'manufacturer:wikidata': 'Q108485435',
@@ -30,6 +41,76 @@ class CameraProfile {
builtin: true,
);
/// Builtin: Motorola Solutions/Vigilant ALPR camera
factory CameraProfile.motorola() => CameraProfile(
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,
);
/// Builtin: Genetec ALPR camera
factory CameraProfile.genetec() => CameraProfile(
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,
);
/// Builtin: Leonardo/ELSAG ALPR camera
factory CameraProfile.leonardo() => CameraProfile(
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,
);
/// Builtin: Neology ALPR camera
factory CameraProfile.neology() => CameraProfile(
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,
);
/// Returns true if this profile can be used for submissions
bool get isSubmittable {
if (!builtin) return true; // All custom profiles are submittable
// Only the generic ALPR builtin profile is not submittable
return id != 'builtin-generic-alpr';
}
CameraProfile copyWith({
String? id,
String? name,

View File

@@ -1,10 +1,13 @@
import 'package:latlong2/latlong.dart';
import 'camera_profile.dart';
import '../state/settings_state.dart';
class PendingUpload {
final LatLng coord;
final double direction;
final CameraProfile profile;
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;
@@ -12,15 +15,34 @@ class PendingUpload {
required this.coord,
required this.direction,
required this.profile,
required this.uploadMode,
this.originalNodeId,
this.attempts = 0,
this.error = 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';
}
}
Map<String, dynamic> toJson() => {
'lat': coord.latitude,
'lon': coord.longitude,
'dir': direction,
'profile': profile.toJson(),
'uploadMode': uploadMode.index,
'originalNodeId': originalNodeId,
'attempts': attempts,
'error': error,
};
@@ -30,7 +52,11 @@ class PendingUpload {
direction: j['dir'],
profile: j['profile'] is Map<String, dynamic>
? CameraProfile.fromJson(j['profile'])
: CameraProfile.alpr(),
: CameraProfile.genericAlpr(),
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,
);

View 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',
),
],
),
];
}
}

View File

@@ -1,16 +1,22 @@
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 '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_camera_sheet.dart';
import '../widgets/edit_camera_sheet.dart';
import '../widgets/camera_provider_with_cache.dart';
import '../services/offline_areas/offline_tile_utils.dart';
import '../widgets/download_area_dialog.dart';
enum FollowMeMode {
off, // No following
northUp, // Follow position, keep north up
rotating, // Follow position and rotation
}
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@@ -19,12 +25,74 @@ 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;
FollowMeMode _followMeMode = FollowMeMode.northUp;
bool _editSheetShown = false;
@override
void initState() {
super.initState();
_mapController = AnimatedMapController(vsync: this);
// Load saved follow-me mode
_loadFollowMeMode();
}
/// Load the saved follow-me mode
Future<void> _loadFollowMeMode() async {
final savedMode = await MapViewState.loadFollowMeMode();
if (mounted) {
setState(() {
_followMeMode = savedMode;
});
}
}
@override
void dispose() {
_mapController.dispose();
super.dispose();
}
String _getFollowMeTooltip() {
switch (_followMeMode) {
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() {
switch (_followMeMode) {
case FollowMeMode.off:
return Icons.gps_off;
case FollowMeMode.northUp:
return Icons.gps_fixed;
case FollowMeMode.rotating:
return Icons.navigation;
}
}
FollowMeMode _getNextFollowMeMode() {
switch (_followMeMode) {
case FollowMeMode.off:
return FollowMeMode.northUp;
case FollowMeMode.northUp:
return FollowMeMode.rotating;
case FollowMeMode.rotating:
return FollowMeMode.off;
}
}
void _openAddCameraSheet() {
// Disable follow-me when adding a camera so the map doesn't jump around
setState(() => _followMeMode = FollowMeMode.off);
final appState = context.read<AppState>();
appState.startAddSession();
final session = appState.session!; // guaranteed nonnull now
@@ -34,13 +102,32 @@ class _HomeScreenState extends State<HomeScreen> {
);
}
void _openEditCameraSheet() {
// Disable follow-me when editing a camera so the map doesn't jump around
setState(() => _followMeMode = FollowMeMode.off);
final appState = context.read<AppState>();
final session = appState.editSession!; // should be non-null when this is called
_scaffoldKey.currentState!.showBottomSheet(
(ctx) => EditCameraSheet(session: session),
);
}
@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((_) => _openEditCameraSheet());
} else if (appState.editSession == null) {
_editSheetShown = false;
}
return MultiProvider(
providers: [
ChangeNotifierProvider<TileProviderWithCache>(create: (_) => TileProviderWithCache()),
ChangeNotifierProvider<CameraProviderWithCache>(create: (_) => CameraProviderWithCache()),
],
child: Scaffold(
@@ -49,9 +136,21 @@ class _HomeScreenState extends State<HomeScreen> {
title: const Text('Flock Map'),
actions: [
IconButton(
tooltip: _followMe ? 'Disable followme' : 'Enable followme',
icon: Icon(_followMe ? Icons.gps_fixed : Icons.gps_off),
onPressed: () => setState(() => _followMe = !_followMe),
tooltip: _getFollowMeTooltip(),
icon: Icon(_getFollowMeIcon()),
onPressed: () {
setState(() {
final oldMode = _followMeMode;
_followMeMode = _getNextFollowMeMode();
debugPrint('[HomeScreen] Follow mode changed: $oldMode$_followMeMode');
});
// Save the new follow-me mode
MapViewState.saveFollowMeMode(_followMeMode);
// If enabling follow-me, retry location init in case permission was granted
if (_followMeMode != FollowMeMode.off) {
_mapViewKey.currentState?.retryLocationInit();
}
},
),
IconButton(
icon: const Icon(Icons.settings),
@@ -59,220 +158,70 @@ class _HomeScreenState extends State<HomeScreen> {
),
],
),
body: MapView(
controller: _mapController,
followMe: _followMe,
onUserGesture: () {
if (_followMe) setState(() => _followMe = false);
},
),
floatingActionButton: appState.session == null
? Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
FloatingActionButton.extended(
onPressed: _openAddCameraSheet,
icon: const Icon(Icons.add_location_alt),
label: const Text('Tag Camera'),
heroTag: 'tag_camera_fab',
),
const SizedBox(height: 12),
FloatingActionButton.extended(
onPressed: () => showDialog(
context: context,
builder: (ctx) => DownloadAreaDialog(controller: _mapController),
),
icon: const Icon(Icons.download_for_offline),
label: const Text('Download'),
heroTag: 'download_fab',
),
],
)
: null,
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
),
);
}
}
// --- Download area dialog ---
class DownloadAreaDialog extends StatefulWidget {
final MapController controller;
const DownloadAreaDialog({super.key, required this.controller});
@override
State<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 = findDynamicMinZoom(bounds);
final maxZoom = _zoom.toInt();
final nTiles = computeTileList(bounds, minZoom, maxZoom).length;
final totalMb = (nTiles * kTileEstimateKb) / 1024.0;
setState(() {
_minZoom = minZoom;
_tileCount = nTiles;
_mbEstimate = totalMb;
});
}
@override
Widget build(BuildContext context) {
final bounds = widget.controller.camera.visibleBounds;
final maxZoom = _zoom.toInt();
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,
body: Stack(
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());
MapView(
key: _mapViewKey,
controller: _mapController,
followMeMode: _followMeMode,
onUserGesture: () {
if (_followMeMode != FollowMeMode.off) {
setState(() => _followMeMode = FollowMeMode.off);
}
},
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Storage estimate:'),
Text(_mbEstimate == null
? ''
: '${_tileCount} tiles, ${_mbEstimate!.toStringAsFixed(1)} MB'),
],
Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom + kBottomButtonBarMargin,
left: 8,
right: 8,
),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 10, offset: Offset(0, -2))],
),
margin: EdgeInsets.only(bottom: kBottomButtonBarMargin),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
children: [
Expanded(
child: ElevatedButton.icon(
icon: Icon(Icons.add_location_alt),
label: Text('Tag Camera'),
onPressed: _openAddCameraSheet,
style: ElevatedButton.styleFrom(
minimumSize: Size(0, 48),
textStyle: TextStyle(fontSize: 16),
),
),
),
SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
icon: Icon(Icons.download_for_offline),
label: Text('Download'),
onPressed: () => showDialog(
context: context,
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
),
style: ElevatedButton.styleFrom(
minimumSize: Size(0, 48),
textStyle: TextStyle(fontSize: 16),
),
),
),
],
),
),
),
),
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'),
),
],
);
}
}

View File

@@ -52,14 +52,16 @@ 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.builtin
? 'View Profile'
: (widget.profile.name.isEmpty ? 'New Profile' : 'Edit Profile')),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
TextField(
controller: _nameCtrl,
readOnly: widget.profile.builtin,
decoration: const InputDecoration(
labelText: 'Profile name',
hintText: 'e.g., Custom ALPR Camera',
@@ -71,20 +73,22 @@ class _ProfileEditorState extends State<ProfileEditor> {
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'),
),
if (!widget.profile.builtin)
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.builtin)
ElevatedButton(
onPressed: _save,
child: const Text('Save Profile'),
),
],
),
);
@@ -108,7 +112,10 @@ class _ProfileEditorState extends State<ProfileEditor> {
isDense: true,
),
controller: keyController,
onChanged: (v) => _tags[i] = MapEntry(v, _tags[i].value),
readOnly: widget.profile.builtin,
onChanged: widget.profile.builtin
? null
: (v) => _tags[i] = MapEntry(v, _tags[i].value),
),
),
const SizedBox(width: 8),
@@ -121,13 +128,17 @@ class _ProfileEditorState extends State<ProfileEditor> {
isDense: true,
),
controller: valueController,
onChanged: (v) => _tags[i] = MapEntry(_tags[i].key, v),
readOnly: widget.profile.builtin,
onChanged: widget.profile.builtin
? 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.builtin)
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () => setState(() => _tags.removeAt(i)),
),
],
),
);

View File

@@ -7,6 +7,7 @@ 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/tile_provider_section.dart';
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@@ -28,6 +29,8 @@ class SettingsScreen extends StatelessWidget {
Divider(),
MaxCamerasSection(),
Divider(),
TileProviderSection(),
Divider(),
OfflineModeSection(),
Divider(),
OfflineAreasSection(),

View File

@@ -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(() {});
},

View File

@@ -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),
),
);
}

View File

@@ -44,42 +44,67 @@ 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.builtin
? 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);
}
},
),
),
),
],

View File

@@ -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>();
@@ -73,10 +96,13 @@ class QueueSection extends StatelessWidget {
return ListTile(
leading: Icon(
upload.error ? Icons.error : Icons.camera_alt,
color: upload.error ? Colors.red : null,
color: upload.error
? Colors.red
: _getUploadModeColor(upload.uploadMode),
),
title: Text('Camera ${index + 1}${upload.error ? " (Error)" : ""}'),
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'

View File

@@ -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'),
),
),
],
);
}
}

View 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();
}
}

View 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'),
),
],
),
);
}
}

View File

@@ -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');

View File

@@ -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;
}
}
}

View File

@@ -1,11 +1,12 @@
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter/foundation.dart';
import '../models/camera_profile.dart';
import '../models/osm_camera_node.dart';
import '../app_state.dart';
import 'map_data_submodules/cameras_from_overpass.dart';
import 'map_data_submodules/tiles_from_osm.dart';
import 'map_data_submodules/tiles_from_remote.dart';
import 'map_data_submodules/cameras_from_local.dart';
import 'map_data_submodules/tiles_from_local.dart';
@@ -39,12 +40,10 @@ class MapDataProvider {
MapSource source = MapSource.auto,
}) async {
final offline = AppState.instance.offlineMode;
print('[MapDataProvider] getCameras called, source=$source, offlineMode=$offline');
// Explicit remote request: error if offline, else always remote
if (source == MapSource.remote) {
if (offline) {
print('[MapDataProvider] Overpass request BLOCKED because we are in offlineMode');
throw OfflineModeException("Cannot fetch remote cameras in offline mode.");
}
return camerasFromOverpass(
@@ -80,7 +79,7 @@ class MapDataProvider {
pageSize: AppState.instance.maxCameras,
);
} catch (e) {
print('[MapDataProvider] Remote camera fetch failed, error: $e. Falling back to local.');
debugPrint('[MapDataProvider] Remote camera fetch failed, error: $e. Falling back to local.');
return fetchLocalCameras(
bounds: bounds,
profiles: profiles,
@@ -121,15 +120,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 +139,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();
}
}

View File

@@ -1,11 +1,13 @@
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/camera_profile.dart';
import '../../models/osm_camera_node.dart';
import '../../app_state.dart';
import '../network_status.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).
@@ -42,14 +44,20 @@ Future<List<OsmCameraNode>> camerasFromOverpass({
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}');
// Only log errors
if (resp.statusCode != 200) {
print('[camerasFromOverpass] Overpass failed: ${resp.body}');
debugPrint('[camerasFromOverpass] Overpass failed: ${resp.body}');
NetworkStatus.instance.reportOverpassIssue();
return [];
}
final data = jsonDecode(resp.body) as Map<String, dynamic>;
final elements = data['elements'] as List<dynamic>;
print('[camerasFromOverpass] Retrieved elements: ${elements.length}');
// Only log if many cameras found or if it's a bulk download
if (elements.length > 20 || fetchAllPages) {
debugPrint('[camerasFromOverpass] Retrieved ${elements.length} cameras');
}
NetworkStatus.instance.reportOverpassSuccess();
return elements.whereType<Map<String, dynamic>>().map((e) {
return OsmCameraNode(
id: e['id'],
@@ -59,6 +67,14 @@ Future<List<OsmCameraNode>> camerasFromOverpass({
}).toList();
} catch (e) {
print('[camerasFromOverpass] Overpass exception: $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.reportOverpassIssue();
}
return [];
}
}

View File

@@ -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();

View File

@@ -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--;
}
}
}

View 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;
}
}

View 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();
}
}

View File

@@ -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();
}
}

View 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().getAllCamerasForDownload(
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()));
}
}

View File

@@ -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;
}

View File

@@ -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()));
}

View File

@@ -56,15 +56,30 @@ List<int> latLonToTile(double lat, double lon, int zoom) {
return [xtile, ytile];
}
int findDynamicMinZoom(LatLngBounds bounds, {int maxSearchZoom = 19}) {
for (int z = 1; z <= maxSearchZoom; z++) {
final swTile = latLonToTile(bounds.southWest.latitude, bounds.southWest.longitude, z);
final neTile = latLonToTile(bounds.northEast.latitude, bounds.northEast.longitude, z);
if (swTile[0] != neTile[0] || swTile[1] != neTile[1]) {
return z - 1 > 0 ? z - 1 : 1;
}
}
return maxSearchZoom;
/// 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() {

View 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',
);
}
}
}

View 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();
}
}

View File

@@ -2,7 +2,7 @@ import 'dart:async';
import 'package:http/http.dart' as http;
import '../models/pending_upload.dart';
import '../dev_config.dart';
import '../app_state.dart';
class Uploader {
@@ -17,11 +17,12 @@ class Uploader {
print('Uploader: Starting upload for camera 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="FlockMap 0.5"/>
<tag k="comment" v="Add surveillance camera"/>
<tag k="created_by" v="$kClientName $kClientVersion"/>
<tag k="comment" v="$action ${p.profile.name} surveillance camera"/>
</changeset>
</osm>''';
print('Uploader: Creating changeset...');
@@ -34,28 +35,45 @@ 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
// 2. create or update node
final mergedTags = Map<String, String>.from(p.profile.tags)
..['direction'] = p.direction.round().toString();
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) {
// Update existing node
final nodeXml = '''
<osm>
<node changeset="$csId" id="${p.originalNodeId}" 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...');

103
lib/state/auth_state.dart Normal file
View 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();
}
}

View File

@@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/camera_profile.dart';
import '../services/profile_service.dart';
class ProfileState extends ChangeNotifier {
static const String _enabledPrefsKey = 'enabled_profiles';
final List<CameraProfile> _profiles = [];
final Set<CameraProfile> _enabled = {};
// Getters
List<CameraProfile> get profiles => List.unmodifiable(_profiles);
bool isEnabled(CameraProfile p) => _enabled.contains(p);
List<CameraProfile> 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(CameraProfile.genericAlpr());
_profiles.add(CameraProfile.flock());
_profiles.add(CameraProfile.motorola());
_profiles.add(CameraProfile.genetec());
_profiles.add(CameraProfile.leonardo());
_profiles.add(CameraProfile.neology());
_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(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();
}
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 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();
}
// 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(),
);
}
}

View File

@@ -0,0 +1,157 @@
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
import '../models/camera_profile.dart';
import '../models/osm_camera_node.dart';
// ------------------ AddCameraSession ------------------
class AddCameraSession {
AddCameraSession({required this.profile, this.directionDegrees = 0});
CameraProfile profile;
double directionDegrees;
LatLng? target;
}
// ------------------ EditCameraSession ------------------
class EditCameraSession {
EditCameraSession({
required this.originalNode,
required this.profile,
required this.directionDegrees,
required this.target,
});
final OsmCameraNode originalNode; // The original camera being edited
CameraProfile profile;
double directionDegrees;
LatLng target; // Current position (can be dragged)
}
class SessionState extends ChangeNotifier {
AddCameraSession? _session;
EditCameraSession? _editSession;
// Getters
AddCameraSession? get session => _session;
EditCameraSession? get editSession => _editSession;
void startAddSession(List<CameraProfile> enabledProfiles) {
final submittableProfiles = enabledProfiles.where((p) => p.isSubmittable).toList();
final defaultProfile = submittableProfiles.isNotEmpty
? submittableProfiles.first
: enabledProfiles.first; // Fallback to any enabled profile
_session = AddCameraSession(profile: defaultProfile);
_editSession = null; // Clear any edit session
notifyListeners();
}
void startEditSession(OsmCameraNode node, List<CameraProfile> enabledProfiles) {
final submittableProfiles = enabledProfiles.where((p) => p.isSubmittable).toList();
// Try to find a matching profile based on the node's tags
CameraProfile 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 = EditCameraSession(
originalNode: node,
profile: matchingProfile,
directionDegrees: node.directionDeg ?? 0,
target: node.coord,
);
_session = null; // Clear any add session
notifyListeners();
}
bool _profileMatchesTags(CameraProfile 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,
CameraProfile? profile,
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 (target != null) {
_session!.target = target;
dirty = true;
}
if (dirty) notifyListeners();
}
void updateEditSession({
double? directionDeg,
CameraProfile? profile,
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 (target != null && target != _editSession!.target) {
_editSession!.target = target;
dirty = true;
}
if (dirty) notifyListeners();
}
void cancelSession() {
_session = null;
notifyListeners();
}
void cancelEditSession() {
_editSession = null;
notifyListeners();
}
AddCameraSession? commitSession() {
if (_session?.target == null) return null;
final session = _session!;
_session = null;
notifyListeners();
return session;
}
EditCameraSession? commitEditSession() {
if (_editSession == null) return null;
final session = _editSession!;
_editSession = null;
notifyListeners();
return session;
}
}

View File

@@ -0,0 +1,215 @@
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 }
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';
bool _offlineMode = false;
int _maxCameras = 250;
UploadMode _uploadMode = UploadMode.simulate;
List<TileProvider> _tileProviders = [];
String _selectedTileTypeId = '';
// Getters
bool get offlineMode => _offlineMode;
int get maxCameras => _maxCameras;
UploadMode get uploadMode => _uploadMode;
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 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();
}
}

View File

@@ -0,0 +1,214 @@
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(AddCameraSession session, {required UploadMode uploadMode}) {
final upload = PendingUpload(
coord: session.target!,
direction: session.directionDegrees,
profile: session.profile,
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 = 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();
notifyListeners();
}
// Add a completed edit session to the upload queue
void addFromEditSession(EditCameraSession session, {required UploadMode uploadMode}) {
final upload = PendingUpload(
coord: session.target,
direction: session.directionDegrees,
profile: session.profile,
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 = Map<String, String>.from(upload.profile.tags);
editedTags['direction'] = upload.direction.toStringAsFixed(0);
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, () {
_queue.remove(item);
_saveQueue();
notifyListeners();
}, uploadMode: item.uploadMode);
ok = await up.upload(item);
}
if (ok && item.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));
}
}
});
}
void stopUploader() {
_uploadTimer?.cancel();
}
// ---------- 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();
}
}

View File

@@ -26,8 +26,8 @@ class AddCameraSheet extends StatelessWidget {
Navigator.pop(context);
}
final customProfiles = appState.enabledProfiles.where((p) => !p.builtin).toList();
final allowSubmit = customProfiles.isNotEmpty && !session.profile.builtin;
final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList();
final allowSubmit = submittableProfiles.isNotEmpty && session.profile.isSubmittable;
return Padding(
padding:
@@ -49,7 +49,7 @@ class AddCameraSheet extends StatelessWidget {
title: const Text('Profile'),
trailing: DropdownButton<CameraProfile>(
value: session.profile,
items: appState.enabledProfiles
items: submittableProfiles
.map((p) => DropdownMenuItem(value: p, child: Text(p.name)))
.toList(),
onChanged: (p) =>
@@ -67,7 +67,7 @@ class AddCameraSheet extends StatelessWidget {
onChanged: (v) => appState.updateSession(directionDeg: v),
),
),
if (customProfiles.isEmpty)
if (submittableProfiles.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
@@ -76,14 +76,14 @@ class AddCameraSheet extends StatelessWidget {
SizedBox(width: 6),
Expanded(
child: Text(
'Enable or create a custom profile in Settings to submit new cameras.',
'Enable a submittable profile in Settings to submit new cameras.',
style: TextStyle(color: Colors.red, fontSize: 13),
),
),
],
),
)
else if (session.profile.builtin)
else if (!session.profile.isSubmittable)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
@@ -92,7 +92,7 @@ class AddCameraSheet extends StatelessWidget {
SizedBox(width: 6),
Expanded(
child: Text(
'The built-in profile is for map viewing only. Please select a custom profile to submit new cameras.',
'This profile is for map viewing only. Please select a submittable profile to submit new cameras.',
style: TextStyle(color: Colors.orange, fontSize: 13),
),
),

View 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,
),
),
);
}
}

View File

@@ -5,6 +5,7 @@ import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
import '../services/map_data_provider.dart';
import '../services/camera_cache.dart';
import '../services/network_status.dart';
import '../models/camera_profile.dart';
import '../models/osm_camera_node.dart';
import '../app_state.dart';
@@ -19,8 +20,18 @@ 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
@@ -35,24 +46,24 @@ class CameraProviderWithCache extends ChangeNotifier {
// Debounce rapid panning/zooming
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 400), () async {
final isOffline = AppState.instance.offlineMode;
if (!isOffline) {
try {
final fresh = await MapDataProvider().getCameras(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
source: MapSource.remote,
);
if (fresh.isNotEmpty) {
CameraCache.instance.addOrUpdate(fresh);
notifyListeners();
}
} catch (e) {
debugPrint('[CameraProviderWithCache] Overpass fetch failed: $e');
// Cache already holds whatever is available for the view
try {
// Use MapSource.auto to handle both offline and online modes appropriately
final fresh = await MapDataProvider().getCameras(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
source: MapSource.auto,
);
if (fresh.isNotEmpty) {
CameraCache.instance.addOrUpdate(fresh);
// Clear waiting status when camera data arrives
NetworkStatus.instance.clearWaiting();
notifyListeners();
}
} // else, only cache is used
} catch (e) {
debugPrint('[CameraProviderWithCache] Camera fetch failed: $e');
// Cache already holds whatever is available for the view
}
});
}
@@ -61,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<CameraProfile> 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, CameraProfile profile) {
for (final entry in profile.tags.entries) {
if (camera.tags[entry.key] != entry.value) return false;
}
return true;
}
}

View File

@@ -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'),
),
],
),
],
),
),
),
);
}

View 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'),
),
],
);
}
}

View File

@@ -0,0 +1,151 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../app_state.dart';
import '../models/camera_profile.dart';
import '../state/settings_state.dart';
class EditCameraSheet extends StatelessWidget {
const EditCameraSheet({super.key, required this.session});
final EditCameraSession 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('Camera 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 = submittableProfiles.isNotEmpty && session.profile.isSubmittable && !isSandboxMode;
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 Camera #${session.originalNode.id}',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
ListTile(
title: const Text('Profile'),
trailing: DropdownButton<CameraProfile>(
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: (v) => appState.updateEditSession(directionDeg: v),
),
),
if (isSandboxMode)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: const [
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 cameras.',
style: TextStyle(color: Colors.blue, fontSize: 13),
),
),
],
),
)
else if (submittableProfiles.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 a submittable profile in Settings to edit cameras.',
style: TextStyle(color: Colors.red, fontSize: 13),
),
),
],
),
)
else if (!session.profile.isSubmittable)
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(
'This profile is for map viewing only. Please select a submittable profile to edit 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('Save Edit'),
),
),
],
),
),
const SizedBox(height: 20),
],
),
);
}
}

View 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;
}
}

View 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,
AddCameraSession? session,
EditCameraSession? editSession,
}) {
final overlays = <Polygon>[];
// Add session cone if in add-camera mode
if (session != null && session.target != null) {
overlays.add(_buildCone(
session.target!,
session.directionDegrees,
zoom,
isSession: true,
));
}
// Add edit session cone if in edit-camera mode
if (editSession != null) {
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,
);
}
}

View 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,
),
),
);
}
}

View File

@@ -0,0 +1,150 @@
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 AddCameraSession? session;
final EditCameraSession? 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),
),
],
),
),
// Fixed pin when adding or editing camera
if (session != null || editSession != null)
IgnorePointer(
child: Center(
child: Transform.translate(
offset: const Offset(0, kAddPinYOffset),
child: CameraIcon(
type: editSession != null ? CameraIconType.editing : CameraIconType.mock
),
),
),
),
],
);
}
}

View File

@@ -1,122 +1,145 @@
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 'package:http/http.dart' as http;
import 'package:collection/collection.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../app_state.dart';
import '../services/map_data_provider.dart';
import '../services/offline_area_service.dart';
import '../services/simple_tile_service.dart';
import '../services/network_status.dart';
import '../models/osm_camera_node.dart';
import '../models/camera_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) {
return GestureDetector(
onTap: _onTap,
onDoubleTap: _onDoubleTap,
child: const Icon(Icons.videocam, color: Colors.orange),
);
}
}
import 'map/camera_markers.dart';
import 'map/direction_cones.dart';
import 'map/map_overlays.dart';
import 'network_status_indicator.dart';
import '../dev_config.dart';
import '../screens/home_screen.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,
});
final bool followMe;
final FollowMeMode followMeMode;
final VoidCallback onUserGesture;
@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;
LatLng? _initialLocation;
double? _initialZoom;
bool _hasMovedToInitialLocation = false;
late final CameraProviderWithCache _cameraProvider;
late final SimpleTileHttpClient _tileHttpClient;
// Track profile changes to trigger camera refresh
List<CameraProfile>? _lastEnabledProfiles;
// Track zoom to clear queue on zoom changes
double? _lastZoom;
// Track changes that require cache clearing
String? _lastTileTypeId;
bool? _lastOfflineMode;
int _mapRebuildKey = 0;
@override
void initState() {
super.initState();
// _debounceTileLayerUpdate removed
OfflineAreaService();
_controller = widget.controller;
_tileHttpClient = SimpleTileHttpClient();
// Load last map position before initializing GPS
_loadLastMapPosition().then((_) {
// Move to last known position after loading and widget is built
WidgetsBinding.instance.addPostFrameCallback((_) {
_moveToInitialLocationIfNeeded();
});
});
_initLocation();
// Set up camera overlay caching
_cameraProvider = CameraProviderWithCache.instance;
_cameraProvider.addListener(_onCamerasUpdated);
// Ensure initial overlays are fetched
// Fetch initial cameras
WidgetsBinding.instance.addPostFrameCallback((_) {
_refreshCamerasFromProvider();
});
}
/// Move to initial location if we have one and haven't moved yet
void _moveToInitialLocationIfNeeded() {
if (!_hasMovedToInitialLocation && _initialLocation != null && mounted) {
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('[MapView] Moved to initial location: ${_initialLocation!.latitude}, ${_initialLocation!.longitude}');
} else {
debugPrint('[MapView] Invalid initial location, not moving: ${_initialLocation!.latitude}, ${_initialLocation!.longitude}, zoom: $zoom');
}
} catch (e) {
debugPrint('[MapView] Failed to move to initial location: $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;
}
@override
void dispose() {
_positionSub?.cancel();
_debounce.dispose();
_cameraDebounce.dispose();
_tileDebounce.dispose();
_mapPositionDebounce.dispose();
_cameraProvider.removeListener(_onCamerasUpdated);
_tileHttpClient.close();
super.dispose();
}
@@ -124,15 +147,107 @@ class _MapViewState extends State<MapView> {
if (mounted) setState(() {});
}
/// Public method to retry location initialization (e.g., after permission granted)
void retryLocationInit() {
debugPrint('[MapView] Retrying location initialization');
_initLocation();
}
/// Save the last map position to persistent storage
Future<void> _saveLastMapPosition(LatLng location, double zoom) async {
try {
// Validate coordinates and zoom before saving
if (!_isValidCoordinate(location.latitude) ||
!_isValidCoordinate(location.longitude) ||
!_isValidZoom(zoom)) {
debugPrint('[MapView] 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('[MapView] Saved last map position: ${location.latitude}, ${location.longitude}, zoom: $zoom');
} catch (e) {
debugPrint('[MapView] Failed to save last map position: $e');
}
}
/// Load the last map position from persistent storage
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('[MapView] Loaded last map position: ${_initialLocation!.latitude}, ${_initialLocation!.longitude}, zoom: $_initialZoom');
} else {
debugPrint('[MapView] Invalid saved coordinates, using defaults');
}
} catch (e) {
debugPrint('[MapView] Failed to load last map position: $e');
}
}
/// Save the follow-me mode to persistent storage
static Future<void> saveFollowMeMode(FollowMeMode mode) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(kFollowMeModeKey, mode.index);
debugPrint('[MapView] Saved follow-me mode: $mode');
} catch (e) {
debugPrint('[MapView] Failed to save follow-me mode: $e');
}
}
/// Load the follow-me mode from persistent storage
static Future<FollowMeMode> loadFollowMeMode() async {
try {
final prefs = await SharedPreferences.getInstance();
final modeIndex = prefs.getInt(kFollowMeModeKey);
if (modeIndex != null && modeIndex < FollowMeMode.values.length) {
final mode = FollowMeMode.values[modeIndex];
debugPrint('[MapView] Loaded follow-me mode: $mode');
return mode;
}
} catch (e) {
debugPrint('[MapView] Failed to load follow-me mode: $e');
}
// Default to northUp if no saved mode
return FollowMeMode.northUp;
}
/// 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('[MapView] Cleared stored map position');
} catch (e) {
debugPrint('[MapView] Failed to clear stored map position: $e');
}
}
void _refreshCamerasFromProvider() {
final appState = context.read<AppState>();
LatLngBounds? bounds;
try {
bounds = _controller.camera.visibleBounds;
bounds = _controller.mapController.camera.visibleBounds;
} catch (_) {
return;
}
final zoom = _controller.camera.zoom;
final zoom = _controller.mapController.camera.zoom;
if (zoom < kCameraMinZoomLevel) {
// Show a snackbar-style bubble, if desired
if (mounted) {
@@ -152,11 +267,35 @@ class _MapViewState extends State<MapView> {
);
}
@override
void didUpdateWidget(covariant MapView oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.followMe && !oldWidget.followMe && _currentLatLng != null) {
_controller.move(_currentLatLng!, _controller.camera.zoom);
// Back to original pattern - simple check
if (widget.followMeMode != FollowMeMode.off &&
oldWidget.followMeMode == FollowMeMode.off &&
_currentLatLng != null) {
// Move to current location when follow me is first enabled - smooth animation
if (widget.followMeMode == FollowMeMode.northUp) {
_controller.animateTo(
dest: _currentLatLng!,
zoom: _controller.mapController.camera.zoom,
duration: kFollowMeAnimationDuration,
curve: Curves.easeOut,
);
} else if (widget.followMeMode == FollowMeMode.rotating) {
// When switching to rotating mode, reset to north-up first - smooth animation
_controller.animateTo(
dest: _currentLatLng!,
zoom: _controller.mapController.camera.zoom,
rotation: 0.0,
duration: kFollowMeAnimationDuration,
curve: Curves.easeOut,
);
}
}
}
@@ -169,11 +308,37 @@ class _MapViewState extends State<MapView> {
Geolocator.getPositionStream().listen((Position position) {
final latLng = LatLng(position.latitude, position.longitude);
setState(() => _currentLatLng = latLng);
if (widget.followMe) {
// Back to original pattern - directly check widget parameter
if (widget.followMeMode != FollowMeMode.off) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
try {
_controller.move(latLng, _controller.camera.zoom);
if (widget.followMeMode == FollowMeMode.northUp) {
// Follow position only, keep current rotation - smooth animation
_controller.animateTo(
dest: latLng,
zoom: _controller.mapController.camera.zoom,
duration: kFollowMeAnimationDuration,
curve: Curves.easeOut,
);
} else if (widget.followMeMode == FollowMeMode.rotating) {
// Follow position and rotation based on heading - smooth animation
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('MapController not ready yet: $e');
}
@@ -185,29 +350,105 @@ class _MapViewState extends State<MapView> {
double _safeZoom() {
try {
return _controller.camera.zoom;
return _controller.mapController.camera.zoom;
} catch (_) {
return 15.0;
}
}
/// Helper to check if two profile lists are equal
bool _profileListsEqual(List<CameraProfile> list1, List<CameraProfile> 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);
}
/// Build tile layer - uses fake domain that SimpleTileHttpClient can parse
Widget _buildTileLayer(AppState appState) {
final selectedTileType = appState.selectedTileType;
final selectedProvider = appState.selectedTileProvider;
// 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
),
);
}
@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
final currentEnabledProfiles = appState.enabledProfiles;
if (_lastEnabledProfiles == null ||
!_profileListsEqual(_lastEnabledProfiles!, currentEnabledProfiles)) {
_lastEnabledProfiles = List.from(currentEnabledProfiles);
// Refresh cameras when profiles change
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();
// Then fetch new cameras for newly enabled profiles
_refreshCamerasFromProvider();
});
}
// Check if tile type OR offline mode changed and clear cache if needed
final currentTileTypeId = appState.selectedTileType?.id;
final currentOfflineMode = appState.offlineMode;
if ((_lastTileTypeId != null && _lastTileTypeId != currentTileTypeId) ||
(_lastOfflineMode != null && _lastOfflineMode != currentOfflineMode)) {
// Force map rebuild with new key to bust flutter_map cache
_mapRebuildKey++;
final reason = _lastTileTypeId != currentTileTypeId
? 'tile type ($currentTileTypeId)'
: 'offline mode ($currentOfflineMode)';
debugPrint('[MapView] *** CACHE CLEAR *** $reason changed - rebuilding map $_mapRebuildKey');
WidgetsBinding.instance.addPostFrameCallback((_) {
debugPrint('[MapView] Post-frame: Clearing tile request queue');
_tileHttpClient.clearTileQueue();
});
}
_lastTileTypeId = currentTileTypeId;
_lastOfflineMode = currentOfflineMode;
// Seed addmode 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)
@@ -215,44 +456,34 @@ 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: _currentLatLng,
);
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);
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)),
];
return Stack(
children: [
PolygonLayer(polygons: overlays),
if (editLines.isNotEmpty) PolylineLayer(polylines: editLines),
MarkerLayer(markers: markers),
],
);
@@ -262,11 +493,11 @@ class _MapViewState extends State<MapView> {
return Stack(
children: [
FlutterMap(
key: ValueKey(appState.offlineMode),
mapController: _controller,
key: ValueKey('map_${appState.offlineMode}_${appState.selectedTileType?.id ?? 'none'}_$_mapRebuildKey'),
mapController: _controller.mapController,
options: MapOptions(
initialCenter: _currentLatLng ?? LatLng(37.7749, -122.4194),
initialZoom: 15,
initialCenter: _currentLatLng ?? _initialLocation ?? LatLng(37.7749, -122.4194),
initialZoom: _initialZoom ?? 15,
maxZoom: 19,
onPositionChanged: (pos, gesture) {
setState(() {}); // Instant UI update for zoom, etc.
@@ -274,44 +505,48 @@ 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)
_tileHttpClient.clearTileQueue();
});
}
_lastZoom = currentZoom;
// Save map position (debounced to avoid excessive writes)
_mapPositionDebounce(() {
// Only save if position and zoom are valid
if (_isValidCoordinate(pos.center.latitude) &&
_isValidCoordinate(pos.center.longitude) &&
_isValidZoom(pos.zoom)) {
_saveLastMapPosition(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;
}
}
),
_buildTileLayer(appState),
cameraLayers,
// Built-in scale bar from flutter_map
Scalebar(
alignment: Alignment.bottomLeft,
padding: EdgeInsets.only(left: 8, bottom: 54), // above attribution
padding: EdgeInsets.only(left: 8, bottom: kScaleBarBottomOffset), // from dev_config
textStyle: TextStyle(color: Colors.black, fontWeight: FontWeight.bold),
lineColor: Colors.black,
strokeWidth: 3,
@@ -320,107 +555,51 @@ class _MapViewState extends State<MapView> {
],
),
// 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: 92,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.52),
borderRadius: BorderRadius.circular(7),
),
child: Builder(
builder: (context) {
final zoom = _controller.camera.zoom;
return Text(
'Zoom: ${zoom.toStringAsFixed(2)}',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600,
),
);
},
),
),
),
// Attribution overlay
Positioned(
bottom: 20,
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),
),
),
// All map overlays (mode indicator, zoom, attribution, add pin)
MapOverlays(
mapController: _controller.mapController,
uploadMode: appState.uploadMode,
session: session,
editSession: editSession,
attribution: appState.selectedTileType?.attribution,
),
// Fixed pin when adding camera
if (session != null)
const IgnorePointer(
child: Center(
child: Icon(Icons.place, size: 40, color: Colors.redAccent),
),
),
// Network status indicator (top-left)
const NetworkStatusIndicator(),
],
);
}
Polygon _buildCone(LatLng origin, double bearingDeg, double zoom) {
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);
return Polygon(
points: [origin, left, right, origin],
color: Colors.redAccent.withOpacity(0.25),
borderColor: Colors.redAccent,
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;
}
}

View 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,
),
),
],
),
),
);
},
),
);
}
}

View File

@@ -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.
}
}
}

42
macos/Podfile Normal file
View File

@@ -0,0 +1,42 @@
platform :osx, '10.15'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\""
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_macos_podfile_setup
target 'Runner' do
use_frameworks!
flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_macos_build_settings(target)
end
end

View File

@@ -0,0 +1,801 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXAggregateTarget section */
33CC111A2044C6BA0003C045 /* Flutter Assemble */ = {
isa = PBXAggregateTarget;
buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */;
buildPhases = (
33CC111E2044C6BF0003C045 /* ShellScript */,
);
dependencies = (
);
name = "Flutter Assemble";
productName = FLX;
};
/* End PBXAggregateTarget section */
/* Begin PBXBuildFile section */
331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; };
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; };
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; };
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; };
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
4ABD443377DEEA0E6ABDF041 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 14DE83B4CECC3B5785F26339 /* Pods_RunnerTests.framework */; };
6C9CBD6E8FB459527EFAC650 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 787E01B101B3B87713551F4B /* Pods_Runner.framework */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 33CC10E52044A3C60003C045 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 33CC10EC2044A3C60003C045;
remoteInfo = Runner;
};
33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 33CC10E52044A3C60003C045 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 33CC111A2044C6BA0003C045;
remoteInfo = FLX;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
33CC110E2044A8840003C045 /* Bundle Framework */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Bundle Framework";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
14DE83B4CECC3B5785F26339 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
33CC10ED2044A3C60003C045 /* flock_map_app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = flock_map_app.app; sourceTree = BUILT_PRODUCTS_DIR; };
33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = "<group>"; };
33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = "<group>"; };
33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = "<group>"; };
33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = "<group>"; };
33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = "<group>"; };
33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = "<group>"; };
33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = "<group>"; };
33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; };
56FF786478D8CA9C8C96AA65 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
787E01B101B3B87713551F4B /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
A5843D2F351DECB4BC4BEAAB /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
AB51B320061555571937E868 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
BD24094E8F1C40303547AFDE /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
D27A8C599345B70419381EFA /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
E7DC9C3D113BA5E9CC61AC3A /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
331C80D2294CF70F00263BE5 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
4ABD443377DEEA0E6ABDF041 /* Pods_RunnerTests.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
33CC10EA2044A3C60003C045 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
6C9CBD6E8FB459527EFAC650 /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C80D6294CF71000263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C80D7294CF71000263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
33BA886A226E78AF003329D5 /* Configs */ = {
isa = PBXGroup;
children = (
33E5194F232828860026EE4D /* AppInfo.xcconfig */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
333000ED22D3DE5D00554162 /* Warnings.xcconfig */,
);
path = Configs;
sourceTree = "<group>";
};
33CC10E42044A3C60003C045 = {
isa = PBXGroup;
children = (
33FAB671232836740065AC1E /* Runner */,
33CEB47122A05771004F2AC0 /* Flutter */,
331C80D6294CF71000263BE5 /* RunnerTests */,
33CC10EE2044A3C60003C045 /* Products */,
D73912EC22F37F3D000D13A0 /* Frameworks */,
EDD70D25756DD7FE6827E9B4 /* Pods */,
);
sourceTree = "<group>";
};
33CC10EE2044A3C60003C045 /* Products */ = {
isa = PBXGroup;
children = (
33CC10ED2044A3C60003C045 /* flock_map_app.app */,
331C80D5294CF71000263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
33CC11242044D66E0003C045 /* Resources */ = {
isa = PBXGroup;
children = (
33CC10F22044A3C60003C045 /* Assets.xcassets */,
33CC10F42044A3C60003C045 /* MainMenu.xib */,
33CC10F72044A3C60003C045 /* Info.plist */,
);
name = Resources;
path = ..;
sourceTree = "<group>";
};
33CEB47122A05771004F2AC0 /* Flutter */ = {
isa = PBXGroup;
children = (
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */,
33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */,
33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */,
33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */,
);
path = Flutter;
sourceTree = "<group>";
};
33FAB671232836740065AC1E /* Runner */ = {
isa = PBXGroup;
children = (
33CC10F02044A3C60003C045 /* AppDelegate.swift */,
33CC11122044BFA00003C045 /* MainFlutterWindow.swift */,
33E51913231747F40026EE4D /* DebugProfile.entitlements */,
33E51914231749380026EE4D /* Release.entitlements */,
33CC11242044D66E0003C045 /* Resources */,
33BA886A226E78AF003329D5 /* Configs */,
);
path = Runner;
sourceTree = "<group>";
};
D73912EC22F37F3D000D13A0 /* Frameworks */ = {
isa = PBXGroup;
children = (
787E01B101B3B87713551F4B /* Pods_Runner.framework */,
14DE83B4CECC3B5785F26339 /* Pods_RunnerTests.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
EDD70D25756DD7FE6827E9B4 /* Pods */ = {
isa = PBXGroup;
children = (
E7DC9C3D113BA5E9CC61AC3A /* Pods-Runner.debug.xcconfig */,
D27A8C599345B70419381EFA /* Pods-Runner.release.xcconfig */,
56FF786478D8CA9C8C96AA65 /* Pods-Runner.profile.xcconfig */,
AB51B320061555571937E868 /* Pods-RunnerTests.debug.xcconfig */,
BD24094E8F1C40303547AFDE /* Pods-RunnerTests.release.xcconfig */,
A5843D2F351DECB4BC4BEAAB /* Pods-RunnerTests.profile.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C80D4294CF70F00263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
E7C7AB9BE4EB75C78762778A /* [CP] Check Pods Manifest.lock */,
331C80D1294CF70F00263BE5 /* Sources */,
331C80D2294CF70F00263BE5 /* Frameworks */,
331C80D3294CF70F00263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C80DA294CF71000263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
33CC10EC2044A3C60003C045 /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
95B0D858ED0CCD2F613E2F77 /* [CP] Check Pods Manifest.lock */,
33CC10E92044A3C60003C045 /* Sources */,
33CC10EA2044A3C60003C045 /* Frameworks */,
33CC10EB2044A3C60003C045 /* Resources */,
33CC110E2044A8840003C045 /* Bundle Framework */,
3399D490228B24CF009A79C7 /* ShellScript */,
910E141E4DDEB328B0A70A97 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
dependencies = (
33CC11202044C79F0003C045 /* PBXTargetDependency */,
);
name = Runner;
productName = Runner;
productReference = 33CC10ED2044A3C60003C045 /* flock_map_app.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
33CC10E52044A3C60003C045 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 0920;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C80D4294CF70F00263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 33CC10EC2044A3C60003C045;
};
33CC10EC2044A3C60003C045 = {
CreatedOnToolsVersion = 9.2;
LastSwiftMigration = 1100;
ProvisioningStyle = Automatic;
SystemCapabilities = {
com.apple.Sandbox = {
enabled = 1;
};
};
};
33CC111A2044C6BA0003C045 = {
CreatedOnToolsVersion = 9.2;
ProvisioningStyle = Manual;
};
};
};
buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 33CC10E42044A3C60003C045;
productRefGroup = 33CC10EE2044A3C60003C045 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
33CC10EC2044A3C60003C045 /* Runner */,
331C80D4294CF70F00263BE5 /* RunnerTests */,
33CC111A2044C6BA0003C045 /* Flutter Assemble */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C80D3294CF70F00263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
33CC10EB2044A3C60003C045 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */,
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3399D490228B24CF009A79C7 /* ShellScript */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n";
};
33CC111E2044C6BF0003C045 /* ShellScript */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
Flutter/ephemeral/FlutterInputs.xcfilelist,
);
inputPaths = (
Flutter/ephemeral/tripwire,
);
outputFileListPaths = (
Flutter/ephemeral/FlutterOutputs.xcfilelist,
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire";
};
910E141E4DDEB328B0A70A97 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
95B0D858ED0CCD2F613E2F77 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
E7C7AB9BE4EB75C78762778A /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C80D1294CF70F00263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
33CC10E92044A3C60003C045 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */,
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */,
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C80DA294CF71000263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 33CC10EC2044A3C60003C045 /* Runner */;
targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */;
};
33CC11202044C79F0003C045 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */;
targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
33CC10F42044A3C60003C045 /* MainMenu.xib */ = {
isa = PBXVariantGroup;
children = (
33CC10F52044A3C60003C045 /* Base */,
);
name = MainMenu.xib;
path = Runner;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
331C80DB294CF71000263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = AB51B320061555571937E868 /* Pods-RunnerTests.debug.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.flockMapApp.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/flock_map_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/flock_map_app";
};
name = Debug;
};
331C80DC294CF71000263BE5 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = BD24094E8F1C40303547AFDE /* Pods-RunnerTests.release.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.flockMapApp.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/flock_map_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/flock_map_app";
};
name = Release;
};
331C80DD294CF71000263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = A5843D2F351DECB4BC4BEAAB /* Pods-RunnerTests.profile.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.flockMapApp.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/flock_map_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/flock_map_app";
};
name = Profile;
};
338D0CE9231458BD00FA5F75 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CODE_SIGN_IDENTITY = "-";
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.15;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
};
name = Profile;
};
338D0CEA231458BD00FA5F75 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 5.0;
};
name = Profile;
};
338D0CEB231458BD00FA5F75 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Manual;
PRODUCT_NAME = "$(TARGET_NAME)";
};
name = Profile;
};
33CC10F92044A3C60003C045 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CODE_SIGN_IDENTITY = "-";
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.15;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
33CC10FA2044A3C60003C045 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CODE_SIGN_IDENTITY = "-";
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.15;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
};
name = Release;
};
33CC10FC2044A3C60003C045 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
};
name = Debug;
};
33CC10FD2044A3C60003C045 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 5.0;
};
name = Release;
};
33CC111C2044C6BA0003C045 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Manual;
PRODUCT_NAME = "$(TARGET_NAME)";
};
name = Debug;
};
33CC111D2044C6BA0003C045 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
PRODUCT_NAME = "$(TARGET_NAME)";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C80DB294CF71000263BE5 /* Debug */,
331C80DC294CF71000263BE5 /* Release */,
331C80DD294CF71000263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
33CC10F92044A3C60003C045 /* Debug */,
33CC10FA2044A3C60003C045 /* Release */,
338D0CE9231458BD00FA5F75 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
33CC10FC2044A3C60003C045 /* Debug */,
33CC10FD2044A3C60003C045 /* Release */,
338D0CEA231458BD00FA5F75 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = {
isa = XCConfigurationList;
buildConfigurations = (
33CC111C2044C6BA0003C045 /* Debug */,
33CC111D2044C6BA0003C045 /* Release */,
338D0CEB231458BD00FA5F75 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 33CC10E52044A3C60003C045 /* Project object */;
}

View File

@@ -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:

View File

@@ -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

View 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);
});
});
}