mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-04-09 13:32:26 +02:00
Compare commits
1 Commits
v1.4.5-rc
...
secret_dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
999a918062 |
18
README.md
18
README.md
@@ -98,21 +98,24 @@ cp lib/keys.dart.example lib/keys.dart
|
||||
## Roadmap
|
||||
|
||||
### Needed Bugfixes
|
||||
- Update node cache to reflect cleared queue entries
|
||||
- Improve/retune tile fetching backoff/retry
|
||||
- Are offline areas preferred for fast loading even when online? Check working.
|
||||
- Fix network indicator - only done when fetch queue is empty!
|
||||
|
||||
### Current Development
|
||||
- Decide what to do for extracting nodes attached to a way/relation:
|
||||
- Auto extract (how?)
|
||||
- Leave it alone (wrong answer unless user chooses intentionally)
|
||||
- Manual cleanup (cognitive load for users)
|
||||
- Delete the old one (also wrong answer unless user chooses intentionally)
|
||||
- Give multiple of these options??
|
||||
- Two nodes too close together warning
|
||||
- Nav start+end too close together error (warning + disable submit button?)
|
||||
- Improve/retune tile fetching backoff/retry
|
||||
- Disable deletes on nodes belonging to ways/relations
|
||||
- Support FOV range notation: 0-360, 90-270, 10-45;90-125
|
||||
- Add some builtin satellite tile provider
|
||||
- Persistent cache for MY submissions: assume submissions worked, cache,clean up when we see that node appear in overpass/OSM results or when older than 24h
|
||||
- Link to "my changes" on osm (username edit history)
|
||||
- Are offline areas preferred for fast loading even when online? Check working.
|
||||
- Fix network indicator - only done when fetch queue is empty!
|
||||
|
||||
### Current Development
|
||||
- Persistent cache for MY submissions: clean up when we see that node appear in overpass/OSM results or when older than 24h
|
||||
- Dropdown on "refine tags" page to select acceptable options for camera:mount= (is this a boolean property of a profile?)
|
||||
- Tutorial / info guide before submitting first node, info and links before creating first profile
|
||||
- Option to pull in profiles from NSI (man_made=surveillance only?)
|
||||
@@ -127,7 +130,6 @@ cp lib/keys.dart.example lib/keys.dart
|
||||
### Future Features & Wishlist
|
||||
- Update offline area nodes while browsing?
|
||||
- Offline navigation (pending vector map tiles)
|
||||
- Android Auto / CarPlay
|
||||
|
||||
### Maybes
|
||||
- Yellow ring for devices missing specific tag details
|
||||
|
||||
@@ -1,39 +1,30 @@
|
||||
{
|
||||
"1.4.5": {
|
||||
"content": [
|
||||
"• NEW: Minimum zoom level (Z15) enforced for adding and editing surveillance nodes to ensure precise positioning",
|
||||
"• NEW: Minimum zoom level (Z10) enforced for offline area downloads to prevent insanely large areas",
|
||||
"• IMPROVED: Offline area download confirmation now shows as popup with 'View Progress in Settings' button instead of snackbar"
|
||||
]
|
||||
},
|
||||
"1.4.4": {
|
||||
"content": [
|
||||
"• FOV range notation parsing - now supports OSM data like '90-270' (180° FOV centered at 180°)",
|
||||
"• Complex range notation support: 'ESE;90-125;290' displays multiple FOV cones correctly",
|
||||
"• Profiles now support optional specific FOV values",
|
||||
"• Smart cone rendering - variable FOV widths, 360° cameras show full circles"
|
||||
]
|
||||
},
|
||||
"1.4.3": {
|
||||
"content": [
|
||||
"• NEW: Proximity warning when placing nodes too close together - prevents accidental duplicate submissions"
|
||||
]
|
||||
},
|
||||
"1.4.2": {
|
||||
"content": [
|
||||
"• NEW: Dedicated 'Upload Queue' page - queue items are now shown in a proper list view instead of a popup",
|
||||
"• NEW: 'Clear Upload Queue' button is always visible at the top of queue page, greyed out when empty",
|
||||
"• NEW: 'OpenStreetMap Account' page for managing OSM login and account settings",
|
||||
"• NEW: 'View My Edits on OSM' button takes you directly to your edit history on OpenStreetMap"
|
||||
"• NEW: 'View My Edits on OSM' button takes you directly to your edit history on OpenStreetMap",
|
||||
"• IMPROVED: Settings page organization with dedicated pages for upload management and OSM account",
|
||||
"• IMPROVED: Better empty queue state with helpful messaging",
|
||||
"• UX: Cleaner settings page layout with auth and queue sections moved to their own dedicated pages",
|
||||
"• UX: Added informational content about OpenStreetMap on the account page"
|
||||
]
|
||||
},
|
||||
"1.4.1": {
|
||||
"content": [
|
||||
"• NEW: 'Extract node from way/relation' option for constrained nodes (currently disabled while we decide what that means)"
|
||||
"• NEW: 'Extract node from way/relation' option for constrained nodes",
|
||||
"• When editing nodes that are part of ways or relations, you can now check 'Extract node from way' to create a new node with the same tags at a new location",
|
||||
"• This preserves the original node in its way/relation while creating an independent copy that can be moved freely",
|
||||
"• Useful for cases where surveillance equipment has been relocated but the original node must remain for mapping accuracy",
|
||||
"• Extraction creates a separate OSM changeset and node, leaving the original node untouched"
|
||||
]
|
||||
},
|
||||
"1.4.0": {
|
||||
"content": [
|
||||
"• IMPROVED: Advanced editing options now only show apps available on your platform (iOS/Android)",
|
||||
"• IMPROVED: When an OSM editor app isn't installed, automatically redirect to the appropriate app store",
|
||||
"• IMPROVED: Better error handling for external editor launches with app store fallback",
|
||||
"• Supported editors: Vespucci (Android), StreetComplete (Android), EveryDoor (both), Go Map!! (iOS)",
|
||||
"• Web editors (iD, RapiD) remain available on all platforms as before"
|
||||
]
|
||||
@@ -42,17 +33,27 @@
|
||||
"content": [
|
||||
"• NEW: 'Pause Upload Queue' toggle in Offline Settings - stops uploads while keeping live data access",
|
||||
"• Useful for metered connections or when you want to batch uploads later",
|
||||
"• FIXED: Sheets now resize when rotating between orientations"
|
||||
"• Upload queue is now disabled if either full offline mode OR pause queue processing is enabled",
|
||||
"• FIXED: Sheet buttons now remain visible when rotating from portrait to landscape mode",
|
||||
"• FIXED: Sheets now properly resize when rotating between orientations without requiring user interaction",
|
||||
"• IMPROVED: Tag list height adapts automatically for landscape orientation to prevent covering map",
|
||||
"• IMPROVED: Sheets with few tags now shrink to appropriate size rather than maintaining fixed height",
|
||||
"• IMPROVED: More reliable sheet layout using proper flexible height constraints",
|
||||
"• CLEANED: Fixed minor code formatting inconsistencies"
|
||||
]
|
||||
},
|
||||
"1.3.3": {
|
||||
"content": [
|
||||
"• UX: Edits re-enabled. Only nodes which are part of ways/relations cannot be moved",
|
||||
"• NEW: Added builtin surveillance device profiles for Rekor and Axis Communications ALPR cameras",
|
||||
"• Both profiles include proper OSM tags for manufacturer identification and require direction setting",
|
||||
"• NEW: Advanced editing options - access iD Editor, RapiD, Vespucci, StreetComplete, and other OSM editors",
|
||||
"• NEW: 'View on OSM' links to see nodes directly on OpenStreetMap website",
|
||||
"• UX: Constrained nodes (part of ways/relations) cannot be moved to prevent data corruption",
|
||||
"• UX: Auto-clickable URLs in all tag values - any URL becomes a tappable link",
|
||||
"• UX: Tag lists now scroll with max height to keep buttons and map visible"
|
||||
"• UX: Tag lists now scroll with max height to keep buttons and map visible",
|
||||
"• UX: Improved button layout on mobile with two rows for better accessibility",
|
||||
"• UX: Localized network status messages in all supported languages",
|
||||
"• FIXED: Duplicate changelog service calls eliminated"
|
||||
]
|
||||
},
|
||||
"1.3.2": {
|
||||
|
||||
@@ -17,8 +17,6 @@ import 'services/changelog_service.dart';
|
||||
import 'services/operator_profile_service.dart';
|
||||
import 'services/profile_service.dart';
|
||||
import 'widgets/camera_provider_with_cache.dart';
|
||||
import 'widgets/proximity_warning_dialog.dart';
|
||||
import 'dev_config.dart';
|
||||
import 'state/auth_state.dart';
|
||||
import 'state/navigation_state.dart';
|
||||
import 'state/operator_profile_state.dart';
|
||||
|
||||
@@ -2,36 +2,125 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Developer/build-time configuration for global/non-user-tunable constants.
|
||||
/// Single source of truth with typed maps for settings auto-generation.
|
||||
|
||||
// Fallback tile storage estimate (KB per tile), used when no preview tile data is available
|
||||
const double kFallbackTileEstimateKb = 25.0;
|
||||
// Typed configuration maps - single definition of each constant
|
||||
const Map<String, bool> _boolConfig = {
|
||||
'kEnableDevelopmentModes': true,
|
||||
'kEnableNodeEdits': true,
|
||||
'kEnableNodeExtraction': false,
|
||||
};
|
||||
|
||||
// Preview tile coordinates for tile provider previews and size estimates
|
||||
const int kPreviewTileZoom = 18;
|
||||
const int kPreviewTileY = 101300;
|
||||
const int kPreviewTileX = 41904;
|
||||
const Map<String, int> _intConfig = {
|
||||
'kPreviewTileZoom': 18,
|
||||
'kPreviewTileY': 101300,
|
||||
'kPreviewTileX': 41904,
|
||||
'kNodeMinZoomLevel': 10,
|
||||
'kOsmApiMinZoomLevel': 13,
|
||||
'kPreFetchZoomLevel': 10,
|
||||
'kMaxPreFetchSplitDepth': 3,
|
||||
'kDataRefreshIntervalSeconds': 60,
|
||||
'kProximityAlertDefaultDistance': 400,
|
||||
'kProximityAlertMinDistance': 50,
|
||||
'kProximityAlertMaxDistance': 1600,
|
||||
'kTileFetchMaxAttempts': 16,
|
||||
'kTileFetchInitialDelayMs': 500,
|
||||
'kTileFetchMaxDelayMs': 10000,
|
||||
'kTileFetchRandomJitterMs': 250,
|
||||
'kMaxUserDownloadZoomSpan': 7,
|
||||
'kMaxReasonableTileCount': 20000,
|
||||
'kAbsoluteMaxTileCount': 50000,
|
||||
'kAbsoluteMaxZoom': 23,
|
||||
};
|
||||
|
||||
// Direction cone for map view
|
||||
const double kDirectionConeHalfAngle = 35.0; // degrees
|
||||
const double kDirectionConeBaseLength = 5; // multiplier
|
||||
const Color kDirectionConeColor = Color(0xD0767474); // FOV cone color
|
||||
const double kDirectionConeOpacity = 0.5; // Fill opacity for FOV cones
|
||||
// Base values for thickness - use helper functions below for pixel-ratio scaling
|
||||
const double _kDirectionConeBorderWidthBase = 1.6;
|
||||
const Map<String, double> _doubleConfig = {
|
||||
'kFallbackTileEstimateKb': 25.0,
|
||||
'kDirectionConeHalfAngle': 35.0,
|
||||
'kDirectionConeBaseLength': 5.0,
|
||||
'kDirectionConeOpacity': 0.5,
|
||||
'_kDirectionConeBorderWidthBase': 1.6,
|
||||
'kBottomButtonBarOffset': 4.0,
|
||||
'kButtonBarHeight': 60.0,
|
||||
'kAttributionSpacingAboveButtonBar': 10.0,
|
||||
'kZoomIndicatorSpacingAboveButtonBar': 40.0,
|
||||
'kScaleBarSpacingAboveButtonBar': 70.0,
|
||||
'kZoomControlsSpacingAboveButtonBar': 20.0,
|
||||
'kPreFetchAreaExpansionMultiplier': 3.0,
|
||||
'kMinSpeedForRotationMps': 1.0,
|
||||
'kMaxTagListHeightRatioPortrait': 0.3,
|
||||
'kMaxTagListHeightRatioLandscape': 0.2,
|
||||
'kNodeDoubleTapZoomDelta': 1.0,
|
||||
'kScrollWheelVelocity': 0.01,
|
||||
'kPinchZoomThreshold': 0.2,
|
||||
'kPinchMoveThreshold': 30.0,
|
||||
'kRotationThreshold': 6.0,
|
||||
'kNodeIconDiameter': 18.0,
|
||||
'_kNodeRingThicknessBase': 2.5,
|
||||
'kNodeDotOpacity': 0.3,
|
||||
'kDirectionButtonMinWidth': 22.0,
|
||||
'kDirectionButtonMinHeight': 32.0,
|
||||
'kTileFetchBackoffMultiplier': 1.5,
|
||||
};
|
||||
|
||||
// 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)
|
||||
const Map<String, String> _stringConfig = {
|
||||
'kClientName': 'DeFlock', // Read-only in settings
|
||||
'kSuspectedLocationsCsvUrl': 'https://stopflock.com/app/flock_utilities_mini_latest.csv',
|
||||
};
|
||||
|
||||
// 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
|
||||
const Map<String, Color> _colorConfig = {
|
||||
'kDirectionConeColor': Color(0xD0767474),
|
||||
'kNodeRingColorReal': Color(0xFF3036F0),
|
||||
'kNodeRingColorMock': Color(0xD0FFFFFF),
|
||||
'kNodeRingColorPending': Color(0xD09C27B0),
|
||||
'kNodeRingColorEditing': Color(0xD0FF9800),
|
||||
'kNodeRingColorPendingEdit': Color(0xD0757575),
|
||||
'kNodeRingColorPendingDeletion': Color(0xC0F44336),
|
||||
};
|
||||
|
||||
const Map<String, Duration> _durationConfig = {
|
||||
'kMarkerTapTimeout': Duration(milliseconds: 250),
|
||||
'kDebounceCameraRefresh': Duration(milliseconds: 500),
|
||||
'kFollowMeAnimationDuration': Duration(milliseconds: 600),
|
||||
'kProximityAlertCooldown': Duration(minutes: 10),
|
||||
};
|
||||
|
||||
// Dynamic accessor class
|
||||
class _DevConfig {
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) {
|
||||
final name = invocation.memberName.toString().replaceAll('Symbol("', '').replaceAll('")', '');
|
||||
|
||||
// Check each typed map
|
||||
if (_boolConfig.containsKey(name)) return _boolConfig[name];
|
||||
if (_intConfig.containsKey(name)) return _intConfig[name];
|
||||
if (_doubleConfig.containsKey(name)) return _doubleConfig[name];
|
||||
if (_stringConfig.containsKey(name)) return _stringConfig[name];
|
||||
if (_colorConfig.containsKey(name)) return _colorConfig[name];
|
||||
if (_durationConfig.containsKey(name)) return _durationConfig[name];
|
||||
|
||||
throw NoSuchMethodError.withInvocation(this, invocation);
|
||||
}
|
||||
}
|
||||
|
||||
// Global accessor
|
||||
final dynamic dev = _DevConfig();
|
||||
|
||||
// For settings page - combine all maps
|
||||
Map<String, dynamic> get devConfigForSettings => {
|
||||
..._boolConfig,
|
||||
..._intConfig,
|
||||
..._doubleConfig,
|
||||
..._stringConfig,
|
||||
..._colorConfig,
|
||||
..._durationConfig,
|
||||
};
|
||||
|
||||
// Computed constants
|
||||
bool get kEnableNavigationFeatures => dev.kEnableDevelopmentModes;
|
||||
|
||||
// Helper to calculate bottom position relative to button bar
|
||||
double bottomPositionFromButtonBar(double spacingAboveButtonBar, double safeAreaBottom) {
|
||||
return safeAreaBottom + kBottomButtonBarOffset + kButtonBarHeight + spacingAboveButtonBar;
|
||||
return safeAreaBottom + dev.kBottomButtonBarOffset + dev.kButtonBarHeight + spacingAboveButtonBar;
|
||||
}
|
||||
|
||||
// Helper to get left positioning that accounts for safe area (for landscape mode)
|
||||
@@ -49,28 +138,9 @@ double topPositionWithSafeArea(double baseTop, EdgeInsets safeArea) {
|
||||
return baseTop + safeArea.top;
|
||||
}
|
||||
|
||||
// Client name for OSM uploads ("created_by" tag)
|
||||
const String kClientName = 'DeFlock';
|
||||
// Note: Version is now dynamically retrieved from VersionService
|
||||
|
||||
// Suspected locations CSV URL
|
||||
const String kSuspectedLocationsCsvUrl = 'https://alprwatch.org/suspected-locations/deflock-latest.csv';
|
||||
|
||||
// Development/testing features - set to false for production builds
|
||||
const bool kEnableDevelopmentModes = false; // Set to false to hide sandbox/simulate modes and force production mode
|
||||
|
||||
// Navigation features - set to false to hide navigation UI elements while in development
|
||||
const bool kEnableNavigationFeatures = kEnableDevelopmentModes; // Hide navigation until fully implemented
|
||||
|
||||
// Node editing features - set to false to temporarily disable editing
|
||||
const bool kEnableNodeEdits = true; // Set to false to temporarily disable node editing
|
||||
|
||||
// Node extraction features - set to false to hide extract functionality for constrained nodes
|
||||
const bool kEnableNodeExtraction = false; // Set to true to enable extract from way/relation feature (WIP)
|
||||
|
||||
/// Navigation availability: only dev builds, and only when online
|
||||
bool enableNavigationFeatures({required bool offlineMode}) {
|
||||
if (!kEnableDevelopmentModes) {
|
||||
if (!dev.kEnableDevelopmentModes) {
|
||||
return false; // Release builds: never allow navigation
|
||||
} else {
|
||||
return !offlineMode; // Dev builds: only when online
|
||||
@@ -80,8 +150,6 @@ bool enableNavigationFeatures({required bool offlineMode}) {
|
||||
// Marker/node interaction
|
||||
const int kNodeMinZoomLevel = 10; // Minimum zoom to show nodes (Overpass)
|
||||
const int kOsmApiMinZoomLevel = 13; // Minimum zoom for OSM API bbox queries (sandbox mode)
|
||||
const int kMinZoomForNodeEditingSheets = 15; // Minimum zoom to open add/edit node sheets
|
||||
const int kMinZoomForOfflineDownload = 10; // Minimum zoom to download offline areas (prevents large area crashes)
|
||||
const Duration kMarkerTapTimeout = Duration(milliseconds: 250);
|
||||
const Duration kDebounceCameraRefresh = Duration(milliseconds: 500);
|
||||
|
||||
@@ -114,9 +182,6 @@ const int kProximityAlertMinDistance = 50; // meters
|
||||
const int kProximityAlertMaxDistance = 1600; // meters
|
||||
const Duration kProximityAlertCooldown = Duration(minutes: 10); // Cooldown between alerts for same node
|
||||
|
||||
// Node proximity warning configuration (for new/edited nodes that are too close to existing ones)
|
||||
const double kNodeProximityWarningDistance = 15.0; // meters - distance threshold to show warning
|
||||
|
||||
// Map interaction configuration
|
||||
const double kNodeDoubleTapZoomDelta = 1.0; // How much to zoom in when double-tapping nodes (was 1.0)
|
||||
const double kScrollWheelVelocity = 0.01; // Mouse scroll wheel zoom speed (default 0.005)
|
||||
@@ -156,11 +221,11 @@ const double kDirectionButtonMinHeight = 32.0;
|
||||
|
||||
// Helper functions for pixel-ratio scaling
|
||||
double getDirectionConeBorderWidth(BuildContext context) {
|
||||
// return _kDirectionConeBorderWidthBase * MediaQuery.of(context).devicePixelRatio;
|
||||
return _kDirectionConeBorderWidthBase;
|
||||
// return dev._kDirectionConeBorderWidthBase * MediaQuery.of(context).devicePixelRatio;
|
||||
return dev._kDirectionConeBorderWidthBase;
|
||||
}
|
||||
|
||||
double getNodeRingThickness(BuildContext context) {
|
||||
// return _kNodeRingThicknessBase * MediaQuery.of(context).devicePixelRatio;
|
||||
return _kNodeRingThicknessBase;
|
||||
// return dev._kNodeRingThicknessBase * MediaQuery.of(context).devicePixelRatio;
|
||||
return dev._kNodeRingThicknessBase;
|
||||
}
|
||||
|
||||
@@ -21,24 +21,6 @@
|
||||
"advanced": "Erweitert",
|
||||
"useAdvancedEditor": "Erweiterten Editor verwenden"
|
||||
},
|
||||
"proximityWarning": {
|
||||
"title": "Knoten sehr nah an vorhandenem Gerät",
|
||||
"message": "Dieser Knoten ist nur {} Meter von einem vorhandenen Überwachungsgerät entfernt.",
|
||||
"suggestion": "Wenn mehrere Geräte am selben Mast sind, verwenden Sie bitte mehrere Richtungen auf einem einzigen Knoten, anstatt separate Knoten zu erstellen.",
|
||||
"nearbyNodes": "Nahegelegene Gerät(e) gefunden ({}):",
|
||||
"nodeInfo": "Knoten #{} - {}",
|
||||
"andMore": "...und {} weitere",
|
||||
"goBack": "Zurück",
|
||||
"submitAnyway": "Trotzdem senden",
|
||||
"nodeType": {
|
||||
"alpr": "ALPR/ANPR Kamera",
|
||||
"publicCamera": "Öffentliche Überwachungskamera",
|
||||
"camera": "Überwachungskamera",
|
||||
"amenity": "{}",
|
||||
"device": "{} Gerät",
|
||||
"unknown": "Unbekanntes Gerät"
|
||||
}
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Verfolgung aktivieren",
|
||||
"follow": "Verfolgung aktivieren (Rotation)",
|
||||
@@ -118,7 +100,6 @@
|
||||
"enableSubmittableProfile": "Aktivieren Sie ein übertragbares Profil in den Einstellungen, um Knoten zu bearbeiten.",
|
||||
"profileViewOnlyWarning": "Dieses Profil ist nur zum Anzeigen der Karte gedacht. Bitte wählen Sie ein übertragbares Profil aus, um Knoten zu bearbeiten.",
|
||||
"cannotMoveConstrainedNode": "Kann diese Kamera nicht verschieben - sie ist mit einem anderen Kartenelement verbunden (OSM-Weg/Relation). Sie können trotzdem ihre Tags und Richtung bearbeiten.",
|
||||
"zoomInRequiredMessage": "Zoomen Sie auf mindestens Stufe {} heran, um Überwachungsknoten hinzuzufügen oder zu bearbeiten. Dies gewährleistet eine präzise Positionierung für genaues Kartieren.",
|
||||
"extractFromWay": "Knoten aus Weg/Relation extrahieren",
|
||||
"extractFromWaySubtitle": "Neuen Knoten mit gleichen Tags erstellen, Verschieben an neuen Ort ermöglichen",
|
||||
"refineTags": "Tags Verfeinern",
|
||||
@@ -134,16 +115,9 @@
|
||||
"withinTileLimit": "Innerhalb {} Kachel-Limit",
|
||||
"exceedsTileLimit": "Aktuelle Auswahl überschreitet {} Kachel-Limit",
|
||||
"offlineModeWarning": "Downloads im Offline-Modus deaktiviert. Deaktivieren Sie den Offline-Modus, um neue Bereiche herunterzuladen.",
|
||||
"areaTooBigMessage": "Zoomen Sie auf mindestens Stufe {} heran, um Offline-Bereiche herunterzuladen. Downloads großer Gebiete können die App zum Absturz bringen.",
|
||||
"downloadStarted": "Download gestartet! Lade Kacheln und Knoten...",
|
||||
"downloadFailed": "Download konnte nicht gestartet werden: {}"
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Download gestartet",
|
||||
"message": "Download gestartet! Lade Kacheln und Knoten...",
|
||||
"ok": "OK",
|
||||
"viewProgress": "Fortschritt in Einstellungen anzeigen"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Upload-Ziel",
|
||||
"subtitle": "Wählen Sie, wohin Kameras hochgeladen werden",
|
||||
@@ -273,10 +247,6 @@
|
||||
"profileNameRequired": "Profil-Name ist erforderlich",
|
||||
"requiresDirection": "Benötigt Richtung",
|
||||
"requiresDirectionSubtitle": "Ob Kameras dieses Typs ein Richtungs-Tag benötigen",
|
||||
"fov": "Sichtfeld",
|
||||
"fovHint": "Sichtfeld in Grad (leer lassen für Standard)",
|
||||
"fovSubtitle": "Kamera-Sichtfeld - verwendet für Kegelbreite und Bereichsübertragungsformat",
|
||||
"fovInvalid": "Sichtfeld muss zwischen 1 und 360 Grad liegen",
|
||||
"submittable": "Übertragbar",
|
||||
"submittableSubtitle": "Ob dieses Profil für Kamera-Übertragungen verwendet werden kann",
|
||||
"osmTags": "OSM-Tags",
|
||||
|
||||
@@ -39,24 +39,6 @@
|
||||
"advanced": "Advanced",
|
||||
"useAdvancedEditor": "Use Advanced Editor"
|
||||
},
|
||||
"proximityWarning": {
|
||||
"title": "Node Very Close to Existing Device",
|
||||
"message": "This node is only {} meters from an existing surveillance device.",
|
||||
"suggestion": "If multiple devices are on the same pole, please use multiple directions on a single node instead of creating separate nodes.",
|
||||
"nearbyNodes": "Nearby device(s) found ({}):",
|
||||
"nodeInfo": "Node #{} - {}",
|
||||
"andMore": "...and {} more",
|
||||
"goBack": "Go Back",
|
||||
"submitAnyway": "Submit Anyway",
|
||||
"nodeType": {
|
||||
"alpr": "ALPR/ANPR Camera",
|
||||
"publicCamera": "Public Surveillance Camera",
|
||||
"camera": "Surveillance Camera",
|
||||
"amenity": "{}",
|
||||
"device": "{} Device",
|
||||
"unknown": "Unknown Device"
|
||||
}
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Enable follow-me",
|
||||
"follow": "Enable follow-me (rotating)",
|
||||
@@ -136,7 +118,6 @@
|
||||
"enableSubmittableProfile": "Enable a submittable profile in Settings to edit nodes.",
|
||||
"profileViewOnlyWarning": "This profile is for map viewing only. Please select a submittable profile to edit nodes.",
|
||||
"cannotMoveConstrainedNode": "Cannot move this camera - it's connected to another map element (OSM way/relation). You can still edit its tags and direction.",
|
||||
"zoomInRequiredMessage": "Zoom in to at least level {} to add or edit surveillance nodes. This ensures precise positioning for accurate mapping.",
|
||||
"extractFromWay": "Extract node from way/relation",
|
||||
"extractFromWaySubtitle": "Create new node with same tags, allow moving to new location",
|
||||
"refineTags": "Refine Tags",
|
||||
@@ -152,16 +133,9 @@
|
||||
"withinTileLimit": "Within {} tile limit",
|
||||
"exceedsTileLimit": "Current selection exceeds {} tile limit",
|
||||
"offlineModeWarning": "Downloads disabled while in offline mode. Disable offline mode to download new areas.",
|
||||
"areaTooBigMessage": "Zoom in to at least level {} to download offline areas. Large area downloads can cause the app to become unresponsive.",
|
||||
"downloadStarted": "Download started! Fetching tiles and nodes...",
|
||||
"downloadFailed": "Failed to start download: {}"
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Download Started",
|
||||
"message": "Download started! Fetching tiles and nodes...",
|
||||
"ok": "OK",
|
||||
"viewProgress": "View Progress in Settings"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Upload Destination",
|
||||
"subtitle": "Choose where cameras are uploaded",
|
||||
@@ -291,10 +265,6 @@
|
||||
"profileNameRequired": "Profile name is required",
|
||||
"requiresDirection": "Requires Direction",
|
||||
"requiresDirectionSubtitle": "Whether cameras of this type need a direction tag",
|
||||
"fov": "Field of View",
|
||||
"fovHint": "FOV in degrees (leave empty for default)",
|
||||
"fovSubtitle": "Camera field of view - used for cone width and range submission format",
|
||||
"fovInvalid": "FOV must be between 1 and 360 degrees",
|
||||
"submittable": "Submittable",
|
||||
"submittableSubtitle": "Whether this profile can be used for camera submissions",
|
||||
"osmTags": "OSM Tags",
|
||||
|
||||
@@ -39,24 +39,6 @@
|
||||
"advanced": "Avanzado",
|
||||
"useAdvancedEditor": "Usar Editor Avanzado"
|
||||
},
|
||||
"proximityWarning": {
|
||||
"title": "Nodo Muy Cerca de Dispositivo Existente",
|
||||
"message": "Este nodo está a solo {} metros de un dispositivo de vigilancia existente.",
|
||||
"suggestion": "Si hay múltiples dispositivos en el mismo poste, use múltiples direcciones en un solo nodo en lugar de crear nodos separados.",
|
||||
"nearbyNodes": "Dispositivo(s) cercano(s) encontrado(s) ({}):",
|
||||
"nodeInfo": "Nodo #{} - {}",
|
||||
"andMore": "...y {} más",
|
||||
"goBack": "Volver",
|
||||
"submitAnyway": "Enviar de Todas Formas",
|
||||
"nodeType": {
|
||||
"alpr": "Cámara ALPR/ANPR",
|
||||
"publicCamera": "Cámara de Vigilancia Pública",
|
||||
"camera": "Cámara de Vigilancia",
|
||||
"amenity": "{}",
|
||||
"device": "Dispositivo {}",
|
||||
"unknown": "Dispositivo Desconocido"
|
||||
}
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Activar seguimiento",
|
||||
"follow": "Activar seguimiento (rotación)",
|
||||
@@ -136,7 +118,6 @@
|
||||
"enableSubmittableProfile": "Habilite un perfil envíable en Configuración para editar nodos.",
|
||||
"profileViewOnlyWarning": "Este perfil es solo para visualización del mapa. Por favor, seleccione un perfil envíable para editar nodos.",
|
||||
"cannotMoveConstrainedNode": "No se puede mover esta cámara - está conectada a otro elemento del mapa (OSM way/relation). Aún puede editar sus etiquetas y dirección.",
|
||||
"zoomInRequiredMessage": "Amplíe al menos al nivel {} para agregar o editar nodos de vigilancia. Esto garantiza un posicionamiento preciso para un mapeo exacto.",
|
||||
"extractFromWay": "Extraer nodo de way/relation",
|
||||
"extractFromWaySubtitle": "Crear nuevo nodo con las mismas etiquetas, permitir mover a nueva ubicación",
|
||||
"refineTags": "Refinar Etiquetas",
|
||||
@@ -152,16 +133,9 @@
|
||||
"withinTileLimit": "Dentro del límite de {} mosaicos",
|
||||
"exceedsTileLimit": "La selección actual excede el límite de {} mosaicos",
|
||||
"offlineModeWarning": "Descargas deshabilitadas en modo sin conexión. Deshabilite el modo sin conexión para descargar nuevas áreas.",
|
||||
"areaTooBigMessage": "Amplíe al menos al nivel {} para descargar áreas sin conexión. Las descargas de áreas grandes pueden hacer que la aplicación deje de responder.",
|
||||
"downloadStarted": "¡Descarga iniciada! Obteniendo mosaicos y nodos...",
|
||||
"downloadFailed": "Error al iniciar la descarga: {}"
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Descarga Iniciada",
|
||||
"message": "¡Descarga iniciada! Obteniendo mosaicos y nodos...",
|
||||
"ok": "OK",
|
||||
"viewProgress": "Ver Progreso en Configuración"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Destino de Subida",
|
||||
"subtitle": "Elige dónde se suben las cámaras",
|
||||
@@ -291,10 +265,6 @@
|
||||
"profileNameRequired": "El nombre del perfil es requerido",
|
||||
"requiresDirection": "Requiere Dirección",
|
||||
"requiresDirectionSubtitle": "Si las cámaras de este tipo necesitan una etiqueta de dirección",
|
||||
"fov": "Campo de Visión",
|
||||
"fovHint": "Campo de visión en grados (dejar vacío para el predeterminado)",
|
||||
"fovSubtitle": "Campo de visión de la cámara - usado para el ancho del cono y formato de envío por rango",
|
||||
"fovInvalid": "El campo de visión debe estar entre 1 y 360 grados",
|
||||
"submittable": "Envíable",
|
||||
"submittableSubtitle": "Si este perfil puede usarse para envíos de cámaras",
|
||||
"osmTags": "Etiquetas OSM",
|
||||
|
||||
@@ -39,24 +39,6 @@
|
||||
"advanced": "Avancé",
|
||||
"useAdvancedEditor": "Utiliser l'Éditeur Avancé"
|
||||
},
|
||||
"proximityWarning": {
|
||||
"title": "Nœud Très Proche d'un Dispositif Existant",
|
||||
"message": "Ce nœud n'est qu'à {} mètres d'un dispositif de surveillance existant.",
|
||||
"suggestion": "Si plusieurs dispositifs se trouvent sur le même poteau, veuillez utiliser plusieurs directions sur un seul nœud au lieu de créer des nœuds séparés.",
|
||||
"nearbyNodes": "Dispositif(s) proche(s) trouvé(s) ({}) :",
|
||||
"nodeInfo": "Nœud #{} - {}",
|
||||
"andMore": "...et {} de plus",
|
||||
"goBack": "Retour",
|
||||
"submitAnyway": "Soumettre Quand Même",
|
||||
"nodeType": {
|
||||
"alpr": "Caméra ALPR/ANPR",
|
||||
"publicCamera": "Caméra de Surveillance Publique",
|
||||
"camera": "Caméra de Surveillance",
|
||||
"amenity": "{}",
|
||||
"device": "Dispositif {}",
|
||||
"unknown": "Dispositif Inconnu"
|
||||
}
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Activer le suivi",
|
||||
"follow": "Activer le suivi (rotation)",
|
||||
@@ -136,7 +118,6 @@
|
||||
"enableSubmittableProfile": "Activez un profil soumissible dans les Paramètres pour modifier les nœuds.",
|
||||
"profileViewOnlyWarning": "Ce profil est uniquement pour la visualisation de la carte. Veuillez sélectionner un profil soumissible pour modifier les nœuds.",
|
||||
"cannotMoveConstrainedNode": "Impossible de déplacer cette caméra - elle est connectée à un autre élément de carte (OSM way/relation). Vous pouvez toujours modifier ses balises et sa direction.",
|
||||
"zoomInRequiredMessage": "Zoomez au moins au niveau {} pour ajouter ou modifier des nœuds de surveillance. Cela garantit un positionnement précis pour une cartographie exacte.",
|
||||
"extractFromWay": "Extraire le nœud du way/relation",
|
||||
"extractFromWaySubtitle": "Créer un nouveau nœud avec les mêmes balises, permettre le déplacement vers un nouvel emplacement",
|
||||
"refineTags": "Affiner Balises",
|
||||
@@ -152,16 +133,9 @@
|
||||
"withinTileLimit": "Dans la limite de {} tuiles",
|
||||
"exceedsTileLimit": "La sélection actuelle dépasse la limite de {} tuiles",
|
||||
"offlineModeWarning": "Téléchargements désactivés en mode hors ligne. Désactivez le mode hors ligne pour télécharger de nouvelles zones.",
|
||||
"areaTooBigMessage": "Zoomez au moins au niveau {} pour télécharger des zones hors ligne. Les téléchargements de grandes zones peuvent rendre l'application non réactive.",
|
||||
"downloadStarted": "Téléchargement démarré ! Récupération des tuiles et nœuds...",
|
||||
"downloadStarted": "Téléchargement démarré! Récupération des tuiles et nœuds...",
|
||||
"downloadFailed": "Échec du démarrage du téléchargement: {}"
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Téléchargement Démarré",
|
||||
"message": "Téléchargement démarré! Récupération des tuiles et nœuds...",
|
||||
"ok": "OK",
|
||||
"viewProgress": "Voir le Progrès dans Paramètres"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Destination de Téléchargement",
|
||||
"subtitle": "Choisir où les caméras sont téléchargées",
|
||||
@@ -291,10 +265,6 @@
|
||||
"profileNameRequired": "Le nom du profil est requis",
|
||||
"requiresDirection": "Nécessite Direction",
|
||||
"requiresDirectionSubtitle": "Si les caméras de ce type ont besoin d'une balise de direction",
|
||||
"fov": "Champ de Vision",
|
||||
"fovHint": "Champ de vision en degrés (laisser vide pour la valeur par défaut)",
|
||||
"fovSubtitle": "Champ de vision de la caméra - utilisé pour la largeur du cône et le format de soumission par plage",
|
||||
"fovInvalid": "Le champ de vision doit être entre 1 et 360 degrés",
|
||||
"submittable": "Soumissible",
|
||||
"submittableSubtitle": "Si ce profil peut être utilisé pour les soumissions de caméras",
|
||||
"osmTags": "Balises OSM",
|
||||
|
||||
@@ -39,24 +39,6 @@
|
||||
"advanced": "Avanzato",
|
||||
"useAdvancedEditor": "Usa Editor Avanzato"
|
||||
},
|
||||
"proximityWarning": {
|
||||
"title": "Nodo Molto Vicino a Dispositivo Esistente",
|
||||
"message": "Questo nodo è a soli {} metri da un dispositivo di sorveglianza esistente.",
|
||||
"suggestion": "Se ci sono più dispositivi sullo stesso palo, utilizzare più direzioni su un singolo nodo invece di creare nodi separati.",
|
||||
"nearbyNodes": "Dispositivo/i vicino/i trovato/i ({}):",
|
||||
"nodeInfo": "Nodo #{} - {}",
|
||||
"andMore": "...e altri {}",
|
||||
"goBack": "Torna Indietro",
|
||||
"submitAnyway": "Invia Comunque",
|
||||
"nodeType": {
|
||||
"alpr": "Telecamera ALPR/ANPR",
|
||||
"publicCamera": "Telecamera di Sorveglianza Pubblica",
|
||||
"camera": "Telecamera di Sorveglianza",
|
||||
"amenity": "{}",
|
||||
"device": "Dispositivo {}",
|
||||
"unknown": "Dispositivo Sconosciuto"
|
||||
}
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Attiva seguimi",
|
||||
"follow": "Attiva seguimi (rotazione)",
|
||||
@@ -136,7 +118,6 @@
|
||||
"enableSubmittableProfile": "Abilita un profilo inviabile nelle Impostazioni per modificare i nodi.",
|
||||
"profileViewOnlyWarning": "Questo profilo è solo per la visualizzazione della mappa. Per favore seleziona un profilo inviabile per modificare i nodi.",
|
||||
"cannotMoveConstrainedNode": "Impossibile spostare questa telecamera - è collegata a un altro elemento della mappa (OSM way/relation). Puoi ancora modificare i suoi tag e direzione.",
|
||||
"zoomInRequiredMessage": "Ingrandisci almeno al livello {} per aggiungere o modificare nodi di sorveglianza. Questo garantisce un posizionamento preciso per una mappatura accurata.",
|
||||
"extractFromWay": "Estrai nodo da way/relation",
|
||||
"extractFromWaySubtitle": "Crea nuovo nodo con gli stessi tag, consenti spostamento in nuova posizione",
|
||||
"refineTags": "Affina Tag",
|
||||
@@ -152,16 +133,9 @@
|
||||
"withinTileLimit": "Entro il limite di {} tile",
|
||||
"exceedsTileLimit": "La selezione corrente supera il limite di {} tile",
|
||||
"offlineModeWarning": "Download disabilitati in modalità offline. Disabilita la modalità offline per scaricare nuove aree.",
|
||||
"areaTooBigMessage": "Ingrandisci almeno al livello {} per scaricare aree offline. I download di aree grandi possono rendere l'app non reattiva.",
|
||||
"downloadStarted": "Download avviato! Recupero tile e nodi...",
|
||||
"downloadFailed": "Impossibile avviare il download: {}"
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Download Avviato",
|
||||
"message": "Download avviato! Recupero tile e nodi...",
|
||||
"ok": "OK",
|
||||
"viewProgress": "Visualizza Progresso in Impostazioni"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Destinazione Upload",
|
||||
"subtitle": "Scegli dove vengono caricate le telecamere",
|
||||
@@ -291,10 +265,6 @@
|
||||
"profileNameRequired": "Il nome del profilo è obbligatorio",
|
||||
"requiresDirection": "Richiede Direzione",
|
||||
"requiresDirectionSubtitle": "Se le telecamere di questo tipo necessitano di un tag direzione",
|
||||
"fov": "Campo Visivo",
|
||||
"fovHint": "Campo visivo in gradi (lasciare vuoto per il valore predefinito)",
|
||||
"fovSubtitle": "Campo visivo della telecamera - utilizzato per la larghezza del cono e il formato di invio per intervallo",
|
||||
"fovInvalid": "Il campo visivo deve essere tra 1 e 360 gradi",
|
||||
"submittable": "Inviabile",
|
||||
"submittableSubtitle": "Se questo profilo può essere usato per invii di telecamere",
|
||||
"osmTags": "Tag OSM",
|
||||
|
||||
@@ -39,24 +39,6 @@
|
||||
"advanced": "Avançado",
|
||||
"useAdvancedEditor": "Usar Editor Avançado"
|
||||
},
|
||||
"proximityWarning": {
|
||||
"title": "Nó Muito Próximo de Dispositivo Existente",
|
||||
"message": "Este nó está a apenas {} metros de um dispositivo de vigilância existente.",
|
||||
"suggestion": "Se vários dispositivos estão no mesmo poste, use várias direções em um único nó em vez de criar nós separados.",
|
||||
"nearbyNodes": "Dispositivo(s) próximo(s) encontrado(s) ({}):",
|
||||
"nodeInfo": "Nó #{} - {}",
|
||||
"andMore": "...e mais {}",
|
||||
"goBack": "Voltar",
|
||||
"submitAnyway": "Enviar Mesmo Assim",
|
||||
"nodeType": {
|
||||
"alpr": "Câmera ALPR/ANPR",
|
||||
"publicCamera": "Câmera de Vigilância Pública",
|
||||
"camera": "Câmera de Vigilância",
|
||||
"amenity": "{}",
|
||||
"device": "Dispositivo {}",
|
||||
"unknown": "Dispositivo Desconhecido"
|
||||
}
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Ativar seguir-me",
|
||||
"follow": "Ativar seguir-me (rotação)",
|
||||
@@ -136,7 +118,6 @@
|
||||
"enableSubmittableProfile": "Ative um perfil enviável nas Configurações para editar nós.",
|
||||
"profileViewOnlyWarning": "Este perfil é apenas para visualização do mapa. Por favor, selecione um perfil enviável para editar nós.",
|
||||
"cannotMoveConstrainedNode": "Não é possível mover esta câmera - ela está conectada a outro elemento do mapa (OSM way/relation). Você ainda pode editar suas tags e direção.",
|
||||
"zoomInRequiredMessage": "Amplie para pelo menos o nível {} para adicionar ou editar nós de vigilância. Isto garante um posicionamento preciso para mapeamento exato.",
|
||||
"extractFromWay": "Extrair nó do way/relation",
|
||||
"extractFromWaySubtitle": "Criar novo nó com as mesmas tags, permitir mover para nova localização",
|
||||
"refineTags": "Refinar Tags",
|
||||
@@ -152,16 +133,9 @@
|
||||
"withinTileLimit": "Dentro do limite de {} tiles",
|
||||
"exceedsTileLimit": "A seleção atual excede o limite de {} tiles",
|
||||
"offlineModeWarning": "Downloads desabilitados no modo offline. Desative o modo offline para baixar novas áreas.",
|
||||
"areaTooBigMessage": "Amplie para pelo menos o nível {} para baixar áreas offline. Downloads de áreas grandes podem tornar o aplicativo não responsivo.",
|
||||
"downloadStarted": "Download iniciado! Buscando tiles e nós...",
|
||||
"downloadFailed": "Falha ao iniciar o download: {}"
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Download Iniciado",
|
||||
"message": "Download iniciado! Buscando tiles e nós...",
|
||||
"ok": "OK",
|
||||
"viewProgress": "Ver Progresso nas Configurações"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Destino do Upload",
|
||||
"subtitle": "Escolha onde as câmeras são enviadas",
|
||||
@@ -291,10 +265,6 @@
|
||||
"profileNameRequired": "Nome do perfil é obrigatório",
|
||||
"requiresDirection": "Requer Direção",
|
||||
"requiresDirectionSubtitle": "Se câmeras deste tipo precisam de uma tag de direção",
|
||||
"fov": "Campo de Visão",
|
||||
"fovHint": "Campo de visão em graus (deixar vazio para o padrão)",
|
||||
"fovSubtitle": "Campo de visão da câmera - usado para largura do cone e formato de envio por intervalo",
|
||||
"fovInvalid": "Campo de visão deve estar entre 1 e 360 graus",
|
||||
"submittable": "Enviável",
|
||||
"submittableSubtitle": "Se este perfil pode ser usado para envios de câmeras",
|
||||
"osmTags": "Tags OSM",
|
||||
|
||||
@@ -39,24 +39,6 @@
|
||||
"advanced": "高级",
|
||||
"useAdvancedEditor": "使用高级编辑器"
|
||||
},
|
||||
"proximityWarning": {
|
||||
"title": "节点过于靠近现有设备",
|
||||
"message": "此节点距离现有监控设备仅 {} 米。",
|
||||
"suggestion": "如果同一根杆上有多个设备,请在单个节点上使用多个方向,而不是创建单独的节点。",
|
||||
"nearbyNodes": "发现附近设备 ({}):",
|
||||
"nodeInfo": "节点 #{} - {}",
|
||||
"andMore": "...还有 {} 个",
|
||||
"goBack": "返回",
|
||||
"submitAnyway": "仍然提交",
|
||||
"nodeType": {
|
||||
"alpr": "ALPR/ANPR 摄像头",
|
||||
"publicCamera": "公共监控摄像头",
|
||||
"camera": "监控摄像头",
|
||||
"amenity": "{}",
|
||||
"device": "{} 设备",
|
||||
"unknown": "未知设备"
|
||||
}
|
||||
},
|
||||
"followMe": {
|
||||
"off": "启用跟随模式",
|
||||
"follow": "启用跟随模式(旋转)",
|
||||
@@ -136,7 +118,6 @@
|
||||
"enableSubmittableProfile": "在设置中启用可提交的配置文件以编辑节点。",
|
||||
"profileViewOnlyWarning": "此配置文件仅用于地图查看。请选择可提交的配置文件来编辑节点。",
|
||||
"cannotMoveConstrainedNode": "无法移动此相机 - 它连接到另一个地图元素(OSM way/relation)。您仍可以编辑其标签和方向。",
|
||||
"zoomInRequiredMessage": "请放大至至少第{}级来添加或编辑监控节点。这确保精确定位以便准确制图。",
|
||||
"extractFromWay": "从way/relation中提取节点",
|
||||
"extractFromWaySubtitle": "创建具有相同标签的新节点,允许移动到新位置",
|
||||
"refineTags": "细化标签",
|
||||
@@ -152,16 +133,9 @@
|
||||
"withinTileLimit": "在 {} 瓦片限制内",
|
||||
"exceedsTileLimit": "当前选择超出 {} 瓦片限制",
|
||||
"offlineModeWarning": "离线模式下禁用下载。禁用离线模式以下载新区域。",
|
||||
"areaTooBigMessage": "请放大至至少第{}级来下载离线区域。下载大区域可能导致应用程序无响应。",
|
||||
"downloadStarted": "下载已开始!正在获取瓦片和节点...",
|
||||
"downloadFailed": "启动下载失败:{}"
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "下载已开始",
|
||||
"message": "下载已开始!正在获取瓦片和节点...",
|
||||
"ok": "确定",
|
||||
"viewProgress": "在设置中查看进度"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "上传目标",
|
||||
"subtitle": "选择摄像头上传位置",
|
||||
@@ -291,10 +265,6 @@
|
||||
"profileNameRequired": "配置文件名称为必填项",
|
||||
"requiresDirection": "需要方向",
|
||||
"requiresDirectionSubtitle": "此类型的摄像头是否需要方向标签",
|
||||
"fov": "视场角",
|
||||
"fovHint": "视场角度数(留空使用默认值)",
|
||||
"fovSubtitle": "摄像头视场角 - 用于锥体宽度和范围提交格式",
|
||||
"fovInvalid": "视场角必须在1到360度之间",
|
||||
"submittable": "可提交",
|
||||
"submittableSubtitle": "此配置文件是否可用于摄像头提交",
|
||||
"osmTags": "OSM 标签",
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'screens/profiles_settings_screen.dart';
|
||||
import 'screens/navigation_settings_screen.dart';
|
||||
import 'screens/offline_settings_screen.dart';
|
||||
import 'screens/advanced_settings_screen.dart';
|
||||
import 'screens/developer_settings_screen.dart';
|
||||
import 'screens/language_settings_screen.dart';
|
||||
import 'screens/about_screen.dart';
|
||||
import 'screens/release_notes_screen.dart';
|
||||
@@ -77,6 +78,7 @@ class DeFlockApp extends StatelessWidget {
|
||||
'/settings/navigation': (context) => const NavigationSettingsScreen(),
|
||||
'/settings/offline': (context) => const OfflineSettingsScreen(),
|
||||
'/settings/advanced': (context) => const AdvancedSettingsScreen(),
|
||||
'/settings/developer': (context) => const DeveloperSettingsScreen(),
|
||||
'/settings/language': (context) => const LanguageSettingsScreen(),
|
||||
'/settings/about': (context) => const AboutScreen(),
|
||||
'/settings/release-notes': (context) => const ReleaseNotesScreen(),
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
/// Represents a direction with its associated field-of-view (FOV) cone.
|
||||
class DirectionFov {
|
||||
/// The center direction in degrees (0-359, where 0 is north)
|
||||
final double centerDegrees;
|
||||
|
||||
/// The field-of-view width in degrees (e.g., 35, 90, 180, 360)
|
||||
final double fovDegrees;
|
||||
|
||||
DirectionFov(this.centerDegrees, this.fovDegrees);
|
||||
|
||||
@override
|
||||
String toString() => 'DirectionFov(center: ${centerDegrees}°, fov: ${fovDegrees}°)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is DirectionFov &&
|
||||
runtimeType == other.runtimeType &&
|
||||
centerDegrees == other.centerDegrees &&
|
||||
fovDegrees == other.fovDegrees;
|
||||
|
||||
@override
|
||||
int get hashCode => centerDegrees.hashCode ^ fovDegrees.hashCode;
|
||||
}
|
||||
@@ -9,7 +9,6 @@ class NodeProfile {
|
||||
final bool requiresDirection;
|
||||
final bool submittable;
|
||||
final bool editable;
|
||||
final double? fov; // Field-of-view in degrees (null means use dev_config default)
|
||||
|
||||
NodeProfile({
|
||||
required this.id,
|
||||
@@ -19,7 +18,6 @@ class NodeProfile {
|
||||
this.requiresDirection = true,
|
||||
this.submittable = true,
|
||||
this.editable = true,
|
||||
this.fov,
|
||||
});
|
||||
|
||||
/// Get all built-in default node profiles
|
||||
@@ -52,7 +50,6 @@ class NodeProfile {
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
fov: 45.0, // Flock cameras typically have narrow FOV
|
||||
),
|
||||
NodeProfile(
|
||||
id: 'builtin-motorola',
|
||||
@@ -70,7 +67,6 @@ class NodeProfile {
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
fov: 60.0, // Motorola cameras typically have moderate FOV
|
||||
),
|
||||
NodeProfile(
|
||||
id: 'builtin-genetec',
|
||||
@@ -88,7 +84,6 @@ class NodeProfile {
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
fov: 50.0, // Genetec cameras typically have moderate FOV
|
||||
),
|
||||
NodeProfile(
|
||||
id: 'builtin-leonardo',
|
||||
@@ -106,7 +101,6 @@ class NodeProfile {
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
fov: 55.0, // Leonardo cameras typically have moderate FOV
|
||||
),
|
||||
NodeProfile(
|
||||
id: 'builtin-neology',
|
||||
@@ -156,7 +150,6 @@ class NodeProfile {
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
fov: 90.0, // Axis cameras can have wider FOV
|
||||
),
|
||||
NodeProfile(
|
||||
id: 'builtin-generic-gunshot',
|
||||
@@ -215,7 +208,6 @@ class NodeProfile {
|
||||
bool? requiresDirection,
|
||||
bool? submittable,
|
||||
bool? editable,
|
||||
double? fov,
|
||||
}) =>
|
||||
NodeProfile(
|
||||
id: id ?? this.id,
|
||||
@@ -225,7 +217,6 @@ class NodeProfile {
|
||||
requiresDirection: requiresDirection ?? this.requiresDirection,
|
||||
submittable: submittable ?? this.submittable,
|
||||
editable: editable ?? this.editable,
|
||||
fov: fov ?? this.fov,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
@@ -236,7 +227,6 @@ class NodeProfile {
|
||||
'requiresDirection': requiresDirection,
|
||||
'submittable': submittable,
|
||||
'editable': editable,
|
||||
'fov': fov,
|
||||
};
|
||||
|
||||
factory NodeProfile.fromJson(Map<String, dynamic> j) => NodeProfile(
|
||||
@@ -247,7 +237,6 @@ class NodeProfile {
|
||||
requiresDirection: j['requiresDirection'] ?? true, // Default to true for backward compatibility
|
||||
submittable: j['submittable'] ?? true, // Default to true for backward compatibility
|
||||
editable: j['editable'] ?? true, // Default to true for backward compatibility
|
||||
fov: j['fov']?.toDouble(), // Can be null for backward compatibility
|
||||
);
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'direction_fov.dart';
|
||||
import '../dev_config.dart';
|
||||
|
||||
class OsmNode {
|
||||
final int id;
|
||||
@@ -38,10 +36,9 @@ class OsmNode {
|
||||
);
|
||||
}
|
||||
|
||||
bool get hasDirection => directionFovPairs.isNotEmpty;
|
||||
bool get hasDirection => directionDeg.isNotEmpty;
|
||||
|
||||
/// Get direction and FOV pairs, supporting range notation like "90-270" or "10-45;90-125;290"
|
||||
List<DirectionFov> get directionFovPairs {
|
||||
List<double> get directionDeg {
|
||||
final raw = tags['direction'] ?? tags['camera:direction'];
|
||||
if (raw == null) return [];
|
||||
|
||||
@@ -53,35 +50,17 @@ class OsmNode {
|
||||
'W': 270.0, 'WNW': 292.5, 'NW': 315.0, 'NNW': 337.5,
|
||||
};
|
||||
|
||||
final directionFovList = <DirectionFov>[];
|
||||
// Split on semicolons and parse each direction
|
||||
final directions = <double>[];
|
||||
final parts = raw.split(';');
|
||||
|
||||
for (final part in parts) {
|
||||
final trimmed = part.trim();
|
||||
final trimmed = part.trim().toUpperCase();
|
||||
if (trimmed.isEmpty) continue;
|
||||
|
||||
// Check if this part contains a range (e.g., "90-270")
|
||||
if (trimmed.contains('-') && RegExp(r'^\d+\.?\d*-\d+\.?\d*$').hasMatch(trimmed)) {
|
||||
final rangeParts = trimmed.split('-');
|
||||
if (rangeParts.length == 2) {
|
||||
final start = double.tryParse(rangeParts[0]);
|
||||
final end = double.tryParse(rangeParts[1]);
|
||||
|
||||
if (start != null && end != null) {
|
||||
final normalized = _calculateRangeCenter(start, end);
|
||||
directionFovList.add(normalized);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Not a range, handle as single direction
|
||||
final trimmedUpper = trimmed.toUpperCase();
|
||||
|
||||
// First try compass direction lookup
|
||||
if (compassDirections.containsKey(trimmedUpper)) {
|
||||
final degrees = compassDirections[trimmedUpper]!;
|
||||
directionFovList.add(DirectionFov(degrees, kDirectionConeHalfAngle * 2));
|
||||
if (compassDirections.containsKey(trimmed)) {
|
||||
directions.add(compassDirections[trimmed]!);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -95,35 +74,9 @@ class OsmNode {
|
||||
|
||||
// Normalize: wrap negative or >360 into 0‑359 range
|
||||
final normalized = ((val % 360) + 360) % 360;
|
||||
directionFovList.add(DirectionFov(normalized, kDirectionConeHalfAngle * 2));
|
||||
directions.add(normalized);
|
||||
}
|
||||
|
||||
return directionFovList;
|
||||
}
|
||||
|
||||
/// Calculate center and width for a range like "90-270" or "270-90"
|
||||
DirectionFov _calculateRangeCenter(double start, double end) {
|
||||
// Normalize start and end to 0-359 range
|
||||
start = ((start % 360) + 360) % 360;
|
||||
end = ((end % 360) + 360) % 360;
|
||||
|
||||
double width, center;
|
||||
|
||||
if (start > end) {
|
||||
// Wrapping case: 270-90
|
||||
width = (end + 360) - start;
|
||||
center = ((start + end + 360) / 2) % 360;
|
||||
} else {
|
||||
// Normal case: 90-270
|
||||
width = end - start;
|
||||
center = (start + end) / 2;
|
||||
}
|
||||
|
||||
return DirectionFov(center, width);
|
||||
}
|
||||
|
||||
/// Legacy getter for backward compatibility - returns just center directions
|
||||
List<double> get directionDeg {
|
||||
return directionFovPairs.map((df) => df.centerDegrees).toList();
|
||||
return directions;
|
||||
}
|
||||
}
|
||||
127
lib/screens/developer_settings_screen.dart
Normal file
127
lib/screens/developer_settings_screen.dart
Normal file
@@ -0,0 +1,127 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../dev_config.dart';
|
||||
|
||||
class DeveloperSettingsScreen extends StatefulWidget {
|
||||
const DeveloperSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<DeveloperSettingsScreen> createState() => _DeveloperSettingsScreenState();
|
||||
}
|
||||
|
||||
class _DeveloperSettingsScreenState extends State<DeveloperSettingsScreen> {
|
||||
final Map<String, TextEditingController> _controllers = {};
|
||||
final Map<String, dynamic> _overrides = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeControllers();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final controller in _controllers.values) {
|
||||
controller.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _initializeControllers() {
|
||||
for (final entry in devConfigForSettings.entries) {
|
||||
if (entry.value is String) {
|
||||
_controllers[entry.key] = TextEditingController(text: entry.value);
|
||||
} else if (entry.value is int) {
|
||||
_controllers[entry.key] = TextEditingController(text: entry.value.toString());
|
||||
} else if (entry.value is double) {
|
||||
_controllers[entry.key] = TextEditingController(text: entry.value.toString());
|
||||
} else if (entry.value is Color) {
|
||||
final color = entry.value as Color;
|
||||
final hex = color.value.toRadixString(16).padLeft(8, '0').toUpperCase();
|
||||
_controllers[entry.key] = TextEditingController(text: hex);
|
||||
} else if (entry.value is Duration) {
|
||||
final duration = entry.value as Duration;
|
||||
_controllers[entry.key] = TextEditingController(text: duration.inMilliseconds.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _saveAndRestart() {
|
||||
// For now, just show a dialog - actual restart would require platform channels
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Restart Required'),
|
||||
content: const Text('Changes saved. Please restart the app to apply new settings.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSettingWidget(String key, dynamic defaultValue) {
|
||||
if (key == 'kClientName') {
|
||||
// Special read-only case
|
||||
return ListTile(
|
||||
title: Text(key),
|
||||
subtitle: Text(defaultValue.toString()),
|
||||
trailing: const Text('READ ONLY'),
|
||||
);
|
||||
}
|
||||
|
||||
if (defaultValue is bool) {
|
||||
return SwitchListTile(
|
||||
title: Text(key),
|
||||
value: _overrides[key] ?? defaultValue,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_overrides[key] = value;
|
||||
});
|
||||
},
|
||||
);
|
||||
} else if (defaultValue is int || defaultValue is double || defaultValue is String ||
|
||||
defaultValue is Color || defaultValue is Duration) {
|
||||
return ListTile(
|
||||
title: Text(key),
|
||||
subtitle: TextField(
|
||||
controller: _controllers[key],
|
||||
keyboardType: defaultValue is int || defaultValue is double
|
||||
? const TextInputType.numberWithOptions(signed: true, decimal: true)
|
||||
: TextInputType.text,
|
||||
textInputAction: TextInputAction.done,
|
||||
onChanged: (value) {
|
||||
// Store the string value for now - actual parsing would happen on save
|
||||
_overrides[key] = value;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Developer Settings'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _saveAndRestart,
|
||||
child: const Text('SAVE', style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: devConfigForSettings.entries
|
||||
.map((entry) => _buildSettingWidget(entry.key, entry.value))
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -103,21 +103,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
|
||||
void _openAddNodeSheet() {
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
// Check minimum zoom level before opening sheet
|
||||
final currentZoom = _mapController.mapController.camera.zoom;
|
||||
if (currentZoom < kMinZoomForNodeEditingSheets) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
LocalizationService.instance.t('editNode.zoomInRequiredMessage',
|
||||
params: [kMinZoomForNodeEditingSheets.toString()])
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable follow-me when adding a camera so the map doesn't jump around
|
||||
appState.setFollowMeMode(FollowMeMode.off);
|
||||
|
||||
@@ -547,20 +532,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
child: NodeTagSheet(
|
||||
node: node,
|
||||
onEditPressed: () {
|
||||
// Check minimum zoom level before starting edit session
|
||||
final currentZoom = _mapController.mapController.camera.zoom;
|
||||
if (currentZoom < kMinZoomForNodeEditingSheets) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
LocalizationService.instance.t('editNode.zoomInRequiredMessage',
|
||||
params: [kMinZoomForNodeEditingSheets.toString()])
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final appState = context.read<AppState>();
|
||||
appState.startEditSession(node);
|
||||
// This will trigger _openEditNodeSheet via the existing auto-show logic
|
||||
@@ -742,7 +713,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
final safeArea = MediaQuery.of(context).padding;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: safeArea.bottom + kBottomButtonBarOffset,
|
||||
bottom: safeArea.bottom + dev.kBottomButtonBarOffset,
|
||||
left: leftPositionWithSafeArea(8, safeArea),
|
||||
right: rightPositionWithSafeArea(8, safeArea),
|
||||
),
|
||||
@@ -760,7 +731,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
)
|
||||
],
|
||||
),
|
||||
margin: EdgeInsets.only(bottom: kBottomButtonBarOffset),
|
||||
margin: EdgeInsets.only(bottom: dev.kBottomButtonBarOffset),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -789,26 +760,10 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
child: ElevatedButton.icon(
|
||||
icon: Icon(Icons.download_for_offline),
|
||||
label: Text(LocalizationService.instance.download),
|
||||
onPressed: () {
|
||||
// Check minimum zoom level before opening download dialog
|
||||
final currentZoom = _mapController.mapController.camera.zoom;
|
||||
if (currentZoom < kMinZoomForOfflineDownload) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
LocalizationService.instance.t('download.areaTooBigMessage',
|
||||
params: [kMinZoomForOfflineDownload.toString()])
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
|
||||
);
|
||||
},
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: Size(0, 48),
|
||||
textStyle: TextStyle(fontSize: 16),
|
||||
|
||||
@@ -117,7 +117,7 @@ class OSMAccountScreen extends StatelessWidget {
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Upload Mode Section (only show in development builds)
|
||||
if (kEnableDevelopmentModes) ...[
|
||||
if (dev.kEnableDevelopmentModes) ...[
|
||||
Card(
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
|
||||
@@ -20,7 +20,6 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
late List<MapEntry<String, String>> _tags;
|
||||
late bool _requiresDirection;
|
||||
late bool _submittable;
|
||||
late TextEditingController _fovCtrl;
|
||||
|
||||
static const _defaultTags = [
|
||||
MapEntry('man_made', 'surveillance'),
|
||||
@@ -39,7 +38,6 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
_nameCtrl = TextEditingController(text: widget.profile.name);
|
||||
_requiresDirection = widget.profile.requiresDirection;
|
||||
_submittable = widget.profile.submittable;
|
||||
_fovCtrl = TextEditingController(text: widget.profile.fov?.toString() ?? '');
|
||||
|
||||
if (widget.profile.tags.isEmpty) {
|
||||
// New profile → start with sensible defaults
|
||||
@@ -52,7 +50,6 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
@override
|
||||
void dispose() {
|
||||
_nameCtrl.dispose();
|
||||
_fovCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -94,21 +91,6 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
onChanged: (value) => setState(() => _requiresDirection = value ?? true),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
if (_requiresDirection) Padding(
|
||||
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8),
|
||||
child: TextField(
|
||||
controller: _fovCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: locService.t('profileEditor.fov'),
|
||||
hintText: locService.t('profileEditor.fovHint'),
|
||||
helperText: locService.t('profileEditor.fovSubtitle'),
|
||||
errorText: _validateFov(),
|
||||
suffixText: '°',
|
||||
),
|
||||
onChanged: (value) => setState(() {}), // Trigger validation
|
||||
),
|
||||
),
|
||||
CheckboxListTile(
|
||||
title: Text(locService.t('profileEditor.submittable')),
|
||||
subtitle: Text(locService.t('profileEditor.submittableSubtitle')),
|
||||
@@ -199,17 +181,6 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
});
|
||||
}
|
||||
|
||||
String? _validateFov() {
|
||||
final text = _fovCtrl.text.trim();
|
||||
if (text.isEmpty) return null; // Optional field
|
||||
|
||||
final fov = double.tryParse(text);
|
||||
if (fov == null || fov <= 0 || fov > 360) {
|
||||
return LocalizationService.instance.t('profileEditor.fovInvalid');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _save() {
|
||||
final locService = LocalizationService.instance;
|
||||
final name = _nameCtrl.text.trim();
|
||||
@@ -219,15 +190,6 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
.showSnackBar(SnackBar(content: Text(locService.t('profileEditor.profileNameRequired'))));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate FOV if provided
|
||||
if (_validateFov() != null) {
|
||||
return; // Don't save if FOV validation fails
|
||||
}
|
||||
|
||||
// Parse FOV
|
||||
final fovText = _fovCtrl.text.trim();
|
||||
final fov = fovText.isEmpty ? null : double.tryParse(fovText);
|
||||
|
||||
final tagMap = <String, String>{};
|
||||
for (final e in _tags) {
|
||||
@@ -249,7 +211,6 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
requiresDirection: _requiresDirection,
|
||||
submittable: _submittable,
|
||||
editable: true, // All custom profiles are editable by definition
|
||||
fov: fov,
|
||||
);
|
||||
|
||||
context.read<AppState>().addOrUpdateProfile(newProfile);
|
||||
|
||||
@@ -1,11 +1,41 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../services/version_service.dart';
|
||||
import '../dev_config.dart';
|
||||
|
||||
class SettingsScreen extends StatelessWidget {
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SettingsScreen> createState() => _SettingsScreenState();
|
||||
}
|
||||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
int _versionTapCount = 0;
|
||||
Timer? _tapTimer;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tapTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onVersionTap() {
|
||||
_tapTimer?.cancel();
|
||||
_versionTapCount++;
|
||||
|
||||
if (_versionTapCount >= 10) {
|
||||
Navigator.pushNamed(context, '/settings/developer');
|
||||
_versionTapCount = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
_tapTimer = Timer(const Duration(milliseconds: 400), () {
|
||||
_versionTapCount = 0;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final locService = LocalizationService.instance;
|
||||
@@ -100,15 +130,18 @@ class SettingsScreen extends StatelessWidget {
|
||||
),
|
||||
const Divider(),
|
||||
|
||||
// Version display
|
||||
// Version display with secret tap counter
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text(
|
||||
'Version: ${VersionService().version}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.6),
|
||||
child: GestureDetector(
|
||||
onTap: _onVersionTap,
|
||||
child: Text(
|
||||
'Version: ${VersionService().version}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.6),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -345,7 +345,7 @@ class _TileTypeDialogState extends State<_TileTypeDialog> {
|
||||
if (value?.trim().isEmpty == true) return locService.t('tileTypeEditor.maxZoomRequired');
|
||||
final zoom = int.tryParse(value!);
|
||||
if (zoom == null) return locService.t('tileTypeEditor.maxZoomInvalid');
|
||||
if (zoom < 1 || zoom > kAbsoluteMaxZoom) return locService.t('tileTypeEditor.maxZoomRange', params: ['1', kAbsoluteMaxZoom.toString()]);
|
||||
if (zoom < 1 || zoom > dev.kAbsoluteMaxZoom) return locService.t('tileTypeEditor.maxZoomRange', params: ['1', kAbsoluteMaxZoom.toString()]);
|
||||
return null;
|
||||
},
|
||||
),
|
||||
@@ -405,9 +405,9 @@ class _TileTypeDialogState extends State<_TileTypeDialog> {
|
||||
try {
|
||||
// Use a sample tile from configured preview location
|
||||
final url = _urlController.text
|
||||
.replaceAll('{z}', kPreviewTileZoom.toString())
|
||||
.replaceAll('{x}', kPreviewTileX.toString())
|
||||
.replaceAll('{y}', kPreviewTileY.toString());
|
||||
.replaceAll('{z}', dev.kPreviewTileZoom.toString())
|
||||
.replaceAll('{x}', dev.kPreviewTileX.toString())
|
||||
.replaceAll('{y}', dev.kPreviewTileY.toString());
|
||||
|
||||
final response = await http.get(Uri.parse(url));
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ Future<List<OsmNode>> _fetchOverpassNodesWithSplitting({
|
||||
}) async {
|
||||
if (profiles.isEmpty) return [];
|
||||
|
||||
const int maxSplitDepth = kMaxPreFetchSplitDepth; // Maximum times we'll split (4^3 = 64 max sub-areas)
|
||||
final int maxSplitDepth = dev.dev.kMaxPreFetchSplitDepth; // Maximum times we'll split (4^3 = 64 max sub-areas)
|
||||
|
||||
try {
|
||||
return await _fetchSingleOverpassQuery(
|
||||
|
||||
@@ -36,14 +36,14 @@ void clearRemoteTileQueueSelective(LatLngBounds currentBounds) {
|
||||
/// Uses: initialDelay * (multiplier ^ (attempt - 1)) + randomJitter, capped at maxDelay
|
||||
int _calculateRetryDelay(int attempt, Random random) {
|
||||
// Calculate exponential backoff: initialDelay * (multiplier ^ (attempt - 1))
|
||||
final baseDelay = (kTileFetchInitialDelayMs *
|
||||
pow(kTileFetchBackoffMultiplier, attempt - 1)).round();
|
||||
final baseDelay = (dev.kTileFetchInitialDelayMs *
|
||||
pow(dev.kTileFetchBackoffMultiplier, attempt - 1)).round();
|
||||
|
||||
// Add random jitter to avoid thundering herd
|
||||
final jitter = random.nextInt(kTileFetchRandomJitterMs + 1);
|
||||
final jitter = random.nextInt(dev.kTileFetchRandomJitterMs + 1);
|
||||
|
||||
// Apply max delay cap
|
||||
return (baseDelay + jitter).clamp(0, kTileFetchMaxDelayMs);
|
||||
return (baseDelay + jitter).clamp(0, dev.kTileFetchMaxDelayMs);
|
||||
}
|
||||
|
||||
/// Convert tile coordinates to lat/lng bounds for spatial filtering
|
||||
@@ -101,7 +101,7 @@ Future<List<int>> fetchRemoteTile({
|
||||
required int y,
|
||||
required String url,
|
||||
}) async {
|
||||
const int maxAttempts = kTileFetchMaxAttempts;
|
||||
final int maxAttempts = dev.dev.kTileFetchMaxAttempts;
|
||||
int attempt = 0;
|
||||
final random = Random();
|
||||
final hostInfo = Uri.parse(url).host; // For logging
|
||||
|
||||
@@ -2,8 +2,6 @@ import 'package:latlong2/latlong.dart';
|
||||
import '../models/osm_node.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
|
||||
const Distance _distance = Distance();
|
||||
|
||||
class NodeCache {
|
||||
// Singleton instance
|
||||
static final NodeCache instance = NodeCache._internal();
|
||||
@@ -105,34 +103,6 @@ class NodeCache {
|
||||
(coord1.longitude - coord2.longitude).abs() < tolerance;
|
||||
}
|
||||
|
||||
/// Find nodes within the specified distance (in meters) of the given coordinate
|
||||
/// Excludes nodes with the excludeNodeId (useful when checking proximity for edited nodes)
|
||||
List<OsmNode> findNodesWithinDistance(LatLng coord, double distanceMeters, {int? excludeNodeId}) {
|
||||
final nearbyNodes = <OsmNode>[];
|
||||
|
||||
for (final node in _nodes.values) {
|
||||
// Skip the excluded node (typically the node being edited)
|
||||
if (excludeNodeId != null && node.id == excludeNodeId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip temporary nodes (negative IDs) with pending upload/edit/deletion markers
|
||||
if (node.id < 0 && (
|
||||
node.tags.containsKey('_pending_upload') ||
|
||||
node.tags.containsKey('_pending_edit') ||
|
||||
node.tags.containsKey('_pending_deletion'))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final distance = _distance.as(LengthUnit.Meter, coord, node.coord);
|
||||
if (distance <= distanceMeters) {
|
||||
nearbyNodes.add(node);
|
||||
}
|
||||
}
|
||||
|
||||
return nearbyNodes;
|
||||
}
|
||||
|
||||
/// Utility: point-in-bounds for coordinates
|
||||
bool _inBounds(LatLng coord, LatLngBounds bounds) {
|
||||
return coord.latitude >= bounds.southWest.latitude &&
|
||||
|
||||
@@ -30,8 +30,8 @@ class PrefetchAreaService {
|
||||
Timer? _debounceTimer;
|
||||
|
||||
// Configuration from dev_config
|
||||
static const double _areaExpansionMultiplier = kPreFetchAreaExpansionMultiplier;
|
||||
static const int _preFetchZoomLevel = kPreFetchZoomLevel;
|
||||
static final double _areaExpansionMultiplier = dev.dev.kPreFetchAreaExpansionMultiplier;
|
||||
static final int _preFetchZoomLevel = dev.dev.kPreFetchZoomLevel;
|
||||
|
||||
/// Check if the given bounds are fully within the current pre-fetched area.
|
||||
bool isWithinPreFetchedArea(LatLngBounds bounds, List<NodeProfile> profiles, UploadMode uploadMode) {
|
||||
@@ -58,7 +58,7 @@ class PrefetchAreaService {
|
||||
/// Check if cached data is stale (older than configured refresh interval).
|
||||
bool isDataStale() {
|
||||
if (_lastFetchTime == null) return true;
|
||||
return DateTime.now().difference(_lastFetchTime!).inSeconds > kDataRefreshIntervalSeconds;
|
||||
return DateTime.now().difference(_lastFetchTime!).inSeconds > dev.kDataRefreshIntervalSeconds;
|
||||
}
|
||||
|
||||
/// Request pre-fetch for the given view bounds if not already covered or if data is stale.
|
||||
@@ -84,7 +84,7 @@ class PrefetchAreaService {
|
||||
}
|
||||
|
||||
if (isStale) {
|
||||
debugPrint('[PrefetchAreaService] Data is stale (>${kDataRefreshIntervalSeconds}s), refreshing');
|
||||
debugPrint('[PrefetchAreaService] Data is stale (>${dev.kDataRefreshIntervalSeconds}s), refreshing');
|
||||
} else {
|
||||
debugPrint('[PrefetchAreaService] Current view outside pre-fetched area, fetching larger area');
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ class ProximityAlertService {
|
||||
|
||||
// Simple in-memory tracking of recent alerts to prevent spam
|
||||
final List<RecentAlert> _recentAlerts = [];
|
||||
static const Duration _alertCooldown = kProximityAlertCooldown;
|
||||
static final Duration _alertCooldown = dev.dev.kProximityAlertCooldown;
|
||||
|
||||
// Callback for showing in-app visual alerts
|
||||
VoidCallback? _onVisualAlert;
|
||||
|
||||
@@ -102,10 +102,10 @@ class SuspectedLocationService {
|
||||
/// Fetch data from the CSV URL
|
||||
Future<bool> _fetchData() async {
|
||||
try {
|
||||
debugPrint('[SuspectedLocationService] Fetching CSV data from $kSuspectedLocationsCsvUrl');
|
||||
debugPrint('[SuspectedLocationService] Fetching CSV data from $dev.kSuspectedLocationsCsvUrl');
|
||||
|
||||
final response = await http.get(
|
||||
Uri.parse(kSuspectedLocationsCsvUrl),
|
||||
Uri.parse(dev.kSuspectedLocationsCsvUrl),
|
||||
headers: {
|
||||
'User-Agent': 'DeFlock/1.0 (OSM surveillance mapping app)',
|
||||
},
|
||||
|
||||
@@ -61,7 +61,7 @@ class TilePreviewService {
|
||||
|
||||
static Future<Uint8List?> _fetchPreviewForTileType(TileType tileType, String? apiKey) async {
|
||||
try {
|
||||
final url = tileType.getTileUrl(kPreviewTileZoom, kPreviewTileX, kPreviewTileY, apiKey: apiKey);
|
||||
final url = tileType.getTileUrl(dev.kPreviewTileZoom, dev.kPreviewTileX, dev.kPreviewTileY, apiKey: apiKey);
|
||||
|
||||
final response = await http.get(Uri.parse(url)).timeout(_timeout);
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ class Uploader {
|
||||
final csXml = '''
|
||||
<osm>
|
||||
<changeset>
|
||||
<tag k="created_by" v="$kClientName ${VersionService().version}"/>
|
||||
<tag k="created_by" v="$dev.kClientName ${VersionService().version}"/>
|
||||
<tag k="comment" v="$action $profileName surveillance node"/>
|
||||
</changeset>
|
||||
</osm>''';
|
||||
|
||||
@@ -33,10 +33,10 @@ class SettingsState extends ChangeNotifier {
|
||||
bool _offlineMode = false;
|
||||
bool _pauseQueueProcessing = false;
|
||||
int _maxCameras = 250;
|
||||
UploadMode _uploadMode = kEnableDevelopmentModes ? UploadMode.simulate : UploadMode.production;
|
||||
UploadMode _uploadMode = dev.kEnableDevelopmentModes ? UploadMode.simulate : UploadMode.production;
|
||||
FollowMeMode _followMeMode = FollowMeMode.follow;
|
||||
bool _proximityAlertsEnabled = false;
|
||||
int _proximityAlertDistance = kProximityAlertDefaultDistance;
|
||||
int _proximityAlertDistance = dev.kProximityAlertDefaultDistance;
|
||||
bool _networkStatusIndicatorEnabled = true;
|
||||
int _suspectedLocationMinDistance = 100; // meters
|
||||
List<TileProvider> _tileProviders = [];
|
||||
@@ -105,7 +105,7 @@ class SettingsState extends ChangeNotifier {
|
||||
|
||||
// Load proximity alerts settings
|
||||
_proximityAlertsEnabled = prefs.getBool(_proximityAlertsEnabledPrefsKey) ?? false;
|
||||
_proximityAlertDistance = prefs.getInt(_proximityAlertDistancePrefsKey) ?? kProximityAlertDefaultDistance;
|
||||
_proximityAlertDistance = prefs.getInt(_proximityAlertDistancePrefsKey) ?? dev.kProximityAlertDefaultDistance;
|
||||
|
||||
// Load network status indicator setting
|
||||
_networkStatusIndicatorEnabled = prefs.getBool(_networkStatusIndicatorEnabledPrefsKey) ?? true;
|
||||
@@ -128,7 +128,7 @@ class SettingsState extends ChangeNotifier {
|
||||
}
|
||||
|
||||
// In production builds, force production mode if development modes are disabled
|
||||
if (!kEnableDevelopmentModes && _uploadMode != UploadMode.production) {
|
||||
if (!dev.kEnableDevelopmentModes && _uploadMode != UploadMode.production) {
|
||||
debugPrint('SettingsState: Development modes disabled, forcing production mode');
|
||||
_uploadMode = UploadMode.production;
|
||||
await prefs.setInt(_uploadModePrefsKey, _uploadMode.index);
|
||||
@@ -236,7 +236,7 @@ class SettingsState extends ChangeNotifier {
|
||||
|
||||
Future<void> setUploadMode(UploadMode mode) async {
|
||||
// In production builds, only allow production mode
|
||||
if (!kEnableDevelopmentModes && mode != UploadMode.production) {
|
||||
if (!dev.kEnableDevelopmentModes && mode != UploadMode.production) {
|
||||
debugPrint('SettingsState: Development modes disabled, forcing production mode');
|
||||
mode = UploadMode.production;
|
||||
}
|
||||
@@ -323,8 +323,8 @@ class SettingsState extends ChangeNotifier {
|
||||
|
||||
/// Set proximity alert distance in meters
|
||||
Future<void> setProximityAlertDistance(int distance) async {
|
||||
if (distance < kProximityAlertMinDistance) distance = kProximityAlertMinDistance;
|
||||
if (distance > kProximityAlertMaxDistance) distance = kProximityAlertMaxDistance;
|
||||
if (distance < dev.kProximityAlertMinDistance) distance = dev.kProximityAlertMinDistance;
|
||||
if (distance > dev.kProximityAlertMaxDistance) distance = dev.kProximityAlertMaxDistance;
|
||||
if (_proximityAlertDistance != distance) {
|
||||
_proximityAlertDistance = distance;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
@@ -30,7 +30,7 @@ class UploadQueueState extends ChangeNotifier {
|
||||
void addFromSession(AddNodeSession session, {required UploadMode uploadMode}) {
|
||||
final upload = PendingUpload(
|
||||
coord: session.target!,
|
||||
direction: _formatDirectionsForSubmission(session.directions, session.profile),
|
||||
direction: _formatDirectionsAsString(session.directions),
|
||||
profile: session.profile!, // Safe to use ! because commitSession() checks for null
|
||||
operatorProfile: session.operatorProfile,
|
||||
uploadMode: uploadMode,
|
||||
@@ -82,7 +82,7 @@ class UploadQueueState extends ChangeNotifier {
|
||||
|
||||
final upload = PendingUpload(
|
||||
coord: coordToUse,
|
||||
direction: _formatDirectionsForSubmission(session.directions, session.profile),
|
||||
direction: _formatDirectionsAsString(session.directions),
|
||||
profile: session.profile!, // Safe to use ! because commitEditSession() checks for null
|
||||
operatorProfile: session.operatorProfile,
|
||||
uploadMode: uploadMode,
|
||||
@@ -330,33 +330,13 @@ class UploadQueueState extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to format multiple directions for submission, supporting profile FOV
|
||||
dynamic _formatDirectionsForSubmission(List<double> directions, NodeProfile? profile) {
|
||||
// Helper method to format multiple directions as a string or number
|
||||
dynamic _formatDirectionsAsString(List<double> directions) {
|
||||
if (directions.isEmpty) return 0.0;
|
||||
|
||||
// If profile has FOV, convert center directions to range notation
|
||||
if (profile?.fov != null && profile!.fov! > 0) {
|
||||
final ranges = directions.map((center) =>
|
||||
_formatDirectionWithFov(center, profile.fov!)
|
||||
).toList();
|
||||
|
||||
return ranges.length == 1 ? ranges.first : ranges.join(';');
|
||||
}
|
||||
|
||||
// No profile FOV: use original format (single number or semicolon-separated)
|
||||
if (directions.length == 1) return directions.first;
|
||||
return directions.map((d) => d.round().toString()).join(';');
|
||||
}
|
||||
|
||||
// Convert a center direction and FOV to range notation (e.g., 180° center with 90° FOV -> "135-225")
|
||||
String _formatDirectionWithFov(double center, double fov) {
|
||||
final halfFov = fov / 2;
|
||||
final start = (center - halfFov + 360) % 360;
|
||||
final end = (center + halfFov) % 360;
|
||||
|
||||
return '${start.round()}-${end.round()}';
|
||||
}
|
||||
|
||||
// ---------- Queue persistence ----------
|
||||
Future<void> _saveQueue() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
@@ -6,58 +6,13 @@ import '../dev_config.dart';
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/operator_profile.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../services/node_cache.dart';
|
||||
import 'refine_tags_sheet.dart';
|
||||
import 'proximity_warning_dialog.dart';
|
||||
|
||||
class AddNodeSheet extends StatelessWidget {
|
||||
const AddNodeSheet({super.key, required this.session});
|
||||
|
||||
final AddNodeSession session;
|
||||
|
||||
void _checkProximityAndCommit(BuildContext context, AppState appState, LocalizationService locService) {
|
||||
// Only check proximity if we have a target location
|
||||
if (session.target == null) {
|
||||
_commitWithoutCheck(context, appState, locService);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for nearby nodes within the configured distance
|
||||
final nearbyNodes = NodeCache.instance.findNodesWithinDistance(
|
||||
session.target!,
|
||||
kNodeProximityWarningDistance,
|
||||
);
|
||||
|
||||
if (nearbyNodes.isNotEmpty) {
|
||||
// Show proximity warning dialog
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => ProximityWarningDialog(
|
||||
nearbyNodes: nearbyNodes,
|
||||
distance: kNodeProximityWarningDistance,
|
||||
onGoBack: () {
|
||||
Navigator.of(context).pop(); // Close dialog
|
||||
},
|
||||
onSubmitAnyway: () {
|
||||
Navigator.of(context).pop(); // Close dialog
|
||||
_commitWithoutCheck(context, appState, locService);
|
||||
},
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// No nearby nodes, proceed with commit
|
||||
_commitWithoutCheck(context, appState, locService);
|
||||
}
|
||||
}
|
||||
|
||||
void _commitWithoutCheck(BuildContext context, AppState appState, LocalizationService locService) {
|
||||
appState.commitSession();
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(locService.t('node.queuedForUpload'))),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDirectionControls(BuildContext context, AppState appState, AddNodeSession session, LocalizationService locService) {
|
||||
final requiresDirection = session.profile != null && session.profile!.requiresDirection;
|
||||
|
||||
@@ -126,7 +81,7 @@ class AddNodeSheet extends StatelessWidget {
|
||||
: null,
|
||||
tooltip: requiresDirection ? 'Remove current direction' : 'Direction not required for this profile',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
|
||||
constraints: BoxConstraints(minWidth: dev.kDirectionButtonMinWidth, minHeight: dev.kDirectionButtonMinHeight),
|
||||
),
|
||||
// Add button
|
||||
IconButton(
|
||||
@@ -140,7 +95,7 @@ class AddNodeSheet extends StatelessWidget {
|
||||
? (session.directions.length >= 8 ? 'Maximum 8 directions allowed' : 'Add new direction')
|
||||
: 'Direction not required for this profile',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
|
||||
constraints: BoxConstraints(minWidth: dev.kDirectionButtonMinWidth, minHeight: dev.kDirectionButtonMinHeight),
|
||||
),
|
||||
// Cycle button
|
||||
IconButton(
|
||||
@@ -154,7 +109,7 @@ class AddNodeSheet extends StatelessWidget {
|
||||
: null,
|
||||
tooltip: requiresDirection ? 'Cycle through directions' : 'Direction not required for this profile',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
|
||||
constraints: BoxConstraints(minWidth: dev.kDirectionButtonMinWidth, minHeight: dev.kDirectionButtonMinHeight),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -189,7 +144,11 @@ class AddNodeSheet extends StatelessWidget {
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
void _commit() {
|
||||
_checkProximityAndCommit(context, appState, locService);
|
||||
appState.commitSession();
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(locService.t('node.queuedForUpload'))),
|
||||
);
|
||||
}
|
||||
|
||||
void _cancel() {
|
||||
|
||||
@@ -19,28 +19,28 @@ class CameraIcon extends StatelessWidget {
|
||||
Color get _ringColor {
|
||||
switch (type) {
|
||||
case CameraIconType.real:
|
||||
return kNodeRingColorReal;
|
||||
return dev.kNodeRingColorReal;
|
||||
case CameraIconType.mock:
|
||||
return kNodeRingColorMock;
|
||||
return dev.kNodeRingColorMock;
|
||||
case CameraIconType.pending:
|
||||
return kNodeRingColorPending;
|
||||
return dev.kNodeRingColorPending;
|
||||
case CameraIconType.editing:
|
||||
return kNodeRingColorEditing;
|
||||
return dev.kNodeRingColorEditing;
|
||||
case CameraIconType.pendingEdit:
|
||||
return kNodeRingColorPendingEdit;
|
||||
return dev.kNodeRingColorPendingEdit;
|
||||
case CameraIconType.pendingDeletion:
|
||||
return kNodeRingColorPendingDeletion;
|
||||
return dev.kNodeRingColorPendingDeletion;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: kNodeIconDiameter,
|
||||
height: kNodeIconDiameter,
|
||||
width: dev.kNodeIconDiameter,
|
||||
height: dev.kNodeIconDiameter,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: _ringColor.withOpacity(kNodeDotOpacity),
|
||||
color: _ringColor.withOpacity(dev.kNodeDotOpacity),
|
||||
border: Border.all(
|
||||
color: _ringColor,
|
||||
width: getNodeRingThickness(context),
|
||||
|
||||
@@ -9,7 +9,6 @@ import '../dev_config.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../services/offline_area_service.dart';
|
||||
import '../services/offline_areas/offline_tile_utils.dart';
|
||||
import 'download_started_dialog.dart';
|
||||
|
||||
class DownloadAreaDialog extends StatefulWidget {
|
||||
final MapController controller;
|
||||
@@ -77,14 +76,14 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
|
||||
|
||||
/// 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++) {
|
||||
for (int zoom = minZoom; zoom <= dev.kAbsoluteMaxZoom; zoom++) {
|
||||
final tileCount = computeTileList(bounds, minZoom, zoom).length;
|
||||
if (tileCount > kAbsoluteMaxTileCount) {
|
||||
if (tileCount > dev.kAbsoluteMaxTileCount) {
|
||||
// Return the previous zoom level that was still under the absolute limit
|
||||
return math.max(minZoom, zoom - 1);
|
||||
}
|
||||
}
|
||||
return kAbsoluteMaxZoom;
|
||||
return dev.kAbsoluteMaxZoom;
|
||||
}
|
||||
|
||||
/// Get tile size estimate in KB, using preview tile data if available, otherwise fallback to constant
|
||||
@@ -99,7 +98,7 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
|
||||
return previewSizeKb;
|
||||
} else {
|
||||
// Fall back to configured estimate
|
||||
return kFallbackTileEstimateKb;
|
||||
return dev.kFallbackTileEstimateKb;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,7 +176,7 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: _tileCount! > kMaxReasonableTileCount
|
||||
color: _tileCount! > dev.kMaxReasonableTileCount
|
||||
? Colors.orange.withOpacity(0.1)
|
||||
: Colors.green.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
@@ -186,12 +185,12 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_tileCount! > kMaxReasonableTileCount
|
||||
_tileCount! > dev.kMaxReasonableTileCount
|
||||
? 'Above recommended limit (Z${_maxPossibleZoom})'
|
||||
: locService.t('download.maxRecommendedZoom', params: [_maxPossibleZoom.toString()]),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _tileCount! > kMaxReasonableTileCount
|
||||
color: _tileCount! > dev.kMaxReasonableTileCount
|
||||
? Colors.orange[700]
|
||||
: Colors.green[700],
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -199,12 +198,12 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_tileCount! > kMaxReasonableTileCount
|
||||
? 'Current selection exceeds ${kMaxReasonableTileCount} recommended tile limit but is within ${kAbsoluteMaxTileCount} absolute limit'
|
||||
_tileCount! > dev.kMaxReasonableTileCount
|
||||
? 'Current selection exceeds ${dev.kMaxReasonableTileCount} recommended tile limit but is within ${dev.kAbsoluteMaxTileCount} absolute limit'
|
||||
: locService.t('download.withinTileLimit', params: [kMaxReasonableTileCount.toString()]),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: _tileCount! > kMaxReasonableTileCount
|
||||
color: _tileCount! > dev.kMaxReasonableTileCount
|
||||
? Colors.orange[600]
|
||||
: Colors.green[600],
|
||||
),
|
||||
@@ -276,29 +275,16 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
|
||||
tileTypeName: selectedTileType?.name,
|
||||
);
|
||||
Navigator.pop(context);
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => const DownloadStartedDialog(),
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(locService.t('download.downloadStarted')),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
Navigator.pop(context);
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
const Icon(Icons.error, color: Colors.red),
|
||||
const SizedBox(width: 10),
|
||||
Text(locService.t('download.title')),
|
||||
],
|
||||
),
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(locService.t('download.downloadFailed', params: [e.toString()])),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(locService.t('actions.ok')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
class DownloadStartedDialog extends StatelessWidget {
|
||||
const DownloadStartedDialog({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
const Icon(Icons.download_for_offline, color: Colors.green),
|
||||
const SizedBox(width: 10),
|
||||
Text(locService.t('downloadStarted.title')),
|
||||
],
|
||||
),
|
||||
content: Text(locService.t('downloadStarted.message')),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(locService.t('downloadStarted.ok')),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.pushNamed(context, '/settings/offline');
|
||||
},
|
||||
child: Text(locService.t('downloadStarted.viewProgress')),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,55 +6,15 @@ import '../dev_config.dart';
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/operator_profile.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../services/node_cache.dart';
|
||||
import '../state/settings_state.dart';
|
||||
import 'refine_tags_sheet.dart';
|
||||
import 'advanced_edit_options_sheet.dart';
|
||||
import 'proximity_warning_dialog.dart';
|
||||
|
||||
class EditNodeSheet extends StatelessWidget {
|
||||
const EditNodeSheet({super.key, required this.session});
|
||||
|
||||
final EditNodeSession session;
|
||||
|
||||
void _checkProximityAndCommit(BuildContext context, AppState appState, LocalizationService locService) {
|
||||
// Check for nearby nodes within the configured distance, excluding the node being edited
|
||||
final nearbyNodes = NodeCache.instance.findNodesWithinDistance(
|
||||
session.target,
|
||||
kNodeProximityWarningDistance,
|
||||
excludeNodeId: session.originalNode.id,
|
||||
);
|
||||
|
||||
if (nearbyNodes.isNotEmpty) {
|
||||
// Show proximity warning dialog
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => ProximityWarningDialog(
|
||||
nearbyNodes: nearbyNodes,
|
||||
distance: kNodeProximityWarningDistance,
|
||||
onGoBack: () {
|
||||
Navigator.of(context).pop(); // Close dialog
|
||||
},
|
||||
onSubmitAnyway: () {
|
||||
Navigator.of(context).pop(); // Close dialog
|
||||
_commitWithoutCheck(context, appState, locService);
|
||||
},
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// No nearby nodes, proceed with commit
|
||||
_commitWithoutCheck(context, appState, locService);
|
||||
}
|
||||
}
|
||||
|
||||
void _commitWithoutCheck(BuildContext context, AppState appState, LocalizationService locService) {
|
||||
appState.commitEditSession();
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(locService.t('node.editQueuedForUpload'))),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDirectionControls(BuildContext context, AppState appState, EditNodeSession session, LocalizationService locService) {
|
||||
final requiresDirection = session.profile != null && session.profile!.requiresDirection;
|
||||
|
||||
@@ -123,7 +83,7 @@ class EditNodeSheet extends StatelessWidget {
|
||||
: null,
|
||||
tooltip: requiresDirection ? 'Remove current direction' : 'Direction not required for this profile',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
|
||||
constraints: BoxConstraints(minWidth: dev.kDirectionButtonMinWidth, minHeight: dev.kDirectionButtonMinHeight),
|
||||
),
|
||||
// Add button
|
||||
IconButton(
|
||||
@@ -137,7 +97,7 @@ class EditNodeSheet extends StatelessWidget {
|
||||
? (session.directions.length >= 8 ? 'Maximum 8 directions allowed' : 'Add new direction')
|
||||
: 'Direction not required for this profile',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
|
||||
constraints: BoxConstraints(minWidth: dev.kDirectionButtonMinWidth, minHeight: dev.kDirectionButtonMinHeight),
|
||||
),
|
||||
// Cycle button
|
||||
IconButton(
|
||||
@@ -151,7 +111,7 @@ class EditNodeSheet extends StatelessWidget {
|
||||
: null,
|
||||
tooltip: requiresDirection ? 'Cycle through directions' : 'Direction not required for this profile',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
|
||||
constraints: BoxConstraints(minWidth: dev.kDirectionButtonMinWidth, minHeight: dev.kDirectionButtonMinHeight),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -186,7 +146,11 @@ class EditNodeSheet extends StatelessWidget {
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
void _commit() {
|
||||
_checkProximityAndCommit(context, appState, locService);
|
||||
appState.commitEditSession();
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(locService.t('node.editQueuedForUpload'))),
|
||||
);
|
||||
}
|
||||
|
||||
void _cancel() {
|
||||
@@ -196,7 +160,7 @@ class EditNodeSheet extends StatelessWidget {
|
||||
|
||||
final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList();
|
||||
final isSandboxMode = appState.uploadMode == UploadMode.sandbox;
|
||||
final allowSubmit = kEnableNodeEdits &&
|
||||
final allowSubmit = dev.kEnableNodeEdits &&
|
||||
appState.isLoggedIn &&
|
||||
submittableProfiles.isNotEmpty &&
|
||||
session.profile != null &&
|
||||
@@ -256,7 +220,7 @@ class EditNodeSheet extends StatelessWidget {
|
||||
child: Column(
|
||||
children: [
|
||||
// Extract from way checkbox (only show if enabled in dev config)
|
||||
if (kEnableNodeExtraction) ...[
|
||||
if (dev.kEnableNodeExtraction) ...[
|
||||
CheckboxListTile(
|
||||
title: Text(locService.t('editNode.extractFromWay')),
|
||||
subtitle: Text(locService.t('editNode.extractFromWaySubtitle')),
|
||||
@@ -270,7 +234,7 @@ class EditNodeSheet extends StatelessWidget {
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
// Constraint info message (only show if extract is not checked or not enabled)
|
||||
if (!kEnableNodeExtraction || !session.extractFromWay) ...[
|
||||
if (!dev.kEnableNodeExtraction || !session.extractFromWay) ...[
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, size: 20),
|
||||
@@ -302,7 +266,7 @@ class EditNodeSheet extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
|
||||
if (!kEnableNodeEdits)
|
||||
if (!dev.kEnableNodeEdits)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
|
||||
@@ -28,7 +28,7 @@ class CameraMapMarker extends StatefulWidget {
|
||||
class _CameraMapMarkerState extends State<CameraMapMarker> {
|
||||
Timer? _tapTimer;
|
||||
// From dev_config.dart for build-time parameters
|
||||
static const Duration tapTimeout = kMarkerTapTimeout;
|
||||
static final Duration tapTimeout = dev.dev.kMarkerTapTimeout;
|
||||
|
||||
void _onTap() {
|
||||
_tapTimer = Timer(tapTimeout, () {
|
||||
@@ -49,7 +49,7 @@ class _CameraMapMarkerState extends State<CameraMapMarker> {
|
||||
|
||||
void _onDoubleTap() {
|
||||
_tapTimer?.cancel();
|
||||
widget.mapController.move(widget.node.coord, widget.mapController.camera.zoom + kNodeDoubleTapZoomDelta);
|
||||
widget.mapController.move(widget.node.coord, widget.mapController.camera.zoom + dev.kNodeDoubleTapZoomDelta);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -108,8 +108,8 @@ class CameraMarkersBuilder {
|
||||
|
||||
return Marker(
|
||||
point: n.coord,
|
||||
width: kNodeIconDiameter,
|
||||
height: kNodeIconDiameter,
|
||||
width: dev.kNodeIconDiameter,
|
||||
height: dev.kNodeIconDiameter,
|
||||
child: Opacity(
|
||||
opacity: shouldDimNode ? 0.5 : 1.0,
|
||||
child: CameraMapMarker(
|
||||
|
||||
@@ -72,12 +72,12 @@ class CameraRefreshController {
|
||||
}
|
||||
|
||||
final zoom = controller.mapController.camera.zoom;
|
||||
if (zoom < kNodeMinZoomLevel) {
|
||||
if (zoom < dev.kNodeMinZoomLevel) {
|
||||
// Show a snackbar-style bubble warning
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Nodes not drawn below zoom level $kNodeMinZoomLevel'),
|
||||
content: Text('Nodes not drawn below zoom level $dev.kNodeMinZoomLevel'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -6,7 +6,6 @@ import 'package:latlong2/latlong.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../../dev_config.dart';
|
||||
import '../../models/osm_node.dart';
|
||||
import '../../models/direction_fov.dart';
|
||||
|
||||
/// Helper class to build direction cone polygons for cameras
|
||||
class DirectionConesBuilder {
|
||||
@@ -21,13 +20,10 @@ class DirectionConesBuilder {
|
||||
|
||||
// Add session cones if in add-camera mode and profile requires direction
|
||||
if (session != null && session.target != null && session.profile?.requiresDirection == true) {
|
||||
final sessionFov = session.profile?.fov ?? (kDirectionConeHalfAngle * 2);
|
||||
|
||||
// Add current working direction (full opacity)
|
||||
overlays.add(_buildConeWithFov(
|
||||
overlays.add(_buildCone(
|
||||
session.target!,
|
||||
session.directionDegrees,
|
||||
sessionFov,
|
||||
zoom,
|
||||
context: context,
|
||||
isSession: true,
|
||||
@@ -37,10 +33,9 @@ class DirectionConesBuilder {
|
||||
// Add other directions (reduced opacity)
|
||||
for (int i = 0; i < session.directions.length; i++) {
|
||||
if (i != session.currentDirectionIndex) {
|
||||
overlays.add(_buildConeWithFov(
|
||||
overlays.add(_buildCone(
|
||||
session.target!,
|
||||
session.directions[i],
|
||||
sessionFov,
|
||||
zoom,
|
||||
context: context,
|
||||
isSession: true,
|
||||
@@ -52,13 +47,10 @@ class DirectionConesBuilder {
|
||||
|
||||
// Add edit session cones if in edit-camera mode and profile requires direction
|
||||
if (editSession != null && editSession.profile?.requiresDirection == true) {
|
||||
final sessionFov = editSession.profile?.fov ?? (kDirectionConeHalfAngle * 2);
|
||||
|
||||
// Add current working direction (full opacity)
|
||||
overlays.add(_buildConeWithFov(
|
||||
overlays.add(_buildCone(
|
||||
editSession.target,
|
||||
editSession.directionDegrees,
|
||||
sessionFov,
|
||||
zoom,
|
||||
context: context,
|
||||
isSession: true,
|
||||
@@ -68,10 +60,9 @@ class DirectionConesBuilder {
|
||||
// Add other directions (reduced opacity)
|
||||
for (int i = 0; i < editSession.directions.length; i++) {
|
||||
if (i != editSession.currentDirectionIndex) {
|
||||
overlays.add(_buildConeWithFov(
|
||||
overlays.add(_buildCone(
|
||||
editSession.target,
|
||||
editSession.directions[i],
|
||||
sessionFov,
|
||||
zoom,
|
||||
context: context,
|
||||
isSession: true,
|
||||
@@ -85,12 +76,11 @@ class DirectionConesBuilder {
|
||||
for (final node in cameras) {
|
||||
if (_isValidCameraWithDirection(node) &&
|
||||
(editSession == null || node.id != editSession.originalNode.id)) {
|
||||
// Build a cone for each direction+fov pair
|
||||
for (final directionFov in node.directionFovPairs) {
|
||||
overlays.add(_buildConeWithFov(
|
||||
// Build a cone for each direction
|
||||
for (final direction in node.directionDeg) {
|
||||
overlays.add(_buildCone(
|
||||
node.coord,
|
||||
directionFov.centerDegrees,
|
||||
directionFov.fovDegrees,
|
||||
direction,
|
||||
zoom,
|
||||
context: context,
|
||||
));
|
||||
@@ -113,30 +103,6 @@ class DirectionConesBuilder {
|
||||
node.tags['_pending_upload'] == 'true';
|
||||
}
|
||||
|
||||
/// Build cone with variable FOV width - new method for range notation support
|
||||
static Polygon _buildConeWithFov(
|
||||
LatLng origin,
|
||||
double bearingDeg,
|
||||
double fovDegrees,
|
||||
double zoom, {
|
||||
required BuildContext context,
|
||||
bool isPending = false,
|
||||
bool isSession = false,
|
||||
bool isActiveDirection = true,
|
||||
}) {
|
||||
return _buildConeInternal(
|
||||
origin: origin,
|
||||
bearingDeg: bearingDeg,
|
||||
halfAngleDeg: fovDegrees / 2,
|
||||
zoom: zoom,
|
||||
context: context,
|
||||
isPending: isPending,
|
||||
isSession: isSession,
|
||||
isActiveDirection: isActiveDirection,
|
||||
);
|
||||
}
|
||||
|
||||
/// Legacy method for backward compatibility - uses dev_config FOV
|
||||
static Polygon _buildCone(
|
||||
LatLng origin,
|
||||
double bearingDeg,
|
||||
@@ -146,43 +112,11 @@ class DirectionConesBuilder {
|
||||
bool isSession = false,
|
||||
bool isActiveDirection = true,
|
||||
}) {
|
||||
return _buildConeInternal(
|
||||
origin: origin,
|
||||
bearingDeg: bearingDeg,
|
||||
halfAngleDeg: kDirectionConeHalfAngle,
|
||||
zoom: zoom,
|
||||
context: context,
|
||||
isPending: isPending,
|
||||
isSession: isSession,
|
||||
isActiveDirection: isActiveDirection,
|
||||
);
|
||||
}
|
||||
|
||||
/// Internal cone building method that handles the actual rendering
|
||||
static Polygon _buildConeInternal({
|
||||
required LatLng origin,
|
||||
required double bearingDeg,
|
||||
required double halfAngleDeg,
|
||||
required double zoom,
|
||||
required BuildContext context,
|
||||
bool isPending = false,
|
||||
bool isSession = false,
|
||||
bool isActiveDirection = true,
|
||||
}) {
|
||||
// Handle full circle case (360-degree FOV)
|
||||
if (halfAngleDeg >= 180) {
|
||||
return _buildFullCircle(
|
||||
origin: origin,
|
||||
zoom: zoom,
|
||||
context: context,
|
||||
isSession: isSession,
|
||||
isActiveDirection: isActiveDirection,
|
||||
);
|
||||
}
|
||||
final halfAngle = dev.kDirectionConeHalfAngle;
|
||||
|
||||
// Calculate pixel-based radii
|
||||
final outerRadiusPx = kNodeIconDiameter + (kNodeIconDiameter * kDirectionConeBaseLength);
|
||||
final innerRadiusPx = kNodeIconDiameter + (2 * getNodeRingThickness(context));
|
||||
final outerRadiusPx = dev.kNodeIconDiameter + (dev.kNodeIconDiameter * dev.kDirectionConeBaseLength);
|
||||
final innerRadiusPx = dev.kNodeIconDiameter + (2 * getNodeRingThickness(context));
|
||||
|
||||
// Convert pixels to coordinate distances with zoom scaling
|
||||
final pixelToCoordinate = 0.00001 * math.pow(2, 15 - zoom);
|
||||
@@ -190,9 +124,7 @@ class DirectionConesBuilder {
|
||||
final innerRadius = innerRadiusPx * pixelToCoordinate;
|
||||
|
||||
// Number of points for the outer arc (within our directional range)
|
||||
// Scale arc points based on FOV width for better rendering
|
||||
final baseArcPoints = 12;
|
||||
final arcPoints = math.max(6, (baseArcPoints * halfAngleDeg / 45).round());
|
||||
const int arcPoints = 12;
|
||||
|
||||
LatLng project(double deg, double distance) {
|
||||
final rad = deg * math.pi / 180;
|
||||
@@ -207,81 +139,26 @@ class DirectionConesBuilder {
|
||||
|
||||
// Add outer arc points from left to right (counterclockwise for proper polygon winding)
|
||||
for (int i = 0; i <= arcPoints; i++) {
|
||||
final angle = bearingDeg - halfAngleDeg + (i * 2 * halfAngleDeg / arcPoints);
|
||||
final angle = bearingDeg - halfAngle + (i * 2 * halfAngle / arcPoints);
|
||||
points.add(project(angle, outerRadius));
|
||||
}
|
||||
|
||||
// Add inner arc points from right to left (to close the donut shape)
|
||||
for (int i = arcPoints; i >= 0; i--) {
|
||||
final angle = bearingDeg - halfAngleDeg + (i * 2 * halfAngleDeg / arcPoints);
|
||||
final angle = bearingDeg - halfAngle + (i * 2 * halfAngle / arcPoints);
|
||||
points.add(project(angle, innerRadius));
|
||||
}
|
||||
|
||||
// Adjust opacity based on direction state
|
||||
double opacity = kDirectionConeOpacity;
|
||||
double opacity = dev.kDirectionConeOpacity;
|
||||
if (isSession && !isActiveDirection) {
|
||||
opacity = kDirectionConeOpacity * 0.4; // Reduced opacity for inactive session directions
|
||||
opacity = dev.kDirectionConeOpacity * 0.4; // Reduced opacity for inactive session directions
|
||||
}
|
||||
|
||||
return Polygon(
|
||||
points: points,
|
||||
color: kDirectionConeColor.withOpacity(opacity),
|
||||
borderColor: kDirectionConeColor,
|
||||
borderStrokeWidth: getDirectionConeBorderWidth(context),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a full circle for 360-degree FOV cases
|
||||
static Polygon _buildFullCircle({
|
||||
required LatLng origin,
|
||||
required double zoom,
|
||||
required BuildContext context,
|
||||
bool isSession = false,
|
||||
bool isActiveDirection = true,
|
||||
}) {
|
||||
// Calculate pixel-based radii
|
||||
final outerRadiusPx = kNodeIconDiameter + (kNodeIconDiameter * kDirectionConeBaseLength);
|
||||
final innerRadiusPx = kNodeIconDiameter + (2 * getNodeRingThickness(context));
|
||||
|
||||
// Convert pixels to coordinate distances with zoom scaling
|
||||
final pixelToCoordinate = 0.00001 * math.pow(2, 15 - zoom);
|
||||
final outerRadius = outerRadiusPx * pixelToCoordinate;
|
||||
final innerRadius = innerRadiusPx * pixelToCoordinate;
|
||||
|
||||
// Create full circle with many points for smooth rendering
|
||||
const int circlePoints = 36;
|
||||
final points = <LatLng>[];
|
||||
|
||||
LatLng project(double deg, double distance) {
|
||||
final rad = deg * math.pi / 180;
|
||||
final dLat = distance * math.cos(rad);
|
||||
final dLon =
|
||||
distance * math.sin(rad) / math.cos(origin.latitude * math.pi / 180);
|
||||
return LatLng(origin.latitude + dLat, origin.longitude + dLon);
|
||||
}
|
||||
|
||||
// Add outer circle points
|
||||
for (int i = 0; i < circlePoints; i++) {
|
||||
final angle = i * 360.0 / circlePoints;
|
||||
points.add(project(angle, outerRadius));
|
||||
}
|
||||
|
||||
// Add inner circle points in reverse order to create donut
|
||||
for (int i = circlePoints - 1; i >= 0; i--) {
|
||||
final angle = i * 360.0 / circlePoints;
|
||||
points.add(project(angle, innerRadius));
|
||||
}
|
||||
|
||||
// Adjust opacity based on direction state
|
||||
double opacity = kDirectionConeOpacity;
|
||||
if (isSession && !isActiveDirection) {
|
||||
opacity = kDirectionConeOpacity * 0.4;
|
||||
}
|
||||
|
||||
return Polygon(
|
||||
points: points,
|
||||
color: kDirectionConeColor.withOpacity(opacity),
|
||||
borderColor: kDirectionConeColor,
|
||||
color: dev.kDirectionConeColor.withOpacity(opacity),
|
||||
borderColor: dev.kDirectionConeColor,
|
||||
borderStrokeWidth: getDirectionConeBorderWidth(context),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ class GpsController {
|
||||
controller.animateTo(
|
||||
dest: _currentLatLng!,
|
||||
zoom: controller.mapController.camera.zoom,
|
||||
duration: kFollowMeAnimationDuration,
|
||||
duration: dev.kFollowMeAnimationDuration,
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
onMapMovedProgrammatically?.call();
|
||||
@@ -70,7 +70,7 @@ class GpsController {
|
||||
dest: _currentLatLng!,
|
||||
zoom: controller.mapController.camera.zoom,
|
||||
rotation: 0.0,
|
||||
duration: kFollowMeAnimationDuration,
|
||||
duration: dev.kFollowMeAnimationDuration,
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
onMapMovedProgrammatically?.call();
|
||||
@@ -123,7 +123,7 @@ class GpsController {
|
||||
dest: latLng,
|
||||
zoom: controller.mapController.camera.zoom,
|
||||
rotation: controller.mapController.camera.rotation,
|
||||
duration: kFollowMeAnimationDuration,
|
||||
duration: dev.kFollowMeAnimationDuration,
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
|
||||
@@ -135,14 +135,14 @@ class GpsController {
|
||||
final speed = position.speed; // Speed in m/s
|
||||
|
||||
// Only apply rotation if moving fast enough to avoid wild spinning when stationary
|
||||
final shouldRotate = !speed.isNaN && speed >= kMinSpeedForRotationMps && !heading.isNaN;
|
||||
final shouldRotate = !speed.isNaN && speed >= dev.kMinSpeedForRotationMps && !heading.isNaN;
|
||||
final rotation = shouldRotate ? -heading : controller.mapController.camera.rotation;
|
||||
|
||||
controller.animateTo(
|
||||
dest: latLng,
|
||||
zoom: controller.mapController.camera.zoom,
|
||||
rotation: rotation,
|
||||
duration: kFollowMeAnimationDuration,
|
||||
duration: dev.kFollowMeAnimationDuration,
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ class MapOverlays extends StatelessWidget {
|
||||
// Zoom indicator, positioned relative to button bar with left safe area
|
||||
Positioned(
|
||||
left: leftPositionWithSafeArea(10, safeArea),
|
||||
bottom: bottomPositionFromButtonBar(kZoomIndicatorSpacingAboveButtonBar, safeArea.bottom),
|
||||
bottom: bottomPositionFromButtonBar(dev.kZoomIndicatorSpacingAboveButtonBar, safeArea.bottom),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
@@ -125,7 +125,7 @@ class MapOverlays extends StatelessWidget {
|
||||
// Attribution overlay, positioned relative to button bar with left safe area
|
||||
if (attribution != null)
|
||||
Positioned(
|
||||
bottom: bottomPositionFromButtonBar(kAttributionSpacingAboveButtonBar, safeArea.bottom),
|
||||
bottom: bottomPositionFromButtonBar(dev.kAttributionSpacingAboveButtonBar, safeArea.bottom),
|
||||
left: leftPositionWithSafeArea(10, safeArea),
|
||||
child: GestureDetector(
|
||||
onTap: () => _showAttributionDialog(context, attribution!),
|
||||
@@ -151,7 +151,7 @@ class MapOverlays extends StatelessWidget {
|
||||
|
||||
// Zoom and layer controls (bottom-right), positioned relative to button bar with right safe area
|
||||
Positioned(
|
||||
bottom: bottomPositionFromButtonBar(kZoomControlsSpacingAboveButtonBar, safeArea.bottom),
|
||||
bottom: bottomPositionFromButtonBar(dev.kZoomControlsSpacingAboveButtonBar, safeArea.bottom),
|
||||
right: rightPositionWithSafeArea(16, safeArea),
|
||||
child: Consumer<AppState>(
|
||||
builder: (context, appState, child) {
|
||||
|
||||
@@ -28,7 +28,7 @@ class SuspectedLocationMapMarker extends StatefulWidget {
|
||||
class _SuspectedLocationMapMarkerState extends State<SuspectedLocationMapMarker> {
|
||||
Timer? _tapTimer;
|
||||
// From dev_config.dart for build-time parameters
|
||||
static const Duration tapTimeout = kMarkerTapTimeout;
|
||||
static final Duration tapTimeout = dev.dev.kMarkerTapTimeout;
|
||||
|
||||
void _onTap() {
|
||||
_tapTimer = Timer(tapTimeout, () {
|
||||
@@ -47,7 +47,7 @@ class _SuspectedLocationMapMarkerState extends State<SuspectedLocationMapMarker>
|
||||
|
||||
void _onDoubleTap() {
|
||||
_tapTimer?.cancel();
|
||||
widget.mapController.move(widget.location.centroid, widget.mapController.camera.zoom + kNodeDoubleTapZoomDelta);
|
||||
widget.mapController.move(widget.location.centroid, widget.mapController.camera.zoom + dev.kNodeDoubleTapZoomDelta);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -60,7 +60,7 @@ class MapView extends StatefulWidget {
|
||||
|
||||
class MapViewState extends State<MapView> {
|
||||
late final AnimatedMapController _controller;
|
||||
final Debouncer _cameraDebounce = Debouncer(kDebounceCameraRefresh);
|
||||
final Debouncer _cameraDebounce = Debouncer(dev.kDebounceCameraRefresh);
|
||||
final Debouncer _tileDebounce = Debouncer(const Duration(milliseconds: 150));
|
||||
final Debouncer _mapPositionDebounce = Debouncer(const Duration(milliseconds: 1000));
|
||||
final Debouncer _constrainedNodeSnapBack = Debouncer(const Duration(milliseconds: 100));
|
||||
@@ -240,9 +240,9 @@ class MapViewState extends State<MapView> {
|
||||
|
||||
// OSM API (sandbox mode) needs higher zoom level due to bbox size limits
|
||||
if (uploadMode == UploadMode.sandbox) {
|
||||
return kOsmApiMinZoomLevel;
|
||||
return dev.kOsmApiMinZoomLevel;
|
||||
} else {
|
||||
return kNodeMinZoomLevel;
|
||||
return dev.kNodeMinZoomLevel;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,17 +268,17 @@ class MapViewState extends State<MapView> {
|
||||
// Check if we're editing a constrained node that's not being extracted
|
||||
if (editSession?.originalNode.isConstrained == true && editSession?.extractFromWay != true) {
|
||||
// Constrained node (not extracting): only allow pinch zoom and rotation, disable ALL panning
|
||||
return const InteractionOptions(
|
||||
return InteractionOptions(
|
||||
enableMultiFingerGestureRace: true,
|
||||
flags: InteractiveFlag.pinchZoom | InteractiveFlag.rotate,
|
||||
scrollWheelVelocity: kScrollWheelVelocity,
|
||||
pinchZoomThreshold: kPinchZoomThreshold,
|
||||
pinchMoveThreshold: kPinchMoveThreshold,
|
||||
scrollWheelVelocity: dev.kScrollWheelVelocity,
|
||||
pinchZoomThreshold: dev.kPinchZoomThreshold,
|
||||
pinchMoveThreshold: dev.kPinchMoveThreshold,
|
||||
);
|
||||
}
|
||||
|
||||
// Normal case: all interactions allowed with gesture race to prevent accidental rotation during zoom
|
||||
return const InteractionOptions(
|
||||
return InteractionOptions(
|
||||
enableMultiFingerGestureRace: true,
|
||||
flags: InteractiveFlag.doubleTapDragZoom |
|
||||
InteractiveFlag.doubleTapZoom |
|
||||
@@ -287,9 +287,9 @@ class MapViewState extends State<MapView> {
|
||||
InteractiveFlag.pinchZoom |
|
||||
InteractiveFlag.rotate |
|
||||
InteractiveFlag.scrollWheelZoom,
|
||||
scrollWheelVelocity: kScrollWheelVelocity,
|
||||
pinchZoomThreshold: kPinchZoomThreshold,
|
||||
pinchMoveThreshold: kPinchMoveThreshold,
|
||||
scrollWheelVelocity: dev.kScrollWheelVelocity,
|
||||
pinchZoomThreshold: dev.kPinchZoomThreshold,
|
||||
pinchMoveThreshold: dev.kPinchMoveThreshold,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -506,8 +506,8 @@ class MapViewState extends State<MapView> {
|
||||
centerMarkers.add(
|
||||
Marker(
|
||||
point: center,
|
||||
width: kNodeIconDiameter,
|
||||
height: kNodeIconDiameter,
|
||||
width: dev.kNodeIconDiameter,
|
||||
height: dev.kNodeIconDiameter,
|
||||
child: CameraIcon(
|
||||
type: editSession != null ? CameraIconType.editing : CameraIconType.mock,
|
||||
),
|
||||
@@ -597,18 +597,6 @@ class MapViewState extends State<MapView> {
|
||||
widget.onUserGesture();
|
||||
}
|
||||
|
||||
// Enforce minimum zoom level for add/edit node sheets (but not tag sheet)
|
||||
if ((session != null || editSession != null) && pos.zoom < kMinZoomForNodeEditingSheets) {
|
||||
// User tried to zoom out below minimum - snap back to minimum zoom
|
||||
_controller.animateTo(
|
||||
dest: pos.center,
|
||||
zoom: kMinZoomForNodeEditingSheets.toDouble(),
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
return; // Don't process other position updates
|
||||
}
|
||||
|
||||
if (session != null) {
|
||||
appState.updateSession(target: pos.center);
|
||||
}
|
||||
@@ -703,7 +691,7 @@ class MapViewState extends State<MapView> {
|
||||
alignment: Alignment.bottomLeft,
|
||||
padding: EdgeInsets.only(
|
||||
left: leftPositionWithSafeArea(8, safeArea),
|
||||
bottom: bottomPositionFromButtonBar(kScaleBarSpacingAboveButtonBar, safeArea.bottom)
|
||||
bottom: bottomPositionFromButtonBar(dev.kScaleBarSpacingAboveButtonBar, safeArea.bottom)
|
||||
),
|
||||
textStyle: TextStyle(color: Colors.black, fontWeight: FontWeight.bold),
|
||||
lineColor: Colors.black,
|
||||
@@ -765,7 +753,7 @@ class MapViewState extends State<MapView> {
|
||||
if (originalCoord != null) {
|
||||
lines.add(Polyline(
|
||||
points: [originalCoord, node.coord],
|
||||
color: kNodeRingColorPending,
|
||||
color: dev.kNodeRingColorPending,
|
||||
strokeWidth: 3.0,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../models/osm_node.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
class ProximityWarningDialog extends StatelessWidget {
|
||||
final List<OsmNode> nearbyNodes;
|
||||
final double distance;
|
||||
final VoidCallback onGoBack;
|
||||
final VoidCallback onSubmitAnyway;
|
||||
|
||||
const ProximityWarningDialog({
|
||||
super.key,
|
||||
required this.nearbyNodes,
|
||||
required this.distance,
|
||||
required this.onGoBack,
|
||||
required this.onSubmitAnyway,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return AlertDialog(
|
||||
icon: const Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
color: Colors.orange,
|
||||
size: 32,
|
||||
),
|
||||
title: Text(locService.t('proximityWarning.title')),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('proximityWarning.message',
|
||||
params: [distance.toStringAsFixed(1)]),
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
locService.t('proximityWarning.suggestion'),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
locService.t('proximityWarning.nearbyNodes',
|
||||
params: [nearbyNodes.length.toString()]),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...nearbyNodes.take(3).map((node) => Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0, bottom: 4.0),
|
||||
child: Text(
|
||||
'• ${locService.t('proximityWarning.nodeInfo', params: [
|
||||
node.id.toString(),
|
||||
_getNodeTypeDescription(node, locService),
|
||||
])}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
)),
|
||||
if (nearbyNodes.length > 3)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0, top: 4.0),
|
||||
child: Text(
|
||||
locService.t('proximityWarning.andMore',
|
||||
params: [(nearbyNodes.length - 3).toString()]),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: onGoBack,
|
||||
child: Text(locService.t('proximityWarning.goBack')),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: onSubmitAnyway,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.orange,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: Text(locService.t('proximityWarning.submitAnyway')),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _getNodeTypeDescription(OsmNode node, LocalizationService locService) {
|
||||
// Try to get a meaningful description from the node's tags
|
||||
final manMade = node.tags['man_made'];
|
||||
final amenity = node.tags['amenity'];
|
||||
final surveillance = node.tags['surveillance'];
|
||||
final surveillanceType = node.tags['surveillance:type'];
|
||||
final manufacturer = node.tags['manufacturer'];
|
||||
|
||||
if (manMade == 'surveillance') {
|
||||
if (surveillanceType == 'ALPR' || surveillanceType == 'ANPR') {
|
||||
return locService.t('proximityWarning.nodeType.alpr');
|
||||
} else if (surveillance == 'public') {
|
||||
return locService.t('proximityWarning.nodeType.publicCamera');
|
||||
} else {
|
||||
return locService.t('proximityWarning.nodeType.camera');
|
||||
}
|
||||
} else if (amenity != null) {
|
||||
return locService.t('proximityWarning.nodeType.amenity', params: [amenity]);
|
||||
} else if (manufacturer != null) {
|
||||
return locService.t('proximityWarning.nodeType.device', params: [manufacturer]);
|
||||
} else {
|
||||
return locService.t('proximityWarning.nodeType.unknown');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
name: deflockapp
|
||||
description: Map public surveillance infrastructure with OpenStreetMap
|
||||
publish_to: "none"
|
||||
version: 1.4.5+16 # The thing after the + is the version code, incremented with each release
|
||||
version: 1.4.1+14 # The thing after the + is the version code, incremented with each release
|
||||
|
||||
environment:
|
||||
sdk: ">=3.5.0 <4.0.0" # oauth2_client 4.x needs Dart 3.5+
|
||||
|
||||
150
scripts/update_dev_config.py
Normal file
150
scripts/update_dev_config.py
Normal file
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
# All constants to replace
|
||||
CONSTANTS = [
|
||||
"kFallbackTileEstimateKb", "kPreviewTileZoom", "kPreviewTileY", "kPreviewTileX",
|
||||
"kDirectionConeHalfAngle", "kDirectionConeBaseLength", "kDirectionConeColor", "kDirectionConeOpacity",
|
||||
"kBottomButtonBarOffset", "kButtonBarHeight", "kAttributionSpacingAboveButtonBar",
|
||||
"kZoomIndicatorSpacingAboveButtonBar", "kScaleBarSpacingAboveButtonBar", "kZoomControlsSpacingAboveButtonBar",
|
||||
"kClientName", "kSuspectedLocationsCsvUrl", "kEnableDevelopmentModes", "kEnableNodeEdits", "kEnableNodeExtraction",
|
||||
"kNodeMinZoomLevel", "kOsmApiMinZoomLevel", "kMarkerTapTimeout", "kDebounceCameraRefresh",
|
||||
"kPreFetchAreaExpansionMultiplier", "kPreFetchZoomLevel", "kMaxPreFetchSplitDepth", "kDataRefreshIntervalSeconds",
|
||||
"kFollowMeAnimationDuration", "kMinSpeedForRotationMps", "kProximityAlertDefaultDistance",
|
||||
"kProximityAlertMinDistance", "kProximityAlertMaxDistance", "kProximityAlertCooldown",
|
||||
"kNodeDoubleTapZoomDelta", "kScrollWheelVelocity", "kPinchZoomThreshold", "kPinchMoveThreshold", "kRotationThreshold",
|
||||
"kTileFetchMaxAttempts", "kTileFetchInitialDelayMs", "kTileFetchBackoffMultiplier", "kTileFetchMaxDelayMs",
|
||||
"kTileFetchRandomJitterMs", "kMaxUserDownloadZoomSpan", "kMaxReasonableTileCount", "kAbsoluteMaxTileCount",
|
||||
"kAbsoluteMaxZoom", "kNodeIconDiameter", "kNodeDotOpacity", "kNodeRingColorReal", "kNodeRingColorMock",
|
||||
"kNodeRingColorPending", "kNodeRingColorEditing", "kNodeRingColorPendingEdit", "kNodeRingColorPendingDeletion",
|
||||
"kDirectionButtonMinWidth", "kDirectionButtonMinHeight"
|
||||
]
|
||||
|
||||
def find_dart_files():
|
||||
"""Find all .dart files except dev_config.dart"""
|
||||
dart_files = []
|
||||
for root, dirs, files in os.walk('.'):
|
||||
for file in files:
|
||||
if file.endswith('.dart'):
|
||||
path = os.path.join(root, file)
|
||||
if 'dev_config.dart' not in path:
|
||||
dart_files.append(path)
|
||||
return dart_files
|
||||
|
||||
def process_file(filepath):
|
||||
"""Process a single dart file"""
|
||||
print(f" 📝 Processing {filepath}")
|
||||
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
except Exception as e:
|
||||
print(f" ❌ Error reading file: {e}")
|
||||
return False
|
||||
|
||||
original_content = content
|
||||
changes_made = []
|
||||
|
||||
# Process each constant
|
||||
for constant in CONSTANTS:
|
||||
content, changed = process_constant_in_content(content, constant)
|
||||
if changed:
|
||||
changes_made.append(constant)
|
||||
|
||||
# Only write if something actually changed
|
||||
if content != original_content:
|
||||
try:
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
print(f" ✅ Updated: {', '.join(changes_made)}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f" ❌ Error writing file: {e}")
|
||||
return False
|
||||
else:
|
||||
print(f" ⏭️ No changes needed")
|
||||
return False
|
||||
|
||||
def process_constant_in_content(content, constant):
|
||||
"""Process a single constant in file content, handling const issues"""
|
||||
original_content = content
|
||||
|
||||
# Skip if already using dev.constant (idempotent)
|
||||
if f"dev.{constant}" in content:
|
||||
return content, False
|
||||
|
||||
# Skip if constant not found at all
|
||||
if constant not in content:
|
||||
return content, False
|
||||
|
||||
print(f" 🔄 Replacing {constant}")
|
||||
|
||||
# Pattern 1: const Type variable = kConstant;
|
||||
# Change to: final Type variable = dev.kConstant;
|
||||
pattern1 = rf'\bconst\s+(\w+)\s+(\w+)\s*=\s*{re.escape(constant)}\s*;'
|
||||
replacement1 = rf'final \1 \2 = dev.{constant};'
|
||||
content = re.sub(pattern1, replacement1, content)
|
||||
|
||||
# Pattern 2: static const Type variable = kConstant;
|
||||
# Change to: static final Type variable = dev.kConstant;
|
||||
pattern2 = rf'\bstatic\s+const\s+(\w+)\s+(\w+)\s*=\s*{re.escape(constant)}\s*;'
|
||||
replacement2 = rf'static final \1 \2 = dev.{constant};'
|
||||
content = re.sub(pattern2, replacement2, content)
|
||||
|
||||
# Pattern 3: const ConstructorName(...kConstant...)
|
||||
# We need to be careful here - find const constructors that contain our constant
|
||||
# and remove the const keyword
|
||||
# This is tricky to do perfectly with regex, so let's do a simple approach:
|
||||
# If we find "const SomeConstructor(" followed by our constant somewhere before the matching ")"
|
||||
# we'll remove the const keyword from the constructor
|
||||
|
||||
# Find all const constructor calls that contain our constant
|
||||
const_constructor_pattern = r'\bconst\s+(\w+)\s*\([^)]*' + re.escape(constant) + r'[^)]*\)'
|
||||
matches = list(re.finditer(const_constructor_pattern, content))
|
||||
|
||||
# Replace const with just the constructor name for each match
|
||||
for match in reversed(matches): # Reverse to maintain positions
|
||||
full_match = match.group(0)
|
||||
constructor_name = match.group(1)
|
||||
# Remove 'const ' from the beginning
|
||||
replacement = full_match.replace(f'const {constructor_name}', constructor_name, 1)
|
||||
content = content[:match.start()] + replacement + content[match.end():]
|
||||
|
||||
# Pattern 4: Simple replacements - any remaining instances of kConstant
|
||||
# Use word boundaries to avoid partial matches, but avoid already replaced dev.kConstant
|
||||
pattern4 = rf'\b{re.escape(constant)}\b(?![\w.])' # Negative lookahead to avoid partial matches
|
||||
replacement4 = f'dev.{constant}'
|
||||
content = re.sub(pattern4, replacement4, content)
|
||||
|
||||
return content, content != original_content
|
||||
|
||||
def main():
|
||||
print("🚀 Starting dev_config reference update...")
|
||||
print("🔍 Finding Dart files...")
|
||||
|
||||
dart_files = find_dart_files()
|
||||
print(f"📁 Found {len(dart_files)} Dart files to process")
|
||||
|
||||
if not dart_files:
|
||||
print("❌ No Dart files found!")
|
||||
return
|
||||
|
||||
updated_files = 0
|
||||
|
||||
for filepath in dart_files:
|
||||
if process_file(filepath):
|
||||
updated_files += 1
|
||||
|
||||
print(f"\n✨ Finished! Updated {updated_files} out of {len(dart_files)} files")
|
||||
print("💡 Next steps:")
|
||||
print(" 1. flutter analyze (check for syntax errors)")
|
||||
print(" 2. flutter pub get (refresh dependencies)")
|
||||
print(" 3. flutter run (test the app)")
|
||||
|
||||
if updated_files > 0:
|
||||
print("⚠️ If you see compilation errors, the script can be run again safely")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
12
scripts/update_dev_config_references.sh
Executable file
12
scripts/update_dev_config_references.sh
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Super simple test - just replace one constant first
|
||||
echo "🔄 Testing with kClientName..."
|
||||
|
||||
find . -name "*.dart" -not -path "./lib/dev_config.dart" -exec grep -l "kClientName" {} \;
|
||||
|
||||
echo "Found files with kClientName. Now replacing..."
|
||||
|
||||
find . -name "*.dart" -not -path "./lib/dev_config.dart" -exec sed -i '' 's/kClientName/dev.kClientName/g' {} \;
|
||||
|
||||
echo "✅ Done with test. Check if lib/services/uploader.dart changed"
|
||||
Reference in New Issue
Block a user