Merge pull request #20 from FoggedLens/vector-tiles

Everything but Vector tiles:

Splash screens and icons working right on both platforms
Search function available through a button
Stop adjusting UX depending on keyboard
Stop overlapping OS nav controls bar on android
Improve attribution (truncation, tap to view)
Add configurable max zoom to tile types
Remove tile providers whose terms we were possibly breaking
Navigation/routing UX, hidden behind dev mode - not in release yet
This commit is contained in:
stopflock
2025-10-06 11:11:46 -05:00
committed by GitHub
78 changed files with 408 additions and 302 deletions
+16 -3
View File
@@ -44,6 +44,11 @@ jobs:
- name: Install dependencies
run: flutter pub get
- name: Generate icons and splash screens
run: |
dart run flutter_launcher_icons
dart run flutter_native_splash:create
- name: Decode Keystore
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/app/keystore.jks
@@ -88,6 +93,11 @@ jobs:
- name: Install dependencies
run: flutter pub get
- name: Generate icons and splash screens
run: |
dart run flutter_launcher_icons
dart run flutter_native_splash:create
- name: Decode Keystore
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/app/keystore.jks
@@ -125,14 +135,17 @@ jobs:
- name: Install dependencies
run: flutter pub get
- name: Generate icons and splash screens
run: |
dart run flutter_launcher_icons
dart run flutter_native_splash:create
# - name: Build iOS .ipa
# run: flutter build ipa --release
- name: Build iOS .app
run: flutter build ios --release --no-codesign
- name: Convert to IPA
run: |
flutter build ios --release --no-codesign
./app2ipa.sh build/ios/iphoneos/Runner.app
- name: Upload IPA artifact
+14 -1
View File
@@ -25,6 +25,11 @@ android/app/profile/
android/app/release/
*.iml
# Generated icons and splash screens (exclude manually maintained files)
android/app/src/main/res/drawable*/
android/app/src/main/res/mipmap*/
!android/app/src/main/res/values*/
# ───────────────────────────────
# iOS / macOS
# ───────────────────────────────
@@ -37,10 +42,18 @@ ios/Runner.xcworkspace/
macos/Pods/
macos/.generated/
macos/Flutter/ephemeral/
# CocoaPods  commit Podfile.lock if you need reproducible iOS builds
# CocoaPods commit Podfile.lock if you need reproducible iOS builds
Podfile.lock
Pods/
# Generated icons and splash screens
ios/Runner/Assets.xcassets/AppIcon.appiconset/*
ios/Runner/Assets.xcassets/LaunchImage.imageset/*
ios/Runner/Assets.xcassets/LaunchBackground.imageset/*
!ios/Runner/Assets.xcassets/AppIcon.appiconset/.gitkeep
!ios/Runner/Assets.xcassets/LaunchImage.imageset/.gitkeep
!ios/Runner/Assets.xcassets/LaunchBackground.imageset/.gitkeep
# Xcode user data & build artifacts
*.xcworkspace
*.xcuserstate
+5 -4
View File
@@ -13,7 +13,7 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
- **Map surveillance infrastructure** including cameras, ALPRs, gunshot detectors, and more with precise location, direction, and manufacturer details
- **Upload to OpenStreetMap** with OAuth2 integration (live or sandbox modes)
- **Work completely offline** with downloadable map areas and device data, plus upload queue
- **Multiple map types** including satellite imagery from Google, Esri, Mapbox, and OpenStreetMap, plus custom map tile provider support
- **Multiple map types** including satellite imagery from USGS, Esri, Mapbox, and topographic maps from OpenTopoMap, plus custom map tile provider support
- **Editing Ability** to update existing device locations and properties
- **Built-in device profiles** for Flock Safety, Motorola, Genetec, Leonardo, and other major manufacturers, plus custom profiles for more specific tag sets
@@ -22,7 +22,7 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
## Key Features
### Map & Navigation
- **Multi-source tiles**: Switch between OpenStreetMap, Google Satellite, Esri imagery, Mapbox, and any custom providers
- **Multi-source tiles**: Switch between OpenStreetMap, USGS imagery, Esri imagery, Mapbox, OpenTopoMap, and any custom providers
- **Offline-first design**: Download a region for complete offline operation
- **Smooth UX**: Intuitive controls, follow-me mode with GPS rotation, and gesture-friendly interactions
- **Device visualization**: Color-coded markers showing real devices (blue), pending uploads (purple), pending edits (grey), devices being edited (orange), and pending deletions (red)
@@ -79,9 +79,10 @@ cp lib/keys.dart.example lib/keys.dart
## Roadmap
### Needed Bugfixes
- Pull version from pubspec.yaml instead of dev_config.dart; surely there's some kind of state we can pull from already
- Are offline areas really working? Are they preferred for fast loading even when online? Check working.
### Current Development
- Swap in alprwatch.org/directions avoidance routing API
- Help button with links to email, discord, and website
- Move download button?
- Clean cache when nodes have disappeared / been deleted by others / queue item was deleted
@@ -92,7 +93,7 @@ cp lib/keys.dart.example lib/keys.dart
### Future Features & Wishlist
- Update offline area nodes while browsing?
- Suspected locations toggle (alprwatch.com/flock/utilities)
- Route planning that avoids surveillance devices (alprwatch.com/directions)
- Offline navigation
### Maybes
- Yellow ring for devices missing specific tag details?
+1 -1
View File
@@ -23,7 +23,7 @@
android:hardwareAccelerated="true"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:windowSoftInputMode="adjustResize">
android:windowSoftInputMode="adjustNothing">
<!-- The theme behind the splash while Flutter initializes -->
<meta-data
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

@@ -2,6 +2,9 @@
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
@@ -2,6 +2,9 @@
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
+1 -1
View File
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="launch_background">#202020</color>
<color name="launch_background">#152131</color>
</resources>
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

+5 -5
View File
@@ -1,8 +1,8 @@
#!/bin/bash
echo "Generate splash screens..."
flutter pub run flutter_native_splash:create
echo
echo
echo "Generate icons..."
flutter pub run flutter_launcher_icons:main
dart run flutter_launcher_icons
echo
echo
echo "Generate splash screens..."
dart run flutter_native_splash:create
@@ -1 +0,0 @@
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 875 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

@@ -1,21 +0,0 @@
{
"images" : [
{
"filename" : "background.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

@@ -1,23 +0,0 @@
{
"images" : [
{
"filename" : "LaunchImage.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "LaunchImage@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "LaunchImage@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

@@ -1,5 +0,0 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
+22 -18
View File
@@ -38,13 +38,22 @@ const String kClientName = 'DeFlock';
// Note: Version is now dynamically retrieved from VersionService
// Development/testing features - set to false for production builds
const bool kEnableDevelopmentModes = true; // Set to false to hide sandbox/simulate modes and force production mode
const bool kEnableDevelopmentModes = false; // Set to false to hide sandbox/simulate modes and force production mode
// Navigation features - set to false to hide navigation UI elements while in development
const bool kEnableNavigationFeatures = kEnableDevelopmentModes; // Hide navigation until fully implemented
/// Navigation availability: only dev builds, and only when online
bool enableNavigationFeatures({required bool offlineMode}) {
if (!kEnableDevelopmentModes) {
return false; // Release builds: never allow navigation
} else {
return !offlineMode; // Dev builds: only when online
}
}
// Marker/node interaction
const int kCameraMinZoomLevel = 10; // Minimum zoom to show nodes (Overpass)
const int kNodeMinZoomLevel = 10; // Minimum zoom to show nodes (Overpass)
const int kOsmApiMinZoomLevel = 13; // Minimum zoom for OSM API bbox queries (sandbox mode)
const Duration kMarkerTapTimeout = Duration(milliseconds: 250);
const Duration kDebounceCameraRefresh = Duration(milliseconds: 500);
@@ -59,11 +68,6 @@ const int kProximityAlertMinDistance = 50; // meters
const int kProximityAlertMaxDistance = 1000; // meters
const Duration kProximityAlertCooldown = Duration(minutes: 10); // Cooldown between alerts for same node
// Last map location and settings storage
const String kLastMapLatKey = 'last_map_latitude';
const String kLastMapLngKey = 'last_map_longitude';
const String kLastMapZoomKey = 'last_map_zoom';
// Tile/OSM fetch retry parameters (for tunable backoff)
const int kTileFetchMaxAttempts = 3;
const int kTileFetchInitialDelayMs = 4000;
@@ -79,15 +83,15 @@ const int kMaxUserDownloadZoomSpan = 7;
// Download area limits and constants
const int kMaxReasonableTileCount = 20000;
const int kAbsoluteMaxTileCount = 50000;
const int kAbsoluteMaxZoom = 19;
const int kAbsoluteMaxZoom = 23;
// Camera icon configuration
const double kCameraIconDiameter = 20.0;
const double kCameraRingThickness = 4.0;
const double kCameraDotOpacity = 0.4; // Opacity for the grey dot interior
const Color kCameraRingColorReal = Color(0xC43F55F3); // Real nodes from OSM - blue
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 kCameraRingColorPendingDeletion = Color(0xA4F44336); // Node pending deletion - red, slightly transparent
// Node icon configuration
const double kNodeIconDiameter = 20.0;
const double kNodeRingThickness = 4.0;
const double kNodeDotOpacity = 0.4; // Opacity for the grey dot interior
const Color kNodeRingColorReal = Color(0xC43F55F3); // Real nodes from OSM - blue
const Color kNodeRingColorMock = Color(0xC4FFFFFF); // Add node mock point - white
const Color kNodeRingColorPending = Color(0xC49C27B0); // Submitted/pending nodes - purple
const Color kNodeRingColorEditing = Color(0xC4FF9800); // Node being edited - orange
const Color kNodeRingColorPendingEdit = Color(0xC4757575); // Original node with pending edit - grey
const Color kNodeRingColorPendingDeletion = Color(0xA4F44336); // Node pending deletion - red, slightly transparent
+7 -1
View File
@@ -184,6 +184,11 @@
"attribution": "Zuschreibung",
"attributionHint": "© Karten-Anbieter",
"attributionRequired": "Zuschreibung ist erforderlich",
"maxZoom": "Max Zoom-Stufe",
"maxZoomHint": "Maximale Zoom-Stufe (1-23)",
"maxZoomRequired": "Max Zoom ist erforderlich",
"maxZoomInvalid": "Max Zoom muss eine Zahl sein",
"maxZoomRange": "Max Zoom muss zwischen {} und {} liegen",
"fetchPreview": "Vorschau Laden",
"previewTileLoaded": "Vorschau-Kachel erfolgreich geladen",
"previewTileFailed": "Vorschau laden fehlgeschlagen: {}",
@@ -201,7 +206,8 @@
},
"mapTiles": {
"title": "Karten-Kacheln",
"manageProviders": "Anbieter Verwalten"
"manageProviders": "Anbieter Verwalten",
"attribution": "Karten-Zuschreibung"
},
"profileEditor": {
"viewProfile": "Profil Anzeigen",
+7 -1
View File
@@ -191,6 +191,11 @@
"attribution": "Attribution",
"attributionHint": "© Map Provider",
"attributionRequired": "Attribution is required",
"maxZoom": "Max Zoom Level",
"maxZoomHint": "Maximum zoom level (1-23)",
"maxZoomRequired": "Max zoom is required",
"maxZoomInvalid": "Max zoom must be a number",
"maxZoomRange": "Max zoom must be between {} and {}",
"fetchPreview": "Fetch Preview",
"previewTileLoaded": "Preview tile loaded successfully",
"previewTileFailed": "Failed to fetch preview: {}",
@@ -208,7 +213,8 @@
},
"mapTiles": {
"title": "Map Tiles",
"manageProviders": "Manage Providers"
"manageProviders": "Manage Providers",
"attribution": "Map Attribution"
},
"profileEditor": {
"viewProfile": "View Profile",
+7 -1
View File
@@ -191,6 +191,11 @@
"attribution": "Atribución",
"attributionHint": "© Proveedor de Mapas",
"attributionRequired": "La atribución es requerida",
"maxZoom": "Nivel de Zoom Máximo",
"maxZoomHint": "Nivel de zoom máximo (1-23)",
"maxZoomRequired": "El zoom máximo es requerido",
"maxZoomInvalid": "El zoom máximo debe ser un número",
"maxZoomRange": "El zoom máximo debe estar entre {} y {}",
"fetchPreview": "Obtener Vista Previa",
"previewTileLoaded": "Tile de vista previa cargado exitosamente",
"previewTileFailed": "Falló al obtener vista previa: {}",
@@ -208,7 +213,8 @@
},
"mapTiles": {
"title": "Tiles de Mapa",
"manageProviders": "Gestionar Proveedores"
"manageProviders": "Gestionar Proveedores",
"attribution": "Atribución del Mapa"
},
"profileEditor": {
"viewProfile": "Ver Perfil",
+7 -1
View File
@@ -191,6 +191,11 @@
"attribution": "Attribution",
"attributionHint": "© Fournisseur de Cartes",
"attributionRequired": "L'attribution est requise",
"maxZoom": "Niveau de Zoom Maximum",
"maxZoomHint": "Niveau de zoom maximum (1-23)",
"maxZoomRequired": "Le zoom maximum est requis",
"maxZoomInvalid": "Le zoom maximum doit être un nombre",
"maxZoomRange": "Le zoom maximum doit être entre {} et {}",
"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: {}",
@@ -208,7 +213,8 @@
},
"mapTiles": {
"title": "Tuiles de Carte",
"manageProviders": "Gérer Fournisseurs"
"manageProviders": "Gérer Fournisseurs",
"attribution": "Attribution de Carte"
},
"profileEditor": {
"viewProfile": "Voir Profil",
+7 -1
View File
@@ -191,6 +191,11 @@
"attribution": "Attribuzione",
"attributionHint": "© Fornitore Mappe",
"attributionRequired": "L'attribuzione è obbligatoria",
"maxZoom": "Livello Zoom Massimo",
"maxZoomHint": "Livello di zoom massimo (1-23)",
"maxZoomRequired": "Il zoom massimo è obbligatorio",
"maxZoomInvalid": "Il zoom massimo deve essere un numero",
"maxZoomRange": "Il zoom massimo deve essere tra {} e {}",
"fetchPreview": "Ottieni Anteprima",
"previewTileLoaded": "Tile di anteprima caricato con successo",
"previewTileFailed": "Impossibile ottenere l'anteprima: {}",
@@ -208,7 +213,8 @@
},
"mapTiles": {
"title": "Tile Mappa",
"manageProviders": "Gestisci Fornitori"
"manageProviders": "Gestisci Fornitori",
"attribution": "Attribuzione Mappa"
},
"profileEditor": {
"viewProfile": "Visualizza Profilo",
+7 -1
View File
@@ -191,6 +191,11 @@
"attribution": "Atribuição",
"attributionHint": "© Provedor de Mapas",
"attributionRequired": "Atribuição é obrigatória",
"maxZoom": "Nível de Zoom Máximo",
"maxZoomHint": "Nível de zoom máximo (1-23)",
"maxZoomRequired": "Zoom máximo é obrigatório",
"maxZoomInvalid": "Zoom máximo deve ser um número",
"maxZoomRange": "Zoom máximo deve estar entre {} e {}",
"fetchPreview": "Buscar Preview",
"previewTileLoaded": "Tile de preview carregado com sucesso",
"previewTileFailed": "Falha ao buscar preview: {}",
@@ -208,7 +213,8 @@
},
"mapTiles": {
"title": "Tiles do Mapa",
"manageProviders": "Gerenciar Provedores"
"manageProviders": "Gerenciar Provedores",
"attribution": "Atribuição do Mapa"
},
"profileEditor": {
"viewProfile": "Ver Perfil",
+7 -1
View File
@@ -191,6 +191,11 @@
"attribution": "归属",
"attributionHint": "© 地图提供商",
"attributionRequired": "归属为必填项",
"maxZoom": "最大缩放级别",
"maxZoomHint": "最大缩放级别 (1-23)",
"maxZoomRequired": "最大缩放为必填项",
"maxZoomInvalid": "最大缩放必须为数字",
"maxZoomRange": "最大缩放必须在 {} 和 {} 之间",
"fetchPreview": "获取预览",
"previewTileLoaded": "预览瓦片加载成功",
"previewTileFailed": "获取预览失败:{}",
@@ -208,7 +213,8 @@
},
"mapTiles": {
"title": "地图瓦片",
"manageProviders": "管理提供商"
"manageProviders": "管理提供商",
"attribution": "地图归属"
},
"profileEditor": {
"viewProfile": "查看配置文件",
+20 -36
View File
@@ -8,6 +8,7 @@ class TileType {
final String urlTemplate;
final String attribution;
final Uint8List? previewTile; // Single tile image data for preview
final int maxZoom; // Maximum zoom level for this tile type
const TileType({
required this.id,
@@ -15,6 +16,7 @@ class TileType {
required this.urlTemplate,
required this.attribution,
this.previewTile,
this.maxZoom = 18, // Default max zoom level
});
/// Create URL for a specific tile, replacing template variables
@@ -40,6 +42,7 @@ class TileType {
'urlTemplate': urlTemplate,
'attribution': attribution,
'previewTile': previewTile != null ? base64Encode(previewTile!) : null,
'maxZoom': maxZoom,
};
static TileType fromJson(Map<String, dynamic> json) => TileType(
@@ -50,6 +53,7 @@ class TileType {
previewTile: json['previewTile'] != null
? base64Decode(json['previewTile'])
: null,
maxZoom: json['maxZoom'] ?? 18, // Default to 18 if not specified
);
TileType copyWith({
@@ -58,12 +62,14 @@ class TileType {
String? urlTemplate,
String? attribution,
Uint8List? previewTile,
int? maxZoom,
}) => TileType(
id: id ?? this.id,
name: name ?? this.name,
urlTemplate: urlTemplate ?? this.urlTemplate,
attribution: attribution ?? this.attribution,
previewTile: previewTile ?? this.previewTile,
maxZoom: maxZoom ?? this.maxZoom,
);
@override
@@ -151,42 +157,7 @@ class DefaultTileProviders {
name: 'Street Map',
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution: '© OpenStreetMap contributors',
),
],
),
TileProvider(
id: 'google',
name: 'Google',
tileTypes: [
TileType(
id: 'google_hybrid',
name: 'Satellite + Roads',
urlTemplate: 'https://mt1.google.com/vt/lyrs=y&x={x}&y={y}&z={z}',
attribution: '© Google',
),
TileType(
id: 'google_satellite',
name: 'Satellite Only',
urlTemplate: 'https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}',
attribution: '© Google',
),
TileType(
id: 'google_roadmap',
name: 'Road Map',
urlTemplate: 'https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}',
attribution: '© Google',
),
],
),
TileProvider(
id: 'esri',
name: 'Esri',
tileTypes: [
TileType(
id: 'esri_satellite',
name: 'Satellite Imagery',
urlTemplate: 'https://services.arcgisonline.com/ArcGis/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}.png',
attribution: '© Esri © Maxar',
maxZoom: 19,
),
],
),
@@ -208,6 +179,19 @@ class DefaultTileProviders {
),
],
),
TileProvider(
id: 'opentopomap_memomaps',
name: 'OpenTopoMap/Memomaps',
tileTypes: [
TileType(
id: 'opentopomap_topo',
name: 'Topographic',
urlTemplate: 'https://tile.memomaps.de/tilegen/{z}/{x}/{y}.png',
attribution: 'Kartendaten: © OpenStreetMap-Mitwirkende, SRTM | Kartendarstellung: © OpenTopoMap (CC-BY-SA)',
maxZoom: 18,
),
],
),
];
}
}
+107 -65
View File
@@ -102,13 +102,18 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
final session = appState.session!; // guaranteed nonnull now
final controller = _scaffoldKey.currentState!.showBottomSheet(
(ctx) => MeasuredSheet(
onHeightChanged: (height) {
setState(() {
_addSheetHeight = height;
});
},
child: AddNodeSheet(session: session),
(ctx) => Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom, // Only safe area, no keyboard
),
child: MeasuredSheet(
onHeightChanged: (height) {
setState(() {
_addSheetHeight = height + MediaQuery.of(context).padding.bottom;
});
},
child: AddNodeSheet(session: session),
),
),
);
@@ -140,19 +145,24 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
if (!mounted) return;
final controller = _scaffoldKey.currentState!.showBottomSheet(
(ctx) => MeasuredSheet(
onHeightChanged: (height) {
setState(() {
_editSheetHeight = height;
// Clear transition flag and reset tag sheet height once edit sheet starts sizing
if (height > 0 && _transitioningToEdit) {
_transitioningToEdit = false;
_tagSheetHeight = 0.0; // Now safe to reset
_selectedNodeId = null; // Clear selection when moving to edit
}
});
},
child: EditNodeSheet(session: session),
(ctx) => Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom, // Only safe area, no keyboard
),
child: MeasuredSheet(
onHeightChanged: (height) {
setState(() {
_editSheetHeight = height + MediaQuery.of(context).padding.bottom;
// Clear transition flag and reset tag sheet height once edit sheet starts sizing
if (height > 0 && _transitioningToEdit) {
_transitioningToEdit = false;
_tagSheetHeight = 0.0; // Now safe to reset
_selectedNodeId = null; // Clear selection when moving to edit
}
});
},
child: EditNodeSheet(session: session),
),
),
);
@@ -168,15 +178,20 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
void _openNavigationSheet() {
final controller = _scaffoldKey.currentState!.showBottomSheet(
(ctx) => MeasuredSheet(
onHeightChanged: (height) {
setState(() {
_navigationSheetHeight = height;
});
},
child: NavigationSheet(
onStartRoute: _onStartRoute,
onResumeRoute: _onResumeRoute,
(ctx) => Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom, // Only safe area, no keyboard
),
child: MeasuredSheet(
onHeightChanged: (height) {
setState(() {
_navigationSheetHeight = height + MediaQuery.of(context).padding.bottom;
});
},
child: NavigationSheet(
onStartRoute: _onStartRoute,
onResumeRoute: _onResumeRoute,
),
),
),
);
@@ -330,16 +345,30 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
// Zoom out a bit to show the full route when viewing overview
_zoomToShowFullRoute(appState);
} else {
// Search button - enter search mode
debugPrint('[HomeScreen] Entering search mode');
try {
final mapCenter = _mapController.mapController.camera.center;
debugPrint('[HomeScreen] Map center: $mapCenter');
appState.enterSearchMode(mapCenter);
} catch (e) {
// Controller not ready, use fallback location
debugPrint('[HomeScreen] Map controller not ready: $e, using fallback');
appState.enterSearchMode(LatLng(37.7749, -122.4194));
// Search button
if (appState.offlineMode) {
// Show offline snackbar instead of entering search mode
debugPrint('[HomeScreen] Search disabled - offline mode');
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Search not available while offline'),
duration: const Duration(seconds: 3),
behavior: SnackBarBehavior.floating,
),
);
} else {
// Enter search mode normally
debugPrint('[HomeScreen] Entering search mode');
try {
final mapCenter = _mapController.mapController.camera.center;
debugPrint('[HomeScreen] Map center: $mapCenter');
appState.enterSearchMode(mapCenter);
} catch (e) {
// Controller not ready, use fallback location
debugPrint('[HomeScreen] Map controller not ready: $e, using fallback');
appState.enterSearchMode(LatLng(37.7749, -122.4194));
}
}
}
}
@@ -392,19 +421,24 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
}
final controller = _scaffoldKey.currentState!.showBottomSheet(
(ctx) => MeasuredSheet(
onHeightChanged: (height) {
setState(() {
_tagSheetHeight = height;
});
},
child: NodeTagSheet(
node: node,
onEditPressed: () {
final appState = context.read<AppState>();
appState.startEditSession(node);
// This will trigger _openEditNodeSheet via the existing auto-show logic
(ctx) => Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom, // Only safe area, no keyboard
),
child: MeasuredSheet(
onHeightChanged: (height) {
setState(() {
_tagSheetHeight = height + MediaQuery.of(context).padding.bottom;
});
},
child: NodeTagSheet(
node: node,
onEditPressed: () {
final appState = context.read<AppState>();
appState.startEditSession(node);
// This will trigger _openEditNodeSheet via the existing auto-show logic
},
),
),
),
);
@@ -457,7 +491,9 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
providers: [
ChangeNotifierProvider<CameraProviderWithCache>(create: (_) => CameraProviderWithCache()),
],
child: Scaffold(
child: MediaQuery(
data: MediaQuery.of(context).copyWith(viewInsets: EdgeInsets.zero),
child: Scaffold(
key: _scaffoldKey,
appBar: AppBar(
automaticallyImplyLeading: false, // Disable automatic back button
@@ -500,15 +536,15 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
sheetHeight: activeSheetHeight,
selectedNodeId: _selectedNodeId,
onNodeTap: openNodeTagSheet,
onSearchPressed: kEnableNavigationFeatures ? _onNavigationButtonPressed : null,
onSearchPressed: _onNavigationButtonPressed,
onUserGesture: () {
if (appState.followMeMode != FollowMeMode.off) {
appState.setFollowMeMode(FollowMeMode.off);
}
},
),
// Search bar (slides in when in search mode) - only in dev mode
if (kEnableNavigationFeatures && appState.isInSearchMode)
// Search bar (slides in when in search mode) - only online since search doesn't work offline
if (!appState.offlineMode && appState.isInSearchMode)
Positioned(
top: 0,
left: 0,
@@ -546,6 +582,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
child: Row(
children: [
Expanded(
flex: 7, // 70% for primary action
child: AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) => ElevatedButton.icon(
@@ -561,18 +598,22 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
),
SizedBox(width: 12),
Expanded(
flex: 3, // 30% for secondary action
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),
builder: (context, child) => FittedBox(
fit: BoxFit.scaleDown,
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),
),
),
),
),
@@ -586,6 +627,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
],
),
),
),
);
}
}
@@ -256,6 +256,7 @@ class _TileTypeDialogState extends State<_TileTypeDialog> {
late final TextEditingController _nameController;
late final TextEditingController _urlController;
late final TextEditingController _attributionController;
late final TextEditingController _maxZoomController;
Uint8List? _previewTile;
bool _isLoadingPreview = false;
@@ -266,6 +267,7 @@ class _TileTypeDialogState extends State<_TileTypeDialog> {
_nameController = TextEditingController(text: tileType?.name ?? '');
_urlController = TextEditingController(text: tileType?.urlTemplate ?? '');
_attributionController = TextEditingController(text: tileType?.attribution ?? '');
_maxZoomController = TextEditingController(text: (tileType?.maxZoom ?? 18).toString());
_previewTile = tileType?.previewTile;
}
@@ -274,6 +276,7 @@ class _TileTypeDialogState extends State<_TileTypeDialog> {
_nameController.dispose();
_urlController.dispose();
_attributionController.dispose();
_maxZoomController.dispose();
super.dispose();
}
@@ -326,6 +329,22 @@ class _TileTypeDialogState extends State<_TileTypeDialog> {
validator: (value) => value?.trim().isEmpty == true ? locService.t('tileTypeEditor.attributionRequired') : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _maxZoomController,
decoration: InputDecoration(
labelText: locService.t('tileTypeEditor.maxZoom'),
hintText: locService.t('tileTypeEditor.maxZoomHint'),
),
keyboardType: TextInputType.number,
validator: (value) {
if (value?.trim().isEmpty == true) return locService.t('tileTypeEditor.maxZoomRequired');
final zoom = int.tryParse(value!);
if (zoom == null) return locService.t('tileTypeEditor.maxZoomInvalid');
if (zoom < 1 || zoom > kAbsoluteMaxZoom) return locService.t('tileTypeEditor.maxZoomRange', params: ['1', kAbsoluteMaxZoom.toString()]);
return null;
},
),
const SizedBox(height: 16),
Row(
children: [
TextButton.icon(
@@ -425,6 +444,7 @@ class _TileTypeDialogState extends State<_TileTypeDialog> {
urlTemplate: _urlController.text.trim(),
attribution: _attributionController.text.trim(),
previewTile: _previewTile,
maxZoom: int.parse(_maxZoomController.text.trim()),
);
widget.onSave(tileType);
+22
View File
@@ -153,6 +153,9 @@ class SettingsState extends ChangeNotifier {
_tileProviders = providersList
.map((json) => TileProvider.fromJson(json))
.toList();
// Migration: Add any missing built-in providers
await _addMissingBuiltinProviders(prefs);
}
} catch (e) {
debugPrint('Error loading tile providers: $e');
@@ -166,6 +169,25 @@ class SettingsState extends ChangeNotifier {
}
}
/// Add any built-in providers that are missing from user's configuration
Future<void> _addMissingBuiltinProviders(SharedPreferences prefs) async {
final defaultProviders = DefaultTileProviders.createDefaults();
final existingProviderIds = _tileProviders.map((p) => p.id).toSet();
bool hasUpdates = false;
for (final defaultProvider in defaultProviders) {
if (!existingProviderIds.contains(defaultProvider.id)) {
_tileProviders.add(defaultProvider);
hasUpdates = true;
debugPrint('SettingsState: Added missing built-in provider: ${defaultProvider.name}');
}
}
if (hasUpdates) {
await _saveTileProviders(prefs);
}
}
Future<void> _saveTileProviders(SharedPreferences prefs) async {
try {
final providersJson = jsonEncode(
+1 -5
View File
@@ -51,10 +51,7 @@ class AddNodeSheet extends StatelessWidget {
}
}
return Padding(
padding:
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: Column(
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 12),
@@ -192,7 +189,6 @@ class AddNodeSheet extends StatelessWidget {
),
const SizedBox(height: 20),
],
),
);
},
);
+10 -10
View File
@@ -19,31 +19,31 @@ class CameraIcon extends StatelessWidget {
Color get _ringColor {
switch (type) {
case CameraIconType.real:
return kCameraRingColorReal;
return kNodeRingColorReal;
case CameraIconType.mock:
return kCameraRingColorMock;
return kNodeRingColorMock;
case CameraIconType.pending:
return kCameraRingColorPending;
return kNodeRingColorPending;
case CameraIconType.editing:
return kCameraRingColorEditing;
return kNodeRingColorEditing;
case CameraIconType.pendingEdit:
return kCameraRingColorPendingEdit;
return kNodeRingColorPendingEdit;
case CameraIconType.pendingDeletion:
return kCameraRingColorPendingDeletion;
return kNodeRingColorPendingDeletion;
}
}
@override
Widget build(BuildContext context) {
return Container(
width: kCameraIconDiameter,
height: kCameraIconDiameter,
width: kNodeIconDiameter,
height: kNodeIconDiameter,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.black.withOpacity(kCameraDotOpacity),
color: Colors.black.withOpacity(kNodeDotOpacity),
border: Border.all(
color: _ringColor,
width: kCameraRingThickness,
width: kNodeRingThickness,
),
),
);
+1 -5
View File
@@ -53,10 +53,7 @@ class EditNodeSheet extends StatelessWidget {
}
}
return Padding(
padding:
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: Column(
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 12),
@@ -199,7 +196,6 @@ class EditNodeSheet extends StatelessWidget {
),
const SizedBox(height: 20),
],
),
);
},
);
+2 -2
View File
@@ -107,8 +107,8 @@ class CameraMarkersBuilder {
return Marker(
point: n.coord,
width: kCameraIconDiameter,
height: kCameraIconDiameter,
width: kNodeIconDiameter,
height: kNodeIconDiameter,
child: Opacity(
opacity: shouldDim ? 0.5 : 1.0,
child: CameraMapMarker(
@@ -69,12 +69,12 @@ class CameraRefreshController {
}
final zoom = controller.mapController.camera.zoom;
if (zoom < kCameraMinZoomLevel) {
if (zoom < kNodeMinZoomLevel) {
// Show a snackbar-style bubble warning
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Cameras not drawn below zoom level $kCameraMinZoomLevel'),
content: Text('Nodes not drawn below zoom level $kNodeMinZoomLevel'),
duration: const Duration(seconds: 2),
),
);
+52 -23
View File
@@ -27,6 +27,27 @@ class MapOverlays extends StatelessWidget {
this.onSearchPressed,
});
/// Show full attribution text in a dialog
void _showAttributionDialog(BuildContext context, String attribution) {
final locService = LocalizationService.instance;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(locService.t('mapTiles.attribution')),
content: SelectableText(
attribution,
style: const TextStyle(fontSize: 14),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(locService.t('actions.close')),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Stack(
@@ -97,17 +118,23 @@ class MapOverlays extends StatelessWidget {
Positioned(
bottom: bottomPositionFromButtonBar(kAttributionSpacingAboveButtonBar, MediaQuery.of(context).padding.bottom),
left: 10,
child: Container(
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: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurface,
child: GestureDetector(
onTap: () => _showAttributionDialog(context, attribution!),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface.withOpacity(0.9),
borderRadius: BorderRadius.circular(4),
),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
constraints: const BoxConstraints(maxWidth: 250),
child: Text(
attribution!,
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurface,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
),
@@ -121,18 +148,20 @@ class MapOverlays extends StatelessWidget {
builder: (context, appState, child) {
return Column(
children: [
// Navigation button - simplified logic (only show in dev mode)
if (kEnableNavigationFeatures && onSearchPressed != null && (appState.showSearchButton || appState.showRouteButton)) ...[
FloatingActionButton(
mini: true,
heroTag: "search_nav",
onPressed: onSearchPressed,
tooltip: appState.showRouteButton
? LocalizationService.instance.t('navigation.routeOverview')
: LocalizationService.instance.t('navigation.searchLocation'),
child: Icon(appState.showRouteButton ? Icons.route : Icons.search),
),
const SizedBox(height: 8),
// Search/Navigation button - show search button always, show route button only in dev mode when online
if (onSearchPressed != null) ...[
if (appState.showSearchButton || (enableNavigationFeatures(offlineMode: appState.offlineMode) && appState.showRouteButton)) ...[
FloatingActionButton(
mini: true,
heroTag: "search_nav",
onPressed: onSearchPressed,
tooltip: appState.showRouteButton
? LocalizationService.instance.t('navigation.routeOverview')
: LocalizationService.instance.t('navigation.searchLocation'),
child: Icon(appState.showRouteButton ? Icons.route : Icons.search),
),
const SizedBox(height: 8),
],
],
// Layer selector button
+9 -11
View File
@@ -3,8 +3,6 @@ import 'package:flutter_map_animations/flutter_map_animations.dart';
import 'package:latlong2/latlong.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../dev_config.dart';
/// Manages map position persistence and initial positioning.
/// Handles saving/loading last map position and moving to initial locations.
@@ -27,9 +25,9 @@ class MapPositionManager {
Future<void> loadLastMapPosition() async {
try {
final prefs = await SharedPreferences.getInstance();
final lat = prefs.getDouble(kLastMapLatKey);
final lng = prefs.getDouble(kLastMapLngKey);
final zoom = prefs.getDouble(kLastMapZoomKey);
final lat = prefs.getDouble('last_map_latitude');
final lng = prefs.getDouble('last_map_longitude');
final zoom = prefs.getDouble('last_map_zoom');
if (lat != null && lng != null &&
_isValidCoordinate(lat) && _isValidCoordinate(lng)) {
@@ -80,9 +78,9 @@ class MapPositionManager {
}
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble(kLastMapLatKey, location.latitude);
await prefs.setDouble(kLastMapLngKey, location.longitude);
await prefs.setDouble(kLastMapZoomKey, zoom);
await prefs.setDouble('last_map_latitude', location.latitude);
await prefs.setDouble('last_map_longitude', location.longitude);
await prefs.setDouble('last_map_zoom', zoom);
debugPrint('[MapPositionManager] Saved last map position: ${location.latitude}, ${location.longitude}, zoom: $zoom');
} catch (e) {
debugPrint('[MapPositionManager] Failed to save last map position: $e');
@@ -95,9 +93,9 @@ class MapPositionManager {
static Future<void> clearStoredMapPosition() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(kLastMapLatKey);
await prefs.remove(kLastMapLngKey);
await prefs.remove(kLastMapZoomKey);
await prefs.remove('last_map_latitude');
await prefs.remove('last_map_longitude');
await prefs.remove('last_map_zoom');
debugPrint('[MapPositionManager] Cleared stored map position');
} catch (e) {
debugPrint('[MapPositionManager] Failed to clear stored map position: $e');
+1
View File
@@ -78,6 +78,7 @@ class TileLayerManager {
return TileLayer(
urlTemplate: urlTemplate,
userAgentPackageName: 'me.deflock.deflockapp',
maxZoom: selectedTileType?.maxZoom?.toDouble() ?? 18.0,
tileProvider: NetworkTileProvider(
httpClient: _tileHttpClient,
// Enable flutter_map caching - cache busting handled by URL changes and FlutterMap key
+5 -5
View File
@@ -222,7 +222,7 @@ class MapViewState extends State<MapView> {
if (uploadMode == UploadMode.sandbox) {
return kOsmApiMinZoomLevel;
} else {
return kCameraMinZoomLevel;
return kNodeMinZoomLevel;
}
}
@@ -370,8 +370,8 @@ class MapViewState extends State<MapView> {
centerMarkers.add(
Marker(
point: center,
width: kCameraIconDiameter,
height: kCameraIconDiameter,
width: kNodeIconDiameter,
height: kNodeIconDiameter,
child: CameraIcon(
type: editSession != null ? CameraIconType.editing : CameraIconType.mock,
),
@@ -452,7 +452,7 @@ class MapViewState extends State<MapView> {
options: MapOptions(
initialCenter: _gpsController.currentLocation ?? _positionManager.initialLocation ?? LatLng(37.7749, -122.4194),
initialZoom: _positionManager.initialZoom ?? 15,
maxZoom: 19,
maxZoom: (appState.selectedTileType?.maxZoom ?? 18).toDouble(),
onPositionChanged: (pos, gesture) {
setState(() {}); // Instant UI update for zoom, etc.
if (gesture) widget.onUserGesture();
@@ -573,7 +573,7 @@ class MapViewState extends State<MapView> {
if (originalCoord != null) {
lines.add(Polyline(
points: [originalCoord, camera.coord],
color: kCameraRingColorPending,
color: kNodeRingColorPending,
strokeWidth: 3.0,
));
}
+25 -21
View File
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import 'package:latlong2/latlong.dart';
import '../app_state.dart';
import '../dev_config.dart';
import '../services/localization_service.dart';
class NavigationSheet extends StatelessWidget {
@@ -100,29 +101,32 @@ class NavigationSheet extends StatelessWidget {
address: provisionalAddress,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
icon: const Icon(Icons.directions),
label: Text(LocalizationService.instance.t('navigation.routeTo')),
onPressed: () {
appState.startRoutePlanning(thisLocationIsStart: false);
},
// Only show routing buttons if navigation features are enabled
if (enableNavigationFeatures(offlineMode: appState.offlineMode)) ...[
Row(
children: [
Expanded(
child: ElevatedButton.icon(
icon: const Icon(Icons.directions),
label: Text(LocalizationService.instance.t('navigation.routeTo')),
onPressed: () {
appState.startRoutePlanning(thisLocationIsStart: false);
},
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
icon: const Icon(Icons.my_location),
label: Text(LocalizationService.instance.t('navigation.routeFrom')),
onPressed: () {
appState.startRoutePlanning(thisLocationIsStart: true);
},
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
icon: const Icon(Icons.my_location),
label: Text(LocalizationService.instance.t('navigation.routeFrom')),
onPressed: () {
appState.startRoutePlanning(thisLocationIsStart: true);
},
),
),
),
],
),
],
),
],
],
// SETTING SECOND POINT: Show both points and select button
+12 -9
View File
@@ -1,7 +1,7 @@
name: deflockapp
description: Map public surveillance infrastructure with OpenStreetMap
publish_to: "none"
version: 1.0.7
version: 1.0.10
environment:
sdk: ">=3.5.0 <4.0.0" # oauth2_client 4.x needs Dart 3.5+
@@ -39,20 +39,23 @@ flutter:
uses-material-design: true
assets:
- assets/info.txt
- assets/app_icon.png
- assets/android_app_icon.png
- assets/transparent_1x1.png
- assets/deflock-logo.svg
- lib/localizations/
flutter_native_splash:
color: "#152131"
image: assets/app_icon.png
android: true
ios: true
flutter_icons:
flutter_launcher_icons:
android: true
ios: true
image_path: "assets/app_icon.png"
min_sdk_android: 21
adaptive_icon_background: "assets/android_app_icon.png"
adaptive_icon_foreground: "assets/transparent_1x1.png"
flutter_native_splash:
color: "#152131"
image: assets/app_icon.png
android_12:
android: true
ios: true