From b735283f27c712d66c15f94f04891c678fb891d1 Mon Sep 17 00:00:00 2001 From: stopflock Date: Tue, 26 Aug 2025 22:58:24 -0500 Subject: [PATCH 01/12] smoother follow me --- lib/widgets/map_view.dart | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index 8616831..83911b1 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -136,7 +136,11 @@ class MapViewState extends State { void didUpdateWidget(covariant MapView oldWidget) { super.didUpdateWidget(oldWidget); if (widget.followMe && !oldWidget.followMe && _currentLatLng != null) { - _controller.move(_currentLatLng!, _controller.camera.zoom); + // Use smooth animation when follow me is first enabled + _controller.animatedMove( + _currentLatLng!, + _controller.camera.zoom, + ); } } @@ -145,15 +149,26 @@ class MapViewState extends State { if (perm == LocationPermission.denied || perm == LocationPermission.deniedForever) return; + // Configure location settings for smoother tracking + const locationSettings = LocationSettings( + accuracy: LocationAccuracy.high, + distanceFilter: 1, // Update every 1 meter + timeLimit: Duration(seconds: 2), // Max 2 seconds between updates + ); + _positionSub = - Geolocator.getPositionStream().listen((Position position) { + Geolocator.getPositionStream(locationSettings: locationSettings).listen((Position position) { final latLng = LatLng(position.latitude, position.longitude); setState(() => _currentLatLng = latLng); if (widget.followMe) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { try { - _controller.move(latLng, _controller.camera.zoom); + // Use smooth animation instead of instant jump + _controller.animatedMove( + latLng, + _controller.camera.zoom, + ); } catch (e) { debugPrint('MapController not ready yet: $e'); } From 2d0dc7fd6657c3d15b16a29851f564d6a924835d Mon Sep 17 00:00:00 2001 From: stopflock Date: Tue, 26 Aug 2025 23:46:38 -0500 Subject: [PATCH 02/12] ternary follow me --- lib/screens/home_screen.dart | 59 +++++++++++++++++++++++++++++++----- lib/widgets/map_view.dart | 37 ++++++++++++++-------- 2 files changed, 75 insertions(+), 21 deletions(-) diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 186b732..6424137 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -10,6 +10,12 @@ import '../widgets/add_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 +27,44 @@ class _HomeScreenState extends State { final GlobalKey _scaffoldKey = GlobalKey(); final GlobalKey _mapViewKey = GlobalKey(); final MapController _mapController = MapController(); - bool _followMe = true; + FollowMeMode _followMeMode = FollowMeMode.northUp; + + 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(); @@ -50,12 +89,14 @@ 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(() { + _followMeMode = _getNextFollowMeMode(); + }); // If enabling follow-me, retry location init in case permission was granted - if (_followMe) { + if (_followMeMode != FollowMeMode.off) { _mapViewKey.currentState?.retryLocationInit(); } }, @@ -71,9 +112,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/widgets/map_view.dart b/lib/widgets/map_view.dart index 83911b1..64c47c4 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -22,16 +22,18 @@ 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,12 +137,16 @@ class MapViewState extends State { @override void didUpdateWidget(covariant MapView oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.followMe && !oldWidget.followMe && _currentLatLng != null) { + if (widget.followMeMode != FollowMeMode.off && + oldWidget.followMeMode == FollowMeMode.off && + _currentLatLng != null) { // Use smooth animation when follow me is first enabled - _controller.animatedMove( - _currentLatLng!, - _controller.camera.zoom, - ); + 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, then let GPS handle rotation + _controller.moveAndRotate(_currentLatLng!, _controller.camera.zoom, 0.0); + } } } @@ -160,15 +166,20 @@ class MapViewState extends State { Geolocator.getPositionStream(locationSettings: locationSettings).listen((Position position) { final latLng = LatLng(position.latitude, position.longitude); setState(() => _currentLatLng = latLng); - if (widget.followMe) { + + if (widget.followMeMode != FollowMeMode.off) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { try { - // Use smooth animation instead of instant jump - _controller.animatedMove( - 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'); } From 24b20e8a5795a21422039c14a80c8bf7643073ce Mon Sep 17 00:00:00 2001 From: stopflock Date: Wed, 27 Aug 2025 18:24:47 -0500 Subject: [PATCH 03/12] fix gps on android --- lib/screens/home_screen.dart | 2 ++ lib/widgets/map_view.dart | 16 +++++----------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 6424137..490aafa 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -93,7 +93,9 @@ class _HomeScreenState extends State { icon: Icon(_getFollowMeIcon()), onPressed: () { 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 (_followMeMode != FollowMeMode.off) { diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index 64c47c4..31c62dc 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -21,7 +21,6 @@ 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 { @@ -137,14 +136,15 @@ class MapViewState extends State { @override void didUpdateWidget(covariant MapView oldWidget) { super.didUpdateWidget(oldWidget); + // Back to original pattern - simple check if (widget.followMeMode != FollowMeMode.off && oldWidget.followMeMode == FollowMeMode.off && _currentLatLng != null) { - // Use smooth animation when follow me is first enabled + // 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, then let GPS handle rotation + // When switching to rotating mode, reset to north-up first _controller.moveAndRotate(_currentLatLng!, _controller.camera.zoom, 0.0); } } @@ -155,18 +155,12 @@ class MapViewState extends State { if (perm == LocationPermission.denied || perm == LocationPermission.deniedForever) return; - // Configure location settings for smoother tracking - const locationSettings = LocationSettings( - accuracy: LocationAccuracy.high, - distanceFilter: 1, // Update every 1 meter - timeLimit: Duration(seconds: 2), // Max 2 seconds between updates - ); - _positionSub = - Geolocator.getPositionStream(locationSettings: locationSettings).listen((Position position) { + Geolocator.getPositionStream().listen((Position position) { final latLng = LatLng(position.latitude, position.longitude); setState(() => _currentLatLng = latLng); + // Back to original pattern - directly check widget parameter if (widget.followMeMode != FollowMeMode.off) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { From 376fa27736263d6f2096b9391432314265663ea4 Mon Sep 17 00:00:00 2001 From: stopflock Date: Wed, 27 Aug 2025 21:24:22 -0500 Subject: [PATCH 04/12] better builtin profiles --- lib/models/camera_profile.dart | 82 +++++++++++++++++++++++++++++-- lib/models/pending_upload.dart | 2 +- lib/state/profile_state.dart | 7 ++- lib/state/session_state.dart | 6 ++- lib/widgets/add_camera_sheet.dart | 14 +++--- 5 files changed, 97 insertions(+), 14 deletions(-) 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..ac51c84 100644 --- a/lib/models/pending_upload.dart +++ b/lib/models/pending_upload.dart @@ -30,7 +30,7 @@ class PendingUpload { direction: j['dir'], profile: j['profile'] is Map ? CameraProfile.fromJson(j['profile']) - : CameraProfile.alpr(), + : CameraProfile.genericAlpr(), attempts: j['attempts'] ?? 0, error: j['error'] ?? false, ); 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..cefb920 100644 --- a/lib/state/session_state.dart +++ b/lib/state/session_state.dart @@ -18,7 +18,11 @@ class SessionState extends ChangeNotifier { AddCameraSession? get session => _session; 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); notifyListeners(); } 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), ), ), From 2db4f597dc58f3b9f10a5069415487c308e5069b Mon Sep 17 00:00:00 2001 From: stopflock Date: Wed, 27 Aug 2025 22:13:51 -0500 Subject: [PATCH 05/12] allow viewing builtin profiles --- lib/screens/profile_editor.dart | 45 +++++---- .../profile_list_section.dart | 95 ++++++++++++------- 2 files changed, 88 insertions(+), 52 deletions(-) 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); + } + }, + ), ), ), ], From aee0dcf8b86cb603c2c26385d6370fadc1ff9ab2 Mon Sep 17 00:00:00 2001 From: stopflock Date: Thu, 28 Aug 2025 10:22:57 -0500 Subject: [PATCH 06/12] builds, needs work --- lib/app_state.dart | 32 ++++++- lib/dev_config.dart | 1 + lib/models/pending_upload.dart | 7 ++ lib/state/session_state.dart | 94 +++++++++++++++++++- lib/state/upload_queue_state.dart | 29 +++++++ lib/widgets/camera_icon.dart | 3 + lib/widgets/camera_tag_sheet.dart | 49 +++++++++-- lib/widgets/edit_camera_sheet.dart | 133 +++++++++++++++++++++++++++++ lib/widgets/map/map_overlays.dart | 10 ++- lib/widgets/map_view.dart | 16 ++++ 10 files changed, 362 insertions(+), 12 deletions(-) create mode 100644 lib/widgets/edit_camera_sheet.dart 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, ), From fae1cac6e4744b7487a990c1e490572f515c64f4 Mon Sep 17 00:00:00 2001 From: stopflock Date: Thu, 28 Aug 2025 10:49:02 -0500 Subject: [PATCH 07/12] still not able to refine location --- lib/widgets/camera_tag_sheet.dart | 16 +++++++++------- lib/widgets/map/direction_cones.dart | 17 +++++++++++++++-- lib/widgets/map_view.dart | 1 + 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/lib/widgets/camera_tag_sheet.dart b/lib/widgets/camera_tag_sheet.dart index 1e18cf7..24ae4b3 100644 --- a/lib/widgets/camera_tag_sheet.dart +++ b/lib/widgets/camera_tag_sheet.dart @@ -20,14 +20,16 @@ class CameraTagSheet extends StatelessWidget { 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, - ); + // Show the edit sheet using a post-frame callback to ensure proper context + WidgetsBinding.instance.addPostFrameCallback((_) { + final session = appState.editSession!; + showModalBottomSheet( + context: context, + builder: (_) => EditCameraSheet(session: session), + showDragHandle: true, + ); + }); } return SafeArea( 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_view.dart b/lib/widgets/map_view.dart index 43f29b2..7931b23 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -309,6 +309,7 @@ class MapViewState extends State { cameras: cameras, zoom: zoom, session: session, + editSession: editSession, ); return Stack( From efbb8765de0203d611b0b4e3b4fdf9c4400e9d9e Mon Sep 17 00:00:00 2001 From: stopflock Date: Thu, 28 Aug 2025 11:24:22 -0500 Subject: [PATCH 08/12] location editable --- lib/screens/home_screen.dart | 22 ++++++++++++++++++++++ lib/widgets/camera_tag_sheet.dart | 13 +------------ 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 490aafa..0ef368f 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -7,6 +7,7 @@ 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'; @@ -28,6 +29,7 @@ class _HomeScreenState extends State { final GlobalKey _mapViewKey = GlobalKey(); final MapController _mapController = MapController(); FollowMeMode _followMeMode = FollowMeMode.northUp; + bool _editSheetShown = false; String _getFollowMeTooltip() { switch (_followMeMode) { @@ -75,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()), diff --git a/lib/widgets/camera_tag_sheet.dart b/lib/widgets/camera_tag_sheet.dart index 24ae4b3..3853d99 100644 --- a/lib/widgets/camera_tag_sheet.dart +++ b/lib/widgets/camera_tag_sheet.dart @@ -2,7 +2,6 @@ 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; @@ -19,17 +18,7 @@ class CameraTagSheet extends StatelessWidget { void _openEditSheet() { Navigator.pop(context); // Close this sheet first - appState.startEditSession(node); - - // Show the edit sheet using a post-frame callback to ensure proper context - WidgetsBinding.instance.addPostFrameCallback((_) { - final session = appState.editSession!; - showModalBottomSheet( - context: context, - builder: (_) => EditCameraSheet(session: session), - showDragHandle: true, - ); - }); + appState.startEditSession(node); // HomeScreen will auto-show the edit sheet } return SafeArea( From 26d8eca3122b6c7f754ab72233e36f882e2e0856 Mon Sep 17 00:00:00 2001 From: stopflock Date: Thu, 28 Aug 2025 11:32:53 -0500 Subject: [PATCH 09/12] UX working --- lib/dev_config.dart | 1 + lib/state/upload_queue_state.dart | 26 ++++++++++++++++++++------ lib/widgets/camera_icon.dart | 11 +++++++---- lib/widgets/camera_tag_sheet.dart | 8 +++++--- lib/widgets/map/camera_markers.dart | 21 +++++++++++++++------ 5 files changed, 48 insertions(+), 19 deletions(-) diff --git a/lib/dev_config.dart b/lib/dev_config.dart index 30173c1..22b8017 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -57,3 +57,4 @@ const Color kCameraRingColorReal = Color(0xC43F55F3); // Real cameras from OSM - 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/state/upload_queue_state.dart b/lib/state/upload_queue_state.dart index a01eb54..ecd1cad 100644 --- a/lib/state/upload_queue_state.dart +++ b/lib/state/upload_queue_state.dart @@ -68,17 +68,31 @@ class UploadQueueState extends ChangeNotifier { _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 + // Create two cache entries: - final updatedNode = OsmCameraNode( + // 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, + coord: session.originalNode.coord, // Keep at original location tags: originalTags, ); - CameraCache.instance.addOrUpdate([updatedNode]); + // 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 + + 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(); diff --git a/lib/widgets/camera_icon.dart b/lib/widgets/camera_icon.dart index dd62df2..d3c4d64 100644 --- a/lib/widgets/camera_icon.dart +++ b/lib/widgets/camera_icon.dart @@ -2,10 +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 - editing, // Orange ring - camera being edited + 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 @@ -24,6 +25,8 @@ class CameraIcon extends StatelessWidget { 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 3853d99..c5c897b 100644 --- a/lib/widgets/camera_tag_sheet.dart +++ b/lib/widgets/camera_tag_sheet.dart @@ -12,9 +12,11 @@ class CameraTagSheet extends StatelessWidget { 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'; + // 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 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), ); } } From 7ff945e262df66aa4c367b186f0b884c2557ce68 Mon Sep 17 00:00:00 2001 From: stopflock Date: Thu, 28 Aug 2025 12:15:26 -0500 Subject: [PATCH 10/12] edit line --- lib/state/upload_queue_state.dart | 1 + lib/widgets/map_view.dart | 36 +++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/lib/state/upload_queue_state.dart b/lib/state/upload_queue_state.dart index ecd1cad..b1cd21f 100644 --- a/lib/state/upload_queue_state.dart +++ b/lib/state/upload_queue_state.dart @@ -85,6 +85,7 @@ class UploadQueueState extends ChangeNotifier { 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, diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index 7931b23..84286be 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -312,9 +312,13 @@ class MapViewState extends State { 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), ], ); @@ -390,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: Colors.orange.withOpacity(0.7), + strokeWidth: 2.0, + )); + } + } + } + + return lines; + } } From 45bf73aeee2c6b1d6d0148b7a1c44d9c61a51cf0 Mon Sep 17 00:00:00 2001 From: stopflock Date: Thu, 28 Aug 2025 12:35:40 -0500 Subject: [PATCH 11/12] preserve _tags in cam cache, make line purple and thicker --- lib/services/camera_cache.dart | 18 +++++++++++++++++- lib/widgets/map_view.dart | 4 ++-- 2 files changed, 19 insertions(+), 3 deletions(-) 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/widgets/map_view.dart b/lib/widgets/map_view.dart index 84286be..89f7d30 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -417,8 +417,8 @@ class MapViewState extends State { if (originalCoord != null) { lines.add(Polyline( points: [originalCoord, camera.coord], - color: Colors.orange.withOpacity(0.7), - strokeWidth: 2.0, + color: kCameraRingColorPending, + strokeWidth: 3.0, )); } } From d9f6c8c8e03af7bf6b06d50cb9671dbe7466bfb8 Mon Sep 17 00:00:00 2001 From: stopflock Date: Thu, 28 Aug 2025 12:39:30 -0500 Subject: [PATCH 12/12] Update existing node instead of creating new. DNU ANY COMMIT ON THIS BRANCH PRIOR TO HERE!!!!111!!!1!! --- lib/services/uploader.dart | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) 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...');