mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-02-13 09:12:56 +00:00
Compare commits
39 Commits
v1.3.2-rel
...
v1.5.1-rc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3868236816 | ||
|
|
52af77e1ed | ||
|
|
c150e3ccee | ||
|
|
c6cc68c9b4 | ||
|
|
961465ebb5 | ||
|
|
7ff04851f4 | ||
|
|
3baed3c328 | ||
|
|
3ade06eef1 | ||
|
|
c7b70dddc4 | ||
|
|
d747c66990 | ||
|
|
5673c2b627 | ||
|
|
32a0ac17ad | ||
|
|
dec957790c | ||
|
|
9319bbda48 | ||
|
|
ee26576c5e | ||
|
|
d6419d5b7c | ||
|
|
026ece2e29 | ||
|
|
3c996c78c9 | ||
|
|
492cf57520 | ||
|
|
c77ea96eaf | ||
|
|
813a0f06da | ||
|
|
3fc74df616 | ||
|
|
95fad14261 | ||
|
|
1ac43b0c4e | ||
|
|
3174e0bfe1 | ||
|
|
5404daa704 | ||
|
|
20870623f0 | ||
|
|
8ed92dcd7e | ||
|
|
0143c74415 | ||
|
|
6c53d988de | ||
|
|
26cebcc60e | ||
|
|
7c2b9ea087 | ||
|
|
b2645f1341 | ||
|
|
05eedbb910 | ||
|
|
3ea6d6b2ff | ||
|
|
326b7ec523 | ||
|
|
192c6e5158 | ||
|
|
ac53f7f74e | ||
|
|
5b9810b9de |
47
DEVELOPER.md
47
DEVELOPER.md
@@ -72,6 +72,7 @@ The app includes a comprehensive system for welcoming new users and notifying ex
|
||||
### Components
|
||||
- **ChangelogService**: Manages version tracking and changelog loading
|
||||
- **WelcomeDialog**: First launch popup with privacy information and quick links
|
||||
- **SubmissionGuideDialog**: One-time popup before first node submission with best practices
|
||||
- **ChangelogDialog**: Update notification popup for version changes
|
||||
- **ReleaseNotesScreen**: Settings page for viewing all changelog history
|
||||
|
||||
@@ -96,6 +97,7 @@ Changelog content is stored in `assets/changelog.json`:
|
||||
|
||||
### User Experience Flow
|
||||
- **First Launch**: Welcome popup with "don't show again" option
|
||||
- **First Submission**: Submission guide popup with best practices and resource links
|
||||
- **Version Updates**: Changelog popup (only if content exists, no "don't show again")
|
||||
- **Settings Access**: Complete changelog history available in Settings > About > Release Notes
|
||||
|
||||
@@ -123,7 +125,7 @@ The welcome popup explains that the app:
|
||||
|
||||
**Key methods:**
|
||||
- `getNodes()`: Returns cache immediately, triggers pre-fetch if needed (spatial or temporal)
|
||||
- `getTile()`: Tile fetching with enhanced retry strategy (6 attempts, 1-8s delays)
|
||||
- `getTile()`: Tile fetching with unlimited retry strategy (retries until success)
|
||||
- `_fetchRemoteNodes()`: Handles Overpass → OSM API fallback
|
||||
|
||||
**Smart caching flow:**
|
||||
@@ -135,18 +137,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
|
||||
@@ -332,7 +340,36 @@ Most users should contribute to production; testing modes add complexity
|
||||
bool get showUploadModeSelector => kDebugMode;
|
||||
```
|
||||
|
||||
### 11. Navigation & Routing (Implemented, Awaiting Integration)
|
||||
### 11. Tile Provider System & URL Templates
|
||||
|
||||
**Design approach:**
|
||||
- **Flexible URL templates**: Support multiple coordinate systems and load-balancing patterns
|
||||
- **Built-in providers**: Curated set of high-quality, reliable tile sources
|
||||
- **Custom providers**: Users can add any tile service with full validation
|
||||
- **API key management**: Secure storage with per-provider API keys
|
||||
|
||||
**Supported URL placeholders:**
|
||||
```
|
||||
{x}, {y}, {z} - Standard TMS tile coordinates
|
||||
{quadkey} - Bing Maps quadkey format (alternative to x/y/z)
|
||||
{0_3} - Subdomain 0-3 for load balancing
|
||||
{1_4} - Subdomain 1-4 for providers using 1-based indexing
|
||||
{api_key} - API key insertion point (optional)
|
||||
```
|
||||
|
||||
**Built-in providers:**
|
||||
- **OpenStreetMap**: Standard street map tiles, no API key required
|
||||
- **Bing Maps**: High-quality satellite imagery using quadkey system, no API key required
|
||||
- **Mapbox**: Satellite and street tiles, requires API key
|
||||
- **OpenTopoMap**: Topographic maps, no API key required
|
||||
|
||||
**Validation logic:**
|
||||
URL templates must contain either `{quadkey}` OR all of `{x}`, `{y}`, and `{z}`. This allows for both standard tile services and specialized formats like Bing Maps.
|
||||
|
||||
**Why this approach:**
|
||||
Provides maximum flexibility while maintaining simplicity. Users can add any tile service without code changes, while built-in providers offer immediate functionality. The quadkey system enables access to high-quality satellite imagery without API key requirements.
|
||||
|
||||
### 12. Navigation & Routing (Implemented, Awaiting Integration)
|
||||
|
||||
**Current state:**
|
||||
- **Search functionality**: Fully implemented and active
|
||||
|
||||
22
README.md
22
README.md
@@ -21,7 +21,7 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
|
||||
- **Map surveillance infrastructure** including cameras, ALPRs, gunshot detectors, and more with precise location, direction, and manufacturer details
|
||||
- **Upload to OpenStreetMap** with OAuth2 integration (live or sandbox modes)
|
||||
- **Work completely offline** with downloadable map areas and device data, plus upload queue
|
||||
- **Multiple map types** including satellite imagery from USGS, Esri, Mapbox, and topographic maps from OpenTopoMap, plus custom map tile provider support
|
||||
- **Multiple map types** including satellite imagery from Bing Maps, USGS, Esri, Mapbox, and topographic maps from OpenTopoMap, plus custom map tile provider support
|
||||
- **Editing Ability** to update existing device locations and properties
|
||||
- **Built-in device profiles** for Flock Safety, Motorola, Genetec, Leonardo, and other major manufacturers, plus custom profiles for more specific tag sets
|
||||
|
||||
@@ -30,7 +30,7 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
|
||||
## Key Features
|
||||
|
||||
### Map & Navigation
|
||||
- **Multi-source tiles**: Switch between OpenStreetMap, USGS imagery, Esri imagery, Mapbox, OpenTopoMap, and any custom providers
|
||||
- **Multi-source tiles**: Switch between OpenStreetMap, Bing satellite imagery, USGS imagery, Esri imagery, Mapbox, OpenTopoMap, and any custom providers
|
||||
- **Offline-first design**: Download a region for complete offline operation
|
||||
- **Smooth UX**: Intuitive controls, follow-me mode with GPS rotation, compass indicator with north-lock, and gesture-friendly interactions
|
||||
- **Device visualization**: Color-coded markers showing real devices (blue), pending uploads (purple), pending edits (grey), devices being edited (orange), and pending deletions (red)
|
||||
@@ -66,7 +66,7 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
|
||||
1. **Install** the app on iOS or Android - a welcome popup will guide you through key information
|
||||
2. **Enable location** permissions
|
||||
3. **Log into OpenStreetMap**: Choose upload mode and get OAuth2 credentials
|
||||
4. **Add your first device**: Tap the "New Node" button, position the pin, set direction(s), select a profile, and tap submit
|
||||
4. **Add your first device**: Tap the "New Node" button, position the pin, set direction(s), select a profile, and tap submit - a guidance popup will help you with best practices on your first submission
|
||||
5. **Edit or delete devices**: Tap any device marker to view details, then use Edit or Delete buttons
|
||||
|
||||
**New to OpenStreetMap?** Visit [deflock.me](https://deflock.me) for complete setup instructions and community guidelines.
|
||||
@@ -98,20 +98,34 @@ cp lib/keys.dart.example lib/keys.dart
|
||||
## Roadmap
|
||||
|
||||
### Needed Bugfixes
|
||||
- Update node cache to reflect cleared queue entries
|
||||
- Are offline areas preferred for fast loading even when online? Check working.
|
||||
- Fix network indicator - only done when fetch queue is empty!
|
||||
|
||||
### Current Development
|
||||
- Decide what to do for extracting nodes attached to a way/relation:
|
||||
- Auto extract (how?)
|
||||
- Leave it alone (wrong answer unless user chooses intentionally)
|
||||
- Manual cleanup (cognitive load for users)
|
||||
- Delete the old one (also wrong answer unless user chooses intentionally)
|
||||
- Give multiple of these options??
|
||||
- Nav start+end too close together error (warning + disable submit button?)
|
||||
- Persistent cache for MY submissions: assume submissions worked, cache,clean up when we see that node appear in overpass/OSM results or when older than 24h
|
||||
- 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
|
||||
- Import/Export map providers
|
||||
- Swap in alprwatch.org/directions avoidance routing API
|
||||
- Clean cache when nodes have disappeared / been deleted by others / queue item was deleted
|
||||
- Improve offline area node refresh live display
|
||||
- Add Rekor profile
|
||||
|
||||
### Future Features & Wishlist
|
||||
- Update offline area nodes while browsing?
|
||||
- Offline navigation (pending vector map tiles)
|
||||
- Android Auto / CarPlay
|
||||
|
||||
### Maybes
|
||||
- Yellow ring for devices missing specific tag details
|
||||
|
||||
@@ -1,26 +1,144 @@
|
||||
{
|
||||
"1.5.1": {
|
||||
"content": [
|
||||
"• NEW: Bing satellite imagery - high-quality satellite tiles used by the iD editor, no API key required",
|
||||
"• IMPROVED: Enhanced tile provider system with quadkey format support (for Bing Maps and similar providers)",
|
||||
"• IMPROVED: Flexible subdomain patterns - supports both 0-3 and 1-4 subdomain ranges for load balancing",
|
||||
"• IMPROVED: Tile URL validation now accepts either {quadkey} or {x}/{y}/{z} coordinate systems"
|
||||
]
|
||||
},
|
||||
"1.5.0": {
|
||||
"content": [
|
||||
"• NEW: First-submission guide popup - provides essential guidance and links before your first device submission",
|
||||
"• NEW: Manual access to dialogs in Settings > About - view welcome message and submission guide anytime"
|
||||
]
|
||||
},
|
||||
"1.4.6": {
|
||||
"content": [
|
||||
"• IMPROVED: Tile fetching reliability - removed retry limits so visible tiles always load eventually",
|
||||
"• FIXED: Queue management - cancel requests for off-screen tiles, ongoing requests continue normally"
|
||||
]
|
||||
},
|
||||
"1.4.5": {
|
||||
"content": [
|
||||
"• NEW: Minimum zoom level (Z15) enforced for adding and editing surveillance nodes to ensure precise positioning",
|
||||
"• NEW: Minimum zoom level (Z10) enforced for offline area downloads to prevent insanely large areas",
|
||||
"• IMPROVED: Offline area download confirmation now shows as popup with 'View Progress in Settings' button instead of snackbar"
|
||||
]
|
||||
},
|
||||
"1.4.4": {
|
||||
"content": [
|
||||
"• FOV range notation parsing - now supports OSM data like '90-270' (180° FOV centered at 180°)",
|
||||
"• Complex range notation support: 'ESE;90-125;290' displays multiple FOV cones correctly",
|
||||
"• Profiles now support optional specific FOV values",
|
||||
"• Smart cone rendering - variable FOV widths, 360° cameras show full circles"
|
||||
]
|
||||
},
|
||||
"1.4.3": {
|
||||
"content": [
|
||||
"• NEW: Proximity warning when placing nodes too close together - prevents accidental duplicate submissions"
|
||||
]
|
||||
},
|
||||
"1.4.2": {
|
||||
"content": [
|
||||
"• NEW: Dedicated 'Upload Queue' page - queue items are now shown in a proper list view instead of a popup",
|
||||
"• NEW: '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"
|
||||
]
|
||||
},
|
||||
"1.4.1": {
|
||||
"content": [
|
||||
"• NEW: 'Extract node from way/relation' option for constrained nodes (currently disabled while we decide what that means)"
|
||||
]
|
||||
},
|
||||
"1.4.0": {
|
||||
"content": [
|
||||
"• IMPROVED: Advanced editing options now only show apps available on your platform (iOS/Android)",
|
||||
"• 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",
|
||||
"• FIXED: Sheets now resize when rotating between orientations"
|
||||
]
|
||||
},
|
||||
"1.3.3": {
|
||||
"content": [
|
||||
"• UX: Edits re-enabled. Only nodes which are part of ways/relations cannot be moved",
|
||||
"• NEW: Added builtin surveillance device profiles for Rekor and Axis Communications ALPR cameras",
|
||||
"• 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: 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"
|
||||
]
|
||||
},
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ import 'services/changelog_service.dart';
|
||||
import 'services/operator_profile_service.dart';
|
||||
import 'services/profile_service.dart';
|
||||
import 'widgets/camera_provider_with_cache.dart';
|
||||
import 'widgets/proximity_warning_dialog.dart';
|
||||
import 'dev_config.dart';
|
||||
import 'state/auth_state.dart';
|
||||
import 'state/navigation_state.dart';
|
||||
import 'state/operator_profile_state.dart';
|
||||
@@ -130,6 +132,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 +279,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 +421,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 +543,7 @@ class AppState extends ChangeNotifier {
|
||||
void _startUploader() {
|
||||
_uploadQueueState.startUploader(
|
||||
offlineMode: offlineMode,
|
||||
pauseQueueProcessing: pauseQueueProcessing,
|
||||
uploadMode: uploadMode,
|
||||
getAccessToken: _authState.getAccessToken,
|
||||
);
|
||||
|
||||
@@ -34,14 +34,27 @@ double bottomPositionFromButtonBar(double spacingAboveButtonBar, double safeArea
|
||||
return safeAreaBottom + kBottomButtonBarOffset + 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;
|
||||
}
|
||||
|
||||
// Helper to get top positioning that accounts for safe area
|
||||
double topPositionWithSafeArea(double baseTop, EdgeInsets safeArea) {
|
||||
return baseTop + safeArea.top;
|
||||
}
|
||||
|
||||
// Client name for OSM uploads ("created_by" tag)
|
||||
const String kClientName = 'DeFlock';
|
||||
// Note: Version is now dynamically retrieved from VersionService
|
||||
|
||||
// Suspected locations CSV URL
|
||||
const String kSuspectedLocationsCsvUrl = 'https://stopflock.com/app/flock_utilities_mini_latest.csv';
|
||||
const String kSuspectedLocationsCsvUrl = 'https://alprwatch.org/suspected-locations/deflock-latest.csv';
|
||||
|
||||
// Development/testing features - set to false for production builds
|
||||
const bool kEnableDevelopmentModes = false; // Set to false to hide sandbox/simulate modes and force production mode
|
||||
@@ -50,7 +63,10 @@ const bool kEnableDevelopmentModes = false; // Set to false to hide sandbox/simu
|
||||
const bool kEnableNavigationFeatures = kEnableDevelopmentModes; // Hide navigation until fully implemented
|
||||
|
||||
// Node editing features - set to false to temporarily disable editing
|
||||
const bool kEnableNodeEdits = false; // Set to false to temporarily disable node editing
|
||||
const bool kEnableNodeEdits = true; // Set to false to temporarily disable node editing
|
||||
|
||||
// Node extraction features - set to false to hide extract functionality for constrained nodes
|
||||
const bool kEnableNodeExtraction = false; // Set to true to enable extract from way/relation feature (WIP)
|
||||
|
||||
/// Navigation availability: only dev builds, and only when online
|
||||
bool enableNavigationFeatures({required bool offlineMode}) {
|
||||
@@ -64,6 +80,8 @@ bool enableNavigationFeatures({required bool offlineMode}) {
|
||||
// Marker/node interaction
|
||||
const int kNodeMinZoomLevel = 10; // Minimum zoom to show nodes (Overpass)
|
||||
const int kOsmApiMinZoomLevel = 13; // Minimum zoom for OSM API bbox queries (sandbox mode)
|
||||
const int kMinZoomForNodeEditingSheets = 15; // Minimum zoom to open add/edit node sheets
|
||||
const int kMinZoomForOfflineDownload = 10; // Minimum zoom to download offline areas (prevents large area crashes)
|
||||
const Duration kMarkerTapTimeout = Duration(milliseconds: 250);
|
||||
const Duration kDebounceCameraRefresh = Duration(milliseconds: 500);
|
||||
|
||||
@@ -79,24 +97,40 @@ 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
|
||||
|
||||
// Node proximity warning configuration (for new/edited nodes that are too close to existing ones)
|
||||
const double kNodeProximityWarningDistance = 15.0; // meters - distance threshold to show warning
|
||||
|
||||
// Map interaction configuration
|
||||
const double kNodeDoubleTapZoomDelta = 1.0; // How much to zoom in when double-tapping nodes (was 1.0)
|
||||
const double kScrollWheelVelocity = 0.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
|
||||
const int kTileFetchInitialDelayMs = 500; // Base delay for first retry (1 second)
|
||||
// Tile fetch configuration (brutalist approach: simple, configurable, unlimited retries)
|
||||
const int kTileFetchConcurrentThreads = 10; // Number of simultaneous tile downloads
|
||||
const int kTileFetchInitialDelayMs = 200; // Base delay for first retry (500ms)
|
||||
const double kTileFetchBackoffMultiplier = 1.5; // Multiply delay by this each attempt
|
||||
const int kTileFetchMaxDelayMs = 10000; // Cap delays at this value (8 seconds max)
|
||||
const int kTileFetchRandomJitterMs = 250; // Random fuzz to add (0 to 500ms)
|
||||
const int kTileFetchMaxDelayMs = 5000; // Cap delays at this value (10 seconds max)
|
||||
const int kTileFetchRandomJitterMs = 100; // Random fuzz to add (0 to 250ms)
|
||||
// Note: Removed max attempts - tiles retry indefinitely until they succeed or are canceled
|
||||
|
||||
// User download max zoom span (user can download up to kMaxUserDownloadZoomSpan zooms above min)
|
||||
const int kMaxUserDownloadZoomSpan = 7;
|
||||
|
||||
@@ -16,7 +16,28 @@
|
||||
"close": "Schließen",
|
||||
"submit": "Senden",
|
||||
"saveEdit": "Bearbeitung Speichern",
|
||||
"clear": "Löschen"
|
||||
"clear": "Löschen",
|
||||
"viewOnOSM": "Auf OSM anzeigen",
|
||||
"advanced": "Erweitert",
|
||||
"useAdvancedEditor": "Erweiterten Editor verwenden"
|
||||
},
|
||||
"proximityWarning": {
|
||||
"title": "Knoten sehr nah an vorhandenem Gerät",
|
||||
"message": "Dieser Knoten ist nur {} Meter von einem vorhandenen Überwachungsgerät entfernt.",
|
||||
"suggestion": "Wenn mehrere Geräte am selben Mast sind, verwenden Sie bitte mehrere Richtungen auf einem einzigen Knoten, anstatt separate Knoten zu erstellen.",
|
||||
"nearbyNodes": "Nahegelegene Gerät(e) gefunden ({}):",
|
||||
"nodeInfo": "Knoten #{} - {}",
|
||||
"andMore": "...und {} weitere",
|
||||
"goBack": "Zurück",
|
||||
"submitAnyway": "Trotzdem senden",
|
||||
"nodeType": {
|
||||
"alpr": "ALPR/ANPR Kamera",
|
||||
"publicCamera": "Öffentliche Überwachungskamera",
|
||||
"camera": "Überwachungskamera",
|
||||
"amenity": "{}",
|
||||
"device": "{} Gerät",
|
||||
"unknown": "Unbekanntes Gerät"
|
||||
}
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Verfolgung aktivieren",
|
||||
@@ -36,6 +57,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,6 +117,10 @@
|
||||
"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 einem anderen Kartenelement verbunden (OSM-Weg/Relation). Sie können trotzdem ihre Tags und Richtung bearbeiten.",
|
||||
"zoomInRequiredMessage": "Zoomen Sie auf mindestens Stufe {} heran, um Überwachungsknoten hinzuzufügen oder zu bearbeiten. Dies gewährleistet eine präzise Positionierung für genaues Kartieren.",
|
||||
"extractFromWay": "Knoten aus Weg/Relation extrahieren",
|
||||
"extractFromWaySubtitle": "Neuen Knoten mit gleichen Tags erstellen, Verschieben an neuen Ort ermöglichen",
|
||||
"refineTags": "Tags Verfeinern",
|
||||
"refineTagsWithProfile": "Tags Verfeinern ({})"
|
||||
},
|
||||
@@ -107,9 +134,16 @@
|
||||
"withinTileLimit": "Innerhalb {} Kachel-Limit",
|
||||
"exceedsTileLimit": "Aktuelle Auswahl überschreitet {} Kachel-Limit",
|
||||
"offlineModeWarning": "Downloads im Offline-Modus deaktiviert. Deaktivieren Sie den Offline-Modus, um neue Bereiche herunterzuladen.",
|
||||
"areaTooBigMessage": "Zoomen Sie auf mindestens Stufe {} heran, um Offline-Bereiche herunterzuladen. Downloads großer Gebiete können die App zum Absturz bringen.",
|
||||
"downloadStarted": "Download gestartet! Lade Kacheln und Knoten...",
|
||||
"downloadFailed": "Download konnte nicht gestartet werden: {}"
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Download gestartet",
|
||||
"message": "Download gestartet! Lade Kacheln und Knoten...",
|
||||
"ok": "OK",
|
||||
"viewProgress": "Fortschritt in Einstellungen anzeigen"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Upload-Ziel",
|
||||
"subtitle": "Wählen Sie, wohin Kameras hochgeladen werden",
|
||||
@@ -121,6 +155,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",
|
||||
@@ -130,6 +166,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.",
|
||||
@@ -137,7 +178,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",
|
||||
@@ -190,7 +235,7 @@
|
||||
"urlTemplate": "URL-Vorlage",
|
||||
"urlTemplateHint": "https://beispiel.com/{z}/{x}/{y}.png",
|
||||
"urlTemplateRequired": "URL-Vorlage ist erforderlich",
|
||||
"urlTemplatePlaceholders": "URL muss {z}, {x} und {y} Platzhalter enthalten",
|
||||
"urlTemplatePlaceholders": "URL muss entweder {quadkey} oder {z}, {x} und {y} Platzhalter enthalten",
|
||||
"attribution": "Zuschreibung",
|
||||
"attributionHint": "© Karten-Anbieter",
|
||||
"attributionRequired": "Zuschreibung ist erforderlich",
|
||||
@@ -228,6 +273,10 @@
|
||||
"profileNameRequired": "Profil-Name ist erforderlich",
|
||||
"requiresDirection": "Benötigt Richtung",
|
||||
"requiresDirectionSubtitle": "Ob Kameras dieses Typs ein Richtungs-Tag benötigen",
|
||||
"fov": "Sichtfeld",
|
||||
"fovHint": "Sichtfeld in Grad (leer lassen für Standard)",
|
||||
"fovSubtitle": "Kamera-Sichtfeld - verwendet für Kegelbreite und Bereichsübertragungsformat",
|
||||
"fovInvalid": "Sichtfeld muss zwischen 1 und 360 Grad liegen",
|
||||
"submittable": "Übertragbar",
|
||||
"submittableSubtitle": "Ob dieses Profil für Kamera-Übertragungen verwendet werden kann",
|
||||
"osmTags": "OSM-Tags",
|
||||
@@ -305,16 +354,48 @@
|
||||
"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"
|
||||
"showIndicatorSubtitle": "Netzwerk-Ladestatus und Fehlerstatus auf der Karte anzeigen",
|
||||
"loading": "Lädt...",
|
||||
"timedOut": "Zeitüberschreitung",
|
||||
"noData": "Keine Kacheln hier",
|
||||
"success": "Fertig",
|
||||
"nodeLimitReached": "Limit erreicht - in Einstellungen erhöhen",
|
||||
"tileProviderSlow": "Kartenanbieter langsam",
|
||||
"nodeDataSlow": "Knotendaten langsam",
|
||||
"networkIssues": "Netzwerkprobleme"
|
||||
},
|
||||
"about": {
|
||||
"title": "DeFlock - Überwachungs-Transparenz",
|
||||
"description": "DeFlock ist eine datenschutzorientierte mobile App zur Kartierung öffentlicher Überwachungsinfrastruktür mit OpenStreetMap. Dokumentieren Sie Kameras, ALPRs, Schussdetektoren und andere Überwachungsgeräte in Ihrer Gemeinde, um diese Infrastruktur sichtbar und durchsuchbar zu machen.",
|
||||
"features": "• Offline-fähige Kartierung mit herunterladbaren Bereichen\n• Direkter Upload zu OpenStreetMap mit OAuth2\n• Integrierte Profile für große Hersteller\n• Datenschutzfreundlich - keine Nutzerdaten gesammelt\n• Multiple Kartenanbieter (OSM, Satellitenbilder)",
|
||||
"initiative": "Teil der breiteren DeFlock-Initiative zur Förderung von Überwachungstransparenz.",
|
||||
"footer": "Besuchen Sie: deflock.me\nGebaut mit Flutter • Open Source"
|
||||
"footer": "Besuchen Sie: deflock.me\nGebaut mit Flutter • Open Source",
|
||||
"showWelcome": "Willkommensnachricht anzeigen",
|
||||
"showSubmissionGuide": "Einreichungsleitfaden anzeigen",
|
||||
"viewReleaseNotes": "Release-Notizen anzeigen"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Willkommen bei DeFlock",
|
||||
@@ -327,6 +408,17 @@
|
||||
"dontShowAgain": "Diese Willkommensnachricht nicht mehr anzeigen",
|
||||
"getStarted": "Los geht's mit DeFlocking!"
|
||||
},
|
||||
"submissionGuide": {
|
||||
"title": "Einreichungs-Richtlinien",
|
||||
"description": "Bevor Sie Ihr erstes Überwachungsgerät einreichen, lesen Sie bitte diese wichtigen Richtlinien für qualitativ hochwertige Beiträge zu OpenStreetMap.",
|
||||
"bestPractices": "• Nur Geräte erfassen, die Sie persönlich beobachtet haben\n• Zeit nehmen für genaue Identifikation von Typ und Hersteller\n• Präzise Positionierung - nah heranzoomen vor Markierung\n• Richtungsinformationen angeben, falls zutreffend\n• Tag-Auswahl vor dem Senden überprüfen",
|
||||
"placementNote": "Denken Sie daran: Genaue, persönlich verifizierte Daten sind essentiell für die DeFlock-Community und das OpenStreetMap-Projekt.",
|
||||
"moreInfo": "Für detaillierte Anleitungen zur Geräteerkennung und Kartierung:",
|
||||
"identificationGuide": "Identifikationsleitfaden",
|
||||
"osmWiki": "OpenStreetMap Wiki",
|
||||
"dontShowAgain": "Diese Anleitung nicht mehr anzeigen",
|
||||
"gotIt": "Verstanden!"
|
||||
},
|
||||
"navigation": {
|
||||
"searchLocation": "Ort suchen",
|
||||
"searchPlaceholder": "Orte oder Koordinaten suchen...",
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
"description": "DeFlock is a privacy-focused mobile app for mapping public surveillance infrastructure using OpenStreetMap. Document cameras, ALPRs, gunshot detectors, and other surveillance devices in your community to make this infrastructure visible and searchable.",
|
||||
"features": "• Offline-capable mapping with downloadable areas\n• Upload directly to OpenStreetMap with OAuth2\n• Built-in profiles for major manufacturers\n• Privacy-respecting - no user data collected\n• Multiple map tile providers (OSM, satellite imagery)",
|
||||
"initiative": "Part of the broader DeFlock initiative to promote surveillance transparency.",
|
||||
"footer": "Visit: deflock.me\nBuilt with Flutter • Open Source"
|
||||
"footer": "Visit: deflock.me\nBuilt with Flutter • Open Source",
|
||||
"showWelcome": "Show Welcome Message",
|
||||
"showSubmissionGuide": "Show Submission Guide",
|
||||
"viewReleaseNotes": "View Release Notes"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Welcome to DeFlock",
|
||||
@@ -23,6 +26,17 @@
|
||||
"dontShowAgain": "Don't show this welcome message again",
|
||||
"getStarted": "Let's Get DeFlocking!"
|
||||
},
|
||||
"submissionGuide": {
|
||||
"title": "Submission Best Practices",
|
||||
"description": "Before submitting your first surveillance device, please take a moment to review these important guidelines to ensure high-quality contributions to OpenStreetMap.",
|
||||
"bestPractices": "• Only map devices you've personally observed firsthand\n• Take time to accurately identify the device type and manufacturer\n• Use precise positioning - zoom in close before placing the marker\n• Include direction information when applicable\n• Double-check your tag selections before submitting",
|
||||
"placementNote": "Remember: Accurate, first-hand data is essential for the DeFlock community and OpenStreetMap project.",
|
||||
"moreInfo": "For detailed guidance on device identification and mapping best practices:",
|
||||
"identificationGuide": "Identification Guide",
|
||||
"osmWiki": "OpenStreetMap Wiki",
|
||||
"dontShowAgain": "Don't show this guide again",
|
||||
"gotIt": "Got It!"
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "New Node",
|
||||
"download": "Download",
|
||||
@@ -34,7 +48,28 @@
|
||||
"close": "Close",
|
||||
"submit": "Submit",
|
||||
"saveEdit": "Save Edit",
|
||||
"clear": "Clear"
|
||||
"clear": "Clear",
|
||||
"viewOnOSM": "View on OSM",
|
||||
"advanced": "Advanced",
|
||||
"useAdvancedEditor": "Use Advanced Editor"
|
||||
},
|
||||
"proximityWarning": {
|
||||
"title": "Node Very Close to Existing Device",
|
||||
"message": "This node is only {} meters from an existing surveillance device.",
|
||||
"suggestion": "If multiple devices are on the same pole, please use multiple directions on a single node instead of creating separate nodes.",
|
||||
"nearbyNodes": "Nearby device(s) found ({}):",
|
||||
"nodeInfo": "Node #{} - {}",
|
||||
"andMore": "...and {} more",
|
||||
"goBack": "Go Back",
|
||||
"submitAnyway": "Submit Anyway",
|
||||
"nodeType": {
|
||||
"alpr": "ALPR/ANPR Camera",
|
||||
"publicCamera": "Public Surveillance Camera",
|
||||
"camera": "Surveillance Camera",
|
||||
"amenity": "{}",
|
||||
"device": "{} Device",
|
||||
"unknown": "Unknown Device"
|
||||
}
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Enable follow-me",
|
||||
@@ -54,6 +89,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,6 +149,10 @@
|
||||
"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 another map element (OSM way/relation). You can still edit its tags and direction.",
|
||||
"zoomInRequiredMessage": "Zoom in to at least level {} to add or edit surveillance nodes. This ensures precise positioning for accurate mapping.",
|
||||
"extractFromWay": "Extract node from way/relation",
|
||||
"extractFromWaySubtitle": "Create new node with same tags, allow moving to new location",
|
||||
"refineTags": "Refine Tags",
|
||||
"refineTagsWithProfile": "Refine Tags ({})"
|
||||
},
|
||||
@@ -125,9 +166,16 @@
|
||||
"withinTileLimit": "Within {} tile limit",
|
||||
"exceedsTileLimit": "Current selection exceeds {} tile limit",
|
||||
"offlineModeWarning": "Downloads disabled while in offline mode. Disable offline mode to download new areas.",
|
||||
"areaTooBigMessage": "Zoom in to at least level {} to download offline areas. Large area downloads can cause the app to become unresponsive.",
|
||||
"downloadStarted": "Download started! Fetching tiles and nodes...",
|
||||
"downloadFailed": "Failed to start download: {}"
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Download Started",
|
||||
"message": "Download started! Fetching tiles and nodes...",
|
||||
"ok": "OK",
|
||||
"viewProgress": "View Progress in Settings"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Upload Destination",
|
||||
"subtitle": "Choose where cameras are uploaded",
|
||||
@@ -139,6 +187,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",
|
||||
@@ -148,6 +198,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.",
|
||||
@@ -155,7 +210,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",
|
||||
@@ -208,7 +267,7 @@
|
||||
"urlTemplate": "URL Template",
|
||||
"urlTemplateHint": "https://example.com/{z}/{x}/{y}.png",
|
||||
"urlTemplateRequired": "URL template is required",
|
||||
"urlTemplatePlaceholders": "URL must contain {z}, {x}, and {y} placeholders",
|
||||
"urlTemplatePlaceholders": "URL must contain either {quadkey} or {z}, {x}, and {y} placeholders",
|
||||
"attribution": "Attribution",
|
||||
"attributionHint": "© Map Provider",
|
||||
"attributionRequired": "Attribution is required",
|
||||
@@ -246,6 +305,10 @@
|
||||
"profileNameRequired": "Profile name is required",
|
||||
"requiresDirection": "Requires Direction",
|
||||
"requiresDirectionSubtitle": "Whether cameras of this type need a direction tag",
|
||||
"fov": "Field of View",
|
||||
"fovHint": "FOV in degrees (leave empty for default)",
|
||||
"fovSubtitle": "Camera field of view - used for cone width and range submission format",
|
||||
"fovInvalid": "FOV must be between 1 and 360 degrees",
|
||||
"submittable": "Submittable",
|
||||
"submittableSubtitle": "Whether this profile can be used for camera submissions",
|
||||
"osmTags": "OSM Tags",
|
||||
@@ -323,9 +386,38 @@
|
||||
"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"
|
||||
"showIndicatorSubtitle": "Display network loading and error status on the map",
|
||||
"loading": "Loading...",
|
||||
"timedOut": "Timed out",
|
||||
"noData": "No tiles here",
|
||||
"success": "Done",
|
||||
"nodeLimitReached": "Showing limit - increase in settings",
|
||||
"tileProviderSlow": "Tile provider slow",
|
||||
"nodeDataSlow": "Node data slow",
|
||||
"networkIssues": "Network issues"
|
||||
},
|
||||
"navigation": {
|
||||
"searchLocation": "Search Location",
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
"description": "DeFlock es una aplicación móvil enfocada en la privacidad para mapear infraestructura de vigilancia pública usando OpenStreetMap. Documenta cámaras, ALPRs, detectores de disparos y otros dispositivos de vigilancia en tu comunidad para hacer visible y consultable esta infraestructura.",
|
||||
"features": "• Mapeo con capacidad offline con áreas descargables\n• Subida directa a OpenStreetMap con OAuth2\n• Perfiles integrados para fabricantes principales\n• Respeta la privacidad - no se recopilan datos del usuario\n• Múltiples proveedores de mapas (OSM, imágenes satelitales)",
|
||||
"initiative": "Parte de la iniciativa más amplia DeFlock para promover la transparencia en vigilancia.",
|
||||
"footer": "Visita: deflock.me\nConstruido con Flutter • Código Abierto"
|
||||
"footer": "Visita: deflock.me\nConstruido con Flutter • Código Abierto",
|
||||
"showWelcome": "Mostrar Mensaje de Bienvenida",
|
||||
"showSubmissionGuide": "Mostrar Guía de Envío",
|
||||
"viewReleaseNotes": "Ver Notas de Lanzamiento"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Bienvenido a DeFlock",
|
||||
@@ -23,6 +26,17 @@
|
||||
"dontShowAgain": "No mostrar este mensaje de bienvenida otra vez",
|
||||
"getStarted": "¡Comencemos con DeFlock!"
|
||||
},
|
||||
"submissionGuide": {
|
||||
"title": "Mejores Prácticas de Envío",
|
||||
"description": "Antes de enviar su primer dispositivo de vigilancia, tómese un momento para revisar estas pautas importantes para contribuciones de alta calidad a OpenStreetMap.",
|
||||
"bestPractices": "• Solo mapee dispositivos que haya observado personalmente\n• Tómese tiempo para identificar con precisión el tipo y fabricante\n• Use posicionamiento preciso - acerque antes de colocar el marcador\n• Incluya información de dirección cuando sea aplicable\n• Verifique sus selecciones de etiquetas antes de enviar",
|
||||
"placementNote": "Recuerde: Los datos precisos y de primera mano son esenciales para la comunidad DeFlock y el proyecto OpenStreetMap.",
|
||||
"moreInfo": "Para orientación detallada sobre identificación de dispositivos y mejores prácticas de mapeo:",
|
||||
"identificationGuide": "Guía de Identificación",
|
||||
"osmWiki": "Wiki de OpenStreetMap",
|
||||
"dontShowAgain": "No mostrar esta guía otra vez",
|
||||
"gotIt": "¡Entendido!"
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "Nuevo Nodo",
|
||||
"download": "Descargar",
|
||||
@@ -34,7 +48,28 @@
|
||||
"close": "Cerrar",
|
||||
"submit": "Enviar",
|
||||
"saveEdit": "Guardar Edición",
|
||||
"clear": "Limpiar"
|
||||
"clear": "Limpiar",
|
||||
"viewOnOSM": "Ver en OSM",
|
||||
"advanced": "Avanzado",
|
||||
"useAdvancedEditor": "Usar Editor Avanzado"
|
||||
},
|
||||
"proximityWarning": {
|
||||
"title": "Nodo Muy Cerca de Dispositivo Existente",
|
||||
"message": "Este nodo está a solo {} metros de un dispositivo de vigilancia existente.",
|
||||
"suggestion": "Si hay múltiples dispositivos en el mismo poste, use múltiples direcciones en un solo nodo en lugar de crear nodos separados.",
|
||||
"nearbyNodes": "Dispositivo(s) cercano(s) encontrado(s) ({}):",
|
||||
"nodeInfo": "Nodo #{} - {}",
|
||||
"andMore": "...y {} más",
|
||||
"goBack": "Volver",
|
||||
"submitAnyway": "Enviar de Todas Formas",
|
||||
"nodeType": {
|
||||
"alpr": "Cámara ALPR/ANPR",
|
||||
"publicCamera": "Cámara de Vigilancia Pública",
|
||||
"camera": "Cámara de Vigilancia",
|
||||
"amenity": "{}",
|
||||
"device": "Dispositivo {}",
|
||||
"unknown": "Dispositivo Desconocido"
|
||||
}
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Activar seguimiento",
|
||||
@@ -54,6 +89,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,6 +149,10 @@
|
||||
"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 otro elemento del mapa (OSM way/relation). Aún puede editar sus etiquetas y dirección.",
|
||||
"zoomInRequiredMessage": "Amplíe al menos al nivel {} para agregar o editar nodos de vigilancia. Esto garantiza un posicionamiento preciso para un mapeo exacto.",
|
||||
"extractFromWay": "Extraer nodo de way/relation",
|
||||
"extractFromWaySubtitle": "Crear nuevo nodo con las mismas etiquetas, permitir mover a nueva ubicación",
|
||||
"refineTags": "Refinar Etiquetas",
|
||||
"refineTagsWithProfile": "Refinar Etiquetas ({})"
|
||||
},
|
||||
@@ -125,9 +166,16 @@
|
||||
"withinTileLimit": "Dentro del límite de {} mosaicos",
|
||||
"exceedsTileLimit": "La selección actual excede el límite de {} mosaicos",
|
||||
"offlineModeWarning": "Descargas deshabilitadas en modo sin conexión. Deshabilite el modo sin conexión para descargar nuevas áreas.",
|
||||
"areaTooBigMessage": "Amplíe al menos al nivel {} para descargar áreas sin conexión. Las descargas de áreas grandes pueden hacer que la aplicación deje de responder.",
|
||||
"downloadStarted": "¡Descarga iniciada! Obteniendo mosaicos y nodos...",
|
||||
"downloadFailed": "Error al iniciar la descarga: {}"
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Descarga Iniciada",
|
||||
"message": "¡Descarga iniciada! Obteniendo mosaicos y nodos...",
|
||||
"ok": "OK",
|
||||
"viewProgress": "Ver Progreso en Configuración"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Destino de Subida",
|
||||
"subtitle": "Elige dónde se suben las cámaras",
|
||||
@@ -139,6 +187,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",
|
||||
@@ -148,6 +198,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.",
|
||||
@@ -155,7 +210,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",
|
||||
@@ -208,7 +267,7 @@
|
||||
"urlTemplate": "Plantilla de URL",
|
||||
"urlTemplateHint": "https://ejemplo.com/{z}/{x}/{y}.png",
|
||||
"urlTemplateRequired": "La plantilla de URL es requerida",
|
||||
"urlTemplatePlaceholders": "La URL debe contener marcadores {z}, {x} y {y}",
|
||||
"urlTemplatePlaceholders": "La URL debe contener marcadores {quadkey} o {z}, {x} y {y}",
|
||||
"attribution": "Atribución",
|
||||
"attributionHint": "© Proveedor de Mapas",
|
||||
"attributionRequired": "La atribución es requerida",
|
||||
@@ -246,6 +305,10 @@
|
||||
"profileNameRequired": "El nombre del perfil es requerido",
|
||||
"requiresDirection": "Requiere Dirección",
|
||||
"requiresDirectionSubtitle": "Si las cámaras de este tipo necesitan una etiqueta de dirección",
|
||||
"fov": "Campo de Visión",
|
||||
"fovHint": "Campo de visión en grados (dejar vacío para el predeterminado)",
|
||||
"fovSubtitle": "Campo de visión de la cámara - usado para el ancho del cono y formato de envío por rango",
|
||||
"fovInvalid": "El campo de visión debe estar entre 1 y 360 grados",
|
||||
"submittable": "Envíable",
|
||||
"submittableSubtitle": "Si este perfil puede usarse para envíos de cámaras",
|
||||
"osmTags": "Etiquetas OSM",
|
||||
@@ -323,9 +386,38 @@
|
||||
"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"
|
||||
"showIndicatorSubtitle": "Mostrar estado de carga y errores de red en el mapa",
|
||||
"loading": "Cargando...",
|
||||
"timedOut": "Tiempo agotado",
|
||||
"noData": "Sin mosaicos aquí",
|
||||
"success": "Hecho",
|
||||
"nodeLimitReached": "Mostrando límite - aumentar en ajustes",
|
||||
"tileProviderSlow": "Proveedor de mosaicos lento",
|
||||
"nodeDataSlow": "Datos de nodo lentos",
|
||||
"networkIssues": "Problemas de red"
|
||||
},
|
||||
"navigation": {
|
||||
"searchLocation": "Buscar ubicación",
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
"description": "DeFlock est une application mobile axée sur la confidentialité pour cartographier l'infrastructure de surveillance publique en utilisant OpenStreetMap. Documentez les caméras, ALPRs, détecteurs de coups de feu et autres dispositifs de surveillance dans votre communauté pour rendre cette infrastructure visible et consultable.",
|
||||
"features": "• Cartographie hors ligne avec zones téléchargeables\n• Upload direct vers OpenStreetMap avec OAuth2\n• Profils intégrés pour les principaux fabricants\n• Respectueux de la confidentialité - aucune donnée utilisateur collectée\n• Multiples fournisseurs de cartes (OSM, imagerie satellite)",
|
||||
"initiative": "Partie de l'initiative plus large DeFlock pour promouvoir la transparence de la surveillance.",
|
||||
"footer": "Visitez : deflock.me\nConstruit avec Flutter • Source Ouverte"
|
||||
"footer": "Visitez : deflock.me\nConstruit avec Flutter • Source Ouverte",
|
||||
"showWelcome": "Afficher le Message de Bienvenue",
|
||||
"showSubmissionGuide": "Afficher le Guide de Soumission",
|
||||
"viewReleaseNotes": "Voir les Notes de Version"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Bienvenue dans DeFlock",
|
||||
@@ -23,6 +26,17 @@
|
||||
"dontShowAgain": "Ne plus afficher ce message de bienvenue",
|
||||
"getStarted": "Commençons le DeFlock !"
|
||||
},
|
||||
"submissionGuide": {
|
||||
"title": "Meilleures Pratiques de Soumission",
|
||||
"description": "Avant de soumettre votre premier dispositif de surveillance, prenez un moment pour examiner ces directives importantes pour des contributions de haute qualité à OpenStreetMap.",
|
||||
"bestPractices": "• Ne cartographiez que les dispositifs que vous avez observés personnellement\n• Prenez le temps d'identifier avec précision le type et le fabricant\n• Utilisez un positionnement précis - zoomez avant de placer le marqueur\n• Incluez les informations de direction quand c'est applicable\n• Vérifiez vos sélections d'étiquettes avant de soumettre",
|
||||
"placementNote": "Rappelez-vous : Des données précises et de première main sont essentielles pour la communauté DeFlock et le projet OpenStreetMap.",
|
||||
"moreInfo": "Pour des conseils détaillés sur l'identification des dispositifs et les meilleures pratiques de cartographie :",
|
||||
"identificationGuide": "Guide d'Identification",
|
||||
"osmWiki": "Wiki OpenStreetMap",
|
||||
"dontShowAgain": "Ne plus afficher ce guide",
|
||||
"gotIt": "Compris !"
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "Nouveau Nœud",
|
||||
"download": "Télécharger",
|
||||
@@ -34,7 +48,28 @@
|
||||
"close": "Fermer",
|
||||
"submit": "Soumettre",
|
||||
"saveEdit": "Sauvegarder Modification",
|
||||
"clear": "Effacer"
|
||||
"clear": "Effacer",
|
||||
"viewOnOSM": "Voir sur OSM",
|
||||
"advanced": "Avancé",
|
||||
"useAdvancedEditor": "Utiliser l'Éditeur Avancé"
|
||||
},
|
||||
"proximityWarning": {
|
||||
"title": "Nœud Très Proche d'un Dispositif Existant",
|
||||
"message": "Ce nœud n'est qu'à {} mètres d'un dispositif de surveillance existant.",
|
||||
"suggestion": "Si plusieurs dispositifs se trouvent sur le même poteau, veuillez utiliser plusieurs directions sur un seul nœud au lieu de créer des nœuds séparés.",
|
||||
"nearbyNodes": "Dispositif(s) proche(s) trouvé(s) ({}) :",
|
||||
"nodeInfo": "Nœud #{} - {}",
|
||||
"andMore": "...et {} de plus",
|
||||
"goBack": "Retour",
|
||||
"submitAnyway": "Soumettre Quand Même",
|
||||
"nodeType": {
|
||||
"alpr": "Caméra ALPR/ANPR",
|
||||
"publicCamera": "Caméra de Surveillance Publique",
|
||||
"camera": "Caméra de Surveillance",
|
||||
"amenity": "{}",
|
||||
"device": "Dispositif {}",
|
||||
"unknown": "Dispositif Inconnu"
|
||||
}
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Activer le suivi",
|
||||
@@ -54,6 +89,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,6 +149,10 @@
|
||||
"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 à un autre élément de carte (OSM way/relation). Vous pouvez toujours modifier ses balises et sa direction.",
|
||||
"zoomInRequiredMessage": "Zoomez au moins au niveau {} pour ajouter ou modifier des nœuds de surveillance. Cela garantit un positionnement précis pour une cartographie exacte.",
|
||||
"extractFromWay": "Extraire le nœud du way/relation",
|
||||
"extractFromWaySubtitle": "Créer un nouveau nœud avec les mêmes balises, permettre le déplacement vers un nouvel emplacement",
|
||||
"refineTags": "Affiner Balises",
|
||||
"refineTagsWithProfile": "Affiner Balises ({})"
|
||||
},
|
||||
@@ -125,9 +166,16 @@
|
||||
"withinTileLimit": "Dans la limite de {} tuiles",
|
||||
"exceedsTileLimit": "La sélection actuelle dépasse la limite de {} tuiles",
|
||||
"offlineModeWarning": "Téléchargements désactivés en mode hors ligne. Désactivez le mode hors ligne pour télécharger de nouvelles zones.",
|
||||
"downloadStarted": "Téléchargement démarré! Récupération des tuiles et nœuds...",
|
||||
"areaTooBigMessage": "Zoomez au moins au niveau {} pour télécharger des zones hors ligne. Les téléchargements de grandes zones peuvent rendre l'application non réactive.",
|
||||
"downloadStarted": "Téléchargement démarré ! Récupération des tuiles et nœuds...",
|
||||
"downloadFailed": "Échec du démarrage du téléchargement: {}"
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Téléchargement Démarré",
|
||||
"message": "Téléchargement démarré! Récupération des tuiles et nœuds...",
|
||||
"ok": "OK",
|
||||
"viewProgress": "Voir le Progrès dans Paramètres"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Destination de Téléchargement",
|
||||
"subtitle": "Choisir où les caméras sont téléchargées",
|
||||
@@ -139,6 +187,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",
|
||||
@@ -148,6 +198,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.",
|
||||
@@ -155,7 +210,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",
|
||||
@@ -208,7 +267,7 @@
|
||||
"urlTemplate": "Modèle d'URL",
|
||||
"urlTemplateHint": "https://exemple.com/{z}/{x}/{y}.png",
|
||||
"urlTemplateRequired": "Le modèle d'URL est requis",
|
||||
"urlTemplatePlaceholders": "L'URL doit contenir les marqueurs {z}, {x} et {y}",
|
||||
"urlTemplatePlaceholders": "L'URL doit contenir soit {quadkey} soit les marqueurs {z}, {x} et {y}",
|
||||
"attribution": "Attribution",
|
||||
"attributionHint": "© Fournisseur de Cartes",
|
||||
"attributionRequired": "L'attribution est requise",
|
||||
@@ -246,6 +305,10 @@
|
||||
"profileNameRequired": "Le nom du profil est requis",
|
||||
"requiresDirection": "Nécessite Direction",
|
||||
"requiresDirectionSubtitle": "Si les caméras de ce type ont besoin d'une balise de direction",
|
||||
"fov": "Champ de Vision",
|
||||
"fovHint": "Champ de vision en degrés (laisser vide pour la valeur par défaut)",
|
||||
"fovSubtitle": "Champ de vision de la caméra - utilisé pour la largeur du cône et le format de soumission par plage",
|
||||
"fovInvalid": "Le champ de vision doit être entre 1 et 360 degrés",
|
||||
"submittable": "Soumissible",
|
||||
"submittableSubtitle": "Si ce profil peut être utilisé pour les soumissions de caméras",
|
||||
"osmTags": "Balises OSM",
|
||||
@@ -323,9 +386,38 @@
|
||||
"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"
|
||||
"showIndicatorSubtitle": "Afficher l'état de chargement et d'erreur réseau sur la carte",
|
||||
"loading": "Chargement...",
|
||||
"timedOut": "Temps dépassé",
|
||||
"noData": "Aucune tuile ici",
|
||||
"success": "Terminé",
|
||||
"nodeLimitReached": "Limite affichée - augmenter dans les paramètres",
|
||||
"tileProviderSlow": "Fournisseur de tuiles lent",
|
||||
"nodeDataSlow": "Données de nœud lentes",
|
||||
"networkIssues": "Problèmes réseau"
|
||||
},
|
||||
"navigation": {
|
||||
"searchLocation": "Rechercher lieu",
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
"description": "DeFlock è un'app mobile orientata alla privacy per mappare l'infrastruttura di sorveglianza pubblica utilizzando OpenStreetMap. Documenta telecamere, ALPR, rilevatori di spari e altri dispositivi di sorveglianza nella tua comunità per rendere questa infrastruttura visibile e ricercabile.",
|
||||
"features": "• Mappatura con capacità offline con aree scaricabili\n• Upload diretto su OpenStreetMap con OAuth2\n• Profili integrati per i principali produttori\n• Rispettoso della privacy - nessun dato utente raccolto\n• Multipli fornitori di mappe (OSM, immagini satellitari)",
|
||||
"initiative": "Parte della più ampia iniziativa DeFlock per promuovere la trasparenza della sorveglianza.",
|
||||
"footer": "Visita: deflock.me\nCostruito con Flutter • Open Source"
|
||||
"footer": "Visita: deflock.me\nCostruito con Flutter • Open Source",
|
||||
"showWelcome": "Mostra Messaggio di Benvenuto",
|
||||
"showSubmissionGuide": "Mostra Guida di Invio",
|
||||
"viewReleaseNotes": "Visualizza Note di Rilascio"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Benvenuto in DeFlock",
|
||||
@@ -23,6 +26,17 @@
|
||||
"dontShowAgain": "Non mostrare più questo messaggio di benvenuto",
|
||||
"getStarted": "Iniziamo con DeFlock!"
|
||||
},
|
||||
"submissionGuide": {
|
||||
"title": "Migliori Pratiche di Invio",
|
||||
"description": "Prima di inviare il tuo primo dispositivo di sorveglianza, prenditi un momento per rivedere queste linee guida importanti per contributi di alta qualità a OpenStreetMap.",
|
||||
"bestPractices": "• Mappa solo dispositivi che hai osservato personalmente\n• Prenditi tempo per identificare accuratamente tipo e produttore\n• Usa posizionamento preciso - ingrandisci prima di piazzare il marcatore\n• Includi informazioni sulla direzione quando applicabile\n• Controlla le tue selezioni di tag prima di inviare",
|
||||
"placementNote": "Ricorda: Dati accurati e di prima mano sono essenziali per la comunità DeFlock e il progetto OpenStreetMap.",
|
||||
"moreInfo": "Per una guida dettagliata sull'identificazione dei dispositivi e le migliori pratiche di mappatura:",
|
||||
"identificationGuide": "Guida di Identificazione",
|
||||
"osmWiki": "Wiki OpenStreetMap",
|
||||
"dontShowAgain": "Non mostrare più questa guida",
|
||||
"gotIt": "Capito!"
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "Nuovo Nodo",
|
||||
"download": "Scarica",
|
||||
@@ -34,7 +48,28 @@
|
||||
"close": "Chiudi",
|
||||
"submit": "Invia",
|
||||
"saveEdit": "Salva Modifica",
|
||||
"clear": "Pulisci"
|
||||
"clear": "Pulisci",
|
||||
"viewOnOSM": "Visualizza su OSM",
|
||||
"advanced": "Avanzato",
|
||||
"useAdvancedEditor": "Usa Editor Avanzato"
|
||||
},
|
||||
"proximityWarning": {
|
||||
"title": "Nodo Molto Vicino a Dispositivo Esistente",
|
||||
"message": "Questo nodo è a soli {} metri da un dispositivo di sorveglianza esistente.",
|
||||
"suggestion": "Se ci sono più dispositivi sullo stesso palo, utilizzare più direzioni su un singolo nodo invece di creare nodi separati.",
|
||||
"nearbyNodes": "Dispositivo/i vicino/i trovato/i ({}):",
|
||||
"nodeInfo": "Nodo #{} - {}",
|
||||
"andMore": "...e altri {}",
|
||||
"goBack": "Torna Indietro",
|
||||
"submitAnyway": "Invia Comunque",
|
||||
"nodeType": {
|
||||
"alpr": "Telecamera ALPR/ANPR",
|
||||
"publicCamera": "Telecamera di Sorveglianza Pubblica",
|
||||
"camera": "Telecamera di Sorveglianza",
|
||||
"amenity": "{}",
|
||||
"device": "Dispositivo {}",
|
||||
"unknown": "Dispositivo Sconosciuto"
|
||||
}
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Attiva seguimi",
|
||||
@@ -54,6 +89,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,6 +149,10 @@
|
||||
"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 un altro elemento della mappa (OSM way/relation). Puoi ancora modificare i suoi tag e direzione.",
|
||||
"zoomInRequiredMessage": "Ingrandisci almeno al livello {} per aggiungere o modificare nodi di sorveglianza. Questo garantisce un posizionamento preciso per una mappatura accurata.",
|
||||
"extractFromWay": "Estrai nodo da way/relation",
|
||||
"extractFromWaySubtitle": "Crea nuovo nodo con gli stessi tag, consenti spostamento in nuova posizione",
|
||||
"refineTags": "Affina Tag",
|
||||
"refineTagsWithProfile": "Affina Tag ({})"
|
||||
},
|
||||
@@ -125,9 +166,16 @@
|
||||
"withinTileLimit": "Entro il limite di {} tile",
|
||||
"exceedsTileLimit": "La selezione corrente supera il limite di {} tile",
|
||||
"offlineModeWarning": "Download disabilitati in modalità offline. Disabilita la modalità offline per scaricare nuove aree.",
|
||||
"areaTooBigMessage": "Ingrandisci almeno al livello {} per scaricare aree offline. I download di aree grandi possono rendere l'app non reattiva.",
|
||||
"downloadStarted": "Download avviato! Recupero tile e nodi...",
|
||||
"downloadFailed": "Impossibile avviare il download: {}"
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Download Avviato",
|
||||
"message": "Download avviato! Recupero tile e nodi...",
|
||||
"ok": "OK",
|
||||
"viewProgress": "Visualizza Progresso in Impostazioni"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Destinazione Upload",
|
||||
"subtitle": "Scegli dove vengono caricate le telecamere",
|
||||
@@ -139,6 +187,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",
|
||||
@@ -148,6 +198,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.",
|
||||
@@ -155,7 +210,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",
|
||||
@@ -208,7 +267,7 @@
|
||||
"urlTemplate": "Template URL",
|
||||
"urlTemplateHint": "https://esempio.com/{z}/{x}/{y}.png",
|
||||
"urlTemplateRequired": "Il template URL è obbligatorio",
|
||||
"urlTemplatePlaceholders": "L'URL deve contenere i segnaposto {z}, {x} e {y}",
|
||||
"urlTemplatePlaceholders": "L'URL deve contenere o {quadkey} o i segnaposto {z}, {x} e {y}",
|
||||
"attribution": "Attribuzione",
|
||||
"attributionHint": "© Fornitore Mappe",
|
||||
"attributionRequired": "L'attribuzione è obbligatoria",
|
||||
@@ -246,6 +305,10 @@
|
||||
"profileNameRequired": "Il nome del profilo è obbligatorio",
|
||||
"requiresDirection": "Richiede Direzione",
|
||||
"requiresDirectionSubtitle": "Se le telecamere di questo tipo necessitano di un tag direzione",
|
||||
"fov": "Campo Visivo",
|
||||
"fovHint": "Campo visivo in gradi (lasciare vuoto per il valore predefinito)",
|
||||
"fovSubtitle": "Campo visivo della telecamera - utilizzato per la larghezza del cono e il formato di invio per intervallo",
|
||||
"fovInvalid": "Il campo visivo deve essere tra 1 e 360 gradi",
|
||||
"submittable": "Inviabile",
|
||||
"submittableSubtitle": "Se questo profilo può essere usato per invii di telecamere",
|
||||
"osmTags": "Tag OSM",
|
||||
@@ -323,9 +386,38 @@
|
||||
"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"
|
||||
"showIndicatorSubtitle": "Visualizza lo stato di caricamento e errori di rete sulla mappa",
|
||||
"loading": "Caricamento...",
|
||||
"timedOut": "Tempo scaduto",
|
||||
"noData": "Nessuna tessera qui",
|
||||
"success": "Fatto",
|
||||
"nodeLimitReached": "Limite visualizzato - aumentare nelle impostazioni",
|
||||
"tileProviderSlow": "Provider di tessere lento",
|
||||
"nodeDataSlow": "Dati del nodo lenti",
|
||||
"networkIssues": "Problemi di rete"
|
||||
},
|
||||
"navigation": {
|
||||
"searchLocation": "Cerca posizione",
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
"description": "DeFlock é um aplicativo móvel focado na privacidade para mapear infraestrutura de vigilância pública usando OpenStreetMap. Documente câmeras, ALPRs, detectores de tiros e outros dispositivos de vigilância em sua comunidade para tornar essa infraestrutura visível e pesquisável.",
|
||||
"features": "• Mapeamento com capacidade offline com áreas para download\n• Upload direto para OpenStreetMap com OAuth2\n• Perfis integrados para principais fabricantes\n• Respeitoso à privacidade - nenhum dado do usuário coletado\n• Múltiplos provedores de mapas (OSM, imagens de satélite)",
|
||||
"initiative": "Parte da iniciativa mais ampla DeFlock para promover transparência na vigilância.",
|
||||
"footer": "Visite: deflock.me\nConstruído com Flutter • Código Aberto"
|
||||
"footer": "Visite: deflock.me\nConstruído com Flutter • Código Aberto",
|
||||
"showWelcome": "Mostrar Mensagem de Boas-vindas",
|
||||
"showSubmissionGuide": "Mostrar Guia de Submissão",
|
||||
"viewReleaseNotes": "Ver Notas de Lançamento"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Bem-vindo ao DeFlock",
|
||||
@@ -23,6 +26,17 @@
|
||||
"dontShowAgain": "Não mostrar esta mensagem de boas-vindas novamente",
|
||||
"getStarted": "Vamos começar com o DeFlock!"
|
||||
},
|
||||
"submissionGuide": {
|
||||
"title": "Melhores Práticas de Submissão",
|
||||
"description": "Antes de submeter seu primeiro dispositivo de vigilância, dedique um momento para revisar estas diretrizes importantes para contribuições de alta qualidade ao OpenStreetMap.",
|
||||
"bestPractices": "• Mapear apenas dispositivos que você observou pessoalmente\n• Dedicar tempo para identificar com precisão tipo e fabricante\n• Usar posicionamento preciso - aproximar antes de colocar o marcador\n• Incluir informações de direção quando aplicável\n• Verificar suas seleções de tags antes de submeter",
|
||||
"placementNote": "Lembre-se: Dados precisos e de primeira mão são essenciais para a comunidade DeFlock e o projeto OpenStreetMap.",
|
||||
"moreInfo": "Para orientação detalhada sobre identificação de dispositivos e melhores práticas de mapeamento:",
|
||||
"identificationGuide": "Guia de Identificação",
|
||||
"osmWiki": "Wiki OpenStreetMap",
|
||||
"dontShowAgain": "Não mostrar este guia novamente",
|
||||
"gotIt": "Entendi!"
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "Novo Nó",
|
||||
"download": "Baixar",
|
||||
@@ -34,7 +48,28 @@
|
||||
"close": "Fechar",
|
||||
"submit": "Enviar",
|
||||
"saveEdit": "Salvar Edição",
|
||||
"clear": "Limpar"
|
||||
"clear": "Limpar",
|
||||
"viewOnOSM": "Ver no OSM",
|
||||
"advanced": "Avançado",
|
||||
"useAdvancedEditor": "Usar Editor Avançado"
|
||||
},
|
||||
"proximityWarning": {
|
||||
"title": "Nó Muito Próximo de Dispositivo Existente",
|
||||
"message": "Este nó está a apenas {} metros de um dispositivo de vigilância existente.",
|
||||
"suggestion": "Se vários dispositivos estão no mesmo poste, use várias direções em um único nó em vez de criar nós separados.",
|
||||
"nearbyNodes": "Dispositivo(s) próximo(s) encontrado(s) ({}):",
|
||||
"nodeInfo": "Nó #{} - {}",
|
||||
"andMore": "...e mais {}",
|
||||
"goBack": "Voltar",
|
||||
"submitAnyway": "Enviar Mesmo Assim",
|
||||
"nodeType": {
|
||||
"alpr": "Câmera ALPR/ANPR",
|
||||
"publicCamera": "Câmera de Vigilância Pública",
|
||||
"camera": "Câmera de Vigilância",
|
||||
"amenity": "{}",
|
||||
"device": "Dispositivo {}",
|
||||
"unknown": "Dispositivo Desconhecido"
|
||||
}
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Ativar seguir-me",
|
||||
@@ -54,6 +89,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,6 +149,10 @@
|
||||
"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 outro elemento do mapa (OSM way/relation). Você ainda pode editar suas tags e direção.",
|
||||
"zoomInRequiredMessage": "Amplie para pelo menos o nível {} para adicionar ou editar nós de vigilância. Isto garante um posicionamento preciso para mapeamento exato.",
|
||||
"extractFromWay": "Extrair nó do way/relation",
|
||||
"extractFromWaySubtitle": "Criar novo nó com as mesmas tags, permitir mover para nova localização",
|
||||
"refineTags": "Refinar Tags",
|
||||
"refineTagsWithProfile": "Refinar Tags ({})"
|
||||
},
|
||||
@@ -125,9 +166,16 @@
|
||||
"withinTileLimit": "Dentro do limite de {} tiles",
|
||||
"exceedsTileLimit": "A seleção atual excede o limite de {} tiles",
|
||||
"offlineModeWarning": "Downloads desabilitados no modo offline. Desative o modo offline para baixar novas áreas.",
|
||||
"areaTooBigMessage": "Amplie para pelo menos o nível {} para baixar áreas offline. Downloads de áreas grandes podem tornar o aplicativo não responsivo.",
|
||||
"downloadStarted": "Download iniciado! Buscando tiles e nós...",
|
||||
"downloadFailed": "Falha ao iniciar o download: {}"
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Download Iniciado",
|
||||
"message": "Download iniciado! Buscando tiles e nós...",
|
||||
"ok": "OK",
|
||||
"viewProgress": "Ver Progresso nas Configurações"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Destino do Upload",
|
||||
"subtitle": "Escolha onde as câmeras são enviadas",
|
||||
@@ -139,6 +187,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",
|
||||
@@ -148,6 +198,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.",
|
||||
@@ -155,7 +210,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",
|
||||
@@ -208,7 +267,7 @@
|
||||
"urlTemplate": "Modelo de URL",
|
||||
"urlTemplateHint": "https://exemplo.com/{z}/{x}/{y}.png",
|
||||
"urlTemplateRequired": "Modelo de URL é obrigatório",
|
||||
"urlTemplatePlaceholders": "URL deve conter os marcadores {z}, {x} e {y}",
|
||||
"urlTemplatePlaceholders": "URL deve conter {quadkey} ou os marcadores {z}, {x} e {y}",
|
||||
"attribution": "Atribuição",
|
||||
"attributionHint": "© Provedor de Mapas",
|
||||
"attributionRequired": "Atribuição é obrigatória",
|
||||
@@ -246,6 +305,10 @@
|
||||
"profileNameRequired": "Nome do perfil é obrigatório",
|
||||
"requiresDirection": "Requer Direção",
|
||||
"requiresDirectionSubtitle": "Se câmeras deste tipo precisam de uma tag de direção",
|
||||
"fov": "Campo de Visão",
|
||||
"fovHint": "Campo de visão em graus (deixar vazio para o padrão)",
|
||||
"fovSubtitle": "Campo de visão da câmera - usado para largura do cone e formato de envio por intervalo",
|
||||
"fovInvalid": "Campo de visão deve estar entre 1 e 360 graus",
|
||||
"submittable": "Enviável",
|
||||
"submittableSubtitle": "Se este perfil pode ser usado para envios de câmeras",
|
||||
"osmTags": "Tags OSM",
|
||||
@@ -323,9 +386,38 @@
|
||||
"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"
|
||||
"showIndicatorSubtitle": "Mostrar status de carregamento e erro de rede no mapa",
|
||||
"loading": "Carregando...",
|
||||
"timedOut": "Tempo esgotado",
|
||||
"noData": "Nenhum tile aqui",
|
||||
"success": "Concluído",
|
||||
"nodeLimitReached": "Limite exibido - aumentar nas configurações",
|
||||
"tileProviderSlow": "Provedor de tiles lento",
|
||||
"nodeDataSlow": "Dados do nó lentos",
|
||||
"networkIssues": "Problemas de rede"
|
||||
},
|
||||
"navigation": {
|
||||
"searchLocation": "Buscar localização",
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
"description": "DeFlock 是一款注重隐私的移动应用,使用 OpenStreetMap 绘制公共监控基础设施。记录您社区中的摄像头、车牌识别系统、枪击探测器和其他监控设备,使这些基础设施可见且可搜索。",
|
||||
"features": "• 具有可下载区域的离线映射功能\n• 使用 OAuth2 直接上传到 OpenStreetMap\n• 主要制造商的内置配置文件\n• 尊重隐私 - 不收集用户数据\n• 多个地图提供商(OSM、卫星图像)",
|
||||
"initiative": "DeFlock 更广泛倡议的一部分,旨在促进监控透明化。",
|
||||
"footer": "访问:deflock.me\n使用 Flutter 构建 • 开源"
|
||||
"footer": "访问:deflock.me\n使用 Flutter 构建 • 开源",
|
||||
"showWelcome": "显示欢迎消息",
|
||||
"showSubmissionGuide": "显示提交指南",
|
||||
"viewReleaseNotes": "查看发布说明"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "欢迎使用 DeFlock",
|
||||
@@ -23,6 +26,17 @@
|
||||
"dontShowAgain": "不再显示此欢迎消息",
|
||||
"getStarted": "开始使用 DeFlock!"
|
||||
},
|
||||
"submissionGuide": {
|
||||
"title": "提交最佳实践",
|
||||
"description": "在提交您的第一个监控设备之前,请花点时间查看这些重要指南,以确保对 OpenStreetMap 的高质量贡献。",
|
||||
"bestPractices": "• 只映射您亲自观察到的设备\n• 花时间准确识别设备类型和制造商\n• 使用精确定位 - 放置标记前请放大\n• 在适用时包含方向信息\n• 提交前请检查您的标签选择",
|
||||
"placementNote": "请记住:准确的第一手数据对 DeFlock 社区和 OpenStreetMap 项目至关重要。",
|
||||
"moreInfo": "有关设备识别和映射最佳实践的详细指导:",
|
||||
"identificationGuide": "识别指南",
|
||||
"osmWiki": "OpenStreetMap Wiki",
|
||||
"dontShowAgain": "不再显示此指南",
|
||||
"gotIt": "明白了!"
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "新建节点",
|
||||
"download": "下载",
|
||||
@@ -34,7 +48,28 @@
|
||||
"close": "关闭",
|
||||
"submit": "提交",
|
||||
"saveEdit": "保存编辑",
|
||||
"clear": "清空"
|
||||
"clear": "清空",
|
||||
"viewOnOSM": "在OSM上查看",
|
||||
"advanced": "高级",
|
||||
"useAdvancedEditor": "使用高级编辑器"
|
||||
},
|
||||
"proximityWarning": {
|
||||
"title": "节点过于靠近现有设备",
|
||||
"message": "此节点距离现有监控设备仅 {} 米。",
|
||||
"suggestion": "如果同一根杆上有多个设备,请在单个节点上使用多个方向,而不是创建单独的节点。",
|
||||
"nearbyNodes": "发现附近设备 ({}):",
|
||||
"nodeInfo": "节点 #{} - {}",
|
||||
"andMore": "...还有 {} 个",
|
||||
"goBack": "返回",
|
||||
"submitAnyway": "仍然提交",
|
||||
"nodeType": {
|
||||
"alpr": "ALPR/ANPR 摄像头",
|
||||
"publicCamera": "公共监控摄像头",
|
||||
"camera": "监控摄像头",
|
||||
"amenity": "{}",
|
||||
"device": "{} 设备",
|
||||
"unknown": "未知设备"
|
||||
}
|
||||
},
|
||||
"followMe": {
|
||||
"off": "启用跟随模式",
|
||||
@@ -54,6 +89,8 @@
|
||||
"maxNodesWarning": "除非您确定有充分的理由,否则您可能不想这样做。",
|
||||
"offlineMode": "离线模式",
|
||||
"offlineModeSubtitle": "禁用除本地/离线区域外的所有网络请求。",
|
||||
"pauseQueueProcessing": "暂停上传队列",
|
||||
"pauseQueueProcessingSubtitle": "停止上传排队的更改,同时保持实时数据访问。",
|
||||
"offlineModeWarningTitle": "活动下载",
|
||||
"offlineModeWarningMessage": "启用离线模式将取消任何活动的区域下载。您要继续吗?",
|
||||
"enableOfflineMode": "启用离线模式",
|
||||
@@ -112,6 +149,10 @@
|
||||
"sandboxModeWarning": "无法将生产节点的编辑提交到沙盒。在设置中切换到生产模式以编辑节点。",
|
||||
"enableSubmittableProfile": "在设置中启用可提交的配置文件以编辑节点。",
|
||||
"profileViewOnlyWarning": "此配置文件仅用于地图查看。请选择可提交的配置文件来编辑节点。",
|
||||
"cannotMoveConstrainedNode": "无法移动此相机 - 它连接到另一个地图元素(OSM way/relation)。您仍可以编辑其标签和方向。",
|
||||
"zoomInRequiredMessage": "请放大至至少第{}级来添加或编辑监控节点。这确保精确定位以便准确制图。",
|
||||
"extractFromWay": "从way/relation中提取节点",
|
||||
"extractFromWaySubtitle": "创建具有相同标签的新节点,允许移动到新位置",
|
||||
"refineTags": "细化标签",
|
||||
"refineTagsWithProfile": "细化标签({})"
|
||||
},
|
||||
@@ -125,9 +166,16 @@
|
||||
"withinTileLimit": "在 {} 瓦片限制内",
|
||||
"exceedsTileLimit": "当前选择超出 {} 瓦片限制",
|
||||
"offlineModeWarning": "离线模式下禁用下载。禁用离线模式以下载新区域。",
|
||||
"areaTooBigMessage": "请放大至至少第{}级来下载离线区域。下载大区域可能导致应用程序无响应。",
|
||||
"downloadStarted": "下载已开始!正在获取瓦片和节点...",
|
||||
"downloadFailed": "启动下载失败:{}"
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "下载已开始",
|
||||
"message": "下载已开始!正在获取瓦片和节点...",
|
||||
"ok": "确定",
|
||||
"viewProgress": "在设置中查看进度"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "上传目标",
|
||||
"subtitle": "选择摄像头上传位置",
|
||||
@@ -139,6 +187,8 @@
|
||||
"simulateDescription": "模拟上传(不联系 OSM 服务器)"
|
||||
},
|
||||
"auth": {
|
||||
"osmAccountTitle": "OpenStreetMap 账户",
|
||||
"osmAccountSubtitle": "管理您的 OSM 登录并查看您的贡献",
|
||||
"loggedInAs": "已登录为 {}",
|
||||
"loginToOSM": "登录 OpenStreetMap",
|
||||
"tapToLogout": "点击登出",
|
||||
@@ -148,6 +198,11 @@
|
||||
"testConnectionSubtitle": "验证 OSM 凭据是否有效",
|
||||
"connectionOK": "连接正常 - 凭据有效",
|
||||
"connectionFailed": "连接失败 - 请重新登录",
|
||||
"viewMyEdits": "在 OSM 上查看我的编辑",
|
||||
"viewMyEditsSubtitle": "查看您在 OpenStreetMap 上的编辑历史",
|
||||
"aboutOSM": "关于 OpenStreetMap",
|
||||
"aboutOSMDescription": "OpenStreetMap 是一个协作的开源地图项目,贡献者创建和维护一个免费的、可编辑的世界地图。您的监控设备贡献有助于使这种基础设施可见和可搜索。",
|
||||
"visitOSM": "访问 OpenStreetMap",
|
||||
"deleteAccount": "删除 OSM 账户",
|
||||
"deleteAccountSubtitle": "管理您的 OpenStreetMap 账户",
|
||||
"deleteAccountExplanation": "要删除您的 OpenStreetMap 账户,您需要访问 OpenStreetMap 网站。这将永久删除您的 OSM 账户和所有相关数据。",
|
||||
@@ -155,7 +210,11 @@
|
||||
"goToOSM": "前往 OpenStreetMap"
|
||||
},
|
||||
"queue": {
|
||||
"title": "上传队列",
|
||||
"subtitle": "管理待上传的监控设备",
|
||||
"pendingUploads": "待上传:{}",
|
||||
"pendingItemsCount": "待处理项目:{}",
|
||||
"nothingInQueue": "队列中没有内容",
|
||||
"simulateModeEnabled": "模拟模式已启用 – 上传已模拟",
|
||||
"sandboxMode": "沙盒模式 – 上传到 OSM 沙盒",
|
||||
"tapToViewQueue": "点击查看队列",
|
||||
@@ -208,7 +267,7 @@
|
||||
"urlTemplate": "URL 模板",
|
||||
"urlTemplateHint": "https://example.com/{z}/{x}/{y}.png",
|
||||
"urlTemplateRequired": "URL 模板为必填项",
|
||||
"urlTemplatePlaceholders": "URL 必须包含 {z}、{x} 和 {y} 占位符",
|
||||
"urlTemplatePlaceholders": "URL 必须包含 {quadkey} 或 {z}、{x} 和 {y} 占位符",
|
||||
"attribution": "归属",
|
||||
"attributionHint": "© 地图提供商",
|
||||
"attributionRequired": "归属为必填项",
|
||||
@@ -246,6 +305,10 @@
|
||||
"profileNameRequired": "配置文件名称为必填项",
|
||||
"requiresDirection": "需要方向",
|
||||
"requiresDirectionSubtitle": "此类型的摄像头是否需要方向标签",
|
||||
"fov": "视场角",
|
||||
"fovHint": "视场角度数(留空使用默认值)",
|
||||
"fovSubtitle": "摄像头视场角 - 用于锥体宽度和范围提交格式",
|
||||
"fovInvalid": "视场角必须在1到360度之间",
|
||||
"submittable": "可提交",
|
||||
"submittableSubtitle": "此配置文件是否可用于摄像头提交",
|
||||
"osmTags": "OSM 标签",
|
||||
@@ -323,9 +386,38 @@
|
||||
"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": "在地图上显示网络加载和错误状态"
|
||||
"showIndicatorSubtitle": "在地图上显示网络加载和错误状态",
|
||||
"loading": "加载中...",
|
||||
"timedOut": "超时",
|
||||
"noData": "这里没有瓦片",
|
||||
"success": "完成",
|
||||
"nodeLimitReached": "显示限制 - 在设置中增加",
|
||||
"tileProviderSlow": "瓦片提供商缓慢",
|
||||
"nodeDataSlow": "节点数据缓慢",
|
||||
"networkIssues": "网络问题"
|
||||
},
|
||||
"navigation": {
|
||||
"searchLocation": "搜索位置",
|
||||
|
||||
@@ -11,6 +11,8 @@ import 'screens/advanced_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,6 +71,8 @@ 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(),
|
||||
|
||||
24
lib/models/direction_fov.dart
Normal file
24
lib/models/direction_fov.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
/// Represents a direction with its associated field-of-view (FOV) cone.
|
||||
class DirectionFov {
|
||||
/// The center direction in degrees (0-359, where 0 is north)
|
||||
final double centerDegrees;
|
||||
|
||||
/// The field-of-view width in degrees (e.g., 35, 90, 180, 360)
|
||||
final double fovDegrees;
|
||||
|
||||
DirectionFov(this.centerDegrees, this.fovDegrees);
|
||||
|
||||
@override
|
||||
String toString() => 'DirectionFov(center: ${centerDegrees}°, fov: ${fovDegrees}°)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is DirectionFov &&
|
||||
runtimeType == other.runtimeType &&
|
||||
centerDegrees == other.centerDegrees &&
|
||||
fovDegrees == other.fovDegrees;
|
||||
|
||||
@override
|
||||
int get hashCode => centerDegrees.hashCode ^ fovDegrees.hashCode;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ class NodeProfile {
|
||||
final bool requiresDirection;
|
||||
final bool submittable;
|
||||
final bool editable;
|
||||
final double? fov; // Field-of-view in degrees (null means use dev_config default)
|
||||
|
||||
NodeProfile({
|
||||
required this.id,
|
||||
@@ -18,6 +19,7 @@ class NodeProfile {
|
||||
this.requiresDirection = true,
|
||||
this.submittable = true,
|
||||
this.editable = true,
|
||||
this.fov,
|
||||
});
|
||||
|
||||
/// Get all built-in default node profiles
|
||||
@@ -50,6 +52,7 @@ class NodeProfile {
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
fov: 45.0, // Flock cameras typically have narrow FOV
|
||||
),
|
||||
NodeProfile(
|
||||
id: 'builtin-motorola',
|
||||
@@ -67,6 +70,7 @@ class NodeProfile {
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
fov: 60.0, // Motorola cameras typically have moderate FOV
|
||||
),
|
||||
NodeProfile(
|
||||
id: 'builtin-genetec',
|
||||
@@ -84,6 +88,7 @@ class NodeProfile {
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
fov: 50.0, // Genetec cameras typically have moderate FOV
|
||||
),
|
||||
NodeProfile(
|
||||
id: 'builtin-leonardo',
|
||||
@@ -101,6 +106,7 @@ class NodeProfile {
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
fov: 55.0, // Leonardo cameras typically have moderate FOV
|
||||
),
|
||||
NodeProfile(
|
||||
id: 'builtin-neology',
|
||||
@@ -118,6 +124,40 @@ class NodeProfile {
|
||||
submittable: true,
|
||||
editable: true,
|
||||
),
|
||||
NodeProfile(
|
||||
id: 'builtin-rekor',
|
||||
name: 'Rekor',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'manufacturer': 'Rekor',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
),
|
||||
NodeProfile(
|
||||
id: 'builtin-axis',
|
||||
name: 'Axis Communications',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'manufacturer': 'Axis Communications',
|
||||
'manufacturer:wikidata': 'Q2347731',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
fov: 90.0, // Axis cameras can have wider FOV
|
||||
),
|
||||
NodeProfile(
|
||||
id: 'builtin-generic-gunshot',
|
||||
name: 'Generic Gunshot Detector',
|
||||
@@ -175,6 +215,7 @@ class NodeProfile {
|
||||
bool? requiresDirection,
|
||||
bool? submittable,
|
||||
bool? editable,
|
||||
double? fov,
|
||||
}) =>
|
||||
NodeProfile(
|
||||
id: id ?? this.id,
|
||||
@@ -184,6 +225,7 @@ class NodeProfile {
|
||||
requiresDirection: requiresDirection ?? this.requiresDirection,
|
||||
submittable: submittable ?? this.submittable,
|
||||
editable: editable ?? this.editable,
|
||||
fov: fov ?? this.fov,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
@@ -194,6 +236,7 @@ class NodeProfile {
|
||||
'requiresDirection': requiresDirection,
|
||||
'submittable': submittable,
|
||||
'editable': editable,
|
||||
'fov': fov,
|
||||
};
|
||||
|
||||
factory NodeProfile.fromJson(Map<String, dynamic> j) => NodeProfile(
|
||||
@@ -204,6 +247,7 @@ class NodeProfile {
|
||||
requiresDirection: j['requiresDirection'] ?? true, // Default to true for backward compatibility
|
||||
submittable: j['submittable'] ?? true, // Default to true for backward compatibility
|
||||
editable: j['editable'] ?? true, // Default to true for backward compatibility
|
||||
fov: j['fov']?.toDouble(), // Can be null for backward compatibility
|
||||
);
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'direction_fov.dart';
|
||||
import '../dev_config.dart';
|
||||
|
||||
class OsmNode {
|
||||
final int id;
|
||||
final LatLng coord;
|
||||
final Map<String, String> tags;
|
||||
final bool isConstrained; // true if part of any way/relation
|
||||
|
||||
OsmNode({
|
||||
required this.id,
|
||||
required this.coord,
|
||||
required this.tags,
|
||||
this.isConstrained = false, // Default to unconstrained for backward compatibility
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
@@ -16,6 +20,7 @@ class OsmNode {
|
||||
'lat': coord.latitude,
|
||||
'lon': coord.longitude,
|
||||
'tags': tags,
|
||||
'isConstrained': isConstrained,
|
||||
};
|
||||
|
||||
factory OsmNode.fromJson(Map<String, dynamic> json) {
|
||||
@@ -29,12 +34,14 @@ class OsmNode {
|
||||
id: json['id'] is int ? json['id'] as int : int.tryParse(json['id'].toString()) ?? 0,
|
||||
coord: LatLng((json['lat'] as num).toDouble(), (json['lon'] as num).toDouble()),
|
||||
tags: tags,
|
||||
isConstrained: json['isConstrained'] as bool? ?? false, // Default to false for backward compatibility
|
||||
);
|
||||
}
|
||||
|
||||
bool get hasDirection => directionDeg.isNotEmpty;
|
||||
bool get hasDirection => directionFovPairs.isNotEmpty;
|
||||
|
||||
List<double> get directionDeg {
|
||||
/// Get direction and FOV pairs, supporting range notation like "90-270" or "10-45;90-125;290"
|
||||
List<DirectionFov> get directionFovPairs {
|
||||
final raw = tags['direction'] ?? tags['camera:direction'];
|
||||
if (raw == null) return [];
|
||||
|
||||
@@ -46,17 +53,35 @@ class OsmNode {
|
||||
'W': 270.0, 'WNW': 292.5, 'NW': 315.0, 'NNW': 337.5,
|
||||
};
|
||||
|
||||
// Split on semicolons and parse each direction
|
||||
final directions = <double>[];
|
||||
final directionFovList = <DirectionFov>[];
|
||||
final parts = raw.split(';');
|
||||
|
||||
for (final part in parts) {
|
||||
final trimmed = part.trim().toUpperCase();
|
||||
final trimmed = part.trim();
|
||||
if (trimmed.isEmpty) continue;
|
||||
|
||||
// Check if this part contains a range (e.g., "90-270")
|
||||
if (trimmed.contains('-') && RegExp(r'^\d+\.?\d*-\d+\.?\d*$').hasMatch(trimmed)) {
|
||||
final rangeParts = trimmed.split('-');
|
||||
if (rangeParts.length == 2) {
|
||||
final start = double.tryParse(rangeParts[0]);
|
||||
final end = double.tryParse(rangeParts[1]);
|
||||
|
||||
if (start != null && end != null) {
|
||||
final normalized = _calculateRangeCenter(start, end);
|
||||
directionFovList.add(normalized);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Not a range, handle as single direction
|
||||
final trimmedUpper = trimmed.toUpperCase();
|
||||
|
||||
// First try compass direction lookup
|
||||
if (compassDirections.containsKey(trimmed)) {
|
||||
directions.add(compassDirections[trimmed]!);
|
||||
if (compassDirections.containsKey(trimmedUpper)) {
|
||||
final degrees = compassDirections[trimmedUpper]!;
|
||||
directionFovList.add(DirectionFov(degrees, kDirectionConeHalfAngle * 2));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -70,9 +95,35 @@ class OsmNode {
|
||||
|
||||
// Normalize: wrap negative or >360 into 0‑359 range
|
||||
final normalized = ((val % 360) + 360) % 360;
|
||||
directions.add(normalized);
|
||||
directionFovList.add(DirectionFov(normalized, kDirectionConeHalfAngle * 2));
|
||||
}
|
||||
|
||||
return directions;
|
||||
return directionFovList;
|
||||
}
|
||||
|
||||
/// Calculate center and width for a range like "90-270" or "270-90"
|
||||
DirectionFov _calculateRangeCenter(double start, double end) {
|
||||
// Normalize start and end to 0-359 range
|
||||
start = ((start % 360) + 360) % 360;
|
||||
end = ((end % 360) + 360) % 360;
|
||||
|
||||
double width, center;
|
||||
|
||||
if (start > end) {
|
||||
// Wrapping case: 270-90
|
||||
width = (end + 360) - start;
|
||||
center = ((start + end + 360) / 2) % 360;
|
||||
} else {
|
||||
// Normal case: 90-270
|
||||
width = end - start;
|
||||
center = (start + end) / 2;
|
||||
}
|
||||
|
||||
return DirectionFov(center, width);
|
||||
}
|
||||
|
||||
/// Legacy getter for backward compatibility - returns just center directions
|
||||
List<double> get directionDeg {
|
||||
return directionFovPairs.map((df) => df.centerDegrees).toList();
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -20,8 +20,35 @@ class TileType {
|
||||
});
|
||||
|
||||
/// Create URL for a specific tile, replacing template variables
|
||||
///
|
||||
/// Supported placeholders:
|
||||
/// - {x}, {y}, {z}: Standard tile coordinates
|
||||
/// - {quadkey}: Bing Maps quadkey format (alternative to x/y/z)
|
||||
/// - {0_3}: Subdomain 0-3 for load balancing
|
||||
/// - {1_4}: Subdomain 1-4 for providers that use 1-based indexing
|
||||
/// - {api_key}: API key placeholder (optional)
|
||||
String getTileUrl(int z, int x, int y, {String? apiKey}) {
|
||||
String url = urlTemplate
|
||||
String url = urlTemplate;
|
||||
|
||||
// Handle Bing Maps quadkey conversion
|
||||
if (url.contains('{quadkey}')) {
|
||||
final quadkey = _convertToQuadkey(x, y, z);
|
||||
url = url.replaceAll('{quadkey}', quadkey);
|
||||
}
|
||||
|
||||
// Handle subdomains for load balancing
|
||||
if (url.contains('{0_3}')) {
|
||||
final subdomain = (x + y) % 4; // 0, 1, 2, 3
|
||||
url = url.replaceAll('{0_3}', subdomain.toString());
|
||||
}
|
||||
|
||||
if (url.contains('{1_4}')) {
|
||||
final subdomain = ((x + y) % 4) + 1; // 1, 2, 3, 4
|
||||
url = url.replaceAll('{1_4}', subdomain.toString());
|
||||
}
|
||||
|
||||
// Standard x/y/z replacement
|
||||
url = url
|
||||
.replaceAll('{z}', z.toString())
|
||||
.replaceAll('{x}', x.toString())
|
||||
.replaceAll('{y}', y.toString());
|
||||
@@ -33,6 +60,19 @@ class TileType {
|
||||
return url;
|
||||
}
|
||||
|
||||
/// Convert x, y, z to Bing Maps quadkey format
|
||||
String _convertToQuadkey(int x, int y, int z) {
|
||||
final quadkey = StringBuffer();
|
||||
for (int i = z; i > 0; i--) {
|
||||
int digit = 0;
|
||||
final mask = 1 << (i - 1);
|
||||
if ((x & mask) != 0) digit++;
|
||||
if ((y & mask) != 0) digit += 2;
|
||||
quadkey.write(digit);
|
||||
}
|
||||
return quadkey.toString();
|
||||
}
|
||||
|
||||
/// Check if this tile type needs an API key
|
||||
bool get requiresApiKey => urlTemplate.contains('{api_key}');
|
||||
|
||||
@@ -161,6 +201,19 @@ class DefaultTileProviders {
|
||||
),
|
||||
],
|
||||
),
|
||||
TileProvider(
|
||||
id: 'bing',
|
||||
name: 'Bing Maps',
|
||||
tileTypes: [
|
||||
TileType(
|
||||
id: 'bing_satellite',
|
||||
name: 'Satellite',
|
||||
urlTemplate: 'https://ecn.t{0_3}.tiles.virtualearth.net/tiles/a{quadkey}.jpeg?g=1&n=z',
|
||||
attribution: '© Microsoft Corporation',
|
||||
maxZoom: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
TileProvider(
|
||||
id: 'mapbox',
|
||||
name: 'Mapbox',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../widgets/welcome_dialog.dart';
|
||||
import '../widgets/submission_guide_dialog.dart';
|
||||
|
||||
class AboutScreen extends StatelessWidget {
|
||||
const AboutScreen({super.key});
|
||||
@@ -74,16 +76,8 @@ class AboutScreen extends StatelessWidget {
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// Release Notes button
|
||||
Center(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.pushNamed(context, '/settings/release-notes');
|
||||
},
|
||||
icon: const Icon(Icons.article_outlined),
|
||||
label: const Text('View Release Notes'),
|
||||
),
|
||||
),
|
||||
// Information dialogs section
|
||||
_buildDialogButtons(context),
|
||||
const SizedBox(height: 24),
|
||||
_buildHelpLinks(context),
|
||||
],
|
||||
@@ -190,4 +184,50 @@ class AboutScreen extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDialogButtons(BuildContext context) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Welcome Message button
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => const WelcomeDialog(showDontShowAgain: false),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.waving_hand_outlined),
|
||||
label: Text(locService.t('about.showWelcome')),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Submission Guide button
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => const SubmissionGuideDialog(showDontShowAgain: false),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.info_outline),
|
||||
label: Text(locService.t('about.showSubmissionGuide')),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Release Notes button
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.pushNamed(context, '/settings/release-notes');
|
||||
},
|
||||
icon: const Icon(Icons.article_outlined),
|
||||
label: Text(locService.t('about.viewReleaseNotes')),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -103,6 +103,21 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
|
||||
void _openAddNodeSheet() {
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
// Check minimum zoom level before opening sheet
|
||||
final currentZoom = _mapController.mapController.camera.zoom;
|
||||
if (currentZoom < kMinZoomForNodeEditingSheets) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
LocalizationService.instance.t('editNode.zoomInRequiredMessage',
|
||||
params: [kMinZoomForNodeEditingSheets.toString()])
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable follow-me when adding a camera so the map doesn't jump around
|
||||
appState.setFollowMeMode(FollowMeMode.off);
|
||||
|
||||
@@ -145,6 +160,25 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
// Disable follow-me when editing a camera so the map doesn't jump around
|
||||
appState.setFollowMeMode(FollowMeMode.off);
|
||||
|
||||
final session = appState.editSession!; // should be non-null when this is called
|
||||
|
||||
// Center map on the node being edited (same animation as openNodeTagSheet)
|
||||
try {
|
||||
_mapController.animateTo(
|
||||
dest: session.originalNode.coord,
|
||||
zoom: _mapController.mapController.camera.zoom,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
} catch (_) {
|
||||
// Map controller not ready, fallback to immediate move
|
||||
try {
|
||||
_mapController.mapController.move(session.originalNode.coord, _mapController.mapController.camera.zoom);
|
||||
} catch (_) {
|
||||
// Controller really not ready, skip centering
|
||||
}
|
||||
}
|
||||
|
||||
// Set transition flag to prevent map bounce
|
||||
_transitioningToEdit = true;
|
||||
|
||||
@@ -152,8 +186,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
if (_tagSheetHeight > 0) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
final session = appState.editSession!; // should be non-null when this is called
|
||||
|
||||
// Small delay to let tag sheet close smoothly
|
||||
Future.delayed(const Duration(milliseconds: 150), () {
|
||||
@@ -515,6 +547,20 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
child: NodeTagSheet(
|
||||
node: node,
|
||||
onEditPressed: () {
|
||||
// Check minimum zoom level before starting edit session
|
||||
final currentZoom = _mapController.mapController.camera.zoom;
|
||||
if (currentZoom < kMinZoomForNodeEditingSheets) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
LocalizationService.instance.t('editNode.zoomInRequiredMessage',
|
||||
params: [kMinZoomForNodeEditingSheets.toString()])
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final appState = context.read<AppState>();
|
||||
appState.startEditSession(node);
|
||||
// This will trigger _openEditNodeSheet via the existing auto-show logic
|
||||
@@ -691,71 +737,92 @@ 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 + 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: 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: () {
|
||||
// Check minimum zoom level before opening download dialog
|
||||
final currentZoom = _mapController.mapController.camera.zoom;
|
||||
if (currentZoom < kMinZoomForOfflineDownload) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
LocalizationService.instance.t('download.areaTooBigMessage',
|
||||
params: [kMinZoomForOfflineDownload.toString()])
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
|
||||
);
|
||||
},
|
||||
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 (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')),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
late List<MapEntry<String, String>> _tags;
|
||||
late bool _requiresDirection;
|
||||
late bool _submittable;
|
||||
late TextEditingController _fovCtrl;
|
||||
|
||||
static const _defaultTags = [
|
||||
MapEntry('man_made', 'surveillance'),
|
||||
@@ -38,6 +39,7 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
_nameCtrl = TextEditingController(text: widget.profile.name);
|
||||
_requiresDirection = widget.profile.requiresDirection;
|
||||
_submittable = widget.profile.submittable;
|
||||
_fovCtrl = TextEditingController(text: widget.profile.fov?.toString() ?? '');
|
||||
|
||||
if (widget.profile.tags.isEmpty) {
|
||||
// New profile → start with sensible defaults
|
||||
@@ -50,6 +52,7 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
@override
|
||||
void dispose() {
|
||||
_nameCtrl.dispose();
|
||||
_fovCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -91,6 +94,21 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
onChanged: (value) => setState(() => _requiresDirection = value ?? true),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
if (_requiresDirection) Padding(
|
||||
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8),
|
||||
child: TextField(
|
||||
controller: _fovCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: locService.t('profileEditor.fov'),
|
||||
hintText: locService.t('profileEditor.fovHint'),
|
||||
helperText: locService.t('profileEditor.fovSubtitle'),
|
||||
errorText: _validateFov(),
|
||||
suffixText: '°',
|
||||
),
|
||||
onChanged: (value) => setState(() {}), // Trigger validation
|
||||
),
|
||||
),
|
||||
CheckboxListTile(
|
||||
title: Text(locService.t('profileEditor.submittable')),
|
||||
subtitle: Text(locService.t('profileEditor.submittableSubtitle')),
|
||||
@@ -181,6 +199,17 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
});
|
||||
}
|
||||
|
||||
String? _validateFov() {
|
||||
final text = _fovCtrl.text.trim();
|
||||
if (text.isEmpty) return null; // Optional field
|
||||
|
||||
final fov = double.tryParse(text);
|
||||
if (fov == null || fov <= 0 || fov > 360) {
|
||||
return LocalizationService.instance.t('profileEditor.fovInvalid');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _save() {
|
||||
final locService = LocalizationService.instance;
|
||||
final name = _nameCtrl.text.trim();
|
||||
@@ -190,6 +219,15 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
.showSnackBar(SnackBar(content: Text(locService.t('profileEditor.profileNameRequired'))));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate FOV if provided
|
||||
if (_validateFov() != null) {
|
||||
return; // Don't save if FOV validation fails
|
||||
}
|
||||
|
||||
// Parse FOV
|
||||
final fovText = _fovCtrl.text.trim();
|
||||
final fov = fovText.isEmpty ? null : double.tryParse(fovText);
|
||||
|
||||
final tagMap = <String, String>{};
|
||||
for (final e in _tags) {
|
||||
@@ -211,6 +249,7 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
requiresDirection: _requiresDirection,
|
||||
submittable: _submittable,
|
||||
editable: true, // All custom profiles are editable by definition
|
||||
fov: fov,
|
||||
);
|
||||
|
||||
context.read<AppState>().addOrUpdateProfile(newProfile);
|
||||
|
||||
@@ -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,7 +1,4 @@
|
||||
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';
|
||||
@@ -25,14 +22,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
|
||||
|
||||
@@ -318,9 +318,15 @@ class _TileTypeDialogState extends State<_TileTypeDialog> {
|
||||
),
|
||||
validator: (value) {
|
||||
if (value?.trim().isEmpty == true) return locService.t('tileTypeEditor.urlTemplateRequired');
|
||||
if (!value!.contains('{z}') || !value.contains('{x}') || !value.contains('{y}')) {
|
||||
|
||||
// Check for either quadkey OR x+y+z placeholders
|
||||
final hasQuadkey = value!.contains('{quadkey}');
|
||||
final hasXYZ = value.contains('{x}') && value.contains('{y}') && value.contains('{z}');
|
||||
|
||||
if (!hasQuadkey && !hasXYZ) {
|
||||
return locService.t('tileTypeEditor.urlTemplatePlaceholders');
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
),
|
||||
@@ -403,11 +409,20 @@ 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());
|
||||
// Create a temporary TileType to use the getTileUrl method
|
||||
final tempTileType = TileType(
|
||||
id: 'preview',
|
||||
name: 'Preview',
|
||||
urlTemplate: _urlController.text.trim(),
|
||||
attribution: 'Preview',
|
||||
);
|
||||
|
||||
final url = tempTileType.getTileUrl(
|
||||
kPreviewTileZoom,
|
||||
kPreviewTileX,
|
||||
kPreviewTileY,
|
||||
apiKey: null, // Don't use API key for preview
|
||||
);
|
||||
|
||||
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);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,27 @@ class ChangelogService {
|
||||
|
||||
static const String _lastSeenVersionKey = 'last_seen_version';
|
||||
static const String _hasSeenWelcomeKey = 'has_seen_welcome';
|
||||
static const String _hasSeenSubmissionGuideKey = 'has_seen_submission_guide';
|
||||
|
||||
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;
|
||||
@@ -51,6 +68,18 @@ class ChangelogService {
|
||||
await prefs.setBool(_hasSeenWelcomeKey, true);
|
||||
}
|
||||
|
||||
/// Check if user has seen the submission guide popup
|
||||
Future<bool> hasSeenSubmissionGuide() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_hasSeenSubmissionGuideKey) ?? false;
|
||||
}
|
||||
|
||||
/// Mark that user has seen the submission guide popup
|
||||
Future<void> markSubmissionGuideSeen() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_hasSeenSubmissionGuideKey, true);
|
||||
}
|
||||
|
||||
/// Check if app version has changed since last launch
|
||||
Future<bool> hasVersionChanged() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -89,8 +118,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 +140,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 +152,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 +230,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 +247,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();
|
||||
|
||||
|
||||
@@ -47,44 +47,7 @@ Future<List<OsmNode>> fetchOsmApiNodes({
|
||||
|
||||
// Parse XML response
|
||||
final document = XmlDocument.parse(response.body);
|
||||
final nodes = <OsmNode>[];
|
||||
|
||||
// Find all node elements
|
||||
for (final nodeElement in document.findAllElements('node')) {
|
||||
final id = int.tryParse(nodeElement.getAttribute('id') ?? '');
|
||||
final latStr = nodeElement.getAttribute('lat');
|
||||
final lonStr = nodeElement.getAttribute('lon');
|
||||
|
||||
if (id == null || latStr == null || lonStr == null) continue;
|
||||
|
||||
final lat = double.tryParse(latStr);
|
||||
final lon = double.tryParse(lonStr);
|
||||
if (lat == null || lon == null) continue;
|
||||
|
||||
// Parse tags
|
||||
final tags = <String, String>{};
|
||||
for (final tagElement in nodeElement.findElements('tag')) {
|
||||
final key = tagElement.getAttribute('k');
|
||||
final value = tagElement.getAttribute('v');
|
||||
if (key != null && value != null) {
|
||||
tags[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this node matches any of our profiles
|
||||
if (_nodeMatchesProfiles(tags, profiles)) {
|
||||
nodes.add(OsmNode(
|
||||
id: id,
|
||||
coord: LatLng(lat, lon),
|
||||
tags: tags,
|
||||
));
|
||||
}
|
||||
|
||||
// Respect maxResults limit if set
|
||||
if (maxResults > 0 && nodes.length >= maxResults) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
final nodes = _parseOsmApiResponseWithConstraints(document, profiles, maxResults);
|
||||
|
||||
if (nodes.isNotEmpty) {
|
||||
debugPrint('[fetchOsmApiNodes] Retrieved ${nodes.length} matching surveillance nodes');
|
||||
@@ -107,6 +70,93 @@ Future<List<OsmNode>> fetchOsmApiNodes({
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse OSM API XML response to create OsmNode objects with constraint information.
|
||||
List<OsmNode> _parseOsmApiResponseWithConstraints(XmlDocument document, List<NodeProfile> profiles, int maxResults) {
|
||||
final surveillanceNodes = <int, Map<String, dynamic>>{}; // nodeId -> node data
|
||||
final constrainedNodeIds = <int>{};
|
||||
|
||||
// First pass: collect surveillance nodes
|
||||
for (final nodeElement in document.findAllElements('node')) {
|
||||
final id = int.tryParse(nodeElement.getAttribute('id') ?? '');
|
||||
final latStr = nodeElement.getAttribute('lat');
|
||||
final lonStr = nodeElement.getAttribute('lon');
|
||||
|
||||
if (id == null || latStr == null || lonStr == null) continue;
|
||||
|
||||
final lat = double.tryParse(latStr);
|
||||
final lon = double.tryParse(lonStr);
|
||||
if (lat == null || lon == null) continue;
|
||||
|
||||
// Parse tags
|
||||
final tags = <String, String>{};
|
||||
for (final tagElement in nodeElement.findElements('tag')) {
|
||||
final key = tagElement.getAttribute('k');
|
||||
final value = tagElement.getAttribute('v');
|
||||
if (key != null && value != null) {
|
||||
tags[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this node matches any of our profiles
|
||||
if (_nodeMatchesProfiles(tags, profiles)) {
|
||||
surveillanceNodes[id] = {
|
||||
'id': id,
|
||||
'lat': lat,
|
||||
'lon': lon,
|
||||
'tags': tags,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: identify constrained nodes from ways
|
||||
for (final wayElement in document.findAllElements('way')) {
|
||||
for (final ndElement in wayElement.findElements('nd')) {
|
||||
final ref = int.tryParse(ndElement.getAttribute('ref') ?? '');
|
||||
if (ref != null && surveillanceNodes.containsKey(ref)) {
|
||||
constrainedNodeIds.add(ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Third pass: identify constrained nodes from relations
|
||||
for (final relationElement in document.findAllElements('relation')) {
|
||||
for (final memberElement in relationElement.findElements('member')) {
|
||||
if (memberElement.getAttribute('type') == 'node') {
|
||||
final ref = int.tryParse(memberElement.getAttribute('ref') ?? '');
|
||||
if (ref != null && surveillanceNodes.containsKey(ref)) {
|
||||
constrainedNodeIds.add(ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create OsmNode objects with constraint information
|
||||
final nodes = <OsmNode>[];
|
||||
for (final nodeData in surveillanceNodes.values) {
|
||||
final nodeId = nodeData['id'] as int;
|
||||
final isConstrained = constrainedNodeIds.contains(nodeId);
|
||||
|
||||
nodes.add(OsmNode(
|
||||
id: nodeId,
|
||||
coord: LatLng(nodeData['lat'], nodeData['lon']),
|
||||
tags: nodeData['tags'] as Map<String, String>,
|
||||
isConstrained: isConstrained,
|
||||
));
|
||||
|
||||
// Respect maxResults limit if set
|
||||
if (maxResults > 0 && nodes.length >= maxResults) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
final constrainedCount = nodes.where((n) => n.isConstrained).length;
|
||||
if (constrainedCount > 0) {
|
||||
debugPrint('[fetchOsmApiNodes] Found $constrainedCount constrained nodes out of ${nodes.length} total');
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/// Check if a node's tags match any of the given profiles
|
||||
bool _nodeMatchesProfiles(Map<String, String> nodeTags, List<NodeProfile> profiles) {
|
||||
for (final profile in profiles) {
|
||||
|
||||
@@ -154,18 +154,13 @@ Future<List<OsmNode>> _fetchSingleOverpassQuery({
|
||||
final elements = data['elements'] as List<dynamic>;
|
||||
|
||||
if (elements.length > 20) {
|
||||
debugPrint('[fetchOverpassNodes] Retrieved ${elements.length} surveillance nodes');
|
||||
debugPrint('[fetchOverpassNodes] Retrieved ${elements.length} elements (nodes + ways/relations)');
|
||||
}
|
||||
|
||||
NetworkStatus.instance.reportOverpassSuccess();
|
||||
|
||||
final nodes = elements.whereType<Map<String, dynamic>>().map((element) {
|
||||
return OsmNode(
|
||||
id: element['id'],
|
||||
coord: LatLng(element['lat'], element['lon']),
|
||||
tags: Map<String, String>.from(element['tags'] ?? {}),
|
||||
);
|
||||
}).toList();
|
||||
// Parse response to determine which nodes are constrained
|
||||
final nodes = _parseOverpassResponseWithConstraints(elements);
|
||||
|
||||
// Clean up any pending uploads that now appear in Overpass results
|
||||
_cleanupCompletedUploads(nodes);
|
||||
@@ -190,6 +185,7 @@ Future<List<OsmNode>> _fetchSingleOverpassQuery({
|
||||
}
|
||||
|
||||
/// Builds an Overpass API query for surveillance nodes matching the given profiles within bounds.
|
||||
/// Also fetches ways and relations that reference these nodes to determine constraint status.
|
||||
String _buildOverpassQuery(LatLngBounds bounds, List<NodeProfile> profiles, int maxResults) {
|
||||
// Build node clauses for each profile
|
||||
final nodeClauses = profiles.map((profile) {
|
||||
@@ -200,17 +196,19 @@ String _buildOverpassQuery(LatLngBounds bounds, List<NodeProfile> profiles, int
|
||||
|
||||
// Build the node query with tag filters and bounding box
|
||||
return 'node$tagFilters(${bounds.southWest.latitude},${bounds.southWest.longitude},${bounds.northEast.latitude},${bounds.northEast.longitude});';
|
||||
}).join('\n ');
|
||||
}).join('\n ');
|
||||
|
||||
// Use unlimited output if maxResults is 0
|
||||
final outputClause = maxResults > 0 ? 'out body $maxResults;' : 'out body;';
|
||||
|
||||
return '''
|
||||
[out:json][timeout:25];
|
||||
(
|
||||
$nodeClauses
|
||||
);
|
||||
$outputClause
|
||||
out body ${maxResults > 0 ? maxResults : ''};
|
||||
(
|
||||
way(bn);
|
||||
rel(bn);
|
||||
);
|
||||
out meta;
|
||||
''';
|
||||
}
|
||||
|
||||
@@ -243,6 +241,56 @@ List<LatLngBounds> _splitBounds(LatLngBounds bounds) {
|
||||
];
|
||||
}
|
||||
|
||||
/// Parse Overpass response elements to create OsmNode objects with constraint information.
|
||||
List<OsmNode> _parseOverpassResponseWithConstraints(List<dynamic> elements) {
|
||||
final nodeElements = <Map<String, dynamic>>[];
|
||||
final constrainedNodeIds = <int>{};
|
||||
|
||||
// First pass: collect surveillance nodes and identify constrained nodes
|
||||
for (final element in elements.whereType<Map<String, dynamic>>()) {
|
||||
final type = element['type'] as String?;
|
||||
|
||||
if (type == 'node') {
|
||||
// This is a surveillance node - collect it
|
||||
nodeElements.add(element);
|
||||
} else if (type == 'way' || type == 'relation') {
|
||||
// This is a way/relation that references some of our nodes
|
||||
final refs = element['nodes'] as List<dynamic>? ??
|
||||
element['members']?.where((m) => m['type'] == 'node').map((m) => m['ref']) ?? [];
|
||||
|
||||
// Mark all referenced nodes as constrained
|
||||
for (final ref in refs) {
|
||||
if (ref is int) {
|
||||
constrainedNodeIds.add(ref);
|
||||
} else if (ref is String) {
|
||||
final nodeId = int.tryParse(ref);
|
||||
if (nodeId != null) constrainedNodeIds.add(nodeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: create OsmNode objects with constraint info
|
||||
final nodes = nodeElements.map((element) {
|
||||
final nodeId = element['id'] as int;
|
||||
final isConstrained = constrainedNodeIds.contains(nodeId);
|
||||
|
||||
return OsmNode(
|
||||
id: nodeId,
|
||||
coord: LatLng(element['lat'], element['lon']),
|
||||
tags: Map<String, String>.from(element['tags'] ?? {}),
|
||||
isConstrained: isConstrained,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
final constrainedCount = nodes.where((n) => n.isConstrained).length;
|
||||
if (constrainedCount > 0) {
|
||||
debugPrint('[fetchOverpassNodes] Found $constrainedCount constrained nodes out of ${nodes.length} total');
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/// Clean up pending uploads that now appear in Overpass results
|
||||
void _cleanupCompletedUploads(List<OsmNode> overpassNodes) {
|
||||
try {
|
||||
|
||||
@@ -9,7 +9,7 @@ import 'package:deflockapp/dev_config.dart';
|
||||
import '../network_status.dart';
|
||||
|
||||
/// Global semaphore to limit simultaneous tile fetches
|
||||
final _tileFetchSemaphore = _SimpleSemaphore(4); // Max 4 concurrent
|
||||
final _tileFetchSemaphore = _SimpleSemaphore(kTileFetchConcurrentThreads);
|
||||
|
||||
/// Clear queued tile requests when map view changes significantly
|
||||
void clearRemoteTileQueue() {
|
||||
@@ -93,15 +93,15 @@ bool _isTileVisible(int z, int x, int y, LatLngBounds viewBounds) {
|
||||
|
||||
|
||||
|
||||
/// Fetches a tile from any remote provider, with in-memory retries/backoff, and global concurrency limit.
|
||||
/// Returns tile image bytes, or throws on persistent failure.
|
||||
/// Fetches a tile from any remote provider with unlimited retries.
|
||||
/// Returns tile image bytes. Retries forever until success.
|
||||
/// Brutalist approach: Keep trying until it works - no arbitrary retry limits.
|
||||
Future<List<int>> fetchRemoteTile({
|
||||
required int z,
|
||||
required int x,
|
||||
required int y,
|
||||
required String url,
|
||||
}) async {
|
||||
const int maxAttempts = kTileFetchMaxAttempts;
|
||||
int attempt = 0;
|
||||
final random = Random();
|
||||
final hostInfo = Uri.parse(url).host; // For logging
|
||||
@@ -109,20 +109,23 @@ Future<List<int>> fetchRemoteTile({
|
||||
while (true) {
|
||||
await _tileFetchSemaphore.acquire(z: z, x: x, y: y);
|
||||
try {
|
||||
// Only log on first attempt or errors
|
||||
if (attempt == 1) {
|
||||
// Only log on first attempt
|
||||
if (attempt == 0) {
|
||||
debugPrint('[fetchRemoteTile] Fetching $z/$x/$y from $hostInfo');
|
||||
}
|
||||
attempt++;
|
||||
final resp = await http.get(Uri.parse(url));
|
||||
|
||||
if (resp.statusCode == 200 && resp.bodyBytes.isNotEmpty) {
|
||||
// Success - no logging for normal operation
|
||||
NetworkStatus.instance.reportOsmTileSuccess(); // Generic tile server reporting
|
||||
// Success!
|
||||
if (attempt > 1) {
|
||||
debugPrint('[fetchRemoteTile] SUCCESS $z/$x/$y from $hostInfo after $attempt attempts');
|
||||
}
|
||||
NetworkStatus.instance.reportOsmTileSuccess();
|
||||
return resp.bodyBytes;
|
||||
} else {
|
||||
debugPrint('[fetchRemoteTile] FAIL $z/$x/$y from $hostInfo: code=${resp.statusCode}, bytes=${resp.bodyBytes.length}');
|
||||
NetworkStatus.instance.reportOsmTileIssue(); // Generic tile server reporting
|
||||
NetworkStatus.instance.reportOsmTileIssue();
|
||||
throw HttpException('Failed to fetch tile $z/$x/$y from $hostInfo: status ${resp.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -130,17 +133,16 @@ Future<List<int>> fetchRemoteTile({
|
||||
if (e.toString().contains('Connection refused') ||
|
||||
e.toString().contains('Connection timed out') ||
|
||||
e.toString().contains('Connection reset')) {
|
||||
NetworkStatus.instance.reportOsmTileIssue(); // Generic tile server reporting
|
||||
}
|
||||
|
||||
if (attempt >= maxAttempts) {
|
||||
debugPrint("[fetchRemoteTile] Failed for $z/$x/$y from $hostInfo after $attempt attempts: $e");
|
||||
rethrow;
|
||||
NetworkStatus.instance.reportOsmTileIssue();
|
||||
}
|
||||
|
||||
// Calculate delay and retry (no attempt limit - keep trying forever)
|
||||
final delay = _calculateRetryDelay(attempt, random);
|
||||
if (attempt == 1) {
|
||||
debugPrint("[fetchRemoteTile] Attempt $attempt for $z/$x/$y from $hostInfo failed: $e. Retrying in ${delay}ms.");
|
||||
} else if (attempt % 10 == 0) {
|
||||
// Log every 10th attempt to show we're still working
|
||||
debugPrint("[fetchRemoteTile] Still trying $z/$x/$y from $hostInfo (attempt $attempt). Retrying in ${delay}ms.");
|
||||
}
|
||||
await Future.delayed(Duration(milliseconds: delay));
|
||||
} finally {
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'package:latlong2/latlong.dart';
|
||||
import '../models/osm_node.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
|
||||
const Distance _distance = Distance();
|
||||
|
||||
class NodeCache {
|
||||
// Singleton instance
|
||||
static final NodeCache instance = NodeCache._internal();
|
||||
@@ -27,6 +29,7 @@ class NodeCache {
|
||||
id: node.id,
|
||||
coord: node.coord,
|
||||
tags: mergedTags,
|
||||
isConstrained: node.isConstrained, // Preserve constraint information
|
||||
);
|
||||
} else {
|
||||
_nodes[node.id] = node;
|
||||
@@ -58,6 +61,7 @@ class NodeCache {
|
||||
id: node.id,
|
||||
coord: node.coord,
|
||||
tags: cleanTags,
|
||||
isConstrained: node.isConstrained, // Preserve constraint information
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -101,6 +105,34 @@ class NodeCache {
|
||||
(coord1.longitude - coord2.longitude).abs() < tolerance;
|
||||
}
|
||||
|
||||
/// Find nodes within the specified distance (in meters) of the given coordinate
|
||||
/// Excludes nodes with the excludeNodeId (useful when checking proximity for edited nodes)
|
||||
List<OsmNode> findNodesWithinDistance(LatLng coord, double distanceMeters, {int? excludeNodeId}) {
|
||||
final nearbyNodes = <OsmNode>[];
|
||||
|
||||
for (final node in _nodes.values) {
|
||||
// Skip the excluded node (typically the node being edited)
|
||||
if (excludeNodeId != null && node.id == excludeNodeId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip temporary nodes (negative IDs) with pending upload/edit/deletion markers
|
||||
if (node.id < 0 && (
|
||||
node.tags.containsKey('_pending_upload') ||
|
||||
node.tags.containsKey('_pending_edit') ||
|
||||
node.tags.containsKey('_pending_deletion'))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final distance = _distance.as(LengthUnit.Meter, coord, node.coord);
|
||||
if (distance <= distanceMeters) {
|
||||
nearbyNodes.add(node);
|
||||
}
|
||||
}
|
||||
|
||||
return nearbyNodes;
|
||||
}
|
||||
|
||||
/// Utility: point-in-bounds for coordinates
|
||||
bool _inBounds(LatLng coord, LatLngBounds bounds) {
|
||||
return coord.latitude >= bounds.southWest.latitude &&
|
||||
|
||||
@@ -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,6 +35,9 @@ 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';
|
||||
@@ -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,8 +28,10 @@ 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;
|
||||
FollowMeMode _followMeMode = FollowMeMode.follow;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
@@ -29,7 +30,7 @@ class UploadQueueState extends ChangeNotifier {
|
||||
void addFromSession(AddNodeSession session, {required UploadMode uploadMode}) {
|
||||
final upload = PendingUpload(
|
||||
coord: session.target!,
|
||||
direction: _formatDirectionsAsString(session.directions),
|
||||
direction: _formatDirectionsForSubmission(session.directions, session.profile),
|
||||
profile: session.profile!, // Safe to use ! because commitSession() checks for null
|
||||
operatorProfile: session.operatorProfile,
|
||||
uploadMode: uploadMode,
|
||||
@@ -61,44 +62,78 @@ class UploadQueueState extends ChangeNotifier {
|
||||
|
||||
// Add a completed edit session to the upload queue
|
||||
void addFromEditSession(EditNodeSession session, {required UploadMode uploadMode}) {
|
||||
// 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: session.target,
|
||||
direction: _formatDirectionsAsString(session.directions),
|
||||
coord: coordToUse,
|
||||
direction: _formatDirectionsForSubmission(session.directions, session.profile),
|
||||
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();
|
||||
|
||||
@@ -158,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;
|
||||
}
|
||||
@@ -271,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
|
||||
@@ -293,13 +330,33 @@ class UploadQueueState extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to format multiple directions as a string or number
|
||||
dynamic _formatDirectionsAsString(List<double> directions) {
|
||||
// Helper method to format multiple directions for submission, supporting profile FOV
|
||||
dynamic _formatDirectionsForSubmission(List<double> directions, NodeProfile? profile) {
|
||||
if (directions.isEmpty) return 0.0;
|
||||
|
||||
// If profile has FOV, convert center directions to range notation
|
||||
if (profile?.fov != null && profile!.fov! > 0) {
|
||||
final ranges = directions.map((center) =>
|
||||
_formatDirectionWithFov(center, profile.fov!)
|
||||
).toList();
|
||||
|
||||
return ranges.length == 1 ? ranges.first : ranges.join(';');
|
||||
}
|
||||
|
||||
// No profile FOV: use original format (single number or semicolon-separated)
|
||||
if (directions.length == 1) return directions.first;
|
||||
return directions.map((d) => d.round().toString()).join(';');
|
||||
}
|
||||
|
||||
// Convert a center direction and FOV to range notation (e.g., 180° center with 90° FOV -> "135-225")
|
||||
String _formatDirectionWithFov(double center, double fov) {
|
||||
final halfFov = fov / 2;
|
||||
final start = (center - halfFov + 360) % 360;
|
||||
final end = (center + halfFov) % 360;
|
||||
|
||||
return '${start.round()}-${end.round()}';
|
||||
}
|
||||
|
||||
// ---------- Queue persistence ----------
|
||||
Future<void> _saveQueue() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
@@ -6,13 +6,81 @@ import '../dev_config.dart';
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/operator_profile.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../services/node_cache.dart';
|
||||
import '../services/changelog_service.dart';
|
||||
import 'refine_tags_sheet.dart';
|
||||
import 'proximity_warning_dialog.dart';
|
||||
import 'submission_guide_dialog.dart';
|
||||
|
||||
class AddNodeSheet extends StatelessWidget {
|
||||
const AddNodeSheet({super.key, required this.session});
|
||||
|
||||
final AddNodeSession session;
|
||||
|
||||
void _checkProximityAndCommit(BuildContext context, AppState appState, LocalizationService locService) {
|
||||
_checkSubmissionGuideAndProceed(context, appState, locService);
|
||||
}
|
||||
|
||||
void _checkSubmissionGuideAndProceed(BuildContext context, AppState appState, LocalizationService locService) async {
|
||||
// Check if user has seen the submission guide
|
||||
final hasSeenGuide = await ChangelogService().hasSeenSubmissionGuide();
|
||||
|
||||
if (!hasSeenGuide) {
|
||||
// Show submission guide dialog first
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => const SubmissionGuideDialog(),
|
||||
);
|
||||
}
|
||||
|
||||
// Now proceed with proximity check
|
||||
_checkProximityOnly(context, appState, locService);
|
||||
}
|
||||
|
||||
void _checkProximityOnly(BuildContext context, AppState appState, LocalizationService locService) {
|
||||
// Only check proximity if we have a target location
|
||||
if (session.target == null) {
|
||||
_commitWithoutCheck(context, appState, locService);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for nearby nodes within the configured distance
|
||||
final nearbyNodes = NodeCache.instance.findNodesWithinDistance(
|
||||
session.target!,
|
||||
kNodeProximityWarningDistance,
|
||||
);
|
||||
|
||||
if (nearbyNodes.isNotEmpty) {
|
||||
// Show proximity warning dialog
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => ProximityWarningDialog(
|
||||
nearbyNodes: nearbyNodes,
|
||||
distance: kNodeProximityWarningDistance,
|
||||
onGoBack: () {
|
||||
Navigator.of(context).pop(); // Close dialog
|
||||
},
|
||||
onSubmitAnyway: () {
|
||||
Navigator.of(context).pop(); // Close dialog
|
||||
_commitWithoutCheck(context, appState, locService);
|
||||
},
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// No nearby nodes, proceed with commit
|
||||
_commitWithoutCheck(context, appState, locService);
|
||||
}
|
||||
}
|
||||
|
||||
void _commitWithoutCheck(BuildContext context, AppState appState, LocalizationService locService) {
|
||||
appState.commitSession();
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(locService.t('node.queuedForUpload'))),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDirectionControls(BuildContext context, AppState appState, AddNodeSession session, LocalizationService locService) {
|
||||
final requiresDirection = session.profile != null && session.profile!.requiresDirection;
|
||||
|
||||
@@ -88,10 +156,12 @@ class AddNodeSheet extends StatelessWidget {
|
||||
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),
|
||||
),
|
||||
@@ -142,11 +212,7 @@ class AddNodeSheet extends StatelessWidget {
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
void _commit() {
|
||||
appState.commitSession();
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(locService.t('node.queuedForUpload'))),
|
||||
);
|
||||
_checkProximityAndCommit(context, appState, locService);
|
||||
}
|
||||
|
||||
void _cancel() {
|
||||
|
||||
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'];
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,7 @@ class ChangelogDialog extends StatelessWidget {
|
||||
});
|
||||
|
||||
void _onClose(BuildContext context) async {
|
||||
// Update version tracking when closing changelog dialog
|
||||
await ChangelogService().updateLastSeenVersion();
|
||||
// Note: Version tracking is updated by completeVersionChange() after all dialogs
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,6 +9,7 @@ import '../dev_config.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../services/offline_area_service.dart';
|
||||
import '../services/offline_areas/offline_tile_utils.dart';
|
||||
import 'download_started_dialog.dart';
|
||||
|
||||
class DownloadAreaDialog extends StatefulWidget {
|
||||
final MapController controller;
|
||||
@@ -275,16 +276,29 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
|
||||
tileTypeName: selectedTileType?.name,
|
||||
);
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(locService.t('download.downloadStarted')),
|
||||
),
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => const DownloadStartedDialog(),
|
||||
);
|
||||
} catch (e) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
const Icon(Icons.error, color: Colors.red),
|
||||
const SizedBox(width: 10),
|
||||
Text(locService.t('download.title')),
|
||||
],
|
||||
),
|
||||
content: Text(locService.t('download.downloadFailed', params: [e.toString()])),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(locService.t('actions.ok')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
40
lib/widgets/download_started_dialog.dart
Normal file
40
lib/widgets/download_started_dialog.dart
Normal file
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
class DownloadStartedDialog extends StatelessWidget {
|
||||
const DownloadStartedDialog({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
const Icon(Icons.download_for_offline, color: Colors.green),
|
||||
const SizedBox(width: 10),
|
||||
Text(locService.t('downloadStarted.title')),
|
||||
],
|
||||
),
|
||||
content: Text(locService.t('downloadStarted.message')),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(locService.t('downloadStarted.ok')),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.pushNamed(context, '/settings/offline');
|
||||
},
|
||||
child: Text(locService.t('downloadStarted.viewProgress')),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,78 @@ import '../dev_config.dart';
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/operator_profile.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../services/node_cache.dart';
|
||||
import '../services/changelog_service.dart';
|
||||
import '../state/settings_state.dart';
|
||||
import 'refine_tags_sheet.dart';
|
||||
import 'advanced_edit_options_sheet.dart';
|
||||
import 'proximity_warning_dialog.dart';
|
||||
import 'submission_guide_dialog.dart';
|
||||
|
||||
class EditNodeSheet extends StatelessWidget {
|
||||
const EditNodeSheet({super.key, required this.session});
|
||||
|
||||
final EditNodeSession session;
|
||||
|
||||
void _checkProximityAndCommit(BuildContext context, AppState appState, LocalizationService locService) {
|
||||
_checkSubmissionGuideAndProceed(context, appState, locService);
|
||||
}
|
||||
|
||||
void _checkSubmissionGuideAndProceed(BuildContext context, AppState appState, LocalizationService locService) async {
|
||||
// Check if user has seen the submission guide
|
||||
final hasSeenGuide = await ChangelogService().hasSeenSubmissionGuide();
|
||||
|
||||
if (!hasSeenGuide) {
|
||||
// Show submission guide dialog first
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => const SubmissionGuideDialog(),
|
||||
);
|
||||
}
|
||||
|
||||
// Now proceed with proximity check
|
||||
_checkProximityOnly(context, appState, locService);
|
||||
}
|
||||
|
||||
void _checkProximityOnly(BuildContext context, AppState appState, LocalizationService locService) {
|
||||
// Check for nearby nodes within the configured distance, excluding the node being edited
|
||||
final nearbyNodes = NodeCache.instance.findNodesWithinDistance(
|
||||
session.target,
|
||||
kNodeProximityWarningDistance,
|
||||
excludeNodeId: session.originalNode.id,
|
||||
);
|
||||
|
||||
if (nearbyNodes.isNotEmpty) {
|
||||
// Show proximity warning dialog
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => ProximityWarningDialog(
|
||||
nearbyNodes: nearbyNodes,
|
||||
distance: kNodeProximityWarningDistance,
|
||||
onGoBack: () {
|
||||
Navigator.of(context).pop(); // Close dialog
|
||||
},
|
||||
onSubmitAnyway: () {
|
||||
Navigator.of(context).pop(); // Close dialog
|
||||
_commitWithoutCheck(context, appState, locService);
|
||||
},
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// No nearby nodes, proceed with commit
|
||||
_commitWithoutCheck(context, appState, locService);
|
||||
}
|
||||
}
|
||||
|
||||
void _commitWithoutCheck(BuildContext context, AppState appState, LocalizationService locService) {
|
||||
appState.commitEditSession();
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(locService.t('node.editQueuedForUpload'))),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDirectionControls(BuildContext context, AppState appState, EditNodeSession session, LocalizationService locService) {
|
||||
final requiresDirection = session.profile != null && session.profile!.requiresDirection;
|
||||
|
||||
@@ -89,10 +153,12 @@ class EditNodeSheet extends StatelessWidget {
|
||||
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),
|
||||
),
|
||||
@@ -143,11 +209,7 @@ class EditNodeSheet extends StatelessWidget {
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
void _commit() {
|
||||
appState.commitEditSession();
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(locService.t('node.editQueuedForUpload'))),
|
||||
);
|
||||
_checkProximityAndCommit(context, appState, locService);
|
||||
}
|
||||
|
||||
void _cancel() {
|
||||
@@ -210,6 +272,59 @@ class EditNodeSheet extends StatelessWidget {
|
||||
// Direction controls
|
||||
_buildDirectionControls(context, appState, session, locService),
|
||||
|
||||
// Constraint message for nodes that cannot be moved
|
||||
if (session.originalNode.isConstrained)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Column(
|
||||
children: [
|
||||
// Extract from way checkbox (only show if enabled in dev config)
|
||||
if (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 (!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)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
@@ -331,4 +446,12 @@ class EditNodeSheet extends StatelessWidget {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _openAdvancedEdit(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => AdvancedEditOptionsSheet(node: session.originalNode),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import 'package:latlong2/latlong.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../../dev_config.dart';
|
||||
import '../../models/osm_node.dart';
|
||||
import '../../models/direction_fov.dart';
|
||||
|
||||
/// Helper class to build direction cone polygons for cameras
|
||||
class DirectionConesBuilder {
|
||||
@@ -20,10 +21,13 @@ class DirectionConesBuilder {
|
||||
|
||||
// Add session cones if in add-camera mode and profile requires direction
|
||||
if (session != null && session.target != null && session.profile?.requiresDirection == true) {
|
||||
final sessionFov = session.profile?.fov ?? (kDirectionConeHalfAngle * 2);
|
||||
|
||||
// Add current working direction (full opacity)
|
||||
overlays.add(_buildCone(
|
||||
overlays.add(_buildConeWithFov(
|
||||
session.target!,
|
||||
session.directionDegrees,
|
||||
sessionFov,
|
||||
zoom,
|
||||
context: context,
|
||||
isSession: true,
|
||||
@@ -33,9 +37,10 @@ class DirectionConesBuilder {
|
||||
// Add other directions (reduced opacity)
|
||||
for (int i = 0; i < session.directions.length; i++) {
|
||||
if (i != session.currentDirectionIndex) {
|
||||
overlays.add(_buildCone(
|
||||
overlays.add(_buildConeWithFov(
|
||||
session.target!,
|
||||
session.directions[i],
|
||||
sessionFov,
|
||||
zoom,
|
||||
context: context,
|
||||
isSession: true,
|
||||
@@ -47,10 +52,13 @@ class DirectionConesBuilder {
|
||||
|
||||
// Add edit session cones if in edit-camera mode and profile requires direction
|
||||
if (editSession != null && editSession.profile?.requiresDirection == true) {
|
||||
final sessionFov = editSession.profile?.fov ?? (kDirectionConeHalfAngle * 2);
|
||||
|
||||
// Add current working direction (full opacity)
|
||||
overlays.add(_buildCone(
|
||||
overlays.add(_buildConeWithFov(
|
||||
editSession.target,
|
||||
editSession.directionDegrees,
|
||||
sessionFov,
|
||||
zoom,
|
||||
context: context,
|
||||
isSession: true,
|
||||
@@ -60,9 +68,10 @@ class DirectionConesBuilder {
|
||||
// Add other directions (reduced opacity)
|
||||
for (int i = 0; i < editSession.directions.length; i++) {
|
||||
if (i != editSession.currentDirectionIndex) {
|
||||
overlays.add(_buildCone(
|
||||
overlays.add(_buildConeWithFov(
|
||||
editSession.target,
|
||||
editSession.directions[i],
|
||||
sessionFov,
|
||||
zoom,
|
||||
context: context,
|
||||
isSession: true,
|
||||
@@ -76,11 +85,12 @@ class DirectionConesBuilder {
|
||||
for (final node in cameras) {
|
||||
if (_isValidCameraWithDirection(node) &&
|
||||
(editSession == null || node.id != editSession.originalNode.id)) {
|
||||
// Build a cone for each direction
|
||||
for (final direction in node.directionDeg) {
|
||||
overlays.add(_buildCone(
|
||||
// Build a cone for each direction+fov pair
|
||||
for (final directionFov in node.directionFovPairs) {
|
||||
overlays.add(_buildConeWithFov(
|
||||
node.coord,
|
||||
direction,
|
||||
directionFov.centerDegrees,
|
||||
directionFov.fovDegrees,
|
||||
zoom,
|
||||
context: context,
|
||||
));
|
||||
@@ -103,6 +113,30 @@ class DirectionConesBuilder {
|
||||
node.tags['_pending_upload'] == 'true';
|
||||
}
|
||||
|
||||
/// Build cone with variable FOV width - new method for range notation support
|
||||
static Polygon _buildConeWithFov(
|
||||
LatLng origin,
|
||||
double bearingDeg,
|
||||
double fovDegrees,
|
||||
double zoom, {
|
||||
required BuildContext context,
|
||||
bool isPending = false,
|
||||
bool isSession = false,
|
||||
bool isActiveDirection = true,
|
||||
}) {
|
||||
return _buildConeInternal(
|
||||
origin: origin,
|
||||
bearingDeg: bearingDeg,
|
||||
halfAngleDeg: fovDegrees / 2,
|
||||
zoom: zoom,
|
||||
context: context,
|
||||
isPending: isPending,
|
||||
isSession: isSession,
|
||||
isActiveDirection: isActiveDirection,
|
||||
);
|
||||
}
|
||||
|
||||
/// Legacy method for backward compatibility - uses dev_config FOV
|
||||
static Polygon _buildCone(
|
||||
LatLng origin,
|
||||
double bearingDeg,
|
||||
@@ -112,7 +146,39 @@ class DirectionConesBuilder {
|
||||
bool isSession = false,
|
||||
bool isActiveDirection = true,
|
||||
}) {
|
||||
final halfAngle = kDirectionConeHalfAngle;
|
||||
return _buildConeInternal(
|
||||
origin: origin,
|
||||
bearingDeg: bearingDeg,
|
||||
halfAngleDeg: kDirectionConeHalfAngle,
|
||||
zoom: zoom,
|
||||
context: context,
|
||||
isPending: isPending,
|
||||
isSession: isSession,
|
||||
isActiveDirection: isActiveDirection,
|
||||
);
|
||||
}
|
||||
|
||||
/// Internal cone building method that handles the actual rendering
|
||||
static Polygon _buildConeInternal({
|
||||
required LatLng origin,
|
||||
required double bearingDeg,
|
||||
required double halfAngleDeg,
|
||||
required double zoom,
|
||||
required BuildContext context,
|
||||
bool isPending = false,
|
||||
bool isSession = false,
|
||||
bool isActiveDirection = true,
|
||||
}) {
|
||||
// Handle full circle case (360-degree FOV)
|
||||
if (halfAngleDeg >= 180) {
|
||||
return _buildFullCircle(
|
||||
origin: origin,
|
||||
zoom: zoom,
|
||||
context: context,
|
||||
isSession: isSession,
|
||||
isActiveDirection: isActiveDirection,
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate pixel-based radii
|
||||
final outerRadiusPx = kNodeIconDiameter + (kNodeIconDiameter * kDirectionConeBaseLength);
|
||||
@@ -124,7 +190,9 @@ class DirectionConesBuilder {
|
||||
final innerRadius = innerRadiusPx * pixelToCoordinate;
|
||||
|
||||
// Number of points for the outer arc (within our directional range)
|
||||
const int arcPoints = 12;
|
||||
// Scale arc points based on FOV width for better rendering
|
||||
final baseArcPoints = 12;
|
||||
final arcPoints = math.max(6, (baseArcPoints * halfAngleDeg / 45).round());
|
||||
|
||||
LatLng project(double deg, double distance) {
|
||||
final rad = deg * math.pi / 180;
|
||||
@@ -139,13 +207,13 @@ class DirectionConesBuilder {
|
||||
|
||||
// Add outer arc points from left to right (counterclockwise for proper polygon winding)
|
||||
for (int i = 0; i <= arcPoints; i++) {
|
||||
final angle = bearingDeg - halfAngle + (i * 2 * halfAngle / arcPoints);
|
||||
final angle = bearingDeg - halfAngleDeg + (i * 2 * halfAngleDeg / arcPoints);
|
||||
points.add(project(angle, outerRadius));
|
||||
}
|
||||
|
||||
// Add inner arc points from right to left (to close the donut shape)
|
||||
for (int i = arcPoints; i >= 0; i--) {
|
||||
final angle = bearingDeg - halfAngle + (i * 2 * halfAngle / arcPoints);
|
||||
final angle = bearingDeg - halfAngleDeg + (i * 2 * halfAngleDeg / arcPoints);
|
||||
points.add(project(angle, innerRadius));
|
||||
}
|
||||
|
||||
@@ -162,4 +230,59 @@ class DirectionConesBuilder {
|
||||
borderStrokeWidth: getDirectionConeBorderWidth(context),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a full circle for 360-degree FOV cases
|
||||
static Polygon _buildFullCircle({
|
||||
required LatLng origin,
|
||||
required double zoom,
|
||||
required BuildContext context,
|
||||
bool isSession = false,
|
||||
bool isActiveDirection = true,
|
||||
}) {
|
||||
// Calculate pixel-based radii
|
||||
final outerRadiusPx = kNodeIconDiameter + (kNodeIconDiameter * kDirectionConeBaseLength);
|
||||
final innerRadiusPx = kNodeIconDiameter + (2 * getNodeRingThickness(context));
|
||||
|
||||
// Convert pixels to coordinate distances with zoom scaling
|
||||
final pixelToCoordinate = 0.00001 * math.pow(2, 15 - zoom);
|
||||
final outerRadius = outerRadiusPx * pixelToCoordinate;
|
||||
final innerRadius = innerRadiusPx * pixelToCoordinate;
|
||||
|
||||
// Create full circle with many points for smooth rendering
|
||||
const int circlePoints = 36;
|
||||
final points = <LatLng>[];
|
||||
|
||||
LatLng project(double deg, double distance) {
|
||||
final rad = deg * math.pi / 180;
|
||||
final dLat = distance * math.cos(rad);
|
||||
final dLon =
|
||||
distance * math.sin(rad) / math.cos(origin.latitude * math.pi / 180);
|
||||
return LatLng(origin.latitude + dLat, origin.longitude + dLon);
|
||||
}
|
||||
|
||||
// Add outer circle points
|
||||
for (int i = 0; i < circlePoints; i++) {
|
||||
final angle = i * 360.0 / circlePoints;
|
||||
points.add(project(angle, outerRadius));
|
||||
}
|
||||
|
||||
// Add inner circle points in reverse order to create donut
|
||||
for (int i = circlePoints - 1; i >= 0; i--) {
|
||||
final angle = i * 360.0 / circlePoints;
|
||||
points.add(project(angle, innerRadius));
|
||||
}
|
||||
|
||||
// Adjust opacity based on direction state
|
||||
double opacity = kDirectionConeOpacity;
|
||||
if (isSession && !isActiveDirection) {
|
||||
opacity = kDirectionConeOpacity * 0.4;
|
||||
}
|
||||
|
||||
return Polygon(
|
||||
points: points,
|
||||
color: kDirectionConeColor.withOpacity(opacity),
|
||||
borderColor: kDirectionConeColor,
|
||||
borderStrokeWidth: getDirectionConeBorderWidth(context),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(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(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(kZoomControlsSpacingAboveButtonBar, safeArea.bottom),
|
||||
right: rightPositionWithSafeArea(16, safeArea),
|
||||
child: Consumer<AppState>(
|
||||
builder: (context, appState, child) {
|
||||
return Column(
|
||||
|
||||
@@ -12,6 +12,7 @@ import '../models/osm_node.dart';
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/suspected_location.dart';
|
||||
import '../models/tile_provider.dart';
|
||||
import '../state/session_state.dart';
|
||||
import 'debouncer.dart';
|
||||
import 'camera_provider_with_cache.dart';
|
||||
import 'camera_icon.dart';
|
||||
@@ -62,6 +63,7 @@ class MapViewState extends State<MapView> {
|
||||
final Debouncer _cameraDebounce = Debouncer(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));
|
||||
|
||||
late final MapPositionManager _positionManager;
|
||||
late final TileLayerManager _tileManager;
|
||||
@@ -260,6 +262,37 @@ class MapViewState extends State<MapView> {
|
||||
return latDiff > significantMovementThreshold || lngDiff > significantMovementThreshold;
|
||||
}
|
||||
|
||||
/// 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 unless extract is enabled.
|
||||
InteractionOptions _getInteractionOptions(EditNodeSession? editSession) {
|
||||
// Check if we're editing a constrained node that's not being extracted
|
||||
if (editSession?.originalNode.isConstrained == true && editSession?.extractFromWay != true) {
|
||||
// Constrained node (not extracting): only allow pinch zoom and rotation, disable ALL panning
|
||||
return const InteractionOptions(
|
||||
enableMultiFingerGestureRace: true,
|
||||
flags: InteractiveFlag.pinchZoom | InteractiveFlag.rotate,
|
||||
scrollWheelVelocity: kScrollWheelVelocity,
|
||||
pinchZoomThreshold: kPinchZoomThreshold,
|
||||
pinchMoveThreshold: kPinchMoveThreshold,
|
||||
);
|
||||
}
|
||||
|
||||
// Normal case: all interactions allowed with gesture race to prevent accidental rotation during zoom
|
||||
return const InteractionOptions(
|
||||
enableMultiFingerGestureRace: true,
|
||||
flags: InteractiveFlag.doubleTapDragZoom |
|
||||
InteractiveFlag.doubleTapZoom |
|
||||
InteractiveFlag.drag |
|
||||
InteractiveFlag.flingAnimation |
|
||||
InteractiveFlag.pinchZoom |
|
||||
InteractiveFlag.rotate |
|
||||
InteractiveFlag.scrollWheelZoom,
|
||||
scrollWheelVelocity: kScrollWheelVelocity,
|
||||
pinchZoomThreshold: kPinchZoomThreshold,
|
||||
pinchMoveThreshold: kPinchMoveThreshold,
|
||||
);
|
||||
}
|
||||
|
||||
/// Show zoom warning if user is below minimum zoom level
|
||||
void _showZoomWarningIfNeeded(BuildContext context, double currentZoom, int minZoom) {
|
||||
// Only show warning once per zoom level to avoid spam
|
||||
@@ -346,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
|
||||
|
||||
@@ -542,23 +588,60 @@ 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: const InteractionOptions(
|
||||
scrollWheelVelocity: kScrollWheelVelocity,
|
||||
pinchZoomThreshold: kPinchZoomThreshold,
|
||||
pinchMoveThreshold: kPinchMoveThreshold,
|
||||
),
|
||||
interactionOptions: _getInteractionOptions(editSession),
|
||||
onPositionChanged: (pos, gesture) {
|
||||
setState(() {}); // Instant UI update for zoom, etc.
|
||||
if (gesture) {
|
||||
widget.onUserGesture();
|
||||
}
|
||||
|
||||
// Enforce minimum zoom level for add/edit node sheets (but not tag sheet)
|
||||
if ((session != null || editSession != null) && pos.zoom < kMinZoomForNodeEditingSheets) {
|
||||
// User tried to zoom out below minimum - snap back to minimum zoom
|
||||
_controller.animateTo(
|
||||
dest: pos.center,
|
||||
zoom: kMinZoomForNodeEditingSheets.toDouble(),
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
return; // Don't process other position updates
|
||||
}
|
||||
|
||||
if (session != null) {
|
||||
appState.updateSession(target: pos.center);
|
||||
}
|
||||
if (editSession != null) {
|
||||
appState.updateEditSession(target: pos.center);
|
||||
// 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
|
||||
appState.updateEditSession(target: originalPos);
|
||||
|
||||
// Only snap back if position actually drifted, and debounce to wait for gesture completion
|
||||
if (pos.center.latitude != originalPos.latitude || pos.center.longitude != originalPos.longitude) {
|
||||
_constrainedNodeSnapBack(() {
|
||||
// Only animate if we're still in a constrained edit session and still drifted
|
||||
final currentEditSession = appState.editSession;
|
||||
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(
|
||||
dest: originalPos,
|
||||
zoom: _controller.mapController.camera.zoom,
|
||||
curve: Curves.easeOut,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Normal unconstrained node - allow position updates
|
||||
appState.updateEditSession(target: pos.center);
|
||||
}
|
||||
}
|
||||
|
||||
// Update provisional pin location during navigation search/routing
|
||||
@@ -612,17 +695,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(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)
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,109 +1,114 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../services/network_status.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
class NetworkStatusIndicator extends StatelessWidget {
|
||||
const NetworkStatusIndicator({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ChangeNotifierProvider.value(
|
||||
value: NetworkStatus.instance,
|
||||
child: Consumer<NetworkStatus>(
|
||||
builder: (context, networkStatus, child) {
|
||||
String message;
|
||||
IconData icon;
|
||||
Color color;
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => ChangeNotifierProvider.value(
|
||||
value: NetworkStatus.instance,
|
||||
child: Consumer<NetworkStatus>(
|
||||
builder: (context, networkStatus, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
String message;
|
||||
IconData icon;
|
||||
Color color;
|
||||
|
||||
switch (networkStatus.currentStatus) {
|
||||
case NetworkStatusType.waiting:
|
||||
message = 'Loading...';
|
||||
icon = Icons.hourglass_empty;
|
||||
color = Colors.blue;
|
||||
break;
|
||||
|
||||
case NetworkStatusType.timedOut:
|
||||
message = 'Timed out';
|
||||
icon = Icons.hourglass_disabled;
|
||||
color = Colors.orange;
|
||||
break;
|
||||
|
||||
case NetworkStatusType.noData:
|
||||
message = 'No tiles here';
|
||||
icon = Icons.cloud_off;
|
||||
color = Colors.grey;
|
||||
break;
|
||||
switch (networkStatus.currentStatus) {
|
||||
case NetworkStatusType.waiting:
|
||||
message = locService.t('networkStatus.loading');
|
||||
icon = Icons.hourglass_empty;
|
||||
color = Colors.blue;
|
||||
break;
|
||||
|
||||
case NetworkStatusType.timedOut:
|
||||
message = locService.t('networkStatus.timedOut');
|
||||
icon = Icons.hourglass_disabled;
|
||||
color = Colors.orange;
|
||||
break;
|
||||
|
||||
case NetworkStatusType.noData:
|
||||
message = locService.t('networkStatus.noData');
|
||||
icon = Icons.cloud_off;
|
||||
color = Colors.grey;
|
||||
break;
|
||||
|
||||
case NetworkStatusType.success:
|
||||
message = 'Done';
|
||||
icon = Icons.check_circle;
|
||||
color = Colors.green;
|
||||
break;
|
||||
|
||||
case NetworkStatusType.nodeLimitReached:
|
||||
message = 'Showing limit - increase in settings';
|
||||
icon = Icons.visibility_off;
|
||||
color = Colors.amber;
|
||||
break;
|
||||
|
||||
case NetworkStatusType.issues:
|
||||
switch (networkStatus.currentIssueType) {
|
||||
case NetworkIssueType.osmTiles:
|
||||
message = 'Tile provider slow';
|
||||
icon = Icons.map_outlined;
|
||||
color = Colors.orange;
|
||||
break;
|
||||
case NetworkIssueType.overpassApi:
|
||||
message = 'Camera data slow';
|
||||
icon = Icons.camera_alt_outlined;
|
||||
color = Colors.orange;
|
||||
break;
|
||||
case NetworkIssueType.both:
|
||||
message = 'Network issues';
|
||||
icon = Icons.cloud_off_outlined;
|
||||
color = Colors.red;
|
||||
break;
|
||||
default:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
break;
|
||||
|
||||
case NetworkStatusType.ready:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
case NetworkStatusType.success:
|
||||
message = locService.t('networkStatus.success');
|
||||
icon = Icons.check_circle;
|
||||
color = Colors.green;
|
||||
break;
|
||||
|
||||
case NetworkStatusType.nodeLimitReached:
|
||||
message = locService.t('networkStatus.nodeLimitReached');
|
||||
icon = Icons.visibility_off;
|
||||
color = Colors.amber;
|
||||
break;
|
||||
|
||||
case NetworkStatusType.issues:
|
||||
switch (networkStatus.currentIssueType) {
|
||||
case NetworkIssueType.osmTiles:
|
||||
message = locService.t('networkStatus.tileProviderSlow');
|
||||
icon = Icons.map_outlined;
|
||||
color = Colors.orange;
|
||||
break;
|
||||
case NetworkIssueType.overpassApi:
|
||||
message = locService.t('networkStatus.nodeDataSlow');
|
||||
icon = Icons.camera_alt_outlined;
|
||||
color = Colors.orange;
|
||||
break;
|
||||
case NetworkIssueType.both:
|
||||
message = locService.t('networkStatus.networkIssues');
|
||||
icon = Icons.cloud_off_outlined;
|
||||
color = Colors.red;
|
||||
break;
|
||||
default:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
break;
|
||||
|
||||
case NetworkStatusType.ready:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Positioned(
|
||||
top: 8, // Position relative to the map area (not the screen)
|
||||
left: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black87,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: color, width: 1),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: color,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
message,
|
||||
style: TextStyle(
|
||||
return Positioned(
|
||||
top: 8, // Position relative to the map area (not the screen)
|
||||
left: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black87,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: color, width: 1),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: color,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
message,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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')),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
126
lib/widgets/proximity_warning_dialog.dart
Normal file
126
lib/widgets/proximity_warning_dialog.dart
Normal file
@@ -0,0 +1,126 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../models/osm_node.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
class ProximityWarningDialog extends StatelessWidget {
|
||||
final List<OsmNode> nearbyNodes;
|
||||
final double distance;
|
||||
final VoidCallback onGoBack;
|
||||
final VoidCallback onSubmitAnyway;
|
||||
|
||||
const ProximityWarningDialog({
|
||||
super.key,
|
||||
required this.nearbyNodes,
|
||||
required this.distance,
|
||||
required this.onGoBack,
|
||||
required this.onSubmitAnyway,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return AlertDialog(
|
||||
icon: const Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
color: Colors.orange,
|
||||
size: 32,
|
||||
),
|
||||
title: Text(locService.t('proximityWarning.title')),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('proximityWarning.message',
|
||||
params: [distance.toStringAsFixed(1)]),
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
locService.t('proximityWarning.suggestion'),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
locService.t('proximityWarning.nearbyNodes',
|
||||
params: [nearbyNodes.length.toString()]),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...nearbyNodes.take(3).map((node) => Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0, bottom: 4.0),
|
||||
child: Text(
|
||||
'• ${locService.t('proximityWarning.nodeInfo', params: [
|
||||
node.id.toString(),
|
||||
_getNodeTypeDescription(node, locService),
|
||||
])}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
)),
|
||||
if (nearbyNodes.length > 3)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0, top: 4.0),
|
||||
child: Text(
|
||||
locService.t('proximityWarning.andMore',
|
||||
params: [(nearbyNodes.length - 3).toString()]),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: onGoBack,
|
||||
child: Text(locService.t('proximityWarning.goBack')),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: onSubmitAnyway,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.orange,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: Text(locService.t('proximityWarning.submitAnyway')),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _getNodeTypeDescription(OsmNode node, LocalizationService locService) {
|
||||
// Try to get a meaningful description from the node's tags
|
||||
final manMade = node.tags['man_made'];
|
||||
final amenity = node.tags['amenity'];
|
||||
final surveillance = node.tags['surveillance'];
|
||||
final surveillanceType = node.tags['surveillance:type'];
|
||||
final manufacturer = node.tags['manufacturer'];
|
||||
|
||||
if (manMade == 'surveillance') {
|
||||
if (surveillanceType == 'ALPR' || surveillanceType == 'ANPR') {
|
||||
return locService.t('proximityWarning.nodeType.alpr');
|
||||
} else if (surveillance == 'public') {
|
||||
return locService.t('proximityWarning.nodeType.publicCamera');
|
||||
} else {
|
||||
return locService.t('proximityWarning.nodeType.camera');
|
||||
}
|
||||
} else if (amenity != null) {
|
||||
return locService.t('proximityWarning.nodeType.amenity', params: [amenity]);
|
||||
} else if (manufacturer != null) {
|
||||
return locService.t('proximityWarning.nodeType.device', params: [manufacturer]);
|
||||
} else {
|
||||
return locService.t('proximityWarning.nodeType.unknown');
|
||||
}
|
||||
}
|
||||
}
|
||||
185
lib/widgets/submission_guide_dialog.dart
Normal file
185
lib/widgets/submission_guide_dialog.dart
Normal file
@@ -0,0 +1,185 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../services/changelog_service.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
class SubmissionGuideDialog extends StatefulWidget {
|
||||
const SubmissionGuideDialog({super.key, this.showDontShowAgain = true});
|
||||
|
||||
final bool showDontShowAgain;
|
||||
|
||||
@override
|
||||
State<SubmissionGuideDialog> createState() => _SubmissionGuideDialogState();
|
||||
}
|
||||
|
||||
class _SubmissionGuideDialogState extends State<SubmissionGuideDialog> {
|
||||
bool _dontShowAgain = false;
|
||||
bool _isInitialized = false;
|
||||
|
||||
Future<void> _launchUrl(String url) async {
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadCurrentState();
|
||||
}
|
||||
|
||||
Future<void> _loadCurrentState() async {
|
||||
if (!widget.showDontShowAgain) {
|
||||
// When manually opened, show the actual current state
|
||||
final hasSeenSubmissionGuide = await ChangelogService().hasSeenSubmissionGuide();
|
||||
setState(() {
|
||||
_dontShowAgain = hasSeenSubmissionGuide;
|
||||
_isInitialized = true;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_isInitialized = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onClose() async {
|
||||
if (_dontShowAgain && widget.showDontShowAgain) {
|
||||
await ChangelogService().markSubmissionGuideSeen();
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => AlertDialog(
|
||||
title: Text(locService.t('submissionGuide.title')),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Scrollable content
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('submissionGuide.description'),
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.blue.withOpacity(0.3)),
|
||||
),
|
||||
child: Text(
|
||||
locService.t('submissionGuide.bestPractices'),
|
||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
locService.t('submissionGuide.placementNote'),
|
||||
style: const TextStyle(fontSize: 13, fontStyle: FontStyle.italic),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
locService.t('submissionGuide.moreInfo'),
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Resource links row
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_buildLinkButton(
|
||||
locService.t('submissionGuide.identificationGuide'),
|
||||
'https://deflock.me/identify'
|
||||
),
|
||||
_buildLinkButton(
|
||||
locService.t('submissionGuide.osmWiki'),
|
||||
'https://wiki.openstreetmap.org/wiki/Tag:man_made%3Dsurveillance'
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Always visible checkbox, but disabled when manually opened
|
||||
if (_isInitialized)
|
||||
Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: _dontShowAgain,
|
||||
onChanged: widget.showDontShowAgain ? (value) {
|
||||
setState(() {
|
||||
_dontShowAgain = value ?? false;
|
||||
});
|
||||
} : null,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
locService.t('submissionGuide.dontShowAgain'),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: widget.showDontShowAgain
|
||||
? null
|
||||
: Theme.of(context).disabledColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _onClose,
|
||||
child: Text(locService.t('submissionGuide.gotIt')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLinkButton(String text, String url) {
|
||||
return Flexible(
|
||||
child: GestureDetector(
|
||||
onTap: () => _launchUrl(url),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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')),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import '../services/changelog_service.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
class WelcomeDialog extends StatefulWidget {
|
||||
const WelcomeDialog({super.key});
|
||||
const WelcomeDialog({super.key, this.showDontShowAgain = true});
|
||||
|
||||
final bool showDontShowAgain;
|
||||
|
||||
@override
|
||||
State<WelcomeDialog> createState() => _WelcomeDialogState();
|
||||
@@ -12,6 +14,7 @@ class WelcomeDialog extends StatefulWidget {
|
||||
|
||||
class _WelcomeDialogState extends State<WelcomeDialog> {
|
||||
bool _dontShowAgain = false;
|
||||
bool _isInitialized = false;
|
||||
|
||||
Future<void> _launchUrl(String url) async {
|
||||
final uri = Uri.parse(url);
|
||||
@@ -20,13 +23,33 @@ class _WelcomeDialogState extends State<WelcomeDialog> {
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadCurrentState();
|
||||
}
|
||||
|
||||
Future<void> _loadCurrentState() async {
|
||||
if (!widget.showDontShowAgain) {
|
||||
// When manually opened, show the actual current state
|
||||
final hasSeenWelcome = await ChangelogService().hasSeenWelcome();
|
||||
setState(() {
|
||||
_dontShowAgain = hasSeenWelcome;
|
||||
_isInitialized = true;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_isInitialized = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onClose() async {
|
||||
if (_dontShowAgain) {
|
||||
if (_dontShowAgain && widget.showDontShowAgain) {
|
||||
await ChangelogService().markWelcomeSeen();
|
||||
}
|
||||
|
||||
// Always update version tracking when closing welcome dialog
|
||||
await ChangelogService().updateLastSeenVersion();
|
||||
// Note: Version tracking is updated by completeVersionChange() after all dialogs
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
@@ -104,25 +127,31 @@ class _WelcomeDialogState extends State<WelcomeDialog> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Always visible checkbox at the bottom
|
||||
Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: _dontShowAgain,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_dontShowAgain = value ?? false;
|
||||
});
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
locService.t('welcome.dontShowAgain'),
|
||||
style: const TextStyle(fontSize: 13),
|
||||
// Always visible checkbox, but disabled when manually opened
|
||||
if (_isInitialized)
|
||||
Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: _dontShowAgain,
|
||||
onChanged: widget.showDontShowAgain ? (value) {
|
||||
setState(() {
|
||||
_dontShowAgain = value ?? false;
|
||||
});
|
||||
} : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
locService.t('welcome.dontShowAgain'),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: widget.showDontShowAgain
|
||||
? null
|
||||
: Theme.of(context).disabledColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
|
||||
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.2+10 # The thing after the + is the version code, incremented with each release
|
||||
version: 1.5.1+19 # 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
|
||||
|
||||
137
test/models/tile_provider_test.dart
Normal file
137
test/models/tile_provider_test.dart
Normal file
@@ -0,0 +1,137 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flock_map_app/models/tile_provider.dart';
|
||||
|
||||
void main() {
|
||||
group('TileType', () {
|
||||
test('getTileUrl handles standard x/y/z replacement', () {
|
||||
final tileType = TileType(
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
urlTemplate: 'https://example.com/{z}/{x}/{y}.png',
|
||||
attribution: 'Test',
|
||||
);
|
||||
|
||||
final url = tileType.getTileUrl(3, 2, 1);
|
||||
expect(url, 'https://example.com/3/2/1.png');
|
||||
});
|
||||
|
||||
test('getTileUrl handles subdomain patterns', () {
|
||||
final tileType0_3 = TileType(
|
||||
id: 'test_0_3',
|
||||
name: 'Test 0-3',
|
||||
urlTemplate: 'https://s{0_3}.example.com/{z}/{x}/{y}.png',
|
||||
attribution: 'Test',
|
||||
);
|
||||
|
||||
final tileType1_4 = TileType(
|
||||
id: 'test_1_4',
|
||||
name: 'Test 1-4',
|
||||
urlTemplate: 'https://s{1_4}.example.com/{z}/{x}/{y}.png',
|
||||
attribution: 'Test',
|
||||
);
|
||||
|
||||
// Test 0-3 range
|
||||
final url_0_3_a = tileType0_3.getTileUrl(1, 0, 0);
|
||||
final url_0_3_b = tileType0_3.getTileUrl(1, 3, 0);
|
||||
expect(url_0_3_a, contains('s0.example.com'));
|
||||
expect(url_0_3_b, contains('s3.example.com'));
|
||||
|
||||
// Test 1-4 range
|
||||
final url_1_4_a = tileType1_4.getTileUrl(1, 0, 0);
|
||||
final url_1_4_b = tileType1_4.getTileUrl(1, 3, 0);
|
||||
expect(url_1_4_a, contains('s1.example.com'));
|
||||
expect(url_1_4_b, contains('s4.example.com'));
|
||||
|
||||
// Test consistency
|
||||
final url1 = tileType0_3.getTileUrl(1, 2, 3);
|
||||
final url2 = tileType0_3.getTileUrl(1, 2, 3);
|
||||
expect(url1, url2); // Same input should give same output
|
||||
});
|
||||
|
||||
test('getTileUrl handles Bing Maps quadkey conversion', () {
|
||||
final tileType = TileType(
|
||||
id: 'bing_test',
|
||||
name: 'Bing Test',
|
||||
urlTemplate: 'https://ecn.t{subdomain}.tiles.virtualearth.net/tiles/a{quadkey}.jpeg?g=1&n=z',
|
||||
attribution: 'Microsoft',
|
||||
);
|
||||
|
||||
// Test some known quadkey conversions
|
||||
// x=0, y=0, z=1 should give quadkey "0"
|
||||
final url1 = tileType.getTileUrl(1, 0, 0);
|
||||
expect(url1, contains('a0.jpeg'));
|
||||
|
||||
// x=1, y=0, z=1 should give quadkey "1"
|
||||
final url2 = tileType.getTileUrl(1, 1, 0);
|
||||
expect(url2, contains('a1.jpeg'));
|
||||
|
||||
// x=0, y=1, z=1 should give quadkey "2"
|
||||
final url3 = tileType.getTileUrl(1, 0, 1);
|
||||
expect(url3, contains('a2.jpeg'));
|
||||
|
||||
// x=1, y=1, z=1 should give quadkey "3"
|
||||
final url4 = tileType.getTileUrl(1, 1, 1);
|
||||
expect(url4, contains('a3.jpeg'));
|
||||
|
||||
// More complex example: x=3, y=5, z=3 should give quadkey "213"
|
||||
final url5 = tileType.getTileUrl(3, 3, 5);
|
||||
expect(url5, contains('a213.jpeg'));
|
||||
});
|
||||
|
||||
test('getTileUrl handles API key replacement', () {
|
||||
final tileType = TileType(
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
urlTemplate: 'https://api.example.com/{z}/{x}/{y}?key={api_key}',
|
||||
attribution: 'Test',
|
||||
);
|
||||
|
||||
final url = tileType.getTileUrl(1, 2, 3, apiKey: 'mykey123');
|
||||
expect(url, 'https://api.example.com/1/2/3?key=mykey123');
|
||||
});
|
||||
|
||||
test('requiresApiKey detects API key requirement correctly', () {
|
||||
final tileTypeWithKey = TileType(
|
||||
id: 'test1',
|
||||
name: 'Test 1',
|
||||
urlTemplate: 'https://api.example.com/{z}/{x}/{y}?key={api_key}',
|
||||
attribution: 'Test',
|
||||
);
|
||||
|
||||
final tileTypeWithoutKey = TileType(
|
||||
id: 'test2',
|
||||
name: 'Test 2',
|
||||
urlTemplate: 'https://example.com/{z}/{x}/{y}.png',
|
||||
attribution: 'Test',
|
||||
);
|
||||
|
||||
expect(tileTypeWithKey.requiresApiKey, isTrue);
|
||||
expect(tileTypeWithoutKey.requiresApiKey, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('DefaultTileProviders', () {
|
||||
test('contains Bing satellite provider', () {
|
||||
final providers = DefaultTileProviders.createDefaults();
|
||||
final bingProvider = providers.firstWhere((p) => p.id == 'bing');
|
||||
|
||||
expect(bingProvider.name, 'Bing Maps');
|
||||
expect(bingProvider.tileTypes, hasLength(1));
|
||||
|
||||
final satelliteType = bingProvider.tileTypes.first;
|
||||
expect(satelliteType.id, 'bing_satellite');
|
||||
expect(satelliteType.name, 'Satellite');
|
||||
expect(satelliteType.urlTemplate, contains('quadkey'));
|
||||
expect(satelliteType.urlTemplate, contains('0_3'));
|
||||
expect(satelliteType.requiresApiKey, isFalse);
|
||||
expect(satelliteType.attribution, '© Microsoft Corporation');
|
||||
});
|
||||
|
||||
test('all default providers are usable', () {
|
||||
final providers = DefaultTileProviders.createDefaults();
|
||||
for (final provider in providers) {
|
||||
expect(provider.isUsable, isTrue, reason: '${provider.name} should be usable');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user