Compare commits

...

19 Commits

Author SHA1 Message Date
stopflock
1ac43b0c4e Only show appropriate external editors on each platform, redirect to appstore on error 2025-11-19 19:50:39 -06:00
stopflock
3174e0bfe1 Adjust gesture thresholds 2025-11-19 16:24:51 -06:00
stopflock
5404daa704 Gesture race! 2025-11-19 14:24:51 -06:00
stopflock
20870623f0 Compass adjust for search box 2025-11-19 13:36:04 -06:00
stopflock
8ed92dcd7e Home screen respect safe areas in all orientations 2025-11-19 13:32:40 -06:00
stopflock
0143c74415 Reasonable size limits for tag text boxes in sheets 2025-11-18 16:21:31 -06:00
stopflock
6c53d988de Further improve tag views, implement upload queue pause toggle 2025-11-17 13:37:48 -06:00
stopflock
26cebcc60e Localizations for new features 2025-11-16 21:26:35 -06:00
stopflock
7c2b9ea087 Configurable max height for node tags box, localizations for new UX strings 2025-11-16 18:16:50 -06:00
stopflock
b2645f1341 Limit tag list size, make changelog use a list instead of \n, make links clickable in node tags 2025-11-16 17:30:24 -06:00
stopflock
05eedbb910 Link to OSM in node_details sheet. Add option to open node in other editors. 2025-11-16 16:45:54 -06:00
stopflock
3ea6d6b2ff Add TODOs learned from discord discussion 2025-11-16 15:33:25 -06:00
stopflock
326b7ec523 Fix restriction on moving provisional edit nodes which are part of a way (pinch/fling) 2025-11-16 10:27:18 -06:00
stopflock
192c6e5158 Disallow editing location of nodes attached to ways/relations 2025-11-16 00:17:53 -06:00
stopflock
ac53f7f74e Reorder builtin profiles 2025-11-16 00:11:42 -06:00
stopflock
5b9810b9de Add Rekor, Axon profiles 2025-11-15 20:37:05 -06:00
stopflock
49e9c673b1 Bottom offsets for android 2025-11-15 15:41:07 -06:00
stopflock
fb8260d346 Add feature flag to disable edits temporarily during bugfix 2025-11-15 14:39:27 -06:00
stopflock
fee557330d Update actions workflow, disable dev mode 2025-11-15 13:23:37 -06:00
46 changed files with 1749 additions and 489 deletions

View File

@@ -1,33 +1,40 @@
name: Build Release
name: Build and Release
on:
push:
tags:
- '*'
workflow_dispatch:
release:
types: [published]
permissions:
contents: write
jobs:
get-version:
name: Get Version
name: Get Version and Release Info
runs-on: ubuntu-latest
outputs:
version: ${{ steps.set-version.outputs.version }}
is_prerelease: ${{ steps.set-info.outputs.is_prerelease }}
should_upload_to_stores: ${{ steps.set-info.outputs.should_upload_to_stores }}
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Get version from lib/dev_config.dart
- name: Get version from pubspec.yaml
id: set-version
run: |
echo version=$(grep "version:" pubspec.yaml | head -1 | cut -d ':' -f 2 | tr -d ' ' | cut -d '+' -f 1) >> $GITHUB_OUTPUT
# - name: Extract version from pubspec.yaml
# id: extract_version
# run: |
# version=$(grep '^version: ' pubspec.yaml | cut -d ' ' -f 2 | tr -d '\r')
# echo "VERSION=$version" >> $GITHUB_ENV
- name: Determine release actions
id: set-info
run: |
echo "is_prerelease=${{ github.event.release.prerelease }}" >> $GITHUB_OUTPUT
if [ "${{ github.event.release.prerelease }}" = "true" ]; then
echo "should_upload_to_stores=false" >> $GITHUB_OUTPUT
echo "✅ Pre-release - will build and attach assets, no store uploads"
else
echo "should_upload_to_stores=true" >> $GITHUB_OUTPUT
echo "✅ Full release - will build, attach assets, and upload to stores"
fi
build-android-apk:
name: Build Android APK
@@ -246,27 +253,6 @@ jobs:
path: Runner.ipa
if-no-files-found: 'error'
- name: Upload to App Store Connect
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
env:
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_KEY_BASE64 }}
run: |
# Create the private keys directory and decode API key
mkdir -p ~/private_keys
echo -n "$APP_STORE_CONNECT_API_KEY_BASE64" | base64 --decode > ~/private_keys/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8
# Upload to App Store Connect / TestFlight
xcrun altool --upload-app \
--type ios \
--file Runner.ipa \
--apiKey $APP_STORE_CONNECT_API_KEY_ID \
--apiIssuer $APP_STORE_CONNECT_ISSUER_ID
# Clean up sensitive files
rm -rf ~/private_keys
attach-to-release:
name: Attach Assets to Release
needs: [get-version, build-android-apk, build-android-aab, build-ios]
@@ -300,3 +286,54 @@ jobs:
deflock_v${{ needs.get-version.outputs.version }}.apk
deflock_v${{ needs.get-version.outputs.version }}.aab
deflock_v${{ needs.get-version.outputs.version }}.ipa
upload-to-stores:
name: Upload to App Stores
needs: [get-version, build-android-aab, build-ios]
runs-on: macos-latest # Need macOS for iOS uploads
if: needs.get-version.outputs.should_upload_to_stores == 'true'
steps:
- name: Download AAB artifact for Google Play
uses: actions/download-artifact@v4
with:
name: deflock_v${{ needs.get-version.outputs.version }}.aab
- name: Download IPA artifact for App Store
uses: actions/download-artifact@v4
with:
name: deflock_v${{ needs.get-version.outputs.version }}.ipa
# Temporarily disabled - uncomment when Google Play service account is ready
# - name: Upload to Google Play Store
# uses: r0adkll/upload-google-play@v1
# with:
# serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
# packageName: me.deflock.deflockapp
# releaseFiles: app-release.aab
# track: internal # Uploads to Internal Testing track for review before production
# status: completed
# inAppUpdatePriority: 0
- name: Upload to App Store Connect
env:
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_KEY_BASE64 }}
run: |
# Create the private keys directory and decode API key
mkdir -p ~/private_keys
echo -n "$APP_STORE_CONNECT_API_KEY_BASE64" | base64 --decode > ~/private_keys/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8
# Upload to App Store Connect / TestFlight
xcrun altool --upload-app \
--type ios \
--file Runner.ipa \
--apiKey $APP_STORE_CONNECT_API_KEY_ID \
--apiIssuer $APP_STORE_CONNECT_ISSUER_ID
# Clean up sensitive files
rm -rf ~/private_keys
- name: Clean up artifacts
run: |
rm -f app-release.aab Runner.ipa

View File

@@ -494,6 +494,84 @@ void updateMultipleThings() {
---
## Release Process & GitHub Actions
The app uses a **clean, release-triggered workflow** that rebuilds from scratch for maximum reliability:
### How It Works
**Trigger: GitHub Release Creation**
- Create a GitHub release → Workflow automatically builds, attaches assets, and optionally uploads to stores
- **Pre-release checkbox** controls store uploads:
-**Checked** → Build + attach assets (no store uploads)
-**Unchecked** → Build + attach assets + upload to App/Play stores
### Release Types
**Development/Beta Releases**
1. Create GitHub release from any tag/branch
2.**Check "pre-release"** checkbox
3. Publish → Assets built and attached, no store uploads
**Production Releases**
1. Create GitHub release from main/stable branch
2.**Leave "pre-release" unchecked**
3. Publish → Assets built and attached + uploaded to stores
### Store Upload Destinations
**Google Play Store:**
- Uploads to **Internal Testing** track
- Requires manual promotion to Beta/Production
- You maintain full control over public release
**App Store Connect:**
- Uploads to **TestFlight**
- Requires manual App Store submission
- You maintain full control over public release
### Required Secrets
**For Google Play Store Upload:**
- `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` - Complete JSON service account key (plain text)
**For iOS App Store Upload:**
- `APP_STORE_CONNECT_API_KEY_ID` - App Store Connect API key ID
- `APP_STORE_CONNECT_ISSUER_ID` - App Store Connect issuer ID
- `APP_STORE_CONNECT_API_KEY_BASE64` - Base64-encoded .p8 API key file
**For Building:**
- `OSM_PROD_CLIENTID` - OpenStreetMap production OAuth2 client ID
- `OSM_SANDBOX_CLIENTID` - OpenStreetMap sandbox OAuth2 client ID
- Android signing secrets (keystore, passwords, etc.)
- iOS signing certificates and provisioning profiles
### Google Play Store Setup
1. **Google Cloud Console:**
- Create Service Account with "Project Editor" role
- Enable Google Play Android Developer API
- Download JSON key file
2. **Google Play Console:**
- Add service account email to Users & Permissions
- Grant "Release Manager" permissions for your app
- Complete first manual release to activate app listing
3. **GitHub Secrets:**
- Store entire JSON key as `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` (plain text)
### Workflow Benefits
**Brutalist simplicity** - One trigger, clear behavior
**No external dependencies** - Only uses trusted `r0adkll/upload-google-play@v1`
**Explicit control** - GitHub's UI checkbox controls store uploads
**Always rebuilds** - No stale artifacts or cross-workflow complexity
**Safe defaults** - Pre-release prevents accidental production uploads
**No tag coordination** - Works with any commit, tag, or branch
---
## Build & Development Setup
### Prerequisites

View File

@@ -80,6 +80,7 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
**See [DEVELOPER.md](DEVELOPER.md)** for comprehensive technical documentation including:
- Architecture overview and design decisions
- Development setup and build instructions
- Release process and GitHub Actions automation
- Code organization and contribution guidelines
- Debugging tips and troubleshooting
@@ -90,6 +91,8 @@ cp lib/keys.dart.example lib/keys.dart
# Add OAuth2 client IDs, then: flutter run
```
**Releases**: The app uses GitHub's release system for automated building and store uploads. Simply create a GitHub release and use the "pre-release" checkbox to control whether builds go to app stores - checked for beta releases, unchecked for production releases.
---
## Roadmap
@@ -99,12 +102,20 @@ cp lib/keys.dart.example lib/keys.dart
- Fix network indicator - only done when fetch queue is empty!
### Current Development
- Add some builtin satellite tile provider
- Option to pull in profiles from NSI (man_made=surveillance only)
- Persistent cache for MY submissions: clean up when we see that node appear in overpass results or when older than 24h
- Dropdown on "refine tags" page to select acceptable options for camera:mount=
- Tutorial / info guide before submitting first node
- Link to "my changes" on osm (username edit history)
- Option to "extract node from way" for nodes attached to a way to allow moving
### 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?

View File

@@ -1,23 +1,104 @@
{
"1.4.0": {
"content": [
"• IMPROVED: Advanced editing options now only show apps available on your platform (iOS/Android)",
"• IMPROVED: When an OSM editor app isn't installed, automatically redirect to the appropriate app store",
"• IMPROVED: Better error handling for external editor launches with app store fallback",
"• Supported editors: Vespucci (Android), StreetComplete (Android), EveryDoor (both), Go Map!! (iOS)",
"• Web editors (iD, RapiD) remain available on all platforms as before"
]
},
"1.3.4": {
"content": [
"• NEW: 'Pause Upload Queue' toggle in Offline Settings - stops uploads while keeping live data access",
"• Useful for metered connections or when you want to batch uploads later",
"• Upload queue is now disabled if either full offline mode OR pause queue processing is enabled",
"• FIXED: Sheet buttons now remain visible when rotating from portrait to landscape mode",
"• FIXED: Sheets now properly resize when rotating between orientations without requiring user interaction",
"• IMPROVED: Tag list height adapts automatically for landscape orientation to prevent covering map",
"• IMPROVED: Sheets with few tags now shrink to appropriate size rather than maintaining fixed height",
"• IMPROVED: More reliable sheet layout using proper flexible height constraints",
"• CLEANED: Fixed minor code formatting inconsistencies"
]
},
"1.3.3": {
"content": [
"• NEW: Added builtin surveillance device profiles for Rekor and Axis Communications ALPR cameras",
"• Both profiles include proper OSM tags for manufacturer identification and require direction setting",
"• NEW: Advanced editing options - access iD Editor, RapiD, Vespucci, StreetComplete, and other OSM editors",
"• NEW: 'View on OSM' links to see nodes directly on OpenStreetMap website",
"• UX: Constrained nodes (part of ways/relations) cannot be moved to prevent data corruption",
"• UX: Auto-clickable URLs in all tag values - any URL becomes a tappable link",
"• UX: Tag lists now scroll with max height to keep buttons and map visible",
"• UX: Improved button layout on mobile with two rows for better accessibility",
"• UX: Localized network status messages in all supported languages",
"• FIXED: Duplicate changelog service calls eliminated"
]
},
"1.3.2": {
"content": [
"• HOTFIX: Temporarily disabled node editing to prevent OSM database issues while a bug is resolved",
"• UX: Fixed Android navigation bar covering settings page content"
]
},
"1.3.1": {
"content": "• UX: Network status indicator always enabled\n• UX: Direction slider wider on small screens\n• UX: Fixed iOS keyboard missing 'Done' in settings\n• UX: Fixed multi-direction nodes in upload queue\n• UX: Improved suspected locations loading indicator; removed popup, fixed stuck spinner"
"content": [
"• UX: Network status indicator always enabled",
"• UX: Direction slider wider on small screens",
"• UX: Fixed iOS keyboard missing 'Done' in settings",
"• UX: Fixed multi-direction nodes in upload queue",
"• UX: Improved suspected locations loading indicator; removed popup, fixed stuck spinner"
]
},
"1.2.8": {
"content": "• UX: Profile selection is now a required step to prevent accidental submission of default profile.\n• NEW: Note in welcome message about not submitting data you cannot vouch for personally (no street view etc)\n• NEW: Added default operator profiles for the most common private operators nationwide (Lowe's, Home Depot, et al)\n• NEW: Support for cardinal directions in OSM data, multiple directions on a node."
"content": [
"• UX: Profile selection is now a required step to prevent accidental submission of default profile",
"• NEW: Note in welcome message about not submitting data you cannot vouch for personally (no street view etc)",
"• NEW: Added default operator profiles for the most common private operators nationwide (Lowe's, Home Depot, et al)",
"• NEW: Support for cardinal directions in OSM data, multiple directions on a node"
]
},
"1.2.7": {
"content": "• NEW: Compass indicator shows map orientation; tap to spin north-up\n• Smart area caching: Loads 3x larger areas and refreshes data every 60 seconds for much faster browsing\n• Enhanced tile loading: Increased retry attempts with faster delays - tiles load much more reliably\n• Better network status: Simplified loading indicator logic\n• Instant node display: Surveillance devices now appear immediately when data finishes loading\n• Node limit alerts: Get notified when some nodes are not drawn"
"content": [
"• NEW: Compass indicator shows map orientation; tap to spin north-up",
"• Smart area caching: Loads 3x larger areas and refreshes data every 60 seconds for much faster browsing",
"• Enhanced tile loading: Increased retry attempts with faster delays - tiles load much more reliably",
"• Better network status: Simplified loading indicator logic",
"• Instant node display: Surveillance devices now appear immediately when data finishes loading",
"• Node limit alerts: Get notified when some nodes are not drawn"
]
},
"1.2.4": {
"content": "• New welcome popup for first-time users with essential privacy information\n• Automatic changelog display when app updates (like this one!)\n• Added Release Notes viewer in Settings > About\n• Enhanced user onboarding and transparency about data handling\n• Improved documentation for contributors"
"content": [
"• New welcome popup for first-time users with essential privacy information",
"• Automatic changelog display when app updates (like this one!)",
"• Added Release Notes viewer in Settings > About",
"• Enhanced user onboarding and transparency about data handling",
"• Improved documentation for contributors"
]
},
"1.2.3": {
"content": "• Enhanced map performance and stability\n• Improved offline sync reliability\n• Added better error handling for uploads\n• Various bug fixes and improvements"
"content": [
"• Enhanced map performance and stability",
"• Improved offline sync reliability",
"• Added better error handling for uploads",
"• Various bug fixes and improvements"
]
},
"1.2.2": {
"content": "• New surveillance device profiles added\n• Improved tile loading performance\n• Fixed issue with GPS accuracy\n• Updated translations"
"content": [
"• New surveillance device profiles added",
"• Improved tile loading performance",
"• Fixed issue with GPS accuracy",
"• Updated translations"
]
},
"1.2.0": {
"content": "• Major UI improvements\n• Added proximity alerts\n• Enhanced offline capabilities\n• New suspected locations feature"
"content": [
"• Major UI improvements",
"• Added proximity alerts",
"• Enhanced offline capabilities",
"• New suspected locations feature"
]
}
}

View File

@@ -130,6 +130,7 @@ class AppState extends ChangeNotifier {
// Settings state
bool get offlineMode => _settingsState.offlineMode;
bool get pauseQueueProcessing => _settingsState.pauseQueueProcessing;
int get maxCameras => _settingsState.maxCameras;
UploadMode get uploadMode => _settingsState.uploadMode;
FollowMeMode get followMeMode => _settingsState.followMeMode;
@@ -411,6 +412,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 +534,7 @@ class AppState extends ChangeNotifier {
void _startUploader() {
_uploadQueueState.startUploader(
offlineMode: offlineMode,
pauseQueueProcessing: pauseQueueProcessing,
uploadMode: uploadMode,
getAccessToken: _authState.getAccessToken,
);

View File

@@ -34,7 +34,20 @@ 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';
@@ -44,11 +57,14 @@ const String kClientName = 'DeFlock';
const String kSuspectedLocationsCsvUrl = 'https://stopflock.com/app/flock_utilities_mini_latest.csv';
// Development/testing features - set to false for production builds
const bool kEnableDevelopmentModes = true; // Set to false to hide sandbox/simulate modes and force production mode
const bool kEnableDevelopmentModes = false; // Set to false to hide sandbox/simulate modes and force production mode
// Navigation features - set to false to hide navigation UI elements while in development
const bool kEnableNavigationFeatures = kEnableDevelopmentModes; // Hide navigation until fully implemented
// Node editing features - set to false to temporarily disable editing
const bool kEnableNodeEdits = true; // Set to false to temporarily disable node editing
/// Navigation availability: only dev builds, and only when online
bool enableNavigationFeatures({required bool offlineMode}) {
if (!kEnableDevelopmentModes) {
@@ -76,6 +92,17 @@ 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 kProximityAlertMinDistance = 50; // meters
@@ -84,9 +111,10 @@ const Duration kProximityAlertCooldown = Duration(minutes: 10); // Cooldown betw
// 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

View File

@@ -16,7 +16,10 @@
"close": "Schließen",
"submit": "Senden",
"saveEdit": "Bearbeitung Speichern",
"clear": "Löschen"
"clear": "Löschen",
"viewOnOSM": "Auf OSM anzeigen",
"advanced": "Erweitert",
"useAdvancedEditor": "Erweiterten Editor verwenden"
},
"followMe": {
"off": "Verfolgung aktivieren",
@@ -36,6 +39,8 @@
"maxNodesWarning": "Sie möchten das wahrscheinlich nicht tun, es sei denn, Sie sind absolut sicher, dass Sie einen guten Grund dafür haben.",
"offlineMode": "Offline-Modus",
"offlineModeSubtitle": "Alle Netzwerkanfragen außer für lokale/Offline-Bereiche deaktivieren.",
"pauseQueueProcessing": "Upload-Warteschlange pausieren",
"pauseQueueProcessingSubtitle": "Upload von wartenden Änderungen stoppen, aber Live-Datenzugriff beibehalten.",
"offlineModeWarningTitle": "Aktive Downloads",
"offlineModeWarningMessage": "Die Aktivierung des Offline-Modus bricht alle aktiven Bereichsdownloads ab. Möchten Sie fortfahren?",
"enableOfflineMode": "Offline-Modus Aktivieren",
@@ -89,10 +94,12 @@
"profileRequired": "Bitte wählen Sie ein Profil aus, um fortzufahren.",
"direction": "Richtung {}°",
"profileNoDirectionInfo": "Dieses Profil benötigt keine Richtung.",
"temporarilyDisabled": "Bearbeitungen wurden vorübergehend deaktiviert, während wir einen Fehler beheben - Entschuldigung - schauen Sie bald wieder vorbei.",
"mustBeLoggedIn": "Sie müssen angemeldet sein, um Knoten zu bearbeiten. Bitte melden Sie sich über die Einstellungen an.",
"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.",
"refineTags": "Tags Verfeinern",
"refineTagsWithProfile": "Tags Verfeinern ({})"
},
@@ -304,9 +311,38 @@
"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",

View File

@@ -34,7 +34,10 @@
"close": "Close",
"submit": "Submit",
"saveEdit": "Save Edit",
"clear": "Clear"
"clear": "Clear",
"viewOnOSM": "View on OSM",
"advanced": "Advanced",
"useAdvancedEditor": "Use Advanced Editor"
},
"followMe": {
"off": "Enable follow-me",
@@ -54,6 +57,8 @@
"maxNodesWarning": "You probably don't want to do that unless you are absolutely sure you have a good reason for it.",
"offlineMode": "Offline Mode",
"offlineModeSubtitle": "Disable all network requests except for local/offline areas.",
"pauseQueueProcessing": "Pause Upload Queue",
"pauseQueueProcessingSubtitle": "Stop uploading queued changes while keeping live data access.",
"offlineModeWarningTitle": "Active Downloads",
"offlineModeWarningMessage": "Enabling offline mode will cancel any active area downloads. Do you want to continue?",
"enableOfflineMode": "Enable Offline Mode",
@@ -107,10 +112,12 @@
"profileRequired": "Please select a profile to continue.",
"direction": "Direction {}°",
"profileNoDirectionInfo": "This profile does not require a direction.",
"temporarilyDisabled": "Edits have been temporarily disabled while we sort out a bug - apologies - check back soon.",
"mustBeLoggedIn": "You must be logged in to edit nodes. Please log in via Settings.",
"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.",
"refineTags": "Refine Tags",
"refineTagsWithProfile": "Refine Tags ({})"
},
@@ -322,9 +329,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",

View File

@@ -34,7 +34,10 @@
"close": "Cerrar",
"submit": "Enviar",
"saveEdit": "Guardar Edición",
"clear": "Limpiar"
"clear": "Limpiar",
"viewOnOSM": "Ver en OSM",
"advanced": "Avanzado",
"useAdvancedEditor": "Usar Editor Avanzado"
},
"followMe": {
"off": "Activar seguimiento",
@@ -54,6 +57,8 @@
"maxNodesWarning": "Probablemente no quieras hacer eso a menos que estés absolutamente seguro de que tienes una buena razón para ello.",
"offlineMode": "Modo Sin Conexión",
"offlineModeSubtitle": "Deshabilitar todas las solicitudes de red excepto para áreas locales/sin conexión.",
"pauseQueueProcessing": "Pausar Cola de Subida",
"pauseQueueProcessingSubtitle": "Detener la subida de cambios en cola manteniendo acceso a datos en vivo.",
"offlineModeWarningTitle": "Descargas Activas",
"offlineModeWarningMessage": "Habilitar el modo sin conexión cancelará cualquier descarga de área activa. ¿Desea continuar?",
"enableOfflineMode": "Habilitar Modo Sin Conexión",
@@ -107,10 +112,12 @@
"profileRequired": "Por favor, seleccione un perfil para continuar.",
"direction": "Dirección {}°",
"profileNoDirectionInfo": "Este perfil no requiere una dirección.",
"temporarilyDisabled": "Las ediciones han sido temporalmente deshabilitadas mientras solucionamos un error - disculpas - regrese pronto.",
"mustBeLoggedIn": "Debe estar conectado para editar nodos. Por favor, inicie sesión a través de Configuración.",
"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.",
"refineTags": "Refinar Etiquetas",
"refineTagsWithProfile": "Refinar Etiquetas ({})"
},
@@ -322,9 +329,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",

View File

@@ -34,7 +34,10 @@
"close": "Fermer",
"submit": "Soumettre",
"saveEdit": "Sauvegarder Modification",
"clear": "Effacer"
"clear": "Effacer",
"viewOnOSM": "Voir sur OSM",
"advanced": "Avancé",
"useAdvancedEditor": "Utiliser l'Éditeur Avancé"
},
"followMe": {
"off": "Activer le suivi",
@@ -54,6 +57,8 @@
"maxNodesWarning": "Vous ne voulez probablement pas faire cela à moins d'être absolument sûr d'avoir une bonne raison de le faire.",
"offlineMode": "Mode Hors Ligne",
"offlineModeSubtitle": "Désactiver toutes les requêtes réseau sauf pour les zones locales/hors ligne.",
"pauseQueueProcessing": "Suspendre la File d'Upload",
"pauseQueueProcessingSubtitle": "Arrêter l'upload des modifications en attente tout en gardant l'accès aux données en direct.",
"offlineModeWarningTitle": "Téléchargements Actifs",
"offlineModeWarningMessage": "L'activation du mode hors ligne annulera tous les téléchargements de zone actifs. Voulez-vous continuer?",
"enableOfflineMode": "Activer le Mode Hors Ligne",
@@ -107,10 +112,12 @@
"profileRequired": "Veuillez sélectionner un profil pour continuer.",
"direction": "Direction {}°",
"profileNoDirectionInfo": "Ce profil ne nécessite pas de direction.",
"temporarilyDisabled": "Les modifications ont été temporairement désactivées pendant que nous résolvons un bug - désolés - revenez bientôt.",
"mustBeLoggedIn": "Vous devez être connecté pour modifier les nœuds. Veuillez vous connecter via les Paramètres.",
"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.",
"refineTags": "Affiner Balises",
"refineTagsWithProfile": "Affiner Balises ({})"
},
@@ -322,9 +329,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",

View File

@@ -34,7 +34,10 @@
"close": "Chiudi",
"submit": "Invia",
"saveEdit": "Salva Modifica",
"clear": "Pulisci"
"clear": "Pulisci",
"viewOnOSM": "Visualizza su OSM",
"advanced": "Avanzato",
"useAdvancedEditor": "Usa Editor Avanzato"
},
"followMe": {
"off": "Attiva seguimi",
@@ -54,6 +57,8 @@
"maxNodesWarning": "Probabilmente non vuoi farlo a meno che non sei assolutamente sicuro di avere una buona ragione per farlo.",
"offlineMode": "Modalità Offline",
"offlineModeSubtitle": "Disabilita tutte le richieste di rete tranne per aree locali/offline.",
"pauseQueueProcessing": "Pausa Coda Upload",
"pauseQueueProcessingSubtitle": "Ferma l'upload delle modifiche in coda mantenendo l'accesso ai dati dal vivo.",
"offlineModeWarningTitle": "Download Attivi",
"offlineModeWarningMessage": "L'attivazione della modalità offline cancellerà qualsiasi download di area attivo. Vuoi continuare?",
"enableOfflineMode": "Attiva Modalità Offline",
@@ -107,10 +112,12 @@
"profileRequired": "Per favore seleziona un profilo per continuare.",
"direction": "Direzione {}°",
"profileNoDirectionInfo": "Questo profilo non richiede una direzione.",
"temporarilyDisabled": "Le modifiche sono state temporaneamente disabilitate mentre risolviamo un bug - scuse - torna presto.",
"mustBeLoggedIn": "Devi essere loggato per modificare i nodi. Per favore accedi tramite Impostazioni.",
"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.",
"refineTags": "Affina Tag",
"refineTagsWithProfile": "Affina Tag ({})"
},
@@ -322,9 +329,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",

View File

@@ -34,7 +34,10 @@
"close": "Fechar",
"submit": "Enviar",
"saveEdit": "Salvar Edição",
"clear": "Limpar"
"clear": "Limpar",
"viewOnOSM": "Ver no OSM",
"advanced": "Avançado",
"useAdvancedEditor": "Usar Editor Avançado"
},
"followMe": {
"off": "Ativar seguir-me",
@@ -54,6 +57,8 @@
"maxNodesWarning": "Você provavelmente não quer fazer isso a menos que tenha certeza absoluta de que tem uma boa razão para isso.",
"offlineMode": "Modo Offline",
"offlineModeSubtitle": "Desabilitar todas as requisições de rede exceto para áreas locais/offline.",
"pauseQueueProcessing": "Pausar Fila de Upload",
"pauseQueueProcessingSubtitle": "Parar upload de alterações na fila mantendo acesso a dados ao vivo.",
"offlineModeWarningTitle": "Downloads Ativos",
"offlineModeWarningMessage": "Ativar o modo offline cancelará qualquer download de área ativo. Deseja continuar?",
"enableOfflineMode": "Ativar Modo Offline",
@@ -107,10 +112,12 @@
"profileRequired": "Por favor, selecione um perfil para continuar.",
"direction": "Direção {}°",
"profileNoDirectionInfo": "Este perfil não requer uma direção.",
"temporarilyDisabled": "As edições foram temporariamente desabilitadas enquanto resolvemos um bug - desculpe - volte em breve.",
"mustBeLoggedIn": "Você deve estar logado para editar nós. Por favor, faça login via Configurações.",
"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.",
"refineTags": "Refinar Tags",
"refineTagsWithProfile": "Refinar Tags ({})"
},
@@ -322,9 +329,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",

View File

@@ -34,7 +34,10 @@
"close": "关闭",
"submit": "提交",
"saveEdit": "保存编辑",
"clear": "清空"
"clear": "清空",
"viewOnOSM": "在OSM上查看",
"advanced": "高级",
"useAdvancedEditor": "使用高级编辑器"
},
"followMe": {
"off": "启用跟随模式",
@@ -54,6 +57,8 @@
"maxNodesWarning": "除非您确定有充分的理由,否则您可能不想这样做。",
"offlineMode": "离线模式",
"offlineModeSubtitle": "禁用除本地/离线区域外的所有网络请求。",
"pauseQueueProcessing": "暂停上传队列",
"pauseQueueProcessingSubtitle": "停止上传排队的更改,同时保持实时数据访问。",
"offlineModeWarningTitle": "活动下载",
"offlineModeWarningMessage": "启用离线模式将取消任何活动的区域下载。您要继续吗?",
"enableOfflineMode": "启用离线模式",
@@ -107,10 +112,12 @@
"profileRequired": "请选择配置文件以继续。",
"direction": "方向 {}°",
"profileNoDirectionInfo": "此配置文件不需要方向。",
"temporarilyDisabled": "编辑功能已暂时禁用,我们正在修复一个错误 - 抱歉 - 请稍后再试。",
"mustBeLoggedIn": "您必须登录才能编辑节点。请通过设置登录。",
"sandboxModeWarning": "无法将生产节点的编辑提交到沙盒。在设置中切换到生产模式以编辑节点。",
"enableSubmittableProfile": "在设置中启用可提交的配置文件以编辑节点。",
"profileViewOnlyWarning": "此配置文件仅用于地图查看。请选择可提交的配置文件来编辑节点。",
"cannotMoveConstrainedNode": "无法移动此相机 - 它连接到另一个地图元素OSM way/relation。您仍可以编辑其标签和方向。",
"refineTags": "细化标签",
"refineTagsWithProfile": "细化标签({}"
},
@@ -322,9 +329,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": "搜索位置",

View File

@@ -118,6 +118,39 @@ 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,
),
NodeProfile(
id: 'builtin-generic-gunshot',
name: 'Generic Gunshot Detector',

View File

@@ -4,11 +4,13 @@ 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 +18,7 @@ class OsmNode {
'lat': coord.latitude,
'lon': coord.longitude,
'tags': tags,
'isConstrained': isConstrained,
};
factory OsmNode.fromJson(Map<String, dynamic> json) {
@@ -29,6 +32,7 @@ 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
);
}

View File

@@ -31,7 +31,12 @@ class AboutScreen extends StatelessWidget {
title: Text(locService.t('settings.aboutThisApp')),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.of(context).padding.bottom,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [

View File

@@ -20,7 +20,12 @@ class AdvancedSettingsScreen extends StatelessWidget {
title: Text(locService.t('settings.advancedSettings')),
),
body: ListView(
padding: const EdgeInsets.all(16),
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.of(context).padding.bottom,
),
children: const [
MaxNodesSection(),
Divider(),

View File

@@ -145,6 +145,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 +171,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), () {
@@ -691,71 +708,76 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
// Bottom button bar (restored to original)
Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom + kBottomButtonBarOffset,
left: 8,
right: 8,
),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600), // Match typical sheet width
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Theme.of(context).shadowColor.withOpacity(0.3),
blurRadius: 10,
offset: Offset(0, -2),
)
],
child: Builder(
builder: (context) {
final safeArea = MediaQuery.of(context).padding;
return Padding(
padding: EdgeInsets.only(
bottom: safeArea.bottom + 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: () => showDialog(
context: context,
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
),
style: ElevatedButton.styleFrom(
minimumSize: Size(0, 48),
textStyle: TextStyle(fontSize: 16),
),
),
),
),
),
],
),
),
],
),
),
),
),
);
},
),
),
],

View File

@@ -15,9 +15,14 @@ class LanguageSettingsScreen extends StatelessWidget {
appBar: AppBar(
title: Text(locService.t('settings.language')),
),
body: const Padding(
padding: EdgeInsets.all(16),
child: LanguageSection(),
body: Padding(
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.of(context).padding.bottom,
),
child: const LanguageSection(),
),
),
);

View File

@@ -17,7 +17,12 @@ class NavigationSettingsScreen extends StatelessWidget {
title: Text(locService.t('navigation.navigationSettings')),
),
body: Padding(
padding: const EdgeInsets.all(16),
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.of(context).padding.bottom,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [

View File

@@ -17,7 +17,12 @@ class OfflineSettingsScreen extends StatelessWidget {
title: Text(locService.t('settings.offlineSettings')),
),
body: ListView(
padding: const EdgeInsets.all(16),
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.of(context).padding.bottom,
),
children: const [
OfflineModeSection(),
Divider(),

View File

@@ -56,7 +56,12 @@ class _OperatorProfileEditorState extends State<OperatorProfileEditor> {
title: Text(widget.profile.name.isEmpty ? locService.t('operatorProfileEditor.newOperatorProfile') : locService.t('operatorProfileEditor.editOperatorProfile')),
),
body: ListView(
padding: const EdgeInsets.all(16),
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.of(context).padding.bottom,
),
children: [
TextField(
controller: _nameCtrl,

View File

@@ -67,7 +67,12 @@ class _ProfileEditorState extends State<ProfileEditor> {
: (widget.profile.name.isEmpty ? locService.t('profileEditor.newProfile') : locService.t('profileEditor.editProfile'))),
),
body: ListView(
padding: const EdgeInsets.all(16),
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.of(context).padding.bottom,
),
children: [
TextField(
controller: _nameCtrl,

View File

@@ -17,7 +17,12 @@ class ProfilesSettingsScreen extends StatelessWidget {
title: Text(locService.t('settings.profiles')),
),
body: ListView(
padding: const EdgeInsets.all(16),
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.of(context).padding.bottom,
),
children: const [
NodeProfilesSection(),
Divider(),

View File

@@ -84,7 +84,12 @@ class _ReleaseNotesScreenState extends State<ReleaseNotesScreen> {
),
)
: ListView(
padding: const EdgeInsets.all(16),
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.of(context).padding.bottom,
),
children: [
// Current version indicator
Container(

View File

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

View File

@@ -18,7 +18,12 @@ class SettingsScreen extends StatelessWidget {
builder: (context, child) => Scaffold(
appBar: AppBar(title: Text(locService.t('settings.title'))),
body: ListView(
padding: const EdgeInsets.all(16),
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.of(context).padding.bottom,
),
children: [
// Only show upload mode section in development builds
if (kEnableDevelopmentModes) ...[

View File

@@ -64,7 +64,12 @@ class _TileProviderEditorScreenState extends State<TileProviderEditorScreen> {
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.of(context).padding.bottom,
),
children: [
TextFormField(
controller: _nameController,

View File

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

View File

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

View File

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

View File

@@ -27,6 +27,7 @@ class NodeCache {
id: node.id,
coord: node.coord,
tags: mergedTags,
isConstrained: node.isConstrained, // Preserve constraint information
);
} else {
_nodes[node.id] = node;
@@ -58,6 +59,7 @@ class NodeCache {
id: node.id,
coord: node.coord,
tags: cleanTags,
isConstrained: node.isConstrained, // Preserve constraint information
);
}
}

View File

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

View File

@@ -61,8 +61,13 @@ class UploadQueueState extends ChangeNotifier {
// Add a completed edit session to the upload queue
void addFromEditSession(EditNodeSession session, {required UploadMode uploadMode}) {
// For constrained nodes, always use original position regardless of session.target
final coordToUse = session.originalNode.isConstrained
? session.originalNode.coord
: session.target;
final upload = PendingUpload(
coord: session.target,
coord: coordToUse,
direction: _formatDirectionsAsString(session.directions),
profile: session.profile!, // Safe to use ! because commitEditSession() checks for null
operatorProfile: session.operatorProfile,
@@ -158,16 +163,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;
}

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import '../models/operator_profile.dart';
import '../services/localization_service.dart';
import '../state/settings_state.dart';
import 'refine_tags_sheet.dart';
import 'advanced_edit_options_sheet.dart';
class EditNodeSheet extends StatelessWidget {
const EditNodeSheet({super.key, required this.session});
@@ -157,7 +158,8 @@ class EditNodeSheet extends StatelessWidget {
final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList();
final isSandboxMode = appState.uploadMode == UploadMode.sandbox;
final allowSubmit = appState.isLoggedIn &&
final allowSubmit = kEnableNodeEdits &&
appState.isLoggedIn &&
submittableProfiles.isNotEmpty &&
session.profile != null &&
session.profile!.isSubmittable;
@@ -209,7 +211,59 @@ class EditNodeSheet extends StatelessWidget {
// Direction controls
_buildDirectionControls(context, appState, session, locService),
if (!appState.isLoggedIn)
// Constraint message for nodes that cannot be moved
if (session.originalNode.isConstrained)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Column(
children: [
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),
child: Row(
children: [
const Icon(Icons.construction, color: Colors.orange, size: 20),
const SizedBox(width: 6),
Expanded(
child: Text(
locService.t('editNode.temporarilyDisabled'),
style: const TextStyle(color: Colors.orange, fontSize: 13),
),
),
],
),
)
else if (!appState.isLoggedIn)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
@@ -314,4 +368,12 @@ class EditNodeSheet extends StatelessWidget {
},
);
}
void _openAdvancedEdit(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => AdvancedEditOptionsSheet(node: session.originalNode),
);
}
}

View File

@@ -51,13 +51,15 @@ class MapOverlays extends StatelessWidget {
@override
Widget build(BuildContext context) {
final safeArea = MediaQuery.of(context).padding;
return Stack(
children: [
// MODE INDICATOR badge (top-right)
if (uploadMode == UploadMode.sandbox || uploadMode == UploadMode.simulate)
Positioned(
top: 18,
right: 14,
top: topPositionWithSafeArea(18, safeArea),
right: rightPositionWithSafeArea(14, safeArea),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
@@ -86,12 +88,13 @@ class MapOverlays extends StatelessWidget {
// Compass indicator (top-right, below mode indicator)
CompassIndicator(
mapController: mapController,
safeArea: safeArea,
),
// Zoom indicator, positioned relative to button bar
// Zoom indicator, positioned relative to button bar with left safe area
Positioned(
left: 10,
bottom: bottomPositionFromButtonBar(kZoomIndicatorSpacingAboveButtonBar, MediaQuery.of(context).padding.bottom),
left: leftPositionWithSafeArea(10, safeArea),
bottom: bottomPositionFromButtonBar(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(

View File

@@ -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.
InteractionOptions _getInteractionOptions(EditNodeSession? editSession) {
// Check if we're editing a constrained node
if (editSession?.originalNode.isConstrained == true) {
// Constrained node: only allow pinch zoom and rotation, disable ALL panning
return const InteractionOptions(
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
@@ -543,11 +576,7 @@ class MapViewState extends State<MapView> {
initialCenter: _gpsController.currentLocation ?? _positionManager.initialLocation ?? LatLng(37.7749, -122.4194),
initialZoom: _positionManager.initialZoom ?? 15,
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) {
@@ -558,7 +587,35 @@ class MapViewState extends State<MapView> {
appState.updateSession(target: pos.center);
}
if (editSession != null) {
appState.updateEditSession(target: pos.center);
// For constrained nodes, always snap back to original position
if (editSession.originalNode.isConstrained) {
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) {
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 +669,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)
);
},
),
],
),

View File

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

View File

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

View File

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

View File

@@ -25,8 +25,7 @@ class _WelcomeDialogState extends State<WelcomeDialog> {
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();

View File

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

View File

@@ -1,7 +1,7 @@
name: deflockapp
description: Map public surveillance infrastructure with OpenStreetMap
publish_to: "none"
version: 1.3.1+9 # The thing after the + is the version code, incremented with each release
version: 1.4.0+13 # 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