Compare commits

...

23 Commits

Author SHA1 Message Date
stopflock
c42d3afd0b Fix edit submission (addnode version to xml changeset) and improve queue UI upon successful submission. And bump version - patch for 0.9.4. 2025-08-28 23:29:34 -05:00
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
25 changed files with 1397 additions and 315 deletions

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.

View File

@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import 'models/camera_profile.dart';
import 'models/osm_camera_node.dart';
import 'models/pending_upload.dart';
import 'models/tile_provider.dart';
import 'services/offline_area_service.dart';
@@ -14,7 +15,7 @@ import 'state/upload_queue_state.dart';
// Re-export types
export 'state/settings_state.dart' show UploadMode;
export 'state/session_state.dart' show AddCameraSession;
export 'state/session_state.dart' show AddCameraSession, EditCameraSession;
// ------------------ AppState ------------------
class AppState extends ChangeNotifier {
@@ -61,6 +62,7 @@ class AppState extends ChangeNotifier {
// Session state
AddCameraSession? get session => _sessionState.session;
EditCameraSession? get editSession => _sessionState.editSession;
// Settings state
bool get offlineMode => _settingsState.offlineMode;
@@ -139,6 +141,10 @@ class AppState extends ChangeNotifier {
_sessionState.startAddSession(enabledProfiles);
}
void startEditSession(OsmCameraNode node) {
_sessionState.startEditSession(node, enabledProfiles);
}
void updateSession({
double? directionDeg,
CameraProfile? profile,
@@ -151,14 +157,38 @@ class AppState extends ChangeNotifier {
);
}
void updateEditSession({
double? directionDeg,
CameraProfile? profile,
LatLng? target,
}) {
_sessionState.updateEditSession(
directionDeg: directionDeg,
profile: profile,
target: target,
);
}
void cancelSession() {
_sessionState.cancelSession();
}
void cancelEditSession() {
_sessionState.cancelEditSession();
}
void commitSession() {
final session = _sessionState.commitSession();
if (session != null) {
_uploadQueueState.addFromSession(session);
_uploadQueueState.addFromSession(session, uploadMode: uploadMode);
_startUploader();
}
}
void commitEditSession() {
final session = _sessionState.commitEditSession();
if (session != null) {
_uploadQueueState.addFromEditSession(session, uploadMode: uploadMode);
_startUploader();
}
}

View File

@@ -26,13 +26,23 @@ const double kAddPinYOffset = 0.0;
// Client name and version for OSM uploads ("created_by" tag)
const String kClientName = 'FlockMap';
const String kClientVersion = '0.8.10';
const String kClientVersion = '0.9.5';
// 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;
@@ -56,3 +66,5 @@ 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

@@ -14,19 +14,103 @@ 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',
'camera:type': 'fixed',
'manufacturer': 'Flock Safety',
'manufacturer:wikidata': 'Q108485435',
},
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,28 +1,53 @@
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;
bool completing; // True when upload succeeded but item is showing checkmark briefly
PendingUpload({
required this.coord,
required this.direction,
required this.profile,
required this.uploadMode,
this.originalNodeId,
this.attempts = 0,
this.error = false,
this.completing = false,
});
// True if this is an edit of an existing camera, false if it's a new camera
bool get isEdit => originalNodeId != null;
// Get display name for the upload destination
String get uploadModeDisplayName {
switch (uploadMode) {
case UploadMode.production:
return 'Production';
case UploadMode.sandbox:
return 'Sandbox';
case UploadMode.simulate:
return 'Simulate';
}
}
Map<String, dynamic> toJson() => {
'lat': coord.latitude,
'lon': coord.longitude,
'dir': direction,
'profile': profile.toJson(),
'uploadMode': uploadMode.index,
'originalNodeId': originalNodeId,
'attempts': attempts,
'error': error,
'completing': completing,
};
factory PendingUpload.fromJson(Map<String, dynamic> j) => PendingUpload(
@@ -30,9 +55,14 @@ 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,
completing: j['completing'] ?? false, // Default to false for legacy entries
);
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map_animations/flutter_map_animations.dart';
import 'package:provider/provider.dart';
import '../app_state.dart';
@@ -7,9 +8,16 @@ 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 '../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});
@@ -17,15 +25,73 @@ 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 GlobalKey<MapViewState> _mapViewKey = GlobalKey<MapViewState>();
final MapController _mapController = MapController();
bool _followMe = true;
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(() => _followMe = false);
setState(() => _followMeMode = FollowMeMode.off);
final appState = context.read<AppState>();
appState.startAddSession();
@@ -36,10 +102,30 @@ 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<CameraProviderWithCache>(create: (_) => CameraProviderWithCache()),
@@ -50,12 +136,18 @@ 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),
tooltip: _getFollowMeTooltip(),
icon: Icon(_getFollowMeIcon()),
onPressed: () {
setState(() => _followMe = !_followMe);
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 (_followMe) {
if (_followMeMode != FollowMeMode.off) {
_mapViewKey.currentState?.retryLocationInit();
}
},
@@ -71,9 +163,11 @@ class _HomeScreenState extends State<HomeScreen> {
MapView(
key: _mapViewKey,
controller: _mapController,
followMe: _followMe,
followMeMode: _followMeMode,
onUserGesture: () {
if (_followMe) setState(() => _followMe = false);
if (_followMeMode != FollowMeMode.off) {
setState(() => _followMeMode = FollowMeMode.off);
}
},
),
Align(
@@ -112,7 +206,7 @@ class _HomeScreenState extends State<HomeScreen> {
label: Text('Download'),
onPressed: () => showDialog(
context: context,
builder: (ctx) => DownloadAreaDialog(controller: _mapController),
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
),
style: ElevatedButton.styleFrom(
minimumSize: Size(0, 48),

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

@@ -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>();
@@ -58,75 +81,86 @@ class QueueSection extends StatelessWidget {
}
void _showQueueDialog(BuildContext context) {
final appState = context.read<AppState>();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('Upload Queue (${appState.pendingCount} items)'),
content: SizedBox(
width: double.maxFinite,
height: 300,
child: ListView.builder(
itemCount: appState.pendingUploads.length,
itemBuilder: (context, index) {
final upload = appState.pendingUploads[index];
return ListTile(
leading: Icon(
upload.error ? Icons.error : Icons.camera_alt,
color: upload.error ? Colors.red : null,
),
title: Text('Camera ${index + 1}${upload.error ? " (Error)" : ""}'),
subtitle: Text(
'Lat: ${upload.coord.latitude.toStringAsFixed(6)}\n'
'Lon: ${upload.coord.longitude.toStringAsFixed(6)}\n'
'Direction: ${upload.direction.round()}°\n'
'Attempts: ${upload.attempts}' +
(upload.error ? "\nUpload failed. Tap retry to try again." : "")
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (upload.error)
IconButton(
icon: const Icon(Icons.refresh),
color: Colors.orange,
tooltip: 'Retry upload',
onPressed: () {
appState.retryUpload(upload);
},
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
appState.removeFromQueue(upload);
if (appState.pendingCount == 0) {
Navigator.pop(context);
}
},
),
],
),
);
},
builder: (context) => Consumer<AppState>(
builder: (context, appState, child) => AlertDialog(
title: Text('Upload Queue (${appState.pendingCount} items)'),
content: SizedBox(
width: double.maxFinite,
height: 300,
child: appState.pendingUploads.isEmpty
? const Center(child: Text('Queue is empty'))
: ListView.builder(
itemCount: appState.pendingUploads.length,
itemBuilder: (context, index) {
final upload = appState.pendingUploads[index];
return ListTile(
leading: Icon(
upload.error ? Icons.error : Icons.camera_alt,
color: upload.error
? Colors.red
: _getUploadModeColor(upload.uploadMode),
),
title: Text('Camera ${index + 1}'
'${upload.error ? " (Error)" : ""}'
'${upload.completing ? " (Completing...)" : ""}'),
subtitle: Text(
'Dest: ${_getUploadModeDisplayName(upload.uploadMode)}\n'
'Lat: ${upload.coord.latitude.toStringAsFixed(6)}\n'
'Lon: ${upload.coord.longitude.toStringAsFixed(6)}\n'
'Direction: ${upload.direction.round()}°\n'
'Attempts: ${upload.attempts}' +
(upload.error ? "\nUpload failed. Tap retry to try again." : "")
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (upload.error && !upload.completing)
IconButton(
icon: const Icon(Icons.refresh),
color: Colors.orange,
tooltip: 'Retry upload',
onPressed: () {
appState.retryUpload(upload);
},
),
if (upload.completing)
const Icon(Icons.check_circle, color: Colors.green)
else
IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
appState.removeFromQueue(upload);
if (appState.pendingCount == 0) {
Navigator.pop(context);
}
},
),
],
),
);
},
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
if (appState.pendingCount > 1)
actions: [
TextButton(
onPressed: () {
appState.clearQueue();
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Queue cleared')),
);
},
child: const Text('Clear All'),
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
if (appState.pendingCount > 1)
TextButton(
onPressed: () {
appState.clearQueue();
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Queue cleared')),
);
},
child: const Text('Clear All'),
),
],
),
),
);
}

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

@@ -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="$kClientName $kClientVersion"/>
<tag k="comment" v="Add surveillance camera"/>
<tag k="comment" v="$action ${p.profile.name} surveillance camera"/>
</changeset>
</osm>''';
print('Uploader: Creating changeset...');
@@ -34,28 +35,64 @@ 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) {
// First, fetch the current node to get its version
print('Uploader: Fetching current node ${p.originalNodeId} to get version...');
final currentNodeResp = await _get('/api/0.6/node/${p.originalNodeId}');
print('Uploader: Current node response: ${currentNodeResp.statusCode}');
if (currentNodeResp.statusCode != 200) {
print('Uploader: Failed to fetch current node');
return false;
}
// Parse version from the response XML
final currentNodeXml = currentNodeResp.body;
final versionMatch = RegExp(r'version="(\d+)"').firstMatch(currentNodeXml);
if (versionMatch == null) {
print('Uploader: Could not parse version from current node XML');
return false;
}
final currentVersion = versionMatch.group(1)!;
print('Uploader: Current node version: $currentVersion');
// Update existing node with version
final nodeXml = '''
<osm>
<node changeset="$csId" id="${p.originalNodeId}" version="$currentVersion" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
$tagsXml
</node>
</osm>''';
print('Uploader: Updating node ${p.originalNodeId}...');
nodeResp = await _put('/api/0.6/node/${p.originalNodeId}', nodeXml);
nodeId = p.originalNodeId.toString();
} else {
// Create new node
final nodeXml = '''
<osm>
<node changeset="$csId" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
$tagsXml
</node>
</osm>''';
print('Uploader: Creating node...');
final nodeResp = await _put('/api/0.6/node/create', nodeXml);
print('Uploader: Creating new node...');
nodeResp = await _put('/api/0.6/node/create', nodeXml);
nodeId = nodeResp.body.trim();
}
print('Uploader: Node response: ${nodeResp.statusCode} - ${nodeResp.body}');
if (nodeResp.statusCode != 200) {
print('Uploader: Failed to create node');
print('Uploader: Failed to ${p.isEdit ? "update" : "create"} node');
return false;
}
final nodeId = nodeResp.body.trim();
print('Uploader: Created node ID: $nodeId');
print('Uploader: ${p.isEdit ? "Updated" : "Created"} node ID: $nodeId');
// 3. close changeset
print('Uploader: Closing changeset...');
@@ -81,6 +118,11 @@ class Uploader {
}
}
Future<http.Response> _get(String path) => http.get(
Uri.https(_host, path),
headers: _headers,
);
Future<http.Response> _post(String path, String body) => http.post(
Uri.https(_host, path),
headers: _headers,

View File

@@ -19,7 +19,12 @@ class ProfileState extends ChangeNotifier {
// Initialize profiles from built-in and custom sources
Future<void> init() async {
// Initialize profiles: built-in + custom
_profiles.add(CameraProfile.alpr());
_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

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
import '../models/camera_profile.dart';
import '../models/osm_camera_node.dart';
// ------------------ AddCameraSession ------------------
class AddCameraSession {
@@ -11,17 +12,75 @@ class AddCameraSession {
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;
// Getter
// Getters
AddCameraSession? get session => _session;
EditCameraSession? get editSession => _editSession;
void startAddSession(List<CameraProfile> enabledProfiles) {
_session = AddCameraSession(profile: enabledProfiles.first);
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,
@@ -45,11 +104,39 @@ class SessionState extends ChangeNotifier {
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;
@@ -58,4 +145,13 @@ class SessionState extends ChangeNotifier {
notifyListeners();
return session;
}
EditCameraSession? commitEditSession() {
if (_editSession == null) return null;
final session = _editSession!;
_editSession = null;
notifyListeners();
return session;
}
}

View File

@@ -25,11 +25,12 @@ class UploadQueueState extends ChangeNotifier {
}
// Add a completed session to the upload queue
void addFromSession(AddCameraSession session) {
void addFromSession(AddCameraSession session, {required UploadMode uploadMode}) {
final upload = PendingUpload(
coord: session.target!,
direction: session.directionDegrees,
profile: session.profile,
uploadMode: uploadMode,
);
_queue.add(upload);
@@ -56,6 +57,51 @@ class UploadQueueState extends ChangeNotifier {
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();
@@ -101,25 +147,24 @@ class UploadQueueState extends ChangeNotifier {
if (access == null) return; // not logged in
bool ok;
if (uploadMode == UploadMode.simulate) {
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 -- pass uploadMode so uploader can switch between prod and sandbox
// 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: uploadMode);
_markAsCompleting(item);
}, uploadMode: item.uploadMode);
ok = await up.upload(item);
}
if (ok && uploadMode == UploadMode.simulate) {
// Remove manually for simulate mode
_queue.remove(item);
_saveQueue();
notifyListeners();
if (ok && item.uploadMode == UploadMode.simulate) {
// Mark as completing for simulate mode too
_markAsCompleting(item);
}
if (!ok) {
item.attempts++;
@@ -140,6 +185,20 @@ class UploadQueueState extends ChangeNotifier {
_uploadTimer?.cancel();
}
// Mark an item as completing (shows checkmark) and schedule removal after 1 second
void _markAsCompleting(PendingUpload item) {
item.completing = true;
_saveQueue();
notifyListeners();
// Remove the item after 1 second
Timer(const Duration(seconds: 1), () {
_queue.remove(item);
_saveQueue();
notifyListeners();
});
}
// ---------- Queue persistence ----------
Future<void> _saveQueue() async {
final prefs = await SharedPreferences.getInstance();

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

@@ -2,9 +2,11 @@ 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
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
@@ -21,6 +23,10 @@ class CameraIcon extends StatelessWidget {
return kCameraRingColorMock;
case CameraIconType.pending:
return kCameraRingColorPending;
case CameraIconType.editing:
return kCameraRingColorEditing;
case CameraIconType.pendingEdit:
return kCameraRingColorPendingEdit;
}
}

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,6 +10,19 @@ 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),
@@ -46,13 +61,26 @@ 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,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

@@ -46,16 +46,25 @@ class _CameraMapMarkerState extends State<CameraMapMarker> {
@override
Widget build(BuildContext context) {
// Check if this is a pending upload
final isPending = widget.node.tags.containsKey('_pending_upload') &&
widget.node.tags['_pending_upload'] == 'true';
// 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: isPending ? CameraIconType.pending : CameraIconType.real,
),
child: CameraIcon(type: iconType),
);
}
}

View File

@@ -13,6 +13,7 @@ class DirectionConesBuilder {
required List<OsmCameraNode> cameras,
required double zoom,
AddCameraSession? session,
EditCameraSession? editSession,
}) {
final overlays = <Polygon>[];
@@ -22,13 +23,25 @@ class DirectionConesBuilder {
session.target!,
session.directionDegrees,
zoom,
isSession: true,
));
}
// Add cones for cameras with direction
// 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(_isValidCameraWithDirection)
.where((n) => _isValidCameraWithDirection(n) &&
(editSession == null || n.id != editSession.originalNode.id))
.map((n) => _buildCone(
n.coord,
n.directionDeg!,

View File

@@ -11,6 +11,7 @@ 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({
@@ -18,6 +19,7 @@ class MapOverlays extends StatelessWidget {
required this.mapController,
required this.uploadMode,
this.session,
this.editSession,
this.attribution,
});
@@ -130,13 +132,15 @@ class MapOverlays extends StatelessWidget {
),
),
// Fixed pin when adding camera
if (session != null)
// Fixed pin when adding or editing camera
if (session != null || editSession != null)
IgnorePointer(
child: Center(
child: Transform.translate(
offset: const Offset(0, kAddPinYOffset),
child: const CameraIcon(type: CameraIconType.mock),
child: CameraIcon(
type: editSession != null ? CameraIconType.editing : CameraIconType.mock
),
),
),
),

View File

@@ -1,11 +1,13 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.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/offline_area_service.dart';
@@ -21,17 +23,18 @@ 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
@@ -39,12 +42,16 @@ class MapView extends StatefulWidget {
}
class MapViewState extends State<MapView> {
late final MapController _controller;
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;
@@ -66,6 +73,14 @@ class MapViewState extends State<MapView> {
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
@@ -78,11 +93,51 @@ class MapViewState extends State<MapView> {
});
}
/// 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();
_cameraDebounce.dispose();
_tileDebounce.dispose();
_mapPositionDebounce.dispose();
_cameraProvider.removeListener(_onCamerasUpdated);
_tileHttpClient.close();
super.dispose();
@@ -98,17 +153,101 @@ class MapViewState extends State<MapView> {
_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) {
@@ -135,8 +274,28 @@ 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,
);
}
}
}
@@ -149,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');
}
@@ -165,7 +350,7 @@ class MapViewState extends State<MapView> {
double _safeZoom() {
try {
return _controller.camera.zoom;
return _controller.mapController.camera.zoom;
} catch (_) {
return 15.0;
}
@@ -205,6 +390,7 @@ class MapViewState extends State<MapView> {
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
final session = appState.session;
final editSession = appState.editSession;
// Check if enabled profiles changed and refresh cameras if needed
final currentEnabledProfiles = appState.enabledProfiles;
@@ -246,12 +432,23 @@ class MapViewState extends State<MapView> {
// 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)
@@ -259,7 +456,7 @@ class MapViewState extends State<MapView> {
builder: (context, cameraProvider, child) {
LatLngBounds? mapBounds;
try {
mapBounds = _controller.camera.visibleBounds;
mapBounds = _controller.mapController.camera.visibleBounds;
} catch (_) {
mapBounds = null;
}
@@ -269,7 +466,7 @@ class MapViewState extends State<MapView> {
final markers = CameraMarkersBuilder.buildCameraMarkers(
cameras: cameras,
mapController: _controller,
mapController: _controller.mapController,
userLocation: _currentLatLng,
);
@@ -277,11 +474,16 @@ class MapViewState extends State<MapView> {
cameras: cameras,
zoom: zoom,
session: session,
editSession: editSession,
);
// Build edit lines connecting original cameras to their edited positions
final editLines = _buildEditLines(cameras);
return Stack(
children: [
PolygonLayer(polygons: overlays),
if (editLines.isNotEmpty) PolylineLayer(polylines: editLines),
MarkerLayer(markers: markers),
],
);
@@ -292,10 +494,10 @@ class MapViewState extends State<MapView> {
children: [
FlutterMap(
key: ValueKey('map_${appState.offlineMode}_${appState.selectedTileType?.id ?? 'none'}_$_mapRebuildKey'),
mapController: _controller,
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.
@@ -303,6 +505,9 @@ class MapViewState extends State<MapView> {
if (session != null) {
appState.updateSession(target: pos.center);
}
if (editSession != null) {
appState.updateEditSession(target: pos.center);
}
// Show waiting indicator when map moves (user is expecting new content)
NetworkStatus.instance.setWaiting();
@@ -319,6 +524,16 @@ class MapViewState extends State<MapView> {
}
_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);
@@ -342,9 +557,10 @@ class MapViewState extends State<MapView> {
// All map overlays (mode indicator, zoom, attribution, add pin)
MapOverlays(
mapController: _controller,
mapController: _controller.mapController,
uploadMode: appState.uploadMode,
session: session,
editSession: editSession,
attribution: appState.selectedTileType?.attribution,
),
@@ -353,5 +569,37 @@ class MapViewState extends State<MapView> {
],
);
}
/// 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;
}
}
// 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

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