diff --git a/lib/app_state.dart b/lib/app_state.dart index 6586f20..f76a8eb 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -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,10 +157,26 @@ 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) { @@ -163,6 +185,14 @@ class AppState extends ChangeNotifier { } } + void commitEditSession() { + final session = _sessionState.commitEditSession(); + if (session != null) { + _uploadQueueState.addFromEditSession(session); + _startUploader(); + } + } + // ---------- Settings Methods ---------- Future setOfflineMode(bool enabled) async { await _settingsState.setOfflineMode(enabled); diff --git a/lib/dev_config.dart b/lib/dev_config.dart index 4f6a5f2..30173c1 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -56,3 +56,4 @@ 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 diff --git a/lib/models/pending_upload.dart b/lib/models/pending_upload.dart index ac51c84..185d397 100644 --- a/lib/models/pending_upload.dart +++ b/lib/models/pending_upload.dart @@ -5,6 +5,7 @@ class PendingUpload { final LatLng coord; final double direction; final CameraProfile profile; + final int? originalNodeId; // If this is an edit, the ID of the original OSM node int attempts; bool error; @@ -12,15 +13,20 @@ class PendingUpload { required this.coord, required this.direction, required this.profile, + 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; + Map toJson() => { 'lat': coord.latitude, 'lon': coord.longitude, 'dir': direction, 'profile': profile.toJson(), + 'originalNodeId': originalNodeId, 'attempts': attempts, 'error': error, }; @@ -31,6 +37,7 @@ class PendingUpload { profile: j['profile'] is Map ? CameraProfile.fromJson(j['profile']) : CameraProfile.genericAlpr(), + originalNodeId: j['originalNodeId'], attempts: j['attempts'] ?? 0, error: j['error'] ?? false, ); diff --git a/lib/state/session_state.dart b/lib/state/session_state.dart index cefb920..80097ae 100644 --- a/lib/state/session_state.dart +++ b/lib/state/session_state.dart @@ -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,11 +12,28 @@ 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 enabledProfiles) { final submittableProfiles = enabledProfiles.where((p) => p.isSubmittable).toList(); @@ -23,9 +41,46 @@ class SessionState extends ChangeNotifier { ? 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 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 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, @@ -49,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; @@ -62,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; + } } \ No newline at end of file diff --git a/lib/state/upload_queue_state.dart b/lib/state/upload_queue_state.dart index 1355067..a01eb54 100644 --- a/lib/state/upload_queue_state.dart +++ b/lib/state/upload_queue_state.dart @@ -56,6 +56,35 @@ class UploadQueueState extends ChangeNotifier { notifyListeners(); } + // Add a completed edit session to the upload queue + void addFromEditSession(EditCameraSession session) { + final upload = PendingUpload( + coord: session.target, + direction: session.directionDegrees, + profile: session.profile, + originalNodeId: session.originalNode.id, // Track which node we're editing + ); + + _queue.add(upload); + _saveQueue(); + + // Update the original camera in the cache to mark it as having a pending edit + final originalTags = Map.from(session.originalNode.tags); + originalTags['_pending_upload'] = 'true'; // Mark as pending for UI distinction + + final updatedNode = OsmCameraNode( + id: session.originalNode.id, + coord: session.originalNode.coord, + tags: originalTags, + ); + + CameraCache.instance.addOrUpdate([updatedNode]); + // Notify camera provider to update the map + CameraProviderWithCache.instance.notifyListeners(); + + notifyListeners(); + } + void clearQueue() { _queue.clear(); _saveQueue(); diff --git a/lib/widgets/camera_icon.dart b/lib/widgets/camera_icon.dart index 2282862..dd62df2 100644 --- a/lib/widgets/camera_icon.dart +++ b/lib/widgets/camera_icon.dart @@ -5,6 +5,7 @@ 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 } /// Simple camera icon with grey dot and colored ring @@ -21,6 +22,8 @@ class CameraIcon extends StatelessWidget { return kCameraRingColorMock; case CameraIconType.pending: return kCameraRingColorPending; + case CameraIconType.editing: + return kCameraRingColorEditing; } } diff --git a/lib/widgets/camera_tag_sheet.dart b/lib/widgets/camera_tag_sheet.dart index 69980cf..1e18cf7 100644 --- a/lib/widgets/camera_tag_sheet.dart +++ b/lib/widgets/camera_tag_sheet.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import '../models/osm_camera_node.dart'; +import '../app_state.dart'; +import 'edit_camera_sheet.dart'; class CameraTagSheet extends StatelessWidget { final OsmCameraNode node; @@ -8,6 +11,25 @@ class CameraTagSheet extends StatelessWidget { @override Widget build(BuildContext context) { + final appState = context.watch(); + + // Check if this camera is editable (not a pending upload) + final isEditable = !node.tags.containsKey('_pending_upload') || + node.tags['_pending_upload'] != 'true'; + + void _openEditSheet() { + Navigator.pop(context); // Close this sheet first + appState.startEditSession(node); + final session = appState.editSession!; + + // Show the edit sheet + showModalBottomSheet( + context: context, + builder: (_) => EditCameraSheet(session: session), + showDragHandle: true, + ); + } + return SafeArea( child: Padding( padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20), @@ -46,13 +68,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'), + ), + ], ), ], ), diff --git a/lib/widgets/edit_camera_sheet.dart b/lib/widgets/edit_camera_sheet.dart new file mode 100644 index 0000000..6d7f900 --- /dev/null +++ b/lib/widgets/edit_camera_sheet.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../app_state.dart'; +import '../models/camera_profile.dart'; + +class EditCameraSheet extends StatelessWidget { + const EditCameraSheet({super.key, required this.session}); + + final EditCameraSession session; + + @override + Widget build(BuildContext context) { + final appState = context.watch(); + + 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 allowSubmit = submittableProfiles.isNotEmpty && session.profile.isSubmittable; + + 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( + 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 (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), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/map/map_overlays.dart b/lib/widgets/map/map_overlays.dart index b3c2118..5564c9f 100644 --- a/lib/widgets/map/map_overlays.dart +++ b/lib/widgets/map/map_overlays.dart @@ -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 + ), ), ), ), diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index 31c62dc..43f29b2 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -225,6 +225,7 @@ class MapViewState extends State { Widget build(BuildContext context) { final appState = context.watch(); final session = appState.session; + final editSession = appState.editSession; // Check if enabled profiles changed and refresh cameras if needed final currentEnabledProfiles = appState.enabledProfiles; @@ -272,6 +273,17 @@ class MapViewState extends State { ); } catch (_) {/* controller not ready yet */} } + + // For edit sessions, center the map on the camera being edited initially + if (editSession != null && _controller.camera.center != editSession.target) { + WidgetsBinding.instance.addPostFrameCallback( + (_) { + try { + _controller.move(editSession.target, _controller.camera.zoom); + } catch (_) {/* controller not ready yet */} + }, + ); + } final zoom = _safeZoom(); // Fetch cached cameras for current map bounds (using Consumer so overlays redraw instantly) @@ -323,6 +335,9 @@ class MapViewState extends State { 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(); @@ -365,6 +380,7 @@ class MapViewState extends State { mapController: _controller, uploadMode: appState.uploadMode, session: session, + editSession: editSession, attribution: appState.selectedTileType?.attribution, ),