Compare commits

...

17 Commits

Author SHA1 Message Date
stopflock
999a918062 doesnt really work, probably abandon, saving because sunk cost 2025-11-21 12:49:35 -06:00
stopflock
492cf57520 Disable deletion of nodes attached to ways/relations, add option for visibility of WIP extraction feature 2025-11-20 21:17:06 -06:00
stopflock
c77ea96eaf Move OSM account settings and upload queue into their own sections, add "see my edits" button 2025-11-20 20:54:16 -06:00
stopflock
813a0f06da Change prox alerts default and max distance 2025-11-20 20:08:10 -06:00
stopflock
3fc74df616 update roadmap 2025-11-20 14:54:57 -06:00
stopflock
95fad14261 min zoom 1, max cameras 8, extract node from way 2025-11-19 22:46:09 -06:00
stopflock
1ac43b0c4e Only show appropriate external editors on each platform, redirect to appstore on error 2025-11-19 19:50:39 -06:00
stopflock
3174e0bfe1 Adjust gesture thresholds 2025-11-19 16:24:51 -06:00
stopflock
5404daa704 Gesture race! 2025-11-19 14:24:51 -06:00
stopflock
20870623f0 Compass adjust for search box 2025-11-19 13:36:04 -06:00
stopflock
8ed92dcd7e Home screen respect safe areas in all orientations 2025-11-19 13:32:40 -06:00
stopflock
0143c74415 Reasonable size limits for tag text boxes in sheets 2025-11-18 16:21:31 -06:00
stopflock
6c53d988de Further improve tag views, implement upload queue pause toggle 2025-11-17 13:37:48 -06:00
stopflock
26cebcc60e Localizations for new features 2025-11-16 21:26:35 -06:00
stopflock
7c2b9ea087 Configurable max height for node tags box, localizations for new UX strings 2025-11-16 18:16:50 -06:00
stopflock
b2645f1341 Limit tag list size, make changelog use a list instead of \n, make links clickable in node tags 2025-11-16 17:30:24 -06:00
stopflock
05eedbb910 Link to OSM in node_details sheet. Add option to open node in other editors. 2025-11-16 16:45:54 -06:00
51 changed files with 2284 additions and 505 deletions

View File

@@ -135,18 +135,24 @@ The welcome popup explains that the app:
**Why this approach:**
Reduces API load by 3-4x while ensuring data freshness. User sees instant responses from cache while background fetching keeps data current. Eliminates complex dual-path logic in favor of simple spatial/temporal triggers.
### 2. Node Operations (Create/Edit/Delete)
### 2. Node Operations (Create/Edit/Delete/Extract)
**Upload Operations Enum:**
```dart
enum UploadOperation { create, modify, delete }
enum UploadOperation { create, modify, delete, extract }
```
**Why explicit enum vs boolean flags:**
- **Brutalist**: Three explicit states instead of nullable booleans
- **Brutalist**: Four explicit states instead of nullable booleans
- **Extensible**: Easy to add new operations (like bulk operations)
- **Clear intent**: `operation == UploadOperation.delete` is unambiguous
**Operations explained:**
- **create**: Add new node to OSM
- **modify**: Update existing node's tags/position/direction
- **delete**: Remove existing node from OSM
- **extract**: Create new node with tags copied from constrained node, leaving original unchanged
**Session Pattern:**
- `AddNodeSession`: For creating new nodes with single or multiple directions
- `EditNodeSession`: For modifying existing nodes, preserving all existing directions

View File

@@ -98,19 +98,27 @@ cp lib/keys.dart.example lib/keys.dart
## Roadmap
### Needed Bugfixes
- 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
- 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
- Add some builtin satellite tile provider
- Option to pull in profiles from NSI (man_made=surveillance only)
- Persistent cache for MY submissions: clean up when we see that node appear in overpass results or when older than 24h
- Dropdown on "refine tags" page to select acceptable options for camera:mount=
- Tutorial / info guide before submitting first node
- Link to OSM node in node_details_sheet
- Link to "my changes" on osm (username edit history)
- Option to "extract node from way" for nodes attached to a way to allow moving
- Option to "open in other editor" for advanced edits: StreetComplete/EveryDoor/Vespucci/GO!! Map/OSM.org(iD)/Rapid/Level0/OSMand/OrganicMaps/CoMaps
- 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?)
### On Pause
- Suspected locations expansion to more regions

View File

@@ -1,29 +1,125 @@
{
"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",
"• 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",
"• 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"
]
},
"1.3.4": {
"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",
"• 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": "• NEW: Added builtin surveillance device profiles for Rekor and Axis Communications ALPR cameras"
"content": [
"• 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: 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": {
"content": "• HOTFIX: Temporarily disabled node editing to prevent OSM database issues while a bug is resolved\n• UX: Fixed Android navigation bar covering settings page content"
"content": [
"• HOTFIX: Temporarily disabled node editing to prevent OSM database issues while a bug is resolved",
"• UX: Fixed Android navigation bar covering settings page content"
]
},
"1.3.1": {
"content": "• UX: Network status indicator always enabled\n• UX: Direction slider wider on small screens\n• UX: Fixed iOS keyboard missing 'Done' in settings\n• UX: Fixed multi-direction nodes in upload queue\n• UX: Improved suspected locations loading indicator; removed popup, fixed stuck spinner"
"content": [
"• UX: Network status indicator always enabled",
"• UX: Direction slider wider on small screens",
"• UX: Fixed iOS keyboard missing 'Done' in settings",
"• UX: Fixed multi-direction nodes in upload queue",
"• UX: Improved suspected locations loading indicator; removed popup, fixed stuck spinner"
]
},
"1.2.8": {
"content": "• UX: Profile selection is now a required step to prevent accidental submission of default profile.\n• NEW: Note in welcome message about not submitting data you cannot vouch for personally (no street view etc)\n• NEW: Added default operator profiles for the most common private operators nationwide (Lowe's, Home Depot, et al)\n• NEW: Support for cardinal directions in OSM data, multiple directions on a node."
"content": [
"• UX: Profile selection is now a required step to prevent accidental submission of default profile",
"• NEW: Note in welcome message about not submitting data you cannot vouch for personally (no street view etc)",
"• NEW: Added default operator profiles for the most common private operators nationwide (Lowe's, Home Depot, et al)",
"• NEW: Support for cardinal directions in OSM data, multiple directions on a node"
]
},
"1.2.7": {
"content": "• NEW: Compass indicator shows map orientation; tap to spin north-up\n• Smart area caching: Loads 3x larger areas and refreshes data every 60 seconds for much faster browsing\n• Enhanced tile loading: Increased retry attempts with faster delays - tiles load much more reliably\n• Better network status: Simplified loading indicator logic\n• Instant node display: Surveillance devices now appear immediately when data finishes loading\n• Node limit alerts: Get notified when some nodes are not drawn"
"content": [
"• NEW: Compass indicator shows map orientation; tap to spin north-up",
"• Smart area caching: Loads 3x larger areas and refreshes data every 60 seconds for much faster browsing",
"• Enhanced tile loading: Increased retry attempts with faster delays - tiles load much more reliably",
"• Better network status: Simplified loading indicator logic",
"• Instant node display: Surveillance devices now appear immediately when data finishes loading",
"• Node limit alerts: Get notified when some nodes are not drawn"
]
},
"1.2.4": {
"content": "• New welcome popup for first-time users with essential privacy information\n• Automatic changelog display when app updates (like this one!)\n• Added Release Notes viewer in Settings > About\n• Enhanced user onboarding and transparency about data handling\n• Improved documentation for contributors"
"content": [
"• New welcome popup for first-time users with essential privacy information",
"• Automatic changelog display when app updates (like this one!)",
"• Added Release Notes viewer in Settings > About",
"• Enhanced user onboarding and transparency about data handling",
"• Improved documentation for contributors"
]
},
"1.2.3": {
"content": "• Enhanced map performance and stability\n• Improved offline sync reliability\n• Added better error handling for uploads\n• Various bug fixes and improvements"
"content": [
"• Enhanced map performance and stability",
"• Improved offline sync reliability",
"• Added better error handling for uploads",
"• Various bug fixes and improvements"
]
},
"1.2.2": {
"content": "• New surveillance device profiles added\n• Improved tile loading performance\n• Fixed issue with GPS accuracy\n• Updated translations"
"content": [
"• New surveillance device profiles added",
"• Improved tile loading performance",
"• Fixed issue with GPS accuracy",
"• Updated translations"
]
},
"1.2.0": {
"content": "• Major UI improvements\n• Added proximity alerts\n• Enhanced offline capabilities\n• New suspected locations feature"
"content": [
"• Major UI improvements",
"• Added proximity alerts",
"• Enhanced offline capabilities",
"• New suspected locations feature"
]
}
}

View File

@@ -130,6 +130,7 @@ class AppState extends ChangeNotifier {
// Settings state
bool get offlineMode => _settingsState.offlineMode;
bool get pauseQueueProcessing => _settingsState.pauseQueueProcessing;
int get maxCameras => _settingsState.maxCameras;
UploadMode get uploadMode => _settingsState.uploadMode;
FollowMeMode get followMeMode => _settingsState.followMeMode;
@@ -276,14 +277,21 @@ class AppState extends ChangeNotifier {
NodeProfile? profile,
OperatorProfile? operatorProfile,
LatLng? target,
bool? extractFromWay,
}) {
_sessionState.updateEditSession(
directionDeg: directionDeg,
profile: profile,
operatorProfile: operatorProfile,
target: target,
extractFromWay: extractFromWay,
);
}
// For map view to check for pending snap backs
LatLng? consumePendingSnapBack() {
return _sessionState.consumePendingSnapBack();
}
void addDirection() {
_sessionState.addDirection();
@@ -411,6 +419,15 @@ class AppState extends ChangeNotifier {
}
}
Future<void> setPauseQueueProcessing(bool enabled) async {
await _settingsState.setPauseQueueProcessing(enabled);
if (!enabled) {
_startUploader(); // Resume upload queue processing
} else {
_uploadQueueState.stopUploader(); // Stop uploader when paused
}
}
set maxCameras(int n) {
_settingsState.maxCameras = n;
}
@@ -524,6 +541,7 @@ class AppState extends ChangeNotifier {
void _startUploader() {
_uploadQueueState.startUploader(
offlineMode: offlineMode,
pauseQueueProcessing: pauseQueueProcessing,
uploadMode: uploadMode,
getAccessToken: _authState.getAccessToken,
);

View File

@@ -2,59 +2,145 @@
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)
double leftPositionWithSafeArea(double baseLeft, EdgeInsets safeArea) {
return baseLeft + safeArea.left;
}
// Helper to get right positioning that accounts for safe area (for landscape mode)
double rightPositionWithSafeArea(double baseRight, EdgeInsets safeArea) {
return baseRight + safeArea.right;
}
// 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://stopflock.com/app/flock_utilities_mini_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
// Helper to get top positioning that accounts for safe area
double topPositionWithSafeArea(double baseTop, EdgeInsets safeArea) {
return baseTop + safeArea.top;
}
/// 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
@@ -79,17 +165,29 @@ const int kDataRefreshIntervalSeconds = 60; // Refresh cached data after this ma
const Duration kFollowMeAnimationDuration = Duration(milliseconds: 600);
const double kMinSpeedForRotationMps = 1.0; // Minimum speed (m/s) to apply rotation
// Sheet content configuration
const double kMaxTagListHeightRatioPortrait = 0.3; // Maximum height for tag lists in portrait mode
const double kMaxTagListHeightRatioLandscape = 0.2; // Maximum height for tag lists in landscape mode
/// Get appropriate tag list height ratio based on screen orientation
double getTagListHeightRatio(BuildContext context) {
final size = MediaQuery.of(context).size;
final isLandscape = size.width > size.height;
return isLandscape ? kMaxTagListHeightRatioLandscape : kMaxTagListHeightRatioPortrait;
}
// Proximity alerts configuration
const int kProximityAlertDefaultDistance = 200; // meters
const int kProximityAlertDefaultDistance = 400; // meters
const int kProximityAlertMinDistance = 50; // meters
const int kProximityAlertMaxDistance = 1000; // meters
const int kProximityAlertMaxDistance = 1600; // meters
const Duration kProximityAlertCooldown = Duration(minutes: 10); // Cooldown between alerts for same node
// Map interaction configuration
const double kNodeDoubleTapZoomDelta = 1.0; // How much to zoom in when double-tapping nodes (was 1.0)
const double kScrollWheelVelocity = 0.005; // Mouse scroll wheel zoom speed (default 0.005)
const double kPinchZoomThreshold = 0.5; // How much pinch required to start zoom (default 0.5)
const double kPinchMoveThreshold = 40.0; // How much drag required for two-finger pan (default 40.0)
const double kScrollWheelVelocity = 0.01; // Mouse scroll wheel zoom speed (default 0.005)
const double kPinchZoomThreshold = 0.2; // How much pinch required to start zoom (reduced for gesture race)
const double kPinchMoveThreshold = 30.0; // How much drag required for two-finger pan (default 40.0)
const double kRotationThreshold = 6.0; // Degrees of rotation required before map actually rotates (Google Maps style)
// Tile fetch retry parameters (configurable backoff system)
const int kTileFetchMaxAttempts = 16; // Number of retry attempts before giving up
@@ -123,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;
}

View File

@@ -16,7 +16,10 @@
"close": "Schließen",
"submit": "Senden",
"saveEdit": "Bearbeitung Speichern",
"clear": "Löschen"
"clear": "Löschen",
"viewOnOSM": "Auf OSM anzeigen",
"advanced": "Erweitert",
"useAdvancedEditor": "Erweiterten Editor verwenden"
},
"followMe": {
"off": "Verfolgung aktivieren",
@@ -36,6 +39,8 @@
"maxNodesWarning": "Sie möchten das wahrscheinlich nicht tun, es sei denn, Sie sind absolut sicher, dass Sie einen guten Grund dafür haben.",
"offlineMode": "Offline-Modus",
"offlineModeSubtitle": "Alle Netzwerkanfragen außer für lokale/Offline-Bereiche deaktivieren.",
"pauseQueueProcessing": "Upload-Warteschlange pausieren",
"pauseQueueProcessingSubtitle": "Upload von wartenden Änderungen stoppen, aber Live-Datenzugriff beibehalten.",
"offlineModeWarningTitle": "Aktive Downloads",
"offlineModeWarningMessage": "Die Aktivierung des Offline-Modus bricht alle aktiven Bereichsdownloads ab. Möchten Sie fortfahren?",
"enableOfflineMode": "Offline-Modus Aktivieren",
@@ -94,7 +99,9 @@
"sandboxModeWarning": "Bearbeitungen von Produktionsknoten können nicht an die Sandbox übertragen werden. Wechseln Sie in den Produktionsmodus in den Einstellungen, um Knoten zu bearbeiten.",
"enableSubmittableProfile": "Aktivieren Sie ein übertragbares Profil in den Einstellungen, um Knoten zu bearbeiten.",
"profileViewOnlyWarning": "Dieses Profil ist nur zum Anzeigen der Karte gedacht. Bitte wählen Sie ein übertragbares Profil aus, um Knoten zu bearbeiten.",
"cannotMoveConstrainedNode": "Kann diese Kamera nicht verschieben - sie ist mit einer Straße oder einem Bereich verbunden (OSM-Weg/Relation). Sie können trotzdem ihre Tags und Richtung 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.",
"extractFromWay": "Knoten aus Weg/Relation extrahieren",
"extractFromWaySubtitle": "Neuen Knoten mit gleichen Tags erstellen, Verschieben an neuen Ort ermöglichen",
"refineTags": "Tags Verfeinern",
"refineTagsWithProfile": "Tags Verfeinern ({})"
},
@@ -122,6 +129,8 @@
"simulateDescription": "Uploads simulieren (kontaktiert OSM-Server nicht)"
},
"auth": {
"osmAccountTitle": "OpenStreetMap-Konto",
"osmAccountSubtitle": "Ihr OSM-Login verwalten und Ihre Beiträge einsehen",
"loggedInAs": "Angemeldet als {}",
"loginToOSM": "Bei OpenStreetMap anmelden",
"tapToLogout": "Zum Abmelden antippen",
@@ -131,6 +140,11 @@
"testConnectionSubtitle": "OSM-Anmeldedaten überprüfen",
"connectionOK": "Verbindung OK - Anmeldedaten sind gültig",
"connectionFailed": "Verbindung fehlgeschlagen - bitte erneut anmelden",
"viewMyEdits": "Meine Änderungen bei OSM Anzeigen",
"viewMyEditsSubtitle": "Ihr Bearbeitungsverlauf bei OpenStreetMap einsehen",
"aboutOSM": "Über OpenStreetMap",
"aboutOSMDescription": "OpenStreetMap ist ein gemeinschaftliches Open-Source-Kartenprojekt, bei dem Mitwirkende eine kostenlose, bearbeitbare Karte der Welt erstellen und pflegen. Ihre Beiträge zu Überwachungsgeräten helfen dabei, diese Infrastruktur sichtbar und durchsuchbar zu machen.",
"visitOSM": "OpenStreetMap Besuchen",
"deleteAccount": "OSM-Konto Löschen",
"deleteAccountSubtitle": "Ihr OpenStreetMap-Konto verwalten",
"deleteAccountExplanation": "Um Ihr OpenStreetMap-Konto zu löschen, müssen Sie die OpenStreetMap-Website besuchen. Dies entfernt dauerhaft Ihr OSM-Konto und alle zugehörigen Daten.",
@@ -138,7 +152,11 @@
"goToOSM": "Zu OpenStreetMap gehen"
},
"queue": {
"title": "Upload-Warteschlange",
"subtitle": "Ausstehende Überwachungsgeräte-Uploads verwalten",
"pendingUploads": "Ausstehende Uploads: {}",
"pendingItemsCount": "Ausstehende Elemente: {}",
"nothingInQueue": "Warteschlange ist leer",
"simulateModeEnabled": "Simulationsmodus aktiviert Uploads simuliert",
"sandboxMode": "Sandbox-Modus Uploads gehen an OSM Sandbox",
"tapToViewQueue": "Zum Anzeigen der Warteschlange antippen",
@@ -306,6 +324,27 @@
"selectMapLayer": "Kartenschicht Auswählen",
"noTileProvidersAvailable": "Keine Kachel-Anbieter verfügbar"
},
"advancedEdit": {
"title": "Erweiterte Bearbeitungsoptionen",
"subtitle": "Diese Editoren bieten erweiterte Funktionen für komplexe Bearbeitungen.",
"webEditors": "Web-Editoren",
"mobileEditors": "Mobile Editoren",
"iDEditor": "iD Editor",
"iDEditorSubtitle": "Voll ausgestatteter Web-Editor - funktioniert immer",
"rapidEditor": "RapiD Editor",
"rapidEditorSubtitle": "KI-unterstütztes Bearbeiten mit Facebook-Daten",
"vespucci": "Vespucci",
"vespucciSubtitle": "Erweiterte Android OSM-Editor",
"streetComplete": "StreetComplete",
"streetCompleteSubtitle": "Umfragebasierte Mapping-App",
"everyDoor": "EveryDoor",
"everyDoorSubtitle": "Schnelle POI-Bearbeitung",
"goMap": "Go Map!!",
"goMapSubtitle": "iOS OSM-Editor",
"couldNotOpenEditor": "Editor konnte nicht geöffnet werden - App möglicherweise nicht installiert",
"couldNotOpenURL": "URL konnte nicht geöffnet werden",
"couldNotOpenOSMWebsite": "OSM-Website konnte nicht geöffnet werden"
},
"networkStatus": {
"showIndicator": "Netzwerkstatus-Anzeige anzeigen",
"showIndicatorSubtitle": "Netzwerk-Ladestatus und Fehlerstatus auf der Karte anzeigen",

View File

@@ -34,7 +34,10 @@
"close": "Close",
"submit": "Submit",
"saveEdit": "Save Edit",
"clear": "Clear"
"clear": "Clear",
"viewOnOSM": "View on OSM",
"advanced": "Advanced",
"useAdvancedEditor": "Use Advanced Editor"
},
"followMe": {
"off": "Enable follow-me",
@@ -54,6 +57,8 @@
"maxNodesWarning": "You probably don't want to do that unless you are absolutely sure you have a good reason for it.",
"offlineMode": "Offline Mode",
"offlineModeSubtitle": "Disable all network requests except for local/offline areas.",
"pauseQueueProcessing": "Pause Upload Queue",
"pauseQueueProcessingSubtitle": "Stop uploading queued changes while keeping live data access.",
"offlineModeWarningTitle": "Active Downloads",
"offlineModeWarningMessage": "Enabling offline mode will cancel any active area downloads. Do you want to continue?",
"enableOfflineMode": "Enable Offline Mode",
@@ -112,7 +117,9 @@
"sandboxModeWarning": "Cannot submit edits on production nodes to sandbox. Switch to Production mode in Settings to edit nodes.",
"enableSubmittableProfile": "Enable a submittable profile in Settings to edit nodes.",
"profileViewOnlyWarning": "This profile is for map viewing only. Please select a submittable profile to edit nodes.",
"cannotMoveConstrainedNode": "Cannot move this camera - it's connected to a street or area (OSM way/relation). You can still edit its tags and direction.",
"cannotMoveConstrainedNode": "Cannot move this camera - it's connected to another map element (OSM way/relation). You can still edit its tags and direction.",
"extractFromWay": "Extract node from way/relation",
"extractFromWaySubtitle": "Create new node with same tags, allow moving to new location",
"refineTags": "Refine Tags",
"refineTagsWithProfile": "Refine Tags ({})"
},
@@ -140,6 +147,8 @@
"simulateDescription": "Simulate uploads (does not contact OSM servers)"
},
"auth": {
"osmAccountTitle": "OpenStreetMap Account",
"osmAccountSubtitle": "Manage your OSM login and view your contributions",
"loggedInAs": "Logged in as {}",
"loginToOSM": "Log in to OpenStreetMap",
"tapToLogout": "Tap to logout",
@@ -149,6 +158,11 @@
"testConnectionSubtitle": "Verify OSM credentials are working",
"connectionOK": "Connection OK - credentials are valid",
"connectionFailed": "Connection failed - please re-login",
"viewMyEdits": "View My Edits on OSM",
"viewMyEditsSubtitle": "See your edit history on OpenStreetMap",
"aboutOSM": "About OpenStreetMap",
"aboutOSMDescription": "OpenStreetMap is a collaborative, open-source mapping project where contributors create and maintain a free, editable map of the world. Your surveillance device contributions help make this infrastructure visible and searchable.",
"visitOSM": "Visit OpenStreetMap",
"deleteAccount": "Delete OSM Account",
"deleteAccountSubtitle": "Manage your OpenStreetMap account",
"deleteAccountExplanation": "To delete your OpenStreetMap account, you'll need to visit the OpenStreetMap website. This will permanently remove your OSM account and all associated data.",
@@ -156,7 +170,11 @@
"goToOSM": "Go to OpenStreetMap"
},
"queue": {
"title": "Upload Queue",
"subtitle": "Manage pending surveillance device uploads",
"pendingUploads": "Pending uploads: {}",
"pendingItemsCount": "Pending Items: {}",
"nothingInQueue": "Nothing in queue",
"simulateModeEnabled": "Simulate mode enabled uploads simulated",
"sandboxMode": "Sandbox mode uploads go to OSM Sandbox",
"tapToViewQueue": "Tap to view queue",
@@ -324,6 +342,27 @@
"selectMapLayer": "Select Map Layer",
"noTileProvidersAvailable": "No tile providers available"
},
"advancedEdit": {
"title": "Advanced Editing Options",
"subtitle": "These editors offer more advanced features for complex edits.",
"webEditors": "Web Editors",
"mobileEditors": "Mobile Editors",
"iDEditor": "iD Editor",
"iDEditorSubtitle": "Full-featured web editor - always works",
"rapidEditor": "RapiD Editor",
"rapidEditorSubtitle": "AI-assisted editing with Facebook data",
"vespucci": "Vespucci",
"vespucciSubtitle": "Advanced Android OSM editor",
"streetComplete": "StreetComplete",
"streetCompleteSubtitle": "Survey-based mapping app",
"everyDoor": "EveryDoor",
"everyDoorSubtitle": "Fast POI editing",
"goMap": "Go Map!!",
"goMapSubtitle": "iOS OSM editor",
"couldNotOpenEditor": "Could not open editor - app may not be installed",
"couldNotOpenURL": "Could not open URL",
"couldNotOpenOSMWebsite": "Could not open OSM website"
},
"networkStatus": {
"showIndicator": "Show network status indicator",
"showIndicatorSubtitle": "Display network loading and error status on the map",

View File

@@ -34,7 +34,10 @@
"close": "Cerrar",
"submit": "Enviar",
"saveEdit": "Guardar Edición",
"clear": "Limpiar"
"clear": "Limpiar",
"viewOnOSM": "Ver en OSM",
"advanced": "Avanzado",
"useAdvancedEditor": "Usar Editor Avanzado"
},
"followMe": {
"off": "Activar seguimiento",
@@ -54,6 +57,8 @@
"maxNodesWarning": "Probablemente no quieras hacer eso a menos que estés absolutamente seguro de que tienes una buena razón para ello.",
"offlineMode": "Modo Sin Conexión",
"offlineModeSubtitle": "Deshabilitar todas las solicitudes de red excepto para áreas locales/sin conexión.",
"pauseQueueProcessing": "Pausar Cola de Subida",
"pauseQueueProcessingSubtitle": "Detener la subida de cambios en cola manteniendo acceso a datos en vivo.",
"offlineModeWarningTitle": "Descargas Activas",
"offlineModeWarningMessage": "Habilitar el modo sin conexión cancelará cualquier descarga de área activa. ¿Desea continuar?",
"enableOfflineMode": "Habilitar Modo Sin Conexión",
@@ -112,7 +117,9 @@
"sandboxModeWarning": "No se pueden enviar ediciones de nodos de producción al sandbox. Cambie al modo Producción en Configuración para editar nodos.",
"enableSubmittableProfile": "Habilite un perfil envíable en Configuración para editar nodos.",
"profileViewOnlyWarning": "Este perfil es solo para visualización del mapa. Por favor, seleccione un perfil envíable para editar nodos.",
"cannotMoveConstrainedNode": "No se puede mover esta cámara - está conectada a una calle o área (OSM way/relation). Aún puede editar sus etiquetas y dirección.",
"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.",
"extractFromWay": "Extraer nodo de way/relation",
"extractFromWaySubtitle": "Crear nuevo nodo con las mismas etiquetas, permitir mover a nueva ubicación",
"refineTags": "Refinar Etiquetas",
"refineTagsWithProfile": "Refinar Etiquetas ({})"
},
@@ -140,6 +147,8 @@
"simulateDescription": "Simular subidas (no contacta servidores OSM)"
},
"auth": {
"osmAccountTitle": "Cuenta de OpenStreetMap",
"osmAccountSubtitle": "Gestionar tu login de OSM y ver tus contribuciones",
"loggedInAs": "Conectado como {}",
"loginToOSM": "Iniciar sesión en OpenStreetMap",
"tapToLogout": "Toque para cerrar sesión",
@@ -149,6 +158,11 @@
"testConnectionSubtitle": "Verificar que las credenciales de OSM funcionen",
"connectionOK": "Conexión OK - las credenciales son válidas",
"connectionFailed": "Conexión falló - por favor, inicie sesión nuevamente",
"viewMyEdits": "Ver Mis Ediciones en OSM",
"viewMyEditsSubtitle": "Ver tu historial de ediciones en OpenStreetMap",
"aboutOSM": "Acerca de OpenStreetMap",
"aboutOSMDescription": "OpenStreetMap es un proyecto de mapeo colaborativo de código abierto donde los contribuyentes crean y mantienen un mapa gratuito y editable del mundo. Tus contribuciones de dispositivos de vigilancia ayudan a hacer visible y buscable esta infraestructura.",
"visitOSM": "Visitar OpenStreetMap",
"deleteAccount": "Eliminar Cuenta OSM",
"deleteAccountSubtitle": "Gestiona tu cuenta de OpenStreetMap",
"deleteAccountExplanation": "Para eliminar tu cuenta de OpenStreetMap, necesitarás visitar el sitio web de OpenStreetMap. Esto eliminará permanentemente tu cuenta OSM y todos los datos asociados.",
@@ -156,7 +170,11 @@
"goToOSM": "Ir a OpenStreetMap"
},
"queue": {
"title": "Cola de Subida",
"subtitle": "Gestionar subidas pendientes de dispositivos de vigilancia",
"pendingUploads": "Subidas pendientes: {}",
"pendingItemsCount": "Elementos Pendientes: {}",
"nothingInQueue": "No hay nada en la cola",
"simulateModeEnabled": "Modo simulación activado subidas simuladas",
"sandboxMode": "Modo sandbox subidas van al Sandbox OSM",
"tapToViewQueue": "Toque para ver cola",
@@ -324,6 +342,27 @@
"selectMapLayer": "Seleccionar Capa del Mapa",
"noTileProvidersAvailable": "No hay proveedores de teselas disponibles"
},
"advancedEdit": {
"title": "Opciones de Edición Avanzada",
"subtitle": "Estos editores ofrecen funciones más avanzadas para ediciones complejas.",
"webEditors": "Editores Web",
"mobileEditors": "Editores Móviles",
"iDEditor": "Editor iD",
"iDEditorSubtitle": "Editor web completo - siempre funciona",
"rapidEditor": "Editor RapiD",
"rapidEditorSubtitle": "Edición asistida por IA con datos de Facebook",
"vespucci": "Vespucci",
"vespucciSubtitle": "Editor OSM avanzado para Android",
"streetComplete": "StreetComplete",
"streetCompleteSubtitle": "Aplicación de mapeo basada en encuestas",
"everyDoor": "EveryDoor",
"everyDoorSubtitle": "Edición rápida de POI",
"goMap": "Go Map!!",
"goMapSubtitle": "Editor OSM para iOS",
"couldNotOpenEditor": "No se pudo abrir el editor - la aplicación puede no estar instalada",
"couldNotOpenURL": "No se pudo abrir la URL",
"couldNotOpenOSMWebsite": "No se pudo abrir el sitio web de OSM"
},
"networkStatus": {
"showIndicator": "Mostrar indicador de estado de red",
"showIndicatorSubtitle": "Mostrar estado de carga y errores de red en el mapa",

View File

@@ -34,7 +34,10 @@
"close": "Fermer",
"submit": "Soumettre",
"saveEdit": "Sauvegarder Modification",
"clear": "Effacer"
"clear": "Effacer",
"viewOnOSM": "Voir sur OSM",
"advanced": "Avancé",
"useAdvancedEditor": "Utiliser l'Éditeur Avancé"
},
"followMe": {
"off": "Activer le suivi",
@@ -54,6 +57,8 @@
"maxNodesWarning": "Vous ne voulez probablement pas faire cela à moins d'être absolument sûr d'avoir une bonne raison de le faire.",
"offlineMode": "Mode Hors Ligne",
"offlineModeSubtitle": "Désactiver toutes les requêtes réseau sauf pour les zones locales/hors ligne.",
"pauseQueueProcessing": "Suspendre la File d'Upload",
"pauseQueueProcessingSubtitle": "Arrêter l'upload des modifications en attente tout en gardant l'accès aux données en direct.",
"offlineModeWarningTitle": "Téléchargements Actifs",
"offlineModeWarningMessage": "L'activation du mode hors ligne annulera tous les téléchargements de zone actifs. Voulez-vous continuer?",
"enableOfflineMode": "Activer le Mode Hors Ligne",
@@ -112,7 +117,9 @@
"sandboxModeWarning": "Impossible de soumettre des modifications de nœuds de production au sandbox. Passez au mode Production dans les Paramètres pour modifier les nœuds.",
"enableSubmittableProfile": "Activez un profil soumissible dans les Paramètres pour modifier les nœuds.",
"profileViewOnlyWarning": "Ce profil est uniquement pour la visualisation de la carte. Veuillez sélectionner un profil soumissible pour modifier les nœuds.",
"cannotMoveConstrainedNode": "Impossible de déplacer cette caméra - elle est connectée à une rue ou une zone (OSM way/relation). Vous pouvez toujours modifier ses balises et sa direction.",
"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.",
"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",
"refineTagsWithProfile": "Affiner Balises ({})"
},
@@ -140,6 +147,8 @@
"simulateDescription": "Simuler les téléchargements (ne contacte pas les serveurs OSM)"
},
"auth": {
"osmAccountTitle": "Compte OpenStreetMap",
"osmAccountSubtitle": "Gérer votre connexion OSM et voir vos contributions",
"loggedInAs": "Connecté en tant que {}",
"loginToOSM": "Se connecter à OpenStreetMap",
"tapToLogout": "Appuyer pour se déconnecter",
@@ -149,6 +158,11 @@
"testConnectionSubtitle": "Vérifier que les identifiants OSM fonctionnent",
"connectionOK": "Connexion OK - les identifiants sont valides",
"connectionFailed": "Connexion échouée - veuillez vous reconnecter",
"viewMyEdits": "Voir Mes Modifications sur OSM",
"viewMyEditsSubtitle": "Voir votre historique de modifications sur OpenStreetMap",
"aboutOSM": "À Propos d'OpenStreetMap",
"aboutOSMDescription": "OpenStreetMap est un projet cartographique collaboratif open source où les contributeurs créent et maintiennent une carte gratuite et modifiable du monde. Vos contributions de dispositifs de surveillance aident à rendre cette infrastructure visible et consultable.",
"visitOSM": "Visiter OpenStreetMap",
"deleteAccount": "Supprimer Compte OSM",
"deleteAccountSubtitle": "Gérez votre compte OpenStreetMap",
"deleteAccountExplanation": "Pour supprimer votre compte OpenStreetMap, vous devrez visiter le site web OpenStreetMap. Cela supprimera définitivement votre compte OSM et toutes les données associées.",
@@ -156,7 +170,11 @@
"goToOSM": "Aller à OpenStreetMap"
},
"queue": {
"title": "File de Téléchargement",
"subtitle": "Gérer les téléchargements de dispositifs de surveillance en attente",
"pendingUploads": "Téléchargements en attente: {}",
"pendingItemsCount": "Éléments en Attente: {}",
"nothingInQueue": "Rien dans la file",
"simulateModeEnabled": "Mode simulation activé téléchargements simulés",
"sandboxMode": "Mode sandbox téléchargements vont vers OSM Sandbox",
"tapToViewQueue": "Appuyer pour voir la file",
@@ -324,6 +342,27 @@
"selectMapLayer": "Sélectionner la Couche de Carte",
"noTileProvidersAvailable": "Aucun fournisseur de tuiles disponible"
},
"advancedEdit": {
"title": "Options d'Édition Avancées",
"subtitle": "Ces éditeurs offrent des fonctionnalités plus avancées pour les modifications complexes.",
"webEditors": "Éditeurs Web",
"mobileEditors": "Éditeurs Mobiles",
"iDEditor": "Éditeur iD",
"iDEditorSubtitle": "Éditeur web complet - fonctionne toujours",
"rapidEditor": "Éditeur RapiD",
"rapidEditorSubtitle": "Édition assistée par IA avec des données Facebook",
"vespucci": "Vespucci",
"vespucciSubtitle": "Éditeur OSM avancé Android",
"streetComplete": "StreetComplete",
"streetCompleteSubtitle": "Application de cartographie basée sur des enquêtes",
"everyDoor": "EveryDoor",
"everyDoorSubtitle": "Édition rapide de POI",
"goMap": "Go Map!!",
"goMapSubtitle": "Éditeur OSM iOS",
"couldNotOpenEditor": "Impossible d'ouvrir l'éditeur - l'application peut ne pas être installée",
"couldNotOpenURL": "Impossible d'ouvrir l'URL",
"couldNotOpenOSMWebsite": "Impossible d'ouvrir le site web OSM"
},
"networkStatus": {
"showIndicator": "Afficher l'indicateur de statut réseau",
"showIndicatorSubtitle": "Afficher l'état de chargement et d'erreur réseau sur la carte",

View File

@@ -34,7 +34,10 @@
"close": "Chiudi",
"submit": "Invia",
"saveEdit": "Salva Modifica",
"clear": "Pulisci"
"clear": "Pulisci",
"viewOnOSM": "Visualizza su OSM",
"advanced": "Avanzato",
"useAdvancedEditor": "Usa Editor Avanzato"
},
"followMe": {
"off": "Attiva seguimi",
@@ -54,6 +57,8 @@
"maxNodesWarning": "Probabilmente non vuoi farlo a meno che non sei assolutamente sicuro di avere una buona ragione per farlo.",
"offlineMode": "Modalità Offline",
"offlineModeSubtitle": "Disabilita tutte le richieste di rete tranne per aree locali/offline.",
"pauseQueueProcessing": "Pausa Coda Upload",
"pauseQueueProcessingSubtitle": "Ferma l'upload delle modifiche in coda mantenendo l'accesso ai dati dal vivo.",
"offlineModeWarningTitle": "Download Attivi",
"offlineModeWarningMessage": "L'attivazione della modalità offline cancellerà qualsiasi download di area attivo. Vuoi continuare?",
"enableOfflineMode": "Attiva Modalità Offline",
@@ -112,7 +117,9 @@
"sandboxModeWarning": "Impossibile inviare modifiche di nodi di produzione alla sandbox. Passa alla modalità Produzione nelle Impostazioni per modificare i nodi.",
"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 una strada o area (OSM way/relation). Puoi ancora modificare i suoi tag e direzione.",
"cannotMoveConstrainedNode": "Impossibile spostare questa telecamera - è collegata a un altro elemento della mappa (OSM way/relation). Puoi ancora modificare i suoi tag e direzione.",
"extractFromWay": "Estrai nodo da way/relation",
"extractFromWaySubtitle": "Crea nuovo nodo con gli stessi tag, consenti spostamento in nuova posizione",
"refineTags": "Affina Tag",
"refineTagsWithProfile": "Affina Tag ({})"
},
@@ -140,6 +147,8 @@
"simulateDescription": "Simula upload (non contatta i server OSM)"
},
"auth": {
"osmAccountTitle": "Account OpenStreetMap",
"osmAccountSubtitle": "Gestisci il tuo login OSM e visualizza i tuoi contributi",
"loggedInAs": "Loggato come {}",
"loginToOSM": "Accedi a OpenStreetMap",
"tapToLogout": "Tocca per disconnetterti",
@@ -149,6 +158,11 @@
"testConnectionSubtitle": "Verifica che le credenziali OSM funzionino",
"connectionOK": "Connessione OK - le credenziali sono valide",
"connectionFailed": "Connessione fallita - per favore accedi di nuovo",
"viewMyEdits": "Visualizza le Mie Modifiche su OSM",
"viewMyEditsSubtitle": "Visualizza la cronologia delle tue modifiche su OpenStreetMap",
"aboutOSM": "Informazioni su OpenStreetMap",
"aboutOSMDescription": "OpenStreetMap è un progetto cartografico collaborativo open source dove i contributori creano e mantengono una mappa gratuita e modificabile del mondo. I tuoi contributi sui dispositivi di sorveglianza aiutano a rendere visibile e ricercabile questa infrastruttura.",
"visitOSM": "Visita OpenStreetMap",
"deleteAccount": "Elimina Account OSM",
"deleteAccountSubtitle": "Gestisci il tuo account OpenStreetMap",
"deleteAccountExplanation": "Per eliminare il tuo account OpenStreetMap, dovrai visitare il sito web di OpenStreetMap. Questo rimuoverà permanentemente il tuo account OSM e tutti i dati associati.",
@@ -156,7 +170,11 @@
"goToOSM": "Vai a OpenStreetMap"
},
"queue": {
"title": "Coda di Upload",
"subtitle": "Gestisci gli upload di dispositivi di sorveglianza in sospeso",
"pendingUploads": "Upload in sospeso: {}",
"pendingItemsCount": "Elementi in Sospeso: {}",
"nothingInQueue": "Niente in coda",
"simulateModeEnabled": "Modalità simulazione abilitata upload simulati",
"sandboxMode": "Modalità sandbox upload vanno alla Sandbox OSM",
"tapToViewQueue": "Tocca per vedere la coda",
@@ -324,6 +342,27 @@
"selectMapLayer": "Seleziona Livello Mappa",
"noTileProvidersAvailable": "Nessun fornitore di tile disponibile"
},
"advancedEdit": {
"title": "Opzioni di Modifica Avanzate",
"subtitle": "Questi editor offrono funzionalità più avanzate per modifiche complesse.",
"webEditors": "Editor Web",
"mobileEditors": "Editor Mobili",
"iDEditor": "Editor iD",
"iDEditorSubtitle": "Editor web completo - funziona sempre",
"rapidEditor": "Editor RapiD",
"rapidEditorSubtitle": "Modifica assistita da IA con dati Facebook",
"vespucci": "Vespucci",
"vespucciSubtitle": "Editor OSM avanzato Android",
"streetComplete": "StreetComplete",
"streetCompleteSubtitle": "App di mappatura basata su sondaggi",
"everyDoor": "EveryDoor",
"everyDoorSubtitle": "Modifica rapida POI",
"goMap": "Go Map!!",
"goMapSubtitle": "Editor OSM iOS",
"couldNotOpenEditor": "Impossibile aprire l'editor - l'app potrebbe non essere installata",
"couldNotOpenURL": "Impossibile aprire l'URL",
"couldNotOpenOSMWebsite": "Impossibile aprire il sito web OSM"
},
"networkStatus": {
"showIndicator": "Mostra indicatore di stato di rete",
"showIndicatorSubtitle": "Visualizza lo stato di caricamento e errori di rete sulla mappa",

View File

@@ -34,7 +34,10 @@
"close": "Fechar",
"submit": "Enviar",
"saveEdit": "Salvar Edição",
"clear": "Limpar"
"clear": "Limpar",
"viewOnOSM": "Ver no OSM",
"advanced": "Avançado",
"useAdvancedEditor": "Usar Editor Avançado"
},
"followMe": {
"off": "Ativar seguir-me",
@@ -54,6 +57,8 @@
"maxNodesWarning": "Você provavelmente não quer fazer isso a menos que tenha certeza absoluta de que tem uma boa razão para isso.",
"offlineMode": "Modo Offline",
"offlineModeSubtitle": "Desabilitar todas as requisições de rede exceto para áreas locais/offline.",
"pauseQueueProcessing": "Pausar Fila de Upload",
"pauseQueueProcessingSubtitle": "Parar upload de alterações na fila mantendo acesso a dados ao vivo.",
"offlineModeWarningTitle": "Downloads Ativos",
"offlineModeWarningMessage": "Ativar o modo offline cancelará qualquer download de área ativo. Deseja continuar?",
"enableOfflineMode": "Ativar Modo Offline",
@@ -112,7 +117,9 @@
"sandboxModeWarning": "Não é possível enviar edições de nós de produção para o sandbox. Mude para o modo Produção nas Configurações para editar nós.",
"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 uma rua ou área (OSM way/relation). Você ainda pode editar suas tags e direção.",
"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.",
"extractFromWay": "Extrair nó do way/relation",
"extractFromWaySubtitle": "Criar novo nó com as mesmas tags, permitir mover para nova localização",
"refineTags": "Refinar Tags",
"refineTagsWithProfile": "Refinar Tags ({})"
},
@@ -140,6 +147,8 @@
"simulateDescription": "Simular uploads (não contacta servidores OSM)"
},
"auth": {
"osmAccountTitle": "Conta OpenStreetMap",
"osmAccountSubtitle": "Gerencie seu login OSM e visualize suas contribuições",
"loggedInAs": "Logado como {}",
"loginToOSM": "Fazer login no OpenStreetMap",
"tapToLogout": "Toque para sair",
@@ -149,6 +158,11 @@
"testConnectionSubtitle": "Verificar se as credenciais OSM estão funcionando",
"connectionOK": "Conexão OK - credenciais são válidas",
"connectionFailed": "Conexão falhou - por favor, faça login novamente",
"viewMyEdits": "Ver Minhas Edições no OSM",
"viewMyEditsSubtitle": "Ver seu histórico de edições no OpenStreetMap",
"aboutOSM": "Sobre OpenStreetMap",
"aboutOSMDescription": "OpenStreetMap é um projeto de mapeamento colaborativo de código aberto onde os contribuintes criam e mantêm um mapa gratuito e editável do mundo. Suas contribuições de dispositivos de vigilância ajudam a tornar esta infraestrutura visível e pesquisável.",
"visitOSM": "Visitar OpenStreetMap",
"deleteAccount": "Excluir Conta OSM",
"deleteAccountSubtitle": "Gerencie sua conta OpenStreetMap",
"deleteAccountExplanation": "Para excluir sua conta OpenStreetMap, você precisará visitar o site do OpenStreetMap. Isso removerá permanentemente sua conta OSM e todos os dados associados.",
@@ -156,7 +170,11 @@
"goToOSM": "Ir para OpenStreetMap"
},
"queue": {
"title": "Fila de Upload",
"subtitle": "Gerenciar uploads pendentes de dispositivos de vigilância",
"pendingUploads": "Uploads pendentes: {}",
"pendingItemsCount": "Itens Pendentes: {}",
"nothingInQueue": "Nada na fila",
"simulateModeEnabled": "Modo simulação ativado uploads simulados",
"sandboxMode": "Modo sandbox uploads vão para o Sandbox OSM",
"tapToViewQueue": "Toque para ver a fila",
@@ -324,6 +342,27 @@
"selectMapLayer": "Selecionar Camada do Mapa",
"noTileProvidersAvailable": "Nenhum provedor de tiles disponível"
},
"advancedEdit": {
"title": "Opções de Edição Avançada",
"subtitle": "Estes editores oferecem recursos mais avançados para edições complexas.",
"webEditors": "Editores Web",
"mobileEditors": "Editores Móveis",
"iDEditor": "Editor iD",
"iDEditorSubtitle": "Editor web completo - sempre funciona",
"rapidEditor": "Editor RapiD",
"rapidEditorSubtitle": "Edição assistida por IA com dados do Facebook",
"vespucci": "Vespucci",
"vespucciSubtitle": "Editor OSM avançado para Android",
"streetComplete": "StreetComplete",
"streetCompleteSubtitle": "Aplicativo de mapeamento baseado em pesquisas",
"everyDoor": "EveryDoor",
"everyDoorSubtitle": "Edição rápida de POI",
"goMap": "Go Map!!",
"goMapSubtitle": "Editor OSM iOS",
"couldNotOpenEditor": "Não foi possível abrir o editor - aplicativo pode não estar instalado",
"couldNotOpenURL": "Não foi possível abrir a URL",
"couldNotOpenOSMWebsite": "Não foi possível abrir o site do OSM"
},
"networkStatus": {
"showIndicator": "Exibir indicador de status de rede",
"showIndicatorSubtitle": "Mostrar status de carregamento e erro de rede no mapa",

View File

@@ -34,7 +34,10 @@
"close": "关闭",
"submit": "提交",
"saveEdit": "保存编辑",
"clear": "清空"
"clear": "清空",
"viewOnOSM": "在OSM上查看",
"advanced": "高级",
"useAdvancedEditor": "使用高级编辑器"
},
"followMe": {
"off": "启用跟随模式",
@@ -54,6 +57,8 @@
"maxNodesWarning": "除非您确定有充分的理由,否则您可能不想这样做。",
"offlineMode": "离线模式",
"offlineModeSubtitle": "禁用除本地/离线区域外的所有网络请求。",
"pauseQueueProcessing": "暂停上传队列",
"pauseQueueProcessingSubtitle": "停止上传排队的更改,同时保持实时数据访问。",
"offlineModeWarningTitle": "活动下载",
"offlineModeWarningMessage": "启用离线模式将取消任何活动的区域下载。您要继续吗?",
"enableOfflineMode": "启用离线模式",
@@ -112,7 +117,9 @@
"sandboxModeWarning": "无法将生产节点的编辑提交到沙盒。在设置中切换到生产模式以编辑节点。",
"enableSubmittableProfile": "在设置中启用可提交的配置文件以编辑节点。",
"profileViewOnlyWarning": "此配置文件仅用于地图查看。请选择可提交的配置文件来编辑节点。",
"cannotMoveConstrainedNode": "无法移动此相机 - 它连接到街道或区域OSM way/relation。您仍可以编辑其标签和方向。",
"cannotMoveConstrainedNode": "无法移动此相机 - 它连接到另一个地图元素OSM way/relation。您仍可以编辑其标签和方向。",
"extractFromWay": "从way/relation中提取节点",
"extractFromWaySubtitle": "创建具有相同标签的新节点,允许移动到新位置",
"refineTags": "细化标签",
"refineTagsWithProfile": "细化标签({}"
},
@@ -140,6 +147,8 @@
"simulateDescription": "模拟上传(不联系 OSM 服务器)"
},
"auth": {
"osmAccountTitle": "OpenStreetMap 账户",
"osmAccountSubtitle": "管理您的 OSM 登录并查看您的贡献",
"loggedInAs": "已登录为 {}",
"loginToOSM": "登录 OpenStreetMap",
"tapToLogout": "点击登出",
@@ -149,6 +158,11 @@
"testConnectionSubtitle": "验证 OSM 凭据是否有效",
"connectionOK": "连接正常 - 凭据有效",
"connectionFailed": "连接失败 - 请重新登录",
"viewMyEdits": "在 OSM 上查看我的编辑",
"viewMyEditsSubtitle": "查看您在 OpenStreetMap 上的编辑历史",
"aboutOSM": "关于 OpenStreetMap",
"aboutOSMDescription": "OpenStreetMap 是一个协作的开源地图项目,贡献者创建和维护一个免费的、可编辑的世界地图。您的监控设备贡献有助于使这种基础设施可见和可搜索。",
"visitOSM": "访问 OpenStreetMap",
"deleteAccount": "删除 OSM 账户",
"deleteAccountSubtitle": "管理您的 OpenStreetMap 账户",
"deleteAccountExplanation": "要删除您的 OpenStreetMap 账户,您需要访问 OpenStreetMap 网站。这将永久删除您的 OSM 账户和所有相关数据。",
@@ -156,7 +170,11 @@
"goToOSM": "前往 OpenStreetMap"
},
"queue": {
"title": "上传队列",
"subtitle": "管理待上传的监控设备",
"pendingUploads": "待上传:{}",
"pendingItemsCount": "待处理项目:{}",
"nothingInQueue": "队列中没有内容",
"simulateModeEnabled": "模拟模式已启用 上传已模拟",
"sandboxMode": "沙盒模式 上传到 OSM 沙盒",
"tapToViewQueue": "点击查看队列",
@@ -324,6 +342,27 @@
"selectMapLayer": "选择地图图层",
"noTileProvidersAvailable": "无可用瓦片提供商"
},
"advancedEdit": {
"title": "高级编辑选项",
"subtitle": "这些编辑器为复杂编辑提供更高级的功能。",
"webEditors": "网页编辑器",
"mobileEditors": "移动编辑器",
"iDEditor": "iD 编辑器",
"iDEditorSubtitle": "功能完整的网页编辑器 - 始终有效",
"rapidEditor": "RapiD 编辑器",
"rapidEditorSubtitle": "使用Facebook数据的AI辅助编辑",
"vespucci": "Vespucci",
"vespucciSubtitle": "高级Android OSM编辑器",
"streetComplete": "StreetComplete",
"streetCompleteSubtitle": "基于调查的地图应用",
"everyDoor": "EveryDoor",
"everyDoorSubtitle": "快速POI编辑",
"goMap": "Go Map!!",
"goMapSubtitle": "iOS OSM编辑器",
"couldNotOpenEditor": "无法打开编辑器 - 应用可能未安装",
"couldNotOpenURL": "无法打开URL",
"couldNotOpenOSMWebsite": "无法打开OSM网站"
},
"networkStatus": {
"showIndicator": "显示网络状态指示器",
"showIndicatorSubtitle": "在地图上显示网络加载和错误状态",

View File

@@ -8,9 +8,12 @@ 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';
import 'screens/osm_account_screen.dart';
import 'screens/upload_queue_screen.dart';
import 'services/localization_service.dart';
import 'services/version_service.dart';
@@ -69,10 +72,13 @@ class DeFlockApp extends StatelessWidget {
routes: {
'/': (context) => const HomeScreen(),
'/settings': (context) => const SettingsScreen(),
'/settings/osm-account': (context) => const OSMAccountScreen(),
'/settings/queue': (context) => const UploadQueueScreen(),
'/settings/profiles': (context) => const ProfilesSettingsScreen(),
'/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(),

View File

@@ -3,7 +3,7 @@ import 'node_profile.dart';
import 'operator_profile.dart';
import '../state/settings_state.dart';
enum UploadOperation { create, modify, delete }
enum UploadOperation { create, modify, delete, extract }
class PendingUpload {
final LatLng coord;
@@ -32,12 +32,12 @@ class PendingUpload {
this.completing = false,
}) : assert(
(operation == UploadOperation.create && originalNodeId == null) ||
(operation != UploadOperation.create && originalNodeId != null),
'originalNodeId must be null for create operations and non-null for modify/delete operations'
(operation == UploadOperation.create) || (originalNodeId != null),
'originalNodeId must be null for create operations and non-null for modify/delete/extract operations'
),
assert(
(operation == UploadOperation.delete) || (profile != null),
'profile is required for create and modify operations'
'profile is required for create, modify, and extract operations'
);
// True if this is an edit of an existing node, false if it's a new node
@@ -45,6 +45,9 @@ class PendingUpload {
// True if this is a deletion of an existing node
bool get isDeletion => operation == UploadOperation.delete;
// True if this is an extract operation (new node with tags from constrained node)
bool get isExtraction => operation == UploadOperation.extract;
// Get display name for the upload destination
String get uploadModeDisplayName {

View 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(),
),
);
}
}

View File

@@ -708,71 +708,76 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
// Bottom button bar (restored to original)
Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom + kBottomButtonBarOffset,
left: 8,
right: 8,
),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600), // Match typical sheet width
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Theme.of(context).shadowColor.withOpacity(0.3),
blurRadius: 10,
offset: Offset(0, -2),
)
],
child: Builder(
builder: (context) {
final safeArea = MediaQuery.of(context).padding;
return Padding(
padding: EdgeInsets.only(
bottom: safeArea.bottom + dev.kBottomButtonBarOffset,
left: leftPositionWithSafeArea(8, safeArea),
right: rightPositionWithSafeArea(8, safeArea),
),
margin: EdgeInsets.only(bottom: kBottomButtonBarOffset),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
children: [
Expanded(
flex: 7, // 70% for primary action
child: AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) => ElevatedButton.icon(
icon: Icon(Icons.add_location_alt),
label: Text(LocalizationService.instance.tagNode),
onPressed: _openAddNodeSheet,
style: ElevatedButton.styleFrom(
minimumSize: Size(0, 48),
textStyle: TextStyle(fontSize: 16),
),
),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600), // Match typical sheet width
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Theme.of(context).shadowColor.withOpacity(0.3),
blurRadius: 10,
offset: Offset(0, -2),
)
],
),
),
SizedBox(width: 12),
Expanded(
flex: 3, // 30% for secondary action
child: AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) => FittedBox(
fit: BoxFit.scaleDown,
child: ElevatedButton.icon(
icon: Icon(Icons.download_for_offline),
label: Text(LocalizationService.instance.download),
onPressed: () => showDialog(
context: context,
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
),
style: ElevatedButton.styleFrom(
minimumSize: Size(0, 48),
textStyle: TextStyle(fontSize: 16),
margin: EdgeInsets.only(bottom: dev.kBottomButtonBarOffset),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
children: [
Expanded(
flex: 7, // 70% for primary action
child: AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) => ElevatedButton.icon(
icon: Icon(Icons.add_location_alt),
label: Text(LocalizationService.instance.tagNode),
onPressed: _openAddNodeSheet,
style: ElevatedButton.styleFrom(
minimumSize: Size(0, 48),
textStyle: TextStyle(fontSize: 16),
),
),
),
),
),
SizedBox(width: 12),
Expanded(
flex: 3, // 30% for secondary action
child: AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) => FittedBox(
fit: BoxFit.scaleDown,
child: ElevatedButton.icon(
icon: Icon(Icons.download_for_offline),
label: Text(LocalizationService.instance.download),
onPressed: () => showDialog(
context: context,
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
),
style: ElevatedButton.styleFrom(
minimumSize: Size(0, 48),
textStyle: TextStyle(fontSize: 16),
),
),
),
),
),
],
),
),
],
),
),
),
),
);
},
),
),
],

View File

@@ -0,0 +1,176 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../app_state.dart';
import '../services/localization_service.dart';
import '../dev_config.dart';
import '../state/settings_state.dart';
import '../screens/settings/sections/upload_mode_section.dart';
class OSMAccountScreen extends StatelessWidget {
const OSMAccountScreen({super.key});
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
final appState = context.watch<AppState>();
return Scaffold(
appBar: AppBar(
title: Text(locService.t('auth.osmAccountTitle')),
),
body: ListView(
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.of(context).padding.bottom,
),
children: [
// Login/Account Status Section
Card(
child: Column(
children: [
ListTile(
leading: Icon(
appState.isLoggedIn ? Icons.person : Icons.login,
color: appState.isLoggedIn ? Colors.green : null,
),
title: Text(appState.isLoggedIn
? locService.t('auth.loggedInAs', params: [appState.username])
: locService.t('auth.loginToOSM')),
subtitle: appState.isLoggedIn
? Text(locService.t('auth.tapToLogout'))
: Text(locService.t('auth.requiredToSubmit')),
onTap: () async {
if (appState.isLoggedIn) {
await appState.logout();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(locService.t('auth.loggedOut')),
backgroundColor: Colors.grey,
),
);
}
} else {
// Start login flow - the user will be redirected to browser
await appState.forceLogin();
// Don't show immediate feedback - the UI will update automatically
// when the OAuth callback completes and notifyListeners() is called
}
},
),
if (appState.isLoggedIn) ...[
const Divider(),
ListTile(
leading: const Icon(Icons.wifi_protected_setup),
title: Text(locService.t('auth.testConnection')),
subtitle: Text(locService.t('auth.testConnectionSubtitle')),
onTap: () async {
final isValid = await appState.validateToken();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(isValid
? locService.t('auth.connectionOK')
: locService.t('auth.connectionFailed')),
backgroundColor: isValid ? Colors.green : Colors.red,
),
);
}
if (!isValid) {
await appState.logout();
}
},
),
const Divider(),
ListTile(
leading: const Icon(Icons.history),
title: Text(locService.t('auth.viewMyEdits')),
subtitle: Text(locService.t('auth.viewMyEditsSubtitle')),
trailing: const Icon(Icons.open_in_new),
onTap: () async {
final url = Uri.parse('https://openstreetmap.org/user/${Uri.encodeComponent(appState.username)}/history');
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(locService.t('advancedEdit.couldNotOpenOSMWebsite'))),
);
}
}
},
),
],
],
),
),
const SizedBox(height: 16),
// Upload Mode Section (only show in development builds)
if (dev.kEnableDevelopmentModes) ...[
Card(
child: const Padding(
padding: EdgeInsets.all(16.0),
child: UploadModeSection(),
),
),
const SizedBox(height: 16),
],
// Information Section
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('auth.aboutOSM'),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
locService.t('auth.aboutOSMDescription'),
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () async {
final url = Uri.parse('https://openstreetmap.org');
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(locService.t('advancedEdit.couldNotOpenOSMWebsite'))),
);
}
}
},
icon: const Icon(Icons.open_in_new),
label: Text(locService.t('auth.visitOSM')),
),
),
],
),
),
),
],
),
);
},
);
}
}

View File

@@ -77,6 +77,27 @@ class OfflineModeSection extends StatelessWidget {
onChanged: (value) => _handleOfflineModeChange(context, appState, value),
),
),
const SizedBox(height: 8),
ListTile(
leading: Icon(
Icons.pause_circle_outline,
color: appState.offlineMode
? Theme.of(context).disabledColor
: Theme.of(context).iconTheme.color,
),
title: Text(
locService.t('settings.pauseQueueProcessingSubtitle'),
style: appState.offlineMode
? TextStyle(color: Theme.of(context).disabledColor)
: null,
),
trailing: Switch(
value: appState.pauseQueueProcessing,
onChanged: appState.offlineMode
? null // Disable when offline mode is on
: (value) => appState.setPauseQueueProcessing(value),
),
),
],
);
},

View File

@@ -1,14 +1,41 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'settings/sections/auth_section.dart';
import 'settings/sections/upload_mode_section.dart';
import 'settings/sections/queue_section.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;
@@ -25,14 +52,24 @@ class SettingsScreen extends StatelessWidget {
16 + MediaQuery.of(context).padding.bottom,
),
children: [
// Only show upload mode section in development builds
if (kEnableDevelopmentModes) ...[
const UploadModeSection(),
const Divider(),
],
const AuthSection(),
// OpenStreetMap Account
_buildNavigationTile(
context,
icon: Icons.account_circle,
title: locService.t('auth.osmAccountTitle'),
subtitle: locService.t('auth.osmAccountSubtitle'),
onTap: () => Navigator.pushNamed(context, '/settings/osm-account'),
),
const Divider(),
const QueueSection(),
// Upload Queue
_buildNavigationTile(
context,
icon: Icons.queue,
title: locService.t('queue.title'),
subtitle: locService.t('queue.subtitle'),
onTap: () => Navigator.pushNamed(context, '/settings/queue'),
),
const Divider(),
// Navigation to sub-pages
@@ -93,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,
),
),
],

View File

@@ -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));

View File

@@ -0,0 +1,189 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../app_state.dart';
import '../services/localization_service.dart';
import '../state/settings_state.dart';
class UploadQueueScreen extends StatelessWidget {
const UploadQueueScreen({super.key});
String _getUploadModeDisplayName(UploadMode mode) {
final locService = LocalizationService.instance;
switch (mode) {
case UploadMode.production:
return locService.t('uploadMode.production');
case UploadMode.sandbox:
return locService.t('uploadMode.sandbox');
case UploadMode.simulate:
return locService.t('uploadMode.simulate');
}
}
Color _getUploadModeColor(UploadMode mode) {
switch (mode) {
case UploadMode.production:
return Colors.green; // Green for production (real)
case UploadMode.sandbox:
return Colors.orange; // Orange for sandbox (testing)
case UploadMode.simulate:
return Colors.grey; // Grey for simulate (fake)
}
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
final appState = context.watch<AppState>();
return Scaffold(
appBar: AppBar(
title: Text(locService.t('queue.title')),
),
body: ListView(
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.of(context).padding.bottom,
),
children: [
// Clear Upload Queue button - always visible
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: appState.pendingCount > 0 ? () {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(locService.t('queue.clearQueueTitle')),
content: Text(locService.t('queue.clearQueueConfirm', params: [appState.pendingCount.toString()])),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(locService.cancel),
),
TextButton(
onPressed: () {
appState.clearQueue();
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(locService.t('queue.queueCleared'))),
);
},
child: Text(locService.t('actions.clear')),
),
],
),
);
} : null,
icon: const Icon(Icons.clear_all),
label: Text(locService.t('queue.clearUploadQueue')),
style: ElevatedButton.styleFrom(
backgroundColor: appState.pendingCount > 0 ? null : Theme.of(context).disabledColor.withOpacity(0.1),
),
),
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
// Queue list or empty message
if (appState.pendingUploads.isEmpty) ...[
Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
children: [
Icon(
Icons.check_circle_outline,
size: 64,
color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.4),
),
const SizedBox(height: 16),
Text(
locService.t('queue.nothingInQueue'),
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.6),
),
textAlign: TextAlign.center,
),
],
),
),
),
] else ...[
Text(
locService.t('queue.pendingItemsCount', params: [appState.pendingCount.toString()]),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
// Queue items
...appState.pendingUploads.asMap().entries.map((entry) {
final index = entry.key;
final upload = entry.value;
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: Icon(
upload.error ? Icons.error : Icons.camera_alt,
color: upload.error
? Colors.red
: _getUploadModeColor(upload.uploadMode),
),
title: Text(
locService.t('queue.cameraWithIndex', params: [(index + 1).toString()]) +
(upload.error ? locService.t('queue.error') : "") +
(upload.completing ? locService.t('queue.completing') : "")
),
subtitle: Text(
locService.t('queue.destination', params: [_getUploadModeDisplayName(upload.uploadMode)]) + '\n' +
locService.t('queue.latitude', params: [upload.coord.latitude.toStringAsFixed(6)]) + '\n' +
locService.t('queue.longitude', params: [upload.coord.longitude.toStringAsFixed(6)]) + '\n' +
locService.t('queue.direction', params: [
upload.direction is String
? upload.direction.toString()
: upload.direction.round().toString()
]) + '\n' +
locService.t('queue.attempts', params: [upload.attempts.toString()]) +
(upload.error ? "\n${locService.t('queue.uploadFailedRetry')}" : "")
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (upload.error && !upload.completing)
IconButton(
icon: const Icon(Icons.refresh),
color: Colors.orange,
tooltip: locService.t('queue.retryUpload'),
onPressed: () {
appState.retryUpload(upload);
},
),
if (upload.completing)
const Icon(Icons.check_circle, color: Colors.green)
else
IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
appState.removeFromQueue(upload);
},
),
],
),
),
);
}),
],
],
),
);
},
);
}
}

View File

@@ -17,6 +17,22 @@ class ChangelogService {
Map<String, dynamic>? _changelogData;
bool _initialized = false;
/// Parse changelog content from either string or array format
String? _parseChangelogContent(dynamic content) {
if (content == null) return null;
if (content is String) {
// Legacy format: single string with \n
return content.isEmpty ? null : content;
} else if (content is List) {
// New format: array of strings
final lines = content.whereType<String>().where((line) => line.isNotEmpty).toList();
return lines.isEmpty ? null : lines.join('\n');
}
return null;
}
/// Initialize the service by loading changelog data
Future<void> init() async {
if (_initialized) return;
@@ -89,8 +105,7 @@ class ChangelogService {
return null;
}
final content = versionData['content'] as String?;
return (content?.isEmpty == true) ? null : content;
return _parseChangelogContent(versionData['content']);
}
/// Get the changelog content that should be displayed (may be combined from multiple versions)
@@ -112,8 +127,7 @@ class ChangelogService {
final versionData = _changelogData![version] as Map<String, dynamic>?;
if (versionData == null) return null;
final content = versionData['content'] as String?;
return (content?.isEmpty == true) ? null : content;
return _parseChangelogContent(versionData['content']);
}
/// Get all changelog entries (for settings page)
@@ -125,7 +139,7 @@ class ChangelogService {
for (final entry in _changelogData!.entries) {
final version = entry.key;
final versionData = entry.value as Map<String, dynamic>?;
final content = versionData?['content'] as String?;
final content = _parseChangelogContent(versionData?['content']);
// Only include versions with non-empty content
if (content != null && content.isNotEmpty) {
@@ -203,7 +217,7 @@ class ChangelogService {
for (final entry in _changelogData!.entries) {
final version = entry.key;
final versionData = entry.value as Map<String, dynamic>?;
final content = versionData?['content'] as String?;
final content = _parseChangelogContent(versionData?['content']);
// Skip versions with empty content
if (content == null || content.isEmpty) continue;
@@ -220,7 +234,7 @@ class ChangelogService {
// Build changelog content
final intermediateChangelogs = intermediateVersions.map((version) {
final versionData = _changelogData![version] as Map<String, dynamic>;
final content = versionData['content'] as String;
final content = _parseChangelogContent(versionData['content'])!; // Safe to use ! here since we filtered empty content above
return '**Version $version:**\n$content';
}).toList();

View File

@@ -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(

View File

@@ -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

View File

@@ -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');
}

View File

@@ -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;

View File

@@ -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)',
},

View File

@@ -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);

View File

@@ -17,8 +17,8 @@ class Uploader {
try {
print('Uploader: Starting upload for node at ${p.coord.latitude}, ${p.coord.longitude}');
// Safety check: create and modify operations MUST have profiles
if ((p.operation == UploadOperation.create || p.operation == UploadOperation.modify) && p.profile == null) {
// Safety check: create, modify, and extract operations MUST have profiles
if ((p.operation == UploadOperation.create || p.operation == UploadOperation.modify || p.operation == UploadOperation.extract) && p.profile == null) {
print('Uploader: ERROR - ${p.operation.name} operation attempted without profile data');
return false;
}
@@ -35,13 +35,16 @@ class Uploader {
case UploadOperation.delete:
action = 'Delete';
break;
case UploadOperation.extract:
action = 'Extract';
break;
}
// Generate appropriate comment based on operation type
final profileName = p.profile?.name ?? 'surveillance';
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>''';
@@ -141,6 +144,23 @@ class Uploader {
nodeResp = await _delete('/api/0.6/node/${p.originalNodeId}', nodeXml);
nodeId = p.originalNodeId.toString();
break;
case UploadOperation.extract:
// Extract creates a new node with tags from the original node
// The new node is created at the session's target coordinates
final mergedTags = p.getCombinedTags();
final tagsXml = mergedTags.entries.map((e) =>
'<tag k="${e.key}" v="${e.value}"/>').join('\n ');
final nodeXml = '''
<osm>
<node changeset="$csId" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
$tagsXml
</node>
</osm>''';
print('Uploader: Extracting node from ${p.originalNodeId} to create new node...');
nodeResp = await _put('/api/0.6/node/create', nodeXml);
nodeId = nodeResp.body.trim();
break;
}
print('Uploader: Node response: ${nodeResp.statusCode} - ${nodeResp.body}');

View File

@@ -34,12 +34,14 @@ class EditNodeSession {
LatLng target; // Current position (can be dragged)
List<double> directions; // All directions [90, 180, 270]
int currentDirectionIndex; // Which direction we're editing (e.g. 1 = editing the 180°)
bool extractFromWay; // True if user wants to extract this constrained node
EditNodeSession({
required this.originalNode,
this.profile,
required double initialDirection,
required this.target,
this.extractFromWay = false,
}) : directions = [initialDirection],
currentDirectionIndex = 0;
@@ -138,10 +140,14 @@ class SessionState extends ChangeNotifier {
NodeProfile? profile,
OperatorProfile? operatorProfile,
LatLng? target,
bool? extractFromWay,
}) {
if (_editSession == null) return;
bool dirty = false;
bool snapBackRequired = false;
LatLng? snapBackTarget;
if (directionDeg != null && directionDeg != _editSession!.directionDegrees) {
_editSession!.directionDegrees = directionDeg;
dirty = true;
@@ -158,7 +164,31 @@ class SessionState extends ChangeNotifier {
_editSession!.target = target;
dirty = true;
}
if (extractFromWay != null && extractFromWay != _editSession!.extractFromWay) {
_editSession!.extractFromWay = extractFromWay;
// When extract is unchecked, snap back to original location
if (!extractFromWay) {
_editSession!.target = _editSession!.originalNode.coord;
snapBackRequired = true;
snapBackTarget = _editSession!.originalNode.coord;
}
dirty = true;
}
if (dirty) notifyListeners();
// Store snap back info for map view to pick up
if (snapBackRequired && snapBackTarget != null) {
_pendingSnapBack = snapBackTarget;
}
}
// For map view to check and consume snap back requests
LatLng? _pendingSnapBack;
LatLng? consumePendingSnapBack() {
final result = _pendingSnapBack;
_pendingSnapBack = null;
return result;
}
// Add new direction at 0° and switch to editing it

View File

@@ -28,13 +28,15 @@ class SettingsState extends ChangeNotifier {
static const String _proximityAlertDistancePrefsKey = 'proximity_alert_distance';
static const String _networkStatusIndicatorEnabledPrefsKey = 'network_status_indicator_enabled';
static const String _suspectedLocationMinDistancePrefsKey = 'suspected_location_min_distance';
static const String _pauseQueueProcessingPrefsKey = 'pause_queue_processing';
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 = [];
@@ -42,6 +44,7 @@ class SettingsState extends ChangeNotifier {
// Getters
bool get offlineMode => _offlineMode;
bool get pauseQueueProcessing => _pauseQueueProcessing;
int get maxCameras => _maxCameras;
UploadMode get uploadMode => _uploadMode;
FollowMeMode get followMeMode => _followMeMode;
@@ -92,6 +95,9 @@ class SettingsState extends ChangeNotifier {
// Load offline mode
_offlineMode = prefs.getBool(_offlineModePrefsKey) ?? false;
// Load queue processing setting
_pauseQueueProcessing = prefs.getBool(_pauseQueueProcessingPrefsKey) ?? false;
// Load max cameras
if (prefs.containsKey(_maxCamerasPrefsKey)) {
_maxCameras = prefs.getInt(_maxCamerasPrefsKey) ?? 250;
@@ -99,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;
@@ -122,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);
@@ -212,6 +218,13 @@ class SettingsState extends ChangeNotifier {
notifyListeners();
}
Future<void> setPauseQueueProcessing(bool enabled) async {
_pauseQueueProcessing = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_pauseQueueProcessingPrefsKey, enabled);
notifyListeners();
}
set maxCameras(int n) {
if (n < 10) n = 10; // minimum
_maxCameras = n;
@@ -223,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;
}
@@ -310,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();

View File

@@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:latlong2/latlong.dart';
import '../models/pending_upload.dart';
import '../models/osm_node.dart';
@@ -61,10 +62,23 @@ class UploadQueueState extends ChangeNotifier {
// Add a completed edit session to the upload queue
void addFromEditSession(EditNodeSession session, {required UploadMode uploadMode}) {
// For constrained nodes, always use original position regardless of session.target
final coordToUse = session.originalNode.isConstrained
? session.originalNode.coord
: session.target;
// Determine operation type and coordinates
final UploadOperation operation;
final LatLng coordToUse;
if (session.extractFromWay && session.originalNode.isConstrained) {
// Extract operation: create new node at new location
operation = UploadOperation.extract;
coordToUse = session.target;
} else if (session.originalNode.isConstrained) {
// Constrained node without extract: use original position
operation = UploadOperation.modify;
coordToUse = session.originalNode.coord;
} else {
// Unconstrained node: normal modify operation
operation = UploadOperation.modify;
coordToUse = session.target;
}
final upload = PendingUpload(
coord: coordToUse,
@@ -72,38 +86,54 @@ class UploadQueueState extends ChangeNotifier {
profile: session.profile!, // Safe to use ! because commitEditSession() checks for null
operatorProfile: session.operatorProfile,
uploadMode: uploadMode,
operation: UploadOperation.modify,
operation: operation,
originalNodeId: session.originalNode.id, // Track which node we're editing
);
_queue.add(upload);
_saveQueue();
// Create two cache entries:
// 1. Mark the original node with _pending_edit (grey ring) at original location
final originalTags = Map<String, String>.from(session.originalNode.tags);
originalTags['_pending_edit'] = 'true'; // Mark original as having pending edit
final originalNode = OsmNode(
id: session.originalNode.id,
coord: session.originalNode.coord, // Keep at original location
tags: originalTags,
);
// 2. Create new temp node for the edited node (purple ring) at new location
final tempId = -DateTime.now().millisecondsSinceEpoch;
final editedTags = upload.getCombinedTags();
editedTags['_pending_upload'] = 'true'; // Mark as pending upload
editedTags['_original_node_id'] = session.originalNode.id.toString(); // Track original for line drawing
final editedNode = OsmNode(
id: tempId,
coord: upload.coord, // At new location
tags: editedTags,
);
NodeCache.instance.addOrUpdate([originalNode, editedNode]);
// Create cache entries based on operation type:
if (operation == UploadOperation.extract) {
// For extract: only create new node, leave original unchanged
final tempId = -DateTime.now().millisecondsSinceEpoch;
final extractedTags = upload.getCombinedTags();
extractedTags['_pending_upload'] = 'true'; // Mark as pending upload
extractedTags['_original_node_id'] = session.originalNode.id.toString(); // Track original for line drawing
final extractedNode = OsmNode(
id: tempId,
coord: upload.coord, // At new location
tags: extractedTags,
);
NodeCache.instance.addOrUpdate([extractedNode]);
} else {
// For modify: mark original with grey ring and create new temp node
// 1. Mark the original node with _pending_edit (grey ring) at original location
final originalTags = Map<String, String>.from(session.originalNode.tags);
originalTags['_pending_edit'] = 'true'; // Mark original as having pending edit
final originalNode = OsmNode(
id: session.originalNode.id,
coord: session.originalNode.coord, // Keep at original location
tags: originalTags,
);
// 2. Create new temp node for the edited node (purple ring) at new location
final tempId = -DateTime.now().millisecondsSinceEpoch;
final editedTags = upload.getCombinedTags();
editedTags['_pending_upload'] = 'true'; // Mark as pending upload
editedTags['_original_node_id'] = session.originalNode.id.toString(); // Track original for line drawing
final editedNode = OsmNode(
id: tempId,
coord: upload.coord, // At new location
tags: editedTags,
);
NodeCache.instance.addOrUpdate([originalNode, editedNode]);
}
// Notify node provider to update the map
CameraProviderWithCache.instance.notifyListeners();
@@ -163,16 +193,17 @@ class UploadQueueState extends ChangeNotifier {
// Start the upload processing loop
void startUploader({
required bool offlineMode,
required bool pauseQueueProcessing,
required UploadMode uploadMode,
required Future<String?> Function() getAccessToken,
}) {
_uploadTimer?.cancel();
// No uploads without queue, or if offline mode is enabled.
if (_queue.isEmpty || offlineMode) return;
// No uploads if queue is empty, offline mode is enabled, or queue processing is paused
if (_queue.isEmpty || offlineMode || pauseQueueProcessing) return;
_uploadTimer = Timer.periodic(const Duration(seconds: 10), (t) async {
if (_queue.isEmpty || offlineMode) {
if (_queue.isEmpty || offlineMode || pauseQueueProcessing) {
_uploadTimer?.cancel();
return;
}
@@ -276,7 +307,8 @@ class UploadQueueState extends ChangeNotifier {
// Clean up any temp nodes at the same coordinate
NodeCache.instance.removeTempNodesByCoordinate(item.coord);
// For edits, also clean up the original node's _pending_edit marker
// For modify operations, clean up the original node's _pending_edit marker
// For extract operations, we don't modify the original node so leave it unchanged
if (item.isEdit && item.originalNodeId != null) {
// Remove the _pending_edit marker from the original node in cache
// The next Overpass fetch will provide the authoritative data anyway

View File

@@ -81,19 +81,21 @@ 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(
icon: Icon(
Icons.add,
size: 20,
color: requiresDirection ? null : Theme.of(context).disabledColor,
color: requiresDirection && session.directions.length < 8 ? null : Theme.of(context).disabledColor,
),
onPressed: requiresDirection ? () => appState.addDirection() : null,
tooltip: requiresDirection ? 'Add new direction' : 'Direction not required for this profile',
onPressed: requiresDirection && session.directions.length < 8 ? () => appState.addDirection() : null,
tooltip: requiresDirection
? (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(
@@ -107,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),
),
],
),

View File

@@ -0,0 +1,238 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../models/osm_node.dart';
import '../services/localization_service.dart';
/// Information about an OSM editor app
class EditorInfo {
final String name;
final String subtitle;
final IconData icon;
final String? urlScheme; // null means no custom scheme - go straight to store
final String? androidStoreUrl;
final String? iosStoreUrl;
final bool availableOnAndroid;
final bool availableOnIOS;
const EditorInfo({
required this.name,
required this.subtitle,
required this.icon,
this.urlScheme, // Made optional
this.androidStoreUrl,
this.iosStoreUrl,
required this.availableOnAndroid,
required this.availableOnIOS,
});
}
class AdvancedEditOptionsSheet extends StatelessWidget {
final OsmNode node;
const AdvancedEditOptionsSheet({super.key, required this.node});
/// Mobile editor apps with their platform availability and store URLs
List<EditorInfo> get _mobileEditors => [
EditorInfo(
name: LocalizationService.instance.t('advancedEdit.vespucci'),
subtitle: LocalizationService.instance.t('advancedEdit.vespucciSubtitle'),
icon: Icons.android,
urlScheme: 'josm:/load_and_zoom?select=node${node.id}', // Has documented deep link support
androidStoreUrl: 'https://play.google.com/store/apps/details?id=de.blau.android',
availableOnAndroid: true,
availableOnIOS: false,
),
EditorInfo(
name: LocalizationService.instance.t('advancedEdit.streetComplete'),
subtitle: LocalizationService.instance.t('advancedEdit.streetCompleteSubtitle'),
icon: Icons.place,
urlScheme: null, // No documented deep link support - go straight to store
androidStoreUrl: 'https://play.google.com/store/apps/details?id=de.westnordost.streetcomplete',
availableOnAndroid: true,
availableOnIOS: false,
),
EditorInfo(
name: LocalizationService.instance.t('advancedEdit.everyDoor'),
subtitle: LocalizationService.instance.t('advancedEdit.everyDoorSubtitle'),
icon: Icons.map,
urlScheme: null, // No documented deep link support - go straight to store
androidStoreUrl: 'https://play.google.com/store/apps/details?id=info.zverev.ilya.every_door',
iosStoreUrl: 'https://apps.apple.com/app/every-door/id1621945342',
availableOnAndroid: true,
availableOnIOS: true,
),
EditorInfo(
name: LocalizationService.instance.t('advancedEdit.goMap'),
subtitle: LocalizationService.instance.t('advancedEdit.goMapSubtitle'),
icon: Icons.phone_iphone,
urlScheme: null, // No documented deep link support - go straight to store
iosStoreUrl: 'https://apps.apple.com/app/go-map/id592990211',
availableOnAndroid: false,
availableOnIOS: true,
),
];
/// Web editor apps (always available on all platforms)
List<EditorInfo> get _webEditors => [
EditorInfo(
name: LocalizationService.instance.t('advancedEdit.iDEditor'),
subtitle: LocalizationService.instance.t('advancedEdit.iDEditorSubtitle'),
icon: Icons.public,
urlScheme: 'https://www.openstreetmap.org/edit?editor=id&node=${node.id}',
availableOnAndroid: true,
availableOnIOS: true,
),
EditorInfo(
name: LocalizationService.instance.t('advancedEdit.rapidEditor'),
subtitle: LocalizationService.instance.t('advancedEdit.rapidEditorSubtitle'),
icon: Icons.speed,
urlScheme: 'https://rapideditor.org/edit#map=19/0/0&nodes=${node.id}',
availableOnAndroid: true,
availableOnIOS: true,
),
];
@override
Widget build(BuildContext context) {
final locService = LocalizationService.instance;
// Filter mobile editors based on current platform
final availableMobileEditors = _mobileEditors.where((editor) {
if (Platform.isAndroid) return editor.availableOnAndroid;
if (Platform.isIOS) return editor.availableOnIOS;
return false; // Other platforms don't have mobile editors
}).toList();
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('advancedEdit.title'),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
locService.t('advancedEdit.subtitle'),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).textTheme.bodySmall?.color,
),
),
const SizedBox(height: 16),
// Web Editors Section
Text(
locService.t('advancedEdit.webEditors'),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
..._webEditors.map((editor) => _buildEditorTile(context, editor)),
// Mobile Editors Section (only show if there are available editors)
if (availableMobileEditors.isNotEmpty) ...[
const SizedBox(height: 16),
Text(
locService.t('advancedEdit.mobileEditors'),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
...availableMobileEditors.map((editor) => _buildEditorTile(context, editor)),
],
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(locService.t('actions.close')),
),
],
),
],
),
),
);
}
Widget _buildEditorTile(BuildContext context, EditorInfo editor) {
return ListTile(
leading: Icon(editor.icon, size: 24),
title: Text(editor.name),
subtitle: Text(editor.subtitle),
trailing: const Icon(Icons.launch, size: 18),
onTap: () => _launchEditor(context, editor),
contentPadding: const EdgeInsets.symmetric(horizontal: 0, vertical: 4),
);
}
void _launchEditor(BuildContext context, EditorInfo editor) async {
Navigator.pop(context); // Close the sheet first
// If app has a custom URL scheme, try to open it
if (editor.urlScheme != null) {
try {
final uri = Uri.parse(editor.urlScheme!);
final launched = await launchUrl(uri, mode: LaunchMode.externalApplication);
if (launched) return; // Success - app opened
} catch (e) {
// App launch failed - continue to app store
}
}
// No custom scheme or app launch failed - redirect to app store
await _redirectToAppStore(context, editor);
}
Future<void> _redirectToAppStore(BuildContext context, EditorInfo editor) async {
final locService = LocalizationService.instance;
try {
if (Platform.isAndroid && editor.androidStoreUrl != null) {
// Try native Play Store first, then web fallback
final packageName = _extractAndroidPackageName(editor.androidStoreUrl!);
if (packageName != null) {
final marketUri = Uri.parse('market://details?id=$packageName');
try {
final launched = await launchUrl(marketUri, mode: LaunchMode.externalApplication);
if (launched) return;
} catch (e) {
// Fall back to web Play Store
}
}
// Web Play Store fallback
final webStoreUri = Uri.parse(editor.androidStoreUrl!);
await launchUrl(webStoreUri, mode: LaunchMode.externalApplication);
return;
} else if (Platform.isIOS && editor.iosStoreUrl != null) {
// iOS App Store
final iosStoreUri = Uri.parse(editor.iosStoreUrl!);
await launchUrl(iosStoreUri, mode: LaunchMode.externalApplication);
return;
}
} catch (e) {
// Fall through to show error message
}
// Could not open app or store - show error message
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(locService.t('advancedEdit.couldNotOpenEditor'))),
);
}
}
/// Extract Android package name from Play Store URL for market:// scheme
String? _extractAndroidPackageName(String playStoreUrl) {
final uri = Uri.tryParse(playStoreUrl);
if (uri == null) return null;
// Extract from "id=" parameter in Play Store URLs
return uri.queryParameters['id'];
}
}

View File

@@ -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),

View File

@@ -11,10 +11,12 @@ import '../app_state.dart';
/// The compass appears in the top-right corner of the map and is disabled (non-interactive) when in follow+rotate mode.
class CompassIndicator extends StatefulWidget {
final AnimatedMapController mapController;
final EdgeInsets safeArea;
const CompassIndicator({
super.key,
required this.mapController,
required this.safeArea,
});
@override
@@ -46,9 +48,14 @@ class _CompassIndicatorState extends State<CompassIndicator> {
// Check if we're in follow+rotate mode (compass should be disabled)
final isDisabled = appState.followMeMode == FollowMeMode.rotating;
final baseTop = (appState.uploadMode == UploadMode.sandbox || appState.uploadMode == UploadMode.simulate) ? 60 : 18;
// Add extra spacing when search bar is visible
final searchBarOffset = (!appState.offlineMode && appState.isInSearchMode) ? 60 : 0;
return Positioned(
top: (appState.uploadMode == UploadMode.sandbox || appState.uploadMode == UploadMode.simulate) ? 60 : 18,
right: 16,
top: baseTop + widget.safeArea.top + searchBarOffset,
right: 16 + widget.safeArea.right,
child: GestureDetector(
onTap: isDisabled ? null : () {
// Animate to north-up orientation

View File

@@ -76,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
@@ -98,7 +98,7 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
return previewSizeKb;
} else {
// Fall back to configured estimate
return kFallbackTileEstimateKb;
return dev.kFallbackTileEstimateKb;
}
}
@@ -176,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),
@@ -185,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,
@@ -198,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],
),

View File

@@ -8,6 +8,7 @@ import '../models/operator_profile.dart';
import '../services/localization_service.dart';
import '../state/settings_state.dart';
import 'refine_tags_sheet.dart';
import 'advanced_edit_options_sheet.dart';
class EditNodeSheet extends StatelessWidget {
const EditNodeSheet({super.key, required this.session});
@@ -82,19 +83,21 @@ 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(
icon: Icon(
Icons.add,
size: 20,
color: requiresDirection ? null : Theme.of(context).disabledColor,
color: requiresDirection && session.directions.length < 8 ? null : Theme.of(context).disabledColor,
),
onPressed: requiresDirection ? () => appState.addDirection() : null,
tooltip: requiresDirection ? 'Add new direction' : 'Direction not required for this profile',
onPressed: requiresDirection && session.directions.length < 8 ? () => appState.addDirection() : null,
tooltip: requiresDirection
? (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(
@@ -108,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),
),
],
),
@@ -157,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 &&
@@ -214,21 +217,56 @@ class EditNodeSheet extends StatelessWidget {
if (session.originalNode.isConstrained)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
child: Column(
children: [
const Icon(Icons.info_outline, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
locService.t('editNode.cannotMoveConstrainedNode'),
style: Theme.of(context).textTheme.bodyMedium,
// Extract from way checkbox (only show if enabled in dev config)
if (dev.kEnableNodeExtraction) ...[
CheckboxListTile(
title: Text(locService.t('editNode.extractFromWay')),
subtitle: Text(locService.t('editNode.extractFromWaySubtitle')),
value: session.extractFromWay,
onChanged: (value) {
appState.updateEditSession(extractFromWay: value);
},
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
),
const SizedBox(height: 8),
],
// Constraint info message (only show if extract is not checked or not enabled)
if (!dev.kEnableNodeExtraction || !session.extractFromWay) ...[
Row(
children: [
const Icon(Icons.info_outline, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
locService.t('editNode.cannotMoveConstrainedNode'),
style: Theme.of(context).textTheme.bodyMedium,
),
),
],
),
const SizedBox(height: 8),
],
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
OutlinedButton.icon(
onPressed: () => _openAdvancedEdit(context),
icon: const Icon(Icons.open_in_new, size: 16),
label: Text(locService.t('actions.useAdvancedEditor')),
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 32),
),
),
],
),
],
),
),
if (!kEnableNodeEdits)
if (!dev.kEnableNodeEdits)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
@@ -349,4 +387,12 @@ class EditNodeSheet extends StatelessWidget {
},
);
}
void _openAdvancedEdit(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => AdvancedEditOptionsSheet(node: session.originalNode),
);
}
}

View File

@@ -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(

View File

@@ -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),
),
);

View File

@@ -112,11 +112,11 @@ class DirectionConesBuilder {
bool isSession = false,
bool isActiveDirection = true,
}) {
final halfAngle = kDirectionConeHalfAngle;
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);
@@ -150,15 +150,15 @@ class DirectionConesBuilder {
}
// 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,
color: dev.kDirectionConeColor.withOpacity(opacity),
borderColor: dev.kDirectionConeColor,
borderStrokeWidth: getDirectionConeBorderWidth(context),
);
}

View File

@@ -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,
);

View File

@@ -51,13 +51,15 @@ class MapOverlays extends StatelessWidget {
@override
Widget build(BuildContext context) {
final safeArea = MediaQuery.of(context).padding;
return Stack(
children: [
// MODE INDICATOR badge (top-right)
if (uploadMode == UploadMode.sandbox || uploadMode == UploadMode.simulate)
Positioned(
top: 18,
right: 14,
top: topPositionWithSafeArea(18, safeArea),
right: rightPositionWithSafeArea(14, safeArea),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
@@ -86,12 +88,13 @@ class MapOverlays extends StatelessWidget {
// Compass indicator (top-right, below mode indicator)
CompassIndicator(
mapController: mapController,
safeArea: safeArea,
),
// Zoom indicator, positioned relative to button bar
// Zoom indicator, positioned relative to button bar with left safe area
Positioned(
left: 10,
bottom: bottomPositionFromButtonBar(kZoomIndicatorSpacingAboveButtonBar, MediaQuery.of(context).padding.bottom),
left: leftPositionWithSafeArea(10, safeArea),
bottom: bottomPositionFromButtonBar(dev.kZoomIndicatorSpacingAboveButtonBar, safeArea.bottom),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
@@ -119,11 +122,11 @@ class MapOverlays extends StatelessWidget {
),
),
// Attribution overlay, positioned relative to button bar
// Attribution overlay, positioned relative to button bar with left safe area
if (attribution != null)
Positioned(
bottom: bottomPositionFromButtonBar(kAttributionSpacingAboveButtonBar, MediaQuery.of(context).padding.bottom),
left: 10,
bottom: bottomPositionFromButtonBar(dev.kAttributionSpacingAboveButtonBar, safeArea.bottom),
left: leftPositionWithSafeArea(10, safeArea),
child: GestureDetector(
onTap: () => _showAttributionDialog(context, attribution!),
child: Container(
@@ -146,10 +149,10 @@ class MapOverlays extends StatelessWidget {
),
),
// Zoom and layer controls (bottom-right), positioned relative to button bar
// Zoom and layer controls (bottom-right), positioned relative to button bar with right safe area
Positioned(
bottom: bottomPositionFromButtonBar(kZoomControlsSpacingAboveButtonBar, MediaQuery.of(context).padding.bottom),
right: 16,
bottom: bottomPositionFromButtonBar(dev.kZoomControlsSpacingAboveButtonBar, safeArea.bottom),
right: rightPositionWithSafeArea(16, safeArea),
child: Consumer<AppState>(
builder: (context, appState, child) {
return Column(

View File

@@ -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

View File

@@ -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;
}
}
@@ -263,25 +263,33 @@ class MapViewState extends State<MapView> {
}
/// Get interaction options for the map based on whether we're editing a constrained node.
/// Allows zoom and rotation but disables all forms of panning for constrained nodes.
/// Allows zoom and rotation but disables all forms of panning for constrained nodes unless extract is enabled.
InteractionOptions _getInteractionOptions(EditNodeSession? editSession) {
// Check if we're editing a constrained node
if (editSession?.originalNode.isConstrained == true) {
// Constrained node: only allow pinch zoom and rotation, disable ALL panning
return const InteractionOptions(
// 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 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
return const InteractionOptions(
flags: InteractiveFlag.all,
scrollWheelVelocity: kScrollWheelVelocity,
pinchZoomThreshold: kPinchZoomThreshold,
pinchMoveThreshold: kPinchMoveThreshold,
// Normal case: all interactions allowed with gesture race to prevent accidental rotation during zoom
return InteractionOptions(
enableMultiFingerGestureRace: true,
flags: InteractiveFlag.doubleTapDragZoom |
InteractiveFlag.doubleTapZoom |
InteractiveFlag.drag |
InteractiveFlag.flingAnimation |
InteractiveFlag.pinchZoom |
InteractiveFlag.rotate |
InteractiveFlag.scrollWheelZoom,
scrollWheelVelocity: dev.kScrollWheelVelocity,
pinchZoomThreshold: dev.kPinchZoomThreshold,
pinchMoveThreshold: dev.kPinchMoveThreshold,
);
}
@@ -371,6 +379,19 @@ class MapViewState extends State<MapView> {
} catch (_) {/* controller not ready yet */}
}
// Check for pending snap backs (when extract checkbox is unchecked)
final snapBackTarget = appState.consumePendingSnapBack();
if (snapBackTarget != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_controller.animateTo(
dest: snapBackTarget,
zoom: _controller.mapController.camera.zoom,
curve: Curves.easeOut,
duration: const Duration(milliseconds: 250),
);
});
}
// Edit sessions don't need to center - we're already centered from the node tap
// SheetAwareMap handles the visual positioning
@@ -485,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,
),
@@ -567,6 +588,7 @@ class MapViewState extends State<MapView> {
options: MapOptions(
initialCenter: _gpsController.currentLocation ?? _positionManager.initialLocation ?? LatLng(37.7749, -122.4194),
initialZoom: _positionManager.initialZoom ?? 15,
minZoom: 1.0,
maxZoom: (appState.selectedTileType?.maxZoom ?? 18).toDouble(),
interactionOptions: _getInteractionOptions(editSession),
onPositionChanged: (pos, gesture) {
@@ -579,8 +601,8 @@ class MapViewState extends State<MapView> {
appState.updateSession(target: pos.center);
}
if (editSession != null) {
// For constrained nodes, always snap back to original position
if (editSession.originalNode.isConstrained) {
// For constrained nodes that are not being extracted, always snap back to original position
if (editSession.originalNode.isConstrained && !editSession.extractFromWay) {
final originalPos = editSession.originalNode.coord;
// Always keep session target as original position
@@ -591,7 +613,7 @@ class MapViewState extends State<MapView> {
_constrainedNodeSnapBack(() {
// Only animate if we're still in a constrained edit session and still drifted
final currentEditSession = appState.editSession;
if (currentEditSession?.originalNode.isConstrained == true) {
if (currentEditSession?.originalNode.isConstrained == true && currentEditSession?.extractFromWay != true) {
final currentPos = _controller.mapController.camera.center;
if (currentPos.latitude != originalPos.latitude || currentPos.longitude != originalPos.longitude) {
_controller.animateTo(
@@ -661,17 +683,22 @@ class MapViewState extends State<MapView> {
selectedTileType: appState.selectedTileType,
),
cameraLayers,
// Built-in scale bar from flutter_map, positioned relative to button bar
Scalebar(
alignment: Alignment.bottomLeft,
padding: EdgeInsets.only(
left: 8,
bottom: bottomPositionFromButtonBar(kScaleBarSpacingAboveButtonBar, MediaQuery.of(context).padding.bottom)
),
textStyle: TextStyle(color: Colors.black, fontWeight: FontWeight.bold),
lineColor: Colors.black,
strokeWidth: 3,
// backgroundColor removed in flutter_map >=8 (wrap in Container if needed)
// Built-in scale bar from flutter_map, positioned relative to button bar with safe area
Builder(
builder: (context) {
final safeArea = MediaQuery.of(context).padding;
return Scalebar(
alignment: Alignment.bottomLeft,
padding: EdgeInsets.only(
left: leftPositionWithSafeArea(8, safeArea),
bottom: bottomPositionFromButtonBar(dev.kScaleBarSpacingAboveButtonBar, safeArea.bottom)
),
textStyle: TextStyle(color: Colors.black, fontWeight: FontWeight.bold),
lineColor: Colors.black,
strokeWidth: 3,
// backgroundColor removed in flutter_map >=8 (wrap in Container if needed)
);
},
),
],
),
@@ -726,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,
));
}

View File

@@ -1,8 +1,12 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import '../models/osm_node.dart';
import '../app_state.dart';
import '../services/localization_service.dart';
import '../dev_config.dart';
import 'advanced_edit_options_sheet.dart';
class NodeTagSheet extends StatelessWidget {
final OsmNode node;
@@ -67,82 +71,166 @@ class NodeTagSheet extends StatelessWidget {
}
}
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('node.title').replaceAll('{}', node.id.toString()),
style: Theme.of(context).textTheme.titleLarge,
void _viewOnOSM() async {
final url = 'https://www.openstreetmap.org/node/${node.id}';
try {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(locService.t('advancedEdit.couldNotOpenOSMWebsite'))),
);
}
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(locService.t('advancedEdit.couldNotOpenOSMWebsite'))),
);
}
}
}
void _openAdvancedEdit() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => AdvancedEditOptionsSheet(node: node),
);
}
return LayoutBuilder(
builder: (context, constraints) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('node.title').replaceAll('{}', node.id.toString()),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
// Tag list with flexible height constraint
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * getTagListHeightRatio(context),
),
const SizedBox(height: 12),
...node.tags.entries.map(
(e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
e.key,
style: TextStyle(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
...node.tags.entries.map(
(e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
e.key,
style: TextStyle(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(width: 8),
Expanded(
child: Linkify(
onOpen: (link) async {
final uri = Uri.parse(link.url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${LocalizationService.instance.t('advancedEdit.couldNotOpenURL')}: ${link.url}')),
);
}
},
text: e.value,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
),
linkStyle: TextStyle(
color: Theme.of(context).colorScheme.primary,
decoration: TextDecoration.underline,
),
options: const LinkifyOptions(humanize: false),
),
),
],
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
e.value,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
),
softWrap: true,
),
),
],
),
),
],
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (isEditable) ...[
ElevatedButton.icon(
onPressed: _openEditSheet,
icon: const Icon(Icons.edit, size: 18),
label: Text(locService.edit),
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 36),
),
),
const SizedBox(height: 16),
// First row: View and Advanced buttons
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
onPressed: () => _viewOnOSM(),
icon: const Icon(Icons.open_in_new, size: 16),
label: Text(locService.t('actions.viewOnOSM')),
),
const SizedBox(width: 8),
if (isEditable) ...[
OutlinedButton.icon(
onPressed: _openAdvancedEdit,
icon: const Icon(Icons.open_in_new, size: 18),
label: Text(locService.t('actions.advanced')),
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 36),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: _deleteNode,
icon: const Icon(Icons.delete, size: 18),
label: Text(locService.t('actions.delete')),
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 36),
foregroundColor: Colors.red,
),
),
const SizedBox(width: 12),
],
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(locService.t('actions.close')),
),
],
),
],
),
],
),
const SizedBox(height: 8),
// Second row: Edit, Delete, and Close buttons
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (isEditable) ...[
ElevatedButton.icon(
onPressed: _openEditSheet,
icon: const Icon(Icons.edit, size: 18),
label: Text(locService.edit),
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 36),
),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: node.isConstrained ? null : _deleteNode,
icon: const Icon(Icons.delete, size: 18),
label: Text(locService.t('actions.delete')),
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 36),
foregroundColor: node.isConstrained ? null : Colors.red,
),
),
const SizedBox(width: 12),
],
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(locService.t('actions.close')),
),
],
),
],
),
),
);
},
);
},
);
}

View File

@@ -5,6 +5,7 @@ import 'package:url_launcher/url_launcher.dart';
import '../models/suspected_location.dart';
import '../app_state.dart';
import '../services/localization_service.dart';
import '../dev_config.dart';
class SuspectedLocationSheet extends StatelessWidget {
final SuspectedLocation location;
@@ -19,8 +20,6 @@ class SuspectedLocationSheet extends StatelessWidget {
final appState = context.watch<AppState>();
final locService = LocalizationService.instance;
// Get all fields except location and ticket_no
final displayData = <String, String>{};
for (final entry in location.allFields.entries) {
@@ -30,120 +29,135 @@ class SuspectedLocationSheet extends StatelessWidget {
}
}
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('suspectedLocation.title', params: [location.ticketNo]),
style: Theme.of(context).textTheme.titleLarge,
return LayoutBuilder(
builder: (context, constraints) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('suspectedLocation.title', params: [location.ticketNo]),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
// Field list with flexible height constraint
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * getTagListHeightRatio(context),
),
const SizedBox(height: 12),
// Display all fields
...displayData.entries.map(
(e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
e.key,
style: TextStyle(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(width: 8),
Expanded(
child: e.key.toLowerCase().contains('url') && e.value.isNotEmpty
? GestureDetector(
onTap: () async {
final uri = Uri.parse(e.value);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Could not open URL: ${e.value}'),
),
);
}
}
},
child: Text(
e.value,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
decoration: TextDecoration.underline,
),
softWrap: true,
),
)
: Text(
e.value,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
),
softWrap: true,
),
),
],
),
),
),
const SizedBox(height: 16),
// Coordinates info
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
locService.t('suspectedLocation.coordinates'),
style: TextStyle(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
'${location.centroid.latitude.toStringAsFixed(6)}, ${location.centroid.longitude.toStringAsFixed(6)}',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
// Display all fields
...displayData.entries.map(
(e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
e.key,
style: TextStyle(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(width: 8),
Expanded(
child: e.key.toLowerCase().contains('url') && e.value.isNotEmpty
? GestureDetector(
onTap: () async {
final uri = Uri.parse(e.value);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Could not open URL: ${e.value}'),
),
);
}
}
},
child: Text(
e.value,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
decoration: TextDecoration.underline,
),
softWrap: true,
),
)
: Text(
e.value,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
),
softWrap: true,
),
),
],
),
softWrap: true,
),
),
],
),
),
const SizedBox(height: 16),
// Close button
Row(
mainAxisAlignment: MainAxisAlignment.end,
),
const SizedBox(height: 16),
// Coordinates info
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(locService.t('actions.close')),
Text(
locService.t('suspectedLocation.coordinates'),
style: TextStyle(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
'${location.centroid.latitude.toStringAsFixed(6)}, ${location.centroid.longitude.toStringAsFixed(6)}',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
),
softWrap: true,
),
),
],
),
],
),
),
const SizedBox(height: 16),
// Close button
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(locService.t('actions.close')),
),
],
),
],
),
),
);
},
);
},
);
}

View File

@@ -166,6 +166,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.14.4"
flutter_linkify:
dependency: "direct main"
description:
name: flutter_linkify
sha256: "74669e06a8f358fee4512b4320c0b80e51cffc496607931de68d28f099254073"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_local_notifications:
dependency: "direct main"
description:
@@ -395,6 +403,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.9.1"
linkify:
dependency: transitive
description:
name: linkify
sha256: "4139ea77f4651ab9c315b577da2dd108d9aa0bd84b5d03d33323f1970c645832"
url: "https://pub.dev"
source: hosted
version: "5.0.0"
lists:
dependency: transitive
description:

View File

@@ -1,7 +1,7 @@
name: deflockapp
description: Map public surveillance infrastructure with OpenStreetMap
publish_to: "none"
version: 1.3.3+11 # 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+
@@ -21,6 +21,7 @@ dependencies:
xml: ^6.4.2
flutter_local_notifications: ^17.2.2
url_launcher: ^6.3.0
flutter_linkify: ^6.0.0
# Auth, storage, prefs
oauth2_client: ^4.2.0

View 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()

View 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"