From d9f415c527637aae11879cf639a88bbe2a5d9353 Mon Sep 17 00:00:00 2001 From: stopflock Date: Wed, 29 Oct 2025 12:17:16 -0500 Subject: [PATCH] Multiple cameras on one pole --- lib/app_state.dart | 14 +++ lib/dev_config.dart | 2 +- lib/models/osm_node.dart | 36 ++++--- lib/models/pending_upload.dart | 10 +- lib/state/session_state.dart | 90 ++++++++++++++--- lib/state/upload_queue_state.dart | 13 ++- lib/widgets/add_node_sheet.dart | 138 +++++++++++++++++++++------ lib/widgets/edit_node_sheet.dart | 138 +++++++++++++++++++++------ lib/widgets/map/direction_cones.dart | 71 +++++++++++--- 9 files changed, 409 insertions(+), 103 deletions(-) diff --git a/lib/app_state.dart b/lib/app_state.dart index 2ee0008..b69f211 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -285,6 +285,20 @@ class AppState extends ChangeNotifier { ); } + void addDirection() { + _sessionState.addDirection(); + } + + void removeDirection() { + _sessionState.removeDirection(); + } + + void cycleDirection() { + _sessionState.cycleDirection(); + } + + + void cancelSession() { _sessionState.cancelSession(); } diff --git a/lib/dev_config.dart b/lib/dev_config.dart index e5dc173..c590f3d 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -41,7 +41,7 @@ const String kClientName = 'DeFlock'; // Note: Version is now dynamically retrieved from VersionService // Suspected locations CSV URL -const String kSuspectedLocationsCsvUrl = 'https://alprwatch.org/pub/flock_utilities_mini_latest.csv'; +const String kSuspectedLocationsCsvUrl = 'http://10.42.53.112:8888/flock_utilities_mini_latest.csv'; // Development/testing features - set to false for production builds const bool kEnableDevelopmentModes = true; // Set to false to hide sandbox/simulate modes and force production mode diff --git a/lib/models/osm_node.dart b/lib/models/osm_node.dart index 84c1a77..b8ec9ce 100644 --- a/lib/models/osm_node.dart +++ b/lib/models/osm_node.dart @@ -32,23 +32,33 @@ class OsmNode { ); } - bool get hasDirection => - tags.containsKey('direction') || tags.containsKey('camera:direction'); + bool get hasDirection => directionDeg.isNotEmpty; - double? get directionDeg { + List get directionDeg { final raw = tags['direction'] ?? tags['camera:direction']; - if (raw == null) return null; + if (raw == null) return []; - // Keep digits, optional dot, optional leading sign. - final match = RegExp(r'[-+]?\d*\.?\d+').firstMatch(raw); - if (match == null) return null; + // Split on semicolons and parse each direction + final directions = []; + final parts = raw.split(';'); + + for (final part in parts) { + final trimmed = part.trim(); + if (trimmed.isEmpty) continue; + + // Keep digits, optional dot, optional leading sign + final match = RegExp(r'[-+]?\d*\.?\d+').firstMatch(trimmed); + if (match == null) continue; - final numStr = match.group(0); - final val = double.tryParse(numStr ?? ''); - if (val == null) return null; + final numStr = match.group(0); + final val = double.tryParse(numStr ?? ''); + if (val == null) continue; - // Normalize: wrap negative or >360 into 0‑359 range. - final normalized = ((val % 360) + 360) % 360; - return normalized; + // Normalize: wrap negative or >360 into 0‑359 range + final normalized = ((val % 360) + 360) % 360; + directions.add(normalized); + } + + return directions; } } \ No newline at end of file diff --git a/lib/models/pending_upload.dart b/lib/models/pending_upload.dart index 6c0aea0..389e541 100644 --- a/lib/models/pending_upload.dart +++ b/lib/models/pending_upload.dart @@ -7,7 +7,7 @@ enum UploadOperation { create, modify, delete } class PendingUpload { final LatLng coord; - final double direction; + final dynamic direction; // Can be double or String for multiple directions final NodeProfile? profile; final OperatorProfile? operatorProfile; final UploadMode uploadMode; // Capture upload destination when queued @@ -74,7 +74,13 @@ class PendingUpload { // Add direction if required if (profile!.requiresDirection) { - tags['direction'] = direction.toStringAsFixed(0); + if (direction is String) { + tags['direction'] = direction; + } else if (direction is double) { + tags['direction'] = direction.toStringAsFixed(0); + } else { + tags['direction'] = '0'; + } } return tags; diff --git a/lib/state/session_state.dart b/lib/state/session_state.dart index 0f289a6..27fdcda 100644 --- a/lib/state/session_state.dart +++ b/lib/state/session_state.dart @@ -7,27 +7,45 @@ import '../models/osm_node.dart'; // ------------------ AddNodeSession ------------------ class AddNodeSession { - AddNodeSession({this.profile, this.directionDegrees = 0}); NodeProfile? profile; OperatorProfile? operatorProfile; - double directionDegrees; LatLng? target; + List directions; // All directions [90, 180, 270] + int currentDirectionIndex; // Which direction we're editing (e.g. 1 = editing the 180°) + + AddNodeSession({ + this.profile, + double initialDirection = 0, + this.operatorProfile, + this.target, + }) : directions = [initialDirection], + currentDirectionIndex = 0; + + // Slider always shows the current direction being edited + double get directionDegrees => directions[currentDirectionIndex]; + set directionDegrees(double value) => directions[currentDirectionIndex] = value; } // ------------------ EditNodeSession ------------------ class EditNodeSession { - EditNodeSession({ - required this.originalNode, - this.profile, - required this.directionDegrees, - required this.target, - }); - final OsmNode originalNode; // The original node being edited NodeProfile? profile; OperatorProfile? operatorProfile; - double directionDegrees; LatLng target; // Current position (can be dragged) + List directions; // All directions [90, 180, 270] + int currentDirectionIndex; // Which direction we're editing (e.g. 1 = editing the 180°) + + EditNodeSession({ + required this.originalNode, + this.profile, + required double initialDirection, + required this.target, + }) : directions = [initialDirection], + currentDirectionIndex = 0; + + // Slider always shows the current direction being edited + double get directionDegrees => directions[currentDirectionIndex]; + set directionDegrees(double value) => directions[currentDirectionIndex] = value; } class SessionState extends ChangeNotifier { @@ -60,12 +78,19 @@ class SessionState extends ChangeNotifier { } // Start with no profile selected if no match found - force user to choose + // Initialize edit session with all existing directions + final existingDirections = node.directionDeg.isNotEmpty ? node.directionDeg : [0.0]; + _editSession = EditNodeSession( originalNode: node, profile: matchingProfile, - directionDegrees: node.directionDeg ?? 0, + initialDirection: existingDirections.first, target: node.coord, ); + + // Replace the default single direction with all existing directions + _editSession!.directions = List.from(existingDirections); + _editSession!.currentDirectionIndex = 0; // Start editing the first direction _session = null; // Clear any add session notifyListeners(); } @@ -136,6 +161,49 @@ class SessionState extends ChangeNotifier { if (dirty) notifyListeners(); } + // Add new direction at 0° and switch to editing it + void addDirection() { + if (_session != null) { + _session!.directions.add(0.0); + _session!.currentDirectionIndex = _session!.directions.length - 1; + notifyListeners(); + } else if (_editSession != null) { + _editSession!.directions.add(0.0); + _editSession!.currentDirectionIndex = _editSession!.directions.length - 1; + notifyListeners(); + } + } + + // Remove currently selected direction + void removeDirection() { + if (_session != null && _session!.directions.length > 1) { + _session!.directions.removeAt(_session!.currentDirectionIndex); + if (_session!.currentDirectionIndex >= _session!.directions.length) { + _session!.currentDirectionIndex = _session!.directions.length - 1; + } + notifyListeners(); + } else if (_editSession != null && _editSession!.directions.length > 1) { + _editSession!.directions.removeAt(_editSession!.currentDirectionIndex); + if (_editSession!.currentDirectionIndex >= _editSession!.directions.length) { + _editSession!.currentDirectionIndex = _editSession!.directions.length - 1; + } + notifyListeners(); + } + } + + // Cycle to next direction + void cycleDirection() { + if (_session != null && _session!.directions.length > 1) { + _session!.currentDirectionIndex = (_session!.currentDirectionIndex + 1) % _session!.directions.length; + notifyListeners(); + } else if (_editSession != null && _editSession!.directions.length > 1) { + _editSession!.currentDirectionIndex = (_editSession!.currentDirectionIndex + 1) % _editSession!.directions.length; + notifyListeners(); + } + } + + + void cancelSession() { _session = null; notifyListeners(); diff --git a/lib/state/upload_queue_state.dart b/lib/state/upload_queue_state.dart index c227cff..ceb5c88 100644 --- a/lib/state/upload_queue_state.dart +++ b/lib/state/upload_queue_state.dart @@ -29,7 +29,7 @@ class UploadQueueState extends ChangeNotifier { void addFromSession(AddNodeSession session, {required UploadMode uploadMode}) { final upload = PendingUpload( coord: session.target!, - direction: session.directionDegrees, + direction: _formatDirectionsAsString(session.directions), profile: session.profile!, // Safe to use ! because commitSession() checks for null operatorProfile: session.operatorProfile, uploadMode: uploadMode, @@ -63,7 +63,7 @@ class UploadQueueState extends ChangeNotifier { void addFromEditSession(EditNodeSession session, {required UploadMode uploadMode}) { final upload = PendingUpload( coord: session.target, - direction: session.directionDegrees, + direction: _formatDirectionsAsString(session.directions), profile: session.profile!, // Safe to use ! because commitEditSession() checks for null operatorProfile: session.operatorProfile, uploadMode: uploadMode, @@ -109,7 +109,7 @@ class UploadQueueState extends ChangeNotifier { void addFromNodeDeletion(OsmNode node, {required UploadMode uploadMode}) { final upload = PendingUpload( coord: node.coord, - direction: node.directionDeg ?? 0, // Direction not used for deletions but required for API + direction: node.directionDeg.isNotEmpty ? node.directionDeg.first : 0, // Direction not used for deletions but required for API profile: null, // No profile needed for deletions - just delete by node ID uploadMode: uploadMode, operation: UploadOperation.delete, @@ -293,6 +293,13 @@ class UploadQueueState extends ChangeNotifier { } } + // Helper method to format multiple directions as a string or number + dynamic _formatDirectionsAsString(List directions) { + if (directions.isEmpty) return 0.0; + if (directions.length == 1) return directions.first; + return directions.map((d) => d.round().toString()).join(';'); + } + // ---------- Queue persistence ---------- Future _saveQueue() async { final prefs = await SharedPreferences.getInstance(); diff --git a/lib/widgets/add_node_sheet.dart b/lib/widgets/add_node_sheet.dart index 2a992e6..63f7efa 100644 --- a/lib/widgets/add_node_sheet.dart +++ b/lib/widgets/add_node_sheet.dart @@ -12,6 +12,112 @@ class AddNodeSheet extends StatelessWidget { final AddNodeSession session; + Widget _buildDirectionControls(BuildContext context, AppState appState, AddNodeSession session, LocalizationService locService) { + final requiresDirection = session.profile != null && session.profile!.requiresDirection; + + // Format direction display text with bold for current direction + String directionsText = ''; + if (requiresDirection) { + final directionsWithBold = []; + for (int i = 0; i < session.directions.length; i++) { + final dirStr = session.directions[i].round().toString(); + if (i == session.currentDirectionIndex) { + directionsWithBold.add('**$dirStr**'); // Mark for bold formatting + } else { + directionsWithBold.add(dirStr); + } + } + directionsText = directionsWithBold.join(', '); + } + + return Column( + children: [ + ListTile( + title: requiresDirection + ? RichText( + text: TextSpan( + style: Theme.of(context).textTheme.titleMedium, + children: [ + const TextSpan(text: 'Directions: '), + if (directionsText.isNotEmpty) + ...directionsText.split('**').asMap().entries.map((entry) { + final isEven = entry.key % 2 == 0; + return TextSpan( + text: entry.value, + style: TextStyle( + fontWeight: isEven ? FontWeight.normal : FontWeight.bold, + ), + ); + }), + ], + ), + ) + : Text(locService.t('addNode.direction', params: [session.directionDegrees.round().toString()])), + subtitle: Row( + children: [ + // Slider takes most of the space + Expanded( + child: Slider( + min: 0, + max: 359, + divisions: 359, + value: session.directionDegrees, + label: session.directionDegrees.round().toString(), + onChanged: requiresDirection ? (v) => appState.updateSession(directionDeg: v) : null, + ), + ), + // Buttons on the right (only show if direction is required) + if (requiresDirection) ...[ + const SizedBox(width: 8), + // Remove button + IconButton( + icon: const Icon(Icons.remove, size: 20), + onPressed: session.directions.length > 1 ? () => appState.removeDirection() : null, + tooltip: 'Remove current direction', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + ), + // Add button + IconButton( + icon: const Icon(Icons.add, size: 20), + onPressed: () => appState.addDirection(), + tooltip: 'Add new direction', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + ), + // Cycle button + IconButton( + icon: const Icon(Icons.repeat, size: 20), + onPressed: session.directions.length > 1 ? () => appState.cycleDirection() : null, + tooltip: 'Cycle through directions', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + ), + ], + ], + ), + ), + // Show info text when profile doesn't require direction + if (!requiresDirection) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Row( + children: [ + const Icon(Icons.info_outline, color: Colors.grey, size: 16), + const SizedBox(width: 6), + Expanded( + child: Text( + locService.t('addNode.profileNoDirectionInfo'), + style: const TextStyle(color: Colors.grey, fontSize: 12), + ), + ), + ], + ), + ), + ], + ); + } + @override Widget build(BuildContext context) { return AnimatedBuilder( @@ -78,35 +184,9 @@ class AddNodeSheet extends StatelessWidget { onChanged: (p) => appState.updateSession(profile: p), ), ), - ListTile( - title: Text(locService.t('addNode.direction', params: [session.directionDegrees.round().toString()])), - subtitle: Slider( - min: 0, - max: 359, - divisions: 359, - value: session.directionDegrees, - label: session.directionDegrees.round().toString(), - onChanged: (session.profile != null && session.profile!.requiresDirection) - ? (v) => appState.updateSession(directionDeg: v) - : null, // Disabled when no profile selected or profile doesn't require direction - ), - ), - if (session.profile != null && !session.profile!.requiresDirection) - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), - child: Row( - children: [ - const Icon(Icons.info_outline, color: Colors.grey, size: 16), - const SizedBox(width: 6), - Expanded( - child: Text( - locService.t('addNode.profileNoDirectionInfo'), - style: const TextStyle(color: Colors.grey, fontSize: 12), - ), - ), - ], - ), - ), + // Direction controls + _buildDirectionControls(context, appState, session, locService), + if (!appState.isLoggedIn) Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), diff --git a/lib/widgets/edit_node_sheet.dart b/lib/widgets/edit_node_sheet.dart index 4a3290a..56571a4 100644 --- a/lib/widgets/edit_node_sheet.dart +++ b/lib/widgets/edit_node_sheet.dart @@ -13,6 +13,112 @@ class EditNodeSheet extends StatelessWidget { final EditNodeSession session; + Widget _buildDirectionControls(BuildContext context, AppState appState, EditNodeSession session, LocalizationService locService) { + final requiresDirection = session.profile != null && session.profile!.requiresDirection; + + // Format direction display text with bold for current direction + String directionsText = ''; + if (requiresDirection) { + final directionsWithBold = []; + for (int i = 0; i < session.directions.length; i++) { + final dirStr = session.directions[i].round().toString(); + if (i == session.currentDirectionIndex) { + directionsWithBold.add('**$dirStr**'); // Mark for bold formatting + } else { + directionsWithBold.add(dirStr); + } + } + directionsText = directionsWithBold.join(', '); + } + + return Column( + children: [ + ListTile( + title: requiresDirection + ? RichText( + text: TextSpan( + style: Theme.of(context).textTheme.titleMedium, + children: [ + const TextSpan(text: 'Directions: '), + if (directionsText.isNotEmpty) + ...directionsText.split('**').asMap().entries.map((entry) { + final isEven = entry.key % 2 == 0; + return TextSpan( + text: entry.value, + style: TextStyle( + fontWeight: isEven ? FontWeight.normal : FontWeight.bold, + ), + ); + }), + ], + ), + ) + : Text(locService.t('editNode.direction', params: [session.directionDegrees.round().toString()])), + subtitle: Row( + children: [ + // Slider takes most of the space + Expanded( + child: Slider( + min: 0, + max: 359, + divisions: 359, + value: session.directionDegrees, + label: session.directionDegrees.round().toString(), + onChanged: requiresDirection ? (v) => appState.updateEditSession(directionDeg: v) : null, + ), + ), + // Buttons on the right (only show if direction is required) + if (requiresDirection) ...[ + const SizedBox(width: 8), + // Remove button + IconButton( + icon: const Icon(Icons.remove, size: 20), + onPressed: session.directions.length > 1 ? () => appState.removeDirection() : null, + tooltip: 'Remove current direction', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + ), + // Add button + IconButton( + icon: const Icon(Icons.add, size: 20), + onPressed: () => appState.addDirection(), + tooltip: 'Add new direction', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + ), + // Cycle button + IconButton( + icon: const Icon(Icons.repeat, size: 20), + onPressed: session.directions.length > 1 ? () => appState.cycleDirection() : null, + tooltip: 'Cycle through directions', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + ), + ], + ], + ), + ), + // Show info text when profile doesn't require direction + if (!requiresDirection) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Row( + children: [ + const Icon(Icons.info_outline, color: Colors.grey, size: 16), + const SizedBox(width: 6), + Expanded( + child: Text( + 'This profile does not require a direction.', + style: const TextStyle(color: Colors.grey, fontSize: 12), + ), + ), + ], + ), + ), + ], + ); + } + @override Widget build(BuildContext context) { return AnimatedBuilder( @@ -85,35 +191,9 @@ class EditNodeSheet extends StatelessWidget { onChanged: (p) => appState.updateEditSession(profile: p), ), ), - ListTile( - title: Text(locService.t('editNode.direction', params: [session.directionDegrees.round().toString()])), - subtitle: Slider( - min: 0, - max: 359, - divisions: 359, - value: session.directionDegrees, - label: session.directionDegrees.round().toString(), - onChanged: (session.profile != null && session.profile!.requiresDirection) - ? (v) => appState.updateEditSession(directionDeg: v) - : null, // Disabled when no profile selected or profile doesn't require direction - ), - ), - if (session.profile != null && !session.profile!.requiresDirection) - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), - child: Row( - children: [ - const Icon(Icons.info_outline, color: Colors.grey, size: 16), - const SizedBox(width: 6), - Expanded( - child: Text( - locService.t('editNode.profileNoDirectionInfo'), - style: const TextStyle(color: Colors.grey, fontSize: 12), - ), - ), - ], - ), - ), + // Direction controls + _buildDirectionControls(context, appState, session, locService), + if (!appState.isLoggedIn) Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), diff --git a/lib/widgets/map/direction_cones.dart b/lib/widgets/map/direction_cones.dart index 8b610cf..7324b24 100644 --- a/lib/widgets/map/direction_cones.dart +++ b/lib/widgets/map/direction_cones.dart @@ -18,47 +18,81 @@ class DirectionConesBuilder { }) { final overlays = []; - // Add session cone if in add-camera mode and profile requires direction + // Add session cones if in add-camera mode and profile requires direction if (session != null && session.target != null && session.profile?.requiresDirection == true) { + // Add current working direction (full opacity) overlays.add(_buildCone( session.target!, session.directionDegrees, zoom, context: context, isSession: true, + isActiveDirection: true, )); + + // Add other directions (reduced opacity) + for (int i = 0; i < session.directions.length; i++) { + if (i != session.currentDirectionIndex) { + overlays.add(_buildCone( + session.target!, + session.directions[i], + zoom, + context: context, + isSession: true, + isActiveDirection: false, + )); + } + } } - // Add edit session cone if in edit-camera mode and profile requires direction + // Add edit session cones if in edit-camera mode and profile requires direction if (editSession != null && editSession.profile?.requiresDirection == true) { + // Add current working direction (full opacity) overlays.add(_buildCone( editSession.target, editSession.directionDegrees, zoom, context: context, isSession: true, + isActiveDirection: true, )); + + // Add other directions (reduced opacity) + for (int i = 0; i < editSession.directions.length; i++) { + if (i != editSession.currentDirectionIndex) { + overlays.add(_buildCone( + editSession.target, + editSession.directions[i], + zoom, + context: context, + isSession: true, + isActiveDirection: false, + )); + } + } } // Add cones for cameras with direction (but exclude camera being edited) - overlays.addAll( - cameras - .where((n) => _isValidCameraWithDirection(n) && - (editSession == null || n.id != editSession.originalNode.id)) - .map((n) => _buildCone( - n.coord, - n.directionDeg!, - zoom, - context: context, - )) - ); + for (final node in cameras) { + if (_isValidCameraWithDirection(node) && + (editSession == null || node.id != editSession.originalNode.id)) { + // Build a cone for each direction + for (final direction in node.directionDeg) { + overlays.add(_buildCone( + node.coord, + direction, + zoom, + context: context, + )); + } + } + } return overlays; } static bool _isValidCameraWithDirection(OsmNode node) { return node.hasDirection && - node.directionDeg != null && (node.coord.latitude != 0 || node.coord.longitude != 0) && node.coord.latitude.abs() <= 90 && node.coord.longitude.abs() <= 180; @@ -76,6 +110,7 @@ class DirectionConesBuilder { required BuildContext context, bool isPending = false, bool isSession = false, + bool isActiveDirection = true, }) { final halfAngle = kDirectionConeHalfAngle; @@ -114,9 +149,15 @@ class DirectionConesBuilder { points.add(project(angle, innerRadius)); } + // Adjust opacity based on direction state + double opacity = kDirectionConeOpacity; + if (isSession && !isActiveDirection) { + opacity = kDirectionConeOpacity * 0.4; // Reduced opacity for inactive session directions + } + return Polygon( points: points, - color: kDirectionConeColor.withOpacity(kDirectionConeOpacity), + color: kDirectionConeColor.withOpacity(opacity), borderColor: kDirectionConeColor, borderStrokeWidth: getDirectionConeBorderWidth(context), );