From 9319bbda48faf24c82c5677ed0341d50630d5437 Mon Sep 17 00:00:00 2001 From: stopflock Date: Fri, 21 Nov 2025 19:25:34 -0600 Subject: [PATCH] Support FOV range notation: 0-360, 90-270, 10-45;90-125 --- assets/changelog.json | 14 +++ lib/dev_config.dart | 2 +- lib/localizations/de.json | 4 + lib/localizations/en.json | 4 + lib/localizations/es.json | 4 + lib/localizations/fr.json | 4 + lib/localizations/it.json | 4 + lib/localizations/pt.json | 4 + lib/localizations/zh.json | 4 + lib/models/direction_fov.dart | 24 +++++ lib/models/node_profile.dart | 11 ++ lib/models/osm_node.dart | 65 ++++++++++-- lib/screens/profile_editor.dart | 39 +++++++ lib/state/upload_queue_state.dart | 28 ++++- lib/widgets/map/direction_cones.dart | 147 ++++++++++++++++++++++++--- pubspec.yaml | 2 +- 16 files changed, 333 insertions(+), 27 deletions(-) create mode 100644 lib/models/direction_fov.dart diff --git a/assets/changelog.json b/assets/changelog.json index 55051a9..ca03f2e 100644 --- a/assets/changelog.json +++ b/assets/changelog.json @@ -1,4 +1,18 @@ { + "1.4.4": { + "content": [ + "• NEW: Enhanced FOV (Field of View) support for surveillance cameras", + "• FOV range notation parsing - now supports OSM data like '90-270' (180° FOV centered at 180°)", + "• Complex range notation support: '10-45;90-125;290' displays multiple FOV cones correctly", + "• Profile FOV configuration - built-in profiles now have manufacturer-specific FOV values", + "• Smart cone rendering - variable FOV widths, 360° cameras show full circles", + "• Intelligent submission format - profiles with FOV submit as range notation (e.g., direction 180° with 90° FOV submits as '135-225')", + "• Enhanced visual feedback - provisional cones during editing use profile FOV when available", + "• Backward compatibility - existing single direction values continue to work with default FOV", + "• Profile editor improvements - FOV field added to custom profile creation with validation", + "• Full localization - FOV interface strings translated to all 7 supported languages" + ] + }, "1.4.3": { "content": [ "• NEW: Proximity warning when placing nodes too close together - prevents accidental duplicate submissions" diff --git a/lib/dev_config.dart b/lib/dev_config.dart index 87fb4a7..7549dbe 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -57,7 +57,7 @@ const String kClientName = 'DeFlock'; const String kSuspectedLocationsCsvUrl = 'https://stopflock.com/app/flock_utilities_mini_latest.csv'; // Development/testing features - set to false for production builds -const bool kEnableDevelopmentModes = false; // Set to false to hide sandbox/simulate modes and force production mode +const bool kEnableDevelopmentModes = true; // Set to false to hide sandbox/simulate modes and force production mode // Navigation features - set to false to hide navigation UI elements while in development const bool kEnableNavigationFeatures = kEnableDevelopmentModes; // Hide navigation until fully implemented diff --git a/lib/localizations/de.json b/lib/localizations/de.json index 01a4c8e..4e188ba 100644 --- a/lib/localizations/de.json +++ b/lib/localizations/de.json @@ -265,6 +265,10 @@ "profileNameRequired": "Profil-Name ist erforderlich", "requiresDirection": "Benötigt Richtung", "requiresDirectionSubtitle": "Ob Kameras dieses Typs ein Richtungs-Tag benötigen", + "fov": "Sichtfeld", + "fovHint": "Sichtfeld in Grad (leer lassen für Standard)", + "fovSubtitle": "Kamera-Sichtfeld - verwendet für Kegelbreite und Bereichsübertragungsformat", + "fovInvalid": "Sichtfeld muss zwischen 1 und 360 Grad liegen", "submittable": "Übertragbar", "submittableSubtitle": "Ob dieses Profil für Kamera-Übertragungen verwendet werden kann", "osmTags": "OSM-Tags", diff --git a/lib/localizations/en.json b/lib/localizations/en.json index 56779dd..b6cbfc8 100644 --- a/lib/localizations/en.json +++ b/lib/localizations/en.json @@ -283,6 +283,10 @@ "profileNameRequired": "Profile name is required", "requiresDirection": "Requires Direction", "requiresDirectionSubtitle": "Whether cameras of this type need a direction tag", + "fov": "Field of View", + "fovHint": "FOV in degrees (leave empty for default)", + "fovSubtitle": "Camera field of view - used for cone width and range submission format", + "fovInvalid": "FOV must be between 1 and 360 degrees", "submittable": "Submittable", "submittableSubtitle": "Whether this profile can be used for camera submissions", "osmTags": "OSM Tags", diff --git a/lib/localizations/es.json b/lib/localizations/es.json index 94d66c9..2b0e16e 100644 --- a/lib/localizations/es.json +++ b/lib/localizations/es.json @@ -283,6 +283,10 @@ "profileNameRequired": "El nombre del perfil es requerido", "requiresDirection": "Requiere Dirección", "requiresDirectionSubtitle": "Si las cámaras de este tipo necesitan una etiqueta de dirección", + "fov": "Campo de Visión", + "fovHint": "Campo de visión en grados (dejar vacío para el predeterminado)", + "fovSubtitle": "Campo de visión de la cámara - usado para el ancho del cono y formato de envío por rango", + "fovInvalid": "El campo de visión debe estar entre 1 y 360 grados", "submittable": "Envíable", "submittableSubtitle": "Si este perfil puede usarse para envíos de cámaras", "osmTags": "Etiquetas OSM", diff --git a/lib/localizations/fr.json b/lib/localizations/fr.json index b1a8700..03f2e0f 100644 --- a/lib/localizations/fr.json +++ b/lib/localizations/fr.json @@ -283,6 +283,10 @@ "profileNameRequired": "Le nom du profil est requis", "requiresDirection": "Nécessite Direction", "requiresDirectionSubtitle": "Si les caméras de ce type ont besoin d'une balise de direction", + "fov": "Champ de Vision", + "fovHint": "Champ de vision en degrés (laisser vide pour la valeur par défaut)", + "fovSubtitle": "Champ de vision de la caméra - utilisé pour la largeur du cône et le format de soumission par plage", + "fovInvalid": "Le champ de vision doit être entre 1 et 360 degrés", "submittable": "Soumissible", "submittableSubtitle": "Si ce profil peut être utilisé pour les soumissions de caméras", "osmTags": "Balises OSM", diff --git a/lib/localizations/it.json b/lib/localizations/it.json index aca312e..2ca4634 100644 --- a/lib/localizations/it.json +++ b/lib/localizations/it.json @@ -283,6 +283,10 @@ "profileNameRequired": "Il nome del profilo è obbligatorio", "requiresDirection": "Richiede Direzione", "requiresDirectionSubtitle": "Se le telecamere di questo tipo necessitano di un tag direzione", + "fov": "Campo Visivo", + "fovHint": "Campo visivo in gradi (lasciare vuoto per il valore predefinito)", + "fovSubtitle": "Campo visivo della telecamera - utilizzato per la larghezza del cono e il formato di invio per intervallo", + "fovInvalid": "Il campo visivo deve essere tra 1 e 360 gradi", "submittable": "Inviabile", "submittableSubtitle": "Se questo profilo può essere usato per invii di telecamere", "osmTags": "Tag OSM", diff --git a/lib/localizations/pt.json b/lib/localizations/pt.json index 85f2a5b..58137b9 100644 --- a/lib/localizations/pt.json +++ b/lib/localizations/pt.json @@ -283,6 +283,10 @@ "profileNameRequired": "Nome do perfil é obrigatório", "requiresDirection": "Requer Direção", "requiresDirectionSubtitle": "Se câmeras deste tipo precisam de uma tag de direção", + "fov": "Campo de Visão", + "fovHint": "Campo de visão em graus (deixar vazio para o padrão)", + "fovSubtitle": "Campo de visão da câmera - usado para largura do cone e formato de envio por intervalo", + "fovInvalid": "Campo de visão deve estar entre 1 e 360 graus", "submittable": "Enviável", "submittableSubtitle": "Se este perfil pode ser usado para envios de câmeras", "osmTags": "Tags OSM", diff --git a/lib/localizations/zh.json b/lib/localizations/zh.json index d127911..260a02b 100644 --- a/lib/localizations/zh.json +++ b/lib/localizations/zh.json @@ -283,6 +283,10 @@ "profileNameRequired": "配置文件名称为必填项", "requiresDirection": "需要方向", "requiresDirectionSubtitle": "此类型的摄像头是否需要方向标签", + "fov": "视场角", + "fovHint": "视场角度数(留空使用默认值)", + "fovSubtitle": "摄像头视场角 - 用于锥体宽度和范围提交格式", + "fovInvalid": "视场角必须在1到360度之间", "submittable": "可提交", "submittableSubtitle": "此配置文件是否可用于摄像头提交", "osmTags": "OSM 标签", diff --git a/lib/models/direction_fov.dart b/lib/models/direction_fov.dart new file mode 100644 index 0000000..9e65fd5 --- /dev/null +++ b/lib/models/direction_fov.dart @@ -0,0 +1,24 @@ +/// Represents a direction with its associated field-of-view (FOV) cone. +class DirectionFov { + /// The center direction in degrees (0-359, where 0 is north) + final double centerDegrees; + + /// The field-of-view width in degrees (e.g., 35, 90, 180, 360) + final double fovDegrees; + + DirectionFov(this.centerDegrees, this.fovDegrees); + + @override + String toString() => 'DirectionFov(center: ${centerDegrees}°, fov: ${fovDegrees}°)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is DirectionFov && + runtimeType == other.runtimeType && + centerDegrees == other.centerDegrees && + fovDegrees == other.fovDegrees; + + @override + int get hashCode => centerDegrees.hashCode ^ fovDegrees.hashCode; +} \ No newline at end of file diff --git a/lib/models/node_profile.dart b/lib/models/node_profile.dart index 3bac58e..b242564 100644 --- a/lib/models/node_profile.dart +++ b/lib/models/node_profile.dart @@ -9,6 +9,7 @@ class NodeProfile { final bool requiresDirection; final bool submittable; final bool editable; + final double? fov; // Field-of-view in degrees (null means use dev_config default) NodeProfile({ required this.id, @@ -18,6 +19,7 @@ class NodeProfile { this.requiresDirection = true, this.submittable = true, this.editable = true, + this.fov, }); /// Get all built-in default node profiles @@ -50,6 +52,7 @@ class NodeProfile { requiresDirection: true, submittable: true, editable: true, + fov: 45.0, // Flock cameras typically have narrow FOV ), NodeProfile( id: 'builtin-motorola', @@ -67,6 +70,7 @@ class NodeProfile { requiresDirection: true, submittable: true, editable: true, + fov: 60.0, // Motorola cameras typically have moderate FOV ), NodeProfile( id: 'builtin-genetec', @@ -84,6 +88,7 @@ class NodeProfile { requiresDirection: true, submittable: true, editable: true, + fov: 50.0, // Genetec cameras typically have moderate FOV ), NodeProfile( id: 'builtin-leonardo', @@ -101,6 +106,7 @@ class NodeProfile { requiresDirection: true, submittable: true, editable: true, + fov: 55.0, // Leonardo cameras typically have moderate FOV ), NodeProfile( id: 'builtin-neology', @@ -150,6 +156,7 @@ class NodeProfile { requiresDirection: true, submittable: true, editable: true, + fov: 90.0, // Axis cameras can have wider FOV ), NodeProfile( id: 'builtin-generic-gunshot', @@ -208,6 +215,7 @@ class NodeProfile { bool? requiresDirection, bool? submittable, bool? editable, + double? fov, }) => NodeProfile( id: id ?? this.id, @@ -217,6 +225,7 @@ class NodeProfile { requiresDirection: requiresDirection ?? this.requiresDirection, submittable: submittable ?? this.submittable, editable: editable ?? this.editable, + fov: fov ?? this.fov, ); Map toJson() => { @@ -227,6 +236,7 @@ class NodeProfile { 'requiresDirection': requiresDirection, 'submittable': submittable, 'editable': editable, + 'fov': fov, }; factory NodeProfile.fromJson(Map j) => NodeProfile( @@ -237,6 +247,7 @@ class NodeProfile { requiresDirection: j['requiresDirection'] ?? true, // Default to true for backward compatibility submittable: j['submittable'] ?? true, // Default to true for backward compatibility editable: j['editable'] ?? true, // Default to true for backward compatibility + fov: j['fov']?.toDouble(), // Can be null for backward compatibility ); @override diff --git a/lib/models/osm_node.dart b/lib/models/osm_node.dart index a5f0961..7995327 100644 --- a/lib/models/osm_node.dart +++ b/lib/models/osm_node.dart @@ -1,4 +1,6 @@ import 'package:latlong2/latlong.dart'; +import 'direction_fov.dart'; +import '../dev_config.dart'; class OsmNode { final int id; @@ -36,9 +38,10 @@ class OsmNode { ); } - bool get hasDirection => directionDeg.isNotEmpty; + bool get hasDirection => directionFovPairs.isNotEmpty; - List get directionDeg { + /// Get direction and FOV pairs, supporting range notation like "90-270" or "10-45;90-125;290" + List get directionFovPairs { final raw = tags['direction'] ?? tags['camera:direction']; if (raw == null) return []; @@ -50,17 +53,35 @@ class OsmNode { 'W': 270.0, 'WNW': 292.5, 'NW': 315.0, 'NNW': 337.5, }; - // Split on semicolons and parse each direction - final directions = []; + final directionFovList = []; final parts = raw.split(';'); for (final part in parts) { - final trimmed = part.trim().toUpperCase(); + final trimmed = part.trim(); if (trimmed.isEmpty) continue; + // Check if this part contains a range (e.g., "90-270") + if (trimmed.contains('-') && RegExp(r'^\d+\.?\d*-\d+\.?\d*$').hasMatch(trimmed)) { + final rangeParts = trimmed.split('-'); + if (rangeParts.length == 2) { + final start = double.tryParse(rangeParts[0]); + final end = double.tryParse(rangeParts[1]); + + if (start != null && end != null) { + final normalized = _calculateRangeCenter(start, end); + directionFovList.add(normalized); + continue; + } + } + } + + // Not a range, handle as single direction + final trimmedUpper = trimmed.toUpperCase(); + // First try compass direction lookup - if (compassDirections.containsKey(trimmed)) { - directions.add(compassDirections[trimmed]!); + if (compassDirections.containsKey(trimmedUpper)) { + final degrees = compassDirections[trimmedUpper]!; + directionFovList.add(DirectionFov(degrees, kDirectionConeHalfAngle * 2)); continue; } @@ -74,9 +95,35 @@ class OsmNode { // Normalize: wrap negative or >360 into 0‑359 range final normalized = ((val % 360) + 360) % 360; - directions.add(normalized); + directionFovList.add(DirectionFov(normalized, kDirectionConeHalfAngle * 2)); } - return directions; + return directionFovList; + } + + /// Calculate center and width for a range like "90-270" or "270-90" + DirectionFov _calculateRangeCenter(double start, double end) { + // Normalize start and end to 0-359 range + start = ((start % 360) + 360) % 360; + end = ((end % 360) + 360) % 360; + + double width, center; + + if (start > end) { + // Wrapping case: 270-90 + width = (end + 360) - start; + center = ((start + end + 360) / 2) % 360; + } else { + // Normal case: 90-270 + width = end - start; + center = (start + end) / 2; + } + + return DirectionFov(center, width); + } + + /// Legacy getter for backward compatibility - returns just center directions + List get directionDeg { + return directionFovPairs.map((df) => df.centerDegrees).toList(); } } \ No newline at end of file diff --git a/lib/screens/profile_editor.dart b/lib/screens/profile_editor.dart index 0731a6d..cf68dd2 100644 --- a/lib/screens/profile_editor.dart +++ b/lib/screens/profile_editor.dart @@ -20,6 +20,7 @@ class _ProfileEditorState extends State { late List> _tags; late bool _requiresDirection; late bool _submittable; + late TextEditingController _fovCtrl; static const _defaultTags = [ MapEntry('man_made', 'surveillance'), @@ -38,6 +39,7 @@ class _ProfileEditorState extends State { _nameCtrl = TextEditingController(text: widget.profile.name); _requiresDirection = widget.profile.requiresDirection; _submittable = widget.profile.submittable; + _fovCtrl = TextEditingController(text: widget.profile.fov?.toString() ?? ''); if (widget.profile.tags.isEmpty) { // New profile → start with sensible defaults @@ -50,6 +52,7 @@ class _ProfileEditorState extends State { @override void dispose() { _nameCtrl.dispose(); + _fovCtrl.dispose(); super.dispose(); } @@ -91,6 +94,21 @@ class _ProfileEditorState extends State { onChanged: (value) => setState(() => _requiresDirection = value ?? true), controlAffinity: ListTileControlAffinity.leading, ), + if (_requiresDirection) Padding( + padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8), + child: TextField( + controller: _fovCtrl, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: locService.t('profileEditor.fov'), + hintText: locService.t('profileEditor.fovHint'), + helperText: locService.t('profileEditor.fovSubtitle'), + errorText: _validateFov(), + suffixText: '°', + ), + onChanged: (value) => setState(() {}), // Trigger validation + ), + ), CheckboxListTile( title: Text(locService.t('profileEditor.submittable')), subtitle: Text(locService.t('profileEditor.submittableSubtitle')), @@ -181,6 +199,17 @@ class _ProfileEditorState extends State { }); } + String? _validateFov() { + final text = _fovCtrl.text.trim(); + if (text.isEmpty) return null; // Optional field + + final fov = double.tryParse(text); + if (fov == null || fov <= 0 || fov > 360) { + return LocalizationService.instance.t('profileEditor.fovInvalid'); + } + return null; + } + void _save() { final locService = LocalizationService.instance; final name = _nameCtrl.text.trim(); @@ -190,6 +219,15 @@ class _ProfileEditorState extends State { .showSnackBar(SnackBar(content: Text(locService.t('profileEditor.profileNameRequired')))); return; } + + // Validate FOV if provided + if (_validateFov() != null) { + return; // Don't save if FOV validation fails + } + + // Parse FOV + final fovText = _fovCtrl.text.trim(); + final fov = fovText.isEmpty ? null : double.tryParse(fovText); final tagMap = {}; for (final e in _tags) { @@ -211,6 +249,7 @@ class _ProfileEditorState extends State { requiresDirection: _requiresDirection, submittable: _submittable, editable: true, // All custom profiles are editable by definition + fov: fov, ); context.read().addOrUpdateProfile(newProfile); diff --git a/lib/state/upload_queue_state.dart b/lib/state/upload_queue_state.dart index bf88bea..4a3cb51 100644 --- a/lib/state/upload_queue_state.dart +++ b/lib/state/upload_queue_state.dart @@ -30,7 +30,7 @@ class UploadQueueState extends ChangeNotifier { void addFromSession(AddNodeSession session, {required UploadMode uploadMode}) { final upload = PendingUpload( coord: session.target!, - direction: _formatDirectionsAsString(session.directions), + direction: _formatDirectionsForSubmission(session.directions, session.profile), profile: session.profile!, // Safe to use ! because commitSession() checks for null operatorProfile: session.operatorProfile, uploadMode: uploadMode, @@ -82,7 +82,7 @@ class UploadQueueState extends ChangeNotifier { final upload = PendingUpload( coord: coordToUse, - direction: _formatDirectionsAsString(session.directions), + direction: _formatDirectionsForSubmission(session.directions, session.profile), profile: session.profile!, // Safe to use ! because commitEditSession() checks for null operatorProfile: session.operatorProfile, uploadMode: uploadMode, @@ -330,13 +330,33 @@ class UploadQueueState extends ChangeNotifier { } } - // Helper method to format multiple directions as a string or number - dynamic _formatDirectionsAsString(List directions) { + // Helper method to format multiple directions for submission, supporting profile FOV + dynamic _formatDirectionsForSubmission(List directions, NodeProfile? profile) { if (directions.isEmpty) return 0.0; + + // If profile has FOV, convert center directions to range notation + if (profile?.fov != null && profile!.fov! > 0) { + final ranges = directions.map((center) => + _formatDirectionWithFov(center, profile.fov!) + ).toList(); + + return ranges.length == 1 ? ranges.first : ranges.join(';'); + } + + // No profile FOV: use original format (single number or semicolon-separated) if (directions.length == 1) return directions.first; return directions.map((d) => d.round().toString()).join(';'); } + // Convert a center direction and FOV to range notation (e.g., 180° center with 90° FOV -> "135-225") + String _formatDirectionWithFov(double center, double fov) { + final halfFov = fov / 2; + final start = (center - halfFov + 360) % 360; + final end = (center + halfFov) % 360; + + return '${start.round()}-${end.round()}'; + } + // ---------- Queue persistence ---------- Future _saveQueue() async { final prefs = await SharedPreferences.getInstance(); diff --git a/lib/widgets/map/direction_cones.dart b/lib/widgets/map/direction_cones.dart index 7324b24..8668e25 100644 --- a/lib/widgets/map/direction_cones.dart +++ b/lib/widgets/map/direction_cones.dart @@ -6,6 +6,7 @@ import 'package:latlong2/latlong.dart'; import '../../app_state.dart'; import '../../dev_config.dart'; import '../../models/osm_node.dart'; +import '../../models/direction_fov.dart'; /// Helper class to build direction cone polygons for cameras class DirectionConesBuilder { @@ -20,10 +21,13 @@ class DirectionConesBuilder { // Add session cones if in add-camera mode and profile requires direction if (session != null && session.target != null && session.profile?.requiresDirection == true) { + final sessionFov = session.profile?.fov ?? (kDirectionConeHalfAngle * 2); + // Add current working direction (full opacity) - overlays.add(_buildCone( + overlays.add(_buildConeWithFov( session.target!, session.directionDegrees, + sessionFov, zoom, context: context, isSession: true, @@ -33,9 +37,10 @@ class DirectionConesBuilder { // Add other directions (reduced opacity) for (int i = 0; i < session.directions.length; i++) { if (i != session.currentDirectionIndex) { - overlays.add(_buildCone( + overlays.add(_buildConeWithFov( session.target!, session.directions[i], + sessionFov, zoom, context: context, isSession: true, @@ -47,10 +52,13 @@ class DirectionConesBuilder { // Add edit session cones if in edit-camera mode and profile requires direction if (editSession != null && editSession.profile?.requiresDirection == true) { + final sessionFov = editSession.profile?.fov ?? (kDirectionConeHalfAngle * 2); + // Add current working direction (full opacity) - overlays.add(_buildCone( + overlays.add(_buildConeWithFov( editSession.target, editSession.directionDegrees, + sessionFov, zoom, context: context, isSession: true, @@ -60,9 +68,10 @@ class DirectionConesBuilder { // Add other directions (reduced opacity) for (int i = 0; i < editSession.directions.length; i++) { if (i != editSession.currentDirectionIndex) { - overlays.add(_buildCone( + overlays.add(_buildConeWithFov( editSession.target, editSession.directions[i], + sessionFov, zoom, context: context, isSession: true, @@ -76,11 +85,12 @@ class DirectionConesBuilder { 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( + // Build a cone for each direction+fov pair + for (final directionFov in node.directionFovPairs) { + overlays.add(_buildConeWithFov( node.coord, - direction, + directionFov.centerDegrees, + directionFov.fovDegrees, zoom, context: context, )); @@ -103,6 +113,30 @@ class DirectionConesBuilder { node.tags['_pending_upload'] == 'true'; } + /// Build cone with variable FOV width - new method for range notation support + static Polygon _buildConeWithFov( + LatLng origin, + double bearingDeg, + double fovDegrees, + double zoom, { + required BuildContext context, + bool isPending = false, + bool isSession = false, + bool isActiveDirection = true, + }) { + return _buildConeInternal( + origin: origin, + bearingDeg: bearingDeg, + halfAngleDeg: fovDegrees / 2, + zoom: zoom, + context: context, + isPending: isPending, + isSession: isSession, + isActiveDirection: isActiveDirection, + ); + } + + /// Legacy method for backward compatibility - uses dev_config FOV static Polygon _buildCone( LatLng origin, double bearingDeg, @@ -112,7 +146,39 @@ class DirectionConesBuilder { bool isSession = false, bool isActiveDirection = true, }) { - final halfAngle = kDirectionConeHalfAngle; + return _buildConeInternal( + origin: origin, + bearingDeg: bearingDeg, + halfAngleDeg: kDirectionConeHalfAngle, + zoom: zoom, + context: context, + isPending: isPending, + isSession: isSession, + isActiveDirection: isActiveDirection, + ); + } + + /// Internal cone building method that handles the actual rendering + static Polygon _buildConeInternal({ + required LatLng origin, + required double bearingDeg, + required double halfAngleDeg, + required double zoom, + required BuildContext context, + bool isPending = false, + bool isSession = false, + bool isActiveDirection = true, + }) { + // Handle full circle case (360-degree FOV) + if (halfAngleDeg >= 180) { + return _buildFullCircle( + origin: origin, + zoom: zoom, + context: context, + isSession: isSession, + isActiveDirection: isActiveDirection, + ); + } // Calculate pixel-based radii final outerRadiusPx = kNodeIconDiameter + (kNodeIconDiameter * kDirectionConeBaseLength); @@ -124,7 +190,9 @@ class DirectionConesBuilder { final innerRadius = innerRadiusPx * pixelToCoordinate; // Number of points for the outer arc (within our directional range) - const int arcPoints = 12; + // Scale arc points based on FOV width for better rendering + final baseArcPoints = 12; + final arcPoints = math.max(6, (baseArcPoints * halfAngleDeg / 45).round()); LatLng project(double deg, double distance) { final rad = deg * math.pi / 180; @@ -139,13 +207,13 @@ class DirectionConesBuilder { // Add outer arc points from left to right (counterclockwise for proper polygon winding) for (int i = 0; i <= arcPoints; i++) { - final angle = bearingDeg - halfAngle + (i * 2 * halfAngle / arcPoints); + final angle = bearingDeg - halfAngleDeg + (i * 2 * halfAngleDeg / arcPoints); points.add(project(angle, outerRadius)); } // Add inner arc points from right to left (to close the donut shape) for (int i = arcPoints; i >= 0; i--) { - final angle = bearingDeg - halfAngle + (i * 2 * halfAngle / arcPoints); + final angle = bearingDeg - halfAngleDeg + (i * 2 * halfAngleDeg / arcPoints); points.add(project(angle, innerRadius)); } @@ -162,4 +230,59 @@ class DirectionConesBuilder { borderStrokeWidth: getDirectionConeBorderWidth(context), ); } + + /// Build a full circle for 360-degree FOV cases + static Polygon _buildFullCircle({ + required LatLng origin, + required double zoom, + required BuildContext context, + bool isSession = false, + bool isActiveDirection = true, + }) { + // Calculate pixel-based radii + final outerRadiusPx = kNodeIconDiameter + (kNodeIconDiameter * kDirectionConeBaseLength); + final innerRadiusPx = kNodeIconDiameter + (2 * getNodeRingThickness(context)); + + // Convert pixels to coordinate distances with zoom scaling + final pixelToCoordinate = 0.00001 * math.pow(2, 15 - zoom); + final outerRadius = outerRadiusPx * pixelToCoordinate; + final innerRadius = innerRadiusPx * pixelToCoordinate; + + // Create full circle with many points for smooth rendering + const int circlePoints = 36; + final points = []; + + LatLng project(double deg, double distance) { + final rad = deg * math.pi / 180; + final dLat = distance * math.cos(rad); + final dLon = + distance * math.sin(rad) / math.cos(origin.latitude * math.pi / 180); + return LatLng(origin.latitude + dLat, origin.longitude + dLon); + } + + // Add outer circle points + for (int i = 0; i < circlePoints; i++) { + final angle = i * 360.0 / circlePoints; + points.add(project(angle, outerRadius)); + } + + // Add inner circle points in reverse order to create donut + for (int i = circlePoints - 1; i >= 0; i--) { + final angle = i * 360.0 / circlePoints; + points.add(project(angle, innerRadius)); + } + + // Adjust opacity based on direction state + double opacity = kDirectionConeOpacity; + if (isSession && !isActiveDirection) { + opacity = kDirectionConeOpacity * 0.4; + } + + return Polygon( + points: points, + color: kDirectionConeColor.withOpacity(opacity), + borderColor: kDirectionConeColor, + borderStrokeWidth: getDirectionConeBorderWidth(context), + ); + } } \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 20cbeac..999daf1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: deflockapp description: Map public surveillance infrastructure with OpenStreetMap publish_to: "none" -version: 1.4.3+14 # The thing after the + is the version code, incremented with each release +version: 1.4.4+15 # The thing after the + is the version code, incremented with each release environment: sdk: ">=3.5.0 <4.0.0" # oauth2_client 4.x needs Dart 3.5+