mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-02-13 09:12:56 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3baed3c328 | ||
|
|
3ade06eef1 | ||
|
|
c7b70dddc4 | ||
|
|
d747c66990 | ||
|
|
5673c2b627 | ||
|
|
32a0ac17ad | ||
|
|
dec957790c | ||
|
|
9319bbda48 |
11
README.md
11
README.md
@@ -99,6 +99,11 @@ cp lib/keys.dart.example lib/keys.dart
|
||||
|
||||
### Needed Bugfixes
|
||||
- Update node cache to reflect cleared queue entries
|
||||
- Improve/retune tile fetching backoff/retry
|
||||
- Are offline areas preferred for fast loading even when online? Check working.
|
||||
- Fix network indicator - only done when fetch queue is empty!
|
||||
|
||||
### Current Development
|
||||
- Decide what to do for extracting nodes attached to a way/relation:
|
||||
- Auto extract (how?)
|
||||
- Leave it alone (wrong answer unless user chooses intentionally)
|
||||
@@ -106,12 +111,7 @@ cp lib/keys.dart.example lib/keys.dart
|
||||
- Delete the old one (also wrong answer unless user chooses intentionally)
|
||||
- Give multiple of these options??
|
||||
- Nav start+end too close together error (warning + disable submit button?)
|
||||
- Improve/retune tile fetching backoff/retry
|
||||
- Add some builtin satellite tile provider
|
||||
- Are offline areas preferred for fast loading even when online? Check working.
|
||||
- Fix network indicator - only done when fetch queue is empty!
|
||||
|
||||
### Current Development
|
||||
- Persistent cache for MY submissions: assume submissions worked, cache,clean up when we see that node appear in overpass/OSM results or when older than 24h
|
||||
- Dropdown on "refine tags" page to select acceptable options for camera:mount= (is this a boolean property of a profile?)
|
||||
- Tutorial / info guide before submitting first node, info and links before creating first profile
|
||||
@@ -127,6 +127,7 @@ cp lib/keys.dart.example lib/keys.dart
|
||||
### Future Features & Wishlist
|
||||
- Update offline area nodes while browsing?
|
||||
- Offline navigation (pending vector map tiles)
|
||||
- Android Auto / CarPlay
|
||||
|
||||
### Maybes
|
||||
- Yellow ring for devices missing specific tag details
|
||||
|
||||
@@ -1,4 +1,19 @@
|
||||
{
|
||||
"1.4.5": {
|
||||
"content": [
|
||||
"• NEW: Minimum zoom level (Z15) enforced for adding and editing surveillance nodes to ensure precise positioning",
|
||||
"• NEW: Minimum zoom level (Z10) enforced for offline area downloads to prevent insanely large areas",
|
||||
"• IMPROVED: Offline area download confirmation now shows as popup with 'View Progress in Settings' button instead of snackbar"
|
||||
]
|
||||
},
|
||||
"1.4.4": {
|
||||
"content": [
|
||||
"• FOV range notation parsing - now supports OSM data like '90-270' (180° FOV centered at 180°)",
|
||||
"• Complex range notation support: 'ESE;90-125;290' displays multiple FOV cones correctly",
|
||||
"• Profiles now support optional specific FOV values",
|
||||
"• Smart cone rendering - variable FOV widths, 360° cameras show full circles"
|
||||
]
|
||||
},
|
||||
"1.4.3": {
|
||||
"content": [
|
||||
"• NEW: Proximity warning when placing nodes too close together - prevents accidental duplicate submissions"
|
||||
|
||||
@@ -54,7 +54,7 @@ const String kClientName = 'DeFlock';
|
||||
// Note: Version is now dynamically retrieved from VersionService
|
||||
|
||||
// Suspected locations CSV URL
|
||||
const String kSuspectedLocationsCsvUrl = 'https://stopflock.com/app/flock_utilities_mini_latest.csv';
|
||||
const String kSuspectedLocationsCsvUrl = 'https://alprwatch.org/suspected-locations/deflock-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
|
||||
@@ -80,6 +80,8 @@ bool enableNavigationFeatures({required bool offlineMode}) {
|
||||
// Marker/node interaction
|
||||
const int kNodeMinZoomLevel = 10; // Minimum zoom to show nodes (Overpass)
|
||||
const int kOsmApiMinZoomLevel = 13; // Minimum zoom for OSM API bbox queries (sandbox mode)
|
||||
const int kMinZoomForNodeEditingSheets = 15; // Minimum zoom to open add/edit node sheets
|
||||
const int kMinZoomForOfflineDownload = 10; // Minimum zoom to download offline areas (prevents large area crashes)
|
||||
const Duration kMarkerTapTimeout = Duration(milliseconds: 250);
|
||||
const Duration kDebounceCameraRefresh = Duration(milliseconds: 500);
|
||||
|
||||
|
||||
@@ -118,6 +118,7 @@
|
||||
"enableSubmittableProfile": "Aktivieren Sie ein übertragbares Profil in den Einstellungen, um Knoten zu bearbeiten.",
|
||||
"profileViewOnlyWarning": "Dieses Profil ist nur zum Anzeigen der Karte gedacht. Bitte wählen Sie ein übertragbares Profil aus, um Knoten zu bearbeiten.",
|
||||
"cannotMoveConstrainedNode": "Kann diese Kamera nicht verschieben - sie ist mit einem anderen Kartenelement verbunden (OSM-Weg/Relation). Sie können trotzdem ihre Tags und Richtung bearbeiten.",
|
||||
"zoomInRequiredMessage": "Zoomen Sie auf mindestens Stufe {} heran, um Überwachungsknoten hinzuzufügen oder zu bearbeiten. Dies gewährleistet eine präzise Positionierung für genaues Kartieren.",
|
||||
"extractFromWay": "Knoten aus Weg/Relation extrahieren",
|
||||
"extractFromWaySubtitle": "Neuen Knoten mit gleichen Tags erstellen, Verschieben an neuen Ort ermöglichen",
|
||||
"refineTags": "Tags Verfeinern",
|
||||
@@ -133,9 +134,16 @@
|
||||
"withinTileLimit": "Innerhalb {} Kachel-Limit",
|
||||
"exceedsTileLimit": "Aktuelle Auswahl überschreitet {} Kachel-Limit",
|
||||
"offlineModeWarning": "Downloads im Offline-Modus deaktiviert. Deaktivieren Sie den Offline-Modus, um neue Bereiche herunterzuladen.",
|
||||
"areaTooBigMessage": "Zoomen Sie auf mindestens Stufe {} heran, um Offline-Bereiche herunterzuladen. Downloads großer Gebiete können die App zum Absturz bringen.",
|
||||
"downloadStarted": "Download gestartet! Lade Kacheln und Knoten...",
|
||||
"downloadFailed": "Download konnte nicht gestartet werden: {}"
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Download gestartet",
|
||||
"message": "Download gestartet! Lade Kacheln und Knoten...",
|
||||
"ok": "OK",
|
||||
"viewProgress": "Fortschritt in Einstellungen anzeigen"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Upload-Ziel",
|
||||
"subtitle": "Wählen Sie, wohin Kameras hochgeladen werden",
|
||||
@@ -265,6 +273,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",
|
||||
|
||||
@@ -136,6 +136,7 @@
|
||||
"enableSubmittableProfile": "Enable a submittable profile in Settings to edit nodes.",
|
||||
"profileViewOnlyWarning": "This profile is for map viewing only. Please select a submittable profile to edit nodes.",
|
||||
"cannotMoveConstrainedNode": "Cannot move this camera - it's connected to another map element (OSM way/relation). You can still edit its tags and direction.",
|
||||
"zoomInRequiredMessage": "Zoom in to at least level {} to add or edit surveillance nodes. This ensures precise positioning for accurate mapping.",
|
||||
"extractFromWay": "Extract node from way/relation",
|
||||
"extractFromWaySubtitle": "Create new node with same tags, allow moving to new location",
|
||||
"refineTags": "Refine Tags",
|
||||
@@ -151,9 +152,16 @@
|
||||
"withinTileLimit": "Within {} tile limit",
|
||||
"exceedsTileLimit": "Current selection exceeds {} tile limit",
|
||||
"offlineModeWarning": "Downloads disabled while in offline mode. Disable offline mode to download new areas.",
|
||||
"areaTooBigMessage": "Zoom in to at least level {} to download offline areas. Large area downloads can cause the app to become unresponsive.",
|
||||
"downloadStarted": "Download started! Fetching tiles and nodes...",
|
||||
"downloadFailed": "Failed to start download: {}"
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Download Started",
|
||||
"message": "Download started! Fetching tiles and nodes...",
|
||||
"ok": "OK",
|
||||
"viewProgress": "View Progress in Settings"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Upload Destination",
|
||||
"subtitle": "Choose where cameras are uploaded",
|
||||
@@ -283,6 +291,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",
|
||||
|
||||
@@ -136,6 +136,7 @@
|
||||
"enableSubmittableProfile": "Habilite un perfil envíable en Configuración para editar nodos.",
|
||||
"profileViewOnlyWarning": "Este perfil es solo para visualización del mapa. Por favor, seleccione un perfil envíable para editar nodos.",
|
||||
"cannotMoveConstrainedNode": "No se puede mover esta cámara - está conectada a otro elemento del mapa (OSM way/relation). Aún puede editar sus etiquetas y dirección.",
|
||||
"zoomInRequiredMessage": "Amplíe al menos al nivel {} para agregar o editar nodos de vigilancia. Esto garantiza un posicionamiento preciso para un mapeo exacto.",
|
||||
"extractFromWay": "Extraer nodo de way/relation",
|
||||
"extractFromWaySubtitle": "Crear nuevo nodo con las mismas etiquetas, permitir mover a nueva ubicación",
|
||||
"refineTags": "Refinar Etiquetas",
|
||||
@@ -151,9 +152,16 @@
|
||||
"withinTileLimit": "Dentro del límite de {} mosaicos",
|
||||
"exceedsTileLimit": "La selección actual excede el límite de {} mosaicos",
|
||||
"offlineModeWarning": "Descargas deshabilitadas en modo sin conexión. Deshabilite el modo sin conexión para descargar nuevas áreas.",
|
||||
"areaTooBigMessage": "Amplíe al menos al nivel {} para descargar áreas sin conexión. Las descargas de áreas grandes pueden hacer que la aplicación deje de responder.",
|
||||
"downloadStarted": "¡Descarga iniciada! Obteniendo mosaicos y nodos...",
|
||||
"downloadFailed": "Error al iniciar la descarga: {}"
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Descarga Iniciada",
|
||||
"message": "¡Descarga iniciada! Obteniendo mosaicos y nodos...",
|
||||
"ok": "OK",
|
||||
"viewProgress": "Ver Progreso en Configuración"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Destino de Subida",
|
||||
"subtitle": "Elige dónde se suben las cámaras",
|
||||
@@ -283,6 +291,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",
|
||||
|
||||
@@ -136,6 +136,7 @@
|
||||
"enableSubmittableProfile": "Activez un profil soumissible dans les Paramètres pour modifier les nœuds.",
|
||||
"profileViewOnlyWarning": "Ce profil est uniquement pour la visualisation de la carte. Veuillez sélectionner un profil soumissible pour modifier les nœuds.",
|
||||
"cannotMoveConstrainedNode": "Impossible de déplacer cette caméra - elle est connectée à un autre élément de carte (OSM way/relation). Vous pouvez toujours modifier ses balises et sa direction.",
|
||||
"zoomInRequiredMessage": "Zoomez au moins au niveau {} pour ajouter ou modifier des nœuds de surveillance. Cela garantit un positionnement précis pour une cartographie exacte.",
|
||||
"extractFromWay": "Extraire le nœud du way/relation",
|
||||
"extractFromWaySubtitle": "Créer un nouveau nœud avec les mêmes balises, permettre le déplacement vers un nouvel emplacement",
|
||||
"refineTags": "Affiner Balises",
|
||||
@@ -151,9 +152,16 @@
|
||||
"withinTileLimit": "Dans la limite de {} tuiles",
|
||||
"exceedsTileLimit": "La sélection actuelle dépasse la limite de {} tuiles",
|
||||
"offlineModeWarning": "Téléchargements désactivés en mode hors ligne. Désactivez le mode hors ligne pour télécharger de nouvelles zones.",
|
||||
"downloadStarted": "Téléchargement démarré! Récupération des tuiles et nœuds...",
|
||||
"areaTooBigMessage": "Zoomez au moins au niveau {} pour télécharger des zones hors ligne. Les téléchargements de grandes zones peuvent rendre l'application non réactive.",
|
||||
"downloadStarted": "Téléchargement démarré ! Récupération des tuiles et nœuds...",
|
||||
"downloadFailed": "Échec du démarrage du téléchargement: {}"
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Téléchargement Démarré",
|
||||
"message": "Téléchargement démarré! Récupération des tuiles et nœuds...",
|
||||
"ok": "OK",
|
||||
"viewProgress": "Voir le Progrès dans Paramètres"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Destination de Téléchargement",
|
||||
"subtitle": "Choisir où les caméras sont téléchargées",
|
||||
@@ -283,6 +291,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",
|
||||
|
||||
@@ -136,6 +136,7 @@
|
||||
"enableSubmittableProfile": "Abilita un profilo inviabile nelle Impostazioni per modificare i nodi.",
|
||||
"profileViewOnlyWarning": "Questo profilo è solo per la visualizzazione della mappa. Per favore seleziona un profilo inviabile per modificare i nodi.",
|
||||
"cannotMoveConstrainedNode": "Impossibile spostare questa telecamera - è collegata a un altro elemento della mappa (OSM way/relation). Puoi ancora modificare i suoi tag e direzione.",
|
||||
"zoomInRequiredMessage": "Ingrandisci almeno al livello {} per aggiungere o modificare nodi di sorveglianza. Questo garantisce un posizionamento preciso per una mappatura accurata.",
|
||||
"extractFromWay": "Estrai nodo da way/relation",
|
||||
"extractFromWaySubtitle": "Crea nuovo nodo con gli stessi tag, consenti spostamento in nuova posizione",
|
||||
"refineTags": "Affina Tag",
|
||||
@@ -151,9 +152,16 @@
|
||||
"withinTileLimit": "Entro il limite di {} tile",
|
||||
"exceedsTileLimit": "La selezione corrente supera il limite di {} tile",
|
||||
"offlineModeWarning": "Download disabilitati in modalità offline. Disabilita la modalità offline per scaricare nuove aree.",
|
||||
"areaTooBigMessage": "Ingrandisci almeno al livello {} per scaricare aree offline. I download di aree grandi possono rendere l'app non reattiva.",
|
||||
"downloadStarted": "Download avviato! Recupero tile e nodi...",
|
||||
"downloadFailed": "Impossibile avviare il download: {}"
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Download Avviato",
|
||||
"message": "Download avviato! Recupero tile e nodi...",
|
||||
"ok": "OK",
|
||||
"viewProgress": "Visualizza Progresso in Impostazioni"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Destinazione Upload",
|
||||
"subtitle": "Scegli dove vengono caricate le telecamere",
|
||||
@@ -283,6 +291,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",
|
||||
|
||||
@@ -136,6 +136,7 @@
|
||||
"enableSubmittableProfile": "Ative um perfil enviável nas Configurações para editar nós.",
|
||||
"profileViewOnlyWarning": "Este perfil é apenas para visualização do mapa. Por favor, selecione um perfil enviável para editar nós.",
|
||||
"cannotMoveConstrainedNode": "Não é possível mover esta câmera - ela está conectada a outro elemento do mapa (OSM way/relation). Você ainda pode editar suas tags e direção.",
|
||||
"zoomInRequiredMessage": "Amplie para pelo menos o nível {} para adicionar ou editar nós de vigilância. Isto garante um posicionamento preciso para mapeamento exato.",
|
||||
"extractFromWay": "Extrair nó do way/relation",
|
||||
"extractFromWaySubtitle": "Criar novo nó com as mesmas tags, permitir mover para nova localização",
|
||||
"refineTags": "Refinar Tags",
|
||||
@@ -151,9 +152,16 @@
|
||||
"withinTileLimit": "Dentro do limite de {} tiles",
|
||||
"exceedsTileLimit": "A seleção atual excede o limite de {} tiles",
|
||||
"offlineModeWarning": "Downloads desabilitados no modo offline. Desative o modo offline para baixar novas áreas.",
|
||||
"areaTooBigMessage": "Amplie para pelo menos o nível {} para baixar áreas offline. Downloads de áreas grandes podem tornar o aplicativo não responsivo.",
|
||||
"downloadStarted": "Download iniciado! Buscando tiles e nós...",
|
||||
"downloadFailed": "Falha ao iniciar o download: {}"
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Download Iniciado",
|
||||
"message": "Download iniciado! Buscando tiles e nós...",
|
||||
"ok": "OK",
|
||||
"viewProgress": "Ver Progresso nas Configurações"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Destino do Upload",
|
||||
"subtitle": "Escolha onde as câmeras são enviadas",
|
||||
@@ -283,6 +291,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",
|
||||
|
||||
@@ -136,6 +136,7 @@
|
||||
"enableSubmittableProfile": "在设置中启用可提交的配置文件以编辑节点。",
|
||||
"profileViewOnlyWarning": "此配置文件仅用于地图查看。请选择可提交的配置文件来编辑节点。",
|
||||
"cannotMoveConstrainedNode": "无法移动此相机 - 它连接到另一个地图元素(OSM way/relation)。您仍可以编辑其标签和方向。",
|
||||
"zoomInRequiredMessage": "请放大至至少第{}级来添加或编辑监控节点。这确保精确定位以便准确制图。",
|
||||
"extractFromWay": "从way/relation中提取节点",
|
||||
"extractFromWaySubtitle": "创建具有相同标签的新节点,允许移动到新位置",
|
||||
"refineTags": "细化标签",
|
||||
@@ -151,9 +152,16 @@
|
||||
"withinTileLimit": "在 {} 瓦片限制内",
|
||||
"exceedsTileLimit": "当前选择超出 {} 瓦片限制",
|
||||
"offlineModeWarning": "离线模式下禁用下载。禁用离线模式以下载新区域。",
|
||||
"areaTooBigMessage": "请放大至至少第{}级来下载离线区域。下载大区域可能导致应用程序无响应。",
|
||||
"downloadStarted": "下载已开始!正在获取瓦片和节点...",
|
||||
"downloadFailed": "启动下载失败:{}"
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "下载已开始",
|
||||
"message": "下载已开始!正在获取瓦片和节点...",
|
||||
"ok": "确定",
|
||||
"viewProgress": "在设置中查看进度"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "上传目标",
|
||||
"subtitle": "选择摄像头上传位置",
|
||||
@@ -283,6 +291,10 @@
|
||||
"profileNameRequired": "配置文件名称为必填项",
|
||||
"requiresDirection": "需要方向",
|
||||
"requiresDirectionSubtitle": "此类型的摄像头是否需要方向标签",
|
||||
"fov": "视场角",
|
||||
"fovHint": "视场角度数(留空使用默认值)",
|
||||
"fovSubtitle": "摄像头视场角 - 用于锥体宽度和范围提交格式",
|
||||
"fovInvalid": "视场角必须在1到360度之间",
|
||||
"submittable": "可提交",
|
||||
"submittableSubtitle": "此配置文件是否可用于摄像头提交",
|
||||
"osmTags": "OSM 标签",
|
||||
|
||||
24
lib/models/direction_fov.dart
Normal file
24
lib/models/direction_fov.dart
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 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<double> get directionDeg {
|
||||
return directionFovPairs.map((df) => df.centerDegrees).toList();
|
||||
}
|
||||
}
|
||||
@@ -103,6 +103,21 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
|
||||
void _openAddNodeSheet() {
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
// Check minimum zoom level before opening sheet
|
||||
final currentZoom = _mapController.mapController.camera.zoom;
|
||||
if (currentZoom < kMinZoomForNodeEditingSheets) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
LocalizationService.instance.t('editNode.zoomInRequiredMessage',
|
||||
params: [kMinZoomForNodeEditingSheets.toString()])
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable follow-me when adding a camera so the map doesn't jump around
|
||||
appState.setFollowMeMode(FollowMeMode.off);
|
||||
|
||||
@@ -532,6 +547,20 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
child: NodeTagSheet(
|
||||
node: node,
|
||||
onEditPressed: () {
|
||||
// Check minimum zoom level before starting edit session
|
||||
final currentZoom = _mapController.mapController.camera.zoom;
|
||||
if (currentZoom < kMinZoomForNodeEditingSheets) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
LocalizationService.instance.t('editNode.zoomInRequiredMessage',
|
||||
params: [kMinZoomForNodeEditingSheets.toString()])
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final appState = context.read<AppState>();
|
||||
appState.startEditSession(node);
|
||||
// This will trigger _openEditNodeSheet via the existing auto-show logic
|
||||
@@ -760,10 +789,26 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
child: ElevatedButton.icon(
|
||||
icon: Icon(Icons.download_for_offline),
|
||||
label: Text(LocalizationService.instance.download),
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
|
||||
),
|
||||
onPressed: () {
|
||||
// Check minimum zoom level before opening download dialog
|
||||
final currentZoom = _mapController.mapController.camera.zoom;
|
||||
if (currentZoom < kMinZoomForOfflineDownload) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
LocalizationService.instance.t('download.areaTooBigMessage',
|
||||
params: [kMinZoomForOfflineDownload.toString()])
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
|
||||
);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: Size(0, 48),
|
||||
textStyle: TextStyle(fontSize: 16),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -9,6 +9,7 @@ import '../dev_config.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../services/offline_area_service.dart';
|
||||
import '../services/offline_areas/offline_tile_utils.dart';
|
||||
import 'download_started_dialog.dart';
|
||||
|
||||
class DownloadAreaDialog extends StatefulWidget {
|
||||
final MapController controller;
|
||||
@@ -275,16 +276,29 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
|
||||
tileTypeName: selectedTileType?.name,
|
||||
);
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(locService.t('download.downloadStarted')),
|
||||
),
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => const DownloadStartedDialog(),
|
||||
);
|
||||
} catch (e) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
const Icon(Icons.error, color: Colors.red),
|
||||
const SizedBox(width: 10),
|
||||
Text(locService.t('download.title')),
|
||||
],
|
||||
),
|
||||
content: Text(locService.t('download.downloadFailed', params: [e.toString()])),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(locService.t('actions.ok')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
40
lib/widgets/download_started_dialog.dart
Normal file
40
lib/widgets/download_started_dialog.dart
Normal file
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
class DownloadStartedDialog extends StatelessWidget {
|
||||
const DownloadStartedDialog({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
const Icon(Icons.download_for_offline, color: Colors.green),
|
||||
const SizedBox(width: 10),
|
||||
Text(locService.t('downloadStarted.title')),
|
||||
],
|
||||
),
|
||||
content: Text(locService.t('downloadStarted.message')),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(locService.t('downloadStarted.ok')),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.pushNamed(context, '/settings/offline');
|
||||
},
|
||||
child: Text(locService.t('downloadStarted.viewProgress')),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -597,6 +597,18 @@ class MapViewState extends State<MapView> {
|
||||
widget.onUserGesture();
|
||||
}
|
||||
|
||||
// Enforce minimum zoom level for add/edit node sheets (but not tag sheet)
|
||||
if ((session != null || editSession != null) && pos.zoom < kMinZoomForNodeEditingSheets) {
|
||||
// User tried to zoom out below minimum - snap back to minimum zoom
|
||||
_controller.animateTo(
|
||||
dest: pos.center,
|
||||
zoom: kMinZoomForNodeEditingSheets.toDouble(),
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
return; // Don't process other position updates
|
||||
}
|
||||
|
||||
if (session != null) {
|
||||
appState.updateSession(target: pos.center);
|
||||
}
|
||||
|
||||
@@ -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.5+16 # 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+
|
||||
|
||||
Reference in New Issue
Block a user