Compare commits

..

57 Commits

Author SHA1 Message Date
stopflock
3baed3c328 Change suspected locations URL back to alprwatch 2025-11-22 10:42:32 -06:00
stopflock
3ade06eef1 todos, dev mode 2025-11-22 10:40:08 -06:00
stopflock
c7b70dddc4 De-vibe changelog 2025-11-22 00:27:46 -06:00
stopflock
d747c66990 Disallow new/edit nodes below zoom 15, disallow downloads below zoom 10. 2025-11-22 00:17:24 -06:00
stopflock
5673c2b627 Link to view progress in settings after starting an offline area download 2025-11-21 21:08:10 -06:00
stopflock
32a0ac17ad update readme roadmap order 2025-11-21 19:33:17 -06:00
stopflock
dec957790c devibe changelog 2025-11-21 19:26:32 -06:00
stopflock
9319bbda48 Support FOV range notation: 0-360, 90-270, 10-45;90-125 2025-11-21 19:25:34 -06:00
stopflock
ee26576c5e Update changelog 2025-11-21 16:51:35 -06:00
stopflock
d6419d5b7c Turn off dev mode 2025-11-21 16:43:40 -06:00
stopflock
026ece2e29 Update roadmap, bump version to 1.4.3, changelog still needs de-vibing 2025-11-21 15:42:32 -06:00
stopflock
3c996c78c9 Two nodes too close together warning 2025-11-21 15:35:12 -06:00
stopflock
492cf57520 Disable deletion of nodes attached to ways/relations, add option for visibility of WIP extraction feature 2025-11-20 21:17:06 -06:00
stopflock
c77ea96eaf Move OSM account settings and upload queue into their own sections, add "see my edits" button 2025-11-20 20:54:16 -06:00
stopflock
813a0f06da Change prox alerts default and max distance 2025-11-20 20:08:10 -06:00
stopflock
3fc74df616 update roadmap 2025-11-20 14:54:57 -06:00
stopflock
95fad14261 min zoom 1, max cameras 8, extract node from way 2025-11-19 22:46:09 -06:00
stopflock
1ac43b0c4e Only show appropriate external editors on each platform, redirect to appstore on error 2025-11-19 19:50:39 -06:00
stopflock
3174e0bfe1 Adjust gesture thresholds 2025-11-19 16:24:51 -06:00
stopflock
5404daa704 Gesture race! 2025-11-19 14:24:51 -06:00
stopflock
20870623f0 Compass adjust for search box 2025-11-19 13:36:04 -06:00
stopflock
8ed92dcd7e Home screen respect safe areas in all orientations 2025-11-19 13:32:40 -06:00
stopflock
0143c74415 Reasonable size limits for tag text boxes in sheets 2025-11-18 16:21:31 -06:00
stopflock
6c53d988de Further improve tag views, implement upload queue pause toggle 2025-11-17 13:37:48 -06:00
stopflock
26cebcc60e Localizations for new features 2025-11-16 21:26:35 -06:00
stopflock
7c2b9ea087 Configurable max height for node tags box, localizations for new UX strings 2025-11-16 18:16:50 -06:00
stopflock
b2645f1341 Limit tag list size, make changelog use a list instead of \n, make links clickable in node tags 2025-11-16 17:30:24 -06:00
stopflock
05eedbb910 Link to OSM in node_details sheet. Add option to open node in other editors. 2025-11-16 16:45:54 -06:00
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
stopflock
4c0e3b822c De-vibe changelog 2025-11-14 12:34:07 -06:00
stopflock
181852766a update TODOs 2025-11-14 11:46:16 -06:00
stopflock
f108929dce Always show add/cycle/delete direction buttons 2025-11-13 20:17:29 -06:00
stopflock
2cf840e74d Improvements to suspected locations 2025-11-13 13:22:46 -06:00
stopflock
3810dfa8d2 configurable button width, always enable network status indicator, new version migration logic available through changelog_service 2025-11-12 15:53:14 -06:00
stopflock
d57b2f64b1 Bump version 2025-11-09 14:32:46 -06:00
stopflock
e45f10e496 Make more room for direction slider next to add/remove/cycle buttons 2025-11-09 14:32:05 -06:00
stopflock
4ae0737016 Fix upload queue view for multi-direction submissions 2025-11-09 14:31:58 -06:00
stopflock
ae93cff719 Same as prev - forgot dev_config 2025-11-09 13:59:05 -06:00
stopflock
abdd494727 Give up on configurable tap+drag zoom. Breaks double tap zoom. 2025-11-09 13:59:05 -06:00
stopflock
4ccf3cace3 Wrap a few things trying to prevent UI / main thread hang we saw one time 2025-11-09 13:59:05 -06:00
stopflock
ca049033e4 Merge pull request #23 from Pugsrgreat/main
Added App Store + Google Play embeds to readme
2025-11-09 09:20:09 -06:00
Pugsrgreat
5cf8bb7725 Revise app store links and badges in README
Updated app store links and images in README.
2025-11-09 09:55:08 -05:00
Pugsrgreat
e5ff4ac233 Update README.md 2025-11-09 09:51:37 -05:00
Pugsrgreat
4040429865 Add files via upload 2025-11-09 09:37:53 -05:00
Pugsrgreat
90b7783aaf Add app image link to README
Added an image link to the README for the app.
2025-11-09 09:22:34 -05:00
stopflock
65cc6747bf bump version 2025-11-07 15:45:16 -06:00
stopflock
5bd450eb14 Fix setting integers in settings on iOS 2025-11-07 15:45:09 -06:00
stopflock
b0a4128bb7 Configurable zoom behaviors, desensitize double tap + drag 2025-11-07 14:29:08 -06:00
stopflock
4cdbb9f404 iOS location message accuracy 2025-11-07 14:26:28 -06:00
Pugsrgreat
8d05406ef5 Add Google Play Store link to README
Added information about Google Play Store availability.
2025-11-05 19:47:58 -05:00
69 changed files with 3652 additions and 737 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

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

@@ -6,6 +6,14 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
**For complete documentation, tutorials, and community info, visit [deflock.me](https://deflock.me)**
<a href="https://apps.apple.com/us/app/deflock-me/id6752760780" style="display: inline-block;">
<img src="https://toolbox.marketingtools.apple.com/api/v2/badges/download-on-the-app-store/black/en-us?releaseDate=1695859200" alt="Download on the App Store" style="width: 246px; height: 82px; vertical-align: middle; object-fit: contain;" />
</a>
<a href="https://play.google.com/store/apps/details?id=me.deflock.deflockapp" style="display: inline-block;">
<img src="assets/GetItOnGooglePlay_Badge_Web_color_English.png" alt="Download on the Google Play Store" style="width: 246px; height: 82px; vertical-align: middle; object-fit: contain;" />
</a>
---
## What This App Does
@@ -72,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
@@ -82,29 +91,43 @@ 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
### Needed Bugfixes
- Update node cache to reflect cleared queue entries
- Improve/retune tile fetching backoff/retry
- Are offline areas preferred for fast loading even when online? Check working.
- Fix network indicator - only done when fetch queue is empty!
### Recently Completed
- **Multi-direction support**: Devices can now have multiple viewing directions (e.g., "90;180") with individual FOV cones
- **Dynamic suspected location fields**: Server-controlled field display for suspected locations data
### Current Development
- Decide what to do for extracting nodes attached to a way/relation:
- Auto extract (how?)
- Leave it alone (wrong answer unless user chooses intentionally)
- Manual cleanup (cognitive load for users)
- Delete the old one (also wrong answer unless user chooses intentionally)
- Give multiple of these options??
- Nav start+end too close together error (warning + disable submit button?)
- Add some builtin satellite tile provider
- Persistent cache for MY submissions: assume submissions worked, cache,clean up when we see that node appear in overpass/OSM results or when older than 24h
- Dropdown on "refine tags" page to select acceptable options for camera:mount= (is this a boolean property of a profile?)
- Tutorial / info guide before submitting first node, info and links before creating first profile
- Option to pull in profiles from NSI (man_made=surveillance only?)
### On Pause
- Suspected locations expansion to more regions
- Import/Export map providers
- Swap in alprwatch.org/directions avoidance routing API
- Clean cache when nodes have disappeared / been deleted by others / queue item was deleted
- Improve offline area node refresh live display
- Add Rekor profile
### Future Features & Wishlist
- Update offline area nodes while browsing?
- Offline navigation (pending vector map tiles)
- Suspected locations expansion to more regions
- Android Auto / CarPlay
### Maybes
- Yellow ring for devices missing specific tag details

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@@ -1,20 +1,124 @@
{
"1.4.5": {
"content": [
"• NEW: Minimum zoom level (Z15) enforced for adding and editing surveillance nodes to ensure precise positioning",
"• NEW: Minimum zoom level (Z10) enforced for offline area downloads to prevent insanely large areas",
"• IMPROVED: Offline area download confirmation now shows as popup with 'View Progress in Settings' button instead of snackbar"
]
},
"1.4.4": {
"content": [
"• FOV range notation parsing - now supports OSM data like '90-270' (180° FOV centered at 180°)",
"• Complex range notation support: 'ESE;90-125;290' displays multiple FOV cones correctly",
"• Profiles now support optional specific FOV values",
"• Smart cone rendering - variable FOV widths, 360° cameras show full circles"
]
},
"1.4.3": {
"content": [
"• NEW: Proximity warning when placing nodes too close together - prevents accidental duplicate submissions"
]
},
"1.4.2": {
"content": [
"• NEW: Dedicated 'Upload Queue' page - queue items are now shown in a proper list view instead of a popup",
"• NEW: 'OpenStreetMap Account' page for managing OSM login and account settings",
"• NEW: 'View My Edits on OSM' button takes you directly to your edit history on OpenStreetMap"
]
},
"1.4.1": {
"content": [
"• NEW: 'Extract node from way/relation' option for constrained nodes (currently disabled while we decide what that means)"
]
},
"1.4.0": {
"content": [
"• IMPROVED: Advanced editing options now only show apps available on your platform (iOS/Android)",
"• Supported editors: Vespucci (Android), StreetComplete (Android), EveryDoor (both), Go Map!! (iOS)",
"• Web editors (iD, RapiD) remain available on all platforms as before"
]
},
"1.3.4": {
"content": [
"• NEW: 'Pause Upload Queue' toggle in Offline Settings - stops uploads while keeping live data access",
"• Useful for metered connections or when you want to batch uploads later",
"• FIXED: Sheets now resize when rotating between orientations"
]
},
"1.3.3": {
"content": [
"• UX: Edits re-enabled. Only nodes which are part of ways/relations cannot be moved",
"• NEW: Added builtin surveillance device profiles for Rekor and Axis Communications ALPR cameras",
"• NEW: Advanced editing options - access iD Editor, RapiD, Vespucci, StreetComplete, and other OSM editors",
"• NEW: 'View on OSM' links to see nodes directly on OpenStreetMap website",
"• UX: Auto-clickable URLs in all tag values - any URL becomes a tappable link",
"• UX: Tag lists now scroll with max height to keep buttons and map visible"
]
},
"1.3.2": {
"content": [
"• HOTFIX: Temporarily disabled node editing to prevent OSM database issues while a bug is resolved",
"• UX: Fixed Android navigation bar covering settings page content"
]
},
"1.3.1": {
"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

@@ -25,7 +25,7 @@
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSLocationWhenInUseUsageDescription</key>
<string>This app needs your location to show nearby cameras.</string>
<string>This app optionally uses your location to show nearby cameras by centering the map on your location.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>This app optionally uses your location to center the map on your current position and provide proximity alerts for nearby surveillance devices. These features are entirely optional.</string>
<key>UILaunchStoryboardName</key>

View File

@@ -17,6 +17,8 @@ import 'services/changelog_service.dart';
import 'services/operator_profile_service.dart';
import 'services/profile_service.dart';
import 'widgets/camera_provider_with_cache.dart';
import 'widgets/proximity_warning_dialog.dart';
import 'dev_config.dart';
import 'state/auth_state.dart';
import 'state/navigation_state.dart';
import 'state/operator_profile_state.dart';
@@ -130,6 +132,7 @@ class AppState extends ChangeNotifier {
// Settings state
bool get offlineMode => _settingsState.offlineMode;
bool get pauseQueueProcessing => _settingsState.pauseQueueProcessing;
int get maxCameras => _settingsState.maxCameras;
UploadMode get uploadMode => _settingsState.uploadMode;
FollowMeMode get followMeMode => _settingsState.followMeMode;
@@ -276,14 +279,21 @@ class AppState extends ChangeNotifier {
NodeProfile? profile,
OperatorProfile? operatorProfile,
LatLng? target,
bool? extractFromWay,
}) {
_sessionState.updateEditSession(
directionDeg: directionDeg,
profile: profile,
operatorProfile: operatorProfile,
target: target,
extractFromWay: extractFromWay,
);
}
// For map view to check for pending snap backs
LatLng? consumePendingSnapBack() {
return _sessionState.consumePendingSnapBack();
}
void addDirection() {
_sessionState.addDirection();
@@ -411,6 +421,15 @@ class AppState extends ChangeNotifier {
}
}
Future<void> setPauseQueueProcessing(bool enabled) async {
await _settingsState.setPauseQueueProcessing(enabled);
if (!enabled) {
_startUploader(); // Resume upload queue processing
} else {
_uploadQueueState.stopUploader(); // Stop uploader when paused
}
}
set maxCameras(int n) {
_settingsState.maxCameras = n;
}
@@ -485,10 +504,8 @@ class AppState extends ChangeNotifier {
await _suspectedLocationState.setEnabled(enabled);
}
Future<bool> refreshSuspectedLocations({
void Function(String message, double? progress)? onProgress,
}) async {
return await _suspectedLocationState.refreshData(onProgress: onProgress);
Future<bool> refreshSuspectedLocations() async {
return await _suspectedLocationState.refreshData();
}
void selectSuspectedLocation(SuspectedLocation location) {
@@ -526,6 +543,7 @@ class AppState extends ChangeNotifier {
void _startUploader() {
_uploadQueueState.startUploader(
offlineMode: offlineMode,
pauseQueueProcessing: pauseQueueProcessing,
uploadMode: uploadMode,
getAccessToken: _authState.getAccessToken,
);

View File

@@ -34,14 +34,27 @@ double bottomPositionFromButtonBar(double spacingAboveButtonBar, double safeArea
return safeAreaBottom + kBottomButtonBarOffset + kButtonBarHeight + spacingAboveButtonBar;
}
// Helper to get left positioning that accounts for safe area (for landscape mode)
double leftPositionWithSafeArea(double baseLeft, EdgeInsets safeArea) {
return baseLeft + safeArea.left;
}
// Helper to get right positioning that accounts for safe area (for landscape mode)
double rightPositionWithSafeArea(double baseRight, EdgeInsets safeArea) {
return baseRight + safeArea.right;
}
// Helper to get top positioning that accounts for safe area
double topPositionWithSafeArea(double baseTop, EdgeInsets safeArea) {
return baseTop + safeArea.top;
}
// Client name for OSM uploads ("created_by" tag)
const String kClientName = 'DeFlock';
// Note: Version is now dynamically retrieved from VersionService
// Suspected locations CSV URL
const String kSuspectedLocationsCsvUrl = 'https://stopflock.com/app/flock_utilities_mini_latest.csv';
const String kSuspectedLocationsCsvUrl = 'https://alprwatch.org/suspected-locations/deflock-latest.csv';
// Development/testing features - set to false for production builds
const bool kEnableDevelopmentModes = false; // Set to false to hide sandbox/simulate modes and force production mode
@@ -49,6 +62,12 @@ const bool kEnableDevelopmentModes = false; // Set to false to hide sandbox/simu
// Navigation features - set to false to hide navigation UI elements while in development
const bool kEnableNavigationFeatures = kEnableDevelopmentModes; // Hide navigation until fully implemented
// Node editing features - set to false to temporarily disable editing
const bool kEnableNodeEdits = true; // Set to false to temporarily disable node editing
// Node extraction features - set to false to hide extract functionality for constrained nodes
const bool kEnableNodeExtraction = false; // Set to true to enable extract from way/relation feature (WIP)
/// Navigation availability: only dev builds, and only when online
bool enableNavigationFeatures({required bool offlineMode}) {
if (!kEnableDevelopmentModes) {
@@ -61,6 +80,8 @@ bool enableNavigationFeatures({required bool offlineMode}) {
// Marker/node interaction
const int kNodeMinZoomLevel = 10; // Minimum zoom to show nodes (Overpass)
const int kOsmApiMinZoomLevel = 13; // Minimum zoom for OSM API bbox queries (sandbox mode)
const int kMinZoomForNodeEditingSheets = 15; // Minimum zoom to open add/edit node sheets
const int kMinZoomForOfflineDownload = 10; // Minimum zoom to download offline areas (prevents large area crashes)
const Duration kMarkerTapTimeout = Duration(milliseconds: 250);
const Duration kDebounceCameraRefresh = Duration(milliseconds: 500);
@@ -76,12 +97,33 @@ const int kDataRefreshIntervalSeconds = 60; // Refresh cached data after this ma
const Duration kFollowMeAnimationDuration = Duration(milliseconds: 600);
const double kMinSpeedForRotationMps = 1.0; // Minimum speed (m/s) to apply rotation
// Sheet content configuration
const double kMaxTagListHeightRatioPortrait = 0.3; // Maximum height for tag lists in portrait mode
const double kMaxTagListHeightRatioLandscape = 0.2; // Maximum height for tag lists in landscape mode
/// Get appropriate tag list height ratio based on screen orientation
double getTagListHeightRatio(BuildContext context) {
final size = MediaQuery.of(context).size;
final isLandscape = size.width > size.height;
return isLandscape ? kMaxTagListHeightRatioLandscape : kMaxTagListHeightRatioPortrait;
}
// Proximity alerts configuration
const int kProximityAlertDefaultDistance = 200; // meters
const int kProximityAlertDefaultDistance = 400; // meters
const int kProximityAlertMinDistance = 50; // meters
const int kProximityAlertMaxDistance = 1000; // meters
const int kProximityAlertMaxDistance = 1600; // meters
const Duration kProximityAlertCooldown = Duration(minutes: 10); // Cooldown between alerts for same node
// Node proximity warning configuration (for new/edited nodes that are too close to existing ones)
const double kNodeProximityWarningDistance = 15.0; // meters - distance threshold to show warning
// Map interaction configuration
const double kNodeDoubleTapZoomDelta = 1.0; // How much to zoom in when double-tapping nodes (was 1.0)
const double kScrollWheelVelocity = 0.01; // Mouse scroll wheel zoom speed (default 0.005)
const double kPinchZoomThreshold = 0.2; // How much pinch required to start zoom (reduced for gesture race)
const double kPinchMoveThreshold = 30.0; // How much drag required for two-finger pan (default 40.0)
const double kRotationThreshold = 6.0; // Degrees of rotation required before map actually rotates (Google Maps style)
// Tile fetch retry parameters (configurable backoff system)
const int kTileFetchMaxAttempts = 16; // Number of retry attempts before giving up
const int kTileFetchInitialDelayMs = 500; // Base delay for first retry (1 second)
@@ -108,6 +150,10 @@ const Color kNodeRingColorEditing = Color(0xD0FF9800); // Node being edited - or
const Color kNodeRingColorPendingEdit = Color(0xD0757575); // Original node with pending edit - grey
const Color kNodeRingColorPendingDeletion = Color(0xC0F44336); // Node pending deletion - red, slightly transparent
// Direction slider control buttons configuration
const double kDirectionButtonMinWidth = 22.0;
const double kDirectionButtonMinHeight = 32.0;
// Helper functions for pixel-ratio scaling
double getDirectionConeBorderWidth(BuildContext context) {
// return _kDirectionConeBorderWidthBase * MediaQuery.of(context).devicePixelRatio;

View File

@@ -16,7 +16,28 @@
"close": "Schließen",
"submit": "Senden",
"saveEdit": "Bearbeitung Speichern",
"clear": "Löschen"
"clear": "Löschen",
"viewOnOSM": "Auf OSM anzeigen",
"advanced": "Erweitert",
"useAdvancedEditor": "Erweiterten Editor verwenden"
},
"proximityWarning": {
"title": "Knoten sehr nah an vorhandenem Gerät",
"message": "Dieser Knoten ist nur {} Meter von einem vorhandenen Überwachungsgerät entfernt.",
"suggestion": "Wenn mehrere Geräte am selben Mast sind, verwenden Sie bitte mehrere Richtungen auf einem einzigen Knoten, anstatt separate Knoten zu erstellen.",
"nearbyNodes": "Nahegelegene Gerät(e) gefunden ({}):",
"nodeInfo": "Knoten #{} - {}",
"andMore": "...und {} weitere",
"goBack": "Zurück",
"submitAnyway": "Trotzdem senden",
"nodeType": {
"alpr": "ALPR/ANPR Kamera",
"publicCamera": "Öffentliche Überwachungskamera",
"camera": "Überwachungskamera",
"amenity": "{}",
"device": "{} Gerät",
"unknown": "Unbekanntes Gerät"
}
},
"followMe": {
"off": "Verfolgung aktivieren",
@@ -36,6 +57,8 @@
"maxNodesWarning": "Sie möchten das wahrscheinlich nicht tun, es sei denn, Sie sind absolut sicher, dass Sie einen guten Grund dafür haben.",
"offlineMode": "Offline-Modus",
"offlineModeSubtitle": "Alle Netzwerkanfragen außer für lokale/Offline-Bereiche deaktivieren.",
"pauseQueueProcessing": "Upload-Warteschlange pausieren",
"pauseQueueProcessingSubtitle": "Upload von wartenden Änderungen stoppen, aber Live-Datenzugriff beibehalten.",
"offlineModeWarningTitle": "Aktive Downloads",
"offlineModeWarningMessage": "Die Aktivierung des Offline-Modus bricht alle aktiven Bereichsdownloads ab. Möchten Sie fortfahren?",
"enableOfflineMode": "Offline-Modus Aktivieren",
@@ -89,10 +112,15 @@
"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.",
"zoomInRequiredMessage": "Zoomen Sie auf mindestens Stufe {} heran, um Überwachungsknoten hinzuzufügen oder zu bearbeiten. Dies gewährleistet eine präzise Positionierung für genaues Kartieren.",
"extractFromWay": "Knoten aus Weg/Relation extrahieren",
"extractFromWaySubtitle": "Neuen Knoten mit gleichen Tags erstellen, Verschieben an neuen Ort ermöglichen",
"refineTags": "Tags Verfeinern",
"refineTagsWithProfile": "Tags Verfeinern ({})"
},
@@ -106,9 +134,16 @@
"withinTileLimit": "Innerhalb {} Kachel-Limit",
"exceedsTileLimit": "Aktuelle Auswahl überschreitet {} Kachel-Limit",
"offlineModeWarning": "Downloads im Offline-Modus deaktiviert. Deaktivieren Sie den Offline-Modus, um neue Bereiche herunterzuladen.",
"areaTooBigMessage": "Zoomen Sie auf mindestens Stufe {} heran, um Offline-Bereiche herunterzuladen. Downloads großer Gebiete können die App zum Absturz bringen.",
"downloadStarted": "Download gestartet! Lade Kacheln und Knoten...",
"downloadFailed": "Download konnte nicht gestartet werden: {}"
},
"downloadStarted": {
"title": "Download gestartet",
"message": "Download gestartet! Lade Kacheln und Knoten...",
"ok": "OK",
"viewProgress": "Fortschritt in Einstellungen anzeigen"
},
"uploadMode": {
"title": "Upload-Ziel",
"subtitle": "Wählen Sie, wohin Kameras hochgeladen werden",
@@ -120,6 +155,8 @@
"simulateDescription": "Uploads simulieren (kontaktiert OSM-Server nicht)"
},
"auth": {
"osmAccountTitle": "OpenStreetMap-Konto",
"osmAccountSubtitle": "Ihr OSM-Login verwalten und Ihre Beiträge einsehen",
"loggedInAs": "Angemeldet als {}",
"loginToOSM": "Bei OpenStreetMap anmelden",
"tapToLogout": "Zum Abmelden antippen",
@@ -129,6 +166,11 @@
"testConnectionSubtitle": "OSM-Anmeldedaten überprüfen",
"connectionOK": "Verbindung OK - Anmeldedaten sind gültig",
"connectionFailed": "Verbindung fehlgeschlagen - bitte erneut anmelden",
"viewMyEdits": "Meine Änderungen bei OSM Anzeigen",
"viewMyEditsSubtitle": "Ihr Bearbeitungsverlauf bei OpenStreetMap einsehen",
"aboutOSM": "Über OpenStreetMap",
"aboutOSMDescription": "OpenStreetMap ist ein gemeinschaftliches Open-Source-Kartenprojekt, bei dem Mitwirkende eine kostenlose, bearbeitbare Karte der Welt erstellen und pflegen. Ihre Beiträge zu Überwachungsgeräten helfen dabei, diese Infrastruktur sichtbar und durchsuchbar zu machen.",
"visitOSM": "OpenStreetMap Besuchen",
"deleteAccount": "OSM-Konto Löschen",
"deleteAccountSubtitle": "Ihr OpenStreetMap-Konto verwalten",
"deleteAccountExplanation": "Um Ihr OpenStreetMap-Konto zu löschen, müssen Sie die OpenStreetMap-Website besuchen. Dies entfernt dauerhaft Ihr OSM-Konto und alle zugehörigen Daten.",
@@ -136,7 +178,11 @@
"goToOSM": "Zu OpenStreetMap gehen"
},
"queue": {
"title": "Upload-Warteschlange",
"subtitle": "Ausstehende Überwachungsgeräte-Uploads verwalten",
"pendingUploads": "Ausstehende Uploads: {}",
"pendingItemsCount": "Ausstehende Elemente: {}",
"nothingInQueue": "Warteschlange ist leer",
"simulateModeEnabled": "Simulationsmodus aktiviert Uploads simuliert",
"sandboxMode": "Sandbox-Modus Uploads gehen an OSM Sandbox",
"tapToViewQueue": "Zum Anzeigen der Warteschlange antippen",
@@ -227,6 +273,10 @@
"profileNameRequired": "Profil-Name ist erforderlich",
"requiresDirection": "Benötigt Richtung",
"requiresDirectionSubtitle": "Ob Kameras dieses Typs ein Richtungs-Tag benötigen",
"fov": "Sichtfeld",
"fovHint": "Sichtfeld in Grad (leer lassen für Standard)",
"fovSubtitle": "Kamera-Sichtfeld - verwendet für Kegelbreite und Bereichsübertragungsformat",
"fovInvalid": "Sichtfeld muss zwischen 1 und 360 Grad liegen",
"submittable": "Übertragbar",
"submittableSubtitle": "Ob dieses Profil für Kamera-Übertragungen verwendet werden kann",
"osmTags": "OSM-Tags",
@@ -304,9 +354,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,28 @@
"close": "Close",
"submit": "Submit",
"saveEdit": "Save Edit",
"clear": "Clear"
"clear": "Clear",
"viewOnOSM": "View on OSM",
"advanced": "Advanced",
"useAdvancedEditor": "Use Advanced Editor"
},
"proximityWarning": {
"title": "Node Very Close to Existing Device",
"message": "This node is only {} meters from an existing surveillance device.",
"suggestion": "If multiple devices are on the same pole, please use multiple directions on a single node instead of creating separate nodes.",
"nearbyNodes": "Nearby device(s) found ({}):",
"nodeInfo": "Node #{} - {}",
"andMore": "...and {} more",
"goBack": "Go Back",
"submitAnyway": "Submit Anyway",
"nodeType": {
"alpr": "ALPR/ANPR Camera",
"publicCamera": "Public Surveillance Camera",
"camera": "Surveillance Camera",
"amenity": "{}",
"device": "{} Device",
"unknown": "Unknown Device"
}
},
"followMe": {
"off": "Enable follow-me",
@@ -54,6 +75,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 +130,15 @@
"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.",
"zoomInRequiredMessage": "Zoom in to at least level {} to add or edit surveillance nodes. This ensures precise positioning for accurate mapping.",
"extractFromWay": "Extract node from way/relation",
"extractFromWaySubtitle": "Create new node with same tags, allow moving to new location",
"refineTags": "Refine Tags",
"refineTagsWithProfile": "Refine Tags ({})"
},
@@ -124,9 +152,16 @@
"withinTileLimit": "Within {} tile limit",
"exceedsTileLimit": "Current selection exceeds {} tile limit",
"offlineModeWarning": "Downloads disabled while in offline mode. Disable offline mode to download new areas.",
"areaTooBigMessage": "Zoom in to at least level {} to download offline areas. Large area downloads can cause the app to become unresponsive.",
"downloadStarted": "Download started! Fetching tiles and nodes...",
"downloadFailed": "Failed to start download: {}"
},
"downloadStarted": {
"title": "Download Started",
"message": "Download started! Fetching tiles and nodes...",
"ok": "OK",
"viewProgress": "View Progress in Settings"
},
"uploadMode": {
"title": "Upload Destination",
"subtitle": "Choose where cameras are uploaded",
@@ -138,6 +173,8 @@
"simulateDescription": "Simulate uploads (does not contact OSM servers)"
},
"auth": {
"osmAccountTitle": "OpenStreetMap Account",
"osmAccountSubtitle": "Manage your OSM login and view your contributions",
"loggedInAs": "Logged in as {}",
"loginToOSM": "Log in to OpenStreetMap",
"tapToLogout": "Tap to logout",
@@ -147,6 +184,11 @@
"testConnectionSubtitle": "Verify OSM credentials are working",
"connectionOK": "Connection OK - credentials are valid",
"connectionFailed": "Connection failed - please re-login",
"viewMyEdits": "View My Edits on OSM",
"viewMyEditsSubtitle": "See your edit history on OpenStreetMap",
"aboutOSM": "About OpenStreetMap",
"aboutOSMDescription": "OpenStreetMap is a collaborative, open-source mapping project where contributors create and maintain a free, editable map of the world. Your surveillance device contributions help make this infrastructure visible and searchable.",
"visitOSM": "Visit OpenStreetMap",
"deleteAccount": "Delete OSM Account",
"deleteAccountSubtitle": "Manage your OpenStreetMap account",
"deleteAccountExplanation": "To delete your OpenStreetMap account, you'll need to visit the OpenStreetMap website. This will permanently remove your OSM account and all associated data.",
@@ -154,7 +196,11 @@
"goToOSM": "Go to OpenStreetMap"
},
"queue": {
"title": "Upload Queue",
"subtitle": "Manage pending surveillance device uploads",
"pendingUploads": "Pending uploads: {}",
"pendingItemsCount": "Pending Items: {}",
"nothingInQueue": "Nothing in queue",
"simulateModeEnabled": "Simulate mode enabled uploads simulated",
"sandboxMode": "Sandbox mode uploads go to OSM Sandbox",
"tapToViewQueue": "Tap to view queue",
@@ -245,6 +291,10 @@
"profileNameRequired": "Profile name is required",
"requiresDirection": "Requires Direction",
"requiresDirectionSubtitle": "Whether cameras of this type need a direction tag",
"fov": "Field of View",
"fovHint": "FOV in degrees (leave empty for default)",
"fovSubtitle": "Camera field of view - used for cone width and range submission format",
"fovInvalid": "FOV must be between 1 and 360 degrees",
"submittable": "Submittable",
"submittableSubtitle": "Whether this profile can be used for camera submissions",
"osmTags": "OSM Tags",
@@ -322,9 +372,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,28 @@
"close": "Cerrar",
"submit": "Enviar",
"saveEdit": "Guardar Edición",
"clear": "Limpiar"
"clear": "Limpiar",
"viewOnOSM": "Ver en OSM",
"advanced": "Avanzado",
"useAdvancedEditor": "Usar Editor Avanzado"
},
"proximityWarning": {
"title": "Nodo Muy Cerca de Dispositivo Existente",
"message": "Este nodo está a solo {} metros de un dispositivo de vigilancia existente.",
"suggestion": "Si hay múltiples dispositivos en el mismo poste, use múltiples direcciones en un solo nodo en lugar de crear nodos separados.",
"nearbyNodes": "Dispositivo(s) cercano(s) encontrado(s) ({}):",
"nodeInfo": "Nodo #{} - {}",
"andMore": "...y {} más",
"goBack": "Volver",
"submitAnyway": "Enviar de Todas Formas",
"nodeType": {
"alpr": "Cámara ALPR/ANPR",
"publicCamera": "Cámara de Vigilancia Pública",
"camera": "Cámara de Vigilancia",
"amenity": "{}",
"device": "Dispositivo {}",
"unknown": "Dispositivo Desconocido"
}
},
"followMe": {
"off": "Activar seguimiento",
@@ -54,6 +75,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 +130,15 @@
"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.",
"zoomInRequiredMessage": "Amplíe al menos al nivel {} para agregar o editar nodos de vigilancia. Esto garantiza un posicionamiento preciso para un mapeo exacto.",
"extractFromWay": "Extraer nodo de way/relation",
"extractFromWaySubtitle": "Crear nuevo nodo con las mismas etiquetas, permitir mover a nueva ubicación",
"refineTags": "Refinar Etiquetas",
"refineTagsWithProfile": "Refinar Etiquetas ({})"
},
@@ -124,9 +152,16 @@
"withinTileLimit": "Dentro del límite de {} mosaicos",
"exceedsTileLimit": "La selección actual excede el límite de {} mosaicos",
"offlineModeWarning": "Descargas deshabilitadas en modo sin conexión. Deshabilite el modo sin conexión para descargar nuevas áreas.",
"areaTooBigMessage": "Amplíe al menos al nivel {} para descargar áreas sin conexión. Las descargas de áreas grandes pueden hacer que la aplicación deje de responder.",
"downloadStarted": "¡Descarga iniciada! Obteniendo mosaicos y nodos...",
"downloadFailed": "Error al iniciar la descarga: {}"
},
"downloadStarted": {
"title": "Descarga Iniciada",
"message": "¡Descarga iniciada! Obteniendo mosaicos y nodos...",
"ok": "OK",
"viewProgress": "Ver Progreso en Configuración"
},
"uploadMode": {
"title": "Destino de Subida",
"subtitle": "Elige dónde se suben las cámaras",
@@ -138,6 +173,8 @@
"simulateDescription": "Simular subidas (no contacta servidores OSM)"
},
"auth": {
"osmAccountTitle": "Cuenta de OpenStreetMap",
"osmAccountSubtitle": "Gestionar tu login de OSM y ver tus contribuciones",
"loggedInAs": "Conectado como {}",
"loginToOSM": "Iniciar sesión en OpenStreetMap",
"tapToLogout": "Toque para cerrar sesión",
@@ -147,6 +184,11 @@
"testConnectionSubtitle": "Verificar que las credenciales de OSM funcionen",
"connectionOK": "Conexión OK - las credenciales son válidas",
"connectionFailed": "Conexión falló - por favor, inicie sesión nuevamente",
"viewMyEdits": "Ver Mis Ediciones en OSM",
"viewMyEditsSubtitle": "Ver tu historial de ediciones en OpenStreetMap",
"aboutOSM": "Acerca de OpenStreetMap",
"aboutOSMDescription": "OpenStreetMap es un proyecto de mapeo colaborativo de código abierto donde los contribuyentes crean y mantienen un mapa gratuito y editable del mundo. Tus contribuciones de dispositivos de vigilancia ayudan a hacer visible y buscable esta infraestructura.",
"visitOSM": "Visitar OpenStreetMap",
"deleteAccount": "Eliminar Cuenta OSM",
"deleteAccountSubtitle": "Gestiona tu cuenta de OpenStreetMap",
"deleteAccountExplanation": "Para eliminar tu cuenta de OpenStreetMap, necesitarás visitar el sitio web de OpenStreetMap. Esto eliminará permanentemente tu cuenta OSM y todos los datos asociados.",
@@ -154,7 +196,11 @@
"goToOSM": "Ir a OpenStreetMap"
},
"queue": {
"title": "Cola de Subida",
"subtitle": "Gestionar subidas pendientes de dispositivos de vigilancia",
"pendingUploads": "Subidas pendientes: {}",
"pendingItemsCount": "Elementos Pendientes: {}",
"nothingInQueue": "No hay nada en la cola",
"simulateModeEnabled": "Modo simulación activado subidas simuladas",
"sandboxMode": "Modo sandbox subidas van al Sandbox OSM",
"tapToViewQueue": "Toque para ver cola",
@@ -245,6 +291,10 @@
"profileNameRequired": "El nombre del perfil es requerido",
"requiresDirection": "Requiere Dirección",
"requiresDirectionSubtitle": "Si las cámaras de este tipo necesitan una etiqueta de dirección",
"fov": "Campo de Visión",
"fovHint": "Campo de visión en grados (dejar vacío para el predeterminado)",
"fovSubtitle": "Campo de visión de la cámara - usado para el ancho del cono y formato de envío por rango",
"fovInvalid": "El campo de visión debe estar entre 1 y 360 grados",
"submittable": "Envíable",
"submittableSubtitle": "Si este perfil puede usarse para envíos de cámaras",
"osmTags": "Etiquetas OSM",
@@ -322,9 +372,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,28 @@
"close": "Fermer",
"submit": "Soumettre",
"saveEdit": "Sauvegarder Modification",
"clear": "Effacer"
"clear": "Effacer",
"viewOnOSM": "Voir sur OSM",
"advanced": "Avancé",
"useAdvancedEditor": "Utiliser l'Éditeur Avancé"
},
"proximityWarning": {
"title": "Nœud Très Proche d'un Dispositif Existant",
"message": "Ce nœud n'est qu'à {} mètres d'un dispositif de surveillance existant.",
"suggestion": "Si plusieurs dispositifs se trouvent sur le même poteau, veuillez utiliser plusieurs directions sur un seul nœud au lieu de créer des nœuds séparés.",
"nearbyNodes": "Dispositif(s) proche(s) trouvé(s) ({}) :",
"nodeInfo": "Nœud #{} - {}",
"andMore": "...et {} de plus",
"goBack": "Retour",
"submitAnyway": "Soumettre Quand Même",
"nodeType": {
"alpr": "Caméra ALPR/ANPR",
"publicCamera": "Caméra de Surveillance Publique",
"camera": "Caméra de Surveillance",
"amenity": "{}",
"device": "Dispositif {}",
"unknown": "Dispositif Inconnu"
}
},
"followMe": {
"off": "Activer le suivi",
@@ -54,6 +75,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 +130,15 @@
"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.",
"zoomInRequiredMessage": "Zoomez au moins au niveau {} pour ajouter ou modifier des nœuds de surveillance. Cela garantit un positionnement précis pour une cartographie exacte.",
"extractFromWay": "Extraire le nœud du way/relation",
"extractFromWaySubtitle": "Créer un nouveau nœud avec les mêmes balises, permettre le déplacement vers un nouvel emplacement",
"refineTags": "Affiner Balises",
"refineTagsWithProfile": "Affiner Balises ({})"
},
@@ -124,9 +152,16 @@
"withinTileLimit": "Dans la limite de {} tuiles",
"exceedsTileLimit": "La sélection actuelle dépasse la limite de {} tuiles",
"offlineModeWarning": "Téléchargements désactivés en mode hors ligne. Désactivez le mode hors ligne pour télécharger de nouvelles zones.",
"downloadStarted": "Téléchargement démarré! Récupération des tuiles et nœuds...",
"areaTooBigMessage": "Zoomez au moins au niveau {} pour télécharger des zones hors ligne. Les téléchargements de grandes zones peuvent rendre l'application non réactive.",
"downloadStarted": "Téléchargement démarré ! Récupération des tuiles et nœuds...",
"downloadFailed": "Échec du démarrage du téléchargement: {}"
},
"downloadStarted": {
"title": "Téléchargement Démarré",
"message": "Téléchargement démarré! Récupération des tuiles et nœuds...",
"ok": "OK",
"viewProgress": "Voir le Progrès dans Paramètres"
},
"uploadMode": {
"title": "Destination de Téléchargement",
"subtitle": "Choisir où les caméras sont téléchargées",
@@ -138,6 +173,8 @@
"simulateDescription": "Simuler les téléchargements (ne contacte pas les serveurs OSM)"
},
"auth": {
"osmAccountTitle": "Compte OpenStreetMap",
"osmAccountSubtitle": "Gérer votre connexion OSM et voir vos contributions",
"loggedInAs": "Connecté en tant que {}",
"loginToOSM": "Se connecter à OpenStreetMap",
"tapToLogout": "Appuyer pour se déconnecter",
@@ -147,6 +184,11 @@
"testConnectionSubtitle": "Vérifier que les identifiants OSM fonctionnent",
"connectionOK": "Connexion OK - les identifiants sont valides",
"connectionFailed": "Connexion échouée - veuillez vous reconnecter",
"viewMyEdits": "Voir Mes Modifications sur OSM",
"viewMyEditsSubtitle": "Voir votre historique de modifications sur OpenStreetMap",
"aboutOSM": "À Propos d'OpenStreetMap",
"aboutOSMDescription": "OpenStreetMap est un projet cartographique collaboratif open source où les contributeurs créent et maintiennent une carte gratuite et modifiable du monde. Vos contributions de dispositifs de surveillance aident à rendre cette infrastructure visible et consultable.",
"visitOSM": "Visiter OpenStreetMap",
"deleteAccount": "Supprimer Compte OSM",
"deleteAccountSubtitle": "Gérez votre compte OpenStreetMap",
"deleteAccountExplanation": "Pour supprimer votre compte OpenStreetMap, vous devrez visiter le site web OpenStreetMap. Cela supprimera définitivement votre compte OSM et toutes les données associées.",
@@ -154,7 +196,11 @@
"goToOSM": "Aller à OpenStreetMap"
},
"queue": {
"title": "File de Téléchargement",
"subtitle": "Gérer les téléchargements de dispositifs de surveillance en attente",
"pendingUploads": "Téléchargements en attente: {}",
"pendingItemsCount": "Éléments en Attente: {}",
"nothingInQueue": "Rien dans la file",
"simulateModeEnabled": "Mode simulation activé téléchargements simulés",
"sandboxMode": "Mode sandbox téléchargements vont vers OSM Sandbox",
"tapToViewQueue": "Appuyer pour voir la file",
@@ -245,6 +291,10 @@
"profileNameRequired": "Le nom du profil est requis",
"requiresDirection": "Nécessite Direction",
"requiresDirectionSubtitle": "Si les caméras de ce type ont besoin d'une balise de direction",
"fov": "Champ de Vision",
"fovHint": "Champ de vision en degrés (laisser vide pour la valeur par défaut)",
"fovSubtitle": "Champ de vision de la caméra - utilisé pour la largeur du cône et le format de soumission par plage",
"fovInvalid": "Le champ de vision doit être entre 1 et 360 degrés",
"submittable": "Soumissible",
"submittableSubtitle": "Si ce profil peut être utilisé pour les soumissions de caméras",
"osmTags": "Balises OSM",
@@ -322,9 +372,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,28 @@
"close": "Chiudi",
"submit": "Invia",
"saveEdit": "Salva Modifica",
"clear": "Pulisci"
"clear": "Pulisci",
"viewOnOSM": "Visualizza su OSM",
"advanced": "Avanzato",
"useAdvancedEditor": "Usa Editor Avanzato"
},
"proximityWarning": {
"title": "Nodo Molto Vicino a Dispositivo Esistente",
"message": "Questo nodo è a soli {} metri da un dispositivo di sorveglianza esistente.",
"suggestion": "Se ci sono più dispositivi sullo stesso palo, utilizzare più direzioni su un singolo nodo invece di creare nodi separati.",
"nearbyNodes": "Dispositivo/i vicino/i trovato/i ({}):",
"nodeInfo": "Nodo #{} - {}",
"andMore": "...e altri {}",
"goBack": "Torna Indietro",
"submitAnyway": "Invia Comunque",
"nodeType": {
"alpr": "Telecamera ALPR/ANPR",
"publicCamera": "Telecamera di Sorveglianza Pubblica",
"camera": "Telecamera di Sorveglianza",
"amenity": "{}",
"device": "Dispositivo {}",
"unknown": "Dispositivo Sconosciuto"
}
},
"followMe": {
"off": "Attiva seguimi",
@@ -54,6 +75,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 +130,15 @@
"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.",
"zoomInRequiredMessage": "Ingrandisci almeno al livello {} per aggiungere o modificare nodi di sorveglianza. Questo garantisce un posizionamento preciso per una mappatura accurata.",
"extractFromWay": "Estrai nodo da way/relation",
"extractFromWaySubtitle": "Crea nuovo nodo con gli stessi tag, consenti spostamento in nuova posizione",
"refineTags": "Affina Tag",
"refineTagsWithProfile": "Affina Tag ({})"
},
@@ -124,9 +152,16 @@
"withinTileLimit": "Entro il limite di {} tile",
"exceedsTileLimit": "La selezione corrente supera il limite di {} tile",
"offlineModeWarning": "Download disabilitati in modalità offline. Disabilita la modalità offline per scaricare nuove aree.",
"areaTooBigMessage": "Ingrandisci almeno al livello {} per scaricare aree offline. I download di aree grandi possono rendere l'app non reattiva.",
"downloadStarted": "Download avviato! Recupero tile e nodi...",
"downloadFailed": "Impossibile avviare il download: {}"
},
"downloadStarted": {
"title": "Download Avviato",
"message": "Download avviato! Recupero tile e nodi...",
"ok": "OK",
"viewProgress": "Visualizza Progresso in Impostazioni"
},
"uploadMode": {
"title": "Destinazione Upload",
"subtitle": "Scegli dove vengono caricate le telecamere",
@@ -138,6 +173,8 @@
"simulateDescription": "Simula upload (non contatta i server OSM)"
},
"auth": {
"osmAccountTitle": "Account OpenStreetMap",
"osmAccountSubtitle": "Gestisci il tuo login OSM e visualizza i tuoi contributi",
"loggedInAs": "Loggato come {}",
"loginToOSM": "Accedi a OpenStreetMap",
"tapToLogout": "Tocca per disconnetterti",
@@ -147,6 +184,11 @@
"testConnectionSubtitle": "Verifica che le credenziali OSM funzionino",
"connectionOK": "Connessione OK - le credenziali sono valide",
"connectionFailed": "Connessione fallita - per favore accedi di nuovo",
"viewMyEdits": "Visualizza le Mie Modifiche su OSM",
"viewMyEditsSubtitle": "Visualizza la cronologia delle tue modifiche su OpenStreetMap",
"aboutOSM": "Informazioni su OpenStreetMap",
"aboutOSMDescription": "OpenStreetMap è un progetto cartografico collaborativo open source dove i contributori creano e mantengono una mappa gratuita e modificabile del mondo. I tuoi contributi sui dispositivi di sorveglianza aiutano a rendere visibile e ricercabile questa infrastruttura.",
"visitOSM": "Visita OpenStreetMap",
"deleteAccount": "Elimina Account OSM",
"deleteAccountSubtitle": "Gestisci il tuo account OpenStreetMap",
"deleteAccountExplanation": "Per eliminare il tuo account OpenStreetMap, dovrai visitare il sito web di OpenStreetMap. Questo rimuoverà permanentemente il tuo account OSM e tutti i dati associati.",
@@ -154,7 +196,11 @@
"goToOSM": "Vai a OpenStreetMap"
},
"queue": {
"title": "Coda di Upload",
"subtitle": "Gestisci gli upload di dispositivi di sorveglianza in sospeso",
"pendingUploads": "Upload in sospeso: {}",
"pendingItemsCount": "Elementi in Sospeso: {}",
"nothingInQueue": "Niente in coda",
"simulateModeEnabled": "Modalità simulazione abilitata upload simulati",
"sandboxMode": "Modalità sandbox upload vanno alla Sandbox OSM",
"tapToViewQueue": "Tocca per vedere la coda",
@@ -245,6 +291,10 @@
"profileNameRequired": "Il nome del profilo è obbligatorio",
"requiresDirection": "Richiede Direzione",
"requiresDirectionSubtitle": "Se le telecamere di questo tipo necessitano di un tag direzione",
"fov": "Campo Visivo",
"fovHint": "Campo visivo in gradi (lasciare vuoto per il valore predefinito)",
"fovSubtitle": "Campo visivo della telecamera - utilizzato per la larghezza del cono e il formato di invio per intervallo",
"fovInvalid": "Il campo visivo deve essere tra 1 e 360 gradi",
"submittable": "Inviabile",
"submittableSubtitle": "Se questo profilo può essere usato per invii di telecamere",
"osmTags": "Tag OSM",
@@ -322,9 +372,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,28 @@
"close": "Fechar",
"submit": "Enviar",
"saveEdit": "Salvar Edição",
"clear": "Limpar"
"clear": "Limpar",
"viewOnOSM": "Ver no OSM",
"advanced": "Avançado",
"useAdvancedEditor": "Usar Editor Avançado"
},
"proximityWarning": {
"title": "Nó Muito Próximo de Dispositivo Existente",
"message": "Este nó está a apenas {} metros de um dispositivo de vigilância existente.",
"suggestion": "Se vários dispositivos estão no mesmo poste, use várias direções em um único nó em vez de criar nós separados.",
"nearbyNodes": "Dispositivo(s) próximo(s) encontrado(s) ({}):",
"nodeInfo": "Nó #{} - {}",
"andMore": "...e mais {}",
"goBack": "Voltar",
"submitAnyway": "Enviar Mesmo Assim",
"nodeType": {
"alpr": "Câmera ALPR/ANPR",
"publicCamera": "Câmera de Vigilância Pública",
"camera": "Câmera de Vigilância",
"amenity": "{}",
"device": "Dispositivo {}",
"unknown": "Dispositivo Desconhecido"
}
},
"followMe": {
"off": "Ativar seguir-me",
@@ -54,6 +75,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 +130,15 @@
"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.",
"zoomInRequiredMessage": "Amplie para pelo menos o nível {} para adicionar ou editar nós de vigilância. Isto garante um posicionamento preciso para mapeamento exato.",
"extractFromWay": "Extrair nó do way/relation",
"extractFromWaySubtitle": "Criar novo nó com as mesmas tags, permitir mover para nova localização",
"refineTags": "Refinar Tags",
"refineTagsWithProfile": "Refinar Tags ({})"
},
@@ -124,9 +152,16 @@
"withinTileLimit": "Dentro do limite de {} tiles",
"exceedsTileLimit": "A seleção atual excede o limite de {} tiles",
"offlineModeWarning": "Downloads desabilitados no modo offline. Desative o modo offline para baixar novas áreas.",
"areaTooBigMessage": "Amplie para pelo menos o nível {} para baixar áreas offline. Downloads de áreas grandes podem tornar o aplicativo não responsivo.",
"downloadStarted": "Download iniciado! Buscando tiles e nós...",
"downloadFailed": "Falha ao iniciar o download: {}"
},
"downloadStarted": {
"title": "Download Iniciado",
"message": "Download iniciado! Buscando tiles e nós...",
"ok": "OK",
"viewProgress": "Ver Progresso nas Configurações"
},
"uploadMode": {
"title": "Destino do Upload",
"subtitle": "Escolha onde as câmeras são enviadas",
@@ -138,6 +173,8 @@
"simulateDescription": "Simular uploads (não contacta servidores OSM)"
},
"auth": {
"osmAccountTitle": "Conta OpenStreetMap",
"osmAccountSubtitle": "Gerencie seu login OSM e visualize suas contribuições",
"loggedInAs": "Logado como {}",
"loginToOSM": "Fazer login no OpenStreetMap",
"tapToLogout": "Toque para sair",
@@ -147,6 +184,11 @@
"testConnectionSubtitle": "Verificar se as credenciais OSM estão funcionando",
"connectionOK": "Conexão OK - credenciais são válidas",
"connectionFailed": "Conexão falhou - por favor, faça login novamente",
"viewMyEdits": "Ver Minhas Edições no OSM",
"viewMyEditsSubtitle": "Ver seu histórico de edições no OpenStreetMap",
"aboutOSM": "Sobre OpenStreetMap",
"aboutOSMDescription": "OpenStreetMap é um projeto de mapeamento colaborativo de código aberto onde os contribuintes criam e mantêm um mapa gratuito e editável do mundo. Suas contribuições de dispositivos de vigilância ajudam a tornar esta infraestrutura visível e pesquisável.",
"visitOSM": "Visitar OpenStreetMap",
"deleteAccount": "Excluir Conta OSM",
"deleteAccountSubtitle": "Gerencie sua conta OpenStreetMap",
"deleteAccountExplanation": "Para excluir sua conta OpenStreetMap, você precisará visitar o site do OpenStreetMap. Isso removerá permanentemente sua conta OSM e todos os dados associados.",
@@ -154,7 +196,11 @@
"goToOSM": "Ir para OpenStreetMap"
},
"queue": {
"title": "Fila de Upload",
"subtitle": "Gerenciar uploads pendentes de dispositivos de vigilância",
"pendingUploads": "Uploads pendentes: {}",
"pendingItemsCount": "Itens Pendentes: {}",
"nothingInQueue": "Nada na fila",
"simulateModeEnabled": "Modo simulação ativado uploads simulados",
"sandboxMode": "Modo sandbox uploads vão para o Sandbox OSM",
"tapToViewQueue": "Toque para ver a fila",
@@ -245,6 +291,10 @@
"profileNameRequired": "Nome do perfil é obrigatório",
"requiresDirection": "Requer Direção",
"requiresDirectionSubtitle": "Se câmeras deste tipo precisam de uma tag de direção",
"fov": "Campo de Visão",
"fovHint": "Campo de visão em graus (deixar vazio para o padrão)",
"fovSubtitle": "Campo de visão da câmera - usado para largura do cone e formato de envio por intervalo",
"fovInvalid": "Campo de visão deve estar entre 1 e 360 graus",
"submittable": "Enviável",
"submittableSubtitle": "Se este perfil pode ser usado para envios de câmeras",
"osmTags": "Tags OSM",
@@ -322,9 +372,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,28 @@
"close": "关闭",
"submit": "提交",
"saveEdit": "保存编辑",
"clear": "清空"
"clear": "清空",
"viewOnOSM": "在OSM上查看",
"advanced": "高级",
"useAdvancedEditor": "使用高级编辑器"
},
"proximityWarning": {
"title": "节点过于靠近现有设备",
"message": "此节点距离现有监控设备仅 {} 米。",
"suggestion": "如果同一根杆上有多个设备,请在单个节点上使用多个方向,而不是创建单独的节点。",
"nearbyNodes": "发现附近设备 ({})",
"nodeInfo": "节点 #{} - {}",
"andMore": "...还有 {} 个",
"goBack": "返回",
"submitAnyway": "仍然提交",
"nodeType": {
"alpr": "ALPR/ANPR 摄像头",
"publicCamera": "公共监控摄像头",
"camera": "监控摄像头",
"amenity": "{}",
"device": "{} 设备",
"unknown": "未知设备"
}
},
"followMe": {
"off": "启用跟随模式",
@@ -54,6 +75,8 @@
"maxNodesWarning": "除非您确定有充分的理由,否则您可能不想这样做。",
"offlineMode": "离线模式",
"offlineModeSubtitle": "禁用除本地/离线区域外的所有网络请求。",
"pauseQueueProcessing": "暂停上传队列",
"pauseQueueProcessingSubtitle": "停止上传排队的更改,同时保持实时数据访问。",
"offlineModeWarningTitle": "活动下载",
"offlineModeWarningMessage": "启用离线模式将取消任何活动的区域下载。您要继续吗?",
"enableOfflineMode": "启用离线模式",
@@ -107,10 +130,15 @@
"profileRequired": "请选择配置文件以继续。",
"direction": "方向 {}°",
"profileNoDirectionInfo": "此配置文件不需要方向。",
"temporarilyDisabled": "编辑功能已暂时禁用,我们正在修复一个错误 - 抱歉 - 请稍后再试。",
"mustBeLoggedIn": "您必须登录才能编辑节点。请通过设置登录。",
"sandboxModeWarning": "无法将生产节点的编辑提交到沙盒。在设置中切换到生产模式以编辑节点。",
"enableSubmittableProfile": "在设置中启用可提交的配置文件以编辑节点。",
"profileViewOnlyWarning": "此配置文件仅用于地图查看。请选择可提交的配置文件来编辑节点。",
"cannotMoveConstrainedNode": "无法移动此相机 - 它连接到另一个地图元素OSM way/relation。您仍可以编辑其标签和方向。",
"zoomInRequiredMessage": "请放大至至少第{}级来添加或编辑监控节点。这确保精确定位以便准确制图。",
"extractFromWay": "从way/relation中提取节点",
"extractFromWaySubtitle": "创建具有相同标签的新节点,允许移动到新位置",
"refineTags": "细化标签",
"refineTagsWithProfile": "细化标签({}"
},
@@ -124,9 +152,16 @@
"withinTileLimit": "在 {} 瓦片限制内",
"exceedsTileLimit": "当前选择超出 {} 瓦片限制",
"offlineModeWarning": "离线模式下禁用下载。禁用离线模式以下载新区域。",
"areaTooBigMessage": "请放大至至少第{}级来下载离线区域。下载大区域可能导致应用程序无响应。",
"downloadStarted": "下载已开始!正在获取瓦片和节点...",
"downloadFailed": "启动下载失败:{}"
},
"downloadStarted": {
"title": "下载已开始",
"message": "下载已开始!正在获取瓦片和节点...",
"ok": "确定",
"viewProgress": "在设置中查看进度"
},
"uploadMode": {
"title": "上传目标",
"subtitle": "选择摄像头上传位置",
@@ -138,6 +173,8 @@
"simulateDescription": "模拟上传(不联系 OSM 服务器)"
},
"auth": {
"osmAccountTitle": "OpenStreetMap 账户",
"osmAccountSubtitle": "管理您的 OSM 登录并查看您的贡献",
"loggedInAs": "已登录为 {}",
"loginToOSM": "登录 OpenStreetMap",
"tapToLogout": "点击登出",
@@ -147,6 +184,11 @@
"testConnectionSubtitle": "验证 OSM 凭据是否有效",
"connectionOK": "连接正常 - 凭据有效",
"connectionFailed": "连接失败 - 请重新登录",
"viewMyEdits": "在 OSM 上查看我的编辑",
"viewMyEditsSubtitle": "查看您在 OpenStreetMap 上的编辑历史",
"aboutOSM": "关于 OpenStreetMap",
"aboutOSMDescription": "OpenStreetMap 是一个协作的开源地图项目,贡献者创建和维护一个免费的、可编辑的世界地图。您的监控设备贡献有助于使这种基础设施可见和可搜索。",
"visitOSM": "访问 OpenStreetMap",
"deleteAccount": "删除 OSM 账户",
"deleteAccountSubtitle": "管理您的 OpenStreetMap 账户",
"deleteAccountExplanation": "要删除您的 OpenStreetMap 账户,您需要访问 OpenStreetMap 网站。这将永久删除您的 OSM 账户和所有相关数据。",
@@ -154,7 +196,11 @@
"goToOSM": "前往 OpenStreetMap"
},
"queue": {
"title": "上传队列",
"subtitle": "管理待上传的监控设备",
"pendingUploads": "待上传:{}",
"pendingItemsCount": "待处理项目:{}",
"nothingInQueue": "队列中没有内容",
"simulateModeEnabled": "模拟模式已启用 上传已模拟",
"sandboxMode": "沙盒模式 上传到 OSM 沙盒",
"tapToViewQueue": "点击查看队列",
@@ -245,6 +291,10 @@
"profileNameRequired": "配置文件名称为必填项",
"requiresDirection": "需要方向",
"requiresDirectionSubtitle": "此类型的摄像头是否需要方向标签",
"fov": "视场角",
"fovHint": "视场角度数(留空使用默认值)",
"fovSubtitle": "摄像头视场角 - 用于锥体宽度和范围提交格式",
"fovInvalid": "视场角必须在1到360度之间",
"submittable": "可提交",
"submittableSubtitle": "此配置文件是否可用于摄像头提交",
"osmTags": "OSM 标签",
@@ -322,9 +372,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

@@ -11,6 +11,8 @@ import 'screens/advanced_settings_screen.dart';
import 'screens/language_settings_screen.dart';
import 'screens/about_screen.dart';
import 'screens/release_notes_screen.dart';
import 'screens/osm_account_screen.dart';
import 'screens/upload_queue_screen.dart';
import 'services/localization_service.dart';
import 'services/version_service.dart';
@@ -69,6 +71,8 @@ class DeFlockApp extends StatelessWidget {
routes: {
'/': (context) => const HomeScreen(),
'/settings': (context) => const SettingsScreen(),
'/settings/osm-account': (context) => const OSMAccountScreen(),
'/settings/queue': (context) => const UploadQueueScreen(),
'/settings/profiles': (context) => const ProfilesSettingsScreen(),
'/settings/navigation': (context) => const NavigationSettingsScreen(),
'/settings/offline': (context) => const OfflineSettingsScreen(),

View File

@@ -0,0 +1,24 @@
/// Represents a direction with its associated field-of-view (FOV) cone.
class DirectionFov {
/// The center direction in degrees (0-359, where 0 is north)
final double centerDegrees;
/// The field-of-view width in degrees (e.g., 35, 90, 180, 360)
final double fovDegrees;
DirectionFov(this.centerDegrees, this.fovDegrees);
@override
String toString() => 'DirectionFov(center: ${centerDegrees}°, fov: ${fovDegrees}°)';
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is DirectionFov &&
runtimeType == other.runtimeType &&
centerDegrees == other.centerDegrees &&
fovDegrees == other.fovDegrees;
@override
int get hashCode => centerDegrees.hashCode ^ fovDegrees.hashCode;
}

View File

@@ -9,6 +9,7 @@ class NodeProfile {
final bool requiresDirection;
final bool submittable;
final bool editable;
final double? fov; // Field-of-view in degrees (null means use dev_config default)
NodeProfile({
required this.id,
@@ -18,6 +19,7 @@ class NodeProfile {
this.requiresDirection = true,
this.submittable = true,
this.editable = true,
this.fov,
});
/// Get all built-in default node profiles
@@ -50,6 +52,7 @@ class NodeProfile {
requiresDirection: true,
submittable: true,
editable: true,
fov: 45.0, // Flock cameras typically have narrow FOV
),
NodeProfile(
id: 'builtin-motorola',
@@ -67,6 +70,7 @@ class NodeProfile {
requiresDirection: true,
submittable: true,
editable: true,
fov: 60.0, // Motorola cameras typically have moderate FOV
),
NodeProfile(
id: 'builtin-genetec',
@@ -84,6 +88,7 @@ class NodeProfile {
requiresDirection: true,
submittable: true,
editable: true,
fov: 50.0, // Genetec cameras typically have moderate FOV
),
NodeProfile(
id: 'builtin-leonardo',
@@ -101,6 +106,7 @@ class NodeProfile {
requiresDirection: true,
submittable: true,
editable: true,
fov: 55.0, // Leonardo cameras typically have moderate FOV
),
NodeProfile(
id: 'builtin-neology',
@@ -118,6 +124,40 @@ class NodeProfile {
submittable: true,
editable: true,
),
NodeProfile(
id: 'builtin-rekor',
name: 'Rekor',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'ALPR',
'surveillance:zone': 'traffic',
'camera:type': 'fixed',
'manufacturer': 'Rekor',
},
builtin: true,
requiresDirection: true,
submittable: true,
editable: true,
),
NodeProfile(
id: 'builtin-axis',
name: 'Axis Communications',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'ALPR',
'surveillance:zone': 'traffic',
'camera:type': 'fixed',
'manufacturer': 'Axis Communications',
'manufacturer:wikidata': 'Q2347731',
},
builtin: true,
requiresDirection: true,
submittable: true,
editable: true,
fov: 90.0, // Axis cameras can have wider FOV
),
NodeProfile(
id: 'builtin-generic-gunshot',
name: 'Generic Gunshot Detector',
@@ -175,6 +215,7 @@ class NodeProfile {
bool? requiresDirection,
bool? submittable,
bool? editable,
double? fov,
}) =>
NodeProfile(
id: id ?? this.id,
@@ -184,6 +225,7 @@ class NodeProfile {
requiresDirection: requiresDirection ?? this.requiresDirection,
submittable: submittable ?? this.submittable,
editable: editable ?? this.editable,
fov: fov ?? this.fov,
);
Map<String, dynamic> toJson() => {
@@ -194,6 +236,7 @@ class NodeProfile {
'requiresDirection': requiresDirection,
'submittable': submittable,
'editable': editable,
'fov': fov,
};
factory NodeProfile.fromJson(Map<String, dynamic> j) => NodeProfile(
@@ -204,6 +247,7 @@ class NodeProfile {
requiresDirection: j['requiresDirection'] ?? true, // Default to true for backward compatibility
submittable: j['submittable'] ?? true, // Default to true for backward compatibility
editable: j['editable'] ?? true, // Default to true for backward compatibility
fov: j['fov']?.toDouble(), // Can be null for backward compatibility
);
@override

View File

@@ -1,14 +1,18 @@
import 'package:latlong2/latlong.dart';
import 'direction_fov.dart';
import '../dev_config.dart';
class OsmNode {
final int id;
final LatLng coord;
final Map<String, String> tags;
final bool isConstrained; // true if part of any way/relation
OsmNode({
required this.id,
required this.coord,
required this.tags,
this.isConstrained = false, // Default to unconstrained for backward compatibility
});
Map<String, dynamic> toJson() => {
@@ -16,6 +20,7 @@ class OsmNode {
'lat': coord.latitude,
'lon': coord.longitude,
'tags': tags,
'isConstrained': isConstrained,
};
factory OsmNode.fromJson(Map<String, dynamic> json) {
@@ -29,12 +34,14 @@ class OsmNode {
id: json['id'] is int ? json['id'] as int : int.tryParse(json['id'].toString()) ?? 0,
coord: LatLng((json['lat'] as num).toDouble(), (json['lon'] as num).toDouble()),
tags: tags,
isConstrained: json['isConstrained'] as bool? ?? false, // Default to false for backward compatibility
);
}
bool get hasDirection => directionDeg.isNotEmpty;
bool get hasDirection => directionFovPairs.isNotEmpty;
List<double> get directionDeg {
/// Get direction and FOV pairs, supporting range notation like "90-270" or "10-45;90-125;290"
List<DirectionFov> get directionFovPairs {
final raw = tags['direction'] ?? tags['camera:direction'];
if (raw == null) return [];
@@ -46,17 +53,35 @@ class OsmNode {
'W': 270.0, 'WNW': 292.5, 'NW': 315.0, 'NNW': 337.5,
};
// Split on semicolons and parse each direction
final directions = <double>[];
final directionFovList = <DirectionFov>[];
final parts = raw.split(';');
for (final part in parts) {
final trimmed = part.trim().toUpperCase();
final trimmed = part.trim();
if (trimmed.isEmpty) continue;
// Check if this part contains a range (e.g., "90-270")
if (trimmed.contains('-') && RegExp(r'^\d+\.?\d*-\d+\.?\d*$').hasMatch(trimmed)) {
final rangeParts = trimmed.split('-');
if (rangeParts.length == 2) {
final start = double.tryParse(rangeParts[0]);
final end = double.tryParse(rangeParts[1]);
if (start != null && end != null) {
final normalized = _calculateRangeCenter(start, end);
directionFovList.add(normalized);
continue;
}
}
}
// Not a range, handle as single direction
final trimmedUpper = trimmed.toUpperCase();
// First try compass direction lookup
if (compassDirections.containsKey(trimmed)) {
directions.add(compassDirections[trimmed]!);
if (compassDirections.containsKey(trimmedUpper)) {
final degrees = compassDirections[trimmedUpper]!;
directionFovList.add(DirectionFov(degrees, kDirectionConeHalfAngle * 2));
continue;
}
@@ -70,9 +95,35 @@ class OsmNode {
// Normalize: wrap negative or >360 into 0359 range
final normalized = ((val % 360) + 360) % 360;
directions.add(normalized);
directionFovList.add(DirectionFov(normalized, kDirectionConeHalfAngle * 2));
}
return directions;
return directionFovList;
}
/// Calculate center and width for a range like "90-270" or "270-90"
DirectionFov _calculateRangeCenter(double start, double end) {
// Normalize start and end to 0-359 range
start = ((start % 360) + 360) % 360;
end = ((end % 360) + 360) % 360;
double width, center;
if (start > end) {
// Wrapping case: 270-90
width = (end + 360) - start;
center = ((start + end + 360) / 2) % 360;
} else {
// Normal case: 90-270
width = end - start;
center = (start + end) / 2;
}
return DirectionFov(center, width);
}
/// Legacy getter for backward compatibility - returns just center directions
List<double> get directionDeg {
return directionFovPairs.map((df) => df.centerDegrees).toList();
}
}

View File

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

View File

@@ -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(),
@@ -28,8 +33,8 @@ class AdvancedSettingsScreen extends StatelessWidget {
Divider(),
SuspectedLocationsSection(),
Divider(),
NetworkStatusSection(),
Divider(),
// NetworkStatusSection(), // Commented out - network status indicator now defaults to enabled
// Divider(),
TileProviderSection(),
],
),

View File

@@ -103,6 +103,21 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
void _openAddNodeSheet() {
final appState = context.read<AppState>();
// Check minimum zoom level before opening sheet
final currentZoom = _mapController.mapController.camera.zoom;
if (currentZoom < kMinZoomForNodeEditingSheets) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
LocalizationService.instance.t('editNode.zoomInRequiredMessage',
params: [kMinZoomForNodeEditingSheets.toString()])
),
),
);
return;
}
// Disable follow-me when adding a camera so the map doesn't jump around
appState.setFollowMeMode(FollowMeMode.off);
@@ -145,6 +160,25 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
// Disable follow-me when editing a camera so the map doesn't jump around
appState.setFollowMeMode(FollowMeMode.off);
final session = appState.editSession!; // should be non-null when this is called
// Center map on the node being edited (same animation as openNodeTagSheet)
try {
_mapController.animateTo(
dest: session.originalNode.coord,
zoom: _mapController.mapController.camera.zoom,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
} catch (_) {
// Map controller not ready, fallback to immediate move
try {
_mapController.mapController.move(session.originalNode.coord, _mapController.mapController.camera.zoom);
} catch (_) {
// Controller really not ready, skip centering
}
}
// Set transition flag to prevent map bounce
_transitioningToEdit = true;
@@ -152,8 +186,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
if (_tagSheetHeight > 0) {
Navigator.of(context).pop();
}
final session = appState.editSession!; // should be non-null when this is called
// Small delay to let tag sheet close smoothly
Future.delayed(const Duration(milliseconds: 150), () {
@@ -244,6 +276,15 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
if (!mounted) return;
try {
final appState = context.read<AppState>();
// Run any needed migrations first
final versionsNeedingMigration = await ChangelogService().getVersionsNeedingMigration();
for (final version in versionsNeedingMigration) {
await ChangelogService().runMigration(version, appState);
}
// Determine what popup to show
final popupType = await ChangelogService().getPopupType();
if (!mounted) return; // Check again after async operation
@@ -258,7 +299,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
break;
case PopupType.changelog:
final changelogContent = ChangelogService().getChangelogForCurrentVersion();
final changelogContent = await ChangelogService().getChangelogContentForDisplay();
if (changelogContent != null) {
await showDialog(
context: context,
@@ -269,18 +310,22 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
break;
case PopupType.none:
// No popup needed, but still update version tracking for future launches
await ChangelogService().updateLastSeenVersion();
// No popup needed
break;
}
// Complete the version change workflow (updates last seen version)
await ChangelogService().completeVersionChange();
} catch (e) {
// Silently handle errors to avoid breaking the app launch
debugPrint('[HomeScreen] Error checking for popup: $e');
// Still update version tracking in case of error
// Still complete version change to avoid getting stuck
try {
await ChangelogService().updateLastSeenVersion();
await ChangelogService().completeVersionChange();
} catch (e2) {
debugPrint('[HomeScreen] Error updating version: $e2');
debugPrint('[HomeScreen] Error completing version change: $e2');
}
}
}
@@ -502,6 +547,20 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
child: NodeTagSheet(
node: node,
onEditPressed: () {
// Check minimum zoom level before starting edit session
final currentZoom = _mapController.mapController.camera.zoom;
if (currentZoom < kMinZoomForNodeEditingSheets) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
LocalizationService.instance.t('editNode.zoomInRequiredMessage',
params: [kMinZoomForNodeEditingSheets.toString()])
),
),
);
return;
}
final appState = context.read<AppState>();
appState.startEditSession(node);
// This will trigger _openEditNodeSheet via the existing auto-show logic
@@ -678,71 +737,92 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
// Bottom button bar (restored to original)
Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom + kBottomButtonBarOffset,
left: 8,
right: 8,
),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600), // Match typical sheet width
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Theme.of(context).shadowColor.withOpacity(0.3),
blurRadius: 10,
offset: Offset(0, -2),
)
],
child: Builder(
builder: (context) {
final safeArea = MediaQuery.of(context).padding;
return Padding(
padding: EdgeInsets.only(
bottom: safeArea.bottom + kBottomButtonBarOffset,
left: leftPositionWithSafeArea(8, safeArea),
right: rightPositionWithSafeArea(8, safeArea),
),
margin: EdgeInsets.only(bottom: kBottomButtonBarOffset),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
children: [
Expanded(
flex: 7, // 70% for primary action
child: AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) => ElevatedButton.icon(
icon: Icon(Icons.add_location_alt),
label: Text(LocalizationService.instance.tagNode),
onPressed: _openAddNodeSheet,
style: ElevatedButton.styleFrom(
minimumSize: Size(0, 48),
textStyle: TextStyle(fontSize: 16),
),
),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600), // Match typical sheet width
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Theme.of(context).shadowColor.withOpacity(0.3),
blurRadius: 10,
offset: Offset(0, -2),
)
],
),
),
SizedBox(width: 12),
Expanded(
flex: 3, // 30% for secondary action
child: AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) => FittedBox(
fit: BoxFit.scaleDown,
child: ElevatedButton.icon(
icon: Icon(Icons.download_for_offline),
label: Text(LocalizationService.instance.download),
onPressed: () => showDialog(
context: context,
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
),
style: ElevatedButton.styleFrom(
minimumSize: Size(0, 48),
textStyle: TextStyle(fontSize: 16),
margin: EdgeInsets.only(bottom: kBottomButtonBarOffset),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
children: [
Expanded(
flex: 7, // 70% for primary action
child: AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) => ElevatedButton.icon(
icon: Icon(Icons.add_location_alt),
label: Text(LocalizationService.instance.tagNode),
onPressed: _openAddNodeSheet,
style: ElevatedButton.styleFrom(
minimumSize: Size(0, 48),
textStyle: TextStyle(fontSize: 16),
),
),
),
),
),
SizedBox(width: 12),
Expanded(
flex: 3, // 30% for secondary action
child: AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) => FittedBox(
fit: BoxFit.scaleDown,
child: ElevatedButton.icon(
icon: Icon(Icons.download_for_offline),
label: Text(LocalizationService.instance.download),
onPressed: () {
// Check minimum zoom level before opening download dialog
final currentZoom = _mapController.mapController.camera.zoom;
if (currentZoom < kMinZoomForOfflineDownload) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
LocalizationService.instance.t('download.areaTooBigMessage',
params: [kMinZoomForOfflineDownload.toString()])
),
),
);
return;
}
showDialog(
context: context,
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
);
},
style: ElevatedButton.styleFrom(
minimumSize: Size(0, 48),
textStyle: TextStyle(fontSize: 16),
),
),
),
),
),
],
),
),
],
),
),
),
),
);
},
),
),
],

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

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

View File

@@ -20,6 +20,7 @@ class _ProfileEditorState extends State<ProfileEditor> {
late List<MapEntry<String, String>> _tags;
late bool _requiresDirection;
late bool _submittable;
late TextEditingController _fovCtrl;
static const _defaultTags = [
MapEntry('man_made', 'surveillance'),
@@ -38,6 +39,7 @@ class _ProfileEditorState extends State<ProfileEditor> {
_nameCtrl = TextEditingController(text: widget.profile.name);
_requiresDirection = widget.profile.requiresDirection;
_submittable = widget.profile.submittable;
_fovCtrl = TextEditingController(text: widget.profile.fov?.toString() ?? '');
if (widget.profile.tags.isEmpty) {
// New profile → start with sensible defaults
@@ -50,6 +52,7 @@ class _ProfileEditorState extends State<ProfileEditor> {
@override
void dispose() {
_nameCtrl.dispose();
_fovCtrl.dispose();
super.dispose();
}
@@ -67,7 +70,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,
@@ -86,6 +94,21 @@ class _ProfileEditorState extends State<ProfileEditor> {
onChanged: (value) => setState(() => _requiresDirection = value ?? true),
controlAffinity: ListTileControlAffinity.leading,
),
if (_requiresDirection) Padding(
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8),
child: TextField(
controller: _fovCtrl,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: locService.t('profileEditor.fov'),
hintText: locService.t('profileEditor.fovHint'),
helperText: locService.t('profileEditor.fovSubtitle'),
errorText: _validateFov(),
suffixText: '°',
),
onChanged: (value) => setState(() {}), // Trigger validation
),
),
CheckboxListTile(
title: Text(locService.t('profileEditor.submittable')),
subtitle: Text(locService.t('profileEditor.submittableSubtitle')),
@@ -176,6 +199,17 @@ class _ProfileEditorState extends State<ProfileEditor> {
});
}
String? _validateFov() {
final text = _fovCtrl.text.trim();
if (text.isEmpty) return null; // Optional field
final fov = double.tryParse(text);
if (fov == null || fov <= 0 || fov > 360) {
return LocalizationService.instance.t('profileEditor.fovInvalid');
}
return null;
}
void _save() {
final locService = LocalizationService.instance;
final name = _nameCtrl.text.trim();
@@ -185,6 +219,15 @@ class _ProfileEditorState extends State<ProfileEditor> {
.showSnackBar(SnackBar(content: Text(locService.t('profileEditor.profileNameRequired'))));
return;
}
// Validate FOV if provided
if (_validateFov() != null) {
return; // Don't save if FOV validation fails
}
// Parse FOV
final fovText = _fovCtrl.text.trim();
final fov = fovText.isEmpty ? null : double.tryParse(fovText);
final tagMap = <String, String>{};
for (final e in _tags) {
@@ -206,6 +249,7 @@ class _ProfileEditorState extends State<ProfileEditor> {
requiresDirection: _requiresDirection,
submittable: _submittable,
editable: true, // All custom profiles are editable by definition
fov: fov,
);
context.read<AppState>().addOrUpdateProfile(newProfile);

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

@@ -70,7 +70,8 @@ class _MaxNodesSectionState extends State<MaxNodesSection> {
width: 80,
child: TextFormField(
controller: _controller,
keyboardType: TextInputType.number,
keyboardType: const TextInputType.numberWithOptions(signed: true, decimal: true),
textInputAction: TextInputAction.done,
decoration: const InputDecoration(
isDense: true,
contentPadding: EdgeInsets.symmetric(vertical: 6, horizontal: 8),

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

@@ -181,7 +181,8 @@ class _ProximityAlertsSectionState extends State<ProximityAlertsSection> {
width: 80,
child: TextField(
controller: _distanceController,
keyboardType: TextInputType.number,
keyboardType: const TextInputType.numberWithOptions(signed: true, decimal: true),
textInputAction: TextInputAction.done,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],

View File

@@ -119,7 +119,11 @@ class QueueSection extends StatelessWidget {
locService.t('queue.destination', params: [_getUploadModeDisplayName(upload.uploadMode)]) + '\n' +
locService.t('queue.latitude', params: [upload.coord.latitude.toStringAsFixed(6)]) + '\n' +
locService.t('queue.longitude', params: [upload.coord.longitude.toStringAsFixed(6)]) + '\n' +
locService.t('queue.direction', params: [upload.direction.round().toString()]) + '\n' +
locService.t('queue.direction', params: [
upload.direction is String
? upload.direction.toString()
: upload.direction.round().toString()
]) + '\n' +
locService.t('queue.attempts', params: [upload.attempts.toString()]) +
(upload.error ? "\n${locService.t('queue.uploadFailedRetry')}" : "")
),

View File

@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../app_state.dart';
import '../../../services/localization_service.dart';
import '../../../widgets/suspected_location_progress_dialog.dart';
class SuspectedLocationsSection extends StatelessWidget {
const SuspectedLocationsSection({super.key});
@@ -39,31 +38,19 @@ class SuspectedLocationsSection extends StatelessWidget {
Future<void> handleRefresh() async {
if (!context.mounted) return;
// Show simple progress dialog
showDialog(
context: context,
barrierDismissible: false,
builder: (progressContext) => SuspectedLocationProgressDialog(
title: locService.t('suspectedLocations.updating'),
message: locService.t('suspectedLocations.downloadingAndProcessing'),
),
);
// Start the refresh
// Use the inline loading indicator by calling refreshSuspectedLocations
// The loading state will be managed by suspected location state
final success = await appState.refreshSuspectedLocations();
// Close progress dialog
// Show result snackbar
if (context.mounted) {
Navigator.of(context).pop();
// Show result snackbar
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(success
? locService.t('suspectedLocations.updateSuccess')
: locService.t('suspectedLocations.updateFailed')),
),
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(success
? locService.t('suspectedLocations.updateSuccess')
: locService.t('suspectedLocations.updateFailed')),
),
);
}
}
@@ -139,7 +126,8 @@ class SuspectedLocationsSection extends StatelessWidget {
width: 80,
child: TextFormField(
initialValue: appState.suspectedLocationMinDistance.toString(),
keyboardType: TextInputType.number,
keyboardType: const TextInputType.numberWithOptions(signed: true, decimal: true),
textInputAction: TextInputAction.done,
decoration: const InputDecoration(
isDense: true,
contentPadding: EdgeInsets.symmetric(vertical: 6, horizontal: 8),

View File

@@ -1,7 +1,4 @@
import 'package:flutter/material.dart';
import 'settings/sections/auth_section.dart';
import 'settings/sections/upload_mode_section.dart';
import 'settings/sections/queue_section.dart';
import '../services/localization_service.dart';
import '../services/version_service.dart';
import '../dev_config.dart';
@@ -18,16 +15,31 @@ 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) ...[
const UploadModeSection(),
const Divider(),
],
const AuthSection(),
// OpenStreetMap Account
_buildNavigationTile(
context,
icon: Icons.account_circle,
title: locService.t('auth.osmAccountTitle'),
subtitle: locService.t('auth.osmAccountSubtitle'),
onTap: () => Navigator.pushNamed(context, '/settings/osm-account'),
),
const Divider(),
const QueueSection(),
// Upload Queue
_buildNavigationTile(
context,
icon: Icons.queue,
title: locService.t('queue.title'),
subtitle: locService.t('queue.subtitle'),
onTap: () => Navigator.pushNamed(context, '/settings/queue'),
),
const Divider(),
// Navigation to sub-pages

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

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

View File

@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'version_service.dart';
import '../app_state.dart';
/// Service for managing changelog data and first launch detection
class ChangelogService {
@@ -16,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;
@@ -67,6 +84,12 @@ class ChangelogService {
debugPrint('[ChangelogService] Updated last seen version to: $currentVersion');
}
/// Get the last seen version (for migration purposes)
Future<String?> getLastSeenVersion() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_lastSeenVersionKey);
}
/// Get changelog content for the current version
String? getChangelogForCurrentVersion() {
if (!_initialized || _changelogData == null) {
@@ -82,8 +105,19 @@ 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)
/// This is the method home_screen should use to get content for the changelog popup
Future<String?> getChangelogContentForDisplay() async {
return await getCombinedChangelogContent();
}
/// Complete the version change workflow - call this after showing popups
/// This updates the last seen version so migrations don't run again
Future<void> completeVersionChange() async {
await updateLastSeenVersion();
}
/// Get changelog content for a specific version
@@ -93,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)
@@ -106,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) {
@@ -133,7 +166,7 @@ class ChangelogService {
// Version changed and there's changelog content
if (hasVersionChanged) {
final changelogContent = getChangelogForCurrentVersion();
final changelogContent = await getCombinedChangelogContent();
if (changelogContent != null) {
return PopupType.changelog;
}
@@ -142,8 +175,139 @@ class ChangelogService {
return PopupType.none;
}
/// Check if version-change migrations need to be run
/// Returns list of version strings that need migrations
Future<List<String>> getVersionsNeedingMigration() async {
final lastSeenVersion = await getLastSeenVersion();
final currentVersion = VersionService().version;
if (lastSeenVersion == null) return []; // First launch, no migrations needed
final versionsNeedingMigration = <String>[];
// Check each version that could need migration
if (needsMigration(lastSeenVersion, currentVersion, '1.3.1')) {
versionsNeedingMigration.add('1.3.1');
}
// Future versions can be added here
// if (needsMigration(lastSeenVersion, currentVersion, '2.0.0')) {
// versionsNeedingMigration.add('2.0.0');
// }
return versionsNeedingMigration;
}
/// Get combined changelog content for all versions between last seen and current
/// Returns null if no changelog content exists for any intermediate version
Future<String?> getCombinedChangelogContent() async {
if (!_initialized || _changelogData == null) return null;
final lastSeenVersion = await getLastSeenVersion();
final currentVersion = VersionService().version;
if (lastSeenVersion == null) {
// First launch - just return current version changelog
return getChangelogForCurrentVersion();
}
final intermediateVersions = <String>[];
// Collect all relevant versions between lastSeen and current (exclusive of lastSeen, inclusive of current)
for (final entry in _changelogData!.entries) {
final version = entry.key;
final versionData = entry.value as Map<String, dynamic>?;
final content = _parseChangelogContent(versionData?['content']);
// Skip versions with empty content
if (content == null || content.isEmpty) continue;
// Include versions where: lastSeenVersion < version <= currentVersion
if (needsMigration(lastSeenVersion, currentVersion, version)) {
intermediateVersions.add(version);
}
}
// Sort versions in descending order (newest first)
intermediateVersions.sort((a, b) => compareVersions(b, a));
// Build changelog content
final intermediateChangelogs = intermediateVersions.map((version) {
final versionData = _changelogData![version] as Map<String, dynamic>;
final content = _parseChangelogContent(versionData['content'])!; // Safe to use ! here since we filtered empty content above
return '**Version $version:**\n$content';
}).toList();
return intermediateChangelogs.isNotEmpty ? intermediateChangelogs.join('\n\n---\n\n') : null;
}
/// Check if the service is properly initialized
bool get isInitialized => _initialized;
/// Run a specific migration by version number
Future<void> runMigration(String version, AppState appState) async {
debugPrint('[ChangelogService] Running $version migration');
switch (version) {
case '1.3.1':
// Enable network status indicator for all existing users
await appState.setNetworkStatusIndicatorEnabled(true);
debugPrint('[ChangelogService] 1.3.1 migration completed: enabled network status indicator');
break;
// Future migrations can be added here
// case '2.0.0':
// await appState.doSomethingNew();
// debugPrint('[ChangelogService] 2.0.0 migration completed');
// break;
default:
debugPrint('[ChangelogService] Unknown migration version: $version');
}
}
/// Check if a migration should run
/// Migration runs if: lastSeenVersion < migrationVersion <= currentVersion
bool needsMigration(String lastSeenVersion, String currentVersion, String migrationVersion) {
final lastVsMigration = compareVersions(lastSeenVersion, migrationVersion);
final migrationVsCurrent = compareVersions(migrationVersion, currentVersion);
return lastVsMigration < 0 && migrationVsCurrent <= 0;
}
/// Compare two version strings
/// Returns -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2
/// Versions are expected in format "major.minor.patch" (e.g., "1.3.1")
int compareVersions(String v1, String v2) {
try {
final v1Parts = v1.split('.').map(int.parse).toList();
final v2Parts = v2.split('.').map(int.parse).toList();
// Ensure we have at least 3 parts (major.minor.patch)
while (v1Parts.length < 3) v1Parts.add(0);
while (v2Parts.length < 3) v2Parts.add(0);
// Compare major version first
if (v1Parts[0] < v2Parts[0]) return -1;
if (v1Parts[0] > v2Parts[0]) return 1;
// Major versions equal, compare minor version
if (v1Parts[1] < v2Parts[1]) return -1;
if (v1Parts[1] > v2Parts[1]) return 1;
// Major and minor equal, compare patch version
if (v1Parts[2] < v2Parts[2]) return -1;
if (v1Parts[2] > v2Parts[2]) return 1;
// All parts equal
return 0;
} catch (e) {
debugPrint('[ChangelogService] Error comparing versions "$v1" vs "$v2": $e');
// Safe fallback: assume they're different so we run migrations
return v1 == v2 ? 0 : -1;
}
}
}
/// Types of popups that can be shown

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

@@ -150,22 +150,17 @@ Future<List<OsmNode>> _fetchSingleOverpassQuery({
return [];
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
final data = await compute(jsonDecode, response.body) as Map<String, dynamic>;
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

@@ -2,6 +2,8 @@ import 'package:latlong2/latlong.dart';
import '../models/osm_node.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
const Distance _distance = Distance();
class NodeCache {
// Singleton instance
static final NodeCache instance = NodeCache._internal();
@@ -11,6 +13,7 @@ class NodeCache {
final Map<int, OsmNode> _nodes = {};
/// Add or update a batch of nodes in the cache.
/// TODO: Consider moving to compute() if cache operations cause ANR
void addOrUpdate(List<OsmNode> nodes) {
for (var node in nodes) {
final existing = _nodes[node.id];
@@ -26,6 +29,7 @@ class NodeCache {
id: node.id,
coord: node.coord,
tags: mergedTags,
isConstrained: node.isConstrained, // Preserve constraint information
);
} else {
_nodes[node.id] = node;
@@ -57,6 +61,7 @@ class NodeCache {
id: node.id,
coord: node.coord,
tags: cleanTags,
isConstrained: node.isConstrained, // Preserve constraint information
);
}
}
@@ -100,6 +105,34 @@ class NodeCache {
(coord1.longitude - coord2.longitude).abs() < tolerance;
}
/// Find nodes within the specified distance (in meters) of the given coordinate
/// Excludes nodes with the excludeNodeId (useful when checking proximity for edited nodes)
List<OsmNode> findNodesWithinDistance(LatLng coord, double distanceMeters, {int? excludeNodeId}) {
final nearbyNodes = <OsmNode>[];
for (final node in _nodes.values) {
// Skip the excluded node (typically the node being edited)
if (excludeNodeId != null && node.id == excludeNodeId) {
continue;
}
// Skip temporary nodes (negative IDs) with pending upload/edit/deletion markers
if (node.id < 0 && (
node.tags.containsKey('_pending_upload') ||
node.tags.containsKey('_pending_edit') ||
node.tags.containsKey('_pending_deletion'))) {
continue;
}
final distance = _distance.as(LengthUnit.Meter, coord, node.coord);
if (distance <= distanceMeters) {
nearbyNodes.add(node);
}
}
return nearbyNodes;
}
/// Utility: point-in-bounds for coordinates
bool _inBounds(LatLng coord, LatLngBounds bounds) {
return coord.latitude >= bounds.southWest.latitude &&

View File

@@ -127,9 +127,8 @@ class SuspectedLocationCache extends ChangeNotifier {
/// Process raw CSV data and save to storage (calculates centroids once)
Future<void> processAndSave(
List<Map<String, dynamic>> rawData,
DateTime fetchTime, {
void Function(String message, double? progress)? onProgress,
}) async {
DateTime fetchTime,
) async {
try {
debugPrint('[SuspectedLocationCache] Processing ${rawData.length} raw entries...');
@@ -141,10 +140,9 @@ class SuspectedLocationCache extends ChangeNotifier {
for (int i = 0; i < rawData.length; i++) {
final rowData = rawData[i];
// Report progress every 1000 entries
// Log progress every 1000 entries for debugging
if (i % 1000 == 0) {
final progress = i / rawData.length;
onProgress?.call('Calculating coordinates: ${i + 1}/${rawData.length}', progress);
debugPrint('[SuspectedLocationCache] Processed ${i + 1}/${rawData.length} entries...');
}
try {

View File

@@ -22,7 +22,6 @@ class SuspectedLocationService {
final SuspectedLocationCache _cache = SuspectedLocationCache();
bool _isEnabled = false;
bool _isLoading = false;
/// Get last fetch time
DateTime? get lastFetchTime => _cache.lastFetchTime;
@@ -30,9 +29,6 @@ class SuspectedLocationService {
/// Check if suspected locations are enabled
bool get isEnabled => _isEnabled;
/// Check if currently loading
bool get isLoading => _isLoading;
/// Initialize the service - load from storage and check if refresh needed
Future<void> init({bool offlineMode = false}) async {
await _loadFromStorage();
@@ -55,22 +51,31 @@ class SuspectedLocationService {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefsKeyEnabled, enabled);
// If enabling for the first time and no data, fetch it in background
if (enabled && !_cache.hasData) {
_fetchData(); // Don't await - let it run in background so UI updates immediately
}
// If disabling, clear the cache
if (!enabled) {
_cache.clear();
}
// Note: If enabling and no data, the state layer will call fetchDataIfNeeded()
}
/// Manually refresh the data
Future<bool> refreshData({
void Function(String message, double? progress)? onProgress,
}) async {
return await _fetchData(onProgress: onProgress);
/// Check if cache has any data
bool get hasData => _cache.hasData;
/// Get last fetch time
DateTime? get lastFetch => _cache.lastFetchTime;
/// Fetch data if needed (for enabling suspected locations when no data exists)
Future<bool> fetchDataIfNeeded() async {
if (!_shouldRefresh()) {
debugPrint('[SuspectedLocationService] Data is fresh, skipping fetch');
return true; // Already have fresh data
}
return await _fetchData();
}
/// Force refresh the data (for manual refresh button)
Future<bool> forceRefresh() async {
return await _fetchData();
}
/// Check if data should be refreshed
@@ -95,14 +100,8 @@ class SuspectedLocationService {
}
/// Fetch data from the CSV URL
Future<bool> _fetchData({
void Function(String message, double? progress)? onProgress,
}) async {
if (_isLoading) return false;
_isLoading = true;
Future<bool> _fetchData() async {
try {
onProgress?.call('Downloading CSV data...', null);
debugPrint('[SuspectedLocationService] Fetching CSV data from $kSuspectedLocationsCsvUrl');
final response = await http.get(
@@ -117,14 +116,8 @@ class SuspectedLocationService {
return false;
}
onProgress?.call('Parsing CSV data...', 0.2);
// Parse CSV with proper field separator and quote handling
final csvData = const CsvToListConverter(
fieldDelimiter: ',',
textDelimiter: '"',
eol: '\n',
).convert(response.body);
final csvData = await compute(_parseCSV, response.body);
debugPrint('[SuspectedLocationService] Parsed ${csvData.length} rows from CSV');
if (csvData.isEmpty) {
@@ -174,11 +167,6 @@ class SuspectedLocationService {
validRows++;
}
// Report progress every 1000 rows
if (rowIndex % 1000 == 0) {
final progress = 0.4 + (rowIndex / dataRows.length) * 0.4; // 40% to 80% of total
onProgress?.call('Processing row $rowIndex...', progress);
}
} catch (e, stackTrace) {
// Skip rows that can't be parsed
debugPrint('[SuspectedLocationService] Error parsing row $rowIndex: $e');
@@ -186,18 +174,12 @@ class SuspectedLocationService {
}
}
onProgress?.call('Calculating coordinates...', 0.8);
debugPrint('[SuspectedLocationService] Parsed $validRows valid rows from ${dataRows.length} total rows');
final fetchTime = DateTime.now();
// Process raw data and save (calculates centroids once)
await _cache.processAndSave(rawDataList, fetchTime, onProgress: (message, progress) {
// Map cache progress to final 20% (0.8 to 1.0)
final finalProgress = 0.8 + (progress ?? 0) * 0.2;
onProgress?.call(message, finalProgress);
});
onProgress?.call('Complete!', 1.0);
await _cache.processAndSave(rawDataList, fetchTime);
debugPrint('[SuspectedLocationService] Successfully fetched and stored $validRows valid raw entries (${rawDataList.length} total)');
return true;
@@ -206,8 +188,6 @@ class SuspectedLocationService {
debugPrint('[SuspectedLocationService] Error fetching data: $e');
debugPrint('[SuspectedLocationService] Stack trace: $stackTrace');
return false;
} finally {
_isLoading = false;
}
}
@@ -223,4 +203,13 @@ class SuspectedLocationService {
LatLng(south, east),
));
}
}
/// Simple CSV parser for compute() - must be top-level function
List<List<dynamic>> _parseCSV(String csvBody) {
return const CsvToListConverter(
fieldDelimiter: ',',
textDelimiter: '"',
eol: '\n',
).convert(csvBody);
}

View File

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

View File

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

View File

@@ -28,20 +28,23 @@ 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;
bool _proximityAlertsEnabled = false;
int _proximityAlertDistance = kProximityAlertDefaultDistance;
bool _networkStatusIndicatorEnabled = false;
bool _networkStatusIndicatorEnabled = true;
int _suspectedLocationMinDistance = 100; // meters
List<TileProvider> _tileProviders = [];
String _selectedTileTypeId = '';
// 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;
@@ -102,7 +108,7 @@ class SettingsState extends ChangeNotifier {
_proximityAlertDistance = prefs.getInt(_proximityAlertDistancePrefsKey) ?? kProximityAlertDefaultDistance;
// Load network status indicator setting
_networkStatusIndicatorEnabled = prefs.getBool(_networkStatusIndicatorEnabledPrefsKey) ?? false;
_networkStatusIndicatorEnabled = prefs.getBool(_networkStatusIndicatorEnabledPrefsKey) ?? true;
// Load suspected location minimum distance
_suspectedLocationMinDistance = prefs.getInt(_suspectedLocationMinDistancePrefsKey) ?? 100;
@@ -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

@@ -31,7 +31,7 @@ class SuspectedLocationState extends ChangeNotifier {
bool get isEnabled => _service.isEnabled;
/// Whether currently loading data
bool get isLoading => _isLoading || _service.isLoading;
bool get isLoading => _isLoading;
/// Last time data was fetched
DateTime? get lastFetchTime => _service.lastFetchTime;
@@ -45,18 +45,36 @@ class SuspectedLocationState extends ChangeNotifier {
/// Enable or disable suspected locations
Future<void> setEnabled(bool enabled) async {
await _service.setEnabled(enabled);
// If enabling and no data exists, fetch it now
if (enabled && !_service.hasData) {
await _fetchData();
}
notifyListeners();
}
/// Manually refresh the data
Future<bool> refreshData({
void Function(String message, double? progress)? onProgress,
}) async {
/// Manually refresh the data (force refresh)
Future<bool> refreshData() async {
_isLoading = true;
notifyListeners();
try {
final success = await _service.refreshData(onProgress: onProgress);
final success = await _service.forceRefresh();
return success;
} finally {
_isLoading = false;
notifyListeners();
}
}
/// Internal method to fetch data if needed with loading state management
Future<bool> _fetchData() async {
_isLoading = true;
notifyListeners();
try {
final success = await _service.fetchDataIfNeeded();
return success;
} finally {
_isLoading = false;

View File

@@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:latlong2/latlong.dart';
import '../models/pending_upload.dart';
import '../models/osm_node.dart';
@@ -29,7 +30,7 @@ class UploadQueueState extends ChangeNotifier {
void addFromSession(AddNodeSession session, {required UploadMode uploadMode}) {
final upload = PendingUpload(
coord: session.target!,
direction: _formatDirectionsAsString(session.directions),
direction: _formatDirectionsForSubmission(session.directions, session.profile),
profile: session.profile!, // Safe to use ! because commitSession() checks for null
operatorProfile: session.operatorProfile,
uploadMode: uploadMode,
@@ -61,44 +62,78 @@ class UploadQueueState extends ChangeNotifier {
// Add a completed edit session to the upload queue
void addFromEditSession(EditNodeSession session, {required UploadMode uploadMode}) {
// Determine operation type and coordinates
final UploadOperation operation;
final LatLng coordToUse;
if (session.extractFromWay && session.originalNode.isConstrained) {
// Extract operation: create new node at new location
operation = UploadOperation.extract;
coordToUse = session.target;
} else if (session.originalNode.isConstrained) {
// Constrained node without extract: use original position
operation = UploadOperation.modify;
coordToUse = session.originalNode.coord;
} else {
// Unconstrained node: normal modify operation
operation = UploadOperation.modify;
coordToUse = session.target;
}
final upload = PendingUpload(
coord: session.target,
direction: _formatDirectionsAsString(session.directions),
coord: coordToUse,
direction: _formatDirectionsForSubmission(session.directions, session.profile),
profile: session.profile!, // Safe to use ! because commitEditSession() checks for null
operatorProfile: session.operatorProfile,
uploadMode: uploadMode,
operation: UploadOperation.modify,
operation: operation,
originalNodeId: session.originalNode.id, // Track which node we're editing
);
_queue.add(upload);
_saveQueue();
// Create two cache entries:
// 1. Mark the original node with _pending_edit (grey ring) at original location
final originalTags = Map<String, String>.from(session.originalNode.tags);
originalTags['_pending_edit'] = 'true'; // Mark original as having pending edit
final originalNode = OsmNode(
id: session.originalNode.id,
coord: session.originalNode.coord, // Keep at original location
tags: originalTags,
);
// 2. Create new temp node for the edited node (purple ring) at new location
final tempId = -DateTime.now().millisecondsSinceEpoch;
final editedTags = upload.getCombinedTags();
editedTags['_pending_upload'] = 'true'; // Mark as pending upload
editedTags['_original_node_id'] = session.originalNode.id.toString(); // Track original for line drawing
final editedNode = OsmNode(
id: tempId,
coord: upload.coord, // At new location
tags: editedTags,
);
NodeCache.instance.addOrUpdate([originalNode, editedNode]);
// Create cache entries based on operation type:
if (operation == UploadOperation.extract) {
// For extract: only create new node, leave original unchanged
final tempId = -DateTime.now().millisecondsSinceEpoch;
final extractedTags = upload.getCombinedTags();
extractedTags['_pending_upload'] = 'true'; // Mark as pending upload
extractedTags['_original_node_id'] = session.originalNode.id.toString(); // Track original for line drawing
final extractedNode = OsmNode(
id: tempId,
coord: upload.coord, // At new location
tags: extractedTags,
);
NodeCache.instance.addOrUpdate([extractedNode]);
} else {
// For modify: mark original with grey ring and create new temp node
// 1. Mark the original node with _pending_edit (grey ring) at original location
final originalTags = Map<String, String>.from(session.originalNode.tags);
originalTags['_pending_edit'] = 'true'; // Mark original as having pending edit
final originalNode = OsmNode(
id: session.originalNode.id,
coord: session.originalNode.coord, // Keep at original location
tags: originalTags,
);
// 2. Create new temp node for the edited node (purple ring) at new location
final tempId = -DateTime.now().millisecondsSinceEpoch;
final editedTags = upload.getCombinedTags();
editedTags['_pending_upload'] = 'true'; // Mark as pending upload
editedTags['_original_node_id'] = session.originalNode.id.toString(); // Track original for line drawing
final editedNode = OsmNode(
id: tempId,
coord: upload.coord, // At new location
tags: editedTags,
);
NodeCache.instance.addOrUpdate([originalNode, editedNode]);
}
// Notify node provider to update the map
CameraProviderWithCache.instance.notifyListeners();
@@ -158,16 +193,17 @@ class UploadQueueState extends ChangeNotifier {
// Start the upload processing loop
void startUploader({
required bool offlineMode,
required bool pauseQueueProcessing,
required UploadMode uploadMode,
required Future<String?> Function() getAccessToken,
}) {
_uploadTimer?.cancel();
// No uploads without queue, or if offline mode is enabled.
if (_queue.isEmpty || offlineMode) return;
// No uploads if queue is empty, offline mode is enabled, or queue processing is paused
if (_queue.isEmpty || offlineMode || pauseQueueProcessing) return;
_uploadTimer = Timer.periodic(const Duration(seconds: 10), (t) async {
if (_queue.isEmpty || offlineMode) {
if (_queue.isEmpty || offlineMode || pauseQueueProcessing) {
_uploadTimer?.cancel();
return;
}
@@ -271,7 +307,8 @@ class UploadQueueState extends ChangeNotifier {
// Clean up any temp nodes at the same coordinate
NodeCache.instance.removeTempNodesByCoordinate(item.coord);
// For edits, also clean up the original node's _pending_edit marker
// For modify operations, clean up the original node's _pending_edit marker
// For extract operations, we don't modify the original node so leave it unchanged
if (item.isEdit && item.originalNodeId != null) {
// Remove the _pending_edit marker from the original node in cache
// The next Overpass fetch will provide the authoritative data anyway
@@ -293,13 +330,33 @@ class UploadQueueState extends ChangeNotifier {
}
}
// Helper method to format multiple directions as a string or number
dynamic _formatDirectionsAsString(List<double> directions) {
// Helper method to format multiple directions for submission, supporting profile FOV
dynamic _formatDirectionsForSubmission(List<double> directions, NodeProfile? profile) {
if (directions.isEmpty) return 0.0;
// If profile has FOV, convert center directions to range notation
if (profile?.fov != null && profile!.fov! > 0) {
final ranges = directions.map((center) =>
_formatDirectionWithFov(center, profile.fov!)
).toList();
return ranges.length == 1 ? ranges.first : ranges.join(';');
}
// No profile FOV: use original format (single number or semicolon-separated)
if (directions.length == 1) return directions.first;
return directions.map((d) => d.round().toString()).join(';');
}
// Convert a center direction and FOV to range notation (e.g., 180° center with 90° FOV -> "135-225")
String _formatDirectionWithFov(double center, double fov) {
final halfFov = fov / 2;
final start = (center - halfFov + 360) % 360;
final end = (center + halfFov) % 360;
return '${start.round()}-${end.round()}';
}
// ---------- Queue persistence ----------
Future<void> _saveQueue() async {
final prefs = await SharedPreferences.getInstance();

View File

@@ -2,16 +2,62 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../app_state.dart';
import '../dev_config.dart';
import '../models/node_profile.dart';
import '../models/operator_profile.dart';
import '../services/localization_service.dart';
import '../services/node_cache.dart';
import 'refine_tags_sheet.dart';
import 'proximity_warning_dialog.dart';
class AddNodeSheet extends StatelessWidget {
const AddNodeSheet({super.key, required this.session});
final AddNodeSession session;
void _checkProximityAndCommit(BuildContext context, AppState appState, LocalizationService locService) {
// Only check proximity if we have a target location
if (session.target == null) {
_commitWithoutCheck(context, appState, locService);
return;
}
// Check for nearby nodes within the configured distance
final nearbyNodes = NodeCache.instance.findNodesWithinDistance(
session.target!,
kNodeProximityWarningDistance,
);
if (nearbyNodes.isNotEmpty) {
// Show proximity warning dialog
showDialog<void>(
context: context,
builder: (context) => ProximityWarningDialog(
nearbyNodes: nearbyNodes,
distance: kNodeProximityWarningDistance,
onGoBack: () {
Navigator.of(context).pop(); // Close dialog
},
onSubmitAnyway: () {
Navigator.of(context).pop(); // Close dialog
_commitWithoutCheck(context, appState, locService);
},
),
);
} else {
// No nearby nodes, proceed with commit
_commitWithoutCheck(context, appState, locService);
}
}
void _commitWithoutCheck(BuildContext context, AppState appState, LocalizationService locService) {
appState.commitSession();
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(locService.t('node.queuedForUpload'))),
);
}
Widget _buildDirectionControls(BuildContext context, AppState appState, AddNodeSession session, LocalizationService locService) {
final requiresDirection = session.profile != null && session.profile!.requiresDirection;
@@ -66,34 +112,50 @@ class AddNodeSheet extends StatelessWidget {
onChanged: requiresDirection ? (v) => appState.updateSession(directionDeg: v) : null,
),
),
// Buttons on the right (only show if direction is required)
if (requiresDirection) ...[
const SizedBox(width: 8),
// Remove button
IconButton(
icon: const Icon(Icons.remove, size: 20),
onPressed: session.directions.length > 1 ? () => appState.removeDirection() : null,
tooltip: 'Remove current direction',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
// Direction control buttons - always show but grey out when direction not required
const SizedBox(width: 8),
// Remove button
IconButton(
icon: Icon(
Icons.remove,
size: 20,
color: requiresDirection ? null : Theme.of(context).disabledColor,
),
// Add button
IconButton(
icon: const Icon(Icons.add, size: 20),
onPressed: () => appState.addDirection(),
tooltip: 'Add new direction',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: requiresDirection && session.directions.length > 1
? () => appState.removeDirection()
: null,
tooltip: requiresDirection ? 'Remove current direction' : 'Direction not required for this profile',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
),
// Add button
IconButton(
icon: Icon(
Icons.add,
size: 20,
color: requiresDirection && session.directions.length < 8 ? null : Theme.of(context).disabledColor,
),
// Cycle button
IconButton(
icon: const Icon(Icons.repeat, size: 20),
onPressed: session.directions.length > 1 ? () => appState.cycleDirection() : null,
tooltip: 'Cycle through directions',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: requiresDirection && session.directions.length < 8 ? () => appState.addDirection() : null,
tooltip: requiresDirection
? (session.directions.length >= 8 ? 'Maximum 8 directions allowed' : 'Add new direction')
: 'Direction not required for this profile',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
),
// Cycle button
IconButton(
icon: Icon(
Icons.repeat,
size: 20,
color: requiresDirection ? null : Theme.of(context).disabledColor,
),
],
onPressed: requiresDirection && session.directions.length > 1
? () => appState.cycleDirection()
: null,
tooltip: requiresDirection ? 'Cycle through directions' : 'Direction not required for this profile',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
),
],
),
),
@@ -127,11 +189,7 @@ class AddNodeSheet extends StatelessWidget {
final appState = context.watch<AppState>();
void _commit() {
appState.commitSession();
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(locService.t('node.queuedForUpload'))),
);
_checkProximityAndCommit(context, appState, locService);
}
void _cancel() {

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

@@ -9,6 +9,7 @@ import '../dev_config.dart';
import '../services/localization_service.dart';
import '../services/offline_area_service.dart';
import '../services/offline_areas/offline_tile_utils.dart';
import 'download_started_dialog.dart';
class DownloadAreaDialog extends StatefulWidget {
final MapController controller;
@@ -275,16 +276,29 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
tileTypeName: selectedTileType?.name,
);
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(locService.t('download.downloadStarted')),
),
showDialog(
context: context,
builder: (context) => const DownloadStartedDialog(),
);
} catch (e) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
const Icon(Icons.error, color: Colors.red),
const SizedBox(width: 10),
Text(locService.t('download.title')),
],
),
content: Text(locService.t('download.downloadFailed', params: [e.toString()])),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(locService.t('actions.ok')),
),
],
),
);
}

View File

@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import '../services/localization_service.dart';
class DownloadStartedDialog extends StatelessWidget {
const DownloadStartedDialog({super.key});
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
return AlertDialog(
title: Row(
children: [
const Icon(Icons.download_for_offline, color: Colors.green),
const SizedBox(width: 10),
Text(locService.t('downloadStarted.title')),
],
),
content: Text(locService.t('downloadStarted.message')),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(locService.t('downloadStarted.ok')),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
Navigator.pushNamed(context, '/settings/offline');
},
child: Text(locService.t('downloadStarted.viewProgress')),
),
],
);
},
);
}
}

View File

@@ -2,17 +2,59 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../app_state.dart';
import '../dev_config.dart';
import '../models/node_profile.dart';
import '../models/operator_profile.dart';
import '../services/localization_service.dart';
import '../services/node_cache.dart';
import '../state/settings_state.dart';
import 'refine_tags_sheet.dart';
import 'advanced_edit_options_sheet.dart';
import 'proximity_warning_dialog.dart';
class EditNodeSheet extends StatelessWidget {
const EditNodeSheet({super.key, required this.session});
final EditNodeSession session;
void _checkProximityAndCommit(BuildContext context, AppState appState, LocalizationService locService) {
// Check for nearby nodes within the configured distance, excluding the node being edited
final nearbyNodes = NodeCache.instance.findNodesWithinDistance(
session.target,
kNodeProximityWarningDistance,
excludeNodeId: session.originalNode.id,
);
if (nearbyNodes.isNotEmpty) {
// Show proximity warning dialog
showDialog<void>(
context: context,
builder: (context) => ProximityWarningDialog(
nearbyNodes: nearbyNodes,
distance: kNodeProximityWarningDistance,
onGoBack: () {
Navigator.of(context).pop(); // Close dialog
},
onSubmitAnyway: () {
Navigator.of(context).pop(); // Close dialog
_commitWithoutCheck(context, appState, locService);
},
),
);
} else {
// No nearby nodes, proceed with commit
_commitWithoutCheck(context, appState, locService);
}
}
void _commitWithoutCheck(BuildContext context, AppState appState, LocalizationService locService) {
appState.commitEditSession();
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(locService.t('node.editQueuedForUpload'))),
);
}
Widget _buildDirectionControls(BuildContext context, AppState appState, EditNodeSession session, LocalizationService locService) {
final requiresDirection = session.profile != null && session.profile!.requiresDirection;
@@ -67,34 +109,50 @@ class EditNodeSheet extends StatelessWidget {
onChanged: requiresDirection ? (v) => appState.updateEditSession(directionDeg: v) : null,
),
),
// Buttons on the right (only show if direction is required)
if (requiresDirection) ...[
const SizedBox(width: 8),
// Remove button
IconButton(
icon: const Icon(Icons.remove, size: 20),
onPressed: session.directions.length > 1 ? () => appState.removeDirection() : null,
tooltip: 'Remove current direction',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
// Direction control buttons - always show but grey out when direction not required
const SizedBox(width: 8),
// Remove button
IconButton(
icon: Icon(
Icons.remove,
size: 20,
color: requiresDirection ? null : Theme.of(context).disabledColor,
),
// Add button
IconButton(
icon: const Icon(Icons.add, size: 20),
onPressed: () => appState.addDirection(),
tooltip: 'Add new direction',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: requiresDirection && session.directions.length > 1
? () => appState.removeDirection()
: null,
tooltip: requiresDirection ? 'Remove current direction' : 'Direction not required for this profile',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
),
// Add button
IconButton(
icon: Icon(
Icons.add,
size: 20,
color: requiresDirection && session.directions.length < 8 ? null : Theme.of(context).disabledColor,
),
// Cycle button
IconButton(
icon: const Icon(Icons.repeat, size: 20),
onPressed: session.directions.length > 1 ? () => appState.cycleDirection() : null,
tooltip: 'Cycle through directions',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: requiresDirection && session.directions.length < 8 ? () => appState.addDirection() : null,
tooltip: requiresDirection
? (session.directions.length >= 8 ? 'Maximum 8 directions allowed' : 'Add new direction')
: 'Direction not required for this profile',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
),
// Cycle button
IconButton(
icon: Icon(
Icons.repeat,
size: 20,
color: requiresDirection ? null : Theme.of(context).disabledColor,
),
],
onPressed: requiresDirection && session.directions.length > 1
? () => appState.cycleDirection()
: null,
tooltip: requiresDirection ? 'Cycle through directions' : 'Direction not required for this profile',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
),
],
),
),
@@ -128,11 +186,7 @@ class EditNodeSheet extends StatelessWidget {
final appState = context.watch<AppState>();
void _commit() {
appState.commitEditSession();
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(locService.t('node.editQueuedForUpload'))),
);
_checkProximityAndCommit(context, appState, locService);
}
void _cancel() {
@@ -142,7 +196,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;
@@ -194,7 +249,76 @@ 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: [
// Extract from way checkbox (only show if enabled in dev config)
if (kEnableNodeExtraction) ...[
CheckboxListTile(
title: Text(locService.t('editNode.extractFromWay')),
subtitle: Text(locService.t('editNode.extractFromWaySubtitle')),
value: session.extractFromWay,
onChanged: (value) {
appState.updateEditSession(extractFromWay: value);
},
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
),
const SizedBox(height: 8),
],
// Constraint info message (only show if extract is not checked or not enabled)
if (!kEnableNodeExtraction || !session.extractFromWay) ...[
Row(
children: [
const Icon(Icons.info_outline, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
locService.t('editNode.cannotMoveConstrainedNode'),
style: Theme.of(context).textTheme.bodyMedium,
),
),
],
),
const SizedBox(height: 8),
],
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
OutlinedButton.icon(
onPressed: () => _openAdvancedEdit(context),
icon: const Icon(Icons.open_in_new, size: 16),
label: Text(locService.t('actions.useAdvancedEditor')),
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 32),
),
),
],
),
],
),
),
if (!kEnableNodeEdits)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
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(
@@ -299,4 +423,12 @@ class EditNodeSheet extends StatelessWidget {
},
);
}
void _openAdvancedEdit(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => AdvancedEditOptionsSheet(node: session.originalNode),
);
}
}

View File

@@ -49,7 +49,7 @@ class _CameraMapMarkerState extends State<CameraMapMarker> {
void _onDoubleTap() {
_tapTimer?.cancel();
widget.mapController.move(widget.node.coord, widget.mapController.camera.zoom + 1);
widget.mapController.move(widget.node.coord, widget.mapController.camera.zoom + kNodeDoubleTapZoomDelta);
}
@override

View File

@@ -6,6 +6,7 @@ import 'package:latlong2/latlong.dart';
import '../../app_state.dart';
import '../../dev_config.dart';
import '../../models/osm_node.dart';
import '../../models/direction_fov.dart';
/// Helper class to build direction cone polygons for cameras
class DirectionConesBuilder {
@@ -20,10 +21,13 @@ class DirectionConesBuilder {
// Add session cones if in add-camera mode and profile requires direction
if (session != null && session.target != null && session.profile?.requiresDirection == true) {
final sessionFov = session.profile?.fov ?? (kDirectionConeHalfAngle * 2);
// Add current working direction (full opacity)
overlays.add(_buildCone(
overlays.add(_buildConeWithFov(
session.target!,
session.directionDegrees,
sessionFov,
zoom,
context: context,
isSession: true,
@@ -33,9 +37,10 @@ class DirectionConesBuilder {
// Add other directions (reduced opacity)
for (int i = 0; i < session.directions.length; i++) {
if (i != session.currentDirectionIndex) {
overlays.add(_buildCone(
overlays.add(_buildConeWithFov(
session.target!,
session.directions[i],
sessionFov,
zoom,
context: context,
isSession: true,
@@ -47,10 +52,13 @@ class DirectionConesBuilder {
// Add edit session cones if in edit-camera mode and profile requires direction
if (editSession != null && editSession.profile?.requiresDirection == true) {
final sessionFov = editSession.profile?.fov ?? (kDirectionConeHalfAngle * 2);
// Add current working direction (full opacity)
overlays.add(_buildCone(
overlays.add(_buildConeWithFov(
editSession.target,
editSession.directionDegrees,
sessionFov,
zoom,
context: context,
isSession: true,
@@ -60,9 +68,10 @@ class DirectionConesBuilder {
// Add other directions (reduced opacity)
for (int i = 0; i < editSession.directions.length; i++) {
if (i != editSession.currentDirectionIndex) {
overlays.add(_buildCone(
overlays.add(_buildConeWithFov(
editSession.target,
editSession.directions[i],
sessionFov,
zoom,
context: context,
isSession: true,
@@ -76,11 +85,12 @@ class DirectionConesBuilder {
for (final node in cameras) {
if (_isValidCameraWithDirection(node) &&
(editSession == null || node.id != editSession.originalNode.id)) {
// Build a cone for each direction
for (final direction in node.directionDeg) {
overlays.add(_buildCone(
// Build a cone for each direction+fov pair
for (final directionFov in node.directionFovPairs) {
overlays.add(_buildConeWithFov(
node.coord,
direction,
directionFov.centerDegrees,
directionFov.fovDegrees,
zoom,
context: context,
));
@@ -103,6 +113,30 @@ class DirectionConesBuilder {
node.tags['_pending_upload'] == 'true';
}
/// Build cone with variable FOV width - new method for range notation support
static Polygon _buildConeWithFov(
LatLng origin,
double bearingDeg,
double fovDegrees,
double zoom, {
required BuildContext context,
bool isPending = false,
bool isSession = false,
bool isActiveDirection = true,
}) {
return _buildConeInternal(
origin: origin,
bearingDeg: bearingDeg,
halfAngleDeg: fovDegrees / 2,
zoom: zoom,
context: context,
isPending: isPending,
isSession: isSession,
isActiveDirection: isActiveDirection,
);
}
/// Legacy method for backward compatibility - uses dev_config FOV
static Polygon _buildCone(
LatLng origin,
double bearingDeg,
@@ -112,7 +146,39 @@ class DirectionConesBuilder {
bool isSession = false,
bool isActiveDirection = true,
}) {
final halfAngle = kDirectionConeHalfAngle;
return _buildConeInternal(
origin: origin,
bearingDeg: bearingDeg,
halfAngleDeg: kDirectionConeHalfAngle,
zoom: zoom,
context: context,
isPending: isPending,
isSession: isSession,
isActiveDirection: isActiveDirection,
);
}
/// Internal cone building method that handles the actual rendering
static Polygon _buildConeInternal({
required LatLng origin,
required double bearingDeg,
required double halfAngleDeg,
required double zoom,
required BuildContext context,
bool isPending = false,
bool isSession = false,
bool isActiveDirection = true,
}) {
// Handle full circle case (360-degree FOV)
if (halfAngleDeg >= 180) {
return _buildFullCircle(
origin: origin,
zoom: zoom,
context: context,
isSession: isSession,
isActiveDirection: isActiveDirection,
);
}
// Calculate pixel-based radii
final outerRadiusPx = kNodeIconDiameter + (kNodeIconDiameter * kDirectionConeBaseLength);
@@ -124,7 +190,9 @@ class DirectionConesBuilder {
final innerRadius = innerRadiusPx * pixelToCoordinate;
// Number of points for the outer arc (within our directional range)
const int arcPoints = 12;
// Scale arc points based on FOV width for better rendering
final baseArcPoints = 12;
final arcPoints = math.max(6, (baseArcPoints * halfAngleDeg / 45).round());
LatLng project(double deg, double distance) {
final rad = deg * math.pi / 180;
@@ -139,13 +207,13 @@ class DirectionConesBuilder {
// Add outer arc points from left to right (counterclockwise for proper polygon winding)
for (int i = 0; i <= arcPoints; i++) {
final angle = bearingDeg - halfAngle + (i * 2 * halfAngle / arcPoints);
final angle = bearingDeg - halfAngleDeg + (i * 2 * halfAngleDeg / arcPoints);
points.add(project(angle, outerRadius));
}
// Add inner arc points from right to left (to close the donut shape)
for (int i = arcPoints; i >= 0; i--) {
final angle = bearingDeg - halfAngle + (i * 2 * halfAngle / arcPoints);
final angle = bearingDeg - halfAngleDeg + (i * 2 * halfAngleDeg / arcPoints);
points.add(project(angle, innerRadius));
}
@@ -162,4 +230,59 @@ class DirectionConesBuilder {
borderStrokeWidth: getDirectionConeBorderWidth(context),
);
}
/// Build a full circle for 360-degree FOV cases
static Polygon _buildFullCircle({
required LatLng origin,
required double zoom,
required BuildContext context,
bool isSession = false,
bool isActiveDirection = true,
}) {
// Calculate pixel-based radii
final outerRadiusPx = kNodeIconDiameter + (kNodeIconDiameter * kDirectionConeBaseLength);
final innerRadiusPx = kNodeIconDiameter + (2 * getNodeRingThickness(context));
// Convert pixels to coordinate distances with zoom scaling
final pixelToCoordinate = 0.00001 * math.pow(2, 15 - zoom);
final outerRadius = outerRadiusPx * pixelToCoordinate;
final innerRadius = innerRadiusPx * pixelToCoordinate;
// Create full circle with many points for smooth rendering
const int circlePoints = 36;
final points = <LatLng>[];
LatLng project(double deg, double distance) {
final rad = deg * math.pi / 180;
final dLat = distance * math.cos(rad);
final dLon =
distance * math.sin(rad) / math.cos(origin.latitude * math.pi / 180);
return LatLng(origin.latitude + dLat, origin.longitude + dLon);
}
// Add outer circle points
for (int i = 0; i < circlePoints; i++) {
final angle = i * 360.0 / circlePoints;
points.add(project(angle, outerRadius));
}
// Add inner circle points in reverse order to create donut
for (int i = circlePoints - 1; i >= 0; i--) {
final angle = i * 360.0 / circlePoints;
points.add(project(angle, innerRadius));
}
// Adjust opacity based on direction state
double opacity = kDirectionConeOpacity;
if (isSession && !isActiveDirection) {
opacity = kDirectionConeOpacity * 0.4;
}
return Polygon(
points: points,
color: kDirectionConeColor.withOpacity(opacity),
borderColor: kDirectionConeColor,
borderStrokeWidth: getDirectionConeBorderWidth(context),
);
}
}

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

@@ -47,7 +47,7 @@ class _SuspectedLocationMapMarkerState extends State<SuspectedLocationMapMarker>
void _onDoubleTap() {
_tapTimer?.cancel();
widget.mapController.move(widget.location.centroid, widget.mapController.camera.zoom + 1);
widget.mapController.move(widget.location.centroid, widget.mapController.camera.zoom + kNodeDoubleTapZoomDelta);
}
@override

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 unless extract is enabled.
InteractionOptions _getInteractionOptions(EditNodeSession? editSession) {
// Check if we're editing a constrained node that's not being extracted
if (editSession?.originalNode.isConstrained == true && editSession?.extractFromWay != true) {
// Constrained node (not extracting): only allow pinch zoom and rotation, disable ALL panning
return const InteractionOptions(
enableMultiFingerGestureRace: true,
flags: InteractiveFlag.pinchZoom | InteractiveFlag.rotate,
scrollWheelVelocity: kScrollWheelVelocity,
pinchZoomThreshold: kPinchZoomThreshold,
pinchMoveThreshold: kPinchMoveThreshold,
);
}
// Normal case: all interactions allowed with gesture race to prevent accidental rotation during zoom
return const InteractionOptions(
enableMultiFingerGestureRace: true,
flags: InteractiveFlag.doubleTapDragZoom |
InteractiveFlag.doubleTapZoom |
InteractiveFlag.drag |
InteractiveFlag.flingAnimation |
InteractiveFlag.pinchZoom |
InteractiveFlag.rotate |
InteractiveFlag.scrollWheelZoom,
scrollWheelVelocity: kScrollWheelVelocity,
pinchZoomThreshold: kPinchZoomThreshold,
pinchMoveThreshold: kPinchMoveThreshold,
);
}
/// Show zoom warning if user is below minimum zoom level
void _showZoomWarningIfNeeded(BuildContext context, double currentZoom, int minZoom) {
// Only show warning once per zoom level to avoid spam
@@ -284,7 +317,6 @@ class MapViewState extends State<MapView> {
}
void _refreshNodesFromProvider() {
final appState = context.read<AppState>();
_cameraController.refreshCamerasFromProvider(
@@ -296,9 +328,6 @@ class MapViewState extends State<MapView> {
}
@override
void didUpdateWidget(covariant MapView oldWidget) {
super.didUpdateWidget(oldWidget);
@@ -316,13 +345,6 @@ class MapViewState extends State<MapView> {
}
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
@@ -357,6 +379,19 @@ class MapViewState extends State<MapView> {
} catch (_) {/* controller not ready yet */}
}
// Check for pending snap backs (when extract checkbox is unchecked)
final snapBackTarget = appState.consumePendingSnapBack();
if (snapBackTarget != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_controller.animateTo(
dest: snapBackTarget,
zoom: _controller.mapController.camera.zoom,
curve: Curves.easeOut,
duration: const Duration(milliseconds: 250),
);
});
}
// Edit sessions don't need to center - we're already centered from the node tap
// SheetAwareMap handles the visual positioning
@@ -553,20 +588,60 @@ class MapViewState extends State<MapView> {
options: MapOptions(
initialCenter: _gpsController.currentLocation ?? _positionManager.initialLocation ?? LatLng(37.7749, -122.4194),
initialZoom: _positionManager.initialZoom ?? 15,
minZoom: 1.0,
maxZoom: (appState.selectedTileType?.maxZoom ?? 18).toDouble(),
interactionOptions: _getInteractionOptions(editSession),
onPositionChanged: (pos, gesture) {
setState(() {}); // Instant UI update for zoom, etc.
if (gesture) {
widget.onUserGesture();
}
// Enforce minimum zoom level for add/edit node sheets (but not tag sheet)
if ((session != null || editSession != null) && pos.zoom < kMinZoomForNodeEditingSheets) {
// User tried to zoom out below minimum - snap back to minimum zoom
_controller.animateTo(
dest: pos.center,
zoom: kMinZoomForNodeEditingSheets.toDouble(),
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
return; // Don't process other position updates
}
if (session != null) {
appState.updateSession(target: pos.center);
}
if (editSession != null) {
appState.updateEditSession(target: pos.center);
// For constrained nodes that are not being extracted, always snap back to original position
if (editSession.originalNode.isConstrained && !editSession.extractFromWay) {
final originalPos = editSession.originalNode.coord;
// Always keep session target as original position
appState.updateEditSession(target: originalPos);
// Only snap back if position actually drifted, and debounce to wait for gesture completion
if (pos.center.latitude != originalPos.latitude || pos.center.longitude != originalPos.longitude) {
_constrainedNodeSnapBack(() {
// Only animate if we're still in a constrained edit session and still drifted
final currentEditSession = appState.editSession;
if (currentEditSession?.originalNode.isConstrained == true && currentEditSession?.extractFromWay != true) {
final currentPos = _controller.mapController.camera.center;
if (currentPos.latitude != originalPos.latitude || currentPos.longitude != originalPos.longitude) {
_controller.animateTo(
dest: originalPos,
zoom: _controller.mapController.camera.zoom,
curve: Curves.easeOut,
duration: const Duration(milliseconds: 250),
);
}
}
});
}
} else {
// Normal unconstrained node - allow position updates
appState.updateEditSession(target: pos.center);
}
}
// Update provisional pin location during navigation search/routing
@@ -620,17 +695,22 @@ class MapViewState extends State<MapView> {
selectedTileType: appState.selectedTileType,
),
cameraLayers,
// Built-in scale bar from flutter_map, positioned relative to button bar
Scalebar(
alignment: Alignment.bottomLeft,
padding: EdgeInsets.only(
left: 8,
bottom: bottomPositionFromButtonBar(kScaleBarSpacingAboveButtonBar, MediaQuery.of(context).padding.bottom)
),
textStyle: TextStyle(color: Colors.black, fontWeight: FontWeight.bold),
lineColor: Colors.black,
strokeWidth: 3,
// backgroundColor removed in flutter_map >=8 (wrap in Container if needed)
// Built-in scale bar from flutter_map, positioned relative to button bar with safe area
Builder(
builder: (context) {
final safeArea = MediaQuery.of(context).padding;
return Scalebar(
alignment: Alignment.bottomLeft,
padding: EdgeInsets.only(
left: leftPositionWithSafeArea(8, safeArea),
bottom: bottomPositionFromButtonBar(kScaleBarSpacingAboveButtonBar, safeArea.bottom)
),
textStyle: TextStyle(color: Colors.black, fontWeight: FontWeight.bold),
lineColor: Colors.black,
strokeWidth: 3,
// backgroundColor removed in flutter_map >=8 (wrap in Container if needed)
);
},
),
],
),

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: node.isConstrained ? null : _deleteNode,
icon: const Icon(Icons.delete, size: 18),
label: Text(locService.t('actions.delete')),
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 36),
foregroundColor: node.isConstrained ? null : Colors.red,
),
),
const SizedBox(width: 12),
],
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(locService.t('actions.close')),
),
],
),
],
),
),
);
},
);
},
);
}

View File

@@ -0,0 +1,126 @@
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
import '../models/osm_node.dart';
import '../services/localization_service.dart';
class ProximityWarningDialog extends StatelessWidget {
final List<OsmNode> nearbyNodes;
final double distance;
final VoidCallback onGoBack;
final VoidCallback onSubmitAnyway;
const ProximityWarningDialog({
super.key,
required this.nearbyNodes,
required this.distance,
required this.onGoBack,
required this.onSubmitAnyway,
});
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
return AlertDialog(
icon: const Icon(
Icons.warning_amber_rounded,
color: Colors.orange,
size: 32,
),
title: Text(locService.t('proximityWarning.title')),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('proximityWarning.message',
params: [distance.toStringAsFixed(1)]),
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 16),
Text(
locService.t('proximityWarning.suggestion'),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontStyle: FontStyle.italic,
),
),
const SizedBox(height: 16),
Text(
locService.t('proximityWarning.nearbyNodes',
params: [nearbyNodes.length.toString()]),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
...nearbyNodes.take(3).map((node) => Padding(
padding: const EdgeInsets.only(left: 8.0, bottom: 4.0),
child: Text(
'${locService.t('proximityWarning.nodeInfo', params: [
node.id.toString(),
_getNodeTypeDescription(node, locService),
])}',
style: Theme.of(context).textTheme.bodySmall,
),
)),
if (nearbyNodes.length > 3)
Padding(
padding: const EdgeInsets.only(left: 8.0, top: 4.0),
child: Text(
locService.t('proximityWarning.andMore',
params: [(nearbyNodes.length - 3).toString()]),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontStyle: FontStyle.italic,
),
),
),
],
),
actions: [
TextButton(
onPressed: onGoBack,
child: Text(locService.t('proximityWarning.goBack')),
),
ElevatedButton(
onPressed: onSubmitAnyway,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
),
child: Text(locService.t('proximityWarning.submitAnyway')),
),
],
);
},
);
}
String _getNodeTypeDescription(OsmNode node, LocalizationService locService) {
// Try to get a meaningful description from the node's tags
final manMade = node.tags['man_made'];
final amenity = node.tags['amenity'];
final surveillance = node.tags['surveillance'];
final surveillanceType = node.tags['surveillance:type'];
final manufacturer = node.tags['manufacturer'];
if (manMade == 'surveillance') {
if (surveillanceType == 'ALPR' || surveillanceType == 'ANPR') {
return locService.t('proximityWarning.nodeType.alpr');
} else if (surveillance == 'public') {
return locService.t('proximityWarning.nodeType.publicCamera');
} else {
return locService.t('proximityWarning.nodeType.camera');
}
} else if (amenity != null) {
return locService.t('proximityWarning.nodeType.amenity', params: [amenity]);
} else if (manufacturer != null) {
return locService.t('proximityWarning.nodeType.device', params: [manufacturer]);
} else {
return locService.t('proximityWarning.nodeType.unknown');
}
}
}

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.2.8+7 # The thing after the + is the version code, incremented with each release
version: 1.4.5+16 # 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