mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-04-03 10:40:28 +02:00
Compare commits
17 Commits
v1.3.3-bet
...
secret_dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
999a918062 | ||
|
|
492cf57520 | ||
|
|
c77ea96eaf | ||
|
|
813a0f06da | ||
|
|
3fc74df616 | ||
|
|
95fad14261 | ||
|
|
1ac43b0c4e | ||
|
|
3174e0bfe1 | ||
|
|
5404daa704 | ||
|
|
20870623f0 | ||
|
|
8ed92dcd7e | ||
|
|
0143c74415 | ||
|
|
6c53d988de | ||
|
|
26cebcc60e | ||
|
|
7c2b9ea087 | ||
|
|
b2645f1341 | ||
|
|
05eedbb910 |
12
DEVELOPER.md
12
DEVELOPER.md
@@ -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
|
||||
|
||||
26
README.md
26
README.md
@@ -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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "在地图上显示网络加载和错误状态",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
127
lib/screens/developer_settings_screen.dart
Normal file
127
lib/screens/developer_settings_screen.dart
Normal file
@@ -0,0 +1,127 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../dev_config.dart';
|
||||
|
||||
class DeveloperSettingsScreen extends StatefulWidget {
|
||||
const DeveloperSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<DeveloperSettingsScreen> createState() => _DeveloperSettingsScreenState();
|
||||
}
|
||||
|
||||
class _DeveloperSettingsScreenState extends State<DeveloperSettingsScreen> {
|
||||
final Map<String, TextEditingController> _controllers = {};
|
||||
final Map<String, dynamic> _overrides = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeControllers();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final controller in _controllers.values) {
|
||||
controller.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _initializeControllers() {
|
||||
for (final entry in devConfigForSettings.entries) {
|
||||
if (entry.value is String) {
|
||||
_controllers[entry.key] = TextEditingController(text: entry.value);
|
||||
} else if (entry.value is int) {
|
||||
_controllers[entry.key] = TextEditingController(text: entry.value.toString());
|
||||
} else if (entry.value is double) {
|
||||
_controllers[entry.key] = TextEditingController(text: entry.value.toString());
|
||||
} else if (entry.value is Color) {
|
||||
final color = entry.value as Color;
|
||||
final hex = color.value.toRadixString(16).padLeft(8, '0').toUpperCase();
|
||||
_controllers[entry.key] = TextEditingController(text: hex);
|
||||
} else if (entry.value is Duration) {
|
||||
final duration = entry.value as Duration;
|
||||
_controllers[entry.key] = TextEditingController(text: duration.inMilliseconds.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _saveAndRestart() {
|
||||
// For now, just show a dialog - actual restart would require platform channels
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Restart Required'),
|
||||
content: const Text('Changes saved. Please restart the app to apply new settings.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSettingWidget(String key, dynamic defaultValue) {
|
||||
if (key == 'kClientName') {
|
||||
// Special read-only case
|
||||
return ListTile(
|
||||
title: Text(key),
|
||||
subtitle: Text(defaultValue.toString()),
|
||||
trailing: const Text('READ ONLY'),
|
||||
);
|
||||
}
|
||||
|
||||
if (defaultValue is bool) {
|
||||
return SwitchListTile(
|
||||
title: Text(key),
|
||||
value: _overrides[key] ?? defaultValue,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_overrides[key] = value;
|
||||
});
|
||||
},
|
||||
);
|
||||
} else if (defaultValue is int || defaultValue is double || defaultValue is String ||
|
||||
defaultValue is Color || defaultValue is Duration) {
|
||||
return ListTile(
|
||||
title: Text(key),
|
||||
subtitle: TextField(
|
||||
controller: _controllers[key],
|
||||
keyboardType: defaultValue is int || defaultValue is double
|
||||
? const TextInputType.numberWithOptions(signed: true, decimal: true)
|
||||
: TextInputType.text,
|
||||
textInputAction: TextInputAction.done,
|
||||
onChanged: (value) {
|
||||
// Store the string value for now - actual parsing would happen on save
|
||||
_overrides[key] = value;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Developer Settings'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _saveAndRestart,
|
||||
child: const Text('SAVE', style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: devConfigForSettings.entries
|
||||
.map((entry) => _buildSettingWidget(entry.key, entry.value))
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
176
lib/screens/osm_account_screen.dart
Normal file
176
lib/screens/osm_account_screen.dart
Normal 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')),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
189
lib/screens/upload_queue_screen.dart
Normal file
189
lib/screens/upload_queue_screen.dart
Normal 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);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ Future<List<OsmNode>> _fetchOverpassNodesWithSplitting({
|
||||
}) async {
|
||||
if (profiles.isEmpty) return [];
|
||||
|
||||
const int maxSplitDepth = kMaxPreFetchSplitDepth; // Maximum times we'll split (4^3 = 64 max sub-areas)
|
||||
final int maxSplitDepth = dev.dev.kMaxPreFetchSplitDepth; // Maximum times we'll split (4^3 = 64 max sub-areas)
|
||||
|
||||
try {
|
||||
return await _fetchSingleOverpassQuery(
|
||||
|
||||
@@ -36,14 +36,14 @@ void clearRemoteTileQueueSelective(LatLngBounds currentBounds) {
|
||||
/// Uses: initialDelay * (multiplier ^ (attempt - 1)) + randomJitter, capped at maxDelay
|
||||
int _calculateRetryDelay(int attempt, Random random) {
|
||||
// Calculate exponential backoff: initialDelay * (multiplier ^ (attempt - 1))
|
||||
final baseDelay = (kTileFetchInitialDelayMs *
|
||||
pow(kTileFetchBackoffMultiplier, attempt - 1)).round();
|
||||
final baseDelay = (dev.kTileFetchInitialDelayMs *
|
||||
pow(dev.kTileFetchBackoffMultiplier, attempt - 1)).round();
|
||||
|
||||
// Add random jitter to avoid thundering herd
|
||||
final jitter = random.nextInt(kTileFetchRandomJitterMs + 1);
|
||||
final jitter = random.nextInt(dev.kTileFetchRandomJitterMs + 1);
|
||||
|
||||
// Apply max delay cap
|
||||
return (baseDelay + jitter).clamp(0, kTileFetchMaxDelayMs);
|
||||
return (baseDelay + jitter).clamp(0, dev.kTileFetchMaxDelayMs);
|
||||
}
|
||||
|
||||
/// Convert tile coordinates to lat/lng bounds for spatial filtering
|
||||
@@ -101,7 +101,7 @@ Future<List<int>> fetchRemoteTile({
|
||||
required int y,
|
||||
required String url,
|
||||
}) async {
|
||||
const int maxAttempts = kTileFetchMaxAttempts;
|
||||
final int maxAttempts = dev.dev.kTileFetchMaxAttempts;
|
||||
int attempt = 0;
|
||||
final random = Random();
|
||||
final hostInfo = Uri.parse(url).host; // For logging
|
||||
|
||||
@@ -30,8 +30,8 @@ class PrefetchAreaService {
|
||||
Timer? _debounceTimer;
|
||||
|
||||
// Configuration from dev_config
|
||||
static const double _areaExpansionMultiplier = kPreFetchAreaExpansionMultiplier;
|
||||
static const int _preFetchZoomLevel = kPreFetchZoomLevel;
|
||||
static final double _areaExpansionMultiplier = dev.dev.kPreFetchAreaExpansionMultiplier;
|
||||
static final int _preFetchZoomLevel = dev.dev.kPreFetchZoomLevel;
|
||||
|
||||
/// Check if the given bounds are fully within the current pre-fetched area.
|
||||
bool isWithinPreFetchedArea(LatLngBounds bounds, List<NodeProfile> profiles, UploadMode uploadMode) {
|
||||
@@ -58,7 +58,7 @@ class PrefetchAreaService {
|
||||
/// Check if cached data is stale (older than configured refresh interval).
|
||||
bool isDataStale() {
|
||||
if (_lastFetchTime == null) return true;
|
||||
return DateTime.now().difference(_lastFetchTime!).inSeconds > kDataRefreshIntervalSeconds;
|
||||
return DateTime.now().difference(_lastFetchTime!).inSeconds > dev.kDataRefreshIntervalSeconds;
|
||||
}
|
||||
|
||||
/// Request pre-fetch for the given view bounds if not already covered or if data is stale.
|
||||
@@ -84,7 +84,7 @@ class PrefetchAreaService {
|
||||
}
|
||||
|
||||
if (isStale) {
|
||||
debugPrint('[PrefetchAreaService] Data is stale (>${kDataRefreshIntervalSeconds}s), refreshing');
|
||||
debugPrint('[PrefetchAreaService] Data is stale (>${dev.kDataRefreshIntervalSeconds}s), refreshing');
|
||||
} else {
|
||||
debugPrint('[PrefetchAreaService] Current view outside pre-fetched area, fetching larger area');
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ class ProximityAlertService {
|
||||
|
||||
// Simple in-memory tracking of recent alerts to prevent spam
|
||||
final List<RecentAlert> _recentAlerts = [];
|
||||
static const Duration _alertCooldown = kProximityAlertCooldown;
|
||||
static final Duration _alertCooldown = dev.dev.kProximityAlertCooldown;
|
||||
|
||||
// Callback for showing in-app visual alerts
|
||||
VoidCallback? _onVisualAlert;
|
||||
|
||||
@@ -102,10 +102,10 @@ class SuspectedLocationService {
|
||||
/// Fetch data from the CSV URL
|
||||
Future<bool> _fetchData() async {
|
||||
try {
|
||||
debugPrint('[SuspectedLocationService] Fetching CSV data from $kSuspectedLocationsCsvUrl');
|
||||
debugPrint('[SuspectedLocationService] Fetching CSV data from $dev.kSuspectedLocationsCsvUrl');
|
||||
|
||||
final response = await http.get(
|
||||
Uri.parse(kSuspectedLocationsCsvUrl),
|
||||
Uri.parse(dev.kSuspectedLocationsCsvUrl),
|
||||
headers: {
|
||||
'User-Agent': 'DeFlock/1.0 (OSM surveillance mapping app)',
|
||||
},
|
||||
|
||||
@@ -61,7 +61,7 @@ class TilePreviewService {
|
||||
|
||||
static Future<Uint8List?> _fetchPreviewForTileType(TileType tileType, String? apiKey) async {
|
||||
try {
|
||||
final url = tileType.getTileUrl(kPreviewTileZoom, kPreviewTileX, kPreviewTileY, apiKey: apiKey);
|
||||
final url = tileType.getTileUrl(dev.kPreviewTileZoom, dev.kPreviewTileX, dev.kPreviewTileY, apiKey: apiKey);
|
||||
|
||||
final response = await http.get(Uri.parse(url)).timeout(_timeout);
|
||||
|
||||
|
||||
@@ -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}');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
238
lib/widgets/advanced_edit_options_sheet.dart
Normal file
238
lib/widgets/advanced_edit_options_sheet.dart
Normal 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'];
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ class CameraMapMarker extends StatefulWidget {
|
||||
class _CameraMapMarkerState extends State<CameraMapMarker> {
|
||||
Timer? _tapTimer;
|
||||
// From dev_config.dart for build-time parameters
|
||||
static const Duration tapTimeout = kMarkerTapTimeout;
|
||||
static final Duration tapTimeout = dev.dev.kMarkerTapTimeout;
|
||||
|
||||
void _onTap() {
|
||||
_tapTimer = Timer(tapTimeout, () {
|
||||
@@ -49,7 +49,7 @@ class _CameraMapMarkerState extends State<CameraMapMarker> {
|
||||
|
||||
void _onDoubleTap() {
|
||||
_tapTimer?.cancel();
|
||||
widget.mapController.move(widget.node.coord, widget.mapController.camera.zoom + kNodeDoubleTapZoomDelta);
|
||||
widget.mapController.move(widget.node.coord, widget.mapController.camera.zoom + dev.kNodeDoubleTapZoomDelta);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -108,8 +108,8 @@ class CameraMarkersBuilder {
|
||||
|
||||
return Marker(
|
||||
point: n.coord,
|
||||
width: kNodeIconDiameter,
|
||||
height: kNodeIconDiameter,
|
||||
width: dev.kNodeIconDiameter,
|
||||
height: dev.kNodeIconDiameter,
|
||||
child: Opacity(
|
||||
opacity: shouldDimNode ? 0.5 : 1.0,
|
||||
child: CameraMapMarker(
|
||||
|
||||
@@ -72,12 +72,12 @@ class CameraRefreshController {
|
||||
}
|
||||
|
||||
final zoom = controller.mapController.camera.zoom;
|
||||
if (zoom < kNodeMinZoomLevel) {
|
||||
if (zoom < dev.kNodeMinZoomLevel) {
|
||||
// Show a snackbar-style bubble warning
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Nodes not drawn below zoom level $kNodeMinZoomLevel'),
|
||||
content: Text('Nodes not drawn below zoom level $dev.kNodeMinZoomLevel'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -28,7 +28,7 @@ class SuspectedLocationMapMarker extends StatefulWidget {
|
||||
class _SuspectedLocationMapMarkerState extends State<SuspectedLocationMapMarker> {
|
||||
Timer? _tapTimer;
|
||||
// From dev_config.dart for build-time parameters
|
||||
static const Duration tapTimeout = kMarkerTapTimeout;
|
||||
static final Duration tapTimeout = dev.dev.kMarkerTapTimeout;
|
||||
|
||||
void _onTap() {
|
||||
_tapTimer = Timer(tapTimeout, () {
|
||||
@@ -47,7 +47,7 @@ class _SuspectedLocationMapMarkerState extends State<SuspectedLocationMapMarker>
|
||||
|
||||
void _onDoubleTap() {
|
||||
_tapTimer?.cancel();
|
||||
widget.mapController.move(widget.location.centroid, widget.mapController.camera.zoom + kNodeDoubleTapZoomDelta);
|
||||
widget.mapController.move(widget.location.centroid, widget.mapController.camera.zoom + dev.kNodeDoubleTapZoomDelta);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -60,7 +60,7 @@ class MapView extends StatefulWidget {
|
||||
|
||||
class MapViewState extends State<MapView> {
|
||||
late final AnimatedMapController _controller;
|
||||
final Debouncer _cameraDebounce = Debouncer(kDebounceCameraRefresh);
|
||||
final Debouncer _cameraDebounce = Debouncer(dev.kDebounceCameraRefresh);
|
||||
final Debouncer _tileDebounce = Debouncer(const Duration(milliseconds: 150));
|
||||
final Debouncer _mapPositionDebounce = Debouncer(const Duration(milliseconds: 1000));
|
||||
final Debouncer _constrainedNodeSnapBack = Debouncer(const Duration(milliseconds: 100));
|
||||
@@ -240,9 +240,9 @@ class MapViewState extends State<MapView> {
|
||||
|
||||
// OSM API (sandbox mode) needs higher zoom level due to bbox size limits
|
||||
if (uploadMode == UploadMode.sandbox) {
|
||||
return kOsmApiMinZoomLevel;
|
||||
return dev.kOsmApiMinZoomLevel;
|
||||
} else {
|
||||
return kNodeMinZoomLevel;
|
||||
return dev.kNodeMinZoomLevel;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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')),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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')),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
16
pubspec.lock
16
pubspec.lock
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
150
scripts/update_dev_config.py
Normal file
150
scripts/update_dev_config.py
Normal file
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
# All constants to replace
|
||||
CONSTANTS = [
|
||||
"kFallbackTileEstimateKb", "kPreviewTileZoom", "kPreviewTileY", "kPreviewTileX",
|
||||
"kDirectionConeHalfAngle", "kDirectionConeBaseLength", "kDirectionConeColor", "kDirectionConeOpacity",
|
||||
"kBottomButtonBarOffset", "kButtonBarHeight", "kAttributionSpacingAboveButtonBar",
|
||||
"kZoomIndicatorSpacingAboveButtonBar", "kScaleBarSpacingAboveButtonBar", "kZoomControlsSpacingAboveButtonBar",
|
||||
"kClientName", "kSuspectedLocationsCsvUrl", "kEnableDevelopmentModes", "kEnableNodeEdits", "kEnableNodeExtraction",
|
||||
"kNodeMinZoomLevel", "kOsmApiMinZoomLevel", "kMarkerTapTimeout", "kDebounceCameraRefresh",
|
||||
"kPreFetchAreaExpansionMultiplier", "kPreFetchZoomLevel", "kMaxPreFetchSplitDepth", "kDataRefreshIntervalSeconds",
|
||||
"kFollowMeAnimationDuration", "kMinSpeedForRotationMps", "kProximityAlertDefaultDistance",
|
||||
"kProximityAlertMinDistance", "kProximityAlertMaxDistance", "kProximityAlertCooldown",
|
||||
"kNodeDoubleTapZoomDelta", "kScrollWheelVelocity", "kPinchZoomThreshold", "kPinchMoveThreshold", "kRotationThreshold",
|
||||
"kTileFetchMaxAttempts", "kTileFetchInitialDelayMs", "kTileFetchBackoffMultiplier", "kTileFetchMaxDelayMs",
|
||||
"kTileFetchRandomJitterMs", "kMaxUserDownloadZoomSpan", "kMaxReasonableTileCount", "kAbsoluteMaxTileCount",
|
||||
"kAbsoluteMaxZoom", "kNodeIconDiameter", "kNodeDotOpacity", "kNodeRingColorReal", "kNodeRingColorMock",
|
||||
"kNodeRingColorPending", "kNodeRingColorEditing", "kNodeRingColorPendingEdit", "kNodeRingColorPendingDeletion",
|
||||
"kDirectionButtonMinWidth", "kDirectionButtonMinHeight"
|
||||
]
|
||||
|
||||
def find_dart_files():
|
||||
"""Find all .dart files except dev_config.dart"""
|
||||
dart_files = []
|
||||
for root, dirs, files in os.walk('.'):
|
||||
for file in files:
|
||||
if file.endswith('.dart'):
|
||||
path = os.path.join(root, file)
|
||||
if 'dev_config.dart' not in path:
|
||||
dart_files.append(path)
|
||||
return dart_files
|
||||
|
||||
def process_file(filepath):
|
||||
"""Process a single dart file"""
|
||||
print(f" 📝 Processing {filepath}")
|
||||
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
except Exception as e:
|
||||
print(f" ❌ Error reading file: {e}")
|
||||
return False
|
||||
|
||||
original_content = content
|
||||
changes_made = []
|
||||
|
||||
# Process each constant
|
||||
for constant in CONSTANTS:
|
||||
content, changed = process_constant_in_content(content, constant)
|
||||
if changed:
|
||||
changes_made.append(constant)
|
||||
|
||||
# Only write if something actually changed
|
||||
if content != original_content:
|
||||
try:
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
print(f" ✅ Updated: {', '.join(changes_made)}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f" ❌ Error writing file: {e}")
|
||||
return False
|
||||
else:
|
||||
print(f" ⏭️ No changes needed")
|
||||
return False
|
||||
|
||||
def process_constant_in_content(content, constant):
|
||||
"""Process a single constant in file content, handling const issues"""
|
||||
original_content = content
|
||||
|
||||
# Skip if already using dev.constant (idempotent)
|
||||
if f"dev.{constant}" in content:
|
||||
return content, False
|
||||
|
||||
# Skip if constant not found at all
|
||||
if constant not in content:
|
||||
return content, False
|
||||
|
||||
print(f" 🔄 Replacing {constant}")
|
||||
|
||||
# Pattern 1: const Type variable = kConstant;
|
||||
# Change to: final Type variable = dev.kConstant;
|
||||
pattern1 = rf'\bconst\s+(\w+)\s+(\w+)\s*=\s*{re.escape(constant)}\s*;'
|
||||
replacement1 = rf'final \1 \2 = dev.{constant};'
|
||||
content = re.sub(pattern1, replacement1, content)
|
||||
|
||||
# Pattern 2: static const Type variable = kConstant;
|
||||
# Change to: static final Type variable = dev.kConstant;
|
||||
pattern2 = rf'\bstatic\s+const\s+(\w+)\s+(\w+)\s*=\s*{re.escape(constant)}\s*;'
|
||||
replacement2 = rf'static final \1 \2 = dev.{constant};'
|
||||
content = re.sub(pattern2, replacement2, content)
|
||||
|
||||
# Pattern 3: const ConstructorName(...kConstant...)
|
||||
# We need to be careful here - find const constructors that contain our constant
|
||||
# and remove the const keyword
|
||||
# This is tricky to do perfectly with regex, so let's do a simple approach:
|
||||
# If we find "const SomeConstructor(" followed by our constant somewhere before the matching ")"
|
||||
# we'll remove the const keyword from the constructor
|
||||
|
||||
# Find all const constructor calls that contain our constant
|
||||
const_constructor_pattern = r'\bconst\s+(\w+)\s*\([^)]*' + re.escape(constant) + r'[^)]*\)'
|
||||
matches = list(re.finditer(const_constructor_pattern, content))
|
||||
|
||||
# Replace const with just the constructor name for each match
|
||||
for match in reversed(matches): # Reverse to maintain positions
|
||||
full_match = match.group(0)
|
||||
constructor_name = match.group(1)
|
||||
# Remove 'const ' from the beginning
|
||||
replacement = full_match.replace(f'const {constructor_name}', constructor_name, 1)
|
||||
content = content[:match.start()] + replacement + content[match.end():]
|
||||
|
||||
# Pattern 4: Simple replacements - any remaining instances of kConstant
|
||||
# Use word boundaries to avoid partial matches, but avoid already replaced dev.kConstant
|
||||
pattern4 = rf'\b{re.escape(constant)}\b(?![\w.])' # Negative lookahead to avoid partial matches
|
||||
replacement4 = f'dev.{constant}'
|
||||
content = re.sub(pattern4, replacement4, content)
|
||||
|
||||
return content, content != original_content
|
||||
|
||||
def main():
|
||||
print("🚀 Starting dev_config reference update...")
|
||||
print("🔍 Finding Dart files...")
|
||||
|
||||
dart_files = find_dart_files()
|
||||
print(f"📁 Found {len(dart_files)} Dart files to process")
|
||||
|
||||
if not dart_files:
|
||||
print("❌ No Dart files found!")
|
||||
return
|
||||
|
||||
updated_files = 0
|
||||
|
||||
for filepath in dart_files:
|
||||
if process_file(filepath):
|
||||
updated_files += 1
|
||||
|
||||
print(f"\n✨ Finished! Updated {updated_files} out of {len(dart_files)} files")
|
||||
print("💡 Next steps:")
|
||||
print(" 1. flutter analyze (check for syntax errors)")
|
||||
print(" 2. flutter pub get (refresh dependencies)")
|
||||
print(" 3. flutter run (test the app)")
|
||||
|
||||
if updated_files > 0:
|
||||
print("⚠️ If you see compilation errors, the script can be run again safely")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
12
scripts/update_dev_config_references.sh
Executable file
12
scripts/update_dev_config_references.sh
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Super simple test - just replace one constant first
|
||||
echo "🔄 Testing with kClientName..."
|
||||
|
||||
find . -name "*.dart" -not -path "./lib/dev_config.dart" -exec grep -l "kClientName" {} \;
|
||||
|
||||
echo "Found files with kClientName. Now replacing..."
|
||||
|
||||
find . -name "*.dart" -not -path "./lib/dev_config.dart" -exec sed -i '' 's/kClientName/dev.kClientName/g' {} \;
|
||||
|
||||
echo "✅ Done with test. Check if lib/services/uploader.dart changed"
|
||||
Reference in New Issue
Block a user