Compare commits

...

19 Commits

Author SHA1 Message Date
stopflock
5c525900f1 bump version, disable dev mode 2025-09-28 23:18:51 -05:00
stopflock
28828fbac0 Merge pull request #15 from FoggedLens/sandbox-enhancements
Sandbox enhancements
2025-09-28 23:17:52 -05:00
stopflock
9bf46721f0 Clear node cache when switching to/from sandbox 2025-09-28 23:16:01 -05:00
stopflock
363439f712 Edits and deletes now working in sandbox 2025-09-28 22:49:07 -05:00
stopflock
38f15a1f8b fetch from sandbox 2025-09-28 22:18:59 -05:00
stopflock
a05abd8bd8 Deletions! 2025-09-28 21:44:28 -05:00
stopflock
c8a8d4c81f update readme todos 2025-09-28 20:44:30 -05:00
stopflock
63e8934490 turn dev mode back off - oops 2025-09-28 20:20:28 -05:00
stopflock
4053c9b39b use real node id for pending uploads, more camera -> node 2025-09-28 20:20:00 -05:00
stopflock
4ad33d17e0 Remove world map area 2025-09-28 19:00:07 -05:00
stopflock
c9f1ecf7d0 give do_build --ios and --android options. bump version. 2025-09-28 18:14:08 -05:00
stopflock
7c49b38230 Fix offline area size estimates, zoom level metadata display 2025-09-28 17:43:03 -05:00
stopflock
25f0e358a3 clean up debug logging 2025-09-28 17:02:58 -05:00
stopflock
0cbcec7017 improve data indicator, offline fetching, offline area loading 2025-09-28 17:00:34 -05:00
stopflock
68289135bd bump version 2025-09-26 17:21:54 -05:00
stopflock
23b7586e25 tag node -> add node, better element offsets, fix splash background on iOS 2025-09-26 15:40:30 -05:00
stopflock
a2b842fb67 offline areas localization 2025-09-26 14:43:57 -05:00
stopflock
175bc8831a new icons, remove upload dests 2025-09-26 14:31:50 -05:00
stopflock
99ce659064 macos specific files for permissions etc, bundle id fix for ios 2025-09-01 11:02:27 -05:00
89 changed files with 1424 additions and 580 deletions

View File

@@ -88,21 +88,19 @@ flutter run
## Roadmap
### v1 todo/bug List
- Fix "tiles loaded" indicator accuracy across different providers
- Generic tile provider error messages (not always "OSM tiles slow")
- Optional custom icons for camera profiles
- Update offline area nodes while browsing?
- Camera deletions
- Clean up cache when submitted changesets appear in Overpass results
- Optional custom icons for camera profiles
- Upgrade device marker design (considering nullplate's svg)
### Future Features & Wishlist
- Jump to location by coordinates, address, or POI name
- Route planning that avoids surveillance devices (alprwatch.com/directions)
- Suspected locations toggle (alprwatch.com/flock/utilities)
- 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?
- "Cache accumulating" offline area?
- "Offline areas" as tile provider?
- Jump to location by coordinates, address, or POI name
- Route planning that avoids surveillance devices
- Custom device providers and OSM/Overpass alternatives
---

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 435 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 805 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 69 B

View File

@@ -1,7 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/launch_background" />
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 757 KiB

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -1,16 +1,53 @@
#!/bin/bash
appver=$(cat lib/dev_config.dart | grep "kClientVersion" | cut -d '=' -f 2 | tr -d ';' | tr -d "\'" | tr -d " ")
# Default options
BUILD_IOS=true
BUILD_ANDROID=true
# Parse arguments
for arg in "$@"; do
case $arg in
--ios)
BUILD_ANDROID=false
;;
--android)
BUILD_IOS=false
;;
*)
echo "Usage: $0 [--ios | --android]"
echo " --ios Build only iOS"
echo " --android Build only Android"
echo " (default builds both)"
exit 1
;;
esac
done
appver=$(grep "kClientVersion" lib/dev_config.dart | cut -d '=' -f 2 | tr -d ';' | tr -d "\'" | tr -d " ")
echo
echo "Building app version ${appver}..."
flutter build ios --no-codesign
flutter build apk
echo
echo "Converting .app to .ipa..."
./app2ipa.sh build/ios/iphoneos/Runner.app
echo
echo "Moving files..."
cp build/app/outputs/flutter-apk/app-release.apk ../flockmap_v${appver}.apk
mv Runner.ipa ../flockmap_v${appver}.ipa
echo
if [ "$BUILD_IOS" = true ]; then
echo "Building iOS..."
flutter build ios --no-codesign || exit 1
echo "Converting .app to .ipa..."
./app2ipa.sh build/ios/iphoneos/Runner.app || exit 1
echo "Moving iOS files..."
mv Runner.ipa "../deflock_v${appver}.ipa" || exit 1
echo
fi
if [ "$BUILD_ANDROID" = true ]; then
echo "Building Android..."
flutter build apk || exit 1
echo "Moving Android files..."
cp build/app/outputs/flutter-apk/app-release.apk "../deflock_v${appver}.apk" || exit 1
echo
fi
echo "Done."

View File

@@ -512,7 +512,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_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -659,7 +659,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_OPTIMIZATION_LEVEL = "-Onone";

Binary file not shown.

Before

Width:  |  Height:  |  Size: 805 KiB

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 875 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 435 KiB

After

Width:  |  Height:  |  Size: 57 KiB

View File

@@ -16,13 +16,19 @@
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleToFill" image="LaunchBackground" translatesAutoresizingMaskIntoConstraints="NO" id="tWc-Dq-wcI"/>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"></imageView>
</subviews>
<color key="backgroundColor" red="0.125" green="0.125" blue="0.125" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="3T2-ad-Qdv"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="RPx-PI-7Xg"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="SdS-ul-q2q"/>
<constraint firstAttribute="trailing" secondItem="tWc-Dq-wcI" secondAttribute="trailing" id="Swv-Gf-Rwn"/>
<constraint firstAttribute="trailing" secondItem="YRO-k0-Ey4" secondAttribute="trailing" id="TQA-XW-tRk"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="duK-uY-Gun"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="kV7-tw-vXt"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="xPn-NY-SIU"/>
</constraints>
</view>
</viewController>
@@ -32,6 +38,7 @@
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
<image name="LaunchImage" width="512" height="512"/>
<image name="LaunchBackground" width="1" height="1"/>
</resources>
</document>

View File

@@ -8,6 +8,8 @@ import 'models/osm_camera_node.dart';
import 'models/pending_upload.dart';
import 'models/tile_provider.dart';
import 'services/offline_area_service.dart';
import 'services/node_cache.dart';
import 'widgets/camera_provider_with_cache.dart';
import 'state/auth_state.dart';
import 'state/operator_profile_state.dart';
import 'state/profile_state.dart';
@@ -216,6 +218,11 @@ class AppState extends ChangeNotifier {
}
}
void deleteNode(OsmCameraNode node) {
_uploadQueueState.addFromNodeDeletion(node, uploadMode: uploadMode);
_startUploader();
}
// ---------- Settings Methods ----------
Future<void> setOfflineMode(bool enabled) async {
await _settingsState.setOfflineMode(enabled);
@@ -233,6 +240,11 @@ class AppState extends ChangeNotifier {
}
Future<void> setUploadMode(UploadMode mode) async {
// Clear node cache when switching upload modes to prevent mixing production/sandbox data
NodeCache.instance.clear();
CameraProviderWithCache.instance.notifyListeners();
debugPrint('[AppState] Cleared node cache due to upload mode change');
await _settingsState.setUploadMode(mode);
await _authState.onUploadModeChanged(mode);
_startUploader(); // Restart uploader with new mode

View File

@@ -2,8 +2,6 @@
import 'package:flutter/material.dart';
/// Developer/build-time configuration for global/non-user-tunable constants.
const int kWorldMinZoom = 1;
const int kWorldMaxZoom = 5;
// Example: Default tile storage estimate (KB per tile), for size estimates
const double kTileEstimateKb = 25.0;
@@ -13,23 +11,34 @@ const double kDirectionConeHalfAngle = 30.0; // degrees
const double kDirectionConeBaseLength = 0.001; // multiplier
const Color kDirectionConeColor = Color(0xFF000000); // FOV cone color
// Margin (bottom) for positioning the floating bottom button bar
const double kBottomButtonBarMargin = 4.0;
// Bottom button bar positioning
const double kBottomButtonBarOffset = 4.0; // Distance from screen bottom (above safe area)
const double kButtonBarHeight = 60.0; // Button height (48) + padding (12)
// Map overlay (attribution, scale bar, zoom) vertical offset from bottom edge
const double kAttributionBottomOffset = 110.0;
const double kZoomIndicatorBottomOffset = 142.0;
const double kScaleBarBottomOffset = 170.0;
// Map overlay spacing relative to button bar top
const double kAttributionSpacingAboveButtonBar = 10.0; // Attribution above button bar top
const double kZoomIndicatorSpacingAboveButtonBar = 40.0; // Zoom indicator above button bar top
const double kScaleBarSpacingAboveButtonBar = 70.0; // Scale bar above button bar top
const double kZoomControlsSpacingAboveButtonBar = 20.0; // Zoom controls above button bar top
// Helper to calculate bottom position relative to button bar
double bottomPositionFromButtonBar(double spacingAboveButtonBar, double safeAreaBottom) {
return safeAreaBottom + kBottomButtonBarOffset + kButtonBarHeight + spacingAboveButtonBar;
}
// Add Camera icon vertical offset (no offset needed since circle is centered)
const double kAddPinYOffset = 0.0;
// Client name and version for OSM uploads ("created_by" tag)
const String kClientName = 'DeFlock';
const String kClientVersion = '0.9.8';
const String kClientVersion = '0.9.12';
// Development/testing features - set to false for production builds
const bool kEnableDevelopmentModes = false; // Set to false to hide sandbox/simulate modes and force production mode
// Marker/node interaction
const int kCameraMinZoomLevel = 10; // Minimum zoom to show nodes or warning
const int kCameraMinZoomLevel = 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);
@@ -56,6 +65,7 @@ const int kMaxUserDownloadZoomSpan = 7;
// Download area limits and constants
const int kMaxReasonableTileCount = 20000;
const int kAbsoluteMaxTileCount = 50000;
const int kAbsoluteMaxZoom = 19;
// Camera icon configuration
@@ -67,3 +77,4 @@ const Color kCameraRingColorMock = Color(0xC4FFFFFF); // Add node mock point - w
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

View File

@@ -6,10 +6,11 @@
"title": "DeFlock"
},
"actions": {
"tagNode": "Knoten Markieren",
"tagNode": "Neuer Knoten",
"download": "Herunterladen",
"settings": "Einstellungen",
"edit": "Bearbeiten",
"delete": "Löschen",
"cancel": "Abbrechen",
"ok": "OK",
"close": "Schließen",
@@ -41,7 +42,10 @@
"title": "Knoten #{}",
"tagSheetTitle": "Gerät-Tags",
"queuedForUpload": "Knoten zum Upload eingereiht",
"editQueuedForUpload": "Knotenbearbeitung zum Upload eingereiht"
"editQueuedForUpload": "Knotenbearbeitung zum Upload eingereiht",
"deleteQueuedForUpload": "Knoten-Löschung zum Upload eingereiht",
"confirmDeleteTitle": "Knoten löschen",
"confirmDeleteMessage": "Sind Sie sicher, dass Sie Knoten #{} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden."
},
"addNode": {
"profile": "Profil",
@@ -211,5 +215,27 @@
"deleteOperatorProfile": "Betreiber-Profil Löschen",
"deleteOperatorProfileConfirm": "Sind Sie sicher, dass Sie \"{}\" löschen möchten?",
"operatorProfileDeleted": "Betreiber-Profil gelöscht"
},
"offlineAreas": {
"noAreasTitle": "Keine Offline-Bereiche",
"noAreasSubtitle": "Laden Sie einen Kartenbereich für die Offline-Nutzung herunter.",
"provider": "Anbieter",
"zoomLevels": "Z{}-{}",
"latitude": "Breite",
"longitude": "Länge",
"tiles": "Kacheln",
"size": "Größe",
"cameras": "Kameras",
"areaIdFallback": "Bereich {}...",
"renameArea": "Bereich umbenennen",
"refreshWorldTiles": "Welt-Kacheln aktualisieren/neu herunterladen",
"deleteOfflineArea": "Offline-Bereich löschen",
"cancelDownload": "Download abbrechen",
"renameAreaDialogTitle": "Offline-Bereich Umbenennen",
"areaNameLabel": "Bereichsname",
"renameButton": "Umbenennen",
"megabytes": "MB",
"kilobytes": "KB",
"progress": "{}%"
}
}

View File

@@ -6,10 +6,11 @@
"title": "DeFlock"
},
"actions": {
"tagNode": "Tag Node",
"tagNode": "New Node",
"download": "Download",
"settings": "Settings",
"edit": "Edit",
"delete": "Delete",
"cancel": "Cancel",
"ok": "OK",
"close": "Close",
@@ -41,7 +42,10 @@
"title": "Node #{}",
"tagSheetTitle": "Surveillance Device Tags",
"queuedForUpload": "Node queued for upload",
"editQueuedForUpload": "Node edit queued for upload"
"editQueuedForUpload": "Node edit queued for upload",
"deleteQueuedForUpload": "Node deletion queued for upload",
"confirmDeleteTitle": "Delete Node",
"confirmDeleteMessage": "Are you sure you want to delete node #{}? This action cannot be undone."
},
"addNode": {
"profile": "Profile",
@@ -211,5 +215,27 @@
"deleteOperatorProfile": "Delete Operator Profile",
"deleteOperatorProfileConfirm": "Are you sure you want to delete \"{}\"?",
"operatorProfileDeleted": "Operator profile deleted"
},
"offlineAreas": {
"noAreasTitle": "No offline areas",
"noAreasSubtitle": "Download a map area for offline use.",
"provider": "Provider",
"zoomLevels": "Z{}-{}",
"latitude": "Lat",
"longitude": "Lon",
"tiles": "Tiles",
"size": "Size",
"cameras": "Cameras",
"areaIdFallback": "Area {}...",
"renameArea": "Rename area",
"refreshWorldTiles": "Refresh/re-download world tiles",
"deleteOfflineArea": "Delete offline area",
"cancelDownload": "Cancel download",
"renameAreaDialogTitle": "Rename Offline Area",
"areaNameLabel": "Area Name",
"renameButton": "Rename",
"megabytes": "MB",
"kilobytes": "KB",
"progress": "{}%"
}
}

View File

@@ -6,10 +6,11 @@
"title": "DeFlock"
},
"actions": {
"tagNode": "Etiquetar Nodo",
"tagNode": "Nuevo Nodo",
"download": "Descargar",
"settings": "Configuración",
"edit": "Editar",
"delete": "Eliminar",
"cancel": "Cancelar",
"ok": "Aceptar",
"close": "Cerrar",
@@ -41,7 +42,10 @@
"title": "Nodo #{}",
"tagSheetTitle": "Etiquetas del Dispositivo",
"queuedForUpload": "Nodo en cola para subir",
"editQueuedForUpload": "Edición de nodo en cola para subir"
"editQueuedForUpload": "Edición de nodo en cola para subir",
"deleteQueuedForUpload": "Eliminación de nodo en cola para subir",
"confirmDeleteTitle": "Eliminar Nodo",
"confirmDeleteMessage": "¿Estás seguro de que quieres eliminar el nodo #{}? Esta acción no se puede deshacer."
},
"addNode": {
"profile": "Perfil",
@@ -211,5 +215,27 @@
"deleteOperatorProfile": "Eliminar Perfil de Operador",
"deleteOperatorProfileConfirm": "¿Está seguro de que desea eliminar \"{}\"?",
"operatorProfileDeleted": "Perfil de operador eliminado"
},
"offlineAreas": {
"noAreasTitle": "Sin áreas sin conexión",
"noAreasSubtitle": "Descarga un área del mapa para uso sin conexión.",
"provider": "Proveedor",
"zoomLevels": "Z{}-{}",
"latitude": "Lat",
"longitude": "Lon",
"tiles": "Teselas",
"size": "Tamaño",
"cameras": "Cámaras",
"areaIdFallback": "Área {}...",
"renameArea": "Renombrar área",
"refreshWorldTiles": "Actualizar/re-descargar teselas mundiales",
"deleteOfflineArea": "Eliminar área sin conexión",
"cancelDownload": "Cancelar descarga",
"renameAreaDialogTitle": "Renombrar Área Sin Conexión",
"areaNameLabel": "Nombre del Área",
"renameButton": "Renombrar",
"megabytes": "MB",
"kilobytes": "KB",
"progress": "{}%"
}
}

View File

@@ -6,10 +6,11 @@
"title": "DeFlock"
},
"actions": {
"tagNode": "Marquer Nœud",
"tagNode": "Nouveau Nœud",
"download": "Télécharger",
"settings": "Paramètres",
"edit": "Modifier",
"delete": "Supprimer",
"cancel": "Annuler",
"ok": "OK",
"close": "Fermer",
@@ -41,7 +42,10 @@
"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"
"editQueuedForUpload": "Modification de nœud mise en file pour envoi",
"deleteQueuedForUpload": "Suppression de nœud mise en file pour envoi",
"confirmDeleteTitle": "Supprimer le Nœud",
"confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer le nœud #{} ? Cette action ne peut pas être annulée."
},
"addNode": {
"profile": "Profil",
@@ -211,5 +215,27 @@
"deleteOperatorProfile": "Supprimer Profil d'Opérateur",
"deleteOperatorProfileConfirm": "Êtes-vous sûr de vouloir supprimer \"{}\"?",
"operatorProfileDeleted": "Profil d'opérateur supprimé"
},
"offlineAreas": {
"noAreasTitle": "Aucune zone hors ligne",
"noAreasSubtitle": "Téléchargez une zone de carte pour utilisation hors ligne.",
"provider": "Fournisseur",
"zoomLevels": "Z{}-{}",
"latitude": "Lat",
"longitude": "Lon",
"tiles": "Tuiles",
"size": "Taille",
"cameras": "Caméras",
"areaIdFallback": "Zone {}...",
"renameArea": "Renommer la zone",
"refreshWorldTiles": "Actualiser/re-télécharger les tuiles mondiales",
"deleteOfflineArea": "Supprimer la zone hors ligne",
"cancelDownload": "Annuler le téléchargement",
"renameAreaDialogTitle": "Renommer la Zone Hors Ligne",
"areaNameLabel": "Nom de la Zone",
"renameButton": "Renommer",
"megabytes": "Mo",
"kilobytes": "Ko",
"progress": "{}%"
}
}

View File

@@ -23,7 +23,7 @@ Future<void> main() async {
// You can customize this splash/loading screen as needed
return MaterialApp(
home: Scaffold(
backgroundColor: Color(0xFF202020),
backgroundColor: Color(0xFF152131),
body: Center(
child: Image.asset(
'assets/app_icon.png',

View File

@@ -3,13 +3,17 @@ import 'node_profile.dart';
import 'operator_profile.dart';
import '../state/settings_state.dart';
enum UploadOperation { create, modify, delete }
class PendingUpload {
final LatLng coord;
final double direction;
final NodeProfile profile;
final OperatorProfile? operatorProfile;
final UploadMode uploadMode; // Capture upload destination when queued
final int? originalNodeId; // If this is an edit, the ID of the original OSM node
final UploadOperation operation; // Type of operation: create, modify, or delete
final int? originalNodeId; // If this is modify/delete, the ID of the original OSM node
int? submittedNodeId; // The actual node ID returned by OSM after successful submission
int attempts;
bool error;
bool completing; // True when upload succeeded but item is showing checkmark briefly
@@ -20,14 +24,23 @@ class PendingUpload {
required this.profile,
this.operatorProfile,
required this.uploadMode,
required this.operation,
this.originalNodeId,
this.submittedNodeId,
this.attempts = 0,
this.error = false,
this.completing = false,
});
}) : assert(
(operation == UploadOperation.create && originalNodeId == null) ||
(operation != UploadOperation.create && originalNodeId != null),
'originalNodeId must be null for create operations and non-null for modify/delete operations'
);
// True if this is an edit of an existing camera, false if it's a new camera
bool get isEdit => originalNodeId != null;
// True if this is an edit of an existing node, false if it's a new node
bool get isEdit => operation == UploadOperation.modify;
// True if this is a deletion of an existing node
bool get isDeletion => operation == UploadOperation.delete;
// Get display name for the upload destination
String get uploadModeDisplayName {
@@ -41,11 +54,11 @@ class PendingUpload {
}
}
// Get combined tags from camera profile and operator profile
// Get combined tags from node profile and operator profile
Map<String, String> getCombinedTags() {
final tags = Map<String, String>.from(profile.tags);
// Add operator profile tags (they override camera profile tags if there are conflicts)
// Add operator profile tags (they override node profile tags if there are conflicts)
if (operatorProfile != null) {
tags.addAll(operatorProfile!.tags);
}
@@ -65,7 +78,9 @@ class PendingUpload {
'profile': profile.toJson(),
'operatorProfile': operatorProfile?.toJson(),
'uploadMode': uploadMode.index,
'operation': operation.index,
'originalNodeId': originalNodeId,
'submittedNodeId': submittedNodeId,
'attempts': attempts,
'error': error,
'completing': completing,
@@ -83,7 +98,11 @@ class PendingUpload {
uploadMode: j['uploadMode'] != null
? UploadMode.values[j['uploadMode']]
: UploadMode.production, // Default for legacy entries
operation: j['operation'] != null
? UploadOperation.values[j['operation']]
: (j['originalNodeId'] != null ? UploadOperation.modify : UploadOperation.create), // Legacy compatibility
originalNodeId: j['originalNodeId'],
submittedNodeId: j['submittedNodeId'],
attempts: j['attempts'] ?? 0,
error: j['error'] ?? false,
completing: j['completing'] ?? false, // Default to false for legacy entries

View File

@@ -201,7 +201,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
alignment: Alignment.bottomCenter,
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom + kBottomButtonBarMargin,
bottom: MediaQuery.of(context).padding.bottom + kBottomButtonBarOffset,
left: 8,
right: 8,
),
@@ -217,7 +217,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
)
],
),
margin: EdgeInsets.only(bottom: kBottomButtonBarMargin),
margin: EdgeInsets.only(bottom: kBottomButtonBarOffset),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
children: [

View File

@@ -11,6 +11,7 @@ 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';
import '../dev_config.dart';
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@@ -23,28 +24,31 @@ class SettingsScreen extends StatelessWidget {
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(),
children: [
// Only show upload mode section in development builds
if (kEnableDevelopmentModes) ...[
const UploadModeSection(),
const Divider(),
],
const AuthSection(),
const Divider(),
const QueueSection(),
const Divider(),
const ProfileListSection(),
const Divider(),
const OperatorProfileListSection(),
const Divider(),
const MaxNodesSection(),
const Divider(),
const TileProviderSection(),
const Divider(),
const OfflineModeSection(),
const Divider(),
const OfflineAreasSection(),
const Divider(),
const LanguageSection(),
const Divider(),
const AboutSection(),
],
),
),

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../../services/offline_area_service.dart';
import '../../services/offline_areas/offline_area_models.dart';
import '../../services/localization_service.dart';
class OfflineAreasSection extends StatefulWidget {
const OfflineAreasSection({super.key});
@@ -25,35 +26,40 @@ class _OfflineAreasSectionState extends State<OfflineAreasSection> {
@override
Widget build(BuildContext context) {
final areas = service.offlineAreas;
if (areas.isEmpty) {
return const ListTile(
leading: Icon(Icons.download_for_offline),
title: Text('No offline areas'),
subtitle: Text('Download a map area for offline use.'),
);
}
return Column(
children: areas.map((area) {
String diskStr = area.sizeBytes > 0
? area.sizeBytes > 1024 * 1024
? "${(area.sizeBytes / (1024 * 1024)).toStringAsFixed(2)} MB"
: "${(area.sizeBytes / 1024).toStringAsFixed(1)} KB"
: '--';
String subtitle =
'Provider: ${area.tileProviderDisplay}\n' +
'Z${area.minZoom}-${area.maxZoom}\n' +
'Lat: ${area.bounds.southWest.latitude.toStringAsFixed(3)}, ${area.bounds.southWest.longitude.toStringAsFixed(3)}\n' +
'Lat: ${area.bounds.northEast.latitude.toStringAsFixed(3)}, ${area.bounds.northEast.longitude.toStringAsFixed(3)}';
if (area.status == OfflineAreaStatus.downloading) {
subtitle += '\nTiles: ${area.tilesDownloaded} / ${area.tilesTotal}';
} else {
subtitle += '\nTiles: ${area.tilesTotal}';
}
subtitle += '\nSize: $diskStr';
if (!area.isPermanent) {
subtitle += '\nCameras: ${area.cameras.length}';
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
final areas = service.offlineAreas;
if (areas.isEmpty) {
return ListTile(
leading: const Icon(Icons.download_for_offline),
title: Text(locService.t('offlineAreas.noAreasTitle')),
subtitle: Text(locService.t('offlineAreas.noAreasSubtitle')),
);
}
return Column(
children: areas.map((area) {
String diskStr = area.sizeBytes > 0
? area.sizeBytes > 1024 * 1024
? "${(area.sizeBytes / (1024 * 1024)).toStringAsFixed(2)} ${locService.t('offlineAreas.megabytes')}"
: "${(area.sizeBytes / 1024).toStringAsFixed(1)} ${locService.t('offlineAreas.kilobytes')}"
: '--';
String subtitle = '${locService.t('offlineAreas.provider')}: ${area.tileProviderDisplay}\n' +
'Max zoom: Z${area.maxZoom}' + '\n' +
'${locService.t('offlineAreas.latitude')}: ${area.bounds.southWest.latitude.toStringAsFixed(3)}, ${area.bounds.southWest.longitude.toStringAsFixed(3)}\n' +
'${locService.t('offlineAreas.latitude')}: ${area.bounds.northEast.latitude.toStringAsFixed(3)}, ${area.bounds.northEast.longitude.toStringAsFixed(3)}';
if (area.status == OfflineAreaStatus.downloading) {
subtitle += '\n${locService.t('offlineAreas.tiles')}: ${area.tilesDownloaded} / ${area.tilesTotal}';
} else {
subtitle += '\n${locService.t('offlineAreas.tiles')}: ${area.tilesTotal}';
}
subtitle += '\n${locService.t('offlineAreas.size')}: $diskStr';
subtitle += '\n${locService.t('offlineAreas.cameras')}: ${area.nodes.length}';
return Card(
child: ListTile(
leading: Icon(area.status == OfflineAreaStatus.complete
@@ -66,35 +72,34 @@ class _OfflineAreasSectionState extends State<OfflineAreasSection> {
Expanded(
child: Text(area.name.isNotEmpty
? area.name
: 'Area ${area.id.substring(0, 6)}...'),
: locService.t('offlineAreas.areaIdFallback', params: [area.id.substring(0, 6)])),
),
if (!area.isPermanent)
IconButton(
icon: const Icon(Icons.edit, size: 20),
tooltip: 'Rename area',
IconButton(
icon: const Icon(Icons.edit, size: 20),
tooltip: locService.t('offlineAreas.renameArea'),
onPressed: () async {
String? newName = await showDialog<String>(
context: context,
builder: (ctx) {
final ctrl = TextEditingController(text: area.name);
return AlertDialog(
title: const Text('Rename Offline Area'),
title: Text(locService.t('offlineAreas.renameAreaDialogTitle')),
content: TextField(
controller: ctrl,
maxLength: 40,
decoration: const InputDecoration(labelText: 'Area Name'),
decoration: InputDecoration(labelText: locService.t('offlineAreas.areaNameLabel')),
autofocus: true,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
child: Text(locService.t('actions.cancel')),
),
ElevatedButton(
onPressed: () {
Navigator.pop(ctx, ctrl.text.trim());
},
child: const Text('Rename'),
child: Text(locService.t('offlineAreas.renameButton')),
),
],
);
@@ -108,32 +113,10 @@ class _OfflineAreasSectionState extends State<OfflineAreasSection> {
}
},
),
if (area.isPermanent && area.status != OfflineAreaStatus.downloading)
IconButton(
icon: const Icon(Icons.refresh, color: Colors.blue),
tooltip: 'Refresh/re-download world tiles',
onPressed: () async {
await service.downloadArea(
id: area.id,
bounds: area.bounds,
minZoom: area.minZoom,
maxZoom: area.maxZoom,
directory: area.directory,
name: area.name,
onProgress: (progress) {},
onComplete: (status) {},
tileProviderId: area.tileProviderId,
tileProviderName: area.tileProviderName,
tileTypeId: area.tileTypeId,
tileTypeName: area.tileTypeName,
);
setState(() {});
},
)
else if (!area.isPermanent && area.status != OfflineAreaStatus.downloading)
if (area.status != OfflineAreaStatus.downloading)
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
tooltip: 'Delete offline area',
tooltip: locService.t('offlineAreas.deleteOfflineArea'),
onPressed: () async {
service.deleteArea(area.id);
setState(() {});
@@ -154,7 +137,7 @@ class _OfflineAreasSectionState extends State<OfflineAreasSection> {
children: [
LinearProgressIndicator(value: area.progress),
Text(
'${(area.progress * 100).toStringAsFixed(0)}%',
locService.t('offlineAreas.progress', params: [(area.progress * 100).toStringAsFixed(0)]),
style: const TextStyle(fontSize: 12),
)
],
@@ -162,7 +145,7 @@ class _OfflineAreasSectionState extends State<OfflineAreasSection> {
),
IconButton(
icon: const Icon(Icons.cancel, color: Colors.orange),
tooltip: 'Cancel download',
tooltip: locService.t('offlineAreas.cancelDownload'),
onPressed: () {
service.cancelDownload(area.id);
setState(() {});
@@ -178,8 +161,10 @@ class _OfflineAreasSectionState extends State<OfflineAreasSection> {
}
: null,
),
);
}).toList(),
);
}).toList(),
);
},
);
}
}

View File

@@ -106,6 +106,36 @@ class AuthService {
}
}
// Restore login state from stored token (for app startup)
Future<String?> restoreLogin() async {
if (_mode == UploadMode.simulate) {
final prefs = await SharedPreferences.getInstance();
final isLoggedIn = prefs.getBool('sim_user_logged_in') ?? false;
if (isLoggedIn) {
_displayName = 'Demo User';
return _displayName;
}
return null;
}
// Get stored token directly from SharedPreferences
final accessToken = await getAccessToken();
if (accessToken == null) {
return null;
}
try {
_displayName = await _fetchUsername(accessToken);
return _displayName;
} catch (e) {
print('AuthService: Error restoring login with stored token: $e');
log('Error restoring login with stored token: $e');
// Token might be expired or invalid, clear it
await logout();
return null;
}
}
Future<void> logout() async {
if (_mode == UploadMode.simulate) {
final prefs = await SharedPreferences.getInstance();

View File

@@ -1,56 +0,0 @@
import 'package:latlong2/latlong.dart';
import '../models/osm_camera_node.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
class CameraCache {
// Singleton instance
static final CameraCache instance = CameraCache._internal();
factory CameraCache() => instance;
CameraCache._internal();
final Map<int, OsmCameraNode> _nodes = {};
/// Add or update a batch of camera nodes in the cache.
void addOrUpdate(List<OsmCameraNode> nodes) {
for (var node in nodes) {
final existing = _nodes[node.id];
if (existing != null) {
// Preserve any tags starting with underscore when updating existing nodes
final mergedTags = Map<String, String>.from(node.tags);
for (final entry in existing.tags.entries) {
if (entry.key.startsWith('_')) {
mergedTags[entry.key] = entry.value;
}
}
_nodes[node.id] = OsmCameraNode(
id: node.id,
coord: node.coord,
tags: mergedTags,
);
} else {
_nodes[node.id] = node;
}
}
}
/// Query for all cached cameras currently within the given LatLngBounds.
List<OsmCameraNode> queryByBounds(LatLngBounds bounds) {
return _nodes.values
.where((node) => _inBounds(node.coord, bounds))
.toList();
}
/// Retrieve all cached cameras.
List<OsmCameraNode> getAll() => _nodes.values.toList();
/// Optionally clear the cache (rarely needed)
void clear() => _nodes.clear();
/// Utility: point-in-bounds for coordinates
bool _inBounds(LatLng coord, LatLngBounds bounds) {
return coord.latitude >= bounds.southWest.latitude &&
coord.latitude <= bounds.northEast.latitude &&
coord.longitude >= bounds.southWest.longitude &&
coord.longitude <= bounds.northEast.longitude;
}
}

View File

@@ -99,10 +99,10 @@ class LocalizationService extends ChangeNotifier {
String result = current is String ? current : key;
// Replace parameters if provided
// Replace parameters if provided - replace first occurrence only for each parameter
if (params != null) {
for (int i = 0; i < params.length; i++) {
result = result.replaceAll('{}', params[i]);
result = result.replaceFirst('{}', params[i]);
}
}

View File

@@ -6,9 +6,11 @@ import '../models/node_profile.dart';
import '../models/osm_camera_node.dart';
import '../app_state.dart';
import 'map_data_submodules/nodes_from_overpass.dart';
import 'map_data_submodules/nodes_from_osm_api.dart';
import 'map_data_submodules/tiles_from_remote.dart';
import 'map_data_submodules/nodes_from_local.dart';
import 'map_data_submodules/tiles_from_local.dart';
import 'network_status.dart';
enum MapSource { local, remote, auto } // For future use
@@ -39,6 +41,7 @@ class MapDataProvider {
UploadMode uploadMode = UploadMode.production,
MapSource source = MapSource.auto,
}) async {
try {
final offline = AppState.instance.offlineMode;
// Explicit remote request: error if offline, else always remote
@@ -46,7 +49,7 @@ class MapDataProvider {
if (offline) {
throw OfflineModeException("Cannot fetch remote nodes in offline mode.");
}
return fetchOverpassNodes(
return _fetchRemoteNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
@@ -62,30 +65,76 @@ class MapDataProvider {
);
}
// AUTO: default = remote first, fallback to local only if offline
// AUTO: In offline mode, behavior depends on upload mode
if (offline) {
return fetchLocalNodes(
bounds: bounds,
profiles: profiles,
maxNodes: AppState.instance.maxCameras,
);
} else {
// Try remote, fallback to local ONLY if remote throws (optional, could be removed for stricter behavior)
try {
return await fetchOverpassNodes(
if (uploadMode == UploadMode.sandbox) {
// Offline + Sandbox = no nodes (local cache is production data)
debugPrint('[MapDataProvider] Offline + Sandbox mode: returning no nodes (local cache is production data)');
return <OsmCameraNode>[];
} else {
// Offline + Production = use local cache
return fetchLocalNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: AppState.instance.maxCameras,
maxNodes: AppState.instance.maxCameras,
);
} catch (e) {
debugPrint('[MapDataProvider] Remote node fetch failed, error: $e. Falling back to local.');
return fetchLocalNodes(
}
} else if (uploadMode == UploadMode.sandbox) {
// Sandbox mode: Only fetch from sandbox API, ignore local production nodes
debugPrint('[MapDataProvider] Sandbox mode: fetching only from sandbox API, ignoring local cache');
return _fetchRemoteNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: AppState.instance.maxCameras,
);
} else {
// Production mode: fetch both remote and local, then merge with deduplication
final List<Future<List<OsmCameraNode>>> futures = [];
// Always try to get local nodes (fast, cached)
futures.add(fetchLocalNodes(
bounds: bounds,
profiles: profiles,
maxNodes: AppState.instance.maxCameras,
);
));
// Always try to get remote nodes (slower, fresh data)
futures.add(_fetchRemoteNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: AppState.instance.maxCameras,
).catchError((e) {
debugPrint('[MapDataProvider] Remote node fetch failed, error: $e. Continuing with local only.');
return <OsmCameraNode>[]; // Return empty list on remote failure
}));
// Wait for both, then merge with deduplication by node ID
final results = await Future.wait(futures);
final localNodes = results[0];
final remoteNodes = results[1];
// Merge with deduplication - prefer remote data over local for same node ID
final Map<int, OsmCameraNode> mergedNodes = {};
// Add local nodes first
for (final node in localNodes) {
mergedNodes[node.id] = node;
}
// Add remote nodes, overwriting any local duplicates
for (final node in remoteNodes) {
mergedNodes[node.id] = node;
}
// Apply maxCameras limit to the merged result
final finalNodes = mergedNodes.values.take(AppState.instance.maxCameras).toList();
return finalNodes;
}
} finally {
// Always report node completion, regardless of success or failure
NetworkStatus.instance.reportNodeComplete();
}
}
@@ -95,18 +144,18 @@ class MapDataProvider {
required LatLngBounds bounds,
required List<NodeProfile> profiles,
UploadMode uploadMode = UploadMode.production,
int pageSize = 500,
int maxResults = 0, // 0 = no limit for offline downloads
int maxTries = 3,
}) async {
final offline = AppState.instance.offlineMode;
if (offline) {
throw OfflineModeException("Cannot fetch remote nodes for offline area download in offline mode.");
}
return fetchOverpassNodes(
return _fetchRemoteNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: pageSize,
maxResults: maxResults, // Pass 0 for unlimited
);
}
@@ -163,4 +212,58 @@ class MapDataProvider {
void clearTileQueue() {
clearRemoteTileQueue();
}
/// Fetch remote nodes with Overpass first, OSM API fallback
Future<List<OsmCameraNode>> _fetchRemoteNodes({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
UploadMode uploadMode = UploadMode.production,
required int maxResults,
}) async {
// For sandbox mode, skip Overpass and go directly to OSM API
// (Overpass doesn't have sandbox data)
if (uploadMode == UploadMode.sandbox) {
debugPrint('[MapDataProvider] Sandbox mode detected, using OSM API directly');
return fetchOsmApiNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: maxResults,
);
}
// For production mode, try Overpass first, then fallback to OSM API
try {
final nodes = await fetchOverpassNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: maxResults,
);
// If Overpass returns nodes, we're good
if (nodes.isNotEmpty) {
return nodes;
}
// If Overpass returns empty (could be no data or could be an issue),
// try OSM API as well to be thorough
debugPrint('[MapDataProvider] Overpass returned no nodes, trying OSM API fallback');
return fetchOsmApiNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: maxResults,
);
} catch (e) {
debugPrint('[MapDataProvider] Overpass failed ($e), trying OSM API fallback');
return fetchOsmApiNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: maxResults,
);
}
}
}

View File

@@ -1,5 +1,6 @@
import 'dart:io';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
import '../../models/osm_camera_node.dart';
@@ -38,15 +39,31 @@ Future<List<OsmCameraNode>> fetchLocalNodes({
// Try in-memory first, else load from disk
Future<List<OsmCameraNode>> _loadAreaNodes(OfflineArea area) async {
if (area.cameras.isNotEmpty) {
return area.cameras;
if (area.nodes.isNotEmpty) {
return area.nodes;
}
final file = File('${area.directory}/cameras.json');
if (await file.exists()) {
final str = await file.readAsString();
final jsonList = jsonDecode(str) as List;
return jsonList.map((e) => OsmCameraNode.fromJson(e)).toList();
// Try new nodes.json first, fall back to legacy cameras.json for backward compatibility
final nodeFile = File('${area.directory}/nodes.json');
final legacyCameraFile = File('${area.directory}/cameras.json');
File? fileToLoad;
if (await nodeFile.exists()) {
fileToLoad = nodeFile;
} else if (await legacyCameraFile.exists()) {
fileToLoad = legacyCameraFile;
}
if (fileToLoad != null) {
try {
final str = await fileToLoad.readAsString();
final jsonList = jsonDecode(str) as List;
return jsonList.map((e) => OsmCameraNode.fromJson(e)).toList();
} catch (e) {
debugPrint('[_loadAreaNodes] Error loading nodes from ${fileToLoad.path}: $e');
}
}
return [];
}

View File

@@ -0,0 +1,129 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:xml/xml.dart';
import '../../models/node_profile.dart';
import '../../models/osm_camera_node.dart';
import '../../app_state.dart';
import '../network_status.dart';
/// Fetches surveillance nodes from the direct OSM API using bbox query.
/// This is a fallback for when Overpass is not available (e.g., sandbox mode).
Future<List<OsmCameraNode>> fetchOsmApiNodes({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
UploadMode uploadMode = UploadMode.production,
required int maxResults,
}) async {
if (profiles.isEmpty) return [];
// Choose API endpoint based on upload mode
final String apiHost = uploadMode == UploadMode.sandbox
? 'api06.dev.openstreetmap.org'
: 'api.openstreetmap.org';
// Build the map query URL - fetches all data in bounding box
final left = bounds.southWest.longitude;
final bottom = bounds.southWest.latitude;
final right = bounds.northEast.longitude;
final top = bounds.northEast.latitude;
final url = 'https://$apiHost/api/0.6/map?bbox=$left,$bottom,$right,$top';
try {
debugPrint('[fetchOsmApiNodes] Querying OSM API for nodes in bbox...');
debugPrint('[fetchOsmApiNodes] URL: $url');
final response = await http.get(Uri.parse(url));
if (response.statusCode != 200) {
debugPrint('[fetchOsmApiNodes] OSM API error: ${response.statusCode} - ${response.body}');
NetworkStatus.instance.reportOverpassIssue(); // Reuse same status tracking
return [];
}
// Parse XML response
final document = XmlDocument.parse(response.body);
final nodes = <OsmCameraNode>[];
// Find all node elements
for (final nodeElement in document.findAllElements('node')) {
final id = int.tryParse(nodeElement.getAttribute('id') ?? '');
final latStr = nodeElement.getAttribute('lat');
final lonStr = nodeElement.getAttribute('lon');
if (id == null || latStr == null || lonStr == null) continue;
final lat = double.tryParse(latStr);
final lon = double.tryParse(lonStr);
if (lat == null || lon == null) continue;
// Parse tags
final tags = <String, String>{};
for (final tagElement in nodeElement.findElements('tag')) {
final key = tagElement.getAttribute('k');
final value = tagElement.getAttribute('v');
if (key != null && value != null) {
tags[key] = value;
}
}
// Check if this node matches any of our profiles
if (_nodeMatchesProfiles(tags, profiles)) {
nodes.add(OsmCameraNode(
id: id,
coord: LatLng(lat, lon),
tags: tags,
));
}
// Respect maxResults limit if set
if (maxResults > 0 && nodes.length >= maxResults) {
break;
}
}
if (nodes.isNotEmpty) {
debugPrint('[fetchOsmApiNodes] Retrieved ${nodes.length} matching surveillance nodes');
}
NetworkStatus.instance.reportOverpassSuccess(); // Reuse same status tracking
return nodes;
} catch (e) {
debugPrint('[fetchOsmApiNodes] Exception: $e');
// Report network issues for connection errors
if (e.toString().contains('Connection refused') ||
e.toString().contains('Connection timed out') ||
e.toString().contains('Connection reset')) {
NetworkStatus.instance.reportOverpassIssue();
}
return [];
}
}
/// Check if a node's tags match any of the given profiles
bool _nodeMatchesProfiles(Map<String, String> nodeTags, List<NodeProfile> profiles) {
for (final profile in profiles) {
if (_nodeMatchesProfile(nodeTags, profile)) {
return true;
}
}
return false;
}
/// Check if a node's tags match a specific profile
bool _nodeMatchesProfile(Map<String, String> nodeTags, NodeProfile profile) {
// All profile tags must be present in the node for it to match
for (final entry in profile.tags.entries) {
if (nodeTags[entry.key] != entry.value) {
return false;
}
}
return true;
}

View File

@@ -6,6 +6,7 @@ import 'package:flutter_map/flutter_map.dart';
import '../../models/node_profile.dart';
import '../../models/osm_camera_node.dart';
import '../../models/pending_upload.dart';
import '../../app_state.dart';
import '../network_status.dart';
@@ -47,7 +48,7 @@ Future<List<OsmCameraNode>> fetchOverpassNodes({
NetworkStatus.instance.reportOverpassSuccess();
return elements.whereType<Map<String, dynamic>>().map((element) {
final nodes = elements.whereType<Map<String, dynamic>>().map((element) {
return OsmCameraNode(
id: element['id'],
coord: LatLng(element['lat'], element['lon']),
@@ -55,6 +56,11 @@ Future<List<OsmCameraNode>> fetchOverpassNodes({
);
}).toList();
// Clean up any pending uploads that now appear in Overpass results
_cleanupCompletedUploads(nodes);
return nodes;
} catch (e) {
debugPrint('[fetchOverpassNodes] Exception: $e');
@@ -82,11 +88,50 @@ String _buildOverpassQuery(LatLngBounds bounds, List<NodeProfile> profiles, int
return 'node$tagFilters(${bounds.southWest.latitude},${bounds.southWest.longitude},${bounds.northEast.latitude},${bounds.northEast.longitude});';
}).join('\n ');
// Use unlimited output if maxResults is 0
final outputClause = maxResults > 0 ? 'out body $maxResults;' : 'out body;';
return '''
[out:json][timeout:25];
(
$nodeClauses
);
out body $maxResults;
$outputClause
''';
}
/// Clean up pending uploads that now appear in Overpass results
void _cleanupCompletedUploads(List<OsmCameraNode> overpassNodes) {
try {
final appState = AppState.instance;
final pendingUploads = appState.pendingUploads;
if (pendingUploads.isEmpty) return;
final overpassNodeIds = overpassNodes.map((n) => n.id).toSet();
// Find pending uploads whose submitted node IDs now appear in Overpass results
final uploadsToRemove = <PendingUpload>[];
for (final upload in pendingUploads) {
if (upload.submittedNodeId != null &&
overpassNodeIds.contains(upload.submittedNodeId!)) {
uploadsToRemove.add(upload);
debugPrint('[OverpassCleanup] Found submitted node ${upload.submittedNodeId} in Overpass results, removing from pending queue');
}
}
// Remove the completed uploads from the queue
for (final upload in uploadsToRemove) {
appState.removeFromQueue(upload);
}
if (uploadsToRemove.isNotEmpty) {
debugPrint('[OverpassCleanup] Cleaned up ${uploadsToRemove.length} completed uploads');
}
} catch (e) {
debugPrint('[OverpassCleanup] Error during cleanup: $e');
// Don't let cleanup errors break the main functionality
}
}

View File

@@ -6,6 +6,9 @@ import '../app_state.dart';
enum NetworkIssueType { osmTiles, overpassApi, both }
enum NetworkStatusType { waiting, issues, timedOut, noData, ready, success }
/// Simple loading state for dual-source async operations (brutalist approach)
enum LoadingState { ready, waiting, success, timeout }
class NetworkStatus extends ChangeNotifier {
static final NetworkStatus instance = NetworkStatus._();
NetworkStatus._();
@@ -23,6 +26,13 @@ class NetworkStatus extends ChangeNotifier {
Timer? _noDataResetTimer;
Timer? _successResetTimer;
// New dual-source loading state (brutalist approach)
LoadingState _tileLoadingState = LoadingState.ready;
LoadingState _nodeLoadingState = LoadingState.ready;
Timer? _tileTimeoutTimer;
Timer? _nodeTimeoutTimer;
Timer? _successDisplayTimer;
// Getters
bool get hasAnyIssues => _osmTilesHaveIssues || _overpassHasIssues;
bool get osmTilesHaveIssues => _osmTilesHaveIssues;
@@ -32,7 +42,22 @@ class NetworkStatus extends ChangeNotifier {
bool get hasNoData => _hasNoData;
bool get hasSuccess => _hasSuccess;
// New dual-source getters (brutalist approach)
LoadingState get tileLoadingState => _tileLoadingState;
LoadingState get nodeLoadingState => _nodeLoadingState;
/// Derive overall loading status from dual sources
bool get isDualSourceLoading => _tileLoadingState == LoadingState.waiting || _nodeLoadingState == LoadingState.waiting;
bool get isDualSourceTimeout => _tileLoadingState == LoadingState.timeout || _nodeLoadingState == LoadingState.timeout;
bool get isDualSourceSuccess => _tileLoadingState == LoadingState.success && _nodeLoadingState == LoadingState.success;
NetworkStatusType get currentStatus {
// Check new dual-source states first
if (isDualSourceTimeout) return NetworkStatusType.timedOut;
if (isDualSourceLoading) return NetworkStatusType.waiting;
if (isDualSourceSuccess) return NetworkStatusType.success;
// Fall back to legacy states for compatibility
if (hasAnyIssues) return NetworkStatusType.issues;
if (_isWaitingForData) return NetworkStatusType.waiting;
if (_isTimedOut) return NetworkStatusType.timedOut;
@@ -206,12 +231,91 @@ class NetworkStatus extends ChangeNotifier {
});
}
// New dual-source loading methods (brutalist approach)
/// Start waiting for both tiles and nodes
void setDualSourceWaiting() {
_tileLoadingState = LoadingState.waiting;
_nodeLoadingState = LoadingState.waiting;
// Set timeout timers for both
_tileTimeoutTimer?.cancel();
_tileTimeoutTimer = Timer(const Duration(seconds: 8), () {
if (_tileLoadingState == LoadingState.waiting) {
_tileLoadingState = LoadingState.timeout;
debugPrint('[NetworkStatus] Tile loading timed out');
notifyListeners();
}
});
_nodeTimeoutTimer?.cancel();
_nodeTimeoutTimer = Timer(const Duration(seconds: 8), () {
if (_nodeLoadingState == LoadingState.waiting) {
_nodeLoadingState = LoadingState.timeout;
debugPrint('[NetworkStatus] Node loading timed out');
notifyListeners();
}
});
notifyListeners();
}
/// Report tile loading completion
void reportTileComplete() {
if (_tileLoadingState == LoadingState.waiting) {
_tileLoadingState = LoadingState.success;
_tileTimeoutTimer?.cancel();
_checkDualSourceComplete();
}
}
/// Report node loading completion
void reportNodeComplete() {
if (_nodeLoadingState == LoadingState.waiting) {
_nodeLoadingState = LoadingState.success;
_nodeTimeoutTimer?.cancel();
_checkDualSourceComplete();
}
}
/// Check if both sources are complete and show success briefly
void _checkDualSourceComplete() {
if (_tileLoadingState == LoadingState.success && _nodeLoadingState == LoadingState.success) {
debugPrint('[NetworkStatus] Both tiles and nodes loaded successfully');
notifyListeners();
// Auto-reset to ready after showing success briefly
_successDisplayTimer?.cancel();
_successDisplayTimer = Timer(const Duration(seconds: 2), () {
_tileLoadingState = LoadingState.ready;
_nodeLoadingState = LoadingState.ready;
notifyListeners();
});
} else {
// Just notify if one completed but not both yet
notifyListeners();
}
}
/// Reset dual-source state to ready
void resetDualSourceState() {
_tileLoadingState = LoadingState.ready;
_nodeLoadingState = LoadingState.ready;
_tileTimeoutTimer?.cancel();
_nodeTimeoutTimer?.cancel();
_successDisplayTimer?.cancel();
notifyListeners();
}
@override
void dispose() {
_osmRecoveryTimer?.cancel();
_overpassRecoveryTimer?.cancel();
_waitingTimer?.cancel();
_noDataResetTimer?.cancel();
_tileTimeoutTimer?.cancel();
_nodeTimeoutTimer?.cancel();
_successDisplayTimer?.cancel();
super.dispose();
}
}

View File

@@ -0,0 +1,110 @@
import 'package:latlong2/latlong.dart';
import '../models/osm_camera_node.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
class NodeCache {
// Singleton instance
static final NodeCache instance = NodeCache._internal();
factory NodeCache() => instance;
NodeCache._internal();
final Map<int, OsmCameraNode> _nodes = {};
/// Add or update a batch of nodes in the cache.
void addOrUpdate(List<OsmCameraNode> nodes) {
for (var node in nodes) {
final existing = _nodes[node.id];
if (existing != null) {
// Preserve any tags starting with underscore when updating existing nodes
final mergedTags = Map<String, String>.from(node.tags);
for (final entry in existing.tags.entries) {
if (entry.key.startsWith('_')) {
mergedTags[entry.key] = entry.value;
}
}
_nodes[node.id] = OsmCameraNode(
id: node.id,
coord: node.coord,
tags: mergedTags,
);
} else {
_nodes[node.id] = node;
}
}
}
/// Query for all cached nodes currently within the given LatLngBounds.
List<OsmCameraNode> queryByBounds(LatLngBounds bounds) {
return _nodes.values
.where((node) => _inBounds(node.coord, bounds))
.toList();
}
/// Retrieve all cached nodes.
List<OsmCameraNode> getAll() => _nodes.values.toList();
/// Optionally clear the cache (rarely needed)
void clear() => _nodes.clear();
/// Remove the _pending_edit marker from a specific node
void removePendingEditMarker(int nodeId) {
final node = _nodes[nodeId];
if (node != null && node.tags.containsKey('_pending_edit')) {
final cleanTags = Map<String, String>.from(node.tags);
cleanTags.remove('_pending_edit');
_nodes[nodeId] = OsmCameraNode(
id: node.id,
coord: node.coord,
tags: cleanTags,
);
}
}
/// Remove a node by ID from the cache (used for successful deletions)
void removeNodeById(int nodeId) {
if (_nodes.remove(nodeId) != null) {
print('[NodeCache] Removed node $nodeId from cache (successful deletion)');
}
}
/// Remove temporary nodes (negative IDs) with _pending_upload marker at the given coordinate
/// This is used when a real node ID is assigned to clean up temp placeholders
void removeTempNodesByCoordinate(LatLng coord, {double tolerance = 0.00001}) {
final nodesToRemove = <int>[];
for (final entry in _nodes.entries) {
final nodeId = entry.key;
final node = entry.value;
// Only consider temp nodes (negative IDs) with pending upload marker
if (nodeId < 0 &&
node.tags.containsKey('_pending_upload') &&
_coordsMatch(node.coord, coord, tolerance)) {
nodesToRemove.add(nodeId);
}
}
for (final nodeId in nodesToRemove) {
_nodes.remove(nodeId);
}
if (nodesToRemove.isNotEmpty) {
print('[NodeCache] Removed ${nodesToRemove.length} temp nodes at coordinate ${coord.latitude}, ${coord.longitude}');
}
}
/// Check if two coordinates match within tolerance
bool _coordsMatch(LatLng coord1, LatLng coord2, double tolerance) {
return (coord1.latitude - coord2.latitude).abs() < tolerance &&
(coord1.longitude - coord2.longitude).abs() < tolerance;
}
/// Utility: point-in-bounds for coordinates
bool _inBounds(LatLng coord, LatLngBounds bounds) {
return coord.latitude >= bounds.southWest.latitude &&
coord.latitude <= bounds.northEast.latitude &&
coord.longitude >= bounds.southWest.longitude &&
coord.longitude <= bounds.northEast.longitude;
}
}

View File

@@ -7,7 +7,7 @@ import 'package:path_provider/path_provider.dart';
import 'offline_areas/offline_area_models.dart';
import 'offline_areas/offline_tile_utils.dart';
import 'offline_areas/offline_area_downloader.dart';
import 'offline_areas/world_area_manager.dart';
import '../models/osm_camera_node.dart';
import '../app_state.dart';
import 'map_data_provider.dart';
@@ -59,8 +59,7 @@ class OfflineAreaService {
if (_initialized) return;
await _loadAreasFromDisk();
await WorldAreaManager.ensureWorldArea(_areas, getOfflineAreaDir, downloadArea);
await saveAreasToDisk(); // Save any world area updates
await _cleanupLegacyWorldAreas();
_initialized = true;
}
@@ -262,9 +261,6 @@ class OfflineAreaService {
}
_areas.remove(area);
await saveAreasToDisk();
if (area.isPermanent) {
await WorldAreaManager.ensureWorldArea(_areas, getOfflineAreaDir, downloadArea);
}
}
void deleteArea(String id) async {
@@ -277,5 +273,25 @@ class OfflineAreaService {
await saveAreasToDisk();
}
/// Remove any legacy world areas from previous versions
Future<void> _cleanupLegacyWorldAreas() async {
final worldAreas = _areas.where((area) => area.isPermanent || area.id == 'world').toList();
if (worldAreas.isNotEmpty) {
debugPrint('OfflineAreaService: Cleaning up ${worldAreas.length} legacy world area(s)');
for (final area in worldAreas) {
final dir = Directory(area.directory);
if (await dir.exists()) {
await dir.delete(recursive: true);
debugPrint('OfflineAreaService: Deleted world area directory: ${area.directory}');
}
_areas.remove(area);
}
await saveAreasToDisk();
debugPrint('OfflineAreaService: Legacy world area cleanup complete');
}
}
}

View File

@@ -10,7 +10,6 @@ import '../../models/osm_camera_node.dart';
import '../map_data_provider.dart';
import 'offline_area_models.dart';
import 'offline_tile_utils.dart';
import 'package:deflockapp/dev_config.dart';
/// Handles the actual downloading process for offline areas
class OfflineAreaDownloader {
@@ -27,12 +26,7 @@ class OfflineAreaDownloader {
required Future<void> Function() saveAreasToDisk,
required Future<void> Function(OfflineArea) getAreaSizeBytes,
}) async {
Set<List<int>> allTiles;
if (area.isPermanent) {
allTiles = computeTileList(globalWorldBounds(), kWorldMinZoom, kWorldMaxZoom);
} else {
allTiles = computeTileList(bounds, minZoom, maxZoom);
}
Set<List<int>> allTiles = computeTileList(bounds, minZoom, maxZoom);
area.tilesTotal = allTiles.length;
// Download tiles with retry logic
@@ -45,17 +39,13 @@ class OfflineAreaDownloader {
getAreaSizeBytes: getAreaSizeBytes,
);
// Download cameras for non-permanent areas
if (!area.isPermanent) {
await _downloadCameras(
area: area,
bounds: bounds,
minZoom: minZoom,
directory: directory,
);
} else {
area.cameras = [];
}
// Download nodes for all areas
await _downloadNodes(
area: area,
bounds: bounds,
minZoom: minZoom,
directory: directory,
);
return success;
}
@@ -138,26 +128,29 @@ class OfflineAreaDownloader {
return missingTiles;
}
/// Download cameras for the area with expanded bounds
static Future<void> _downloadCameras({
/// Download nodes for the area with modest expansion (one zoom level lower)
static Future<void> _downloadNodes({
required OfflineArea area,
required LatLngBounds bounds,
required int minZoom,
required String directory,
}) async {
// Calculate expanded camera bounds that cover the entire tile area at minimum zoom
final cameraBounds = _calculateCameraBounds(bounds, minZoom);
final cameras = await MapDataProvider().getAllNodesForDownload(
bounds: cameraBounds,
// Modest expansion: use tiles at minZoom + 1 instead of minZoom
// This gives a reasonable buffer without capturing entire states
final nodeZoom = (minZoom + 1).clamp(8, 16); // Reasonable bounds for node fetching
final expandedNodeBounds = _calculateNodeBounds(bounds, nodeZoom);
final nodes = await MapDataProvider().getAllNodesForDownload(
bounds: expandedNodeBounds,
profiles: AppState.instance.profiles, // Use ALL profiles, not just enabled ones
);
area.cameras = cameras;
await OfflineAreaDownloader.saveCameras(cameras, directory);
debugPrint('Area ${area.id}: Downloaded ${cameras.length} cameras from expanded bounds (all profiles)');
area.nodes = nodes;
await OfflineAreaDownloader.saveNodes(nodes, directory);
debugPrint('Area ${area.id}: Downloaded ${nodes.length} nodes from modestly expanded bounds (all profiles)');
}
/// Calculate expanded bounds that cover the entire tile area at minimum zoom
static LatLngBounds _calculateCameraBounds(LatLngBounds visibleBounds, int minZoom) {
static LatLngBounds _calculateNodeBounds(LatLngBounds visibleBounds, int minZoom) {
final tiles = computeTileList(visibleBounds, minZoom, minZoom);
if (tiles.isEmpty) return visibleBounds;
@@ -188,9 +181,9 @@ class OfflineAreaDownloader {
await file.writeAsBytes(bytes);
}
/// Save cameras to disk as JSON
static Future<void> saveCameras(List<OsmCameraNode> cams, String dir) async {
final file = File('$dir/cameras.json');
await file.writeAsString(jsonEncode(cams.map((c) => c.toJson()).toList()));
/// Save nodes to disk as JSON
static Future<void> saveNodes(List<OsmCameraNode> nodes, String dir) async {
final file = File('$dir/nodes.json');
await file.writeAsString(jsonEncode(nodes.map((n) => n.toJson()).toList()));
}
}

View File

@@ -17,7 +17,7 @@ class OfflineArea {
double progress; // 0.0 - 1.0
int tilesDownloaded;
int tilesTotal;
List<OsmCameraNode> cameras;
List<OsmCameraNode> nodes;
int sizeBytes; // Disk size in bytes
final bool isPermanent; // Not user-deletable if true
@@ -38,7 +38,7 @@ class OfflineArea {
this.progress = 0,
this.tilesDownloaded = 0,
this.tilesTotal = 0,
this.cameras = const [],
this.nodes = const [],
this.sizeBytes = 0,
this.isPermanent = false,
this.tileProviderId,
@@ -61,7 +61,7 @@ class OfflineArea {
'progress': progress,
'tilesDownloaded': tilesDownloaded,
'tilesTotal': tilesTotal,
'cameras': cameras.map((c) => c.toJson()).toList(),
'nodes': nodes.map((n) => n.toJson()).toList(),
'sizeBytes': sizeBytes,
'isPermanent': isPermanent,
'tileProviderId': tileProviderId,
@@ -87,7 +87,7 @@ class OfflineArea {
progress: (json['progress'] ?? 0).toDouble(),
tilesDownloaded: json['tilesDownloaded'] ?? 0,
tilesTotal: json['tilesTotal'] ?? 0,
cameras: (json['cameras'] as List? ?? [])
nodes: (json['nodes'] as List? ?? json['cameras'] as List? ?? [])
.map((e) => OsmCameraNode.fromJson(e)).toList(),
sizeBytes: json['sizeBytes'] ?? 0,
isPermanent: json['isPermanent'] ?? false,

View File

@@ -1,153 +0,0 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
import 'package:path_provider/path_provider.dart';
import 'offline_area_models.dart';
import 'offline_tile_utils.dart';
import 'package:deflockapp/dev_config.dart';
/// Manages the world area (permanent offline area for base map)
class WorldAreaManager {
static const String _worldAreaId = 'world';
static const String _worldAreaName = 'World Base Map';
/// Ensure world area exists and check if download is needed
static Future<OfflineArea> ensureWorldArea(
List<OfflineArea> areas,
Future<Directory> Function() getOfflineAreaDir,
Future<void> Function({
required String id,
required LatLngBounds bounds,
required int minZoom,
required int maxZoom,
required String directory,
String? name,
String? tileProviderId,
String? tileProviderName,
String? tileTypeId,
String? tileTypeName,
}) downloadArea,
) async {
// Find existing world area
OfflineArea? world;
for (final area in areas) {
if (area.isPermanent) {
world = area;
break;
}
}
// Create world area if it doesn't exist, or update existing area without provider info
if (world == null) {
final appDocDir = await getOfflineAreaDir();
final dir = "${appDocDir.path}/$_worldAreaId";
world = OfflineArea(
id: _worldAreaId,
name: _worldAreaName,
bounds: globalWorldBounds(),
minZoom: kWorldMinZoom,
maxZoom: kWorldMaxZoom,
directory: dir,
status: OfflineAreaStatus.downloading,
isPermanent: true,
// World area always uses OpenStreetMap
tileProviderId: 'openstreetmap',
tileProviderName: 'OpenStreetMap',
tileTypeId: 'osm_street',
tileTypeName: 'Street Map',
);
areas.insert(0, world);
} else if (world.tileProviderId == null || world.tileTypeId == null) {
// Update existing world area that lacks provider metadata
final updatedWorld = OfflineArea(
id: world.id,
name: world.name,
bounds: world.bounds,
minZoom: world.minZoom,
maxZoom: world.maxZoom,
directory: world.directory,
status: world.status,
progress: world.progress,
tilesDownloaded: world.tilesDownloaded,
tilesTotal: world.tilesTotal,
cameras: world.cameras,
sizeBytes: world.sizeBytes,
isPermanent: world.isPermanent,
// Add missing provider metadata
tileProviderId: 'openstreetmap',
tileProviderName: 'OpenStreetMap',
tileTypeId: 'osm_street',
tileTypeName: 'Street Map',
);
final index = areas.indexOf(world);
areas[index] = updatedWorld;
world = updatedWorld;
}
// Check world area status and start download if needed
await _checkAndStartWorldDownload(world, downloadArea);
return world;
}
/// Check world area download status and start if needed
static Future<void> _checkAndStartWorldDownload(
OfflineArea world,
Future<void> Function({
required String id,
required LatLngBounds bounds,
required int minZoom,
required int maxZoom,
required String directory,
String? name,
String? tileProviderId,
String? tileProviderName,
String? tileTypeId,
String? tileTypeName,
}) downloadArea,
) async {
if (world.status == OfflineAreaStatus.complete) return;
// Count existing tiles
final expectedTiles = computeTileList(
globalWorldBounds(),
kWorldMinZoom,
kWorldMaxZoom,
);
int filesFound = 0;
for (final tile in expectedTiles) {
final file = File('${world.directory}/tiles/${tile[0]}/${tile[1]}/${tile[2]}.png');
if (file.existsSync()) {
filesFound++;
}
}
// Update world area stats
world.tilesTotal = expectedTiles.length;
world.tilesDownloaded = filesFound;
world.progress = (world.tilesTotal == 0) ? 0.0 : (filesFound / world.tilesTotal);
if (filesFound == world.tilesTotal) {
world.status = OfflineAreaStatus.complete;
debugPrint('WorldAreaManager: World area download already complete.');
} else {
world.status = OfflineAreaStatus.downloading;
debugPrint('WorldAreaManager: Starting world area download. ${world.tilesDownloaded}/${world.tilesTotal} tiles found.');
// Start download (fire and forget) - use OSM for world areas
downloadArea(
id: world.id,
bounds: world.bounds,
minZoom: world.minZoom,
maxZoom: world.maxZoom,
directory: world.directory,
name: world.name,
tileProviderId: 'openstreetmap',
tileProviderName: 'OpenStreetMap',
tileTypeId: 'osm_street',
tileTypeName: 'Street Map',
);
}
}
}

View File

@@ -10,6 +10,9 @@ import 'network_status.dart';
class SimpleTileHttpClient extends http.BaseClient {
final http.Client _inner = http.Client();
final MapDataProvider _mapDataProvider = MapDataProvider();
// Tile completion tracking (brutalist approach)
int _pendingTileRequests = 0;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
@@ -48,14 +51,14 @@ class SimpleTileHttpClient extends http.BaseClient {
}
Future<http.StreamedResponse> _handleTileRequest(int z, int x, int y) async {
// Increment pending counter (brutalist completion detection)
_pendingTileRequests++;
try {
// Always go through MapDataProvider - it handles offline/online routing
// MapDataProvider will get current provider from AppState
final tileBytes = await _mapDataProvider.getTile(z: z, x: x, y: y, source: MapSource.auto);
// Show success status briefly
NetworkStatus.instance.setSuccess();
// Serve tile with proper cache headers
return http.StreamedResponse(
Stream.value(tileBytes),
@@ -71,15 +74,18 @@ class SimpleTileHttpClient extends http.BaseClient {
} catch (e) {
debugPrint('[SimpleTileService] Could not get tile $z/$x/$y: $e');
// 404 means no tiles available - show "no data" status briefly
NetworkStatus.instance.setNoData();
// Return 404 and let flutter_map handle it gracefully
return http.StreamedResponse(
Stream.value(<int>[]),
404,
reasonPhrase: 'Tile unavailable: $e',
);
} finally {
// Decrement pending counter and report completion when all done
_pendingTileRequests--;
if (_pendingTileRequests == 0) {
NetworkStatus.instance.reportTileComplete();
}
}
}

View File

@@ -9,7 +9,7 @@ class Uploader {
Uploader(this.accessToken, this.onSuccess, {this.uploadMode = UploadMode.production});
final String accessToken;
final void Function() onSuccess;
final void Function(int nodeId) onSuccess;
final UploadMode uploadMode;
Future<bool> upload(PendingUpload p) async {
@@ -17,7 +17,18 @@ class Uploader {
print('Uploader: Starting upload for node at ${p.coord.latitude}, ${p.coord.longitude}');
// 1. open changeset
final action = p.isEdit ? 'Update' : 'Add';
String action;
switch (p.operation) {
case UploadOperation.create:
action = 'Add';
break;
case UploadOperation.modify:
action = 'Update';
break;
case UploadOperation.delete:
action = 'Delete';
break;
}
final csXml = '''
<osm>
<changeset>
@@ -35,63 +46,100 @@ class Uploader {
final csId = csResp.body.trim();
print('Uploader: Created changeset ID: $csId');
// 2. create or update node
final mergedTags = p.getCombinedTags();
final tagsXml = mergedTags.entries.map((e) =>
'<tag k="${e.key}" v="${e.value}"/>').join('\n ');
// 2. create, update, or delete node
final http.Response nodeResp;
final String nodeId;
if (p.isEdit) {
// First, fetch the current node to get its version
print('Uploader: Fetching current node ${p.originalNodeId} to get version...');
final currentNodeResp = await _get('/api/0.6/node/${p.originalNodeId}');
print('Uploader: Current node response: ${currentNodeResp.statusCode}');
if (currentNodeResp.statusCode != 200) {
print('Uploader: Failed to fetch current node');
return false;
}
// Parse version from the response XML
final currentNodeXml = currentNodeResp.body;
final versionMatch = RegExp(r'version="(\d+)"').firstMatch(currentNodeXml);
if (versionMatch == null) {
print('Uploader: Could not parse version from current node XML');
return false;
}
final currentVersion = versionMatch.group(1)!;
print('Uploader: Current node version: $currentVersion');
// Update existing node with version
final nodeXml = '''
<osm>
<node changeset="$csId" id="${p.originalNodeId}" version="$currentVersion" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
$tagsXml
</node>
</osm>''';
print('Uploader: Updating node ${p.originalNodeId}...');
nodeResp = await _put('/api/0.6/node/${p.originalNodeId}', nodeXml);
nodeId = p.originalNodeId.toString();
} else {
// Create new node
final nodeXml = '''
switch (p.operation) {
case UploadOperation.create:
// Create new node
final mergedTags = p.getCombinedTags();
final tagsXml = mergedTags.entries.map((e) =>
'<tag k="${e.key}" v="${e.value}"/>').join('\n ');
final nodeXml = '''
<osm>
<node changeset="$csId" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
$tagsXml
</node>
</osm>''';
print('Uploader: Creating new node...');
nodeResp = await _put('/api/0.6/node/create', nodeXml);
nodeId = nodeResp.body.trim();
print('Uploader: Creating new node...');
nodeResp = await _put('/api/0.6/node/create', nodeXml);
nodeId = nodeResp.body.trim();
break;
case UploadOperation.modify:
// First, fetch the current node to get its version
print('Uploader: Fetching current node ${p.originalNodeId} to get version...');
final currentNodeResp = await _get('/api/0.6/node/${p.originalNodeId}');
print('Uploader: Current node response: ${currentNodeResp.statusCode}');
if (currentNodeResp.statusCode != 200) {
print('Uploader: Failed to fetch current node');
return false;
}
// Parse version from the response XML
final currentNodeXml = currentNodeResp.body;
final versionMatch = RegExp(r'version="(\d+)"').firstMatch(currentNodeXml);
if (versionMatch == null) {
print('Uploader: Could not parse version from current node XML');
return false;
}
final currentVersion = versionMatch.group(1)!;
print('Uploader: Current node version: $currentVersion');
// Update existing node with version
final mergedTags = p.getCombinedTags();
final tagsXml = mergedTags.entries.map((e) =>
'<tag k="${e.key}" v="${e.value}"/>').join('\n ');
final nodeXml = '''
<osm>
<node changeset="$csId" id="${p.originalNodeId}" version="$currentVersion" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
$tagsXml
</node>
</osm>''';
print('Uploader: Updating node ${p.originalNodeId}...');
nodeResp = await _put('/api/0.6/node/${p.originalNodeId}', nodeXml);
nodeId = p.originalNodeId.toString();
break;
case UploadOperation.delete:
// First, fetch the current node to get its version and coordinates
print('Uploader: Fetching current node ${p.originalNodeId} for deletion...');
final currentNodeResp = await _get('/api/0.6/node/${p.originalNodeId}');
print('Uploader: Current node response: ${currentNodeResp.statusCode}');
if (currentNodeResp.statusCode != 200) {
print('Uploader: Failed to fetch current node');
return false;
}
// Parse version and tags from the response XML
final currentNodeXml = currentNodeResp.body;
final versionMatch = RegExp(r'version="(\d+)"').firstMatch(currentNodeXml);
if (versionMatch == null) {
print('Uploader: Could not parse version from current node XML');
return false;
}
final currentVersion = versionMatch.group(1)!;
print('Uploader: Current node version: $currentVersion');
// Delete node - OSM requires current tags and coordinates
final nodeXml = '''
<osm>
<node changeset="$csId" id="${p.originalNodeId}" version="$currentVersion" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
</node>
</osm>''';
print('Uploader: Deleting node ${p.originalNodeId}...');
nodeResp = await _delete('/api/0.6/node/${p.originalNodeId}', nodeXml);
nodeId = p.originalNodeId.toString();
break;
}
print('Uploader: Node response: ${nodeResp.statusCode} - ${nodeResp.body}');
if (nodeResp.statusCode != 200) {
print('Uploader: Failed to ${p.isEdit ? "update" : "create"} node');
print('Uploader: Failed to ${p.operation.name} node');
return false;
}
print('Uploader: ${p.isEdit ? "Updated" : "Created"} node ID: $nodeId');
print('Uploader: ${p.operation.name.capitalize()} node ID: $nodeId');
// 3. close changeset
print('Uploader: Closing changeset...');
@@ -99,7 +147,8 @@ class Uploader {
print('Uploader: Close response: ${closeResp.statusCode}');
print('Uploader: Upload successful!');
onSuccess();
final nodeIdInt = int.parse(nodeId);
onSuccess(nodeIdInt);
return true;
} catch (e) {
print('Uploader: Upload failed with error: $e');
@@ -134,9 +183,21 @@ class Uploader {
body: body,
);
Future<http.Response> _delete(String path, String body) => http.delete(
Uri.https(_host, path),
headers: _headers,
body: body,
);
Map<String, String> get _headers => {
'Authorization': 'Bearer $accessToken',
'Content-Type': 'text/xml',
};
}
extension StringExtension on String {
String capitalize() {
return "${this[0].toUpperCase()}${substring(1)}";
}
}

View File

@@ -18,7 +18,7 @@ class AuthState extends ChangeNotifier {
try {
if (await _auth.isLoggedIn()) {
_username = await _auth.login();
_username = await _auth.restoreLogin();
}
} catch (e) {
print("AuthState: Error during auth initialization: $e");
@@ -44,7 +44,7 @@ class AuthState extends ChangeNotifier {
Future<void> refreshAuthState() async {
try {
if (await _auth.isLoggedIn()) {
_username = await _auth.login();
_username = await _auth.restoreLogin();
} else {
_username = null;
}
@@ -83,7 +83,7 @@ class AuthState extends ChangeNotifier {
if (await _auth.isLoggedIn()) {
final isValid = await validateToken();
if (isValid) {
_username = await _auth.login();
_username = await _auth.restoreLogin();
} else {
await logout(); // This clears _username also.
}

View File

@@ -4,6 +4,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:collection/collection.dart';
import '../models/tile_provider.dart';
import '../dev_config.dart';
// Enum for upload mode (Production, OSM Sandbox, Simulate)
enum UploadMode { production, sandbox, simulate }
@@ -26,7 +27,7 @@ class SettingsState extends ChangeNotifier {
bool _offlineMode = false;
int _maxCameras = 250;
UploadMode _uploadMode = UploadMode.simulate;
UploadMode _uploadMode = kEnableDevelopmentModes ? UploadMode.simulate : UploadMode.production;
FollowMeMode _followMeMode = FollowMeMode.northUp;
List<TileProvider> _tileProviders = [];
String _selectedTileTypeId = '';
@@ -98,6 +99,13 @@ class SettingsState extends ChangeNotifier {
await prefs.setInt(_uploadModePrefsKey, _uploadMode.index);
}
// In production builds, force production mode if development modes are disabled
if (!kEnableDevelopmentModes && _uploadMode != UploadMode.production) {
debugPrint('SettingsState: Development modes disabled, forcing production mode');
_uploadMode = UploadMode.production;
await prefs.setInt(_uploadModePrefsKey, _uploadMode.index);
}
// Load tile providers (default to built-in providers if none saved)
await _loadTileProviders(prefs);
@@ -170,6 +178,12 @@ class SettingsState extends ChangeNotifier {
}
Future<void> setUploadMode(UploadMode mode) async {
// In production builds, only allow production mode
if (!kEnableDevelopmentModes && mode != UploadMode.production) {
debugPrint('SettingsState: Development modes disabled, forcing production mode');
mode = UploadMode.production;
}
_uploadMode = mode;
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_uploadModePrefsKey, mode.index);

View File

@@ -5,7 +5,8 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../models/pending_upload.dart';
import '../models/osm_camera_node.dart';
import '../services/camera_cache.dart';
import '../models/node_profile.dart';
import '../services/node_cache.dart';
import '../services/uploader.dart';
import '../widgets/camera_provider_with_cache.dart';
import 'settings_state.dart';
@@ -32,12 +33,13 @@ class UploadQueueState extends ChangeNotifier {
profile: session.profile,
operatorProfile: session.operatorProfile,
uploadMode: uploadMode,
operation: UploadOperation.create,
);
_queue.add(upload);
_saveQueue();
// Add to camera cache immediately so it shows on the map
// Add to node cache immediately so it shows on the map
// Create a temporary node with a negative ID (to distinguish from real OSM nodes)
// Using timestamp as negative ID to ensure uniqueness
final tempId = -DateTime.now().millisecondsSinceEpoch;
@@ -50,8 +52,8 @@ class UploadQueueState extends ChangeNotifier {
tags: tags,
);
CameraCache.instance.addOrUpdate([tempNode]);
// Notify camera provider to update the map
NodeCache.instance.addOrUpdate([tempNode]);
// Notify node provider to update the map
CameraProviderWithCache.instance.notifyListeners();
notifyListeners();
@@ -65,6 +67,7 @@ class UploadQueueState extends ChangeNotifier {
profile: session.profile,
operatorProfile: session.operatorProfile,
uploadMode: uploadMode,
operation: UploadOperation.modify,
originalNodeId: session.originalNode.id, // Track which node we're editing
);
@@ -73,7 +76,7 @@ class UploadQueueState extends ChangeNotifier {
// Create two cache entries:
// 1. Mark the original camera with _pending_edit (grey ring) at original location
// 1. Mark the original node with _pending_edit (grey ring) at original location
final originalTags = Map<String, String>.from(session.originalNode.tags);
originalTags['_pending_edit'] = 'true'; // Mark original as having pending edit
@@ -83,7 +86,7 @@ class UploadQueueState extends ChangeNotifier {
tags: originalTags,
);
// 2. Create new temp node for the edited camera (purple ring) at new location
// 2. Create new temp node for the edited node (purple ring) at new location
final tempId = -DateTime.now().millisecondsSinceEpoch;
final editedTags = upload.getCombinedTags();
editedTags['_pending_upload'] = 'true'; // Mark as pending upload
@@ -95,8 +98,39 @@ class UploadQueueState extends ChangeNotifier {
tags: editedTags,
);
CameraCache.instance.addOrUpdate([originalNode, editedNode]);
// Notify camera provider to update the map
NodeCache.instance.addOrUpdate([originalNode, editedNode]);
// Notify node provider to update the map
CameraProviderWithCache.instance.notifyListeners();
notifyListeners();
}
// Add a node deletion to the upload queue
void addFromNodeDeletion(OsmCameraNode node, {required UploadMode uploadMode}) {
final upload = PendingUpload(
coord: node.coord,
direction: node.directionDeg ?? 0, // Use existing direction or default to 0
profile: NodeProfile.genericAlpr(), // Dummy profile - not used for deletions
uploadMode: uploadMode,
operation: UploadOperation.delete,
originalNodeId: node.id,
);
_queue.add(upload);
_saveQueue();
// Mark the original node as pending deletion in the cache
final deletionTags = Map<String, String>.from(node.tags);
deletionTags['_pending_deletion'] = 'true';
final nodeWithDeletionTag = OsmCameraNode(
id: node.id,
coord: node.coord,
tags: deletionTags,
);
NodeCache.instance.addOrUpdate([nodeWithDeletionTag]);
// Notify node provider to update the map
CameraProviderWithCache.instance.notifyListeners();
notifyListeners();
@@ -153,19 +187,16 @@ class UploadQueueState extends ChangeNotifier {
debugPrint('[UploadQueue] Simulating upload (no real API call)');
await Future.delayed(const Duration(seconds: 1)); // Simulate network delay
ok = true;
// Simulate a node ID for simulate mode
_markAsCompleting(item, simulatedNodeId: DateTime.now().millisecondsSinceEpoch);
} else {
// Real upload -- use the upload mode that was saved when this item was queued
debugPrint('[UploadQueue] Real upload to: ${item.uploadMode}');
final up = Uploader(access, () {
_markAsCompleting(item);
final up = Uploader(access, (nodeId) {
_markAsCompleting(item, submittedNodeId: nodeId);
}, uploadMode: item.uploadMode);
ok = await up.upload(item);
}
if (ok && item.uploadMode == UploadMode.simulate) {
// Mark as completing for simulate mode too
_markAsCompleting(item);
}
if (!ok) {
item.attempts++;
if (item.attempts >= 3) {
@@ -186,8 +217,32 @@ class UploadQueueState extends ChangeNotifier {
}
// Mark an item as completing (shows checkmark) and schedule removal after 1 second
void _markAsCompleting(PendingUpload item) {
void _markAsCompleting(PendingUpload item, {int? submittedNodeId, int? simulatedNodeId}) {
item.completing = true;
// Store the submitted node ID for cleanup purposes
if (submittedNodeId != null) {
item.submittedNodeId = submittedNodeId;
if (item.isDeletion) {
debugPrint('[UploadQueue] Deletion successful, removing node ID: $submittedNodeId from cache');
_handleSuccessfulDeletion(item);
} else {
debugPrint('[UploadQueue] Upload successful, OSM assigned node ID: $submittedNodeId');
// Update cache with real node ID instead of temp ID
_updateCacheWithRealNodeId(item, submittedNodeId);
}
} else if (simulatedNodeId != null && item.uploadMode == UploadMode.simulate) {
// For simulate mode, use a fake but positive ID
item.submittedNodeId = simulatedNodeId;
if (item.isDeletion) {
debugPrint('[UploadQueue] Simulated deletion, removing fake node ID: $simulatedNodeId from cache');
_handleSuccessfulDeletion(item);
} else {
debugPrint('[UploadQueue] Simulated upload, fake node ID: $simulatedNodeId');
}
}
_saveQueue();
notifyListeners();
@@ -198,6 +253,45 @@ class UploadQueueState extends ChangeNotifier {
notifyListeners();
});
}
// Update the cache to use the real OSM node ID instead of temporary ID
void _updateCacheWithRealNodeId(PendingUpload item, int realNodeId) {
// Create the node with real ID and clean tags (remove temp markers)
final tags = item.getCombinedTags();
final realNode = OsmCameraNode(
id: realNodeId,
coord: item.coord,
tags: tags, // Clean tags without _pending_upload markers
);
// Add/update the cache with the real node
NodeCache.instance.addOrUpdate([realNode]);
// Clean up any temp nodes at the same coordinate
NodeCache.instance.removeTempNodesByCoordinate(item.coord);
// For edits, also clean up the original node's _pending_edit marker
if (item.isEdit && item.originalNodeId != null) {
// Remove the _pending_edit marker from the original node in cache
// The next Overpass fetch will provide the authoritative data anyway
NodeCache.instance.removePendingEditMarker(item.originalNodeId!);
}
// Notify node provider to update the map
CameraProviderWithCache.instance.notifyListeners();
}
// Handle successful deletion by removing the node from cache
void _handleSuccessfulDeletion(PendingUpload item) {
if (item.originalNodeId != null) {
// Remove the node from cache entirely
NodeCache.instance.removeNodeById(item.originalNodeId!);
// Notify node provider to update the map
CameraProviderWithCache.instance.notifyListeners();
}
}
// ---------- Queue persistence ----------
Future<void> _saveQueue() async {

View File

@@ -2,11 +2,12 @@ import 'package:flutter/material.dart';
import '../dev_config.dart';
enum CameraIconType {
real, // Blue ring - real cameras from OSM
mock, // White ring - add camera mock point
pending, // Purple ring - submitted/pending cameras
editing, // Orange ring - camera being edited
pendingEdit, // Grey ring - original camera with pending edit
real, // Blue ring - real cameras from OSM
mock, // White ring - add camera mock point
pending, // Purple ring - submitted/pending cameras
editing, // Orange ring - camera being edited
pendingEdit, // Grey ring - original camera with pending edit
pendingDeletion, // Red ring - camera pending deletion
}
/// Simple camera icon with grey dot and colored ring
@@ -27,6 +28,8 @@ class CameraIcon extends StatelessWidget {
return kCameraRingColorEditing;
case CameraIconType.pendingEdit:
return kCameraRingColorPendingEdit;
case CameraIconType.pendingDeletion:
return kCameraRingColorPendingDeletion;
}
}

View File

@@ -4,13 +4,13 @@ import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
import '../services/map_data_provider.dart';
import '../services/camera_cache.dart';
import '../services/node_cache.dart';
import '../services/network_status.dart';
import '../models/node_profile.dart';
import '../models/osm_camera_node.dart';
import '../app_state.dart';
/// Provides cameras for a map view, using an in-memory cache and optionally
/// Provides surveillance nodes for a map view, using an in-memory cache and optionally
/// merging in new results from Overpass via MapDataProvider when not offline.
class CameraProviderWithCache extends ChangeNotifier {
static final CameraProviderWithCache instance = CameraProviderWithCache._internal();
@@ -21,16 +21,16 @@ class CameraProviderWithCache extends ChangeNotifier {
/// Call this to get (quickly) all cached overlays for the given view.
/// Filters by currently enabled profiles.
List<OsmCameraNode> getCachedCamerasForBounds(LatLngBounds bounds) {
final allCameras = CameraCache.instance.queryByBounds(bounds);
List<OsmCameraNode> getCachedNodesForBounds(LatLngBounds bounds) {
final allNodes = NodeCache.instance.queryByBounds(bounds);
final enabledProfiles = AppState.instance.enabledProfiles;
// If no profiles are enabled, show no cameras
// If no profiles are enabled, show no nodes
if (enabledProfiles.isEmpty) return [];
// Filter cameras to only show those matching enabled profiles
return allCameras.where((camera) {
return _matchesAnyProfile(camera, enabledProfiles);
// Filter nodes to only show those matching enabled profiles
return allNodes.where((node) {
return _matchesAnyProfile(node, enabledProfiles);
}).toList();
}
@@ -55,13 +55,13 @@ class CameraProviderWithCache extends ChangeNotifier {
source: MapSource.auto,
);
if (fresh.isNotEmpty) {
CameraCache.instance.addOrUpdate(fresh);
// Clear waiting status when camera data arrives
NodeCache.instance.addOrUpdate(fresh);
// Clear waiting status when node data arrives
NetworkStatus.instance.clearWaiting();
notifyListeners();
}
} catch (e) {
debugPrint('[CameraProviderWithCache] Camera fetch failed: $e');
debugPrint('[CameraProviderWithCache] Node fetch failed: $e');
// Cache already holds whatever is available for the view
}
});
@@ -69,7 +69,7 @@ class CameraProviderWithCache extends ChangeNotifier {
/// Optionally: clear the cache (could be used for testing/dev)
void clearCache() {
CameraCache.instance.clear();
NodeCache.instance.clear();
notifyListeners();
}
@@ -78,18 +78,18 @@ class CameraProviderWithCache extends ChangeNotifier {
notifyListeners();
}
/// Check if a camera matches any of the provided profiles
bool _matchesAnyProfile(OsmCameraNode camera, List<NodeProfile> profiles) {
/// Check if a node matches any of the provided profiles
bool _matchesAnyProfile(OsmCameraNode node, List<NodeProfile> profiles) {
for (final profile in profiles) {
if (_cameraMatchesProfile(camera, profile)) return true;
if (_nodeMatchesProfile(node, profile)) return true;
}
return false;
}
/// Check if a camera matches a specific profile (all profile tags must match)
bool _cameraMatchesProfile(OsmCameraNode camera, NodeProfile profile) {
/// Check if a node matches a specific profile (all profile tags must match)
bool _nodeMatchesProfile(OsmCameraNode node, NodeProfile profile) {
for (final entry in profile.tags.entries) {
if (camera.tags[entry.key] != entry.value) return false;
if (node.tags[entry.key] != entry.value) return false;
}
return true;
}

View File

@@ -55,7 +55,7 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
);
}
final minZoom = kWorldMaxZoom + 1;
final minZoom = 1; // Always start from zoom 1 to show area overview when zoomed out
final maxZoom = _zoom.toInt();
// Calculate maximum possible zoom based on tile count limit
@@ -63,21 +63,22 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
final nTiles = computeTileList(bounds, minZoom, maxZoom).length;
final totalMb = (nTiles * kTileEstimateKb) / 1024.0;
final roundedMb = (totalMb * 10).round() / 10; // Round to nearest tenth
setState(() {
_minZoom = minZoom;
_maxPossibleZoom = maxPossibleZoom;
_tileCount = nTiles;
_mbEstimate = totalMb;
_mbEstimate = roundedMb;
});
}
/// Calculate the maximum zoom level that keeps tile count under the limit
/// Calculate the maximum zoom level that keeps tile count under the absolute limit
int _calculateMaxZoomForTileLimit(LatLngBounds bounds, int minZoom) {
for (int zoom = minZoom; zoom <= kAbsoluteMaxZoom; zoom++) {
final tileCount = computeTileList(bounds, minZoom, zoom).length;
if (tileCount > kMaxReasonableTileCount) {
// Return the previous zoom level that was still under the limit
if (tileCount > kAbsoluteMaxTileCount) {
// Return the previous zoom level that was still under the absolute limit
return math.max(minZoom, zoom - 1);
}
}
@@ -154,14 +155,6 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
),
],
),
if (_minZoom != null)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(locService.t('download.minZoom')),
Text('Z$_minZoom'),
],
),
if (_maxPossibleZoom != null && _tileCount != null)
Padding(
padding: const EdgeInsets.only(top: 8.0),
@@ -177,7 +170,9 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('download.maxRecommendedZoom', params: [_maxPossibleZoom.toString()]),
_tileCount! > kMaxReasonableTileCount
? 'Above recommended limit (Z${_maxPossibleZoom})'
: locService.t('download.maxRecommendedZoom', params: [_maxPossibleZoom.toString()]),
style: TextStyle(
fontSize: 12,
color: _tileCount! > kMaxReasonableTileCount
@@ -189,7 +184,7 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
const SizedBox(height: 2),
Text(
_tileCount! > kMaxReasonableTileCount
? locService.t('download.exceedsTileLimit', params: [kMaxReasonableTileCount.toString()])
? 'Current selection exceeds ${kMaxReasonableTileCount} recommended tile limit but is within ${kAbsoluteMaxTileCount} absolute limit'
: locService.t('download.withinTileLimit', params: [kMaxReasonableTileCount.toString()]),
style: TextStyle(
fontSize: 11,
@@ -254,8 +249,8 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
OfflineAreaService().downloadArea(
id: id,
bounds: bounds,
minZoom: _minZoom ?? 12,
maxZoom: maxZoom,
minZoom: _minZoom ?? 1,
maxZoom: _zoom.toInt(),
directory: dir,
onProgress: (progress) {},
onComplete: (status) {},

View File

@@ -36,7 +36,7 @@ class EditNodeSheet extends StatelessWidget {
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;
final allowSubmit = appState.isLoggedIn && submittableProfiles.isNotEmpty && session.profile.isSubmittable;
void _openRefineTags() async {
final result = await Navigator.push<OperatorProfile?>(
@@ -130,22 +130,6 @@ class EditNodeSheet extends StatelessWidget {
],
),
)
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),

View File

@@ -51,9 +51,13 @@ class _CameraMapMarkerState extends State<CameraMapMarker> {
widget.node.tags['_pending_upload'] == 'true';
final isPendingEdit = widget.node.tags.containsKey('_pending_edit') &&
widget.node.tags['_pending_edit'] == 'true';
final isPendingDeletion = widget.node.tags.containsKey('_pending_deletion') &&
widget.node.tags['_pending_deletion'] == 'true';
CameraIconType iconType;
if (isPendingUpload) {
if (isPendingDeletion) {
iconType = CameraIconType.pendingDeletion;
} else if (isPendingUpload) {
iconType = CameraIconType.pending;
} else if (isPendingEdit) {
iconType = CameraIconType.pendingEdit;

View File

@@ -57,10 +57,10 @@ class MapOverlays extends StatelessWidget {
),
),
// Zoom indicator, positioned above scale bar
// Zoom indicator, positioned relative to button bar
Positioned(
left: 10,
bottom: kZoomIndicatorBottomOffset,
bottom: bottomPositionFromButtonBar(kZoomIndicatorSpacingAboveButtonBar, MediaQuery.of(context).padding.bottom),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
@@ -83,10 +83,10 @@ class MapOverlays extends StatelessWidget {
),
),
// Attribution overlay
// Attribution overlay, positioned relative to button bar
if (attribution != null)
Positioned(
bottom: kAttributionBottomOffset,
bottom: bottomPositionFromButtonBar(kAttributionSpacingAboveButtonBar, MediaQuery.of(context).padding.bottom),
left: 10,
child: Container(
decoration: BoxDecoration(
@@ -104,9 +104,9 @@ class MapOverlays extends StatelessWidget {
),
),
// Zoom and layer controls (bottom-right)
// Zoom and layer controls (bottom-right), positioned relative to button bar
Positioned(
bottom: 80,
bottom: bottomPositionFromButtonBar(kZoomControlsSpacingAboveButtonBar, MediaQuery.of(context).padding.bottom),
right: 16,
child: Column(
children: [

View File

@@ -129,6 +129,42 @@ class MapViewState extends State<MapView> {
static Future<void> clearStoredMapPosition() =>
MapPositionManager.clearStoredMapPosition();
/// Get minimum zoom level for camera fetching based on upload mode
int _getMinZoomForCameras(BuildContext context) {
final appState = context.read<AppState>();
final uploadMode = appState.uploadMode;
// OSM API (sandbox mode) needs higher zoom level due to bbox size limits
if (uploadMode == UploadMode.sandbox) {
return kOsmApiMinZoomLevel;
} else {
return kCameraMinZoomLevel;
}
}
/// Show zoom warning if user is below minimum zoom level
void _showZoomWarningIfNeeded(BuildContext context, double currentZoom, int minZoom) {
// Only show warning once per zoom level to avoid spam
if (currentZoom.floor() == (minZoom - 1)) {
final appState = context.read<AppState>();
final uploadMode = appState.uploadMode;
final message = uploadMode == UploadMode.sandbox
? 'Zoom to level $minZoom or higher to see nodes in sandbox mode (OSM API bbox limit)'
: 'Zoom to level $minZoom or higher to see surveillance nodes';
// Show a brief snackbar
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
duration: const Duration(seconds: 4),
behavior: SnackBarBehavior.floating,
),
);
}
}
void _refreshCamerasFromProvider() {
@@ -228,7 +264,7 @@ class MapViewState extends State<MapView> {
mapBounds = null;
}
final cameras = (mapBounds != null)
? cameraProvider.getCachedCamerasForBounds(mapBounds)
? cameraProvider.getCachedNodesForBounds(mapBounds)
: <OsmCameraNode>[];
final markers = CameraMarkersBuilder.buildCameraMarkers(
@@ -300,8 +336,8 @@ class MapViewState extends State<MapView> {
appState.updateEditSession(target: pos.center);
}
// Show waiting indicator when map moves (user is expecting new content)
NetworkStatus.instance.setWaiting();
// Start dual-source waiting when map moves (user is expecting new tiles AND nodes)
NetworkStatus.instance.setDualSourceWaiting();
// Only clear tile queue on significant ZOOM changes (not panning)
final currentZoom = pos.zoom;
@@ -321,8 +357,15 @@ class MapViewState extends State<MapView> {
});
// Request more cameras on any map movement/zoom at valid zoom level (slower debounce)
if (pos.zoom >= 10) {
final minZoom = _getMinZoomForCameras(context);
if (pos.zoom >= minZoom) {
_cameraDebounce(_refreshCamerasFromProvider);
} else {
// Skip nodes at low zoom - report immediate completion (brutalist approach)
NetworkStatus.instance.reportNodeComplete();
// Show zoom warning if needed
_showZoomWarningIfNeeded(context, pos.zoom, minZoom);
}
},
),
@@ -332,10 +375,13 @@ class MapViewState extends State<MapView> {
selectedTileType: appState.selectedTileType,
),
cameraLayers,
// Built-in scale bar from flutter_map
// Built-in scale bar from flutter_map, positioned relative to button bar
Scalebar(
alignment: Alignment.bottomLeft,
padding: EdgeInsets.only(left: 8, bottom: kScaleBarBottomOffset), // from dev_config
padding: EdgeInsets.only(
left: 8,
bottom: bottomPositionFromButtonBar(kScaleBarSpacingAboveButtonBar, MediaQuery.of(context).padding.bottom)
),
textStyle: TextStyle(color: Colors.black, fontWeight: FontWeight.bold),
lineColor: Colors.black,
strokeWidth: 3,

View File

@@ -35,7 +35,7 @@ class NetworkStatusIndicator extends StatelessWidget {
break;
case NetworkStatusType.success:
message = 'Tiles loaded';
message = 'Done';
icon = Icons.check_circle;
color = Colors.green;
break;
@@ -43,7 +43,7 @@ class NetworkStatusIndicator extends StatelessWidget {
case NetworkStatusType.issues:
switch (networkStatus.currentIssueType) {
case NetworkIssueType.osmTiles:
message = 'OSM tiles slow';
message = 'Tile provider slow';
icon = Icons.map_outlined;
color = Colors.orange;
break;

View File

@@ -17,17 +17,50 @@ class NodeTagSheet extends StatelessWidget {
final appState = context.watch<AppState>();
final locService = LocalizationService.instance;
// Check if this device is editable (not a pending upload or pending edit)
// Check if this device is editable (not a pending upload, pending edit, or pending deletion)
final isEditable = (!node.tags.containsKey('_pending_upload') ||
node.tags['_pending_upload'] != 'true') &&
(!node.tags.containsKey('_pending_edit') ||
node.tags['_pending_edit'] != 'true');
node.tags['_pending_edit'] != 'true') &&
(!node.tags.containsKey('_pending_deletion') ||
node.tags['_pending_deletion'] != 'true');
void _openEditSheet() {
Navigator.pop(context); // Close this sheet first
appState.startEditSession(node); // HomeScreen will auto-show the edit sheet
}
void _deleteNode() async {
final shouldDelete = await showDialog<bool>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text(locService.t('node.confirmDeleteTitle')),
content: Text(locService.t('node.confirmDeleteMessage', params: [node.id.toString()])),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(locService.cancel),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(locService.t('actions.delete')),
),
],
);
},
);
if (shouldDelete == true && context.mounted) {
Navigator.pop(context); // Close this sheet first
appState.deleteNode(node);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(locService.t('node.deleteQueuedForUpload'))),
);
}
}
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
@@ -81,6 +114,16 @@ class NodeTagSheet extends StatelessWidget {
minimumSize: const Size(0, 36),
),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: _deleteNode,
icon: const Icon(Icons.delete, size: 18),
label: Text(locService.t('actions.delete')),
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 36),
foregroundColor: Colors.red,
),
),
const SizedBox(width: 12),
],
TextButton(

View File

@@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "ephemeral/Flutter-Generated.xcconfig"

View File

@@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "ephemeral/Flutter-Generated.xcconfig"

View File

@@ -67,7 +67,7 @@
331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
33CC10ED2044A3C60003C045 /* flock_map_app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = flock_map_app.app; sourceTree = BUILT_PRODUCTS_DIR; };
33CC10ED2044A3C60003C045 /* DeFlock.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DeFlock.app; sourceTree = BUILT_PRODUCTS_DIR; };
33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
@@ -144,7 +144,7 @@
33CC10EE2044A3C60003C045 /* Products */ = {
isa = PBXGroup;
children = (
33CC10ED2044A3C60003C045 /* flock_map_app.app */,
33CC10ED2044A3C60003C045 /* DeFlock.app */,
331C80D5294CF71000263BE5 /* RunnerTests.xctest */,
);
name = Products;
@@ -479,10 +479,10 @@
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_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/flock_map_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/flock_map_app";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/DeFlock.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/DeFlock";
};
name = Debug;
};
@@ -494,10 +494,10 @@
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_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/flock_map_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/flock_map_app";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/DeFlock.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/DeFlock";
};
name = Release;
};
@@ -509,10 +509,10 @@
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_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/flock_map_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/flock_map_app";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/DeFlock.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/DeFlock";
};
name = Profile;
};

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)me.deflock.deflockapp</string>
</array>
</dict>
</plist>

44
macos/Runner/Info.plist Normal file
View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIconFile</key>
<string></string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSHumanReadableCopyright</key>
<string>$(PRODUCT_COPYRIGHT)</string>
<key>NSMainNibFile</key>
<string>MainMenu</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<!-- OAuth2 redirect handler -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>deflockapp</string>
<key>CFBundleURLSchemes</key>
<array>
<string>deflockapp</string>
</array>
</dict>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)me.deflock.deflockapp</string>
</array>
</dict>
</plist>

View File

@@ -793,7 +793,7 @@ packages:
source: hosted
version: "1.1.0"
xml:
dependency: transitive
dependency: "direct main"
description:
name: xml
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226

View File

@@ -18,6 +18,7 @@ dependencies:
geolocator: ^10.1.0
http: ^1.2.1
flutter_svg: ^2.0.10
xml: ^6.4.2
# Auth, storage, prefs
oauth2_client: ^4.2.0
@@ -43,7 +44,7 @@ flutter:
- lib/localizations/
flutter_native_splash:
color: "#202020"
color: "#152131"
image: assets/app_icon.png
android: true
ios: true