Compare commits

...

6 Commits

Author SHA1 Message Date
stopflock
3868236816 Improve subdomain notation, fix error catching for xyz in tile URL 2025-11-22 22:26:04 -06:00
stopflock
52af77e1ed Add bing sat imagery 2025-11-22 22:00:56 -06:00
stopflock
c150e3ccee Devibe changelog 2025-11-22 17:40:07 -06:00
stopflock
c6cc68c9b4 Add buttons to show welcome message and submission guide on command from about section of settings 2025-11-22 17:19:57 -06:00
stopflock
961465ebb5 Popup message before submitting first node 2025-11-22 14:56:05 -06:00
stopflock
7ff04851f4 Fix tile loading finally 2025-11-22 13:22:17 -06:00
22 changed files with 748 additions and 79 deletions

View File

@@ -72,6 +72,7 @@ The app includes a comprehensive system for welcoming new users and notifying ex
### Components
- **ChangelogService**: Manages version tracking and changelog loading
- **WelcomeDialog**: First launch popup with privacy information and quick links
- **SubmissionGuideDialog**: One-time popup before first node submission with best practices
- **ChangelogDialog**: Update notification popup for version changes
- **ReleaseNotesScreen**: Settings page for viewing all changelog history
@@ -96,6 +97,7 @@ Changelog content is stored in `assets/changelog.json`:
### User Experience Flow
- **First Launch**: Welcome popup with "don't show again" option
- **First Submission**: Submission guide popup with best practices and resource links
- **Version Updates**: Changelog popup (only if content exists, no "don't show again")
- **Settings Access**: Complete changelog history available in Settings > About > Release Notes
@@ -123,7 +125,7 @@ The welcome popup explains that the app:
**Key methods:**
- `getNodes()`: Returns cache immediately, triggers pre-fetch if needed (spatial or temporal)
- `getTile()`: Tile fetching with enhanced retry strategy (6 attempts, 1-8s delays)
- `getTile()`: Tile fetching with unlimited retry strategy (retries until success)
- `_fetchRemoteNodes()`: Handles Overpass → OSM API fallback
**Smart caching flow:**
@@ -338,7 +340,36 @@ Most users should contribute to production; testing modes add complexity
bool get showUploadModeSelector => kDebugMode;
```
### 11. Navigation & Routing (Implemented, Awaiting Integration)
### 11. Tile Provider System & URL Templates
**Design approach:**
- **Flexible URL templates**: Support multiple coordinate systems and load-balancing patterns
- **Built-in providers**: Curated set of high-quality, reliable tile sources
- **Custom providers**: Users can add any tile service with full validation
- **API key management**: Secure storage with per-provider API keys
**Supported URL placeholders:**
```
{x}, {y}, {z} - Standard TMS tile coordinates
{quadkey} - Bing Maps quadkey format (alternative to x/y/z)
{0_3} - Subdomain 0-3 for load balancing
{1_4} - Subdomain 1-4 for providers using 1-based indexing
{api_key} - API key insertion point (optional)
```
**Built-in providers:**
- **OpenStreetMap**: Standard street map tiles, no API key required
- **Bing Maps**: High-quality satellite imagery using quadkey system, no API key required
- **Mapbox**: Satellite and street tiles, requires API key
- **OpenTopoMap**: Topographic maps, no API key required
**Validation logic:**
URL templates must contain either `{quadkey}` OR all of `{x}`, `{y}`, and `{z}`. This allows for both standard tile services and specialized formats like Bing Maps.
**Why this approach:**
Provides maximum flexibility while maintaining simplicity. Users can add any tile service without code changes, while built-in providers offer immediate functionality. The quadkey system enables access to high-quality satellite imagery without API key requirements.
### 12. Navigation & Routing (Implemented, Awaiting Integration)
**Current state:**
- **Search functionality**: Fully implemented and active

View File

@@ -21,7 +21,7 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
- **Map surveillance infrastructure** including cameras, ALPRs, gunshot detectors, and more with precise location, direction, and manufacturer details
- **Upload to OpenStreetMap** with OAuth2 integration (live or sandbox modes)
- **Work completely offline** with downloadable map areas and device data, plus upload queue
- **Multiple map types** including satellite imagery from USGS, Esri, Mapbox, and topographic maps from OpenTopoMap, plus custom map tile provider support
- **Multiple map types** including satellite imagery from Bing Maps, USGS, Esri, Mapbox, and topographic maps from OpenTopoMap, plus custom map tile provider support
- **Editing Ability** to update existing device locations and properties
- **Built-in device profiles** for Flock Safety, Motorola, Genetec, Leonardo, and other major manufacturers, plus custom profiles for more specific tag sets
@@ -30,7 +30,7 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
## Key Features
### Map & Navigation
- **Multi-source tiles**: Switch between OpenStreetMap, USGS imagery, Esri imagery, Mapbox, OpenTopoMap, and any custom providers
- **Multi-source tiles**: Switch between OpenStreetMap, Bing satellite imagery, USGS imagery, Esri imagery, Mapbox, OpenTopoMap, and any custom providers
- **Offline-first design**: Download a region for complete offline operation
- **Smooth UX**: Intuitive controls, follow-me mode with GPS rotation, compass indicator with north-lock, and gesture-friendly interactions
- **Device visualization**: Color-coded markers showing real devices (blue), pending uploads (purple), pending edits (grey), devices being edited (orange), and pending deletions (red)
@@ -66,7 +66,7 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
1. **Install** the app on iOS or Android - a welcome popup will guide you through key information
2. **Enable location** permissions
3. **Log into OpenStreetMap**: Choose upload mode and get OAuth2 credentials
4. **Add your first device**: Tap the "New Node" button, position the pin, set direction(s), select a profile, and tap submit
4. **Add your first device**: Tap the "New Node" button, position the pin, set direction(s), select a profile, and tap submit - a guidance popup will help you with best practices on your first submission
5. **Edit or delete devices**: Tap any device marker to view details, then use Edit or Delete buttons
**New to OpenStreetMap?** Visit [deflock.me](https://deflock.me) for complete setup instructions and community guidelines.
@@ -99,7 +99,6 @@ 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!
@@ -111,7 +110,6 @@ 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?)
- Add some builtin satellite tile provider
- 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

View File

@@ -1,4 +1,24 @@
{
"1.5.1": {
"content": [
"• NEW: Bing satellite imagery - high-quality satellite tiles used by the iD editor, no API key required",
"• IMPROVED: Enhanced tile provider system with quadkey format support (for Bing Maps and similar providers)",
"• IMPROVED: Flexible subdomain patterns - supports both 0-3 and 1-4 subdomain ranges for load balancing",
"• IMPROVED: Tile URL validation now accepts either {quadkey} or {x}/{y}/{z} coordinate systems"
]
},
"1.5.0": {
"content": [
"• NEW: First-submission guide popup - provides essential guidance and links before your first device submission",
"• NEW: Manual access to dialogs in Settings > About - view welcome message and submission guide anytime"
]
},
"1.4.6": {
"content": [
"• IMPROVED: Tile fetching reliability - removed retry limits so visible tiles always load eventually",
"• FIXED: Queue management - cancel requests for off-screen tiles, ongoing requests continue normally"
]
},
"1.4.5": {
"content": [
"• NEW: Minimum zoom level (Z15) enforced for adding and editing surveillance nodes to ensure precise positioning",

View File

@@ -124,12 +124,13 @@ const double kPinchZoomThreshold = 0.2; // How much pinch required to start zoom
const double kPinchMoveThreshold = 30.0; // How much drag required for two-finger pan (default 40.0)
const double kRotationThreshold = 6.0; // Degrees of rotation required before map actually rotates (Google Maps style)
// Tile fetch retry parameters (configurable backoff system)
const int kTileFetchMaxAttempts = 16; // Number of retry attempts before giving up
const int kTileFetchInitialDelayMs = 500; // Base delay for first retry (1 second)
// Tile fetch configuration (brutalist approach: simple, configurable, unlimited retries)
const int kTileFetchConcurrentThreads = 10; // Number of simultaneous tile downloads
const int kTileFetchInitialDelayMs = 200; // Base delay for first retry (500ms)
const double kTileFetchBackoffMultiplier = 1.5; // Multiply delay by this each attempt
const int kTileFetchMaxDelayMs = 10000; // Cap delays at this value (8 seconds max)
const int kTileFetchRandomJitterMs = 250; // Random fuzz to add (0 to 500ms)
const int kTileFetchMaxDelayMs = 5000; // Cap delays at this value (10 seconds max)
const int kTileFetchRandomJitterMs = 100; // Random fuzz to add (0 to 250ms)
// Note: Removed max attempts - tiles retry indefinitely until they succeed or are canceled
// User download max zoom span (user can download up to kMaxUserDownloadZoomSpan zooms above min)
const int kMaxUserDownloadZoomSpan = 7;

View File

@@ -235,7 +235,7 @@
"urlTemplate": "URL-Vorlage",
"urlTemplateHint": "https://beispiel.com/{z}/{x}/{y}.png",
"urlTemplateRequired": "URL-Vorlage ist erforderlich",
"urlTemplatePlaceholders": "URL muss {z}, {x} und {y} Platzhalter enthalten",
"urlTemplatePlaceholders": "URL muss entweder {quadkey} oder {z}, {x} und {y} Platzhalter enthalten",
"attribution": "Zuschreibung",
"attributionHint": "© Karten-Anbieter",
"attributionRequired": "Zuschreibung ist erforderlich",
@@ -392,7 +392,10 @@
"description": "DeFlock ist eine datenschutzorientierte mobile App zur Kartierung öffentlicher Überwachungsinfrastruktür mit OpenStreetMap. Dokumentieren Sie Kameras, ALPRs, Schussdetektoren und andere Überwachungsgeräte in Ihrer Gemeinde, um diese Infrastruktur sichtbar und durchsuchbar zu machen.",
"features": "• Offline-fähige Kartierung mit herunterladbaren Bereichen\n• Direkter Upload zu OpenStreetMap mit OAuth2\n• Integrierte Profile für große Hersteller\n• Datenschutzfreundlich - keine Nutzerdaten gesammelt\n• Multiple Kartenanbieter (OSM, Satellitenbilder)",
"initiative": "Teil der breiteren DeFlock-Initiative zur Förderung von Überwachungstransparenz.",
"footer": "Besuchen Sie: deflock.me\nGebaut mit Flutter • Open Source"
"footer": "Besuchen Sie: deflock.me\nGebaut mit Flutter • Open Source",
"showWelcome": "Willkommensnachricht anzeigen",
"showSubmissionGuide": "Einreichungsleitfaden anzeigen",
"viewReleaseNotes": "Release-Notizen anzeigen"
},
"welcome": {
"title": "Willkommen bei DeFlock",
@@ -405,6 +408,17 @@
"dontShowAgain": "Diese Willkommensnachricht nicht mehr anzeigen",
"getStarted": "Los geht's mit DeFlocking!"
},
"submissionGuide": {
"title": "Einreichungs-Richtlinien",
"description": "Bevor Sie Ihr erstes Überwachungsgerät einreichen, lesen Sie bitte diese wichtigen Richtlinien für qualitativ hochwertige Beiträge zu OpenStreetMap.",
"bestPractices": "• Nur Geräte erfassen, die Sie persönlich beobachtet haben\n• Zeit nehmen für genaue Identifikation von Typ und Hersteller\n• Präzise Positionierung - nah heranzoomen vor Markierung\n• Richtungsinformationen angeben, falls zutreffend\n• Tag-Auswahl vor dem Senden überprüfen",
"placementNote": "Denken Sie daran: Genaue, persönlich verifizierte Daten sind essentiell für die DeFlock-Community und das OpenStreetMap-Projekt.",
"moreInfo": "Für detaillierte Anleitungen zur Geräteerkennung und Kartierung:",
"identificationGuide": "Identifikationsleitfaden",
"osmWiki": "OpenStreetMap Wiki",
"dontShowAgain": "Diese Anleitung nicht mehr anzeigen",
"gotIt": "Verstanden!"
},
"navigation": {
"searchLocation": "Ort suchen",
"searchPlaceholder": "Orte oder Koordinaten suchen...",

View File

@@ -10,7 +10,10 @@
"description": "DeFlock is a privacy-focused mobile app for mapping public surveillance infrastructure using OpenStreetMap. Document cameras, ALPRs, gunshot detectors, and other surveillance devices in your community to make this infrastructure visible and searchable.",
"features": "• Offline-capable mapping with downloadable areas\n• Upload directly to OpenStreetMap with OAuth2\n• Built-in profiles for major manufacturers\n• Privacy-respecting - no user data collected\n• Multiple map tile providers (OSM, satellite imagery)",
"initiative": "Part of the broader DeFlock initiative to promote surveillance transparency.",
"footer": "Visit: deflock.me\nBuilt with Flutter • Open Source"
"footer": "Visit: deflock.me\nBuilt with Flutter • Open Source",
"showWelcome": "Show Welcome Message",
"showSubmissionGuide": "Show Submission Guide",
"viewReleaseNotes": "View Release Notes"
},
"welcome": {
"title": "Welcome to DeFlock",
@@ -23,6 +26,17 @@
"dontShowAgain": "Don't show this welcome message again",
"getStarted": "Let's Get DeFlocking!"
},
"submissionGuide": {
"title": "Submission Best Practices",
"description": "Before submitting your first surveillance device, please take a moment to review these important guidelines to ensure high-quality contributions to OpenStreetMap.",
"bestPractices": "• Only map devices you've personally observed firsthand\n• Take time to accurately identify the device type and manufacturer\n• Use precise positioning - zoom in close before placing the marker\n• Include direction information when applicable\n• Double-check your tag selections before submitting",
"placementNote": "Remember: Accurate, first-hand data is essential for the DeFlock community and OpenStreetMap project.",
"moreInfo": "For detailed guidance on device identification and mapping best practices:",
"identificationGuide": "Identification Guide",
"osmWiki": "OpenStreetMap Wiki",
"dontShowAgain": "Don't show this guide again",
"gotIt": "Got It!"
},
"actions": {
"tagNode": "New Node",
"download": "Download",
@@ -253,7 +267,7 @@
"urlTemplate": "URL Template",
"urlTemplateHint": "https://example.com/{z}/{x}/{y}.png",
"urlTemplateRequired": "URL template is required",
"urlTemplatePlaceholders": "URL must contain {z}, {x}, and {y} placeholders",
"urlTemplatePlaceholders": "URL must contain either {quadkey} or {z}, {x}, and {y} placeholders",
"attribution": "Attribution",
"attributionHint": "© Map Provider",
"attributionRequired": "Attribution is required",

View File

@@ -10,7 +10,10 @@
"description": "DeFlock es una aplicación móvil enfocada en la privacidad para mapear infraestructura de vigilancia pública usando OpenStreetMap. Documenta cámaras, ALPRs, detectores de disparos y otros dispositivos de vigilancia en tu comunidad para hacer visible y consultable esta infraestructura.",
"features": "• Mapeo con capacidad offline con áreas descargables\n• Subida directa a OpenStreetMap con OAuth2\n• Perfiles integrados para fabricantes principales\n• Respeta la privacidad - no se recopilan datos del usuario\n• Múltiples proveedores de mapas (OSM, imágenes satelitales)",
"initiative": "Parte de la iniciativa más amplia DeFlock para promover la transparencia en vigilancia.",
"footer": "Visita: deflock.me\nConstruido con Flutter • Código Abierto"
"footer": "Visita: deflock.me\nConstruido con Flutter • Código Abierto",
"showWelcome": "Mostrar Mensaje de Bienvenida",
"showSubmissionGuide": "Mostrar Guía de Envío",
"viewReleaseNotes": "Ver Notas de Lanzamiento"
},
"welcome": {
"title": "Bienvenido a DeFlock",
@@ -23,6 +26,17 @@
"dontShowAgain": "No mostrar este mensaje de bienvenida otra vez",
"getStarted": "¡Comencemos con DeFlock!"
},
"submissionGuide": {
"title": "Mejores Prácticas de Envío",
"description": "Antes de enviar su primer dispositivo de vigilancia, tómese un momento para revisar estas pautas importantes para contribuciones de alta calidad a OpenStreetMap.",
"bestPractices": "• Solo mapee dispositivos que haya observado personalmente\n• Tómese tiempo para identificar con precisión el tipo y fabricante\n• Use posicionamiento preciso - acerque antes de colocar el marcador\n• Incluya información de dirección cuando sea aplicable\n• Verifique sus selecciones de etiquetas antes de enviar",
"placementNote": "Recuerde: Los datos precisos y de primera mano son esenciales para la comunidad DeFlock y el proyecto OpenStreetMap.",
"moreInfo": "Para orientación detallada sobre identificación de dispositivos y mejores prácticas de mapeo:",
"identificationGuide": "Guía de Identificación",
"osmWiki": "Wiki de OpenStreetMap",
"dontShowAgain": "No mostrar esta guía otra vez",
"gotIt": "¡Entendido!"
},
"actions": {
"tagNode": "Nuevo Nodo",
"download": "Descargar",
@@ -253,7 +267,7 @@
"urlTemplate": "Plantilla de URL",
"urlTemplateHint": "https://ejemplo.com/{z}/{x}/{y}.png",
"urlTemplateRequired": "La plantilla de URL es requerida",
"urlTemplatePlaceholders": "La URL debe contener marcadores {z}, {x} y {y}",
"urlTemplatePlaceholders": "La URL debe contener marcadores {quadkey} o {z}, {x} y {y}",
"attribution": "Atribución",
"attributionHint": "© Proveedor de Mapas",
"attributionRequired": "La atribución es requerida",

View File

@@ -10,7 +10,10 @@
"description": "DeFlock est une application mobile axée sur la confidentialité pour cartographier l'infrastructure de surveillance publique en utilisant OpenStreetMap. Documentez les caméras, ALPRs, détecteurs de coups de feu et autres dispositifs de surveillance dans votre communauté pour rendre cette infrastructure visible et consultable.",
"features": "• Cartographie hors ligne avec zones téléchargeables\n• Upload direct vers OpenStreetMap avec OAuth2\n• Profils intégrés pour les principaux fabricants\n• Respectueux de la confidentialité - aucune donnée utilisateur collectée\n• Multiples fournisseurs de cartes (OSM, imagerie satellite)",
"initiative": "Partie de l'initiative plus large DeFlock pour promouvoir la transparence de la surveillance.",
"footer": "Visitez : deflock.me\nConstruit avec Flutter • Source Ouverte"
"footer": "Visitez : deflock.me\nConstruit avec Flutter • Source Ouverte",
"showWelcome": "Afficher le Message de Bienvenue",
"showSubmissionGuide": "Afficher le Guide de Soumission",
"viewReleaseNotes": "Voir les Notes de Version"
},
"welcome": {
"title": "Bienvenue dans DeFlock",
@@ -23,6 +26,17 @@
"dontShowAgain": "Ne plus afficher ce message de bienvenue",
"getStarted": "Commençons le DeFlock !"
},
"submissionGuide": {
"title": "Meilleures Pratiques de Soumission",
"description": "Avant de soumettre votre premier dispositif de surveillance, prenez un moment pour examiner ces directives importantes pour des contributions de haute qualité à OpenStreetMap.",
"bestPractices": "• Ne cartographiez que les dispositifs que vous avez observés personnellement\n• Prenez le temps d'identifier avec précision le type et le fabricant\n• Utilisez un positionnement précis - zoomez avant de placer le marqueur\n• Incluez les informations de direction quand c'est applicable\n• Vérifiez vos sélections d'étiquettes avant de soumettre",
"placementNote": "Rappelez-vous : Des données précises et de première main sont essentielles pour la communauté DeFlock et le projet OpenStreetMap.",
"moreInfo": "Pour des conseils détaillés sur l'identification des dispositifs et les meilleures pratiques de cartographie :",
"identificationGuide": "Guide d'Identification",
"osmWiki": "Wiki OpenStreetMap",
"dontShowAgain": "Ne plus afficher ce guide",
"gotIt": "Compris !"
},
"actions": {
"tagNode": "Nouveau Nœud",
"download": "Télécharger",
@@ -253,7 +267,7 @@
"urlTemplate": "Modèle d'URL",
"urlTemplateHint": "https://exemple.com/{z}/{x}/{y}.png",
"urlTemplateRequired": "Le modèle d'URL est requis",
"urlTemplatePlaceholders": "L'URL doit contenir les marqueurs {z}, {x} et {y}",
"urlTemplatePlaceholders": "L'URL doit contenir soit {quadkey} soit les marqueurs {z}, {x} et {y}",
"attribution": "Attribution",
"attributionHint": "© Fournisseur de Cartes",
"attributionRequired": "L'attribution est requise",

View File

@@ -10,7 +10,10 @@
"description": "DeFlock è un'app mobile orientata alla privacy per mappare l'infrastruttura di sorveglianza pubblica utilizzando OpenStreetMap. Documenta telecamere, ALPR, rilevatori di spari e altri dispositivi di sorveglianza nella tua comunità per rendere questa infrastruttura visibile e ricercabile.",
"features": "• Mappatura con capacità offline con aree scaricabili\n• Upload diretto su OpenStreetMap con OAuth2\n• Profili integrati per i principali produttori\n• Rispettoso della privacy - nessun dato utente raccolto\n• Multipli fornitori di mappe (OSM, immagini satellitari)",
"initiative": "Parte della più ampia iniziativa DeFlock per promuovere la trasparenza della sorveglianza.",
"footer": "Visita: deflock.me\nCostruito con Flutter • Open Source"
"footer": "Visita: deflock.me\nCostruito con Flutter • Open Source",
"showWelcome": "Mostra Messaggio di Benvenuto",
"showSubmissionGuide": "Mostra Guida di Invio",
"viewReleaseNotes": "Visualizza Note di Rilascio"
},
"welcome": {
"title": "Benvenuto in DeFlock",
@@ -23,6 +26,17 @@
"dontShowAgain": "Non mostrare più questo messaggio di benvenuto",
"getStarted": "Iniziamo con DeFlock!"
},
"submissionGuide": {
"title": "Migliori Pratiche di Invio",
"description": "Prima di inviare il tuo primo dispositivo di sorveglianza, prenditi un momento per rivedere queste linee guida importanti per contributi di alta qualità a OpenStreetMap.",
"bestPractices": "• Mappa solo dispositivi che hai osservato personalmente\n• Prenditi tempo per identificare accuratamente tipo e produttore\n• Usa posizionamento preciso - ingrandisci prima di piazzare il marcatore\n• Includi informazioni sulla direzione quando applicabile\n• Controlla le tue selezioni di tag prima di inviare",
"placementNote": "Ricorda: Dati accurati e di prima mano sono essenziali per la comunità DeFlock e il progetto OpenStreetMap.",
"moreInfo": "Per una guida dettagliata sull'identificazione dei dispositivi e le migliori pratiche di mappatura:",
"identificationGuide": "Guida di Identificazione",
"osmWiki": "Wiki OpenStreetMap",
"dontShowAgain": "Non mostrare più questa guida",
"gotIt": "Capito!"
},
"actions": {
"tagNode": "Nuovo Nodo",
"download": "Scarica",
@@ -253,7 +267,7 @@
"urlTemplate": "Template URL",
"urlTemplateHint": "https://esempio.com/{z}/{x}/{y}.png",
"urlTemplateRequired": "Il template URL è obbligatorio",
"urlTemplatePlaceholders": "L'URL deve contenere i segnaposto {z}, {x} e {y}",
"urlTemplatePlaceholders": "L'URL deve contenere o {quadkey} o i segnaposto {z}, {x} e {y}",
"attribution": "Attribuzione",
"attributionHint": "© Fornitore Mappe",
"attributionRequired": "L'attribuzione è obbligatoria",

View File

@@ -10,7 +10,10 @@
"description": "DeFlock é um aplicativo móvel focado na privacidade para mapear infraestrutura de vigilância pública usando OpenStreetMap. Documente câmeras, ALPRs, detectores de tiros e outros dispositivos de vigilância em sua comunidade para tornar essa infraestrutura visível e pesquisável.",
"features": "• Mapeamento com capacidade offline com áreas para download\n• Upload direto para OpenStreetMap com OAuth2\n• Perfis integrados para principais fabricantes\n• Respeitoso à privacidade - nenhum dado do usuário coletado\n• Múltiplos provedores de mapas (OSM, imagens de satélite)",
"initiative": "Parte da iniciativa mais ampla DeFlock para promover transparência na vigilância.",
"footer": "Visite: deflock.me\nConstruído com Flutter • Código Aberto"
"footer": "Visite: deflock.me\nConstruído com Flutter • Código Aberto",
"showWelcome": "Mostrar Mensagem de Boas-vindas",
"showSubmissionGuide": "Mostrar Guia de Submissão",
"viewReleaseNotes": "Ver Notas de Lançamento"
},
"welcome": {
"title": "Bem-vindo ao DeFlock",
@@ -23,6 +26,17 @@
"dontShowAgain": "Não mostrar esta mensagem de boas-vindas novamente",
"getStarted": "Vamos começar com o DeFlock!"
},
"submissionGuide": {
"title": "Melhores Práticas de Submissão",
"description": "Antes de submeter seu primeiro dispositivo de vigilância, dedique um momento para revisar estas diretrizes importantes para contribuições de alta qualidade ao OpenStreetMap.",
"bestPractices": " Mapear apenas dispositivos que você observou pessoalmente\n Dedicar tempo para identificar com precisão tipo e fabricante\n Usar posicionamento preciso - aproximar antes de colocar o marcador\n Incluir informações de direção quando aplicável\n Verificar suas seleções de tags antes de submeter",
"placementNote": "Lembre-se: Dados precisos e de primeira mão são essenciais para a comunidade DeFlock e o projeto OpenStreetMap.",
"moreInfo": "Para orientação detalhada sobre identificação de dispositivos e melhores práticas de mapeamento:",
"identificationGuide": "Guia de Identificação",
"osmWiki": "Wiki OpenStreetMap",
"dontShowAgain": "Não mostrar este guia novamente",
"gotIt": "Entendi!"
},
"actions": {
"tagNode": "Novo Nó",
"download": "Baixar",
@@ -253,7 +267,7 @@
"urlTemplate": "Modelo de URL",
"urlTemplateHint": "https://exemplo.com/{z}/{x}/{y}.png",
"urlTemplateRequired": "Modelo de URL é obrigatório",
"urlTemplatePlaceholders": "URL deve conter os marcadores {z}, {x} e {y}",
"urlTemplatePlaceholders": "URL deve conter {quadkey} ou os marcadores {z}, {x} e {y}",
"attribution": "Atribuição",
"attributionHint": "© Provedor de Mapas",
"attributionRequired": "Atribuição é obrigatória",

View File

@@ -10,7 +10,10 @@
"description": "DeFlock 是一款注重隐私的移动应用,使用 OpenStreetMap 绘制公共监控基础设施。记录您社区中的摄像头、车牌识别系统、枪击探测器和其他监控设备,使这些基础设施可见且可搜索。",
"features": "• 具有可下载区域的离线映射功能\n• 使用 OAuth2 直接上传到 OpenStreetMap\n• 主要制造商的内置配置文件\n• 尊重隐私 - 不收集用户数据\n• 多个地图提供商OSM、卫星图像",
"initiative": "DeFlock 更广泛倡议的一部分,旨在促进监控透明化。",
"footer": "访问deflock.me\n使用 Flutter 构建 • 开源"
"footer": "访问deflock.me\n使用 Flutter 构建 • 开源",
"showWelcome": "显示欢迎消息",
"showSubmissionGuide": "显示提交指南",
"viewReleaseNotes": "查看发布说明"
},
"welcome": {
"title": "欢迎使用 DeFlock",
@@ -23,6 +26,17 @@
"dontShowAgain": "不再显示此欢迎消息",
"getStarted": "开始使用 DeFlock"
},
"submissionGuide": {
"title": "提交最佳实践",
"description": "在提交您的第一个监控设备之前,请花点时间查看这些重要指南,以确保对 OpenStreetMap 的高质量贡献。",
"bestPractices": "• 只映射您亲自观察到的设备\n• 花时间准确识别设备类型和制造商\n• 使用精确定位 - 放置标记前请放大\n• 在适用时包含方向信息\n• 提交前请检查您的标签选择",
"placementNote": "请记住:准确的第一手数据对 DeFlock 社区和 OpenStreetMap 项目至关重要。",
"moreInfo": "有关设备识别和映射最佳实践的详细指导:",
"identificationGuide": "识别指南",
"osmWiki": "OpenStreetMap Wiki",
"dontShowAgain": "不再显示此指南",
"gotIt": "明白了!"
},
"actions": {
"tagNode": "新建节点",
"download": "下载",
@@ -253,7 +267,7 @@
"urlTemplate": "URL 模板",
"urlTemplateHint": "https://example.com/{z}/{x}/{y}.png",
"urlTemplateRequired": "URL 模板为必填项",
"urlTemplatePlaceholders": "URL 必须包含 {z}、{x} 和 {y} 占位符",
"urlTemplatePlaceholders": "URL 必须包含 {quadkey} 或 {z}、{x} 和 {y} 占位符",
"attribution": "归属",
"attributionHint": "© 地图提供商",
"attributionRequired": "归属为必填项",

View File

@@ -20,8 +20,35 @@ class TileType {
});
/// Create URL for a specific tile, replacing template variables
///
/// Supported placeholders:
/// - {x}, {y}, {z}: Standard tile coordinates
/// - {quadkey}: Bing Maps quadkey format (alternative to x/y/z)
/// - {0_3}: Subdomain 0-3 for load balancing
/// - {1_4}: Subdomain 1-4 for providers that use 1-based indexing
/// - {api_key}: API key placeholder (optional)
String getTileUrl(int z, int x, int y, {String? apiKey}) {
String url = urlTemplate
String url = urlTemplate;
// Handle Bing Maps quadkey conversion
if (url.contains('{quadkey}')) {
final quadkey = _convertToQuadkey(x, y, z);
url = url.replaceAll('{quadkey}', quadkey);
}
// Handle subdomains for load balancing
if (url.contains('{0_3}')) {
final subdomain = (x + y) % 4; // 0, 1, 2, 3
url = url.replaceAll('{0_3}', subdomain.toString());
}
if (url.contains('{1_4}')) {
final subdomain = ((x + y) % 4) + 1; // 1, 2, 3, 4
url = url.replaceAll('{1_4}', subdomain.toString());
}
// Standard x/y/z replacement
url = url
.replaceAll('{z}', z.toString())
.replaceAll('{x}', x.toString())
.replaceAll('{y}', y.toString());
@@ -33,6 +60,19 @@ class TileType {
return url;
}
/// Convert x, y, z to Bing Maps quadkey format
String _convertToQuadkey(int x, int y, int z) {
final quadkey = StringBuffer();
for (int i = z; i > 0; i--) {
int digit = 0;
final mask = 1 << (i - 1);
if ((x & mask) != 0) digit++;
if ((y & mask) != 0) digit += 2;
quadkey.write(digit);
}
return quadkey.toString();
}
/// Check if this tile type needs an API key
bool get requiresApiKey => urlTemplate.contains('{api_key}');
@@ -161,6 +201,19 @@ class DefaultTileProviders {
),
],
),
TileProvider(
id: 'bing',
name: 'Bing Maps',
tileTypes: [
TileType(
id: 'bing_satellite',
name: 'Satellite',
urlTemplate: 'https://ecn.t{0_3}.tiles.virtualearth.net/tiles/a{quadkey}.jpeg?g=1&n=z',
attribution: '© Microsoft Corporation',
maxZoom: 20,
),
],
),
TileProvider(
id: 'mapbox',
name: 'Mapbox',

View File

@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../services/localization_service.dart';
import '../widgets/welcome_dialog.dart';
import '../widgets/submission_guide_dialog.dart';
class AboutScreen extends StatelessWidget {
const AboutScreen({super.key});
@@ -74,16 +76,8 @@ class AboutScreen extends StatelessWidget {
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
// Release Notes button
Center(
child: OutlinedButton.icon(
onPressed: () {
Navigator.pushNamed(context, '/settings/release-notes');
},
icon: const Icon(Icons.article_outlined),
label: const Text('View Release Notes'),
),
),
// Information dialogs section
_buildDialogButtons(context),
const SizedBox(height: 24),
_buildHelpLinks(context),
],
@@ -190,4 +184,50 @@ class AboutScreen extends StatelessWidget {
),
);
}
Widget _buildDialogButtons(BuildContext context) {
final locService = LocalizationService.instance;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Welcome Message button
OutlinedButton.icon(
onPressed: () {
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (context) => const WelcomeDialog(showDontShowAgain: false),
);
},
icon: const Icon(Icons.waving_hand_outlined),
label: Text(locService.t('about.showWelcome')),
),
const SizedBox(height: 8),
// Submission Guide button
OutlinedButton.icon(
onPressed: () {
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (context) => const SubmissionGuideDialog(showDontShowAgain: false),
);
},
icon: const Icon(Icons.info_outline),
label: Text(locService.t('about.showSubmissionGuide')),
),
const SizedBox(height: 8),
// Release Notes button
OutlinedButton.icon(
onPressed: () {
Navigator.pushNamed(context, '/settings/release-notes');
},
icon: const Icon(Icons.article_outlined),
label: Text(locService.t('about.viewReleaseNotes')),
),
],
);
}
}

View File

@@ -318,9 +318,15 @@ class _TileTypeDialogState extends State<_TileTypeDialog> {
),
validator: (value) {
if (value?.trim().isEmpty == true) return locService.t('tileTypeEditor.urlTemplateRequired');
if (!value!.contains('{z}') || !value.contains('{x}') || !value.contains('{y}')) {
// Check for either quadkey OR x+y+z placeholders
final hasQuadkey = value!.contains('{quadkey}');
final hasXYZ = value.contains('{x}') && value.contains('{y}') && value.contains('{z}');
if (!hasQuadkey && !hasXYZ) {
return locService.t('tileTypeEditor.urlTemplatePlaceholders');
}
return null;
},
),
@@ -403,11 +409,20 @@ class _TileTypeDialogState extends State<_TileTypeDialog> {
});
try {
// Use a sample tile from configured preview location
final url = _urlController.text
.replaceAll('{z}', kPreviewTileZoom.toString())
.replaceAll('{x}', kPreviewTileX.toString())
.replaceAll('{y}', kPreviewTileY.toString());
// Create a temporary TileType to use the getTileUrl method
final tempTileType = TileType(
id: 'preview',
name: 'Preview',
urlTemplate: _urlController.text.trim(),
attribution: 'Preview',
);
final url = tempTileType.getTileUrl(
kPreviewTileZoom,
kPreviewTileX,
kPreviewTileY,
apiKey: null, // Don't use API key for preview
);
final response = await http.get(Uri.parse(url));

View File

@@ -13,6 +13,7 @@ class ChangelogService {
static const String _lastSeenVersionKey = 'last_seen_version';
static const String _hasSeenWelcomeKey = 'has_seen_welcome';
static const String _hasSeenSubmissionGuideKey = 'has_seen_submission_guide';
Map<String, dynamic>? _changelogData;
bool _initialized = false;
@@ -67,6 +68,18 @@ class ChangelogService {
await prefs.setBool(_hasSeenWelcomeKey, true);
}
/// Check if user has seen the submission guide popup
Future<bool> hasSeenSubmissionGuide() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_hasSeenSubmissionGuideKey) ?? false;
}
/// Mark that user has seen the submission guide popup
Future<void> markSubmissionGuideSeen() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_hasSeenSubmissionGuideKey, true);
}
/// Check if app version has changed since last launch
Future<bool> hasVersionChanged() async {
final prefs = await SharedPreferences.getInstance();

View File

@@ -9,7 +9,7 @@ import 'package:deflockapp/dev_config.dart';
import '../network_status.dart';
/// Global semaphore to limit simultaneous tile fetches
final _tileFetchSemaphore = _SimpleSemaphore(4); // Max 4 concurrent
final _tileFetchSemaphore = _SimpleSemaphore(kTileFetchConcurrentThreads);
/// Clear queued tile requests when map view changes significantly
void clearRemoteTileQueue() {
@@ -93,15 +93,15 @@ bool _isTileVisible(int z, int x, int y, LatLngBounds viewBounds) {
/// Fetches a tile from any remote provider, with in-memory retries/backoff, and global concurrency limit.
/// Returns tile image bytes, or throws on persistent failure.
/// Fetches a tile from any remote provider with unlimited retries.
/// Returns tile image bytes. Retries forever until success.
/// Brutalist approach: Keep trying until it works - no arbitrary retry limits.
Future<List<int>> fetchRemoteTile({
required int z,
required int x,
required int y,
required String url,
}) async {
const int maxAttempts = kTileFetchMaxAttempts;
int attempt = 0;
final random = Random();
final hostInfo = Uri.parse(url).host; // For logging
@@ -109,20 +109,23 @@ Future<List<int>> fetchRemoteTile({
while (true) {
await _tileFetchSemaphore.acquire(z: z, x: x, y: y);
try {
// Only log on first attempt or errors
if (attempt == 1) {
// Only log on first attempt
if (attempt == 0) {
debugPrint('[fetchRemoteTile] Fetching $z/$x/$y from $hostInfo');
}
attempt++;
final resp = await http.get(Uri.parse(url));
if (resp.statusCode == 200 && resp.bodyBytes.isNotEmpty) {
// Success - no logging for normal operation
NetworkStatus.instance.reportOsmTileSuccess(); // Generic tile server reporting
// Success!
if (attempt > 1) {
debugPrint('[fetchRemoteTile] SUCCESS $z/$x/$y from $hostInfo after $attempt attempts');
}
NetworkStatus.instance.reportOsmTileSuccess();
return resp.bodyBytes;
} else {
debugPrint('[fetchRemoteTile] FAIL $z/$x/$y from $hostInfo: code=${resp.statusCode}, bytes=${resp.bodyBytes.length}');
NetworkStatus.instance.reportOsmTileIssue(); // Generic tile server reporting
NetworkStatus.instance.reportOsmTileIssue();
throw HttpException('Failed to fetch tile $z/$x/$y from $hostInfo: status ${resp.statusCode}');
}
} catch (e) {
@@ -130,17 +133,16 @@ Future<List<int>> fetchRemoteTile({
if (e.toString().contains('Connection refused') ||
e.toString().contains('Connection timed out') ||
e.toString().contains('Connection reset')) {
NetworkStatus.instance.reportOsmTileIssue(); // Generic tile server reporting
}
if (attempt >= maxAttempts) {
debugPrint("[fetchRemoteTile] Failed for $z/$x/$y from $hostInfo after $attempt attempts: $e");
rethrow;
NetworkStatus.instance.reportOsmTileIssue();
}
// Calculate delay and retry (no attempt limit - keep trying forever)
final delay = _calculateRetryDelay(attempt, random);
if (attempt == 1) {
debugPrint("[fetchRemoteTile] Attempt $attempt for $z/$x/$y from $hostInfo failed: $e. Retrying in ${delay}ms.");
} else if (attempt % 10 == 0) {
// Log every 10th attempt to show we're still working
debugPrint("[fetchRemoteTile] Still trying $z/$x/$y from $hostInfo (attempt $attempt). Retrying in ${delay}ms.");
}
await Future.delayed(Duration(milliseconds: delay));
} finally {

View File

@@ -7,8 +7,10 @@ import '../models/node_profile.dart';
import '../models/operator_profile.dart';
import '../services/localization_service.dart';
import '../services/node_cache.dart';
import '../services/changelog_service.dart';
import 'refine_tags_sheet.dart';
import 'proximity_warning_dialog.dart';
import 'submission_guide_dialog.dart';
class AddNodeSheet extends StatelessWidget {
const AddNodeSheet({super.key, required this.session});
@@ -16,6 +18,27 @@ class AddNodeSheet extends StatelessWidget {
final AddNodeSession session;
void _checkProximityAndCommit(BuildContext context, AppState appState, LocalizationService locService) {
_checkSubmissionGuideAndProceed(context, appState, locService);
}
void _checkSubmissionGuideAndProceed(BuildContext context, AppState appState, LocalizationService locService) async {
// Check if user has seen the submission guide
final hasSeenGuide = await ChangelogService().hasSeenSubmissionGuide();
if (!hasSeenGuide) {
// Show submission guide dialog first
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (context) => const SubmissionGuideDialog(),
);
}
// Now proceed with proximity check
_checkProximityOnly(context, appState, locService);
}
void _checkProximityOnly(BuildContext context, AppState appState, LocalizationService locService) {
// Only check proximity if we have a target location
if (session.target == null) {
_commitWithoutCheck(context, appState, locService);

View File

@@ -7,10 +7,12 @@ import '../models/node_profile.dart';
import '../models/operator_profile.dart';
import '../services/localization_service.dart';
import '../services/node_cache.dart';
import '../services/changelog_service.dart';
import '../state/settings_state.dart';
import 'refine_tags_sheet.dart';
import 'advanced_edit_options_sheet.dart';
import 'proximity_warning_dialog.dart';
import 'submission_guide_dialog.dart';
class EditNodeSheet extends StatelessWidget {
const EditNodeSheet({super.key, required this.session});
@@ -18,6 +20,27 @@ class EditNodeSheet extends StatelessWidget {
final EditNodeSession session;
void _checkProximityAndCommit(BuildContext context, AppState appState, LocalizationService locService) {
_checkSubmissionGuideAndProceed(context, appState, locService);
}
void _checkSubmissionGuideAndProceed(BuildContext context, AppState appState, LocalizationService locService) async {
// Check if user has seen the submission guide
final hasSeenGuide = await ChangelogService().hasSeenSubmissionGuide();
if (!hasSeenGuide) {
// Show submission guide dialog first
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (context) => const SubmissionGuideDialog(),
);
}
// Now proceed with proximity check
_checkProximityOnly(context, appState, locService);
}
void _checkProximityOnly(BuildContext context, AppState appState, LocalizationService locService) {
// Check for nearby nodes within the configured distance, excluding the node being edited
final nearbyNodes = NodeCache.instance.findNodesWithinDistance(
session.target,

View File

@@ -0,0 +1,185 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../services/changelog_service.dart';
import '../services/localization_service.dart';
class SubmissionGuideDialog extends StatefulWidget {
const SubmissionGuideDialog({super.key, this.showDontShowAgain = true});
final bool showDontShowAgain;
@override
State<SubmissionGuideDialog> createState() => _SubmissionGuideDialogState();
}
class _SubmissionGuideDialogState extends State<SubmissionGuideDialog> {
bool _dontShowAgain = false;
bool _isInitialized = false;
Future<void> _launchUrl(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
@override
void initState() {
super.initState();
_loadCurrentState();
}
Future<void> _loadCurrentState() async {
if (!widget.showDontShowAgain) {
// When manually opened, show the actual current state
final hasSeenSubmissionGuide = await ChangelogService().hasSeenSubmissionGuide();
setState(() {
_dontShowAgain = hasSeenSubmissionGuide;
_isInitialized = true;
});
} else {
setState(() {
_isInitialized = true;
});
}
}
void _onClose() async {
if (_dontShowAgain && widget.showDontShowAgain) {
await ChangelogService().markSubmissionGuideSeen();
}
if (mounted) {
Navigator.of(context).pop();
}
}
@override
Widget build(BuildContext context) {
final locService = LocalizationService.instance;
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) => AlertDialog(
title: Text(locService.t('submissionGuide.title')),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Scrollable content
Flexible(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('submissionGuide.description'),
style: const TextStyle(fontSize: 14),
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.withOpacity(0.3)),
),
child: Text(
locService.t('submissionGuide.bestPractices'),
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
),
),
const SizedBox(height: 12),
Text(
locService.t('submissionGuide.placementNote'),
style: const TextStyle(fontSize: 13, fontStyle: FontStyle.italic),
),
const SizedBox(height: 16),
Text(
locService.t('submissionGuide.moreInfo'),
style: const TextStyle(fontSize: 13),
),
const SizedBox(height: 16),
// Resource links row
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildLinkButton(
locService.t('submissionGuide.identificationGuide'),
'https://deflock.me/identify'
),
_buildLinkButton(
locService.t('submissionGuide.osmWiki'),
'https://wiki.openstreetmap.org/wiki/Tag:man_made%3Dsurveillance'
),
],
),
],
),
),
),
const SizedBox(height: 16),
// Always visible checkbox, but disabled when manually opened
if (_isInitialized)
Row(
children: [
Checkbox(
value: _dontShowAgain,
onChanged: widget.showDontShowAgain ? (value) {
setState(() {
_dontShowAgain = value ?? false;
});
} : null,
),
Expanded(
child: Text(
locService.t('submissionGuide.dontShowAgain'),
style: TextStyle(
fontSize: 13,
color: widget.showDontShowAgain
? null
: Theme.of(context).disabledColor,
),
),
),
],
),
],
),
actions: [
TextButton(
onPressed: _onClose,
child: Text(locService.t('submissionGuide.gotIt')),
),
],
),
);
}
Widget _buildLinkButton(String text, String url) {
return Flexible(
child: GestureDetector(
onTap: () => _launchUrl(url),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: Theme.of(context).colorScheme.primary.withOpacity(0.3),
),
),
child: Text(
text,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.primary,
),
textAlign: TextAlign.center,
),
),
),
);
}
}

View File

@@ -4,7 +4,9 @@ import '../services/changelog_service.dart';
import '../services/localization_service.dart';
class WelcomeDialog extends StatefulWidget {
const WelcomeDialog({super.key});
const WelcomeDialog({super.key, this.showDontShowAgain = true});
final bool showDontShowAgain;
@override
State<WelcomeDialog> createState() => _WelcomeDialogState();
@@ -12,6 +14,7 @@ class WelcomeDialog extends StatefulWidget {
class _WelcomeDialogState extends State<WelcomeDialog> {
bool _dontShowAgain = false;
bool _isInitialized = false;
Future<void> _launchUrl(String url) async {
final uri = Uri.parse(url);
@@ -20,8 +23,29 @@ class _WelcomeDialogState extends State<WelcomeDialog> {
}
}
@override
void initState() {
super.initState();
_loadCurrentState();
}
Future<void> _loadCurrentState() async {
if (!widget.showDontShowAgain) {
// When manually opened, show the actual current state
final hasSeenWelcome = await ChangelogService().hasSeenWelcome();
setState(() {
_dontShowAgain = hasSeenWelcome;
_isInitialized = true;
});
} else {
setState(() {
_isInitialized = true;
});
}
}
void _onClose() async {
if (_dontShowAgain) {
if (_dontShowAgain && widget.showDontShowAgain) {
await ChangelogService().markWelcomeSeen();
}
@@ -103,25 +127,31 @@ class _WelcomeDialogState extends State<WelcomeDialog> {
),
),
const SizedBox(height: 16),
// Always visible checkbox at the bottom
Row(
children: [
Checkbox(
value: _dontShowAgain,
onChanged: (value) {
setState(() {
_dontShowAgain = value ?? false;
});
},
),
Expanded(
child: Text(
locService.t('welcome.dontShowAgain'),
style: const TextStyle(fontSize: 13),
// Always visible checkbox, but disabled when manually opened
if (_isInitialized)
Row(
children: [
Checkbox(
value: _dontShowAgain,
onChanged: widget.showDontShowAgain ? (value) {
setState(() {
_dontShowAgain = value ?? false;
});
} : null,
),
),
],
),
Expanded(
child: Text(
locService.t('welcome.dontShowAgain'),
style: TextStyle(
fontSize: 13,
color: widget.showDontShowAgain
? null
: Theme.of(context).disabledColor,
),
),
),
],
),
],
),
actions: [

View File

@@ -1,7 +1,7 @@
name: deflockapp
description: Map public surveillance infrastructure with OpenStreetMap
publish_to: "none"
version: 1.4.5+16 # The thing after the + is the version code, incremented with each release
version: 1.5.1+19 # 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+

View File

@@ -0,0 +1,137 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flock_map_app/models/tile_provider.dart';
void main() {
group('TileType', () {
test('getTileUrl handles standard x/y/z replacement', () {
final tileType = TileType(
id: 'test',
name: 'Test',
urlTemplate: 'https://example.com/{z}/{x}/{y}.png',
attribution: 'Test',
);
final url = tileType.getTileUrl(3, 2, 1);
expect(url, 'https://example.com/3/2/1.png');
});
test('getTileUrl handles subdomain patterns', () {
final tileType0_3 = TileType(
id: 'test_0_3',
name: 'Test 0-3',
urlTemplate: 'https://s{0_3}.example.com/{z}/{x}/{y}.png',
attribution: 'Test',
);
final tileType1_4 = TileType(
id: 'test_1_4',
name: 'Test 1-4',
urlTemplate: 'https://s{1_4}.example.com/{z}/{x}/{y}.png',
attribution: 'Test',
);
// Test 0-3 range
final url_0_3_a = tileType0_3.getTileUrl(1, 0, 0);
final url_0_3_b = tileType0_3.getTileUrl(1, 3, 0);
expect(url_0_3_a, contains('s0.example.com'));
expect(url_0_3_b, contains('s3.example.com'));
// Test 1-4 range
final url_1_4_a = tileType1_4.getTileUrl(1, 0, 0);
final url_1_4_b = tileType1_4.getTileUrl(1, 3, 0);
expect(url_1_4_a, contains('s1.example.com'));
expect(url_1_4_b, contains('s4.example.com'));
// Test consistency
final url1 = tileType0_3.getTileUrl(1, 2, 3);
final url2 = tileType0_3.getTileUrl(1, 2, 3);
expect(url1, url2); // Same input should give same output
});
test('getTileUrl handles Bing Maps quadkey conversion', () {
final tileType = TileType(
id: 'bing_test',
name: 'Bing Test',
urlTemplate: 'https://ecn.t{subdomain}.tiles.virtualearth.net/tiles/a{quadkey}.jpeg?g=1&n=z',
attribution: 'Microsoft',
);
// Test some known quadkey conversions
// x=0, y=0, z=1 should give quadkey "0"
final url1 = tileType.getTileUrl(1, 0, 0);
expect(url1, contains('a0.jpeg'));
// x=1, y=0, z=1 should give quadkey "1"
final url2 = tileType.getTileUrl(1, 1, 0);
expect(url2, contains('a1.jpeg'));
// x=0, y=1, z=1 should give quadkey "2"
final url3 = tileType.getTileUrl(1, 0, 1);
expect(url3, contains('a2.jpeg'));
// x=1, y=1, z=1 should give quadkey "3"
final url4 = tileType.getTileUrl(1, 1, 1);
expect(url4, contains('a3.jpeg'));
// More complex example: x=3, y=5, z=3 should give quadkey "213"
final url5 = tileType.getTileUrl(3, 3, 5);
expect(url5, contains('a213.jpeg'));
});
test('getTileUrl handles API key replacement', () {
final tileType = TileType(
id: 'test',
name: 'Test',
urlTemplate: 'https://api.example.com/{z}/{x}/{y}?key={api_key}',
attribution: 'Test',
);
final url = tileType.getTileUrl(1, 2, 3, apiKey: 'mykey123');
expect(url, 'https://api.example.com/1/2/3?key=mykey123');
});
test('requiresApiKey detects API key requirement correctly', () {
final tileTypeWithKey = TileType(
id: 'test1',
name: 'Test 1',
urlTemplate: 'https://api.example.com/{z}/{x}/{y}?key={api_key}',
attribution: 'Test',
);
final tileTypeWithoutKey = TileType(
id: 'test2',
name: 'Test 2',
urlTemplate: 'https://example.com/{z}/{x}/{y}.png',
attribution: 'Test',
);
expect(tileTypeWithKey.requiresApiKey, isTrue);
expect(tileTypeWithoutKey.requiresApiKey, isFalse);
});
});
group('DefaultTileProviders', () {
test('contains Bing satellite provider', () {
final providers = DefaultTileProviders.createDefaults();
final bingProvider = providers.firstWhere((p) => p.id == 'bing');
expect(bingProvider.name, 'Bing Maps');
expect(bingProvider.tileTypes, hasLength(1));
final satelliteType = bingProvider.tileTypes.first;
expect(satelliteType.id, 'bing_satellite');
expect(satelliteType.name, 'Satellite');
expect(satelliteType.urlTemplate, contains('quadkey'));
expect(satelliteType.urlTemplate, contains('0_3'));
expect(satelliteType.requiresApiKey, isFalse);
expect(satelliteType.attribution, '© Microsoft Corporation');
});
test('all default providers are usable', () {
final providers = DefaultTileProviders.createDefaults();
for (final provider in providers) {
expect(provider.isUsable, isTrue, reason: '${provider.name} should be usable');
}
});
});
}