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..22b8017 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -56,3 +56,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 diff --git a/lib/models/camera_profile.dart b/lib/models/camera_profile.dart index e27ac69..210b757 100644 --- a/lib/models/camera_profile.dart +++ b/lib/models/camera_profile.dart @@ -14,19 +14,93 @@ class CameraProfile { this.builtin = false, }); - /// Built‑in default: Generic Flock ALPR camera - factory CameraProfile.alpr() => CameraProfile( - id: 'builtin-alpr', - name: 'Generic Flock', + /// Built‑in 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, + ); + + /// Built‑in: Flock Safety ALPR camera + factory CameraProfile.flock() => CameraProfile( + id: 'builtin-flock', + name: 'Flock', + tags: const { + 'man_made': 'surveillance', + 'surveillance:type': 'ALPR', + 'camera:type': 'fixed', 'manufacturer': 'Flock Safety', 'manufacturer:wikidata': 'Q108485435', }, builtin: true, ); + /// Built‑in: Motorola Solutions/Vigilant ALPR camera + factory CameraProfile.motorola() => CameraProfile( + id: 'builtin-motorola', + name: 'Motorola/Vigilant', + tags: const { + 'man_made': 'surveillance', + 'surveillance:type': 'ALPR', + 'camera:type': 'fixed', + 'manufacturer': 'Motorola Solutions', + 'manufacturer:wikidata': 'Q634815', + }, + builtin: true, + ); + + /// Built‑in: Genetec ALPR camera + factory CameraProfile.genetec() => CameraProfile( + id: 'builtin-genetec', + name: 'Genetec', + tags: const { + 'man_made': 'surveillance', + 'surveillance:type': 'ALPR', + 'camera:type': 'fixed', + 'manufacturer': 'Genetec', + 'manufacturer:wikidata': 'Q30295174', + }, + builtin: true, + ); + + /// Built‑in: Leonardo/ELSAG ALPR camera + factory CameraProfile.leonardo() => CameraProfile( + id: 'builtin-leonardo', + name: 'Leonardo/ELSAG', + tags: const { + 'man_made': 'surveillance', + 'surveillance:type': 'ALPR', + 'camera:type': 'fixed', + 'manufacturer': 'Leonardo', + 'manufacturer:wikidata': 'Q910379', + }, + builtin: true, + ); + + /// Built‑in: Neology ALPR camera + factory CameraProfile.neology() => CameraProfile( + id: 'builtin-neology', + name: 'Neology', + tags: const { + 'man_made': 'surveillance', + 'surveillance:type': 'ALPR', + '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, diff --git a/lib/models/pending_upload.dart b/lib/models/pending_upload.dart index ae47afc..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, }; @@ -30,7 +36,8 @@ class PendingUpload { direction: j['dir'], profile: j['profile'] is Map ? CameraProfile.fromJson(j['profile']) - : CameraProfile.alpr(), + : CameraProfile.genericAlpr(), + originalNodeId: j['originalNodeId'], attempts: j['attempts'] ?? 0, error: j['error'] ?? false, ); diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 186b732..0ef368f 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -7,9 +7,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}); @@ -21,11 +28,45 @@ class _HomeScreenState extends State { final GlobalKey _scaffoldKey = GlobalKey(); final GlobalKey _mapViewKey = GlobalKey(); final MapController _mapController = MapController(); - bool _followMe = true; + FollowMeMode _followMeMode = FollowMeMode.northUp; + bool _editSheetShown = false; + + 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.startAddSession(); @@ -36,10 +77,30 @@ class _HomeScreenState extends State { ); } + void _openEditCameraSheet() { + // Disable follow-me when editing a camera so the map doesn't jump around + setState(() => _followMeMode = FollowMeMode.off); + + final appState = context.read(); + 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(); + // 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(create: (_) => CameraProviderWithCache()), @@ -50,12 +111,16 @@ class _HomeScreenState extends State { title: const Text('Flock Map'), actions: [ IconButton( - tooltip: _followMe ? 'Disable follow‑me' : 'Enable follow‑me', - 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'); + }); // If enabling follow-me, retry location init in case permission was granted - if (_followMe) { + if (_followMeMode != FollowMeMode.off) { _mapViewKey.currentState?.retryLocationInit(); } }, @@ -71,9 +136,11 @@ class _HomeScreenState extends State { MapView( key: _mapViewKey, controller: _mapController, - followMe: _followMe, + followMeMode: _followMeMode, onUserGesture: () { - if (_followMe) setState(() => _followMe = false); + if (_followMeMode != FollowMeMode.off) { + setState(() => _followMeMode = FollowMeMode.off); + } }, ), Align( diff --git a/lib/screens/profile_editor.dart b/lib/screens/profile_editor.dart index 037f992..9ef2055 100644 --- a/lib/screens/profile_editor.dart +++ b/lib/screens/profile_editor.dart @@ -52,14 +52,16 @@ class _ProfileEditorState extends State { 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 { 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 { 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 { 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)), + ), ], ), ); diff --git a/lib/screens/settings_screen_sections/profile_list_section.dart b/lib/screens/settings_screen_sections/profile_list_section.dart index 00612f3..1108ec1 100644 --- a/lib/screens/settings_screen_sections/profile_list_section.dart +++ b/lib/screens/settings_screen_sections/profile_list_section.dart @@ -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); + } + }, + ), ), ), ], diff --git a/lib/services/camera_cache.dart b/lib/services/camera_cache.dart index 777b6de..0de43a5 100644 --- a/lib/services/camera_cache.dart +++ b/lib/services/camera_cache.dart @@ -13,7 +13,23 @@ class CameraCache { /// Add or update a batch of camera nodes in the cache. void addOrUpdate(List 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.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; + } } } diff --git a/lib/services/uploader.dart b/lib/services/uploader.dart index 04d4ca2..ab9c37f 100644 --- a/lib/services/uploader.dart +++ b/lib/services/uploader.dart @@ -34,28 +34,45 @@ class Uploader { final csId = csResp.body.trim(); print('Uploader: Created changeset ID: $csId'); - // 2. create node - // Merge tags: direction in PendingUpload should always be present, - // and override any in the profile for upload purposes + // 2. create or update node final mergedTags = Map.from(p.profile.tags) ..['direction'] = p.direction.round().toString(); final tagsXml = mergedTags.entries.map((e) => '').join('\n '); - final nodeXml = ''' + + final http.Response nodeResp; + final String nodeId; + + if (p.isEdit) { + // Update existing node + final nodeXml = ''' + + + $tagsXml + + '''; + 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 = ''' $tagsXml '''; - 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...'); diff --git a/lib/state/profile_state.dart b/lib/state/profile_state.dart index c847446..5031868 100644 --- a/lib/state/profile_state.dart +++ b/lib/state/profile_state.dart @@ -19,7 +19,12 @@ class ProfileState extends ChangeNotifier { // Initialize profiles from built-in and custom sources Future 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 diff --git a/lib/state/session_state.dart b/lib/state/session_state.dart index 3c25c85..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,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 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 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, @@ -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; + } } \ No newline at end of file diff --git a/lib/state/upload_queue_state.dart b/lib/state/upload_queue_state.dart index 1355067..b1cd21f 100644 --- a/lib/state/upload_queue_state.dart +++ b/lib/state/upload_queue_state.dart @@ -56,6 +56,50 @@ 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(); + + // Create two cache entries: + + // 1. Mark the original camera with _pending_edit (grey ring) at original location + final originalTags = Map.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.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(); diff --git a/lib/widgets/add_camera_sheet.dart b/lib/widgets/add_camera_sheet.dart index 2a7f4d9..81c3eb9 100644 --- a/lib/widgets/add_camera_sheet.dart +++ b/lib/widgets/add_camera_sheet.dart @@ -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( 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), ), ), diff --git a/lib/widgets/camera_icon.dart b/lib/widgets/camera_icon.dart index 2282862..d3c4d64 100644 --- a/lib/widgets/camera_icon.dart +++ b/lib/widgets/camera_icon.dart @@ -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; } } diff --git a/lib/widgets/camera_tag_sheet.dart b/lib/widgets/camera_tag_sheet.dart index 69980cf..c5c897b 100644 --- a/lib/widgets/camera_tag_sheet.dart +++ b/lib/widgets/camera_tag_sheet.dart @@ -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(); + + // 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'), + ), + ], ), ], ), 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/camera_markers.dart b/lib/widgets/map/camera_markers.dart index 40110eb..0ab73b7 100644 --- a/lib/widgets/map/camera_markers.dart +++ b/lib/widgets/map/camera_markers.dart @@ -46,16 +46,25 @@ class _CameraMapMarkerState extends State { @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), ); } } diff --git a/lib/widgets/map/direction_cones.dart b/lib/widgets/map/direction_cones.dart index 005b19f..068e862 100644 --- a/lib/widgets/map/direction_cones.dart +++ b/lib/widgets/map/direction_cones.dart @@ -13,6 +13,7 @@ class DirectionConesBuilder { required List cameras, required double zoom, AddCameraSession? session, + EditCameraSession? editSession, }) { final overlays = []; @@ -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!, 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 8616831..89f7d30 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -21,17 +21,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; 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 @@ -135,8 +136,17 @@ class MapViewState extends State { @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 + if (widget.followMeMode == FollowMeMode.northUp) { + _controller.move(_currentLatLng!, _controller.camera.zoom); + } else if (widget.followMeMode == FollowMeMode.rotating) { + // When switching to rotating mode, reset to north-up first + _controller.moveAndRotate(_currentLatLng!, _controller.camera.zoom, 0.0); + } } } @@ -149,11 +159,21 @@ class MapViewState extends State { 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 + _controller.move(latLng, _controller.camera.zoom); + } else if (widget.followMeMode == FollowMeMode.rotating) { + // Follow position and rotation based on heading + final heading = position.heading; + final rotation = heading.isNaN ? 0.0 : -heading; // Convert to map rotation + _controller.moveAndRotate(latLng, _controller.camera.zoom, rotation); + } } catch (e) { debugPrint('MapController not ready yet: $e'); } @@ -205,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; @@ -252,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) @@ -277,11 +309,16 @@ class MapViewState extends State { 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), ], ); @@ -303,6 +340,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(); @@ -345,6 +385,7 @@ class MapViewState extends State { mapController: _controller, uploadMode: appState.uploadMode, session: session, + editSession: editSession, attribution: appState.selectedTileType?.attribution, ), @@ -353,5 +394,37 @@ class MapViewState extends State { ], ); } + + /// Build polylines connecting original cameras to their edited positions + List _buildEditLines(List cameras) { + final lines = []; + + // Create a lookup map of original node IDs to their coordinates + final originalNodes = {}; + 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; + } }