mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-02-12 16:52:51 +00:00
Merge pull request #13 from FoggedLens/deflock-rebrand
Deflock rebrand, localizations
This commit is contained in:
54
README.md
54
README.md
@@ -1,21 +1,21 @@
|
||||
# Flock Map App
|
||||
# DeFlock
|
||||
|
||||
A comprehensive Flutter app for viewing and mapping surveillance cameras with OpenStreetMap. Includes offline capabilities, editing ability, and an intuitive interface.
|
||||
A comprehensive Flutter app for mapping public surveillance infrastructure with OpenStreetMap. Includes offline capabilities, editing ability, and an intuitive interface.
|
||||
|
||||
**Stop Flock** is a privacy-focused initiative to document the rapid expansion of ALPRs and AI surveillance cameras. This app aims to be the go-to tool for contributors to map cameras in their communities and upload the data to OpenStreetMap, making surveillance infrastructure visible and searchable.
|
||||
**DeFlock** is a privacy-focused initiative to document the rapid expansion of ALPRs, AI surveillance cameras, and other public surveillance infrastructure. This app aims to be the go-to tool for contributors to map surveillance devices in their communities and upload the data to OpenStreetMap, making surveillance infrastructure visible and searchable.
|
||||
|
||||
**For complete documentation, tutorials, and community info, visit [stopflock.com/app](https://stopflock.com/app)**
|
||||
**For complete documentation, tutorials, and community info, visit [deflock.me](https://deflock.me)**
|
||||
|
||||
---
|
||||
|
||||
## What This App Does
|
||||
|
||||
- **Map surveillance cameras** with precise location, direction, and manufacturer details
|
||||
- **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 camera data, plus upload queue
|
||||
- **Work completely offline** with downloadable map areas and device data, plus upload queue
|
||||
- **Multiple map types** including satellite imagery from Google, Esri, Mapbox, and OpenStreetMap, plus custom map tile provider support
|
||||
- **Editing Ability** to update existing camera locations and properties
|
||||
- **Built-in camera profiles** for Flock Safety, Motorola, Genetec, Leonardo, and other major manufacturers, plus custom profiles for more specific tag sets
|
||||
- **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
|
||||
|
||||
---
|
||||
|
||||
@@ -25,13 +25,13 @@ A comprehensive Flutter app for viewing and mapping surveillance cameras with Op
|
||||
- **Multi-source tiles**: Switch between OpenStreetMap, Google Satellite, Esri imagery, Mapbox, and any custom providers
|
||||
- **Offline-first design**: Download a region for complete offline operation
|
||||
- **Smooth UX**: Intuitive controls, follow-me mode with GPS rotation, and gesture-friendly interactions
|
||||
- **Camera visualization**: Color-coded markers showing real cameras (blue), pending uploads (purple), new cameras (white), edited cameras (grey), and cameras being edited (orange)
|
||||
- **Device visualization**: Color-coded markers showing real devices (blue), pending uploads (purple), new devices (white), edited devices (grey), and devices being edited (orange)
|
||||
|
||||
### Camera Management
|
||||
- **Comprehensive profiles**: Built-in profiles for major manufacturers (Flock, Motorola/Vigilant, Genetec, Leonardo/ELSAG, Neology) plus custom profile creation
|
||||
- **Editing capabilities**: Update location, direction, and tags of existing cameras
|
||||
### Device Management
|
||||
- **Comprehensive profiles**: Built-in profiles for major manufacturers (Flock Safety, Motorola/Vigilant, Genetec, Leonardo/ELSAG, Neology) plus custom profile creation
|
||||
- **Editing capabilities**: Update location, direction, and tags of existing devices
|
||||
- **Direction visualization**: Interactive field-of-view cones showing camera viewing angles
|
||||
- **Bulk operations**: Tag multiple cameras efficiently with profile-based workflow
|
||||
- **Bulk operations**: Tag multiple devices efficiently with profile-based workflow
|
||||
|
||||
### Professional Upload & Sync
|
||||
- **OpenStreetMap integration**: Direct upload with full OAuth2 authentication
|
||||
@@ -41,7 +41,7 @@ A comprehensive Flutter app for viewing and mapping surveillance cameras with Op
|
||||
|
||||
### Offline Operations
|
||||
- **Smart area downloads**: Automatically calculate tile counts and storage requirements
|
||||
- **Camera caching**: Offline areas include camera data for complete functionality without network
|
||||
- **Device caching**: Offline areas include surveillance device data for complete functionality without network
|
||||
- **Global base map**: Permanent worldwide coverage at low zoom levels
|
||||
- **Robust downloads**: Exponential backoff, retry logic, and progress tracking for reliable area downloads
|
||||
|
||||
@@ -50,18 +50,18 @@ A comprehensive Flutter app for viewing and mapping surveillance cameras with Op
|
||||
## Quick Start
|
||||
|
||||
1. **Install** the app on iOS or Android
|
||||
2. **Enable location** and grant camera permissions
|
||||
2. **Enable location** permissions
|
||||
3. **Log into OpenStreetMap**: Choose upload mode and get OAuth2 credentials
|
||||
4. **Add your first camera**: Tap the "tag camera" button, position the pin, set direction, select a profile, and tap submit
|
||||
4. **Add your first device**: Tap the "tag node" button, position the pin, set direction, select a profile, and tap submit
|
||||
|
||||
**New to OpenStreetMap?** Visit [stopflock.com/app](https://stopflock.com/app) for complete setup instructions and community guidelines.
|
||||
**New to OpenStreetMap?** Visit [deflock.me](https://deflock.me) for complete setup instructions and community guidelines.
|
||||
|
||||
---
|
||||
|
||||
## For Developers
|
||||
|
||||
### Architecture Highlights
|
||||
- **Unified data provider**: All map tiles and camera data route through `MapDataProvider` with pluggable remote/local sources
|
||||
- **Unified data provider**: All map tiles and surveillance device data route through `MapDataProvider` with pluggable remote/local sources
|
||||
- **Modular settings**: Each settings section is a separate widget for maintainability
|
||||
- **State management**: Provider pattern with clean separation of concerns
|
||||
- **Offline-first**: Network calls are optional; app functions fully offline with downloaded data and queues uploads until online
|
||||
@@ -93,26 +93,26 @@ flutter run
|
||||
- Optional custom icons for camera profiles
|
||||
- Camera deletions
|
||||
- Clean up cache when submitted changesets appear in Overpass results
|
||||
- Upgrade camera marker design (considering nullplate's svg)
|
||||
- Upgrade device marker design (considering nullplate's svg)
|
||||
|
||||
### Future Features & Wishlist
|
||||
- Location-based notifications when approaching cameras
|
||||
- Red/yellow ring for cameras missing specific tag details
|
||||
- Location-based notifications when approaching surveillance devices
|
||||
- Red/yellow ring for devices missing specific tag details
|
||||
- iOS/Android native themes and dark mode support
|
||||
- "Cache accumulating" offline areas?
|
||||
- "Offline areas" as tile provider?
|
||||
- Jump to location by coordinates, address, or POI name
|
||||
- Route planning that avoids surveillance cameras
|
||||
- Custom camera providers and OSM/Overpass alternatives
|
||||
- Route planning that avoids surveillance devices
|
||||
- Custom device providers and OSM/Overpass alternatives
|
||||
|
||||
---
|
||||
|
||||
## Contributing & Community
|
||||
|
||||
This app is part of the larger **Stop Flock** initiative. Join the community:
|
||||
This app is part of the larger **DeFlock** initiative. Join the community:
|
||||
|
||||
- **Documentation & Guides**: [stopflock.com/app](https://stopflock.com/app)
|
||||
- **Community Discussion**: [stopflock.com](https://stopflock.com)
|
||||
- **Documentation & Guides**: [deflock.me](https://deflock.me)
|
||||
- **Community Discussion**: [deflock.me](https://deflock.me)
|
||||
- **Issues & Feature Requests**: GitHub Issues
|
||||
- **Development**: See developer setup above
|
||||
|
||||
@@ -120,7 +120,7 @@ This app is part of the larger **Stop Flock** initiative. Join the community:
|
||||
|
||||
## Privacy & Ethics
|
||||
|
||||
This project helps make existing public surveillance infrastructure transparent and searchable. We only document cameras that are already installed and visible in public spaces.
|
||||
This project helps make existing public surveillance infrastructure transparent and searchable. We only document surveillance devices that are already installed and visible in public spaces.
|
||||
|
||||
No user information is ever collected, and no data leaves your device except submissions to OSM and whatever data your tile provider can glean from your requests.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ plugins {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.flock_map_app"
|
||||
namespace = "me.deflock.deflockapp"
|
||||
|
||||
// Matches current stable Flutter (compileSdk 34 as of July 2025)
|
||||
compileSdk = 35
|
||||
@@ -24,7 +24,7 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
// Application ID (package name)
|
||||
applicationId = "com.example.flock_map_app"
|
||||
applicationId = "me.deflock.deflockapp"
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// oauth2_client 4.x & flutter_web_auth_2 5.x require minSdk 23
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
<application
|
||||
android:name="${applicationName}"
|
||||
android:label="flock_map_app"
|
||||
android:label="DeFlock"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:enableOnBackInvokedCallback="true">
|
||||
<!-- Main Flutter activity -->
|
||||
@@ -17,7 +17,6 @@
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:hardwareAccelerated="true"
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
@@ -44,8 +43,7 @@
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<!-- flockmap://auth -->
|
||||
<data android:scheme="flockmap" android:host="auth"/>
|
||||
<data android:scheme="deflockapp" android:host="auth"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
package com.example.flock_map_app
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
@@ -0,0 +1,5 @@
|
||||
package me.deflock.deflockapp
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
27
assets/deflock-logo.svg
Normal file
27
assets/deflock-logo.svg
Normal file
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 1150 300" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<rect id="Artboard1" x="0" y="0" width="1150" height="300" style="fill:none;"/>
|
||||
<g id="Artboard11" serif:id="Artboard1">
|
||||
<g>
|
||||
<g transform="matrix(344.475,0,0,344.475,30.1181,267.042)">
|
||||
<path d="M0.377,-0.658L0.377,-0.655C0.421,-0.629 0.415,-0.593 0.415,-0.547L0.415,-0.415C0.373,-0.452 0.317,-0.473 0.261,-0.473C0.124,-0.473 0.024,-0.364 0.024,-0.229C0.024,-0.08 0.131,0.013 0.277,0.013C0.295,0.013 0.312,0.013 0.329,0.008L0.388,-0.082C0.361,-0.065 0.334,-0.053 0.302,-0.053C0.197,-0.053 0.125,-0.142 0.125,-0.243C0.125,-0.334 0.19,-0.407 0.27,-0.407C0.323,-0.407 0.374,-0.383 0.399,-0.335C0.418,-0.298 0.415,-0.254 0.415,-0.214L0.415,-0L0.544,-0L0.544,-0.003C0.5,-0.027 0.506,-0.064 0.506,-0.11L0.506,-0.674L0.503,-0.674C0.492,-0.658 0.468,-0.658 0.445,-0.658L0.377,-0.658Z" style="fill:rgb(0,128,188);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(344.475,0,0,344.475,229.914,267.042)">
|
||||
<path d="M0.5,-0.246C0.504,-0.375 0.411,-0.473 0.275,-0.473C0.126,-0.473 0.025,-0.372 0.025,-0.233C0.025,-0.094 0.142,0.013 0.312,0.013C0.359,0.013 0.407,0.006 0.45,-0.012L0.5,-0.106L0.497,-0.106C0.451,-0.07 0.393,-0.053 0.333,-0.053C0.22,-0.053 0.135,-0.124 0.133,-0.246L0.5,-0.246ZM0.137,-0.304C0.149,-0.367 0.199,-0.407 0.266,-0.407C0.338,-0.407 0.384,-0.374 0.395,-0.304L0.137,-0.304Z" style="fill:rgb(0,128,188);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(344.475,0,0,344.475,409.04,267.042)">
|
||||
<path d="M0.023,-0.394L0.071,-0.394L0.071,-0.11C0.071,-0.064 0.077,-0.027 0.033,-0.003L0.033,-0L0.2,-0L0.2,-0.003C0.156,-0.028 0.162,-0.064 0.162,-0.11L0.162,-0.394L0.264,-0.394C0.276,-0.394 0.291,-0.391 0.295,-0.38L0.298,-0.38L0.298,-0.46L0.162,-0.46C0.162,-0.56 0.157,-0.608 0.249,-0.608C0.278,-0.608 0.308,-0.603 0.333,-0.59L0.333,-0.11C0.333,-0.064 0.339,-0.027 0.295,-0.003L0.295,-0L0.462,-0L0.462,-0.003C0.418,-0.027 0.424,-0.064 0.424,-0.11L0.424,-0.674L0.421,-0.674C0.411,-0.663 0.394,-0.656 0.378,-0.656C0.347,-0.656 0.319,-0.674 0.266,-0.674C0.206,-0.674 0.148,-0.654 0.107,-0.608C0.068,-0.564 0.071,-0.525 0.071,-0.46L0.023,-0.394Z" style="fill:rgb(86,86,86);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(344.475,0,0,344.475,581.278,267.042)">
|
||||
<path d="M0.276,0.013C0.417,0.013 0.537,-0.091 0.537,-0.235C0.537,-0.303 0.506,-0.369 0.455,-0.414C0.407,-0.456 0.352,-0.473 0.288,-0.473C0.144,-0.473 0.023,-0.376 0.023,-0.226C0.023,-0.084 0.139,0.013 0.276,0.013ZM0.281,-0.053C0.179,-0.053 0.124,-0.152 0.124,-0.244C0.124,-0.334 0.184,-0.407 0.277,-0.407C0.384,-0.407 0.436,-0.311 0.436,-0.214C0.436,-0.124 0.373,-0.053 0.281,-0.053Z" style="fill:rgb(86,86,86);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(344.475,0,0,344.475,774.184,267.042)">
|
||||
<path d="M0.415,-0.461C0.38,-0.469 0.343,-0.473 0.307,-0.473C0.156,-0.473 0.022,-0.39 0.022,-0.218C0.022,-0.088 0.142,0.013 0.296,0.013C0.34,0.013 0.386,0.009 0.428,-0.007L0.48,-0.102L0.477,-0.102C0.438,-0.073 0.382,-0.053 0.331,-0.053C0.22,-0.053 0.123,-0.129 0.123,-0.244C0.123,-0.339 0.193,-0.407 0.29,-0.407C0.335,-0.407 0.383,-0.391 0.412,-0.358L0.415,-0.358L0.415,-0.461Z" style="fill:rgb(86,86,86);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(344.475,0,0,344.475,932.642,267.042)">
|
||||
<path d="M0.029,-0.658L0.029,-0.655C0.072,-0.63 0.066,-0.593 0.066,-0.547L0.066,-0.111C0.066,-0.065 0.072,-0.028 0.029,-0.003L0.029,-0L0.196,-0L0.196,-0.003C0.151,-0.028 0.157,-0.065 0.157,-0.111L0.157,-0.674L0.154,-0.674C0.141,-0.659 0.117,-0.658 0.095,-0.658L0.029,-0.658ZM0.324,-0.056C0.343,-0.029 0.368,-0 0.426,-0L0.504,-0C0.459,-0.028 0.429,-0.071 0.398,-0.112L0.276,-0.267L0.443,-0.46L0.291,-0.46L0.291,-0.457C0.301,-0.451 0.31,-0.442 0.31,-0.428C0.31,-0.403 0.274,-0.365 0.259,-0.348L0.176,-0.257L0.324,-0.056Z" style="fill:rgb(86,86,86);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
@@ -1,7 +1,73 @@
|
||||
Flock Map App
|
||||
🇺🇸 ENGLISH
|
||||
|
||||
Built with Flutter.
|
||||
DeFlock - Surveillance Transparency
|
||||
|
||||
Offline areas, privacy-respecting, designed for OpenStreetMap camera tagging.
|
||||
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.
|
||||
|
||||
This text is loaded from assets/info.txt.
|
||||
• Offline-capable mapping with downloadable areas
|
||||
• Upload directly to OpenStreetMap with OAuth2
|
||||
• Built-in profiles for major manufacturers
|
||||
• Privacy-respecting - no user data collected
|
||||
• Multiple map tile providers (OSM, satellite imagery)
|
||||
|
||||
Part of the broader DeFlock initiative to promote surveillance transparency.
|
||||
|
||||
Visit: deflock.me
|
||||
Built with Flutter • Open Source
|
||||
|
||||
---
|
||||
|
||||
🇪🇸 ESPAÑOL
|
||||
|
||||
DeFlock - Transparencia en Vigilancia
|
||||
|
||||
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.
|
||||
|
||||
• Mapeo con capacidad offline con áreas descargables
|
||||
• Subida directa a OpenStreetMap con OAuth2
|
||||
• Perfiles integrados para fabricantes principales
|
||||
• Respeta la privacidad - no se recopilan datos del usuario
|
||||
• Múltiples proveedores de mapas (OSM, imágenes satelitales)
|
||||
|
||||
Parte de la iniciativa más amplia DeFlock para promover la transparencia en vigilancia.
|
||||
|
||||
Visita: deflock.me
|
||||
Construido con Flutter • Código Abierto
|
||||
|
||||
---
|
||||
|
||||
🇫🇷 FRANÇAIS
|
||||
|
||||
DeFlock - Transparence de la Surveillance
|
||||
|
||||
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.
|
||||
|
||||
• Cartographie hors ligne avec zones téléchargeables
|
||||
• Upload direct vers OpenStreetMap avec OAuth2
|
||||
• Profils intégrés pour les principaux fabricants
|
||||
• Respectueux de la confidentialité - aucune donnée utilisateur collectée
|
||||
• Multiples fournisseurs de cartes (OSM, imagerie satellite)
|
||||
|
||||
Partie de l'initiative plus large DeFlock pour promouvoir la transparence de la surveillance.
|
||||
|
||||
Visitez : deflock.me
|
||||
Construit avec Flutter • Source Ouverte
|
||||
|
||||
---
|
||||
|
||||
🇩🇪 DEUTSCH
|
||||
|
||||
DeFlock - Überwachungs-Transparenz
|
||||
|
||||
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.
|
||||
|
||||
• Offline-fähige Kartierung mit herunterladbaren Bereichen
|
||||
• Direkter Upload zu OpenStreetMap mit OAuth2
|
||||
• Integrierte Profile für große Hersteller
|
||||
• Datenschutzfreundlich - keine Nutzerdaten gesammelt
|
||||
• Multiple Kartenanbieter (OSM, Satellitenbilder)
|
||||
|
||||
Teil der breiteren DeFlock-Initiative zur Förderung von Überwachungstransparenz.
|
||||
|
||||
Besuchen Sie: deflock.me
|
||||
Gebaut mit Flutter • Open Source
|
||||
|
||||
@@ -477,7 +477,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.flockMapApp;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = me.deflock.deflockapp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -494,7 +494,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.flockMapApp.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = me.deflock.deflockapp.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Flock Map App</string>
|
||||
<string>DeFlock</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
@@ -13,7 +13,7 @@
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>flock_map_app</string>
|
||||
<string>deflockapp</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
@@ -55,7 +55,7 @@
|
||||
<string>None</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>flockmap</string>
|
||||
<string>deflockapp</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
|
||||
@@ -25,8 +25,8 @@ const double kScaleBarBottomOffset = 170.0;
|
||||
const double kAddPinYOffset = 0.0;
|
||||
|
||||
// Client name and version for OSM uploads ("created_by" tag)
|
||||
const String kClientName = 'FlockMap';
|
||||
const String kClientVersion = '0.9.7';
|
||||
const String kClientName = 'DeFlock';
|
||||
const String kClientVersion = '0.9.8';
|
||||
|
||||
// Marker/node interaction
|
||||
const int kCameraMinZoomLevel = 10; // Minimum zoom to show nodes or warning
|
||||
@@ -66,4 +66,4 @@ const Color kCameraRingColorReal = Color(0xC43F55F3); // Real nodes from OSM - b
|
||||
const Color kCameraRingColorMock = Color(0xC4FFFFFF); // Add node mock point - white
|
||||
const Color kCameraRingColorPending = Color(0xC49C27B0); // Submitted/pending nodes - purple
|
||||
const Color kCameraRingColorEditing = Color(0xC4FF9800); // Node being edited - orange
|
||||
const Color kCameraRingColorPendingEdit = Color(0xC4757575); // Original node with pending edit - grey
|
||||
const Color kCameraRingColorPendingEdit = Color(0xC4757575); // Original node with pending edit - grey
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
//
|
||||
// NEVER commit real secrets to public repos. For open source, use keys.dart.example instead.
|
||||
|
||||
const String kOsmProdClientId = 'Js6Fn3NR3HEGaD0ZIiHBQlV9LrVcHmsOsDmApHtSyuY'; // example - replace with real
|
||||
const String kOsmSandboxClientId = 'x26twxRKTZwf1a4Ha1a-wkXncBzqnJv8JwtacJope9Q'; // example - replace with real
|
||||
const String kOsmProdClientId = 'U8p_n6IjZfQiL1KtdiwbB0-o9nto6CAKz7LC2GifJzk'; // example - replace with real
|
||||
const String kOsmSandboxClientId = 'SBHWpWTKf31EdSiTApnah3Fj2rLnk2pEwBORlX0NyZI'; // example - replace with real
|
||||
|
||||
|
||||
57
lib/localizations/README.md
Normal file
57
lib/localizations/README.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# DeFlock Localizations
|
||||
|
||||
This directory contains translation files for DeFlock. Each language is a simple JSON file.
|
||||
|
||||
## Adding a New Language
|
||||
|
||||
Want to add support for your language? It's simple:
|
||||
|
||||
1. **Copy the English file**: `cp en.json your_language_code.json`
|
||||
- Use 2-letter language codes: `es` (Spanish), `fr` (French), `it` (Italian), etc.
|
||||
|
||||
2. **Edit your new file**:
|
||||
```json
|
||||
{
|
||||
"language": {
|
||||
"name": "Your Language Name" ← Change this to your language in your language
|
||||
},
|
||||
"app": {
|
||||
"title": "DeFlock" ← Keep this as-is
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "Your Translation Here",
|
||||
"download": "Your Translation Here",
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Add your language to the About screen**: Edit `assets/info.txt` and add your language section at the bottom (copy the English section and translate it)
|
||||
|
||||
4. **Submit a PR** with your JSON file and the updated about.txt. Done!
|
||||
|
||||
The new language will automatically appear in Settings → Language.
|
||||
|
||||
## Translation Rules
|
||||
|
||||
- **Only translate the values** (text after the `:`), never the keys
|
||||
- **Keep `{}` placeholders** if you see them - they get replaced with numbers/text
|
||||
- **Don't translate "DeFlock"** - it's the app name
|
||||
- **Use your language's name for itself** - "Français" not "French", "Español" not "Spanish"
|
||||
|
||||
## Current Languages
|
||||
|
||||
- `en.json` - English
|
||||
- `es.json` - Español
|
||||
- `fr.json` - Français
|
||||
- `de.json` - Deutsch
|
||||
|
||||
## Files to Update
|
||||
|
||||
For a complete translation, you'll need to touch:
|
||||
1. **`lib/localizations/xx.json`** - Main UI translations (buttons, menus, etc.)
|
||||
2. **`assets/info.txt`** - About screen content (add your language section)
|
||||
|
||||
## That's It!
|
||||
|
||||
No configuration files, no build steps, no complex setup. Add your files and it works.
|
||||
215
lib/localizations/de.json
Normal file
215
lib/localizations/de.json
Normal file
@@ -0,0 +1,215 @@
|
||||
{
|
||||
"language": {
|
||||
"name": "Deutsch"
|
||||
},
|
||||
"app": {
|
||||
"title": "DeFlock"
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "Knoten Markieren",
|
||||
"download": "Herunterladen",
|
||||
"settings": "Einstellungen",
|
||||
"edit": "Bearbeiten",
|
||||
"cancel": "Abbrechen",
|
||||
"ok": "OK",
|
||||
"close": "Schließen",
|
||||
"submit": "Senden",
|
||||
"saveEdit": "Bearbeitung Speichern",
|
||||
"clear": "Löschen"
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Verfolgung aktivieren (Norden oben)",
|
||||
"northUp": "Verfolgung aktivieren (Rotation)",
|
||||
"rotating": "Verfolgung deaktivieren"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
"language": "Sprache",
|
||||
"systemDefault": "Systemstandard",
|
||||
"aboutInfo": "Über / Informationen",
|
||||
"aboutThisApp": "Über Diese App",
|
||||
"maxNodes": "Max. geladene/angezeigte Knoten",
|
||||
"maxNodesSubtitle": "Obergrenze für die Anzahl der Knoten auf der Karte festlegen (Standard: 250).",
|
||||
"maxNodesWarning": "Sie möchten das wahrscheinlich nicht tun, es sei denn, Sie sind absolut sicher, dass Sie einen guten Grund dafür haben.",
|
||||
"offlineMode": "Offline-Modus",
|
||||
"offlineModeSubtitle": "Alle Netzwerkanfragen außer für lokale/Offline-Bereiche deaktivieren.",
|
||||
"offlineModeWarningTitle": "Aktive Downloads",
|
||||
"offlineModeWarningMessage": "Die Aktivierung des Offline-Modus bricht alle aktiven Bereichsdownloads ab. Möchten Sie fortfahren?",
|
||||
"enableOfflineMode": "Offline-Modus Aktivieren"
|
||||
},
|
||||
"node": {
|
||||
"title": "Knoten #{}",
|
||||
"tagSheetTitle": "Gerät-Tags",
|
||||
"queuedForUpload": "Knoten zum Upload eingereiht",
|
||||
"editQueuedForUpload": "Knotenbearbeitung zum Upload eingereiht"
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "Profil",
|
||||
"direction": "Richtung {}°",
|
||||
"profileNoDirectionInfo": "Dieses Profil benötigt keine Richtung.",
|
||||
"mustBeLoggedIn": "Sie müssen angemeldet sein, um neue Knoten zu übertragen. Bitte melden Sie sich über die Einstellungen an.",
|
||||
"enableSubmittableProfile": "Aktivieren Sie ein übertragbares Profil in den Einstellungen, um neue Knoten zu übertragen.",
|
||||
"profileViewOnlyWarning": "Dieses Profil ist nur zum Anzeigen der Karte gedacht. Bitte wählen Sie ein übertragbares Profil aus, um neue Knoten zu übertragen.",
|
||||
"refineTags": "Tags Verfeinern",
|
||||
"refineTagsWithProfile": "Tags Verfeinern ({})"
|
||||
},
|
||||
"editNode": {
|
||||
"title": "Knoten #{} Bearbeiten",
|
||||
"profile": "Profil",
|
||||
"direction": "Richtung {}°",
|
||||
"profileNoDirectionInfo": "Dieses Profil benötigt keine Richtung.",
|
||||
"mustBeLoggedIn": "Sie müssen angemeldet sein, um Knoten zu bearbeiten. Bitte melden Sie sich über die Einstellungen an.",
|
||||
"sandboxModeWarning": "Bearbeitungen von Produktionsknoten können nicht an die Sandbox übertragen werden. Wechseln Sie in den Produktionsmodus in den Einstellungen, um Knoten zu bearbeiten.",
|
||||
"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.",
|
||||
"refineTags": "Tags Verfeinern",
|
||||
"refineTagsWithProfile": "Tags Verfeinern ({})"
|
||||
},
|
||||
"download": {
|
||||
"title": "Kartenbereich Herunterladen",
|
||||
"maxZoomLevel": "Max. Zoom-Level",
|
||||
"storageEstimate": "Speicher-Schätzung:",
|
||||
"tilesAndSize": "{} Kacheln, {} MB",
|
||||
"minZoom": "Min. Zoom:",
|
||||
"maxRecommendedZoom": "Max. empfohlenes Zoom: Z{}",
|
||||
"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.",
|
||||
"downloadStarted": "Download gestartet! Lade Kacheln und Kameras...",
|
||||
"downloadFailed": "Download konnte nicht gestartet werden: {}"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Upload-Ziel",
|
||||
"subtitle": "Wählen Sie, wohin Kameras hochgeladen werden",
|
||||
"production": "Produktion",
|
||||
"sandbox": "Sandbox",
|
||||
"simulate": "Simulieren",
|
||||
"productionDescription": "Hochladen in die Live-OSM-Datenbank (für alle Benutzer sichtbar)",
|
||||
"sandboxDescription": "Uploads gehen an die OSM Sandbox (sicher zum Testen, wird regelmäßig zurückgesetzt).",
|
||||
"sandboxNote": "HINWEIS: Aufgrund von OpenStreetMap-Limitierungen werden Kameras, die an die Sandbox übermittelt werden, NICHT in der Karte dieser App angezeigt.",
|
||||
"simulateDescription": "Uploads simulieren (kontaktiert OSM-Server nicht)"
|
||||
},
|
||||
"auth": {
|
||||
"loggedInAs": "Angemeldet als {}",
|
||||
"loginToOSM": "Bei OpenStreetMap anmelden",
|
||||
"tapToLogout": "Zum Abmelden antippen",
|
||||
"requiredToSubmit": "Erforderlich, um Kameradaten zu übertragen",
|
||||
"loggedOut": "Abgemeldet",
|
||||
"testConnection": "Verbindung Testen",
|
||||
"testConnectionSubtitle": "OSM-Anmeldedaten überprüfen",
|
||||
"connectionOK": "Verbindung OK - Anmeldedaten sind gültig",
|
||||
"connectionFailed": "Verbindung fehlgeschlagen - bitte erneut anmelden"
|
||||
},
|
||||
"queue": {
|
||||
"pendingUploads": "Ausstehende Uploads: {}",
|
||||
"simulateModeEnabled": "Simulationsmodus aktiviert – Uploads simuliert",
|
||||
"sandboxMode": "Sandbox-Modus – Uploads gehen an OSM Sandbox",
|
||||
"tapToViewQueue": "Zum Anzeigen der Warteschlange antippen",
|
||||
"clearUploadQueue": "Upload-Warteschlange Löschen",
|
||||
"removeAllPending": "Alle {} ausstehenden Uploads entfernen",
|
||||
"clearQueueTitle": "Warteschlange Löschen",
|
||||
"clearQueueConfirm": "Alle {} ausstehenden Uploads entfernen?",
|
||||
"queueCleared": "Warteschlange geleert",
|
||||
"uploadQueueTitle": "Upload-Warteschlange ({} Elemente)",
|
||||
"queueIsEmpty": "Warteschlange ist leer",
|
||||
"cameraWithIndex": "Kamera {}",
|
||||
"error": " (Fehler)",
|
||||
"completing": " (Wird abgeschlossen...)",
|
||||
"destination": "Ziel: {}",
|
||||
"latitude": "Lat: {}",
|
||||
"longitude": "Lon: {}",
|
||||
"direction": "Richtung: {}°",
|
||||
"attempts": "Versuche: {}",
|
||||
"uploadFailedRetry": "Upload fehlgeschlagen. Zum Wiederholen antippen.",
|
||||
"retryUpload": "Upload wiederholen",
|
||||
"clearAll": "Alle Löschen"
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "Kachel-Anbieter",
|
||||
"noProvidersConfigured": "Keine Kachel-Anbieter konfiguriert",
|
||||
"tileTypesCount": "{} Kachel-Typen",
|
||||
"apiKeyConfigured": "API-Schlüssel konfiguriert",
|
||||
"needsApiKey": "Benötigt API-Schlüssel",
|
||||
"editProvider": "Anbieter Bearbeiten",
|
||||
"addProvider": "Anbieter Hinzufügen",
|
||||
"deleteProvider": "Anbieter Löschen",
|
||||
"deleteProviderConfirm": "Sind Sie sicher, dass Sie \"{}\" löschen möchten?",
|
||||
"providerName": "Anbieter-Name",
|
||||
"providerNameHint": "z.B. Benutzerdefinierte Karten GmbH",
|
||||
"providerNameRequired": "Anbieter-Name ist erforderlich",
|
||||
"apiKey": "API-Schlüssel (Optional)",
|
||||
"apiKeyHint": "API-Schlüssel eingeben, falls von Kachel-Typen benötigt",
|
||||
"tileTypes": "Kachel-Typen",
|
||||
"addType": "Typ Hinzufügen",
|
||||
"noTileTypesConfigured": "Keine Kachel-Typen konfiguriert",
|
||||
"atLeastOneTileTypeRequired": "Mindestens ein Kachel-Typ ist erforderlich",
|
||||
"manageTileProviders": "Anbieter Verwalten"
|
||||
},
|
||||
"tileTypeEditor": {
|
||||
"editTileType": "Kachel-Typ Bearbeiten",
|
||||
"addTileType": "Kachel-Typ Hinzufügen",
|
||||
"name": "Name",
|
||||
"nameHint": "z.B. Satellit",
|
||||
"nameRequired": "Name ist erforderlich",
|
||||
"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",
|
||||
"attribution": "Zuschreibung",
|
||||
"attributionHint": "© Karten-Anbieter",
|
||||
"attributionRequired": "Zuschreibung ist erforderlich",
|
||||
"fetchPreview": "Vorschau Laden",
|
||||
"previewTileLoaded": "Vorschau-Kachel erfolgreich geladen",
|
||||
"previewTileFailed": "Vorschau laden fehlgeschlagen: {}",
|
||||
"save": "Speichern"
|
||||
},
|
||||
"profiles": {
|
||||
"nodeProfiles": "Knoten-Profile",
|
||||
"newProfile": "Neues Profil",
|
||||
"builtIn": "Eingebaut",
|
||||
"custom": "Benutzerdefiniert",
|
||||
"view": "Anzeigen",
|
||||
"deleteProfile": "Profil Löschen",
|
||||
"deleteProfileConfirm": "Sind Sie sicher, dass Sie \"{}\" löschen möchten?",
|
||||
"profileDeleted": "Profil gelöscht"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Karten-Kacheln",
|
||||
"manageProviders": "Anbieter Verwalten"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Profil Anzeigen",
|
||||
"newProfile": "Neues Profil",
|
||||
"editProfile": "Profil Bearbeiten",
|
||||
"profileName": "Profil-Name",
|
||||
"profileNameHint": "z.B. Benutzerdefinierte ALPR-Kamera",
|
||||
"profileNameRequired": "Profil-Name ist erforderlich",
|
||||
"requiresDirection": "Benötigt Richtung",
|
||||
"requiresDirectionSubtitle": "Ob Kameras dieses Typs ein Richtungs-Tag benötigen",
|
||||
"submittable": "Übertragbar",
|
||||
"submittableSubtitle": "Ob dieses Profil für Kamera-Übertragungen verwendet werden kann",
|
||||
"osmTags": "OSM-Tags",
|
||||
"addTag": "Tag Hinzufügen",
|
||||
"saveProfile": "Profil Speichern",
|
||||
"keyHint": "Schlüssel",
|
||||
"valueHint": "Wert",
|
||||
"atLeastOneTagRequired": "Mindestens ein Tag ist erforderlich",
|
||||
"profileSaved": "Profil \"{}\" gespeichert"
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "Neues Betreiber-Profil",
|
||||
"editOperatorProfile": "Betreiber-Profil Bearbeiten",
|
||||
"operatorName": "Betreiber-Name",
|
||||
"operatorNameHint": "z.B. Polizei Austin",
|
||||
"operatorNameRequired": "Betreiber-Name ist erforderlich",
|
||||
"operatorProfileSaved": "Betreiber-Profil \"{}\" gespeichert"
|
||||
},
|
||||
"operatorProfiles": {
|
||||
"title": "Betreiber-Profile",
|
||||
"noProfilesMessage": "Keine Betreiber-Profile definiert. Erstellen Sie eines, um Betreiber-Tags auf Knoten-Übertragungen anzuwenden.",
|
||||
"tagsCount": "{} Tags",
|
||||
"deleteOperatorProfile": "Betreiber-Profil Löschen",
|
||||
"deleteOperatorProfileConfirm": "Sind Sie sicher, dass Sie \"{}\" löschen möchten?",
|
||||
"operatorProfileDeleted": "Betreiber-Profil gelöscht"
|
||||
}
|
||||
}
|
||||
215
lib/localizations/en.json
Normal file
215
lib/localizations/en.json
Normal file
@@ -0,0 +1,215 @@
|
||||
{
|
||||
"language": {
|
||||
"name": "English"
|
||||
},
|
||||
"app": {
|
||||
"title": "DeFlock"
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "Tag Node",
|
||||
"download": "Download",
|
||||
"settings": "Settings",
|
||||
"edit": "Edit",
|
||||
"cancel": "Cancel",
|
||||
"ok": "OK",
|
||||
"close": "Close",
|
||||
"submit": "Submit",
|
||||
"saveEdit": "Save Edit",
|
||||
"clear": "Clear"
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Enable follow-me (north up)",
|
||||
"northUp": "Enable follow-me (rotating)",
|
||||
"rotating": "Disable follow-me"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"language": "Language",
|
||||
"systemDefault": "System Default",
|
||||
"aboutInfo": "About / Info",
|
||||
"aboutThisApp": "About This App",
|
||||
"maxNodes": "Max nodes fetched/drawn",
|
||||
"maxNodesSubtitle": "Set an upper limit for the number of nodes on the map (default: 250).",
|
||||
"maxNodesWarning": "You probably don't want to do that unless you are absolutely sure you have a good reason for it.",
|
||||
"offlineMode": "Offline Mode",
|
||||
"offlineModeSubtitle": "Disable all network requests except for local/offline areas.",
|
||||
"offlineModeWarningTitle": "Active Downloads",
|
||||
"offlineModeWarningMessage": "Enabling offline mode will cancel any active area downloads. Do you want to continue?",
|
||||
"enableOfflineMode": "Enable Offline Mode"
|
||||
},
|
||||
"node": {
|
||||
"title": "Node #{}",
|
||||
"tagSheetTitle": "Surveillance Device Tags",
|
||||
"queuedForUpload": "Node queued for upload",
|
||||
"editQueuedForUpload": "Node edit queued for upload"
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "Profile",
|
||||
"direction": "Direction {}°",
|
||||
"profileNoDirectionInfo": "This profile does not require a direction.",
|
||||
"mustBeLoggedIn": "You must be logged in to submit new nodes. Please log in via Settings.",
|
||||
"enableSubmittableProfile": "Enable a submittable profile in Settings to submit new nodes.",
|
||||
"profileViewOnlyWarning": "This profile is for map viewing only. Please select a submittable profile to submit new nodes.",
|
||||
"refineTags": "Refine Tags",
|
||||
"refineTagsWithProfile": "Refine Tags ({})"
|
||||
},
|
||||
"editNode": {
|
||||
"title": "Edit Node #{}",
|
||||
"profile": "Profile",
|
||||
"direction": "Direction {}°",
|
||||
"profileNoDirectionInfo": "This profile does not require a direction.",
|
||||
"mustBeLoggedIn": "You must be logged in to edit nodes. Please log in via Settings.",
|
||||
"sandboxModeWarning": "Cannot submit edits on production nodes to sandbox. Switch to Production mode in Settings to edit nodes.",
|
||||
"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.",
|
||||
"refineTags": "Refine Tags",
|
||||
"refineTagsWithProfile": "Refine Tags ({})"
|
||||
},
|
||||
"download": {
|
||||
"title": "Download Map Area",
|
||||
"maxZoomLevel": "Max zoom level",
|
||||
"storageEstimate": "Storage estimate:",
|
||||
"tilesAndSize": "{} tiles, {} MB",
|
||||
"minZoom": "Min zoom:",
|
||||
"maxRecommendedZoom": "Max recommended zoom: Z{}",
|
||||
"withinTileLimit": "Within {} tile limit",
|
||||
"exceedsTileLimit": "Current selection exceeds {} tile limit",
|
||||
"offlineModeWarning": "Downloads disabled while in offline mode. Disable offline mode to download new areas.",
|
||||
"downloadStarted": "Download started! Fetching tiles and cameras...",
|
||||
"downloadFailed": "Failed to start download: {}"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Upload Destination",
|
||||
"subtitle": "Choose where cameras are uploaded",
|
||||
"production": "Production",
|
||||
"sandbox": "Sandbox",
|
||||
"simulate": "Simulate",
|
||||
"productionDescription": "Upload to the live OSM database (visible to all users)",
|
||||
"sandboxDescription": "Uploads go to the OSM Sandbox (safe for testing, resets regularly).",
|
||||
"sandboxNote": "NOTE: Due to OpenStreetMap limitations, cameras submitted to the sandbox will NOT appear on the map in this app.",
|
||||
"simulateDescription": "Simulate uploads (does not contact OSM servers)"
|
||||
},
|
||||
"auth": {
|
||||
"loggedInAs": "Logged in as {}",
|
||||
"loginToOSM": "Log in to OpenStreetMap",
|
||||
"tapToLogout": "Tap to logout",
|
||||
"requiredToSubmit": "Required to submit camera data",
|
||||
"loggedOut": "Logged out",
|
||||
"testConnection": "Test Connection",
|
||||
"testConnectionSubtitle": "Verify OSM credentials are working",
|
||||
"connectionOK": "Connection OK - credentials are valid",
|
||||
"connectionFailed": "Connection failed - please re-login"
|
||||
},
|
||||
"queue": {
|
||||
"pendingUploads": "Pending uploads: {}",
|
||||
"simulateModeEnabled": "Simulate mode enabled – uploads simulated",
|
||||
"sandboxMode": "Sandbox mode – uploads go to OSM Sandbox",
|
||||
"tapToViewQueue": "Tap to view queue",
|
||||
"clearUploadQueue": "Clear Upload Queue",
|
||||
"removeAllPending": "Remove all {} pending uploads",
|
||||
"clearQueueTitle": "Clear Queue",
|
||||
"clearQueueConfirm": "Remove all {} pending uploads?",
|
||||
"queueCleared": "Queue cleared",
|
||||
"uploadQueueTitle": "Upload Queue ({} items)",
|
||||
"queueIsEmpty": "Queue is empty",
|
||||
"cameraWithIndex": "Camera {}",
|
||||
"error": " (Error)",
|
||||
"completing": " (Completing...)",
|
||||
"destination": "Dest: {}",
|
||||
"latitude": "Lat: {}",
|
||||
"longitude": "Lon: {}",
|
||||
"direction": "Direction: {}°",
|
||||
"attempts": "Attempts: {}",
|
||||
"uploadFailedRetry": "Upload failed. Tap retry to try again.",
|
||||
"retryUpload": "Retry upload",
|
||||
"clearAll": "Clear All"
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "Tile Providers",
|
||||
"noProvidersConfigured": "No tile providers configured",
|
||||
"tileTypesCount": "{} tile types",
|
||||
"apiKeyConfigured": "API Key configured",
|
||||
"needsApiKey": "Needs API key",
|
||||
"editProvider": "Edit Provider",
|
||||
"addProvider": "Add Provider",
|
||||
"deleteProvider": "Delete Provider",
|
||||
"deleteProviderConfirm": "Are you sure you want to delete \"{}\"?",
|
||||
"providerName": "Provider Name",
|
||||
"providerNameHint": "e.g., Custom Maps Inc.",
|
||||
"providerNameRequired": "Provider name is required",
|
||||
"apiKey": "API Key (Optional)",
|
||||
"apiKeyHint": "Enter API key if required by tile types",
|
||||
"tileTypes": "Tile Types",
|
||||
"addType": "Add Type",
|
||||
"noTileTypesConfigured": "No tile types configured",
|
||||
"atLeastOneTileTypeRequired": "At least one tile type is required",
|
||||
"manageTileProviders": "Manage Providers"
|
||||
},
|
||||
"tileTypeEditor": {
|
||||
"editTileType": "Edit Tile Type",
|
||||
"addTileType": "Add Tile Type",
|
||||
"name": "Name",
|
||||
"nameHint": "e.g., Satellite",
|
||||
"nameRequired": "Name is required",
|
||||
"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",
|
||||
"attribution": "Attribution",
|
||||
"attributionHint": "© Map Provider",
|
||||
"attributionRequired": "Attribution is required",
|
||||
"fetchPreview": "Fetch Preview",
|
||||
"previewTileLoaded": "Preview tile loaded successfully",
|
||||
"previewTileFailed": "Failed to fetch preview: {}",
|
||||
"save": "Save"
|
||||
},
|
||||
"profiles": {
|
||||
"nodeProfiles": "Node Profiles",
|
||||
"newProfile": "New Profile",
|
||||
"builtIn": "Built-in",
|
||||
"custom": "Custom",
|
||||
"view": "View",
|
||||
"deleteProfile": "Delete Profile",
|
||||
"deleteProfileConfirm": "Are you sure you want to delete \"{}\"?",
|
||||
"profileDeleted": "Profile deleted"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Map Tiles",
|
||||
"manageProviders": "Manage Providers"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "View Profile",
|
||||
"newProfile": "New Profile",
|
||||
"editProfile": "Edit Profile",
|
||||
"profileName": "Profile name",
|
||||
"profileNameHint": "e.g., Custom ALPR Camera",
|
||||
"profileNameRequired": "Profile name is required",
|
||||
"requiresDirection": "Requires Direction",
|
||||
"requiresDirectionSubtitle": "Whether cameras of this type need a direction tag",
|
||||
"submittable": "Submittable",
|
||||
"submittableSubtitle": "Whether this profile can be used for camera submissions",
|
||||
"osmTags": "OSM Tags",
|
||||
"addTag": "Add tag",
|
||||
"saveProfile": "Save Profile",
|
||||
"keyHint": "key",
|
||||
"valueHint": "value",
|
||||
"atLeastOneTagRequired": "At least one tag is required",
|
||||
"profileSaved": "Profile \"{}\" saved"
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "New Operator Profile",
|
||||
"editOperatorProfile": "Edit Operator Profile",
|
||||
"operatorName": "Operator name",
|
||||
"operatorNameHint": "e.g., Austin Police Department",
|
||||
"operatorNameRequired": "Operator name is required",
|
||||
"operatorProfileSaved": "Operator profile \"{}\" saved"
|
||||
},
|
||||
"operatorProfiles": {
|
||||
"title": "Operator Profiles",
|
||||
"noProfilesMessage": "No operator profiles defined. Create one to apply operator tags to node submissions.",
|
||||
"tagsCount": "{} tags",
|
||||
"deleteOperatorProfile": "Delete Operator Profile",
|
||||
"deleteOperatorProfileConfirm": "Are you sure you want to delete \"{}\"?",
|
||||
"operatorProfileDeleted": "Operator profile deleted"
|
||||
}
|
||||
}
|
||||
215
lib/localizations/es.json
Normal file
215
lib/localizations/es.json
Normal file
@@ -0,0 +1,215 @@
|
||||
{
|
||||
"language": {
|
||||
"name": "Español"
|
||||
},
|
||||
"app": {
|
||||
"title": "DeFlock"
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "Etiquetar Nodo",
|
||||
"download": "Descargar",
|
||||
"settings": "Configuración",
|
||||
"edit": "Editar",
|
||||
"cancel": "Cancelar",
|
||||
"ok": "Aceptar",
|
||||
"close": "Cerrar",
|
||||
"submit": "Enviar",
|
||||
"saveEdit": "Guardar Edición",
|
||||
"clear": "Limpiar"
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Activar seguimiento (norte arriba)",
|
||||
"northUp": "Activar seguimiento (rotación)",
|
||||
"rotating": "Desactivar seguimiento"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Configuración",
|
||||
"language": "Idioma",
|
||||
"systemDefault": "Sistema por Defecto",
|
||||
"aboutInfo": "Acerca de / Información",
|
||||
"aboutThisApp": "Acerca de Esta App",
|
||||
"maxNodes": "Máx. nodos obtenidos/dibujados",
|
||||
"maxNodesSubtitle": "Establecer un límite superior para el número de nodos en el mapa (predeterminado: 250).",
|
||||
"maxNodesWarning": "Probablemente no quieras hacer eso a menos que estés absolutamente seguro de que tienes una buena razón para ello.",
|
||||
"offlineMode": "Modo Sin Conexión",
|
||||
"offlineModeSubtitle": "Deshabilitar todas las solicitudes de red excepto para áreas locales/sin conexión.",
|
||||
"offlineModeWarningTitle": "Descargas Activas",
|
||||
"offlineModeWarningMessage": "Habilitar el modo sin conexión cancelará cualquier descarga de área activa. ¿Desea continuar?",
|
||||
"enableOfflineMode": "Habilitar Modo Sin Conexión"
|
||||
},
|
||||
"node": {
|
||||
"title": "Nodo #{}",
|
||||
"tagSheetTitle": "Etiquetas del Dispositivo",
|
||||
"queuedForUpload": "Nodo en cola para subir",
|
||||
"editQueuedForUpload": "Edición de nodo en cola para subir"
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "Perfil",
|
||||
"direction": "Dirección {}°",
|
||||
"profileNoDirectionInfo": "Este perfil no requiere una dirección.",
|
||||
"mustBeLoggedIn": "Debe estar conectado para enviar nuevos nodos. Por favor, inicie sesión a través de Configuración.",
|
||||
"enableSubmittableProfile": "Habilite un perfil envíable en Configuración para enviar nuevos nodos.",
|
||||
"profileViewOnlyWarning": "Este perfil es solo para visualización del mapa. Por favor, seleccione un perfil envíable para enviar nuevos nodos.",
|
||||
"refineTags": "Refinar Etiquetas",
|
||||
"refineTagsWithProfile": "Refinar Etiquetas ({})"
|
||||
},
|
||||
"editNode": {
|
||||
"title": "Editar Nodo #{}",
|
||||
"profile": "Perfil",
|
||||
"direction": "Dirección {}°",
|
||||
"profileNoDirectionInfo": "Este perfil no requiere una dirección.",
|
||||
"mustBeLoggedIn": "Debe estar conectado para editar nodos. Por favor, inicie sesión a través de Configuración.",
|
||||
"sandboxModeWarning": "No se pueden enviar ediciones de nodos de producción al sandbox. Cambie al modo Producción en Configuración para editar nodos.",
|
||||
"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.",
|
||||
"refineTags": "Refinar Etiquetas",
|
||||
"refineTagsWithProfile": "Refinar Etiquetas ({})"
|
||||
},
|
||||
"download": {
|
||||
"title": "Descargar Área del Mapa",
|
||||
"maxZoomLevel": "Nivel máx. de zoom",
|
||||
"storageEstimate": "Estimación de almacenamiento:",
|
||||
"tilesAndSize": "{} mosaicos, {} MB",
|
||||
"minZoom": "Zoom mín.:",
|
||||
"maxRecommendedZoom": "Zoom máx. recomendado: Z{}",
|
||||
"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.",
|
||||
"downloadStarted": "¡Descarga iniciada! Obteniendo mosaicos y cámaras...",
|
||||
"downloadFailed": "Error al iniciar la descarga: {}"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Destino de Subida",
|
||||
"subtitle": "Elige dónde se suben las cámaras",
|
||||
"production": "Producción",
|
||||
"sandbox": "Sandbox",
|
||||
"simulate": "Simular",
|
||||
"productionDescription": "Subir a la base de datos OSM en vivo (visible para todos los usuarios)",
|
||||
"sandboxDescription": "Las subidas van al Sandbox de OSM (seguro para pruebas, se reinicia regularmente).",
|
||||
"sandboxNote": "NOTA: Debido a las limitaciones de OpenStreetMap, las cámaras enviadas al sandbox NO aparecerán en el mapa de esta aplicación.",
|
||||
"simulateDescription": "Simular subidas (no contacta servidores OSM)"
|
||||
},
|
||||
"auth": {
|
||||
"loggedInAs": "Conectado como {}",
|
||||
"loginToOSM": "Iniciar sesión en OpenStreetMap",
|
||||
"tapToLogout": "Toque para cerrar sesión",
|
||||
"requiredToSubmit": "Requerido para enviar datos de cámaras",
|
||||
"loggedOut": "Sesión cerrada",
|
||||
"testConnection": "Probar Conexión",
|
||||
"testConnectionSubtitle": "Verificar que las credenciales de OSM funcionen",
|
||||
"connectionOK": "Conexión OK - las credenciales son válidas",
|
||||
"connectionFailed": "Conexión falló - por favor, inicie sesión nuevamente"
|
||||
},
|
||||
"queue": {
|
||||
"pendingUploads": "Subidas pendientes: {}",
|
||||
"simulateModeEnabled": "Modo simulación activado – subidas simuladas",
|
||||
"sandboxMode": "Modo sandbox – subidas van al Sandbox OSM",
|
||||
"tapToViewQueue": "Toque para ver cola",
|
||||
"clearUploadQueue": "Limpiar Cola de Subida",
|
||||
"removeAllPending": "Eliminar todas las {} subidas pendientes",
|
||||
"clearQueueTitle": "Limpiar Cola",
|
||||
"clearQueueConfirm": "¿Eliminar todas las {} subidas pendientes?",
|
||||
"queueCleared": "Cola limpiada",
|
||||
"uploadQueueTitle": "Cola de Subida ({} elementos)",
|
||||
"queueIsEmpty": "La cola está vacía",
|
||||
"cameraWithIndex": "Cámara {}",
|
||||
"error": " (Error)",
|
||||
"completing": " (Completando...)",
|
||||
"destination": "Dest: {}",
|
||||
"latitude": "Lat: {}",
|
||||
"longitude": "Lon: {}",
|
||||
"direction": "Dirección: {}°",
|
||||
"attempts": "Intentos: {}",
|
||||
"uploadFailedRetry": "Subida falló. Toque reintentar para intentar de nuevo.",
|
||||
"retryUpload": "Reintentar subida",
|
||||
"clearAll": "Limpiar Todo"
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "Proveedores de Tiles",
|
||||
"noProvidersConfigured": "No hay proveedores de tiles configurados",
|
||||
"tileTypesCount": "{} tipos de tiles",
|
||||
"apiKeyConfigured": "Clave API configurada",
|
||||
"needsApiKey": "Necesita clave API",
|
||||
"editProvider": "Editar Proveedor",
|
||||
"addProvider": "Agregar Proveedor",
|
||||
"deleteProvider": "Eliminar Proveedor",
|
||||
"deleteProviderConfirm": "¿Está seguro de que desea eliminar \"{}\"?",
|
||||
"providerName": "Nombre del Proveedor",
|
||||
"providerNameHint": "ej., Mapas Personalizados Inc.",
|
||||
"providerNameRequired": "El nombre del proveedor es requerido",
|
||||
"apiKey": "Clave API (Opcional)",
|
||||
"apiKeyHint": "Ingrese la clave API si es requerida por los tipos de tiles",
|
||||
"tileTypes": "Tipos de Tiles",
|
||||
"addType": "Agregar Tipo",
|
||||
"noTileTypesConfigured": "No hay tipos de tiles configurados",
|
||||
"atLeastOneTileTypeRequired": "Se requiere al menos un tipo de tile",
|
||||
"manageTileProviders": "Gestionar Proveedores"
|
||||
},
|
||||
"tileTypeEditor": {
|
||||
"editTileType": "Editar Tipo de Tile",
|
||||
"addTileType": "Agregar Tipo de Tile",
|
||||
"name": "Nombre",
|
||||
"nameHint": "ej., Satélite",
|
||||
"nameRequired": "El nombre es requerido",
|
||||
"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}",
|
||||
"attribution": "Atribución",
|
||||
"attributionHint": "© Proveedor de Mapas",
|
||||
"attributionRequired": "La atribución es requerida",
|
||||
"fetchPreview": "Obtener Vista Previa",
|
||||
"previewTileLoaded": "Tile de vista previa cargado exitosamente",
|
||||
"previewTileFailed": "Falló al obtener vista previa: {}",
|
||||
"save": "Guardar"
|
||||
},
|
||||
"profiles": {
|
||||
"nodeProfiles": "Perfiles de Nodos",
|
||||
"newProfile": "Nuevo Perfil",
|
||||
"builtIn": "Incorporado",
|
||||
"custom": "Personalizado",
|
||||
"view": "Ver",
|
||||
"deleteProfile": "Eliminar Perfil",
|
||||
"deleteProfileConfirm": "¿Está seguro de que desea eliminar \"{}\"?",
|
||||
"profileDeleted": "Perfil eliminado"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Tiles de Mapa",
|
||||
"manageProviders": "Gestionar Proveedores"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Ver Perfil",
|
||||
"newProfile": "Nuevo Perfil",
|
||||
"editProfile": "Editar Perfil",
|
||||
"profileName": "Nombre del perfil",
|
||||
"profileNameHint": "ej., Cámara ALPR Personalizada",
|
||||
"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",
|
||||
"submittable": "Envíable",
|
||||
"submittableSubtitle": "Si este perfil puede usarse para envíos de cámaras",
|
||||
"osmTags": "Etiquetas OSM",
|
||||
"addTag": "Agregar Etiqueta",
|
||||
"saveProfile": "Guardar Perfil",
|
||||
"keyHint": "clave",
|
||||
"valueHint": "valor",
|
||||
"atLeastOneTagRequired": "Se requiere al menos una etiqueta",
|
||||
"profileSaved": "Perfil \"{}\" guardado"
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "Nuevo Perfil de Operador",
|
||||
"editOperatorProfile": "Editar Perfil de Operador",
|
||||
"operatorName": "Nombre del operador",
|
||||
"operatorNameHint": "ej., Departamento de Policía de Austin",
|
||||
"operatorNameRequired": "El nombre del operador es requerido",
|
||||
"operatorProfileSaved": "Perfil de operador \"{}\" guardado"
|
||||
},
|
||||
"operatorProfiles": {
|
||||
"title": "Perfiles de Operador",
|
||||
"noProfilesMessage": "No hay perfiles de operador definidos. Cree uno para aplicar etiquetas de operador a los envíos de nodos.",
|
||||
"tagsCount": "{} etiquetas",
|
||||
"deleteOperatorProfile": "Eliminar Perfil de Operador",
|
||||
"deleteOperatorProfileConfirm": "¿Está seguro de que desea eliminar \"{}\"?",
|
||||
"operatorProfileDeleted": "Perfil de operador eliminado"
|
||||
}
|
||||
}
|
||||
215
lib/localizations/fr.json
Normal file
215
lib/localizations/fr.json
Normal file
@@ -0,0 +1,215 @@
|
||||
{
|
||||
"language": {
|
||||
"name": "Français"
|
||||
},
|
||||
"app": {
|
||||
"title": "DeFlock"
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "Marquer Nœud",
|
||||
"download": "Télécharger",
|
||||
"settings": "Paramètres",
|
||||
"edit": "Modifier",
|
||||
"cancel": "Annuler",
|
||||
"ok": "OK",
|
||||
"close": "Fermer",
|
||||
"submit": "Soumettre",
|
||||
"saveEdit": "Sauvegarder Modification",
|
||||
"clear": "Effacer"
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Activer le suivi (nord en haut)",
|
||||
"northUp": "Activer le suivi (rotation)",
|
||||
"rotating": "Désactiver le suivi"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Paramètres",
|
||||
"language": "Langue",
|
||||
"systemDefault": "Par Défaut du Système",
|
||||
"aboutInfo": "À Propos / Informations",
|
||||
"aboutThisApp": "À Propos de Cette App",
|
||||
"maxNodes": "Max. nœuds récupérés/dessinés",
|
||||
"maxNodesSubtitle": "Définir une limite supérieure pour le nombre de nœuds sur la carte (par défaut: 250).",
|
||||
"maxNodesWarning": "Vous ne voulez probablement pas faire cela à moins d'être absolument sûr d'avoir une bonne raison de le faire.",
|
||||
"offlineMode": "Mode Hors Ligne",
|
||||
"offlineModeSubtitle": "Désactiver toutes les requêtes réseau sauf pour les zones locales/hors ligne.",
|
||||
"offlineModeWarningTitle": "Téléchargements Actifs",
|
||||
"offlineModeWarningMessage": "L'activation du mode hors ligne annulera tous les téléchargements de zone actifs. Voulez-vous continuer?",
|
||||
"enableOfflineMode": "Activer le Mode Hors Ligne"
|
||||
},
|
||||
"node": {
|
||||
"title": "Nœud #{}",
|
||||
"tagSheetTitle": "Balises du Dispositif",
|
||||
"queuedForUpload": "Nœud mis en file pour envoi",
|
||||
"editQueuedForUpload": "Modification de nœud mise en file pour envoi"
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "Profil",
|
||||
"direction": "Direction {}°",
|
||||
"profileNoDirectionInfo": "Ce profil ne nécessite pas de direction.",
|
||||
"mustBeLoggedIn": "Vous devez être connecté pour soumettre de nouveaux nœuds. Veuillez vous connecter via les Paramètres.",
|
||||
"enableSubmittableProfile": "Activez un profil soumissible dans les Paramètres pour soumettre de nouveaux nœuds.",
|
||||
"profileViewOnlyWarning": "Ce profil est uniquement pour la visualisation de la carte. Veuillez sélectionner un profil soumissible pour soumettre de nouveaux nœuds.",
|
||||
"refineTags": "Affiner Balises",
|
||||
"refineTagsWithProfile": "Affiner Balises ({})"
|
||||
},
|
||||
"editNode": {
|
||||
"title": "Modifier Nœud #{}",
|
||||
"profile": "Profil",
|
||||
"direction": "Direction {}°",
|
||||
"profileNoDirectionInfo": "Ce profil ne nécessite pas de direction.",
|
||||
"mustBeLoggedIn": "Vous devez être connecté pour modifier les nœuds. Veuillez vous connecter via les Paramètres.",
|
||||
"sandboxModeWarning": "Impossible de soumettre des modifications de nœuds de production au sandbox. Passez au mode Production dans les Paramètres pour modifier les nœuds.",
|
||||
"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.",
|
||||
"refineTags": "Affiner Balises",
|
||||
"refineTagsWithProfile": "Affiner Balises ({})"
|
||||
},
|
||||
"download": {
|
||||
"title": "Télécharger Zone de Carte",
|
||||
"maxZoomLevel": "Niveau de zoom max.",
|
||||
"storageEstimate": "Estimation de stockage:",
|
||||
"tilesAndSize": "{} tuiles, {} MB",
|
||||
"minZoom": "Zoom min.:",
|
||||
"maxRecommendedZoom": "Zoom max. recommandé: Z{}",
|
||||
"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 caméras...",
|
||||
"downloadFailed": "Échec du démarrage du téléchargement: {}"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Destination de Téléchargement",
|
||||
"subtitle": "Choisir où les caméras sont téléchargées",
|
||||
"production": "Production",
|
||||
"sandbox": "Sandbox",
|
||||
"simulate": "Simuler",
|
||||
"productionDescription": "Télécharger vers la base de données OSM en direct (visible pour tous les utilisateurs)",
|
||||
"sandboxDescription": "Les téléchargements vont vers le Sandbox OSM (sûr pour les tests, réinitialisé régulièrement).",
|
||||
"sandboxNote": "NOTE: En raison des limitations d'OpenStreetMap, les caméras soumises au sandbox n'apparaîtront PAS sur la carte dans cette application.",
|
||||
"simulateDescription": "Simuler les téléchargements (ne contacte pas les serveurs OSM)"
|
||||
},
|
||||
"auth": {
|
||||
"loggedInAs": "Connecté en tant que {}",
|
||||
"loginToOSM": "Se connecter à OpenStreetMap",
|
||||
"tapToLogout": "Appuyer pour se déconnecter",
|
||||
"requiredToSubmit": "Requis pour soumettre des données de caméras",
|
||||
"loggedOut": "Déconnecté",
|
||||
"testConnection": "Tester Connexion",
|
||||
"testConnectionSubtitle": "Vérifier que les identifiants OSM fonctionnent",
|
||||
"connectionOK": "Connexion OK - les identifiants sont valides",
|
||||
"connectionFailed": "Connexion échouée - veuillez vous reconnecter"
|
||||
},
|
||||
"queue": {
|
||||
"pendingUploads": "Téléchargements en attente: {}",
|
||||
"simulateModeEnabled": "Mode simulation activé – téléchargements simulés",
|
||||
"sandboxMode": "Mode sandbox – téléchargements vont vers OSM Sandbox",
|
||||
"tapToViewQueue": "Appuyer pour voir la file",
|
||||
"clearUploadQueue": "Vider File de Téléchargement",
|
||||
"removeAllPending": "Supprimer tous les {} téléchargements en attente",
|
||||
"clearQueueTitle": "Vider File",
|
||||
"clearQueueConfirm": "Supprimer tous les {} téléchargements en attente?",
|
||||
"queueCleared": "File vidée",
|
||||
"uploadQueueTitle": "File de Téléchargement ({} éléments)",
|
||||
"queueIsEmpty": "La file est vide",
|
||||
"cameraWithIndex": "Caméra {}",
|
||||
"error": " (Erreur)",
|
||||
"completing": " (Finalisation...)",
|
||||
"destination": "Dest: {}",
|
||||
"latitude": "Lat: {}",
|
||||
"longitude": "Lon: {}",
|
||||
"direction": "Direction: {}°",
|
||||
"attempts": "Tentatives: {}",
|
||||
"uploadFailedRetry": "Téléchargement échoué. Appuyer pour réessayer.",
|
||||
"retryUpload": "Réessayer téléchargement",
|
||||
"clearAll": "Tout Vider"
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "Fournisseurs de Tuiles",
|
||||
"noProvidersConfigured": "Aucun fournisseur de tuiles configuré",
|
||||
"tileTypesCount": "{} types de tuiles",
|
||||
"apiKeyConfigured": "Clé API configurée",
|
||||
"needsApiKey": "Nécessite une clé API",
|
||||
"editProvider": "Modifier Fournisseur",
|
||||
"addProvider": "Ajouter Fournisseur",
|
||||
"deleteProvider": "Supprimer Fournisseur",
|
||||
"deleteProviderConfirm": "Êtes-vous sûr de vouloir supprimer \"{}\"?",
|
||||
"providerName": "Nom du Fournisseur",
|
||||
"providerNameHint": "ex., Cartes Personnalisées Inc.",
|
||||
"providerNameRequired": "Le nom du fournisseur est requis",
|
||||
"apiKey": "Clé API (Optionnel)",
|
||||
"apiKeyHint": "Entrez la clé API si requise par les types de tuiles",
|
||||
"tileTypes": "Types de Tuiles",
|
||||
"addType": "Ajouter Type",
|
||||
"noTileTypesConfigured": "Aucun type de tuile configuré",
|
||||
"atLeastOneTileTypeRequired": "Au moins un type de tuile est requis",
|
||||
"manageTileProviders": "Gérer Fournisseurs"
|
||||
},
|
||||
"tileTypeEditor": {
|
||||
"editTileType": "Modifier Type de Tuile",
|
||||
"addTileType": "Ajouter Type de Tuile",
|
||||
"name": "Nom",
|
||||
"nameHint": "ex., Satellite",
|
||||
"nameRequired": "Le nom est requis",
|
||||
"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}",
|
||||
"attribution": "Attribution",
|
||||
"attributionHint": "© Fournisseur de Cartes",
|
||||
"attributionRequired": "L'attribution est requise",
|
||||
"fetchPreview": "Récupérer Aperçu",
|
||||
"previewTileLoaded": "Tuile d'aperçu chargée avec succès",
|
||||
"previewTileFailed": "Échec de récupération de l'aperçu: {}",
|
||||
"save": "Sauvegarder"
|
||||
},
|
||||
"profiles": {
|
||||
"nodeProfiles": "Profils de Nœuds",
|
||||
"newProfile": "Nouveau Profil",
|
||||
"builtIn": "Intégré",
|
||||
"custom": "Personnalisé",
|
||||
"view": "Voir",
|
||||
"deleteProfile": "Supprimer Profil",
|
||||
"deleteProfileConfirm": "Êtes-vous sûr de vouloir supprimer \"{}\"?",
|
||||
"profileDeleted": "Profil supprimé"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Tuiles de Carte",
|
||||
"manageProviders": "Gérer Fournisseurs"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Voir Profil",
|
||||
"newProfile": "Nouveau Profil",
|
||||
"editProfile": "Modifier Profil",
|
||||
"profileName": "Nom du profil",
|
||||
"profileNameHint": "ex., Caméra ALPR Personnalisée",
|
||||
"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",
|
||||
"submittable": "Soumissible",
|
||||
"submittableSubtitle": "Si ce profil peut être utilisé pour les soumissions de caméras",
|
||||
"osmTags": "Balises OSM",
|
||||
"addTag": "Ajouter Balise",
|
||||
"saveProfile": "Sauvegarder Profil",
|
||||
"keyHint": "clé",
|
||||
"valueHint": "valeur",
|
||||
"atLeastOneTagRequired": "Au moins une balise est requise",
|
||||
"profileSaved": "Profil \"{}\" sauvegardé"
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "Nouveau Profil d'Opérateur",
|
||||
"editOperatorProfile": "Modifier Profil d'Opérateur",
|
||||
"operatorName": "Nom de l'opérateur",
|
||||
"operatorNameHint": "ex., Département de Police d'Austin",
|
||||
"operatorNameRequired": "Le nom de l'opérateur est requis",
|
||||
"operatorProfileSaved": "Profil d'opérateur \"{}\" sauvegardé"
|
||||
},
|
||||
"operatorProfiles": {
|
||||
"title": "Profils d'Opérateur",
|
||||
"noProfilesMessage": "Aucun profil d'opérateur défini. Créez-en un pour appliquer des balises d'opérateur aux soumissions de nœuds.",
|
||||
"tagsCount": "{} balises",
|
||||
"deleteOperatorProfile": "Supprimer Profil d'Opérateur",
|
||||
"deleteOperatorProfileConfirm": "Êtes-vous sûr de vouloir supprimer \"{}\"?",
|
||||
"operatorProfileDeleted": "Profil d'opérateur supprimé"
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,15 @@ import 'package:provider/provider.dart';
|
||||
import 'app_state.dart';
|
||||
import 'screens/home_screen.dart';
|
||||
import 'screens/settings_screen.dart';
|
||||
import 'services/localization_service.dart';
|
||||
|
||||
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Initialize localization service
|
||||
await LocalizationService.instance.init();
|
||||
|
||||
runApp(
|
||||
ChangeNotifierProvider(
|
||||
@@ -30,22 +34,25 @@ Future<void> main() async {
|
||||
),
|
||||
);
|
||||
}
|
||||
return const FlockMapApp();
|
||||
return const DeFlockApp();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class FlockMapApp extends StatelessWidget {
|
||||
const FlockMapApp({super.key});
|
||||
class DeFlockApp extends StatelessWidget {
|
||||
const DeFlockApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Flock Map',
|
||||
title: 'DeFlock',
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.green),
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: const Color(0xFF0080BC), // DeFlock blue
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
useMaterial3: true,
|
||||
),
|
||||
routes: {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_map_animations/flutter_map_animations.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../dev_config.dart';
|
||||
import '../widgets/map_view.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
import '../widgets/add_node_sheet.dart';
|
||||
import '../widgets/edit_node_sheet.dart';
|
||||
@@ -43,13 +45,14 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
}
|
||||
|
||||
String _getFollowMeTooltip(FollowMeMode mode) {
|
||||
final locService = LocalizationService.instance;
|
||||
switch (mode) {
|
||||
case FollowMeMode.off:
|
||||
return 'Enable follow-me (north up)';
|
||||
return locService.t('followMe.off');
|
||||
case FollowMeMode.northUp:
|
||||
return 'Enable follow-me (rotating)';
|
||||
return locService.t('followMe.northUp');
|
||||
case FollowMeMode.rotating:
|
||||
return 'Disable follow-me';
|
||||
return locService.t('followMe.rotating');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +154,11 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
child: Scaffold(
|
||||
key: _scaffoldKey,
|
||||
appBar: AppBar(
|
||||
title: const Text('Flock Map'),
|
||||
title: SvgPicture.asset(
|
||||
'assets/deflock-logo.svg',
|
||||
height: 28,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: _getFollowMeTooltip(appState.followMeMode),
|
||||
@@ -167,9 +174,13 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
}
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings),
|
||||
onPressed: () => Navigator.pushNamed(context, '/settings'),
|
||||
AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => IconButton(
|
||||
tooltip: LocalizationService.instance.settings,
|
||||
icon: const Icon(Icons.settings),
|
||||
onPressed: () => Navigator.pushNamed(context, '/settings'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -196,37 +207,49 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 10, offset: Offset(0, -2))],
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Theme.of(context).shadowColor.withOpacity(0.3),
|
||||
blurRadius: 10,
|
||||
offset: Offset(0, -2),
|
||||
)
|
||||
],
|
||||
),
|
||||
margin: EdgeInsets.only(bottom: kBottomButtonBarMargin),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
icon: Icon(Icons.add_location_alt),
|
||||
label: Text('Tag Node'),
|
||||
onPressed: _openAddNodeSheet,
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: Size(0, 48),
|
||||
textStyle: TextStyle(fontSize: 16),
|
||||
child: AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => ElevatedButton.icon(
|
||||
icon: Icon(Icons.add_location_alt),
|
||||
label: Text(LocalizationService.instance.tagNode),
|
||||
onPressed: _openAddNodeSheet,
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: Size(0, 48),
|
||||
textStyle: TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
icon: Icon(Icons.download_for_offline),
|
||||
label: Text('Download'),
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: Size(0, 48),
|
||||
textStyle: TextStyle(fontSize: 16),
|
||||
child: AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => ElevatedButton.icon(
|
||||
icon: Icon(Icons.download_for_offline),
|
||||
label: Text(LocalizationService.instance.download),
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: Size(0, 48),
|
||||
textStyle: TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/operator_profile.dart';
|
||||
import '../app_state.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
class OperatorProfileEditor extends StatefulWidget {
|
||||
const OperatorProfileEditor({super.key, required this.profile});
|
||||
@@ -45,46 +46,55 @@ class _OperatorProfileEditorState extends State<OperatorProfileEditor> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.profile.name.isEmpty ? 'New Operator Profile' : 'Edit Operator Profile'),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
TextField(
|
||||
controller: _nameCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Operator name',
|
||||
hintText: 'e.g., Austin Police Department',
|
||||
),
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.profile.name.isEmpty ? locService.t('operatorProfileEditor.newOperatorProfile') : locService.t('operatorProfileEditor.editOperatorProfile')),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
const Text('OSM Tags',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
TextButton.icon(
|
||||
onPressed: () => setState(() => _tags.add(const MapEntry('', ''))),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add tag'),
|
||||
TextField(
|
||||
controller: _nameCtrl,
|
||||
decoration: InputDecoration(
|
||||
labelText: locService.t('operatorProfileEditor.operatorName'),
|
||||
hintText: locService.t('operatorProfileEditor.operatorNameHint'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(locService.t('profileEditor.osmTags'),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
TextButton.icon(
|
||||
onPressed: () => setState(() => _tags.add(const MapEntry('', ''))),
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(locService.t('profileEditor.addTag')),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
..._buildTagRows(),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: _save,
|
||||
child: Text(locService.t('profileEditor.saveProfile')),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
..._buildTagRows(),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: _save,
|
||||
child: const Text('Save Profile'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildTagRows() {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return List.generate(_tags.length, (i) {
|
||||
final keyController = TextEditingController(text: _tags[i].key);
|
||||
final valueController = TextEditingController(text: _tags[i].value);
|
||||
@@ -96,9 +106,9 @@ class _OperatorProfileEditorState extends State<OperatorProfileEditor> {
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: TextField(
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'key',
|
||||
border: OutlineInputBorder(),
|
||||
decoration: InputDecoration(
|
||||
hintText: locService.t('profileEditor.keyHint'),
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
controller: keyController,
|
||||
@@ -109,9 +119,9 @@ class _OperatorProfileEditorState extends State<OperatorProfileEditor> {
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: TextField(
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'value',
|
||||
border: OutlineInputBorder(),
|
||||
decoration: InputDecoration(
|
||||
hintText: locService.t('profileEditor.valueHint'),
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
controller: valueController,
|
||||
@@ -129,10 +139,12 @@ class _OperatorProfileEditorState extends State<OperatorProfileEditor> {
|
||||
}
|
||||
|
||||
void _save() {
|
||||
final locService = LocalizationService.instance;
|
||||
final name = _nameCtrl.text.trim();
|
||||
|
||||
if (name.isEmpty) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(content: Text('Operator name is required')));
|
||||
.showSnackBar(SnackBar(content: Text(locService.t('operatorProfileEditor.operatorNameRequired'))));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -152,7 +164,7 @@ class _OperatorProfileEditorState extends State<OperatorProfileEditor> {
|
||||
Navigator.pop(context);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Operator profile "${newProfile.name}" saved')),
|
||||
SnackBar(content: Text(locService.t('operatorProfileEditor.operatorProfileSaved', params: [newProfile.name]))),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/node_profile.dart';
|
||||
import '../app_state.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
class ProfileEditor extends StatefulWidget {
|
||||
const ProfileEditor({super.key, required this.profile});
|
||||
@@ -54,68 +55,77 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(!widget.profile.editable
|
||||
? 'View Profile'
|
||||
: (widget.profile.name.isEmpty ? 'New Profile' : 'Edit Profile')),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
TextField(
|
||||
controller: _nameCtrl,
|
||||
readOnly: !widget.profile.editable,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Profile name',
|
||||
hintText: 'e.g., Custom ALPR Camera',
|
||||
),
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(!widget.profile.editable
|
||||
? locService.t('profileEditor.viewProfile')
|
||||
: (widget.profile.name.isEmpty ? locService.t('profileEditor.newProfile') : locService.t('profileEditor.editProfile'))),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (widget.profile.editable) ...[
|
||||
CheckboxListTile(
|
||||
title: const Text('Requires Direction'),
|
||||
subtitle: const Text('Whether cameras of this type need a direction tag'),
|
||||
value: _requiresDirection,
|
||||
onChanged: (value) => setState(() => _requiresDirection = value ?? true),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
CheckboxListTile(
|
||||
title: const Text('Submittable'),
|
||||
subtitle: const Text('Whether this profile can be used for camera submissions'),
|
||||
value: _submittable,
|
||||
onChanged: (value) => setState(() => _submittable = value ?? true),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('OSM Tags',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
if (widget.profile.editable)
|
||||
TextButton.icon(
|
||||
onPressed: () => setState(() => _tags.add(const MapEntry('', ''))),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add tag'),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
TextField(
|
||||
controller: _nameCtrl,
|
||||
readOnly: !widget.profile.editable,
|
||||
decoration: InputDecoration(
|
||||
labelText: locService.t('profileEditor.profileName'),
|
||||
hintText: locService.t('profileEditor.profileNameHint'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (widget.profile.editable) ...[
|
||||
CheckboxListTile(
|
||||
title: Text(locService.t('profileEditor.requiresDirection')),
|
||||
subtitle: Text(locService.t('profileEditor.requiresDirectionSubtitle')),
|
||||
value: _requiresDirection,
|
||||
onChanged: (value) => setState(() => _requiresDirection = value ?? true),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
CheckboxListTile(
|
||||
title: Text(locService.t('profileEditor.submittable')),
|
||||
subtitle: Text(locService.t('profileEditor.submittableSubtitle')),
|
||||
value: _submittable,
|
||||
onChanged: (value) => setState(() => _submittable = value ?? true),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
..._buildTagRows(),
|
||||
const SizedBox(height: 24),
|
||||
if (widget.profile.editable)
|
||||
ElevatedButton(
|
||||
onPressed: _save,
|
||||
child: const Text('Save Profile'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(locService.t('profileEditor.osmTags'),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
if (widget.profile.editable)
|
||||
TextButton.icon(
|
||||
onPressed: () => setState(() => _tags.add(const MapEntry('', ''))),
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(locService.t('profileEditor.addTag')),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
..._buildTagRows(),
|
||||
const SizedBox(height: 24),
|
||||
if (widget.profile.editable)
|
||||
ElevatedButton(
|
||||
onPressed: _save,
|
||||
child: Text(locService.t('profileEditor.saveProfile')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildTagRows() {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return List.generate(_tags.length, (i) {
|
||||
final keyController = TextEditingController(text: _tags[i].key);
|
||||
final valueController = TextEditingController(text: _tags[i].value);
|
||||
@@ -127,9 +137,9 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: TextField(
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'key',
|
||||
border: OutlineInputBorder(),
|
||||
decoration: InputDecoration(
|
||||
hintText: locService.t('profileEditor.keyHint'),
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
controller: keyController,
|
||||
@@ -143,9 +153,9 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: TextField(
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'value',
|
||||
border: OutlineInputBorder(),
|
||||
decoration: InputDecoration(
|
||||
hintText: locService.t('profileEditor.valueHint'),
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
controller: valueController,
|
||||
@@ -167,10 +177,12 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
}
|
||||
|
||||
void _save() {
|
||||
final locService = LocalizationService.instance;
|
||||
final name = _nameCtrl.text.trim();
|
||||
|
||||
if (name.isEmpty) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(content: Text('Profile name is required')));
|
||||
.showSnackBar(SnackBar(content: Text(locService.t('profileEditor.profileNameRequired'))));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -182,7 +194,7 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
|
||||
if (tagMap.isEmpty) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(content: Text('At least one tag is required')));
|
||||
.showSnackBar(SnackBar(content: Text(locService.t('profileEditor.atLeastOneTagRequired'))));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -200,7 +212,7 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
Navigator.pop(context);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Profile "${newProfile.name}" saved')),
|
||||
SnackBar(content: Text(locService.t('profileEditor.profileSaved', params: [newProfile.name]))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,37 +9,44 @@ import 'settings_screen_sections/offline_mode_section.dart';
|
||||
import 'settings_screen_sections/about_section.dart';
|
||||
import 'settings_screen_sections/max_nodes_section.dart';
|
||||
import 'settings_screen_sections/tile_provider_section.dart';
|
||||
import 'settings_screen_sections/language_section.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
class SettingsScreen extends StatelessWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Settings')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: const [
|
||||
UploadModeSection(),
|
||||
Divider(),
|
||||
AuthSection(),
|
||||
Divider(),
|
||||
QueueSection(),
|
||||
Divider(),
|
||||
ProfileListSection(),
|
||||
Divider(),
|
||||
OperatorProfileListSection(),
|
||||
Divider(),
|
||||
MaxNodesSection(),
|
||||
Divider(),
|
||||
TileProviderSection(),
|
||||
Divider(),
|
||||
OfflineModeSection(),
|
||||
Divider(),
|
||||
OfflineAreasSection(),
|
||||
Divider(),
|
||||
AboutSection(),
|
||||
],
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => Scaffold(
|
||||
appBar: AppBar(title: Text(LocalizationService.instance.t('settings.title'))),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: const [
|
||||
UploadModeSection(),
|
||||
Divider(),
|
||||
AuthSection(),
|
||||
Divider(),
|
||||
QueueSection(),
|
||||
Divider(),
|
||||
ProfileListSection(),
|
||||
Divider(),
|
||||
OperatorProfileListSection(),
|
||||
Divider(),
|
||||
MaxNodesSection(),
|
||||
Divider(),
|
||||
TileProviderSection(),
|
||||
Divider(),
|
||||
OfflineModeSection(),
|
||||
Divider(),
|
||||
OfflineAreasSection(),
|
||||
Divider(),
|
||||
LanguageSection(),
|
||||
Divider(),
|
||||
AboutSection(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,35 +1,42 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../services/localization_service.dart';
|
||||
|
||||
class AboutSection extends StatelessWidget {
|
||||
const AboutSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.info_outline),
|
||||
title: const Text('About / Info'),
|
||||
onTap: () async {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => FutureBuilder<String>(
|
||||
future: DefaultAssetBundle.of(context).loadString('assets/info.txt'),
|
||||
builder: (context, snapshot) => AlertDialog(
|
||||
title: const Text('About This App'),
|
||||
content: SingleChildScrollView(
|
||||
child: Text(
|
||||
snapshot.connectionState == ConnectionState.done
|
||||
? (snapshot.data ?? 'No info available.')
|
||||
: 'Loading...',
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.info_outline),
|
||||
title: Text(locService.t('settings.aboutInfo')),
|
||||
onTap: () async {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => FutureBuilder<String>(
|
||||
future: DefaultAssetBundle.of(context).loadString('assets/info.txt'),
|
||||
builder: (context, snapshot) => AlertDialog(
|
||||
title: Text(locService.t('settings.aboutThisApp')),
|
||||
content: SingleChildScrollView(
|
||||
child: Text(
|
||||
snapshot.connectionState == ConnectionState.done
|
||||
? (snapshot.data ?? 'No info available.')
|
||||
: 'Loading...',
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(locService.ok),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,68 +1,77 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../../services/localization_service.dart';
|
||||
|
||||
class AuthSection extends StatelessWidget {
|
||||
const AuthSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
appState.isLoggedIn ? Icons.person : Icons.login,
|
||||
color: appState.isLoggedIn ? Colors.green : null,
|
||||
),
|
||||
title: Text(appState.isLoggedIn
|
||||
? 'Logged in as ${appState.username}'
|
||||
: 'Log in to OpenStreetMap'),
|
||||
subtitle: appState.isLoggedIn
|
||||
? const Text('Tap to logout')
|
||||
: const Text('Required to submit camera data'),
|
||||
onTap: () async {
|
||||
if (appState.isLoggedIn) {
|
||||
await appState.logout();
|
||||
} else {
|
||||
await appState.forceLogin();
|
||||
}
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(appState.isLoggedIn
|
||||
? 'Logged in as ${appState.username}'
|
||||
: 'Logged out'),
|
||||
backgroundColor: appState.isLoggedIn ? Colors.green : Colors.grey,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
if (appState.isLoggedIn)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.wifi_protected_setup),
|
||||
title: const Text('Test Connection'),
|
||||
subtitle: const Text('Verify OSM credentials are working'),
|
||||
onTap: () async {
|
||||
final isValid = await appState.validateToken();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(isValid
|
||||
? 'Connection OK - credentials are valid'
|
||||
: 'Connection failed - please re-login'),
|
||||
backgroundColor: isValid ? Colors.green : Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!isValid) {
|
||||
await appState.logout();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
appState.isLoggedIn ? Icons.person : Icons.login,
|
||||
color: appState.isLoggedIn ? Colors.green : null,
|
||||
),
|
||||
title: Text(appState.isLoggedIn
|
||||
? locService.t('auth.loggedInAs', params: [appState.username])
|
||||
: locService.t('auth.loginToOSM')),
|
||||
subtitle: appState.isLoggedIn
|
||||
? Text(locService.t('auth.tapToLogout'))
|
||||
: Text(locService.t('auth.requiredToSubmit')),
|
||||
onTap: () async {
|
||||
if (appState.isLoggedIn) {
|
||||
await appState.logout();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(locService.t('auth.loggedOut')),
|
||||
backgroundColor: Colors.grey,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Start login flow - the user will be redirected to browser
|
||||
await appState.forceLogin();
|
||||
|
||||
// Don't show immediate feedback - the UI will update automatically
|
||||
// when the OAuth callback completes and notifyListeners() is called
|
||||
}
|
||||
},
|
||||
),
|
||||
if (appState.isLoggedIn)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.wifi_protected_setup),
|
||||
title: Text(locService.t('auth.testConnection')),
|
||||
subtitle: Text(locService.t('auth.testConnectionSubtitle')),
|
||||
onTap: () async {
|
||||
final isValid = await appState.validateToken();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(isValid
|
||||
? locService.t('auth.connectionOK')
|
||||
: locService.t('auth.connectionFailed')),
|
||||
backgroundColor: isValid ? Colors.green : Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!isValid) {
|
||||
await appState.logout();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
90
lib/screens/settings_screen_sections/language_section.dart
Normal file
90
lib/screens/settings_screen_sections/language_section.dart
Normal file
@@ -0,0 +1,90 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../../services/localization_service.dart';
|
||||
|
||||
class LanguageSection extends StatefulWidget {
|
||||
const LanguageSection({super.key});
|
||||
|
||||
@override
|
||||
State<LanguageSection> createState() => _LanguageSectionState();
|
||||
}
|
||||
|
||||
class _LanguageSectionState extends State<LanguageSection> {
|
||||
String? _selectedLanguage;
|
||||
Map<String, String> _languageNames = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSelectedLanguage();
|
||||
_loadLanguageNames();
|
||||
}
|
||||
|
||||
_loadSelectedLanguage() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
setState(() {
|
||||
_selectedLanguage = prefs.getString('language_code');
|
||||
});
|
||||
}
|
||||
|
||||
_loadLanguageNames() async {
|
||||
final locService = LocalizationService.instance;
|
||||
final Map<String, String> names = {};
|
||||
|
||||
for (String langCode in locService.availableLanguages) {
|
||||
names[langCode] = await locService.getLanguageDisplayName(langCode);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_languageNames = names;
|
||||
});
|
||||
}
|
||||
|
||||
_setLanguage(String? languageCode) async {
|
||||
await LocalizationService.instance.setLanguage(languageCode);
|
||||
setState(() {
|
||||
_selectedLanguage = languageCode;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
locService.t('settings.language'),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
// System Default option
|
||||
RadioListTile<String?>(
|
||||
title: Text(locService.t('settings.systemDefault')),
|
||||
value: null,
|
||||
groupValue: _selectedLanguage,
|
||||
onChanged: _setLanguage,
|
||||
),
|
||||
// Dynamic language options
|
||||
...locService.availableLanguages.map((langCode) =>
|
||||
RadioListTile<String>(
|
||||
title: Text(_languageNames[langCode] ?? langCode.toUpperCase()),
|
||||
value: langCode,
|
||||
groupValue: _selectedLanguage,
|
||||
onChanged: _setLanguage,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../../services/localization_service.dart';
|
||||
|
||||
class MaxNodesSection extends StatefulWidget {
|
||||
const MaxNodesSection({super.key});
|
||||
@@ -27,54 +28,61 @@ class _MaxNodesSectionState extends State<MaxNodesSection> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
final current = appState.maxCameras;
|
||||
final showWarning = current > 1000;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.filter_alt),
|
||||
title: const Text('Max nodes fetched/drawn'),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Set an upper limit for the number of nodes on the map (default: 250).'),
|
||||
if (showWarning)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Row(
|
||||
children: const [
|
||||
Icon(Icons.warning, color: Colors.orange, size: 18),
|
||||
SizedBox(width: 6),
|
||||
Expanded(child: Text(
|
||||
'You probably don\'t want to do that unless you are absolutely sure you have a good reason for it.',
|
||||
style: TextStyle(color: Colors.orange),
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: SizedBox(
|
||||
width: 80,
|
||||
child: TextFormField(
|
||||
controller: _controller,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
||||
border: OutlineInputBorder(),
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.watch<AppState>();
|
||||
final current = appState.maxCameras;
|
||||
final showWarning = current > 1000;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.filter_alt),
|
||||
title: Text(locService.t('settings.maxNodes')),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(locService.t('settings.maxNodesSubtitle')),
|
||||
if (showWarning)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.warning, color: Colors.orange, size: 18),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(child: Text(
|
||||
locService.t('settings.maxNodesWarning'),
|
||||
style: const TextStyle(color: Colors.orange),
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: SizedBox(
|
||||
width: 80,
|
||||
child: TextFormField(
|
||||
controller: _controller,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onFieldSubmitted: (value) {
|
||||
final n = int.tryParse(value) ?? 10;
|
||||
appState.maxCameras = n;
|
||||
_controller.text = appState.maxCameras.toString();
|
||||
},
|
||||
),
|
||||
),
|
||||
onFieldSubmitted: (value) {
|
||||
final n = int.tryParse(value) ?? 10;
|
||||
appState.maxCameras = n;
|
||||
_controller.text = appState.maxCameras.toString();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,14 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../../services/offline_area_service.dart';
|
||||
import '../../services/localization_service.dart';
|
||||
|
||||
class OfflineModeSection extends StatelessWidget {
|
||||
const OfflineModeSection({super.key});
|
||||
|
||||
Future<void> _handleOfflineModeChange(BuildContext context, AppState appState, bool value) async {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
// If enabling offline mode, check for active downloads
|
||||
if (value && !appState.offlineMode) {
|
||||
final offlineService = OfflineAreaService();
|
||||
@@ -16,19 +19,17 @@ class OfflineModeSection extends StatelessWidget {
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Row(
|
||||
children: const [
|
||||
Icon(Icons.warning, color: Colors.orange),
|
||||
SizedBox(width: 8),
|
||||
Text('Active Downloads'),
|
||||
children: [
|
||||
const Icon(Icons.warning, color: Colors.orange),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('settings.offlineModeWarningTitle')),
|
||||
],
|
||||
),
|
||||
content: const Text(
|
||||
'Enabling offline mode will cancel any active area downloads. Do you want to continue?',
|
||||
),
|
||||
content: Text(locService.t('settings.offlineModeWarningMessage')),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(locService.cancel),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
@@ -36,7 +37,7 @@ class OfflineModeSection extends StatelessWidget {
|
||||
backgroundColor: Colors.orange,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('Enable Offline Mode'),
|
||||
child: Text(locService.t('settings.enableOfflineMode')),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -54,15 +55,22 @@ class OfflineModeSection extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.wifi_off),
|
||||
title: const Text('Offline Mode'),
|
||||
subtitle: const Text('Disable all network requests except for local/offline areas.'),
|
||||
trailing: Switch(
|
||||
value: appState.offlineMode,
|
||||
onChanged: (value) => _handleOfflineModeChange(context, appState, value),
|
||||
),
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.wifi_off),
|
||||
title: Text(locService.t('settings.offlineMode')),
|
||||
subtitle: Text(locService.t('settings.offlineModeSubtitle')),
|
||||
trailing: Switch(
|
||||
value: appState.offlineMode,
|
||||
onChanged: (value) => _handleOfflineModeChange(context, appState, value),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:uuid/uuid.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../../models/operator_profile.dart';
|
||||
import '../../services/localization_service.dart';
|
||||
import '../operator_profile_editor.dart';
|
||||
|
||||
class OperatorProfileListSection extends StatelessWidget {
|
||||
@@ -10,110 +11,117 @@ class OperatorProfileListSection extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
return Column(
|
||||
children: [
|
||||
const Text('Operator Profiles', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
TextButton.icon(
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => OperatorProfileEditor(
|
||||
profile: OperatorProfile(
|
||||
id: const Uuid().v4(),
|
||||
name: '',
|
||||
tags: const {},
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(locService.t('operatorProfiles.title'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
TextButton.icon(
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => OperatorProfileEditor(
|
||||
profile: OperatorProfile(
|
||||
id: const Uuid().v4(),
|
||||
name: '',
|
||||
tags: const {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(locService.t('profiles.newProfile')),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (appState.operatorProfiles.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
locService.t('operatorProfiles.noProfilesMessage'),
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
)
|
||||
else
|
||||
...appState.operatorProfiles.map(
|
||||
(p) => ListTile(
|
||||
title: Text(p.name),
|
||||
subtitle: Text(locService.t('operatorProfiles.tagsCount', params: [p.tags.length.toString()])),
|
||||
trailing: PopupMenuButton(
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.edit),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('actions.edit')),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.delete, color: Colors.red),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('operatorProfiles.deleteOperatorProfile'), style: const TextStyle(color: Colors.red)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onSelected: (value) {
|
||||
if (value == 'edit') {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => OperatorProfileEditor(profile: p),
|
||||
),
|
||||
);
|
||||
} else if (value == 'delete') {
|
||||
_showDeleteProfileDialog(context, p);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('New Profile'),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (appState.operatorProfiles.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
'No operator profiles defined. Create one to apply operator tags to node submissions.',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
)
|
||||
else
|
||||
...appState.operatorProfiles.map(
|
||||
(p) => ListTile(
|
||||
title: Text(p.name),
|
||||
subtitle: Text('${p.tags.length} tags'),
|
||||
trailing: PopupMenuButton(
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.edit),
|
||||
SizedBox(width: 8),
|
||||
Text('Edit'),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.delete, color: Colors.red),
|
||||
SizedBox(width: 8),
|
||||
Text('Delete', style: TextStyle(color: Colors.red)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onSelected: (value) {
|
||||
if (value == 'edit') {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => OperatorProfileEditor(profile: p),
|
||||
),
|
||||
);
|
||||
} else if (value == 'delete') {
|
||||
_showDeleteProfileDialog(context, p);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showDeleteProfileDialog(BuildContext context, OperatorProfile profile) {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.read<AppState>();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Delete Operator Profile'),
|
||||
content: Text('Are you sure you want to delete "${profile.name}"?'),
|
||||
title: Text(locService.t('operatorProfiles.deleteOperatorProfile')),
|
||||
content: Text(locService.t('operatorProfiles.deleteOperatorProfileConfirm', params: [profile.name])),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(locService.t('actions.cancel')),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
appState.deleteOperatorProfile(profile);
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Operator profile deleted')),
|
||||
SnackBar(content: Text(locService.t('operatorProfiles.operatorProfileDeleted'))),
|
||||
);
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('Delete'),
|
||||
child: Text(locService.t('operatorProfiles.deleteOperatorProfile')),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:uuid/uuid.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../../models/node_profile.dart';
|
||||
import '../../services/localization_service.dart';
|
||||
import '../profile_editor.dart';
|
||||
|
||||
class ProfileListSection extends StatelessWidget {
|
||||
@@ -10,129 +11,136 @@ class ProfileListSection extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
return Column(
|
||||
children: [
|
||||
const Text('Node Profiles', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
TextButton.icon(
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ProfileEditor(
|
||||
profile: NodeProfile(
|
||||
id: const Uuid().v4(),
|
||||
name: '',
|
||||
tags: const {},
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(locService.t('profiles.nodeProfiles'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
TextButton.icon(
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ProfileEditor(
|
||||
profile: NodeProfile(
|
||||
id: const Uuid().v4(),
|
||||
name: '',
|
||||
tags: const {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(locService.t('profiles.newProfile')),
|
||||
),
|
||||
],
|
||||
),
|
||||
...appState.profiles.map(
|
||||
(p) => ListTile(
|
||||
leading: Checkbox(
|
||||
value: appState.isEnabled(p),
|
||||
onChanged: (v) => appState.toggleProfile(p, v ?? false),
|
||||
),
|
||||
title: Text(p.name),
|
||||
subtitle: Text(p.builtin ? locService.t('profiles.builtIn') : locService.t('profiles.custom')),
|
||||
trailing: !p.editable
|
||||
? PopupMenuButton(
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'view',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.visibility),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('profiles.view')),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onSelected: (value) {
|
||||
if (value == 'view') {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ProfileEditor(profile: p),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
)
|
||||
: PopupMenuButton(
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.edit),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('actions.edit')),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.delete, color: Colors.red),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('profiles.deleteProfile'), style: const TextStyle(color: Colors.red)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onSelected: (value) {
|
||||
if (value == 'edit') {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ProfileEditor(profile: p),
|
||||
),
|
||||
);
|
||||
} else if (value == 'delete') {
|
||||
_showDeleteProfileDialog(context, p);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('New Profile'),
|
||||
),
|
||||
],
|
||||
),
|
||||
...appState.profiles.map(
|
||||
(p) => ListTile(
|
||||
leading: Checkbox(
|
||||
value: appState.isEnabled(p),
|
||||
onChanged: (v) => appState.toggleProfile(p, v ?? false),
|
||||
),
|
||||
title: Text(p.name),
|
||||
subtitle: Text(p.builtin ? 'Built-in' : 'Custom'),
|
||||
trailing: !p.editable
|
||||
? PopupMenuButton(
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'view',
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.visibility),
|
||||
SizedBox(width: 8),
|
||||
Text('View'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onSelected: (value) {
|
||||
if (value == 'view') {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ProfileEditor(profile: p),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
)
|
||||
: PopupMenuButton(
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.edit),
|
||||
SizedBox(width: 8),
|
||||
Text('Edit'),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.delete, color: Colors.red),
|
||||
SizedBox(width: 8),
|
||||
Text('Delete', style: TextStyle(color: Colors.red)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onSelected: (value) {
|
||||
if (value == 'edit') {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ProfileEditor(profile: p),
|
||||
),
|
||||
);
|
||||
} else if (value == 'delete') {
|
||||
_showDeleteProfileDialog(context, p);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showDeleteProfileDialog(BuildContext context, NodeProfile profile) {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.read<AppState>();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Delete Profile'),
|
||||
content: Text('Are you sure you want to delete "${profile.name}"?'),
|
||||
title: Text(locService.t('profiles.deleteProfile')),
|
||||
content: Text(locService.t('profiles.deleteProfileConfirm', params: [profile.name])),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(locService.t('actions.cancel')),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
appState.deleteProfile(profile);
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Profile deleted')),
|
||||
SnackBar(content: Text(locService.t('profiles.profileDeleted'))),
|
||||
);
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('Delete'),
|
||||
child: Text(locService.t('profiles.deleteProfile')),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../../services/localization_service.dart';
|
||||
import '../../state/settings_state.dart';
|
||||
|
||||
class QueueSection extends StatelessWidget {
|
||||
const QueueSection({super.key});
|
||||
|
||||
String _getUploadModeDisplayName(UploadMode mode) {
|
||||
final locService = LocalizationService.instance;
|
||||
switch (mode) {
|
||||
case UploadMode.production:
|
||||
return 'Production';
|
||||
return locService.t('uploadMode.production');
|
||||
case UploadMode.sandbox:
|
||||
return 'Sandbox';
|
||||
return locService.t('uploadMode.sandbox');
|
||||
case UploadMode.simulate:
|
||||
return 'Simulate';
|
||||
return locService.t('uploadMode.simulate');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,67 +32,75 @@ class QueueSection extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.queue),
|
||||
title: Text('Pending uploads: ${appState.pendingCount}'),
|
||||
subtitle: appState.uploadMode == UploadMode.simulate
|
||||
? const Text('Simulate mode enabled – uploads simulated')
|
||||
: appState.uploadMode == UploadMode.sandbox
|
||||
? const Text('Sandbox mode – uploads go to OSM Sandbox')
|
||||
: const Text('Tap to view queue'),
|
||||
onTap: appState.pendingCount > 0
|
||||
? () => _showQueueDialog(context)
|
||||
: null,
|
||||
),
|
||||
if (appState.pendingCount > 0)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.clear_all),
|
||||
title: const Text('Clear Upload Queue'),
|
||||
subtitle: Text('Remove all ${appState.pendingCount} pending uploads'),
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Clear Queue'),
|
||||
content: Text('Remove all ${appState.pendingCount} pending uploads?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.queue),
|
||||
title: Text(locService.t('queue.pendingUploads', params: [appState.pendingCount.toString()])),
|
||||
subtitle: appState.uploadMode == UploadMode.simulate
|
||||
? Text(locService.t('queue.simulateModeEnabled'))
|
||||
: appState.uploadMode == UploadMode.sandbox
|
||||
? Text(locService.t('queue.sandboxMode'))
|
||||
: Text(locService.t('queue.tapToViewQueue')),
|
||||
onTap: appState.pendingCount > 0
|
||||
? () => _showQueueDialog(context)
|
||||
: null,
|
||||
),
|
||||
if (appState.pendingCount > 0)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.clear_all),
|
||||
title: Text(locService.t('queue.clearUploadQueue')),
|
||||
subtitle: Text(locService.t('queue.removeAllPending', params: [appState.pendingCount.toString()])),
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(locService.t('queue.clearQueueTitle')),
|
||||
content: Text(locService.t('queue.clearQueueConfirm', params: [appState.pendingCount.toString()])),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(locService.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
appState.clearQueue();
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(locService.t('queue.queueCleared'))),
|
||||
);
|
||||
},
|
||||
child: Text(locService.t('actions.clear')),
|
||||
),
|
||||
],
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
appState.clearQueue();
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Queue cleared')),
|
||||
);
|
||||
},
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showQueueDialog(BuildContext context) {
|
||||
final locService = LocalizationService.instance;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => Consumer<AppState>(
|
||||
builder: (context, appState, child) => AlertDialog(
|
||||
title: Text('Upload Queue (${appState.pendingCount} items)'),
|
||||
title: Text(locService.t('queue.uploadQueueTitle', params: [appState.pendingCount.toString()])),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
height: 300,
|
||||
child: appState.pendingUploads.isEmpty
|
||||
? const Center(child: Text('Queue is empty'))
|
||||
? Center(child: Text(locService.t('queue.queueIsEmpty')))
|
||||
: ListView.builder(
|
||||
itemCount: appState.pendingUploads.length,
|
||||
itemBuilder: (context, index) {
|
||||
@@ -102,16 +112,16 @@ class QueueSection extends StatelessWidget {
|
||||
? Colors.red
|
||||
: _getUploadModeColor(upload.uploadMode),
|
||||
),
|
||||
title: Text('Camera ${index + 1}'
|
||||
'${upload.error ? " (Error)" : ""}'
|
||||
'${upload.completing ? " (Completing...)" : ""}'),
|
||||
title: Text(locService.t('queue.cameraWithIndex', params: [(index + 1).toString()]) +
|
||||
(upload.error ? locService.t('queue.error') : "") +
|
||||
(upload.completing ? locService.t('queue.completing') : "")),
|
||||
subtitle: Text(
|
||||
'Dest: ${_getUploadModeDisplayName(upload.uploadMode)}\n'
|
||||
'Lat: ${upload.coord.latitude.toStringAsFixed(6)}\n'
|
||||
'Lon: ${upload.coord.longitude.toStringAsFixed(6)}\n'
|
||||
'Direction: ${upload.direction.round()}°\n'
|
||||
'Attempts: ${upload.attempts}' +
|
||||
(upload.error ? "\nUpload failed. Tap retry to try again." : "")
|
||||
locService.t('queue.destination', params: [_getUploadModeDisplayName(upload.uploadMode)]) + '\n' +
|
||||
locService.t('queue.latitude', params: [upload.coord.latitude.toStringAsFixed(6)]) + '\n' +
|
||||
locService.t('queue.longitude', params: [upload.coord.longitude.toStringAsFixed(6)]) + '\n' +
|
||||
locService.t('queue.direction', params: [upload.direction.round().toString()]) + '\n' +
|
||||
locService.t('queue.attempts', params: [upload.attempts.toString()]) +
|
||||
(upload.error ? "\n${locService.t('queue.uploadFailedRetry')}" : "")
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -120,7 +130,7 @@ class QueueSection extends StatelessWidget {
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
color: Colors.orange,
|
||||
tooltip: 'Retry upload',
|
||||
tooltip: locService.t('queue.retryUpload'),
|
||||
onPressed: () {
|
||||
appState.retryUpload(upload);
|
||||
},
|
||||
@@ -146,7 +156,7 @@ class QueueSection extends StatelessWidget {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
child: Text(locService.t('actions.close')),
|
||||
),
|
||||
if (appState.pendingCount > 1)
|
||||
TextButton(
|
||||
@@ -154,10 +164,10 @@ class QueueSection extends StatelessWidget {
|
||||
appState.clearQueue();
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Queue cleared')),
|
||||
SnackBar(content: Text(locService.t('queue.queueCleared'))),
|
||||
);
|
||||
},
|
||||
child: const Text('Clear All'),
|
||||
child: Text(locService.t('queue.clearAll')),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import '../../app_state.dart';
|
||||
import '../../models/tile_provider.dart';
|
||||
import '../../services/localization_service.dart';
|
||||
import '../tile_provider_management_screen.dart';
|
||||
|
||||
class TileProviderSection extends StatelessWidget {
|
||||
@@ -10,29 +11,36 @@ class TileProviderSection extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Map Tiles',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const TileProviderManagementScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.settings),
|
||||
label: const Text('Manage Providers'),
|
||||
),
|
||||
),
|
||||
],
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('mapTiles.title'),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const TileProviderManagementScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.settings),
|
||||
label: Text(locService.t('mapTiles.manageProviders')),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +1,85 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../../services/localization_service.dart';
|
||||
|
||||
class UploadModeSection extends StatelessWidget {
|
||||
const UploadModeSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.cloud_upload),
|
||||
title: const Text('Upload Destination'),
|
||||
subtitle: const Text('Choose where cameras are uploaded'),
|
||||
trailing: DropdownButton<UploadMode>(
|
||||
value: appState.uploadMode,
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: UploadMode.production,
|
||||
child: Text('Production'),
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.cloud_upload),
|
||||
title: Text(locService.t('uploadMode.title')),
|
||||
subtitle: Text(locService.t('uploadMode.subtitle')),
|
||||
trailing: DropdownButton<UploadMode>(
|
||||
value: appState.uploadMode,
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: UploadMode.production,
|
||||
child: Text(locService.t('uploadMode.production')),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: UploadMode.sandbox,
|
||||
child: Text(locService.t('uploadMode.sandbox')),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: UploadMode.simulate,
|
||||
child: Text(locService.t('uploadMode.simulate')),
|
||||
),
|
||||
],
|
||||
onChanged: (mode) {
|
||||
if (mode != null) appState.setUploadMode(mode);
|
||||
},
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: UploadMode.sandbox,
|
||||
child: Text('Sandbox'),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 56, top: 2, right: 16, bottom: 12),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
switch (appState.uploadMode) {
|
||||
case UploadMode.production:
|
||||
return Text(
|
||||
locService.t('uploadMode.productionDescription'),
|
||||
style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7))
|
||||
);
|
||||
case UploadMode.sandbox:
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('uploadMode.sandboxDescription'),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.orange),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
locService.t('uploadMode.sandboxNote'),
|
||||
style: const TextStyle(fontSize: 11, color: Colors.redAccent),
|
||||
),
|
||||
],
|
||||
);
|
||||
case UploadMode.simulate:
|
||||
default:
|
||||
return Text(
|
||||
locService.t('uploadMode.simulateDescription'),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.deepPurple)
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: UploadMode.simulate,
|
||||
child: Text('Simulate'),
|
||||
),
|
||||
],
|
||||
onChanged: (mode) {
|
||||
if (mode != null) appState.setUploadMode(mode);
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 56, top: 2, right: 16, bottom: 12),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
switch (appState.uploadMode) {
|
||||
case UploadMode.production:
|
||||
return const Text('Upload to the live OSM database (visible to all users)', style: TextStyle(fontSize: 12, color: Colors.black87));
|
||||
case UploadMode.sandbox:
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Uploads go to the OSM Sandbox (safe for testing, resets regularly).',
|
||||
style: TextStyle(fontSize: 12, color: Colors.orange),
|
||||
),
|
||||
SizedBox(height: 2),
|
||||
Text(
|
||||
'NOTE: Due to OpenStreetMap limitations, cameras submitted to the sandbox will NOT appear on the map in this app.',
|
||||
style: TextStyle(fontSize: 11, color: Colors.redAccent),
|
||||
),
|
||||
],
|
||||
);
|
||||
case UploadMode.simulate:
|
||||
default:
|
||||
return const Text('Simulate uploads (does not contact OSM servers)', style: TextStyle(fontSize: 12, color: Colors.deepPurple));
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:collection/collection.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../models/tile_provider.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
class TileProviderEditorScreen extends StatefulWidget {
|
||||
final TileProvider? provider; // null for adding new provider
|
||||
@@ -44,107 +45,114 @@ class _TileProviderEditorScreenState extends State<TileProviderEditorScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_isEditing ? 'Edit Provider' : 'Add Provider'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _saveProvider,
|
||||
child: const Text('Save'),
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_isEditing ? locService.t('tileProviders.editProvider') : locService.t('tileProviders.addProvider')),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _saveProvider,
|
||||
child: Text(locService.t('tileTypeEditor.save')),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Provider Name',
|
||||
hintText: 'e.g., Custom Maps Inc.',
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Provider name is required';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _apiKeyController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'API Key (Optional)',
|
||||
hintText: 'Enter API key if required by tile types',
|
||||
),
|
||||
obscureText: true,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
body: Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Text(
|
||||
'Tile Types',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: locService.t('tileProviders.providerName'),
|
||||
hintText: locService.t('tileProviders.providerNameHint'),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return locService.t('tileProviders.providerNameRequired');
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: _addTileType,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add Type'),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _apiKeyController,
|
||||
decoration: InputDecoration(
|
||||
labelText: locService.t('tileProviders.apiKey'),
|
||||
hintText: locService.t('tileProviders.apiKeyHint'),
|
||||
),
|
||||
obscureText: true,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('tileProviders.tileTypes'),
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: _addTileType,
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(locService.t('tileProviders.addType')),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_tileTypes.isEmpty)
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(locService.t('tileProviders.noTileTypesConfigured')),
|
||||
),
|
||||
)
|
||||
else
|
||||
..._tileTypes.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final tileType = entry.value;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
title: Text(tileType.name),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(tileType.urlTemplate),
|
||||
Text(
|
||||
tileType.attribution,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: () => _editTileType(index),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: _tileTypes.length > 1
|
||||
? () => _deleteTileType(index)
|
||||
: null, // Can't delete last tile type
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () => _editTileType(index),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_tileTypes.isEmpty)
|
||||
const Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text('No tile types configured'),
|
||||
),
|
||||
)
|
||||
else
|
||||
..._tileTypes.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final tileType = entry.value;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
title: Text(tileType.name),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(tileType.urlTemplate),
|
||||
Text(
|
||||
tileType.attribution,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: () => _editTileType(index),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: _tileTypes.length > 1
|
||||
? () => _deleteTileType(index)
|
||||
: null, // Can't delete last tile type
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () => _editTileType(index),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -207,10 +215,11 @@ class _TileProviderEditorScreenState extends State<TileProviderEditorScreen> {
|
||||
}
|
||||
|
||||
void _saveProvider() {
|
||||
final locService = LocalizationService.instance;
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
if (_tileTypes.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('At least one tile type is required')),
|
||||
SnackBar(content: Text(locService.t('tileProviders.atLeastOneTileTypeRequired'))),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -269,91 +278,99 @@ class _TileTypeDialogState extends State<_TileTypeDialog> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(widget.tileType != null ? 'Edit Tile Type' : 'Add Tile Type'),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Name',
|
||||
hintText: 'e.g., Satellite',
|
||||
),
|
||||
validator: (value) => value?.trim().isEmpty == true ? 'Name is required' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _urlController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'URL Template',
|
||||
hintText: 'https://example.com/{z}/{x}/{y}.png',
|
||||
),
|
||||
validator: (value) {
|
||||
if (value?.trim().isEmpty == true) return 'URL template is required';
|
||||
if (!value!.contains('{z}') || !value.contains('{x}') || !value.contains('{y}')) {
|
||||
return 'URL must contain {z}, {x}, and {y} placeholders';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _attributionController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Attribution',
|
||||
hintText: '© Map Provider',
|
||||
),
|
||||
validator: (value) => value?.trim().isEmpty == true ? 'Attribution is required' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(widget.tileType != null ? locService.t('tileTypeEditor.editTileType') : locService.t('tileTypeEditor.addTileType')),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: _isLoadingPreview ? null : _fetchPreviewTile,
|
||||
icon: _isLoadingPreview
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.preview),
|
||||
label: const Text('Fetch Preview'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (_previewTile != null)
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey),
|
||||
),
|
||||
child: Image.memory(_previewTile!, fit: BoxFit.cover),
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: locService.t('tileTypeEditor.name'),
|
||||
hintText: locService.t('tileTypeEditor.nameHint'),
|
||||
),
|
||||
validator: (value) => value?.trim().isEmpty == true ? locService.t('tileTypeEditor.nameRequired') : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _urlController,
|
||||
decoration: InputDecoration(
|
||||
labelText: locService.t('tileTypeEditor.urlTemplate'),
|
||||
hintText: locService.t('tileTypeEditor.urlTemplateHint'),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value?.trim().isEmpty == true) return locService.t('tileTypeEditor.urlTemplateRequired');
|
||||
if (!value!.contains('{z}') || !value.contains('{x}') || !value.contains('{y}')) {
|
||||
return locService.t('tileTypeEditor.urlTemplatePlaceholders');
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _attributionController,
|
||||
decoration: InputDecoration(
|
||||
labelText: locService.t('tileTypeEditor.attribution'),
|
||||
hintText: locService.t('tileTypeEditor.attributionHint'),
|
||||
),
|
||||
validator: (value) => value?.trim().isEmpty == true ? locService.t('tileTypeEditor.attributionRequired') : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: _isLoadingPreview ? null : _fetchPreviewTile,
|
||||
icon: _isLoadingPreview
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.preview),
|
||||
label: Text(locService.t('tileTypeEditor.fetchPreview')),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (_previewTile != null)
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey),
|
||||
),
|
||||
child: Image.memory(_previewTile!, fit: BoxFit.cover),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _saveTileType,
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(locService.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _saveTileType,
|
||||
child: Text(locService.t('tileTypeEditor.save')),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _fetchPreviewTile() async {
|
||||
final locService = LocalizationService.instance;
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
setState(() {
|
||||
@@ -376,7 +393,7 @@ class _TileTypeDialogState extends State<_TileTypeDialog> {
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Preview tile loaded successfully')),
|
||||
SnackBar(content: Text(locService.t('tileTypeEditor.previewTileLoaded'))),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -385,7 +402,7 @@ class _TileTypeDialogState extends State<_TileTypeDialog> {
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to fetch preview: $e')),
|
||||
SnackBar(content: Text(locService.t('tileTypeEditor.previewTileFailed', params: [e.toString()]))),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../models/tile_provider.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import 'tile_provider_editor_screen.dart';
|
||||
|
||||
class TileProviderManagementScreen extends StatelessWidget {
|
||||
@@ -10,112 +11,118 @@ class TileProviderManagementScreen extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
final providers = appState.tileProviders;
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.watch<AppState>();
|
||||
final providers = appState.tileProviders;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Tile Providers'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: () => _addProvider(context),
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(locService.t('tileProviders.title')),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: () => _addProvider(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: providers.isEmpty
|
||||
? const Center(
|
||||
child: Text('No tile providers configured'),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: providers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final provider = providers[index];
|
||||
final isSelected = appState.selectedTileProvider?.id == provider.id;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: ListTile(
|
||||
title: Text(
|
||||
provider.name,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.bold : null,
|
||||
),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${provider.tileTypes.length} tile types'),
|
||||
if (provider.apiKey?.isNotEmpty == true)
|
||||
const Text(
|
||||
'API Key configured',
|
||||
style: TextStyle(
|
||||
fontStyle: FontStyle.italic,
|
||||
fontSize: 12,
|
||||
),
|
||||
body: providers.isEmpty
|
||||
? Center(
|
||||
child: Text(locService.t('tileProviders.noProvidersConfigured')),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: providers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final provider = providers[index];
|
||||
final isSelected = appState.selectedTileProvider?.id == provider.id;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: ListTile(
|
||||
title: Text(
|
||||
provider.name,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.bold : null,
|
||||
),
|
||||
if (!provider.isUsable)
|
||||
Text(
|
||||
'Needs API key',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.surfaceVariant,
|
||||
child: Icon(
|
||||
Icons.map,
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.onPrimary
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
trailing: providers.length > 1
|
||||
? PopupMenuButton<String>(
|
||||
onSelected: (action) {
|
||||
switch (action) {
|
||||
case 'edit':
|
||||
_editProvider(context, provider);
|
||||
break;
|
||||
case 'delete':
|
||||
_deleteProvider(context, provider);
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.edit),
|
||||
SizedBox(width: 8),
|
||||
Text('Edit'),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(locService.t('tileProviders.tileTypesCount', params: [provider.tileTypes.length.toString()])),
|
||||
if (provider.apiKey?.isNotEmpty == true)
|
||||
Text(
|
||||
locService.t('tileProviders.apiKeyConfigured'),
|
||||
style: const TextStyle(
|
||||
fontStyle: FontStyle.italic,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.delete),
|
||||
SizedBox(width: 8),
|
||||
Text('Delete'),
|
||||
],
|
||||
if (!provider.isUsable)
|
||||
Text(
|
||||
locService.t('tileProviders.needsApiKey'),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: const Icon(Icons.lock, size: 16), // Can't delete last provider
|
||||
onTap: () => _editProvider(context, provider),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.surfaceVariant,
|
||||
child: Icon(
|
||||
Icons.map,
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.onPrimary
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
trailing: providers.length > 1
|
||||
? PopupMenuButton<String>(
|
||||
onSelected: (action) {
|
||||
switch (action) {
|
||||
case 'edit':
|
||||
_editProvider(context, provider);
|
||||
break;
|
||||
case 'delete':
|
||||
_deleteProvider(context, provider);
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.edit),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('actions.edit')),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.delete),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('tileProviders.deleteProvider')),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: const Icon(Icons.lock, size: 16), // Can't delete last provider
|
||||
onTap: () => _editProvider(context, provider),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -136,22 +143,23 @@ class TileProviderManagementScreen extends StatelessWidget {
|
||||
}
|
||||
|
||||
void _deleteProvider(BuildContext context, TileProvider provider) {
|
||||
final locService = LocalizationService.instance;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Delete Provider'),
|
||||
content: Text('Are you sure you want to delete "${provider.name}"?'),
|
||||
title: Text(locService.t('tileProviders.deleteProvider')),
|
||||
content: Text(locService.t('tileProviders.deleteProviderConfirm', params: [provider.name])),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(locService.t('actions.cancel')),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.read<AppState>().deleteTileProvider(provider.id);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Delete'),
|
||||
child: Text(locService.t('tileProviders.deleteProvider')),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -13,7 +13,7 @@ import '../app_state.dart' show UploadMode;
|
||||
|
||||
class AuthService {
|
||||
// Both client IDs from keys.dart
|
||||
static const _redirect = 'flockmap://auth';
|
||||
static const _redirect = 'deflockapp://auth';
|
||||
|
||||
late OAuth2Helper _helper;
|
||||
String? _displayName;
|
||||
@@ -46,7 +46,7 @@ class AuthService {
|
||||
authorizeUrl: '$authBase/oauth2/authorize',
|
||||
tokenUrl: '$authBase/oauth2/token',
|
||||
redirectUri: _redirect,
|
||||
customUriScheme: 'flockmap',
|
||||
customUriScheme: 'deflockapp',
|
||||
);
|
||||
_helper = OAuth2Helper(
|
||||
client,
|
||||
|
||||
131
lib/services/localization_service.dart
Normal file
131
lib/services/localization_service.dart
Normal file
@@ -0,0 +1,131 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class LocalizationService extends ChangeNotifier {
|
||||
static LocalizationService? _instance;
|
||||
static LocalizationService get instance => _instance ??= LocalizationService._();
|
||||
|
||||
LocalizationService._();
|
||||
|
||||
String _currentLanguage = 'en';
|
||||
Map<String, dynamic> _strings = {};
|
||||
List<String> _availableLanguages = [];
|
||||
|
||||
String get currentLanguage => _currentLanguage;
|
||||
List<String> get availableLanguages => _availableLanguages;
|
||||
|
||||
Future<void> init() async {
|
||||
await _discoverAvailableLanguages();
|
||||
await _loadSavedLanguage();
|
||||
await _loadStrings();
|
||||
}
|
||||
|
||||
Future<void> _discoverAvailableLanguages() async {
|
||||
// For now, we'll hardcode the languages we support
|
||||
// In the future, this could scan the assets directory
|
||||
_availableLanguages = ['en', 'es', 'fr', 'de'];
|
||||
}
|
||||
|
||||
Future<void> _loadSavedLanguage() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final savedLanguage = prefs.getString('language_code');
|
||||
|
||||
if (savedLanguage != null && _availableLanguages.contains(savedLanguage)) {
|
||||
_currentLanguage = savedLanguage;
|
||||
} else {
|
||||
// Use system default or fallback to English
|
||||
final systemLocale = Platform.localeName.split('_')[0];
|
||||
if (_availableLanguages.contains(systemLocale)) {
|
||||
_currentLanguage = systemLocale;
|
||||
} else {
|
||||
_currentLanguage = 'en';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadStrings() async {
|
||||
try {
|
||||
final String jsonString = await rootBundle.loadString('lib/localizations/$_currentLanguage.json');
|
||||
_strings = json.decode(jsonString);
|
||||
} catch (e) {
|
||||
// Fallback to English if the language file doesn't exist
|
||||
if (_currentLanguage != 'en') {
|
||||
try {
|
||||
final String fallbackString = await rootBundle.loadString('lib/localizations/en.json');
|
||||
_strings = json.decode(fallbackString);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to load fallback language file: $e');
|
||||
_strings = {};
|
||||
}
|
||||
} else {
|
||||
debugPrint('Failed to load language file for $_currentLanguage: $e');
|
||||
_strings = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setLanguage(String? languageCode) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
if (languageCode == null) {
|
||||
// System default
|
||||
await prefs.remove('language_code');
|
||||
final systemLocale = Platform.localeName.split('_')[0];
|
||||
_currentLanguage = _availableLanguages.contains(systemLocale) ? systemLocale : 'en';
|
||||
} else {
|
||||
await prefs.setString('language_code', languageCode);
|
||||
_currentLanguage = languageCode;
|
||||
}
|
||||
|
||||
await _loadStrings();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
String t(String key, {List<String>? params}) {
|
||||
List<String> keys = key.split('.');
|
||||
dynamic current = _strings;
|
||||
|
||||
for (String k in keys) {
|
||||
if (current is Map && current.containsKey(k)) {
|
||||
current = current[k];
|
||||
} else {
|
||||
// Return the key as fallback for missing translations
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
String result = current is String ? current : key;
|
||||
|
||||
// Replace parameters if provided
|
||||
if (params != null) {
|
||||
for (int i = 0; i < params.length; i++) {
|
||||
result = result.replaceAll('{}', params[i]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get display name for a specific language code
|
||||
Future<String> getLanguageDisplayName(String languageCode) async {
|
||||
try {
|
||||
final String jsonString = await rootBundle.loadString('lib/localizations/$languageCode.json');
|
||||
final Map<String, dynamic> langData = json.decode(jsonString);
|
||||
return langData['language']?['name'] ?? languageCode.toUpperCase();
|
||||
} catch (e) {
|
||||
return languageCode.toUpperCase(); // Fallback to language code
|
||||
}
|
||||
}
|
||||
|
||||
// Helper methods for common strings
|
||||
String get appTitle => t('app.title');
|
||||
String get settings => t('actions.settings');
|
||||
String get tagNode => t('actions.tagNode');
|
||||
String get download => t('actions.download');
|
||||
String get edit => t('actions.edit');
|
||||
String get cancel => t('actions.cancel');
|
||||
String get ok => t('actions.ok');
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import 'dart:io';
|
||||
import 'dart:async';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flock_map_app/dev_config.dart';
|
||||
import 'package:deflockapp/dev_config.dart';
|
||||
import '../network_status.dart';
|
||||
|
||||
/// Global semaphore to limit simultaneous tile fetches
|
||||
|
||||
@@ -11,7 +11,7 @@ import 'offline_areas/world_area_manager.dart';
|
||||
import '../models/osm_camera_node.dart';
|
||||
import '../app_state.dart';
|
||||
import 'map_data_provider.dart';
|
||||
import 'package:flock_map_app/dev_config.dart';
|
||||
import 'package:deflockapp/dev_config.dart';
|
||||
|
||||
/// Service for managing download, storage, and retrieval of offline map areas and cameras.
|
||||
class OfflineAreaService {
|
||||
|
||||
@@ -10,7 +10,7 @@ import '../../models/osm_camera_node.dart';
|
||||
import '../map_data_provider.dart';
|
||||
import 'offline_area_models.dart';
|
||||
import 'offline_tile_utils.dart';
|
||||
import 'package:flock_map_app/dev_config.dart';
|
||||
import 'package:deflockapp/dev_config.dart';
|
||||
|
||||
/// Handles the actual downloading process for offline areas
|
||||
class OfflineAreaDownloader {
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'offline_area_models.dart';
|
||||
import 'offline_tile_utils.dart';
|
||||
import 'package:flock_map_app/dev_config.dart';
|
||||
import 'package:deflockapp/dev_config.dart';
|
||||
|
||||
/// Manages the world area (permanent offline area for base map)
|
||||
class WorldAreaManager {
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
|
||||
import '../app_state.dart';
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/operator_profile.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import 'refine_tags_sheet.dart';
|
||||
|
||||
class AddNodeSheet extends StatelessWidget {
|
||||
@@ -13,181 +14,187 @@ class AddNodeSheet extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
void _commit() {
|
||||
appState.commitSession();
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Node queued for upload')),
|
||||
);
|
||||
}
|
||||
void _commit() {
|
||||
appState.commitSession();
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(locService.t('node.queuedForUpload'))),
|
||||
);
|
||||
}
|
||||
|
||||
void _cancel() {
|
||||
appState.cancelSession();
|
||||
Navigator.pop(context);
|
||||
}
|
||||
void _cancel() {
|
||||
appState.cancelSession();
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList();
|
||||
final allowSubmit = appState.isLoggedIn && submittableProfiles.isNotEmpty && session.profile.isSubmittable;
|
||||
|
||||
void _openRefineTags() async {
|
||||
final result = await Navigator.push<OperatorProfile?>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RefineTagsSheet(
|
||||
selectedOperatorProfile: session.operatorProfile,
|
||||
),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
if (result != session.operatorProfile) {
|
||||
appState.updateSession(operatorProfile: result);
|
||||
}
|
||||
}
|
||||
final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList();
|
||||
final allowSubmit = appState.isLoggedIn && submittableProfiles.isNotEmpty && session.profile.isSubmittable;
|
||||
|
||||
void _openRefineTags() async {
|
||||
final result = await Navigator.push<OperatorProfile?>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RefineTagsSheet(
|
||||
selectedOperatorProfile: session.operatorProfile,
|
||||
),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
if (result != session.operatorProfile) {
|
||||
appState.updateSession(operatorProfile: result);
|
||||
}
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding:
|
||||
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade400,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
title: const Text('Profile'),
|
||||
trailing: DropdownButton<NodeProfile>(
|
||||
value: session.profile,
|
||||
items: submittableProfiles
|
||||
.map((p) => DropdownMenuItem(value: p, child: Text(p.name)))
|
||||
.toList(),
|
||||
onChanged: (p) =>
|
||||
appState.updateSession(profile: p ?? session.profile),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Direction ${session.directionDegrees.round()}°'),
|
||||
subtitle: Slider(
|
||||
min: 0,
|
||||
max: 359,
|
||||
divisions: 359,
|
||||
value: session.directionDegrees,
|
||||
label: session.directionDegrees.round().toString(),
|
||||
onChanged: session.profile.requiresDirection
|
||||
? (v) => appState.updateSession(directionDeg: v)
|
||||
: null, // Disables slider when requiresDirection is false
|
||||
),
|
||||
),
|
||||
if (!session.profile.requiresDirection)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Colors.grey, size: 16),
|
||||
SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'This profile does not require a direction.',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
return Padding(
|
||||
padding:
|
||||
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade400,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!appState.isLoggedIn)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Colors.red, size: 20),
|
||||
SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'You must be logged in to submit new nodes. Please log in via Settings.',
|
||||
style: TextStyle(color: Colors.red, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
title: Text(locService.t('addNode.profile')),
|
||||
trailing: DropdownButton<NodeProfile>(
|
||||
value: session.profile,
|
||||
items: submittableProfiles
|
||||
.map((p) => DropdownMenuItem(value: p, child: Text(p.name)))
|
||||
.toList(),
|
||||
onChanged: (p) =>
|
||||
appState.updateSession(profile: p ?? session.profile),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (submittableProfiles.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Colors.red, size: 20),
|
||||
SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Enable a submittable profile in Settings to submit new nodes.',
|
||||
style: TextStyle(color: Colors.red, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
ListTile(
|
||||
title: Text(locService.t('addNode.direction', params: [session.directionDegrees.round().toString()])),
|
||||
subtitle: Slider(
|
||||
min: 0,
|
||||
max: 359,
|
||||
divisions: 359,
|
||||
value: session.directionDegrees,
|
||||
label: session.directionDegrees.round().toString(),
|
||||
onChanged: session.profile.requiresDirection
|
||||
? (v) => appState.updateSession(directionDeg: v)
|
||||
: null, // Disables slider when requiresDirection is false
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (!session.profile.isSubmittable)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Colors.orange, size: 20),
|
||||
SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'This profile is for map viewing only. Please select a submittable profile to submit new nodes.',
|
||||
style: TextStyle(color: Colors.orange, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _openRefineTags,
|
||||
icon: const Icon(Icons.tune),
|
||||
label: Text(session.operatorProfile != null
|
||||
? 'Refine Tags (${session.operatorProfile!.name})'
|
||||
: 'Refine Tags'),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: _cancel,
|
||||
child: const Text('Cancel'),
|
||||
if (!session.profile.requiresDirection)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, color: Colors.grey, size: 16),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
locService.t('addNode.profileNoDirectionInfo'),
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: allowSubmit ? _commit : null,
|
||||
child: const Text('Submit'),
|
||||
if (!appState.isLoggedIn)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, color: Colors.red, size: 20),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
locService.t('addNode.mustBeLoggedIn'),
|
||||
style: const TextStyle(color: Colors.red, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (submittableProfiles.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, color: Colors.red, size: 20),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
locService.t('addNode.enableSubmittableProfile'),
|
||||
style: const TextStyle(color: Colors.red, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (!session.profile.isSubmittable)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, color: Colors.orange, size: 20),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
locService.t('addNode.profileViewOnlyWarning'),
|
||||
style: const TextStyle(color: Colors.orange, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _openRefineTags,
|
||||
icon: const Icon(Icons.tune),
|
||||
label: Text(session.operatorProfile != null
|
||||
? locService.t('addNode.refineTagsWithProfile', params: [session.operatorProfile!.name])
|
||||
: locService.t('addNode.refineTags')),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: _cancel,
|
||||
child: Text(locService.cancel),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: allowSubmit ? _commit : null,
|
||||
child: Text(locService.t('actions.submit')),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/osm_camera_node.dart';
|
||||
import '../app_state.dart';
|
||||
|
||||
class CameraTagSheet extends StatelessWidget {
|
||||
final OsmCameraNode node;
|
||||
|
||||
const CameraTagSheet({super.key, required this.node});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
// Check if this camera is editable (not a pending upload or pending edit)
|
||||
final isEditable = (!node.tags.containsKey('_pending_upload') ||
|
||||
node.tags['_pending_upload'] != 'true') &&
|
||||
(!node.tags.containsKey('_pending_edit') ||
|
||||
node.tags['_pending_edit'] != 'true');
|
||||
|
||||
void _openEditSheet() {
|
||||
Navigator.pop(context); // Close this sheet first
|
||||
appState.startEditSession(node); // HomeScreen will auto-show the edit sheet
|
||||
}
|
||||
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Camera #${node.id}',
|
||||
style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 12),
|
||||
...node.tags.entries.map(
|
||||
(e) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
e.key,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
e.value,
|
||||
style: const TextStyle(
|
||||
color: Colors.black54,
|
||||
),
|
||||
softWrap: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (isEditable) ...[
|
||||
ElevatedButton.icon(
|
||||
onPressed: _openEditSheet,
|
||||
icon: const Icon(Icons.edit, size: 18),
|
||||
label: const Text('Edit'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(0, 36),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import 'dart:math' as math;
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../dev_config.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../services/offline_area_service.dart';
|
||||
import '../services/offline_areas/offline_tile_utils.dart';
|
||||
|
||||
@@ -87,193 +88,202 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
final bounds = widget.controller.camera.visibleBounds;
|
||||
final maxZoom = _zoom.toInt();
|
||||
final isOfflineMode = appState.offlineMode;
|
||||
|
||||
// Use the calculated max possible zoom instead of fixed span
|
||||
final sliderMin = _minZoom?.toDouble() ?? 12.0;
|
||||
final sliderMax = _maxPossibleZoom?.toDouble() ?? 19.0;
|
||||
final sliderDivisions = math.max(1, (_maxPossibleZoom ?? 19) - (_minZoom ?? 12));
|
||||
final sliderValue = _zoom.clamp(sliderMin, sliderMax);
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.watch<AppState>();
|
||||
final bounds = widget.controller.camera.visibleBounds;
|
||||
final maxZoom = _zoom.toInt();
|
||||
final isOfflineMode = appState.offlineMode;
|
||||
|
||||
// Use the calculated max possible zoom instead of fixed span
|
||||
final sliderMin = _minZoom?.toDouble() ?? 12.0;
|
||||
final sliderMax = _maxPossibleZoom?.toDouble() ?? 19.0;
|
||||
final sliderDivisions = math.max(1, (_maxPossibleZoom ?? 19) - (_minZoom ?? 12));
|
||||
final sliderValue = _zoom.clamp(sliderMin, sliderMax);
|
||||
|
||||
return AlertDialog(
|
||||
title: Row(
|
||||
children: const [
|
||||
Icon(Icons.download_for_offline),
|
||||
SizedBox(width: 10),
|
||||
Text("Download Map Area"),
|
||||
],
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 350,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
return AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
const Icon(Icons.download_for_offline),
|
||||
const SizedBox(width: 10),
|
||||
Text(locService.t('download.title')),
|
||||
],
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 350,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Max zoom level'),
|
||||
Text('Z${_zoom.toStringAsFixed(0)}'),
|
||||
],
|
||||
),
|
||||
|
||||
Slider(
|
||||
min: sliderMin,
|
||||
max: sliderMax,
|
||||
divisions: sliderDivisions,
|
||||
label: 'Z${_zoom.toStringAsFixed(0)}',
|
||||
value: sliderValue,
|
||||
onChanged: (v) {
|
||||
setState(() => _zoom = v);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _recomputeEstimates());
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Storage estimate:'),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_mbEstimate == null
|
||||
? '…'
|
||||
: '${_tileCount} tiles, ${_mbEstimate!.toStringAsFixed(1)} MB',
|
||||
textAlign: TextAlign.end,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(locService.t('download.maxZoomLevel')),
|
||||
Text('Z${_zoom.toStringAsFixed(0)}'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_minZoom != null)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Min zoom:'),
|
||||
Text('Z$_minZoom'),
|
||||
],
|
||||
),
|
||||
if (_maxPossibleZoom != null && _tileCount != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: _tileCount! > kMaxReasonableTileCount
|
||||
? Colors.orange.withOpacity(0.1)
|
||||
: Colors.green.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
||||
Slider(
|
||||
min: sliderMin,
|
||||
max: sliderMax,
|
||||
divisions: sliderDivisions,
|
||||
label: 'Z${_zoom.toStringAsFixed(0)}',
|
||||
value: sliderValue,
|
||||
onChanged: (v) {
|
||||
setState(() => _zoom = v);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _recomputeEstimates());
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(locService.t('download.storageEstimate')),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_mbEstimate == null
|
||||
? '…'
|
||||
: locService.t('download.tilesAndSize', params: [
|
||||
_tileCount.toString(),
|
||||
_mbEstimate!.toStringAsFixed(1)
|
||||
]),
|
||||
textAlign: TextAlign.end,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_minZoom != null)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Max recommended zoom: Z$_maxPossibleZoom',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _tileCount! > kMaxReasonableTileCount
|
||||
? Colors.orange[700]
|
||||
: Colors.green[700],
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_tileCount! > kMaxReasonableTileCount
|
||||
? 'Current selection exceeds ${kMaxReasonableTileCount.toString()} tile limit'
|
||||
: 'Within ${kMaxReasonableTileCount.toString()} tile limit',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: _tileCount! > kMaxReasonableTileCount
|
||||
? Colors.orange[600]
|
||||
: Colors.green[600],
|
||||
),
|
||||
),
|
||||
Text(locService.t('download.minZoom')),
|
||||
Text('Z$_minZoom'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isOfflineMode)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12.0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.orange.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.wifi_off, color: Colors.orange[700], size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Downloads disabled while in offline mode. Disable offline mode to download new areas.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.orange[700],
|
||||
if (_maxPossibleZoom != null && _tileCount != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: _tileCount! > kMaxReasonableTileCount
|
||||
? Colors.orange.withOpacity(0.1)
|
||||
: Colors.green.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('download.maxRecommendedZoom', params: [_maxPossibleZoom.toString()]),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _tileCount! > kMaxReasonableTileCount
|
||||
? Colors.orange[700]
|
||||
: Colors.green[700],
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_tileCount! > kMaxReasonableTileCount
|
||||
? locService.t('download.exceedsTileLimit', params: [kMaxReasonableTileCount.toString()])
|
||||
: locService.t('download.withinTileLimit', params: [kMaxReasonableTileCount.toString()]),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: _tileCount! > kMaxReasonableTileCount
|
||||
? Colors.orange[600]
|
||||
: Colors.green[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isOfflineMode)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12.0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.orange.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.wifi_off, color: Colors.orange[700], size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
locService.t('download.offlineModeWarning'),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.orange[700],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(locService.cancel),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: isOfflineMode ? null : () async {
|
||||
try {
|
||||
final id = DateTime.now().toIso8601String().replaceAll(':', '-');
|
||||
final appDocDir = await OfflineAreaService().getOfflineAreaDir();
|
||||
final dir = "${appDocDir.path}/$id";
|
||||
|
||||
// Get current tile provider info
|
||||
final appState = context.read<AppState>();
|
||||
final selectedProvider = appState.selectedTileProvider;
|
||||
final selectedTileType = appState.selectedTileType;
|
||||
|
||||
// Fire and forget: don't await download, so dialog closes immediately
|
||||
// ignore: unawaited_futures
|
||||
OfflineAreaService().downloadArea(
|
||||
id: id,
|
||||
bounds: bounds,
|
||||
minZoom: _minZoom ?? 12,
|
||||
maxZoom: maxZoom,
|
||||
directory: dir,
|
||||
onProgress: (progress) {},
|
||||
onComplete: (status) {},
|
||||
tileProviderId: selectedProvider?.id,
|
||||
tileProviderName: selectedProvider?.name,
|
||||
tileTypeId: selectedTileType?.id,
|
||||
tileTypeName: selectedTileType?.name,
|
||||
);
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(locService.t('download.downloadStarted')),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(locService.t('download.downloadFailed', params: [e.toString()])),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Text(locService.download),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: isOfflineMode ? null : () async {
|
||||
try {
|
||||
final id = DateTime.now().toIso8601String().replaceAll(':', '-');
|
||||
final appDocDir = await OfflineAreaService().getOfflineAreaDir();
|
||||
final dir = "${appDocDir.path}/$id";
|
||||
|
||||
// Get current tile provider info
|
||||
final appState = context.read<AppState>();
|
||||
final selectedProvider = appState.selectedTileProvider;
|
||||
final selectedTileType = appState.selectedTileType;
|
||||
|
||||
// Fire and forget: don't await download, so dialog closes immediately
|
||||
// ignore: unawaited_futures
|
||||
OfflineAreaService().downloadArea(
|
||||
id: id,
|
||||
bounds: bounds,
|
||||
minZoom: _minZoom ?? 12,
|
||||
maxZoom: maxZoom,
|
||||
directory: dir,
|
||||
onProgress: (progress) {},
|
||||
onComplete: (status) {},
|
||||
tileProviderId: selectedProvider?.id,
|
||||
tileProviderName: selectedProvider?.name,
|
||||
tileTypeId: selectedTileType?.id,
|
||||
tileTypeName: selectedTileType?.name,
|
||||
);
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Download started! Fetching tiles and cameras...'),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to start download: $e'),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Download'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
|
||||
import '../app_state.dart';
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/operator_profile.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../state/settings_state.dart';
|
||||
import 'refine_tags_sheet.dart';
|
||||
|
||||
@@ -14,203 +15,209 @@ class EditNodeSheet extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
void _commit() {
|
||||
appState.commitEditSession();
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Node edit queued for upload')),
|
||||
);
|
||||
}
|
||||
void _commit() {
|
||||
appState.commitEditSession();
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(locService.t('node.editQueuedForUpload'))),
|
||||
);
|
||||
}
|
||||
|
||||
void _cancel() {
|
||||
appState.cancelEditSession();
|
||||
Navigator.pop(context);
|
||||
}
|
||||
void _cancel() {
|
||||
appState.cancelEditSession();
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList();
|
||||
final isSandboxMode = appState.uploadMode == UploadMode.sandbox;
|
||||
final allowSubmit = appState.isLoggedIn && submittableProfiles.isNotEmpty && session.profile.isSubmittable && !isSandboxMode;
|
||||
|
||||
void _openRefineTags() async {
|
||||
final result = await Navigator.push<OperatorProfile?>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RefineTagsSheet(
|
||||
selectedOperatorProfile: session.operatorProfile,
|
||||
),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
if (result != session.operatorProfile) {
|
||||
appState.updateEditSession(operatorProfile: result);
|
||||
}
|
||||
}
|
||||
final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList();
|
||||
final isSandboxMode = appState.uploadMode == UploadMode.sandbox;
|
||||
final allowSubmit = appState.isLoggedIn && submittableProfiles.isNotEmpty && session.profile.isSubmittable && !isSandboxMode;
|
||||
|
||||
void _openRefineTags() async {
|
||||
final result = await Navigator.push<OperatorProfile?>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RefineTagsSheet(
|
||||
selectedOperatorProfile: session.operatorProfile,
|
||||
),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
if (result != session.operatorProfile) {
|
||||
appState.updateEditSession(operatorProfile: result);
|
||||
}
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding:
|
||||
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade400,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Edit Node #${session.originalNode.id}',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
title: const Text('Profile'),
|
||||
trailing: DropdownButton<NodeProfile>(
|
||||
value: session.profile,
|
||||
items: submittableProfiles
|
||||
.map((p) => DropdownMenuItem(value: p, child: Text(p.name)))
|
||||
.toList(),
|
||||
onChanged: (p) =>
|
||||
appState.updateEditSession(profile: p ?? session.profile),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Direction ${session.directionDegrees.round()}°'),
|
||||
subtitle: Slider(
|
||||
min: 0,
|
||||
max: 359,
|
||||
divisions: 359,
|
||||
value: session.directionDegrees,
|
||||
label: session.directionDegrees.round().toString(),
|
||||
onChanged: session.profile.requiresDirection
|
||||
? (v) => appState.updateEditSession(directionDeg: v)
|
||||
: null, // Disables slider when requiresDirection is false
|
||||
),
|
||||
),
|
||||
if (!session.profile.requiresDirection)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Colors.grey, size: 16),
|
||||
SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'This profile does not require a direction.',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
return Padding(
|
||||
padding:
|
||||
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade400,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!appState.isLoggedIn)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Colors.red, size: 20),
|
||||
SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'You must be logged in to edit nodes. Please log in via Settings.',
|
||||
style: TextStyle(color: Colors.red, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
locService.t('editNode.title', params: [session.originalNode.id.toString()]),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
)
|
||||
else if (isSandboxMode)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Colors.blue, size: 20),
|
||||
SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Cannot submit edits on production nodes to sandbox. Switch to Production mode in Settings to edit nodes.',
|
||||
style: TextStyle(color: Colors.blue, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
title: Text(locService.t('editNode.profile')),
|
||||
trailing: DropdownButton<NodeProfile>(
|
||||
value: session.profile,
|
||||
items: submittableProfiles
|
||||
.map((p) => DropdownMenuItem(value: p, child: Text(p.name)))
|
||||
.toList(),
|
||||
onChanged: (p) =>
|
||||
appState.updateEditSession(profile: p ?? session.profile),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (submittableProfiles.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Colors.red, size: 20),
|
||||
SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Enable a submittable profile in Settings to edit nodes.',
|
||||
style: TextStyle(color: Colors.red, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
ListTile(
|
||||
title: Text(locService.t('editNode.direction', params: [session.directionDegrees.round().toString()])),
|
||||
subtitle: Slider(
|
||||
min: 0,
|
||||
max: 359,
|
||||
divisions: 359,
|
||||
value: session.directionDegrees,
|
||||
label: session.directionDegrees.round().toString(),
|
||||
onChanged: session.profile.requiresDirection
|
||||
? (v) => appState.updateEditSession(directionDeg: v)
|
||||
: null, // Disables slider when requiresDirection is false
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (!session.profile.isSubmittable)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Colors.orange, size: 20),
|
||||
SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'This profile is for map viewing only. Please select a submittable profile to edit nodes.',
|
||||
style: TextStyle(color: Colors.orange, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _openRefineTags,
|
||||
icon: const Icon(Icons.tune),
|
||||
label: Text(session.operatorProfile != null
|
||||
? 'Refine Tags (${session.operatorProfile!.name})'
|
||||
: 'Refine Tags'),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: _cancel,
|
||||
child: const Text('Cancel'),
|
||||
if (!session.profile.requiresDirection)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, color: Colors.grey, size: 16),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
locService.t('editNode.profileNoDirectionInfo'),
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: allowSubmit ? _commit : null,
|
||||
child: const Text('Save Edit'),
|
||||
if (!appState.isLoggedIn)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, color: Colors.red, size: 20),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
locService.t('editNode.mustBeLoggedIn'),
|
||||
style: const TextStyle(color: Colors.red, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (isSandboxMode)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, color: Colors.blue, size: 20),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
locService.t('editNode.sandboxModeWarning'),
|
||||
style: const TextStyle(color: Colors.blue, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (submittableProfiles.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, color: Colors.red, size: 20),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
locService.t('editNode.enableSubmittableProfile'),
|
||||
style: const TextStyle(color: Colors.red, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (!session.profile.isSubmittable)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, color: Colors.orange, size: 20),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
locService.t('editNode.profileViewOnlyWarning'),
|
||||
style: const TextStyle(color: Colors.orange, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _openRefineTags,
|
||||
icon: const Icon(Icons.tune),
|
||||
label: Text(session.operatorProfile != null
|
||||
? locService.t('editNode.refineTagsWithProfile', params: [session.operatorProfile!.name])
|
||||
: locService.t('editNode.refineTags')),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: _cancel,
|
||||
child: Text(locService.cancel),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: allowSubmit ? _commit : null,
|
||||
child: Text(locService.t('actions.saveEdit')),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../../dev_config.dart';
|
||||
import '../../models/osm_camera_node.dart';
|
||||
import '../camera_tag_sheet.dart';
|
||||
import '../node_tag_sheet.dart';
|
||||
import '../camera_icon.dart';
|
||||
|
||||
/// Smart marker widget for camera with single/double tap distinction
|
||||
@@ -27,7 +27,7 @@ class _CameraMapMarkerState extends State<CameraMapMarker> {
|
||||
_tapTimer = Timer(tapTimeout, () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (_) => CameraTagSheet(node: widget.node),
|
||||
builder: (_) => NodeTagSheet(node: widget.node),
|
||||
showDragHandle: true,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -89,11 +89,17 @@ class MapOverlays extends StatelessWidget {
|
||||
bottom: kAttributionBottomOffset,
|
||||
left: 10,
|
||||
child: Container(
|
||||
color: Colors.white70,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface.withOpacity(0.9),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: Text(
|
||||
attribution!,
|
||||
style: const TextStyle(fontSize: 11),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -77,7 +77,7 @@ class TileLayerManager {
|
||||
|
||||
return TileLayer(
|
||||
urlTemplate: urlTemplate,
|
||||
userAgentPackageName: 'com.stopflock.flock_map_app',
|
||||
userAgentPackageName: 'me.deflock.deflockapp',
|
||||
tileProvider: NetworkTileProvider(
|
||||
httpClient: _tileHttpClient,
|
||||
// Enable flutter_map caching - cache busting handled by URL changes and FlutterMap key
|
||||
|
||||
100
lib/widgets/node_tag_sheet.dart
Normal file
100
lib/widgets/node_tag_sheet.dart
Normal file
@@ -0,0 +1,100 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/osm_camera_node.dart';
|
||||
import '../app_state.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
class NodeTagSheet extends StatelessWidget {
|
||||
final OsmCameraNode node;
|
||||
|
||||
const NodeTagSheet({super.key, required this.node});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final appState = context.watch<AppState>();
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
// Check if this device is editable (not a pending upload or pending edit)
|
||||
final isEditable = (!node.tags.containsKey('_pending_upload') ||
|
||||
node.tags['_pending_upload'] != 'true') &&
|
||||
(!node.tags.containsKey('_pending_edit') ||
|
||||
node.tags['_pending_edit'] != 'true');
|
||||
|
||||
void _openEditSheet() {
|
||||
Navigator.pop(context); // Close this sheet first
|
||||
appState.startEditSession(node); // HomeScreen will auto-show the edit sheet
|
||||
}
|
||||
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('node.title').replaceAll('{}', node.id.toString()),
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...node.tags.entries.map(
|
||||
(e) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
e.key,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
e.value,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
softWrap: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (isEditable) ...[
|
||||
ElevatedButton.icon(
|
||||
onPressed: _openEditSheet,
|
||||
icon: const Icon(Icons.edit, size: 18),
|
||||
label: Text(locService.edit),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(0, 36),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(locService.t('actions.close')),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
40
pubspec.lock
40
pubspec.lock
@@ -222,6 +222,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
flutter_svg:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_svg
|
||||
sha256: cd57f7969b4679317c17af6fd16ee233c1e60a82ed209d8a475c54fd6fd6f845
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
flutter_web_auth_2:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -411,6 +419,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_parsing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_parsing
|
||||
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -704,6 +720,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.1"
|
||||
vector_graphics:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics
|
||||
sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.19"
|
||||
vector_graphics_codec:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics_codec
|
||||
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.13"
|
||||
vector_graphics_compiler:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics_compiler
|
||||
sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.19"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: flock_map_app
|
||||
description: Simple OSM camera‑mapping client
|
||||
name: deflockapp
|
||||
description: Map public surveillance infrastructure with OpenStreetMap
|
||||
publish_to: "none"
|
||||
version: 0.5.0
|
||||
|
||||
@@ -17,6 +17,7 @@ dependencies:
|
||||
latlong2: ^0.9.0
|
||||
geolocator: ^10.1.0
|
||||
http: ^1.2.1
|
||||
flutter_svg: ^2.0.10
|
||||
|
||||
# Auth, storage, prefs
|
||||
oauth2_client: ^4.2.0
|
||||
@@ -38,6 +39,8 @@ flutter:
|
||||
- assets/info.txt
|
||||
- assets/app_icon.png
|
||||
- assets/transparent_1x1.png
|
||||
- assets/deflock-logo.svg
|
||||
- lib/localizations/
|
||||
|
||||
flutter_native_splash:
|
||||
color: "#202020"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flock_map_app/models/pending_upload.dart';
|
||||
import 'package:flock_map_app/models/camera_profile.dart';
|
||||
import 'package:flock_map_app/state/settings_state.dart';
|
||||
import 'package:deflockapp/models/pending_upload.dart';
|
||||
import 'package:deflockapp/models/node_profile.dart';
|
||||
import 'package:deflockapp/state/settings_state.dart';
|
||||
|
||||
void main() {
|
||||
group('PendingUpload', () {
|
||||
@@ -18,7 +18,7 @@ void main() {
|
||||
final original = PendingUpload(
|
||||
coord: LatLng(37.7749, -122.4194),
|
||||
direction: 90.0,
|
||||
profile: CameraProfile.flock(),
|
||||
profile: NodeProfile.flock(),
|
||||
uploadMode: mode,
|
||||
);
|
||||
|
||||
@@ -46,7 +46,7 @@ void main() {
|
||||
'lat': 37.7749,
|
||||
'lon': -122.4194,
|
||||
'dir': 90.0,
|
||||
'profile': CameraProfile.flock().toJson(),
|
||||
'profile': NodeProfile.flock().toJson(),
|
||||
'originalNodeId': null,
|
||||
'attempts': 0,
|
||||
'error': false,
|
||||
@@ -64,7 +64,7 @@ void main() {
|
||||
final newCamera = PendingUpload(
|
||||
coord: LatLng(37.7749, -122.4194),
|
||||
direction: 90.0,
|
||||
profile: CameraProfile.flock(),
|
||||
profile: NodeProfile.flock(),
|
||||
uploadMode: UploadMode.production,
|
||||
);
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:flock_map_app/main.dart';
|
||||
import 'package:deflockapp/main.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||
|
||||
Reference in New Issue
Block a user