Support FOV range notation: 0-360, 90-270, 10-45;90-125

This commit is contained in:
stopflock
2025-11-21 19:25:34 -06:00
parent ee26576c5e
commit 9319bbda48
16 changed files with 333 additions and 27 deletions

View File

@@ -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"

View File

@@ -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

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -283,6 +283,10 @@
"profileNameRequired": "配置文件名称为必填项",
"requiresDirection": "需要方向",
"requiresDirectionSubtitle": "此类型的摄像头是否需要方向标签",
"fov": "视场角",
"fovHint": "视场角度数(留空使用默认值)",
"fovSubtitle": "摄像头视场角 - 用于锥体宽度和范围提交格式",
"fovInvalid": "视场角必须在1到360度之间",
"submittable": "可提交",
"submittableSubtitle": "此配置文件是否可用于摄像头提交",
"osmTags": "OSM 标签",

View File

@@ -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;
}

View File

@@ -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<String, dynamic> toJson() => {
@@ -227,6 +236,7 @@ class NodeProfile {
'requiresDirection': requiresDirection,
'submittable': submittable,
'editable': editable,
'fov': fov,
};
factory NodeProfile.fromJson(Map<String, dynamic> 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

View File

@@ -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<double> get directionDeg {
/// Get direction and FOV pairs, supporting range notation like "90-270" or "10-45;90-125;290"
List<DirectionFov> 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 = <double>[];
final directionFovList = <DirectionFov>[];
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 0359 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<double> get directionDeg {
return directionFovPairs.map((df) => df.centerDegrees).toList();
}
}

View File

@@ -20,6 +20,7 @@ class _ProfileEditorState extends State<ProfileEditor> {
late List<MapEntry<String, String>> _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<ProfileEditor> {
_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<ProfileEditor> {
@override
void dispose() {
_nameCtrl.dispose();
_fovCtrl.dispose();
super.dispose();
}
@@ -91,6 +94,21 @@ class _ProfileEditorState extends State<ProfileEditor> {
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<ProfileEditor> {
});
}
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<ProfileEditor> {
.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 = <String, String>{};
for (final e in _tags) {
@@ -211,6 +249,7 @@ class _ProfileEditorState extends State<ProfileEditor> {
requiresDirection: _requiresDirection,
submittable: _submittable,
editable: true, // All custom profiles are editable by definition
fov: fov,
);
context.read<AppState>().addOrUpdateProfile(newProfile);

View File

@@ -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<double> directions) {
// Helper method to format multiple directions for submission, supporting profile FOV
dynamic _formatDirectionsForSubmission(List<double> 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<void> _saveQueue() async {
final prefs = await SharedPreferences.getInstance();

View File

@@ -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>[];
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),
);
}
}

View File

@@ -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+