Compare commits

..

71 Commits

Author SHA1 Message Date
stopflock
f78ea1a300 no dev mode - publishing 2026-02-14 14:50:38 -06:00
stopflock
2dca311d2a changelog, version 2026-02-14 14:42:39 -06:00
stopflock
7470b14e38 Merge pull request #113 from dougborg/fix/overpass-out-skel
Use out skel for Overpass way/relation pass
2026-02-14 14:30:08 -06:00
Doug Borg
5df0170344 Use out skel for Overpass way/relation pass and add service tests
Switch the second Overpass pass (ways/relations) from out meta to out skel,
dropping unused tags/version/changeset fields from the response. The app only
reads structural references (node lists, relation members) from these elements.

Also inject http.Client into OverpassService for testability (matching
RoutingService pattern) and add close() for client lifecycle management.

14 tests covering query building, constraint detection, and error handling.

Fixes #108

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 13:26:22 -07:00
stopflock
1429f1d02b Merge pull request #41 from dougborg/fix/map-jitter-on-touch
Fix map jitter/wiggle when touching at low zoom
2026-02-14 13:17:43 -06:00
Doug Borg
f0f23489b5 Fix map jitter when touching map during follow-me animations
Cancel in-progress follow-me animations on pointer-down and suppress
new ones while any pointer is on the map. Without this, GPS position
updates trigger 600ms animateTo() calls that fight with the user's
stationary finger, causing visible wiggle — especially at low zoom
where small geographic shifts cover more pixels.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 02:02:51 -07:00
stopflock
c8e396a6eb Merge pull request #104 from dougborg/fix/tag-value-clear-on-done
Fix tag value reappearing after clearing and pressing Done
2026-02-10 20:31:25 -06:00
Doug Borg
75014be485 Remove simulation tests that don't exercise production code
The RawAutocomplete tests proved the pattern and fix but never
instantiated NSITagValueField itself — no actual coverage gained.
PR #36 replaces the widget entirely, so investing in testability
here isn't worthwhile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 19:01:09 -07:00
Doug Borg
af42e18f6e Guard onFieldSubmitted on non-empty text to prevent cleared values reappearing
When a user clears a tag value and presses Done, RawAutocomplete's
onFieldSubmitted auto-selects the first option from the suggestions
list. Since optionsBuilder returns all suggestions for empty text,
this causes the cleared value to reappear. Guarding the call on
non-empty text prevents the auto-selection while preserving
autocomplete behavior when the user has typed a partial match.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 18:56:28 -07:00
Doug Borg
c7cfdc471c Add tests demonstrating RawAutocomplete onSubmitted bug
RawAutocomplete.onFieldSubmitted auto-selects the first option when
called, which means pressing "Done" on an empty tag value field
re-populates it with the first NSI suggestion. These tests prove the
bug exists (unguarded path) and verify the fix (guarded path).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 18:56:18 -07:00
stopflock
19b3ca236e no longer lose operator profile selection when making other changes to a node 2026-02-10 18:28:07 -06:00
stopflock
2e0dcb1b2b bump version 2026-02-09 23:59:08 -06:00
stopflock
59afd75887 Merge pull request #42 from dougborg/fix/routing-empty-tags
Fix route calculation HTTP 400 caused by empty profile tag values
2026-02-09 18:26:12 -06:00
stopflock
83370fba7e Merge pull request #77 from dougborg/chore/roadmap-to-issues
Move README roadmap to GitHub Issues
2026-02-09 18:23:52 -06:00
stopflock
ba6c7cdbda Merge pull request #40 from dougborg/investigate/dropdown-dismiss
Fix suggestion dropdown dismiss on tap outside
2026-02-09 18:17:53 -06:00
Doug Borg
311125e1f5 Move README roadmap items to GitHub Issues
Replace the inline roadmap section with a link to GitHub Issues.
Each roadmap item has been created as a proper issue (#55-#76) for
better tracking, discussion, and prioritization.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 14:53:45 -07:00
Doug Borg
5abcc58a78 Address PR review: truncate error response logs and close http client
- Gate full error response body logging behind kDebugMode; truncate to
  500 chars in release builds to avoid log noise and data exposure
- Add RoutingService.close() and call from NavigationState.dispose()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 14:32:34 -07:00
Doug Borg
71776ee8f0 Fix route calculation HTTP 400 by filtering empty profile tags
Built-in profiles (Flock, Motorola, etc.) include placeholder empty
values like camera:mount: '' for user refinement. When these get
serialized into the routing request body, the alprwatch API rejects
them with HTTP 400.

Fix: strip empty-valued tags from enabled_profiles before sending
the routing request. Also refactor RoutingService to accept an
injectable http.Client for testability, and log error response
bodies for easier debugging of future API issues.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 14:32:34 -07:00
Doug Borg
6607e30038 Add tests for routing service and node profile serialization
Route calculation to alprwatch API fails with HTTP 400 because
built-in profiles include empty tag values (e.g. camera:mount: '')
that get serialized into the request body and rejected by the API.

Add routing_service_test.dart with 5 tests:
- Empty tags filtered from request (reproduces the bug)
- Successful route parsing
- HTTP error handling
- Network error wrapping
- API-level error surfacing

Add node_profile_test.dart with 4 tests:
- toJson/fromJson round-trip
- getDefaults returns expected profiles
- Empty tag values exist in defaults (documents bug origin)
- Equality based on id

Tests require RoutingService to accept an injectable http.Client,
which will be added in the next commit along with the fix.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 14:32:34 -07:00
Doug Borg
ef4205f4bd Make suggestion limit configurable and remove redundant .take(10) from widget
Move hardcoded suggestion limit to kNSIMaxSuggestions in dev_config, and remove
the redundant .take(10) from optionsBuilder since the fetch stage already caps
results.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 14:32:18 -07:00
Doug Borg
ef6fc1c9c8 Materialize options iterable to list in optionsViewBuilder
Avoids repeated iteration of the lazy .where().take() iterable on each
call to .length and .elementAt() in ListView.builder.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 14:31:37 -07:00
Doug Borg
26c85df7e8 Fix dropdown dismiss by replacing manual overlay with RawAutocomplete
NSITagValueField used raw OverlayEntry + CompositedTransformFollower
with no tap-outside dismiss mechanism, causing suggestion dropdowns to
stay visible when tapping elsewhere. Replace with Flutter's
RawAutocomplete which handles dismiss, keyboard navigation, and
accessibility out of the box.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 14:31:37 -07:00
stopflock
20c1b9b108 Merge pull request #46 from dougborg/docs/dev-setup-guide
Rewrite dev setup docs with tested instructions
2026-02-09 15:18:58 -06:00
Doug Borg
0207f999ee Address Copilot review feedback on PR #46
- build.gradle.kts: use maxOf(flutter.minSdkVersion, 23) to preserve
  the floor required by oauth2_client/flutter_web_auth_2
- DEVELOPER.md: replace hardcoded /opt/homebrew paths with
  $(brew --prefix) for Intel Mac compatibility, use $HOME instead
  of /Users/$USER for --sdk_root
- README.md: label quick-start as macOS-specific, add cross-platform
  pointer to DEVELOPER.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 13:59:11 -07:00
Doug Borg
4a342aee9d Bump Dart SDK constraint to >=3.8.0 and document Flutter 3.35+ requirement
The RadioGroup widget (merged via PR #35) requires Flutter 3.35+ /
Dart 3.8+. The old constraint (>=3.5.0) allowed older SDKs that don't
have RadioGroup, causing cryptic build errors instead of a clear
version mismatch from pub get.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 13:54:42 -07:00
Doug Borg
3827a6fa1d Rewrite dev setup docs with tested, copy-pasteable instructions
Replaces the vague "Latest stable version" prerequisites in DEVELOPER.md
with concrete commands tested on a fresh macOS machine. Adds Android SDK
setup without Android Studio, documents the gen_icons_splashes.sh
requirement, and fixes the OAuth2 config to reference build_keys.conf
instead of the removed keys.dart.example. Also includes Flutter SDK
auto-migrations (iOS 13.0 min, gradle minSdk).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 13:54:42 -07:00
stopflock
ed38e9467c Merge pull request #47 from dougborg/fix/map-bounds-null-safety
Fix null-safety issue with mapBounds in getNearbyNodes
2026-02-09 14:52:26 -06:00
Doug Borg
d124cee9b3 Fix null-safety issue with mapBounds in getNearbyNodes
Change mapBounds from LatLngBounds? to final LatLngBounds so the
compiler can prove it's non-null after the inner try-catch. Addresses
review comment on PR #45.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 13:47:15 -07:00
stopflock
c13dd8e58a Merge pull request #45 from dougborg/pr/fix-upstream-lint
Fix lint warnings after RadioGroup migration
2026-02-09 14:42:25 -06:00
Doug Borg
037165653c Fix lint warnings and cleanup unused code after RadioGroup migration
Remove unused imports, fields, variables, and dead code introduced
during the RadioGroup widget migration and prior changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 13:36:18 -07:00
stopflock
98b73fe019 Merge pull request #35 from dougborg/pr/03-radiogroup
Migrate Radio to RadioGroup widget
2026-02-09 14:26:34 -06:00
stopflock
86e0d656d3 Merge pull request #34 from dougborg/pr/02-lint-cleanup
Add flutter_lints and fix all analyzer warnings
2026-02-09 11:43:48 -06:00
stopflock
a149562001 Merge pull request #38 from dougborg/pr/add-pr-validation-workflow
Add PR validation workflow
2026-02-08 20:19:13 -06:00
Doug Borg
e4b36719d7 Migrate Radio groupValue/onChanged to RadioGroup widget 2026-02-08 14:23:37 -07:00
Doug Borg
3570104800 Add mounted guards for BuildContext use across async gaps 2026-02-08 14:23:07 -07:00
Doug Borg
4fddd8e807 Replace print() with debugPrint() across codebase
Fixes avoid_print lint warnings by using debugPrint which respects
release mode and avoids console overflow on mobile platforms.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 14:23:07 -07:00
Doug Borg
3dada20ec2 Replace deprecated withOpacity and surfaceVariant APIs
Migrate all withOpacity() calls to withValues(alpha:) and
surfaceVariant to surfaceContainerHighest across the codebase.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 14:23:07 -07:00
Doug Borg
c712aba724 Add flutter_lints and fix analyzer errors, dead code, and unused imports 2026-02-08 14:23:06 -07:00
stopflock
498e36f69d Merge pull request #39 from dougborg/pr/localization-fixes
Replace deprecated localization APIs and add test coverage
2026-02-08 12:53:25 -06:00
Doug Borg
e2d0d1b790 Replace deleted validation script with flutter test in build scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 22:31:48 -07:00
Doug Borg
61a2a99bbc Replace deprecated localization APIs and add test coverage
Use AssetManifest.loadFromAssetBundle instead of manually parsing the
deprecated AssetManifest.json. Fix a broken localization key reference
(queue.cameraWithIndex → queue.itemWithIndex).

Replace the standalone scripts/validate_localizations.dart with proper
flutter tests (11 tests across two groups): file integrity checks
(directory exists, en.json present, valid JSON structure, language code
file names, deep key-completeness across all locales) and t() lookup
tests (nested resolution, missing-key fallback, parameter substitution,
partial-path fallback).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 22:31:48 -07:00
Doug Borg
7e67859b2f Add PR validation workflow for analyze and test
No CI currently runs on pull requests — the only workflow triggers on
releases. This adds a lightweight, secrets-free workflow that runs
flutter analyze and flutter test on PRs to main and pushes to main.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 13:57:50 -07:00
stopflock
e559b86400 Merge pull request #33 from dougborg/pr/01-test-fixes
Fix pre-existing test failures in tile provider tests
2026-02-07 13:04:51 -06:00
Doug Borg
73160c32de Add mocktail dev dependency and fix test state leak
Address PR review comments:
- Add mocktail and flutter_test to dev_dependencies in pubspec.yaml
- Add tearDown to reset AppState.instance between tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 11:35:28 -07:00
Doug Borg
d6f7e99941 Fix pre-existing test failures in tile provider tests
- tile_provider_test: Fix stale package:flock_map_app import (now
  deflockapp), correct test assertion for Mapbox requiring API key
- deflock_tile_provider_test: Fix relative imports, replace invalid
  const TileLayer with final, mock AppState.instance for getImage test
- Remove widget_test.dart (default flutter create scaffold, references
  nonexistent MyApp counter widget)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 11:27:39 -07:00
stopflock
0ffb7956c5 clear search box after selecting first nav point 2026-02-06 21:08:01 -06:00
stopflock
2620c8758e dev mode, imperial units incl. custom scalebar 2026-02-06 20:28:08 -06:00
stopflock
8804fdadf4 Remove special "existing tags" profile case; existing tags profile now empty. 2026-02-06 09:56:08 -06:00
stopflock
c50d43e00c Pass through all extraneous tags on an existing node, always. Strip out special case for "existing tags" profile, replace with an empty temp profile so all can be treated the same. 2026-02-05 13:21:54 -06:00
stopflock
5df16f376d Move upload queue pause toggle from offline to queue page in settings 2026-02-03 17:00:02 -06:00
stopflock
38245bfb5b Ask for location permission first, notifications later. Roadmap. 2026-02-03 16:22:46 -06:00
stopflock
aba919f8d4 Fix submissions using existing tags profile by stripping non-xml-safe chars. Allow customizing changeset comment. 2026-02-01 22:22:31 -06:00
stopflock
659cf5c0f0 Fix fetching and loading indicator in sandbox 2026-02-01 18:38:31 -06:00
stopflock
ff5821b184 Prevent no-change edit submissions 2026-02-01 16:51:35 -06:00
stopflock
dd1838319d roadmap 2026-02-01 16:08:36 -06:00
stopflock
f76268f241 get rid of old versions of edited nodes when submitting in sim mode 2026-02-01 16:02:49 -06:00
stopflock
ba3b844c1e "existing tags" temp profile when editing, "existing operator" profile when such tags exist, full editing of existing nodes via refine tags 2026-02-01 14:50:47 -06:00
stopflock
1dd0258c0b cleanup, bump version 2026-01-31 17:35:42 -06:00
stopflock
83d7814fb6 Network status indicator should only respect the latest / current request. Others finish in background. Replace stupid bools with an enum to track state. Be smarter about split requests. 2026-01-31 17:21:31 -06:00
stopflock
d095736078 roadmap 2026-01-31 17:18:58 -06:00
stopflock
77648db32f roadmap 2026-01-31 17:06:09 -06:00
stopflock
9a17d7e666 Network status indicator should only respect the latest / current request. Others finish in background. 2026-01-31 14:22:33 -06:00
stopflock
a34b786918 roadmap 2026-01-30 22:33:27 -06:00
stopflock
6707efebbe Fix color of "get more" link text in profile dropdown 2026-01-30 21:49:27 -06:00
stopflock
69ebd43e0d bump to 2.6 2026-01-30 21:37:12 -06:00
stopflock
79d2fe711d Monolithic reimplementation of node fetching from overpass/offline areas. Prevent submissions in areas without cache coverage. Also fixes offline node loading. 2026-01-30 21:34:55 -06:00
stopflock
4a36c52982 Node fetch rework 2026-01-30 19:11:00 -06:00
stopflock
f478a3eb2d Clean up FOV cone debug logging 2026-01-30 18:55:29 -06:00
stopflock
9621e5f35a "Get more" link in profile dropdown, suggest identify page when creating profile 2026-01-30 12:56:50 -06:00
stopflock
f048ebc7db Merge pull request #28 from heathdutton/issue-27-search-viewbox
Pass (rough) viewbox to search for location-biased results
2026-01-29 12:45:35 -06:00
Heath Dutton🕴️
33ae6473bb pass viewbox to nominatim search for location-biased results 2026-01-29 10:42:56 -05:00
126 changed files with 5098 additions and 2360 deletions

26
.github/workflows/pr.yml vendored Normal file
View File

@@ -0,0 +1,26 @@
name: Validate PR
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
validate:
name: Analyze & Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: subosito/flutter-action@v2
with:
channel: 'stable'
- run: flutter pub get
- name: Analyze
run: flutter analyze
- name: Test
run: flutter test

View File

@@ -58,8 +58,8 @@ jobs:
- name: Install dependencies
run: flutter pub get
- name: Validate localizations
run: dart run scripts/validate_localizations.dart
- name: Run tests
run: flutter test
- name: Generate icons and splash screens
run: |
@@ -110,8 +110,8 @@ jobs:
- name: Install dependencies
run: flutter pub get
- name: Validate localizations
run: dart run scripts/validate_localizations.dart
- name: Run tests
run: flutter test
- name: Generate icons and splash screens
run: |
@@ -155,8 +155,8 @@ jobs:
- name: Install dependencies
run: flutter pub get
- name: Validate localizations
run: dart run scripts/validate_localizations.dart
- name: Run tests
run: flutter test
- name: Generate icons and splash screens
run: |

View File

@@ -97,10 +97,15 @@ Changelog content is stored in `assets/changelog.json`:
### User Experience Flow
- **First Launch**: Welcome popup with "don't show again" option
- **Location Permission**: Requested immediately after welcome dialog on first launch (v2.6.3+)
- **First Submission**: Submission guide popup with best practices and resource links
- **Version Updates**: Changelog popup (only if content exists, no "don't show again")
- **Settings Access**: Complete changelog history available in Settings > About > Release Notes
### Permission Handling (Updated v2.6.3)
- **Location Permission**: Requested on first launch for core GPS functionality
- **Notification Permission**: Requested on-demand when user enables proximity alerts
### Privacy Integration
The welcome popup explains that the app:
- Runs entirely locally on device
@@ -365,11 +370,13 @@ Local cache contains production data. Showing production nodes in sandbox mode w
- **Dual alert types**: Push notifications (background) and visual banners (foreground)
- **Configurable distance**: 25-200 meter alert radius
- **Battery awareness**: Users explicitly opt into background location monitoring
- **On-demand permissions**: Notification permission requested only when user enables proximity alerts (v2.6.3+)
**Implementation notes:**
- Uses Flutter Local Notifications for cross-platform background alerts
- Simple RecentAlert tracking prevents duplicate notifications
- Visual callback system for in-app alerts when app is active
- Permission requests deferred until feature activation for better UX
### 9. Compass Indicator & North Lock
@@ -395,30 +402,39 @@ Local cache contains production data. Showing production nodes in sandbox mode w
**Why separate from follow mode:**
Users often want to follow their location while keeping the map oriented north. Previous "north up" follow mode was confusing because it didn't actually keep north up. This separation provides clear, predictable behavior.
### 10. Network Status Indicator (Simplified in v1.5.2+)
### 10. Network Status Indicator (Refactored in v2.6.1)
**Purpose**: Show loading and error states for surveillance data fetching only
**Simplified approach (v1.5.2+):**
- **Surveillance data focus**: Only tracks node/camera data loading, not tile loading
- **Visual feedback**: Tiles show their own loading progress naturally
- **Reduced complexity**: Eliminated tile completion tracking and multiple issue types
**Brutalist approach (v2.6.1+):**
- **Single enum state**: Replaced multiple boolean flags with simple `NetworkRequestStatus` enum
- **User-initiated only**: Only tracks latest user-initiated requests (pan/zoom), background requests ignored
- **Auto-reset timers**: Different timeouts for different states (success: 2s, errors: 5s, rate limit: 2min)
**Status types:**
- **Loading**: Shows when fetching surveillance data from APIs
- **Success**: Brief confirmation when data loads successfully
- **Timeout**: Network request timeouts
- **Limit reached**: When node display limit is hit
- **API issues**: Overpass/OSM API problems only
**Status states:**
```dart
enum NetworkRequestStatus {
idle, // No indicator shown
loading, // Initial request in progress
splitting, // Request being split due to limits/timeouts
success, // Data loaded successfully (brief confirmation)
timeout, // Request timed out
rateLimited, // API rate limited
noData, // No offline data available
error, // Other network errors
}
```
**What was removed:**
- Tile server issue tracking (tiles handle their own progress)
- "Both" network issue type (only surveillance data matters)
- Complex semaphore-based completion detection
- Tile-related status messages and localizations
**State transitions:**
- **Normal flow**: `idle``loading``success``idle`
- **Split requests**: `loading``splitting``success``idle`
- **Errors**: `loading``timeout/error/rateLimited``idle`
**Why the change:**
The previous approach tracked both tile loading and surveillance data, creating redundancy since tiles already show loading progress visually on the map. Users don't need to be notified about tile loading issues when they can see tiles loading/failing directly. Focusing only on surveillance data makes the indicator more purposeful and less noisy.
**Why the refactor:**
The previous system used multiple boolean flags with complex reconciliation logic, creating potential for conflicting states and race conditions. The new enum-based approach provides clear, explicit state management with predictable transitions and eliminates complexity while maintaining all functionality.
**Request ownership:**
Only user-initiated requests (pan, zoom, manual refresh) report status. Background requests (pre-fetch, cache warming) complete silently to avoid status indicator noise.
### 11. Suspected Locations (v1.8.0+: SQLite Database Storage)
@@ -784,33 +800,104 @@ The app uses a **clean, release-triggered workflow** that rebuilds from scratch
## Build & Development Setup
### Prerequisites
- **Flutter SDK**: Latest stable version
- **Xcode**: For iOS builds (macOS only)
- **Android Studio**: For Android builds
- **Git**: For version control
**macOS** (required for iOS builds; Android-only contributors can use macOS or Linux):
| Tool | Install | Notes |
|------|---------|-------|
| **Homebrew** | [brew.sh](https://brew.sh) | Package manager for macOS |
| **Flutter SDK 3.35+** | `brew install --cask flutter` | Installs Flutter + Dart (3.35+ required for RadioGroup widget) |
| **Xcode** | Mac App Store | Required for iOS builds |
| **CocoaPods** | `brew install cocoapods` | Required for iOS plugin resolution |
| **Android SDK** | See below | Required for Android builds |
| **Java 17+** | `brew install --cask temurin` | Required by Android toolchain (skip if already installed) |
After installing, verify with:
```bash
flutter doctor # All checks should be green
```
### Android SDK Setup (without Android Studio)
You don't need the full Android Studio IDE. Install the command-line tools and let Flutter's build system pull what it needs:
```bash
# 1. Install command-line tools
brew install --cask android-commandlinetools
# 2. Create the SDK directory and install required components
mkdir -p ~/Library/Android/sdk/licenses
# Write license acceptance hashes
printf "\n24333f8a63b6825ea9c5514f83c2829b004d1fee" > ~/Library/Android/sdk/licenses/android-sdk-license
printf "\n84831b9409646a918e30573bab4c9c91346d8abd" > ~/Library/Android/sdk/licenses/android-sdk-preview-license
# 3. Install platform tools and the SDK platform Flutter needs
"$(brew --prefix android-commandlinetools)/cmdline-tools/latest/bin/sdkmanager" \
--sdk_root="$HOME/Library/Android/sdk" \
"platform-tools" "platforms;android-36" "build-tools;35.0.0"
# 4. Copy cmdline-tools into the SDK root (Flutter expects them there)
mkdir -p ~/Library/Android/sdk/cmdline-tools
cp -R "$(brew --prefix android-commandlinetools)/cmdline-tools/latest" \
~/Library/Android/sdk/cmdline-tools/latest
# 5. Point Flutter at the SDK and accept licenses
flutter config --android-sdk ~/Library/Android/sdk
yes | flutter doctor --android-licenses
```
> **Note:** The first `flutter build apk` will auto-download additional components it needs (NDK, CMake, etc). This is normal and only happens once.
### OAuth2 Setup
**Required registrations:**
To run the app with working OSM authentication, register OAuth2 applications:
1. **Production OSM**: https://www.openstreetmap.org/oauth2/applications
2. **Sandbox OSM**: https://master.apis.dev.openstreetmap.org/oauth2/applications
**Configuration:**
For local builds, create `build_keys.conf` (gitignored):
```bash
cp lib/keys.dart.example lib/keys.dart
# Edit keys.dart with your OAuth2 client IDs
cp build_keys.conf.example build_keys.conf
# Edit build_keys.conf with your OAuth2 client IDs
```
### iOS Setup
You can also pass keys directly via `--dart-define`:
```bash
cd ios && pod install
flutter run --dart-define=OSM_PROD_CLIENTID=your_id --dart-define=OSM_SANDBOX_CLIENTID=your_id
```
### First Build
```bash
# 1. Install dependencies
flutter pub get
# 2. Generate icons and splash screens (gitignored, must be regenerated)
./gen_icons_splashes.sh
# 3. Build Android
flutter build apk --debug \
--dart-define=OSM_PROD_CLIENTID=your_id \
--dart-define=OSM_SANDBOX_CLIENTID=your_id
# 4. Build iOS (macOS only, no signing needed for testing)
flutter build ios --no-codesign \
--dart-define=OSM_PROD_CLIENTID=your_id \
--dart-define=OSM_SANDBOX_CLIENTID=your_id
```
> **Important:** You must run `./gen_icons_splashes.sh` before the first build. The generated icons and splash screen assets are gitignored, so the build will fail without this step.
### Running
```bash
flutter pub get
./gen_icons_splashes.sh
flutter run --dart-define=OSM_PROD_CLIENT_ID=[your OAuth2 client ID]
# Run on connected device or simulator
flutter run --dart-define=OSM_PROD_CLIENTID=your_id --dart-define=OSM_SANDBOX_CLIENTID=your_id
# Or use the build script (reads keys from build_keys.conf)
./do_builds.sh # Both platforms
./do_builds.sh --android # Android only
./do_builds.sh --ios # iOS only
```
### Testing

View File

@@ -90,12 +90,16 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
- Code organization and contribution guidelines
- Debugging tips and troubleshooting
**Quick setup:**
**Quick setup (macOS with Homebrew):**
```shell
flutter pub get
cp lib/keys.dart.example lib/keys.dart
# Add OAuth2 client IDs, then: flutter run
brew install --cask flutter # Install Flutter SDK
brew install cocoapods # Required for iOS
flutter pub get # Install dependencies
./gen_icons_splashes.sh # Generate icons & splash screens (required before first build)
cp build_keys.conf.example build_keys.conf # Add your OSM OAuth2 client IDs
./do_builds.sh # Build both platforms
```
See [DEVELOPER.md](DEVELOPER.md) for cross-platform instructions and Android SDK setup.
**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.
@@ -103,44 +107,7 @@ cp lib/keys.dart.example lib/keys.dart
## Roadmap
### Needed Bugfixes
- Make submission guide scarier
- "More..." button in profiles dropdown -> identify page
- Node data fetching super slow; retries not working?
- Tile cache trimming? Does fluttermap handle?
- Filter NSI suggestions based on what has already been typed in
- NSI sometimes doesn't populate a dropdown, maybe always on the second tag added during an edit session?
- Clean cache when nodes have been deleted by others
- Are offline areas preferred for fast loading even when online? Check working.
### Current Development
- Add ability to downvote suspected locations which are old enough
- Turn by turn navigation or at least swipe nav sheet up to see a list
- Import/Export map providers
### On Pause
- Offline navigation (pending vector map tiles)
### Future Features & Wishlist
- Optional reason message when deleting
- Update offline area data while browsing?
- Save named locations to more easily navigate to home or work
### Maybes
- "Universal Links" for better handling of profile import when app not installed?
- Yellow ring for devices missing specific tag details
- Android Auto / CarPlay
- "Cache accumulating" offline area?
- "Offline areas" as tile provider?
- Grab the full latest database for each profile just like for suspected locations (instead of overpass)?
- Optional custom icons for profiles to aid identification
- Custom device providers and OSM/Overpass alternatives
- Offer options for extracting nodes which are 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??
See [GitHub Issues](https://github.com/FoggedLens/deflock-app/issues) for the full list of planned features, known bugs, and ideas.
---

View File

@@ -39,7 +39,7 @@ android {
// ────────────────────────────────────────────────────────────
// oauth2_client 4.x & flutter_web_auth_2 5.x require minSdk 23
// ────────────────────────────────────────────────────────────
minSdk = 23
minSdk = maxOf(flutter.minSdkVersion, 23)
targetSdk = 36
// Flutter tool injects these during `flutter build`

View File

@@ -1,4 +1,56 @@
{
"2.7.1": {
"content": [
"• Fixed operator profile selection being lost when moving node position, adjusting direction, or changing profiles",
"• Further improved node loading by only fetching what is needed to determine whether a node is attached to a way/relation"
]
},
"2.6.4": {
"content": [
"• Added imperial units support (miles, feet) in addition to metric units (km, meters)",
"• Moved units setting from Navigation to Language & Region settings page"
]
},
"2.6.3": {
"content": [
"• Improved first launch experience - location permission is now requested immediately after welcome dialog",
"• Prevent edit submissions where nothing (location, tags, direction) has been changed",
"• Allow customizing changeset comment on refine tags page",
"• Moved upload queue pause toggle to upload queue screen for better discoverability"
]
},
"2.6.2": {
"content": [
"• Enhanced edit workflow; new '<Existing tags>' profile preserves current tags while allowing direction and location edits",
"• New '<Existing operator>' profile when editing nodes with operator tags; preserves operator details automatically",
"• Tag pre-population; existing node values automatically fill empty profile tags to prevent data loss"
]
},
"2.6.1": {
"content": [
"• Simplified network status indicator - cleaner state management",
"• Improved error handling for surveillance data requests",
"• Better status reporting for background vs. user-initiated requests"
]
},
"2.6.0": {
"content": [
"• Fix slow node loading, offline node loading",
"• Prevent submissions when we have no data in that area"
]
},
"2.5.0": {
"content": [
"• NEW: 'Get more...' button in profile dropdowns - easily browse and import profiles from deflock.me/identify",
"• NEW: Profile creation choice dialog - when adding profiles in settings, choose between creating custom profiles or importing from website",
"• Enhanced profile discovery workflow - clearer path for users to find and import community-created profiles"
]
},
"2.4.4": {
"content": [
"• Search results now prioritize locations near your current map view"
]
},
"2.4.3": {
"content": [
"• Fixed 360° FOV rendering - devices with full circle coverage now render as complete rings instead of having a wedge cut out or being a line",

View File

@@ -80,9 +80,9 @@ fi
# Build the dart-define arguments
DART_DEFINE_ARGS="--dart-define=OSM_PROD_CLIENTID=$OSM_PROD_CLIENTID --dart-define=OSM_SANDBOX_CLIENTID=$OSM_SANDBOX_CLIENTID"
# Validate localizations before building
echo "Validating localizations..."
dart run scripts/validate_localizations.dart || exit 1
# Run tests before building
echo "Running tests..."
flutter test || exit 1
echo
appver=$(grep "version:" pubspec.yaml | head -1 | cut -d ':' -f 2 | tr -d ' ' | cut -d '+' -f 1)

View File

@@ -21,6 +21,6 @@
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>12.0</string>
<string>13.0</string>
</dict>
</plist>

View File

@@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '12.0'
# platform :ios, '13.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'

View File

@@ -455,7 +455,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -588,7 +588,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -639,7 +639,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;

View File

@@ -1,7 +1,7 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
import 'package:http/http.dart' as http;
import 'package:latlong2/latlong.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -14,14 +14,14 @@ import 'models/suspected_location.dart';
import 'models/tile_provider.dart';
import 'models/search_result.dart';
import 'services/offline_area_service.dart';
import 'services/node_cache.dart';
import 'services/map_data_provider.dart';
import 'services/node_data_manager.dart';
import 'services/tile_preview_service.dart';
import 'services/changelog_service.dart';
import 'services/operator_profile_service.dart';
import 'services/deep_link_service.dart';
import 'widgets/node_provider_with_cache.dart';
import 'services/profile_service.dart';
import 'widgets/proximity_warning_dialog.dart';
import 'widgets/reauth_messages_dialog.dart';
import 'dev_config.dart';
import 'state/auth_state.dart';
@@ -39,6 +39,7 @@ import 'state/upload_queue_state.dart';
export 'state/navigation_state.dart' show AppNavigationMode;
export 'state/settings_state.dart' show UploadMode, FollowMeMode;
export 'state/session_state.dart' show AddNodeSession, EditNodeSession;
export 'models/pending_upload.dart' show UploadOperation;
// ------------------ AppState ------------------
class AppState extends ChangeNotifier {
@@ -130,6 +131,7 @@ class AppState extends ChangeNotifier {
bool get isNavigationSearchLoading => _navigationState.isSearchLoading;
List<SearchResult> get navigationSearchResults => _navigationState.searchResults;
int get navigationAvoidanceDistance => _settingsState.navigationAvoidanceDistance;
DistanceUnit get distanceUnit => _settingsState.distanceUnit;
// Profile state
List<NodeProfile> get profiles => _profileState.profiles;
@@ -240,6 +242,9 @@ class AppState extends ChangeNotifier {
// Initialize OfflineAreaService to ensure offline areas are loaded
await OfflineAreaService().ensureInitialized();
// Preload offline nodes into cache for immediate display
await NodeDataManager().preloadOfflineNodes();
// Start uploader if conditions are met
_startUploader();
@@ -359,6 +364,7 @@ class AppState extends ChangeNotifier {
/// Show re-authentication dialog if needed
Future<void> checkAndPromptReauthForMessages(BuildContext context) async {
if (await needsReauthForMessages()) {
if (!context.mounted) return;
_showReauthDialog(context);
}
}
@@ -430,7 +436,7 @@ class AppState extends ChangeNotifier {
}
void startEditSession(OsmNode node) {
_sessionState.startEditSession(node, enabledProfiles);
_sessionState.startEditSession(node, enabledProfiles, operatorProfiles);
}
void updateSession({
@@ -439,6 +445,9 @@ class AppState extends ChangeNotifier {
OperatorProfile? operatorProfile,
LatLng? target,
Map<String, String>? refinedTags,
Map<String, String>? additionalExistingTags,
String? changesetComment,
bool updateOperatorProfile = false,
}) {
_sessionState.updateSession(
directionDeg: directionDeg,
@@ -446,6 +455,9 @@ class AppState extends ChangeNotifier {
operatorProfile: operatorProfile,
target: target,
refinedTags: refinedTags,
additionalExistingTags: additionalExistingTags,
changesetComment: changesetComment,
updateOperatorProfile: updateOperatorProfile,
);
// Check tutorial completion if position changed
@@ -461,6 +473,9 @@ class AppState extends ChangeNotifier {
LatLng? target,
bool? extractFromWay,
Map<String, String>? refinedTags,
Map<String, String>? additionalExistingTags,
String? changesetComment,
bool updateOperatorProfile = false,
}) {
_sessionState.updateEditSession(
directionDeg: directionDeg,
@@ -469,6 +484,9 @@ class AppState extends ChangeNotifier {
target: target,
extractFromWay: extractFromWay,
refinedTags: refinedTags,
additionalExistingTags: additionalExistingTags,
changesetComment: changesetComment,
updateOperatorProfile: updateOperatorProfile,
);
// Check tutorial completion if position changed
@@ -524,6 +542,8 @@ class AppState extends ChangeNotifier {
_sessionState.removeDirection();
}
bool get canRemoveDirection => _sessionState.canRemoveDirection;
void cycleDirection() {
_sessionState.cycleDirection();
}
@@ -569,8 +589,8 @@ class AppState extends ChangeNotifier {
}
// ---------- Navigation Methods - Simplified ----------
void enterSearchMode(LatLng mapCenter) {
_navigationState.enterSearchMode(mapCenter);
void enterSearchMode(LatLng mapCenter, {LatLngBounds? viewbox}) {
_navigationState.enterSearchMode(mapCenter, viewbox: viewbox);
}
void cancelNavigation() {
@@ -657,7 +677,7 @@ class AppState extends ChangeNotifier {
Future<void> setUploadMode(UploadMode mode) async {
// Clear node cache when switching upload modes to prevent mixing production/sandbox data
NodeCache.instance.clear();
MapDataProvider().clearCache();
debugPrint('[AppState] Cleared node cache due to upload mode change');
await _settingsState.setUploadMode(mode);
@@ -720,7 +740,11 @@ class AppState extends ChangeNotifier {
/// Set navigation avoidance distance
Future<void> setNavigationAvoidanceDistance(int distance) async {
await _settingsState.setNavigationAvoidanceDistance(distance);
}
}
Future<void> setDistanceUnit(DistanceUnit unit) async {
await _settingsState.setDistanceUnit(unit);
}
// ---------- Queue Methods ----------
void clearQueue() {
@@ -790,6 +814,31 @@ class AppState extends ChangeNotifier {
);
}
// ---------- Utility Methods ----------
/// Generate a default changeset comment for a submission
/// Handles special case of `<Existing tags>` profile by using "a" instead
static String generateDefaultChangesetComment({
required NodeProfile? profile,
required UploadOperation operation,
}) {
// Handle temp profiles with brackets by using "a"
final profileName = profile?.name.startsWith('<') == true && profile?.name.endsWith('>') == true
? 'a'
: profile?.name ?? 'surveillance';
switch (operation) {
case UploadOperation.create:
return 'Add $profileName surveillance node';
case UploadOperation.modify:
return 'Update $profileName surveillance node';
case UploadOperation.delete:
return 'Delete $profileName surveillance node';
case UploadOperation.extract:
return 'Extract $profileName surveillance node';
}
}
// ---------- Private Methods ----------
/// Attempts to fetch missing tile preview images in the background (fire and forget)
void _fetchMissingTilePreviews() {

View File

@@ -144,6 +144,7 @@ const int kDefaultMaxNodes = 500; // Default maximum number of nodes to render o
// NSI (Name Suggestion Index) configuration
const int kNSIMinimumHitCount = 500; // Minimum hit count for NSI suggestions to be considered useful
const int kNSIMaxSuggestions = 10; // Maximum number of tag value suggestions to fetch and display
// Map interaction configuration
const double kNodeDoubleTapZoomDelta = 1.0; // How much to zoom in when double-tapping nodes (was 1.0)

View File

@@ -47,12 +47,16 @@
},
"settings": {
"title": "Einstellungen",
"language": "Sprache",
"language": "Sprache & Region",
"systemDefault": "Systemstandard",
"aboutInfo": "Über / Informationen",
"aboutThisApp": "Über Diese App",
"aboutSubtitle": "App-Informationen und Credits",
"languageSubtitle": "Wählen Sie Ihre bevorzugte Sprache",
"languageSubtitle": "Wählen Sie Ihre bevorzugte Sprache und Einheiten",
"distanceUnit": "Entfernungseinheiten",
"distanceUnitSubtitle": "Wählen Sie zwischen metrischen (km/m) oder imperialen (mi/ft) Einheiten",
"metricUnits": "Metrisch (km, m)",
"imperialUnits": "Imperial (mi, ft)",
"maxNodes": "Max. angezeigte Knoten",
"maxNodesSubtitle": "Obergrenze für die Anzahl der Knoten auf der Karte festlegen.",
"maxNodesWarning": "Sie möchten das wahrscheinlich nicht tun, es sei denn, Sie sind absolut sicher, dass Sie einen guten Grund dafür haben.",
@@ -82,8 +86,7 @@
"enableNotifications": "Benachrichtigungen Aktivieren",
"checkingPermissions": "Berechtigungen prüfen...",
"alertDistance": "Warnentfernung: ",
"meters": "Meter",
"rangeInfo": "Bereich: {}-{} Meter (Standard: {})"
"rangeInfo": "Bereich: {}-{} {} (Standard: {})"
},
"node": {
"title": "Knoten #{}",
@@ -103,8 +106,8 @@
"mustBeLoggedIn": "Sie müssen angemeldet sein, um neue Knoten zu übertragen. Bitte melden Sie sich über die Einstellungen an.",
"enableSubmittableProfile": "Aktivieren Sie ein übertragbares Profil in den Einstellungen, um neue Knoten zu übertragen.",
"profileViewOnlyWarning": "Dieses Profil ist nur zum Anzeigen der Karte gedacht. Bitte wählen Sie ein übertragbares Profil aus, um neue Knoten zu übertragen.",
"refineTags": "Tags Verfeinern",
"refineTagsWithProfile": "Tags Verfeinern ({})"
"loadingAreaData": "Lade Bereichsdaten... Bitte warten Sie vor dem Übertragen.",
"refineTags": "Tags Verfeinern"
},
"editNode": {
"title": "Knoten #{} Bearbeiten",
@@ -118,12 +121,16 @@
"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.",
"loadingAreaData": "Lade Bereichsdaten... Bitte warten Sie vor dem Übertragen.",
"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 ({})"
"existingTags": "<Vorhandene Tags>",
"noChangesDetected": "Keine Änderungen erkannt - nichts zu übertragen",
"noChangesTitle": "Keine Änderungen zu Übertragen",
"noChangesMessage": "Sie haben keine Änderungen an diesem Knoten vorgenommen. Um eine Bearbeitung zu übertragen, müssen Sie den Standort, das Profil, die Richtungen oder die Tags ändern."
},
"download": {
"title": "Kartenbereich Herunterladen",
@@ -279,7 +286,14 @@
"view": "Anzeigen",
"deleteProfile": "Profil Löschen",
"deleteProfileConfirm": "Sind Sie sicher, dass Sie \"{}\" löschen möchten?",
"profileDeleted": "Profil gelöscht"
"profileDeleted": "Profil gelöscht",
"getMore": "Weitere anzeigen...",
"addProfileChoice": "Profil Hinzufügen",
"addProfileChoiceMessage": "Wie möchten Sie ein Profil hinzufügen?",
"createCustomProfile": "Benutzerdefiniertes Profil Erstellen",
"createCustomProfileDescription": "Erstellen Sie ein Profil von Grund auf mit Ihren eigenen Tags",
"importFromWebsite": "Von Webseite Importieren",
"importFromWebsiteDescription": "Profile von deflock.me/identify durchsuchen und importieren"
},
"mapTiles": {
"title": "Karten-Kacheln",
@@ -374,7 +388,11 @@
"profileTagsDescription": "Geben Sie Werte für Tags an, die verfeinert werden müssen:",
"selectValue": "Wert auswählen...",
"noValue": "(Kein Wert)",
"noSuggestions": "Keine Vorschläge verfügbar"
"noSuggestions": "Keine Vorschläge verfügbar",
"existingTagsTitle": "Vorhandene Tags",
"existingTagsDescription": "Bearbeiten Sie die vorhandenen Tags auf diesem Gerät. Hinzufügen, entfernen oder ändern Sie beliebige Tags:",
"existingOperator": "<Vorhandener Betreiber>",
"existingOperatorTags": "vorhandene Betreiber-Tags"
},
"layerSelector": {
"cannotChangeTileTypes": "Kachel-Typen können während des Herunterladens von Offline-Bereichen nicht geändert werden",
@@ -409,7 +427,9 @@
"timedOut": "Anfrage Zeitüberschreitung",
"noData": "Keine Offline-Daten",
"success": "Überwachungsdaten geladen",
"nodeDataSlow": "Überwachungsdaten langsam"
"nodeDataSlow": "Überwachungsdaten langsam",
"rateLimited": "Server-Limitierung",
"networkError": "Netzwerkfehler"
},
"nodeLimitIndicator": {
"message": "Zeige {rendered} von {total} Geräten",
@@ -481,13 +501,7 @@
"avoidanceDistance": "Vermeidungsabstand",
"avoidanceDistanceSubtitle": "Mindestabstand zu Überwachungsgeräten",
"searchHistory": "Max. Suchverlauf",
"searchHistorySubtitle": "Maximale Anzahl kürzlicher Suchen zum Merken",
"units": "Einheiten",
"unitsSubtitle": "Anzeigeeinheiten für Entfernungen und Messungen",
"metric": "Metrisch (km, m)",
"imperial": "Britisch (mi, ft)",
"meters": "Meter",
"feet": "Fuß"
"searchHistorySubtitle": "Maximale Anzahl kürzlicher Suchen zum Merken"
},
"suspectedLocations": {
"title": "Verdächtige Standorte",
@@ -523,5 +537,19 @@
"url": "URL",
"coordinates": "Koordinaten",
"noAddressAvailable": "Keine Adresse verfügbar"
},
"units": {
"meters": "m",
"feet": "ft",
"kilometers": "km",
"miles": "mi",
"metersLong": "Meter",
"feetLong": "Fuß",
"kilometersLong": "Kilometer",
"milesLong": "Meilen",
"metric": "Metrisch",
"imperial": "Imperial",
"metricDescription": "Metrisch (km, m)",
"imperialDescription": "Imperial (mi, ft)"
}
}

View File

@@ -84,12 +84,16 @@
},
"settings": {
"title": "Settings",
"language": "Language",
"language": "Language & Region",
"systemDefault": "System Default",
"aboutInfo": "About / Info",
"aboutThisApp": "About This App",
"aboutSubtitle": "App information and credits",
"languageSubtitle": "Choose your preferred language",
"languageSubtitle": "Choose your preferred language and units",
"distanceUnit": "Distance Units",
"distanceUnitSubtitle": "Choose between metric (km/m) or imperial (mi/ft) units",
"metricUnits": "Metric (km, m)",
"imperialUnits": "Imperial (mi, ft)",
"maxNodes": "Max nodes drawn",
"maxNodesSubtitle": "Set an upper limit for the number of nodes on the map.",
"maxNodesWarning": "You probably don't want to do that unless you are absolutely sure you have a good reason for it.",
@@ -119,8 +123,7 @@
"enableNotifications": "Enable Notifications",
"checkingPermissions": "Checking permissions...",
"alertDistance": "Alert distance: ",
"meters": "meters",
"rangeInfo": "Range: {}-{} meters (default: {})"
"rangeInfo": "Range: {}-{} {} (default: {})"
},
"node": {
"title": "Node #{}",
@@ -140,8 +143,8 @@
"mustBeLoggedIn": "You must be logged in to submit new nodes. Please log in via Settings.",
"enableSubmittableProfile": "Enable a submittable profile in Settings to submit new nodes.",
"profileViewOnlyWarning": "This profile is for map viewing only. Please select a submittable profile to submit new nodes.",
"refineTags": "Refine Tags",
"refineTagsWithProfile": "Refine Tags ({})"
"loadingAreaData": "Loading area data... Please wait before submitting.",
"refineTags": "Refine Tags"
},
"editNode": {
"title": "Edit Node #{}",
@@ -155,12 +158,16 @@
"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.",
"loadingAreaData": "Loading area data... Please wait before submitting.",
"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 ({})"
"existingTags": "<Existing tags>",
"noChangesDetected": "No changes detected - nothing to submit",
"noChangesTitle": "No Changes to Submit",
"noChangesMessage": "You haven't made any changes to this node. To submit an edit, you need to change the location, profile, directions, or tags."
},
"download": {
"title": "Download Map Area",
@@ -316,7 +323,14 @@
"view": "View",
"deleteProfile": "Delete Profile",
"deleteProfileConfirm": "Are you sure you want to delete \"{}\"?",
"profileDeleted": "Profile deleted"
"profileDeleted": "Profile deleted",
"getMore": "Get more...",
"addProfileChoice": "Add Profile",
"addProfileChoiceMessage": "How would you like to add a profile?",
"createCustomProfile": "Create Custom Profile",
"createCustomProfileDescription": "Build a profile from scratch with your own tags",
"importFromWebsite": "Import from Website",
"importFromWebsiteDescription": "Browse and import profiles from deflock.me/identify"
},
"mapTiles": {
"title": "Map Tiles",
@@ -411,7 +425,11 @@
"profileTagsDescription": "Complete these optional tag values for more detailed submissions:",
"selectValue": "Select value...",
"noValue": "(leave empty)",
"noSuggestions": "No suggestions available"
"noSuggestions": "No suggestions available",
"existingTagsTitle": "Existing Tags",
"existingTagsDescription": "Edit the existing tags on this device. Add, remove, or modify any tag:",
"existingOperator": "<Existing operator>",
"existingOperatorTags": "existing operator tags"
},
"layerSelector": {
"cannotChangeTileTypes": "Cannot change tile types while downloading offline areas",
@@ -446,7 +464,9 @@
"timedOut": "Request timed out",
"noData": "No offline data",
"success": "Surveillance data loaded",
"nodeDataSlow": "Surveillance data slow"
"nodeDataSlow": "Surveillance data slow",
"rateLimited": "Rate limited by server",
"networkError": "Network error"
},
"nodeLimitIndicator": {
"message": "Showing {rendered} of {total} devices",
@@ -481,13 +501,7 @@
"avoidanceDistance": "Avoidance Distance",
"avoidanceDistanceSubtitle": "Minimum distance to stay away from surveillance devices",
"searchHistory": "Max Search History",
"searchHistorySubtitle": "Maximum number of recent searches to remember",
"units": "Units",
"unitsSubtitle": "Display units for distances and measurements",
"metric": "Metric (km, m)",
"imperial": "Imperial (mi, ft)",
"meters": "meters",
"feet": "feet"
"searchHistorySubtitle": "Maximum number of recent searches to remember"
},
"suspectedLocations": {
"title": "Suspected Locations",
@@ -523,5 +537,19 @@
"url": "URL",
"coordinates": "Coordinates",
"noAddressAvailable": "No address available"
},
"units": {
"meters": "m",
"feet": "ft",
"kilometers": "km",
"miles": "mi",
"metersLong": "meters",
"feetLong": "feet",
"kilometersLong": "kilometers",
"milesLong": "miles",
"metric": "Metric",
"imperial": "Imperial",
"metricDescription": "Metric (km, m)",
"imperialDescription": "Imperial (mi, ft)"
}
}

View File

@@ -84,12 +84,16 @@
},
"settings": {
"title": "Configuración",
"language": "Idioma",
"language": "Idioma y Región",
"systemDefault": "Sistema por Defecto",
"aboutInfo": "Acerca de / Información",
"aboutThisApp": "Acerca de Esta App",
"aboutSubtitle": "Información de la aplicación y créditos",
"languageSubtitle": "Elige tu idioma preferido",
"languageSubtitle": "Elige tu idioma preferido y unidades",
"distanceUnit": "Unidades de Distancia",
"distanceUnitSubtitle": "Elige entre unidades métricas (km/m) o imperiales (mi/ft)",
"metricUnits": "Métrico (km, m)",
"imperialUnits": "Imperial (mi, ft)",
"maxNodes": "Máx. nodos dibujados",
"maxNodesSubtitle": "Establecer un límite superior para el número de nodos en el mapa.",
"maxNodesWarning": "Probablemente no quieras hacer eso a menos que estés absolutamente seguro de que tienes una buena razón para ello.",
@@ -119,8 +123,7 @@
"enableNotifications": "Habilitar Notificaciones",
"checkingPermissions": "Verificando permisos...",
"alertDistance": "Distancia de alerta: ",
"meters": "metros",
"rangeInfo": "Rango: {}-{} metros (predeterminado: {})"
"rangeInfo": "Rango: {}-{} {} (predeterminado: {})"
},
"node": {
"title": "Nodo #{}",
@@ -140,8 +143,8 @@
"mustBeLoggedIn": "Debe estar conectado para enviar nuevos nodos. Por favor, inicie sesión a través de Configuración.",
"enableSubmittableProfile": "Habilite un perfil envíable en Configuración para enviar nuevos nodos.",
"profileViewOnlyWarning": "Este perfil es solo para visualización del mapa. Por favor, seleccione un perfil envíable para enviar nuevos nodos.",
"refineTags": "Refinar Etiquetas",
"refineTagsWithProfile": "Refinar Etiquetas ({})"
"loadingAreaData": "Cargando datos del área... Por favor espere antes de enviar.",
"refineTags": "Refinar Etiquetas"
},
"editNode": {
"title": "Editar Nodo #{}",
@@ -155,12 +158,16 @@
"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.",
"loadingAreaData": "Cargando datos del área... Por favor espere antes de enviar.",
"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 ({})"
"existingTags": "<Etiquetas existentes>",
"noChangesDetected": "No se detectaron cambios - nada que enviar",
"noChangesTitle": "No Hay Cambios que Enviar",
"noChangesMessage": "No ha realizado ningún cambio en este nodo. Para enviar una edición, necesita cambiar la ubicación, el perfil, las direcciones o las etiquetas."
},
"download": {
"title": "Descargar Área del Mapa",
@@ -316,7 +323,14 @@
"view": "Ver",
"deleteProfile": "Eliminar Perfil",
"deleteProfileConfirm": "¿Está seguro de que desea eliminar \"{}\"?",
"profileDeleted": "Perfil eliminado"
"profileDeleted": "Perfil eliminado",
"getMore": "Obtener más...",
"addProfileChoice": "Añadir Perfil",
"addProfileChoiceMessage": "¿Cómo desea añadir un perfil?",
"createCustomProfile": "Crear Perfil Personalizado",
"createCustomProfileDescription": "Crear un perfil desde cero con sus propias etiquetas",
"importFromWebsite": "Importar desde Sitio Web",
"importFromWebsiteDescription": "Explorar e importar perfiles desde deflock.me/identify"
},
"mapTiles": {
"title": "Tiles de Mapa",
@@ -411,7 +425,11 @@
"profileTagsDescription": "Especifique valores para etiquetas que necesitan refinamiento:",
"selectValue": "Seleccionar un valor...",
"noValue": "(Sin valor)",
"noSuggestions": "No hay sugerencias disponibles"
"noSuggestions": "No hay sugerencias disponibles",
"existingTagsTitle": "Etiquetas Existentes",
"existingTagsDescription": "Edite las etiquetas existentes en este dispositivo. Agregue, elimine o modifique cualquier etiqueta:",
"existingOperator": "<Operador existente>",
"existingOperatorTags": "etiquetas de operador existentes"
},
"layerSelector": {
"cannotChangeTileTypes": "No se pueden cambiar los tipos de teselas mientras se descargan áreas sin conexión",
@@ -446,7 +464,9 @@
"timedOut": "Solicitud agotada",
"noData": "Sin datos sin conexión",
"success": "Datos de vigilancia cargados",
"nodeDataSlow": "Datos de vigilancia lentos"
"nodeDataSlow": "Datos de vigilancia lentos",
"rateLimited": "Limitado por el servidor",
"networkError": "Error de red"
},
"nodeLimitIndicator": {
"message": "Mostrando {rendered} de {total} dispositivos",
@@ -481,13 +501,7 @@
"avoidanceDistance": "Distancia de evitación",
"avoidanceDistanceSubtitle": "Distancia mínima para mantenerse alejado de dispositivos de vigilancia",
"searchHistory": "Historial máximo de búsqueda",
"searchHistorySubtitle": "Número máximo de búsquedas recientes para recordar",
"units": "Unidades",
"unitsSubtitle": "Unidades de visualización para distancias y medidas",
"metric": "Métrico (km, m)",
"imperial": "Imperial (mi, ft)",
"meters": "metros",
"feet": "pies"
"searchHistorySubtitle": "Número máximo de búsquedas recientes para recordar"
},
"suspectedLocations": {
"title": "Ubicaciones Sospechosas",
@@ -523,5 +537,19 @@
"url": "URL",
"coordinates": "Coordenadas",
"noAddressAvailable": "No hay dirección disponible"
},
"units": {
"meters": "m",
"feet": "ft",
"kilometers": "km",
"miles": "mi",
"metersLong": "metros",
"feetLong": "pies",
"kilometersLong": "kilómetros",
"milesLong": "millas",
"metric": "Métrico",
"imperial": "Imperial",
"metricDescription": "Métrico (km, m)",
"imperialDescription": "Imperial (mi, ft)"
}
}

View File

@@ -89,7 +89,11 @@
"aboutInfo": "À Propos / Informations",
"aboutThisApp": "À Propos de Cette App",
"aboutSubtitle": "Informations sur l'application et crédits",
"languageSubtitle": "Choisissez votre langue préférée",
"languageSubtitle": "Choisissez votre langue préférée et unités",
"distanceUnit": "Unités de Distance",
"distanceUnitSubtitle": "Choisir entre unités métriques (km/m) ou impériales (mi/ft)",
"metricUnits": "Métrique (km, m)",
"imperialUnits": "Impérial (mi, ft)",
"maxNodes": "Max. nœuds dessinés",
"maxNodesSubtitle": "Définir une limite supérieure pour le nombre de nœuds sur la carte.",
"maxNodesWarning": "Vous ne voulez probablement pas faire cela à moins d'être absolument sûr d'avoir une bonne raison de le faire.",
@@ -119,8 +123,7 @@
"enableNotifications": "Activer les Notifications",
"checkingPermissions": "Vérification des autorisations...",
"alertDistance": "Distance d'alerte : ",
"meters": "mètres",
"rangeInfo": "Plage : {}-{} mètres (par défaut : {})"
"rangeInfo": "Plage : {}-{} {} (par défaut : {})"
},
"node": {
"title": "Nœud #{}",
@@ -140,8 +143,8 @@
"mustBeLoggedIn": "Vous devez être connecté pour soumettre de nouveaux nœuds. Veuillez vous connecter via les Paramètres.",
"enableSubmittableProfile": "Activez un profil soumissible dans les Paramètres pour soumettre de nouveaux nœuds.",
"profileViewOnlyWarning": "Ce profil est uniquement pour la visualisation de la carte. Veuillez sélectionner un profil soumissible pour soumettre de nouveaux nœuds.",
"refineTags": "Affiner Balises",
"refineTagsWithProfile": "Affiner Balises ({})"
"loadingAreaData": "Chargement des données de zone... Veuillez patienter avant de soumettre.",
"refineTags": "Affiner Balises"
},
"editNode": {
"title": "Modifier Nœud #{}",
@@ -155,12 +158,16 @@
"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.",
"loadingAreaData": "Chargement des données de zone... Veuillez patienter avant de soumettre.",
"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 ({})"
"existingTags": "<Balises existantes>",
"noChangesDetected": "Aucun changement détecté - rien à soumettre",
"noChangesTitle": "Aucun Changement à Soumettre",
"noChangesMessage": "Vous n'avez apporté aucun changement à ce nœud. Pour soumettre une modification, vous devez changer l'emplacement, le profil, les directions ou les balises."
},
"download": {
"title": "Télécharger Zone de Carte",
@@ -316,7 +323,14 @@
"view": "Voir",
"deleteProfile": "Supprimer Profil",
"deleteProfileConfirm": "Êtes-vous sûr de vouloir supprimer \"{}\"?",
"profileDeleted": "Profil supprimé"
"profileDeleted": "Profil supprimé",
"getMore": "En obtenir plus...",
"addProfileChoice": "Ajouter Profil",
"addProfileChoiceMessage": "Comment souhaitez-vous ajouter un profil?",
"createCustomProfile": "Créer Profil Personnalisé",
"createCustomProfileDescription": "Créer un profil à partir de zéro avec vos propres balises",
"importFromWebsite": "Importer depuis Site Web",
"importFromWebsiteDescription": "Parcourir et importer des profils depuis deflock.me/identify"
},
"mapTiles": {
"title": "Tuiles de Carte",
@@ -411,7 +425,11 @@
"profileTagsDescription": "Spécifiez des valeurs pour les étiquettes qui nécessitent un raffinement :",
"selectValue": "Sélectionner une valeur...",
"noValue": "(Aucune valeur)",
"noSuggestions": "Aucune suggestion disponible"
"noSuggestions": "Aucune suggestion disponible",
"existingTagsTitle": "Balises Existantes",
"existingTagsDescription": "Modifiez les balises existantes sur cet appareil. Ajoutez, supprimez ou modifiez n'importe quelle balise :",
"existingOperator": "<Opérateur existant>",
"existingOperatorTags": "balises d'opérateur existantes"
},
"layerSelector": {
"cannotChangeTileTypes": "Impossible de changer les types de tuiles pendant le téléchargement des zones hors ligne",
@@ -446,7 +464,9 @@
"timedOut": "Demande expirée",
"noData": "Aucune donnée hors ligne",
"success": "Données de surveillance chargées",
"nodeDataSlow": "Données de surveillance lentes"
"nodeDataSlow": "Données de surveillance lentes",
"rateLimited": "Limité par le serveur",
"networkError": "Erreur réseau"
},
"nodeLimitIndicator": {
"message": "Affichage de {rendered} sur {total} appareils",
@@ -481,13 +501,7 @@
"avoidanceDistance": "Distance d'évitement",
"avoidanceDistanceSubtitle": "Distance minimale pour éviter les dispositifs de surveillance",
"searchHistory": "Historique de recherche max",
"searchHistorySubtitle": "Nombre maximum de recherches récentes à retenir",
"units": "Unités",
"unitsSubtitle": "Unités d'affichage pour distances et mesures",
"metric": "Métrique (km, m)",
"imperial": "Impérial (mi, ft)",
"meters": "mètres",
"feet": "pieds"
"searchHistorySubtitle": "Nombre maximum de recherches récentes à retenir"
},
"suspectedLocations": {
"title": "Emplacements Suspects",
@@ -523,5 +537,19 @@
"url": "URL",
"coordinates": "Coordonnées",
"noAddressAvailable": "Aucune adresse disponible"
},
"units": {
"meters": "m",
"feet": "ft",
"kilometers": "km",
"miles": "mi",
"metersLong": "mètres",
"feetLong": "pieds",
"kilometersLong": "kilomètres",
"milesLong": "milles",
"metric": "Métrique",
"imperial": "Impérial",
"metricDescription": "Métrique (km, m)",
"imperialDescription": "Impérial (mi, ft)"
}
}

View File

@@ -89,7 +89,11 @@
"aboutInfo": "Informazioni",
"aboutThisApp": "Informazioni su questa App",
"aboutSubtitle": "Informazioni sull'applicazione e crediti",
"languageSubtitle": "Scegli la tua lingua preferita",
"languageSubtitle": "Scegli la tua lingua preferita e unità",
"distanceUnit": "Unità di Distanza",
"distanceUnitSubtitle": "Scegli tra unità metriche (km/m) o imperiali (mi/ft)",
"metricUnits": "Metrico (km, m)",
"imperialUnits": "Imperiale (mi, ft)",
"maxNodes": "Max nodi disegnati",
"maxNodesSubtitle": "Imposta un limite superiore per il numero di nodi sulla mappa.",
"maxNodesWarning": "Probabilmente non vuoi farlo a meno che non sei assolutamente sicuro di avere una buona ragione per farlo.",
@@ -119,8 +123,7 @@
"enableNotifications": "Abilita Notifiche",
"checkingPermissions": "Controllo autorizzazioni...",
"alertDistance": "Distanza di avviso: ",
"meters": "metri",
"rangeInfo": "Intervallo: {}-{} metri (predefinito: {})"
"rangeInfo": "Intervallo: {}-{} {} (predefinito: {})"
},
"node": {
"title": "Nodo #{}",
@@ -140,8 +143,8 @@
"mustBeLoggedIn": "Devi essere loggato per inviare nuovi nodi. Per favore accedi tramite Impostazioni.",
"enableSubmittableProfile": "Abilita un profilo inviabile nelle Impostazioni per inviare nuovi nodi.",
"profileViewOnlyWarning": "Questo profilo è solo per la visualizzazione della mappa. Per favore seleziona un profilo inviabile per inviare nuovi nodi.",
"refineTags": "Affina Tag",
"refineTagsWithProfile": "Affina Tag ({})"
"loadingAreaData": "Caricamento dati area... Per favore attendi prima di inviare.",
"refineTags": "Affina Tag"
},
"editNode": {
"title": "Modifica Nodo #{}",
@@ -155,12 +158,16 @@
"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.",
"loadingAreaData": "Caricamento dati area... Per favore attendi prima di inviare.",
"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 ({})"
"existingTags": "<Tag esistenti>",
"noChangesDetected": "Nessuna modifica rilevata - niente da inviare",
"noChangesTitle": "Nessuna Modifica da Inviare",
"noChangesMessage": "Non hai apportato modifiche a questo nodo. Per inviare una modifica, devi cambiare la posizione, il profilo, le direzioni o i tag."
},
"download": {
"title": "Scarica Area Mappa",
@@ -316,7 +323,14 @@
"view": "Visualizza",
"deleteProfile": "Elimina Profilo",
"deleteProfileConfirm": "Sei sicuro di voler eliminare \"{}\"?",
"profileDeleted": "Profilo eliminato"
"profileDeleted": "Profilo eliminato",
"getMore": "Ottieni altri...",
"addProfileChoice": "Aggiungi Profilo",
"addProfileChoiceMessage": "Come desideri aggiungere un profilo?",
"createCustomProfile": "Crea Profilo Personalizzato",
"createCustomProfileDescription": "Crea un profilo da zero con i tuoi tag",
"importFromWebsite": "Importa da Sito Web",
"importFromWebsiteDescription": "Sfoglia e importa profili da deflock.me/identify"
},
"mapTiles": {
"title": "Tile Mappa",
@@ -411,7 +425,11 @@
"profileTagsDescription": "Specificare valori per i tag che necessitano di raffinamento:",
"selectValue": "Seleziona un valore...",
"noValue": "(Nessun valore)",
"noSuggestions": "Nessun suggerimento disponibile"
"noSuggestions": "Nessun suggerimento disponibile",
"existingTagsTitle": "Tag Esistenti",
"existingTagsDescription": "Modifica i tag esistenti su questo dispositivo. Aggiungi, rimuovi o modifica qualsiasi tag:",
"existingOperator": "<Operatore esistente>",
"existingOperatorTags": "tag operatore esistenti"
},
"layerSelector": {
"cannotChangeTileTypes": "Impossibile cambiare tipi di tile durante il download di aree offline",
@@ -446,7 +464,9 @@
"timedOut": "Richiesta scaduta",
"noData": "Nessun dato offline",
"success": "Dati di sorveglianza caricati",
"nodeDataSlow": "Dati di sorveglianza lenti"
"nodeDataSlow": "Dati di sorveglianza lenti",
"rateLimited": "Limitato dal server",
"networkError": "Errore di rete"
},
"nodeLimitIndicator": {
"message": "Mostra {rendered} di {total} dispositivi",
@@ -481,13 +501,7 @@
"avoidanceDistance": "Distanza di evitamento",
"avoidanceDistanceSubtitle": "Distanza minima da mantenere dai dispositivi di sorveglianza",
"searchHistory": "Cronologia ricerca max",
"searchHistorySubtitle": "Numero massimo di ricerche recenti da ricordare",
"units": "Unità",
"unitsSubtitle": "Unità di visualizzazione per distanze e misure",
"metric": "Metrico (km, m)",
"imperial": "Imperiale (mi, ft)",
"meters": "metri",
"feet": "piedi"
"searchHistorySubtitle": "Numero massimo di ricerche recenti da ricordare"
},
"suspectedLocations": {
"title": "Posizioni Sospette",
@@ -523,5 +537,19 @@
"url": "URL",
"coordinates": "Coordinate",
"noAddressAvailable": "Nessun indirizzo disponibile"
},
"units": {
"meters": "m",
"feet": "ft",
"kilometers": "km",
"miles": "mi",
"metersLong": "metri",
"feetLong": "piedi",
"kilometersLong": "chilometri",
"milesLong": "miglia",
"metric": "Metrico",
"imperial": "Imperiale",
"metricDescription": "Metrico (km, m)",
"imperialDescription": "Imperiale (mi, ft)"
}
}

View File

@@ -89,7 +89,11 @@
"aboutInfo": "Sobre / Informações",
"aboutThisApp": "Sobre este App",
"aboutSubtitle": "Informações do aplicativo e créditos",
"languageSubtitle": "Escolha seu idioma preferido",
"languageSubtitle": "Escolha seu idioma preferido e unidades",
"distanceUnit": "Unidades de Distância",
"distanceUnitSubtitle": "Escolha entre unidades métricas (km/m) ou imperiais (mi/ft)",
"metricUnits": "Métrico (km, m)",
"imperialUnits": "Imperial (mi, ft)",
"maxNodes": "Máx. de nós desenhados",
"maxNodesSubtitle": "Definir um limite superior para o número de nós no mapa.",
"maxNodesWarning": "Você provavelmente não quer fazer isso a menos que tenha certeza absoluta de que tem uma boa razão para isso.",
@@ -119,8 +123,7 @@
"enableNotifications": "Habilitar Notificações",
"checkingPermissions": "Verificando permissões...",
"alertDistance": "Distância de alerta: ",
"meters": "metros",
"rangeInfo": "Faixa: {}-{} metros (padrão: {})"
"rangeInfo": "Faixa: {}-{} {} (padrão: {})"
},
"node": {
"title": "Nó #{}",
@@ -140,8 +143,8 @@
"mustBeLoggedIn": "Você deve estar logado para enviar novos nós. Por favor, faça login via Configurações.",
"enableSubmittableProfile": "Ative um perfil enviável nas Configurações para enviar novos nós.",
"profileViewOnlyWarning": "Este perfil é apenas para visualização do mapa. Por favor, selecione um perfil enviável para enviar novos nós.",
"refineTags": "Refinar Tags",
"refineTagsWithProfile": "Refinar Tags ({})"
"loadingAreaData": "Carregando dados da área... Por favor aguarde antes de enviar.",
"refineTags": "Refinar Tags"
},
"editNode": {
"title": "Editar Nó #{}",
@@ -155,12 +158,16 @@
"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.",
"loadingAreaData": "Carregando dados da área... Por favor aguarde antes de enviar.",
"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 ({})"
"existingTags": "<Tags existentes>",
"noChangesDetected": "Nenhuma alteração detectada - nada para enviar",
"noChangesTitle": "Nenhuma Alteração para Enviar",
"noChangesMessage": "Você não fez nenhuma alteração neste nó. Para enviar uma edição, você precisa alterar a localização, o perfil, as direções ou as tags."
},
"download": {
"title": "Baixar Área do Mapa",
@@ -316,7 +323,14 @@
"view": "Ver",
"deleteProfile": "Excluir Perfil",
"deleteProfileConfirm": "Tem certeza de que deseja excluir \"{}\"?",
"profileDeleted": "Perfil excluído"
"profileDeleted": "Perfil excluído",
"getMore": "Obter mais...",
"addProfileChoice": "Adicionar Perfil",
"addProfileChoiceMessage": "Como gostaria de adicionar um perfil?",
"createCustomProfile": "Criar Perfil Personalizado",
"createCustomProfileDescription": "Construir um perfil do zero com suas próprias tags",
"importFromWebsite": "Importar do Site",
"importFromWebsiteDescription": "Navegar e importar perfis do deflock.me/identify"
},
"mapTiles": {
"title": "Tiles do Mapa",
@@ -411,7 +425,11 @@
"profileTagsDescription": "Especifique valores para tags que precisam de refinamento:",
"selectValue": "Selecionar um valor...",
"noValue": "(Sem valor)",
"noSuggestions": "Nenhuma sugestão disponível"
"noSuggestions": "Nenhuma sugestão disponível",
"existingTagsTitle": "Tags Existentes",
"existingTagsDescription": "Edite as tags existentes neste dispositivo. Adicione, remova ou modifique qualquer tag:",
"existingOperator": "<Operador existente>",
"existingOperatorTags": "tags de operador existentes"
},
"layerSelector": {
"cannotChangeTileTypes": "Não é possível alterar tipos de tiles durante o download de áreas offline",
@@ -446,7 +464,9 @@
"timedOut": "Solicitação expirada",
"noData": "Nenhum dado offline",
"success": "Dados de vigilância carregados",
"nodeDataSlow": "Dados de vigilância lentos"
"nodeDataSlow": "Dados de vigilância lentos",
"rateLimited": "Limitado pelo servidor",
"networkError": "Erro de rede"
},
"nodeLimitIndicator": {
"message": "Mostrando {rendered} de {total} dispositivos",
@@ -481,13 +501,7 @@
"avoidanceDistance": "Distância de evasão",
"avoidanceDistanceSubtitle": "Distância mínima para ficar longe de dispositivos de vigilância",
"searchHistory": "Histórico máximo de busca",
"searchHistorySubtitle": "Número máximo de buscas recentes para lembrar",
"units": "Unidades",
"unitsSubtitle": "Unidades de exibição para distâncias e medidas",
"metric": "Métrico (km, m)",
"imperial": "Imperial (mi, ft)",
"meters": "metros",
"feet": "pés"
"searchHistorySubtitle": "Número máximo de buscas recentes para lembrar"
},
"suspectedLocations": {
"title": "Localizações Suspeitas",
@@ -523,5 +537,19 @@
"url": "URL",
"coordinates": "Coordenadas",
"noAddressAvailable": "Nenhum endereço disponível"
},
"units": {
"meters": "m",
"feet": "ft",
"kilometers": "km",
"miles": "mi",
"metersLong": "metros",
"feetLong": "pés",
"kilometersLong": "quilômetros",
"milesLong": "milhas",
"metric": "Métrico",
"imperial": "Imperial",
"metricDescription": "Métrico (km, m)",
"imperialDescription": "Imperial (mi, ft)"
}
}

View File

@@ -89,7 +89,11 @@
"aboutInfo": "关于 / 信息",
"aboutThisApp": "关于此应用",
"aboutSubtitle": "应用程序信息和鸣谢",
"languageSubtitle": "选择您的首选语言",
"languageSubtitle": "选择您的首选语言和单位",
"distanceUnit": "距离单位",
"distanceUnitSubtitle": "选择公制 (公里/米) 或英制 (英里/英尺) 单位",
"metricUnits": "公制 (公里, 米)",
"imperialUnits": "英制 (英里, 英尺)",
"maxNodes": "最大节点绘制数",
"maxNodesSubtitle": "设置地图上节点数量的上限。",
"maxNodesWarning": "除非您确定有充分的理由,否则您可能不想这样做。",
@@ -119,8 +123,7 @@
"enableNotifications": "启用通知",
"checkingPermissions": "检查权限中...",
"alertDistance": "警报距离:",
"meters": "米",
"rangeInfo": "范围:{}-{} 米(默认:{}"
"rangeInfo": "范围:{}-{} {}(默认:{}"
},
"node": {
"title": "节点 #{}",
@@ -140,8 +143,8 @@
"mustBeLoggedIn": "您必须登录才能提交新节点。请通过设置登录。",
"enableSubmittableProfile": "在设置中启用可提交的配置文件以提交新节点。",
"profileViewOnlyWarning": "此配置文件仅用于地图查看。请选择可提交的配置文件来提交新节点。",
"refineTags": "细化标签",
"refineTagsWithProfile": "细化标签{}"
"loadingAreaData": "正在加载区域数据...提交前请稍候。",
"refineTags": "细化标签"
},
"editNode": {
"title": "编辑节点 #{}",
@@ -155,12 +158,16 @@
"sandboxModeWarning": "无法将生产节点的编辑提交到沙盒。在设置中切换到生产模式以编辑节点。",
"enableSubmittableProfile": "在设置中启用可提交的配置文件以编辑节点。",
"profileViewOnlyWarning": "此配置文件仅用于地图查看。请选择可提交的配置文件来编辑节点。",
"loadingAreaData": "正在加载区域数据...提交前请稍候。",
"cannotMoveConstrainedNode": "无法移动此相机 - 它连接到另一个地图元素OSM way/relation。您仍可以编辑其标签和方向。",
"zoomInRequiredMessage": "请放大至至少第{}级来添加或编辑监控节点。这确保精确定位以便准确制图。",
"extractFromWay": "从way/relation中提取节点",
"extractFromWaySubtitle": "创建具有相同标签的新节点,允许移动到新位置",
"refineTags": "细化标签",
"refineTagsWithProfile": "细化标签({}"
"existingTags": "<现有标签>",
"noChangesDetected": "未检测到更改 - 无需提交",
"noChangesTitle": "无更改可提交",
"noChangesMessage": "您尚未对此节点进行任何更改。要提交编辑,您需要更改位置、配置文件、方向或标签。"
},
"download": {
"title": "下载地图区域",
@@ -316,7 +323,14 @@
"view": "查看",
"deleteProfile": "删除配置文件",
"deleteProfileConfirm": "您确定要删除 \"{}\" 吗?",
"profileDeleted": "配置文件已删除"
"profileDeleted": "配置文件已删除",
"getMore": "获取更多...",
"addProfileChoice": "添加配置文件",
"addProfileChoiceMessage": "您希望如何添加配置文件?",
"createCustomProfile": "创建自定义配置文件",
"createCustomProfileDescription": "从头开始构建带有您自己标签的配置文件",
"importFromWebsite": "从网站导入",
"importFromWebsiteDescription": "浏览并从 deflock.me/identify 导入配置文件"
},
"mapTiles": {
"title": "地图瓦片",
@@ -411,7 +425,11 @@
"profileTagsDescription": "为需要细化的标签指定值:",
"selectValue": "选择值...",
"noValue": "(无值)",
"noSuggestions": "无建议可用"
"noSuggestions": "无建议可用",
"existingTagsTitle": "现有标签",
"existingTagsDescription": "编辑此设备上的现有标签。添加、删除或修改任何标签:",
"existingOperator": "<现有运营商>",
"existingOperatorTags": "现有运营商标签"
},
"layerSelector": {
"cannotChangeTileTypes": "在下载离线区域时无法更改瓦片类型",
@@ -446,7 +464,9 @@
"timedOut": "请求超时",
"noData": "无离线数据",
"success": "监控数据已加载",
"nodeDataSlow": "监控数据缓慢"
"nodeDataSlow": "监控数据缓慢",
"rateLimited": "服务器限流",
"networkError": "网络错误"
},
"nodeLimitIndicator": {
"message": "显示 {rendered} / {total} 设备",
@@ -481,13 +501,7 @@
"avoidanceDistance": "回避距离",
"avoidanceDistanceSubtitle": "与监控设备保持的最小距离",
"searchHistory": "最大搜索历史",
"searchHistorySubtitle": "要记住的最近搜索次数",
"units": "单位",
"unitsSubtitle": "距离和测量的显示单位",
"metric": "公制(公里,米)",
"imperial": "英制(英里,英尺)",
"meters": "米",
"feet": "英尺"
"searchHistorySubtitle": "要记住的最近搜索次数"
},
"suspectedLocations": {
"title": "疑似位置",
@@ -523,5 +537,19 @@
"url": "网址",
"coordinates": "坐标",
"noAddressAvailable": "无可用地址"
},
"units": {
"meters": "米",
"feet": "英尺",
"kilometers": "公里",
"miles": "英里",
"metersLong": "米",
"feetLong": "英尺",
"kilometersLong": "公里",
"milesLong": "英里",
"metric": "公制",
"imperial": "英制",
"metricDescription": "公制 (公里, 米)",
"imperialDescription": "英制 (英里, 英尺)"
}
}

View File

@@ -1,5 +1,4 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -147,7 +146,7 @@ class OneTimeMigrations {
debugPrint('[Migration] Stack trace: $stackTrace');
// Nuclear option: clear everything and show non-dismissible error dialog
if (context != null) {
if (context != null && context.mounted) {
NuclearResetDialog.show(context, error, stackTrace);
} else {
// If no context available, just log and hope for the best

View File

@@ -9,7 +9,7 @@ class DirectionFov {
DirectionFov(this.centerDegrees, this.fovDegrees);
@override
String toString() => 'DirectionFov(center: ${centerDegrees}°, fov: ${fovDegrees}°)';
String toString() => 'DirectionFov(center: $centerDegrees°, fov: $fovDegrees°)';
@override
bool operator ==(Object other) =>

View File

@@ -1,4 +1,4 @@
import 'package:uuid/uuid.dart';
import 'osm_node.dart';
/// Sentinel value for copyWith methods to distinguish between null and not provided
const Object _notProvided = Object();
@@ -264,5 +264,36 @@ class NodeProfile {
@override
int get hashCode => id.hashCode;
/// Create a temporary empty profile for editing existing nodes
/// Used as the default `<Existing tags>` option when editing nodes
/// All existing tags will flow through as additionalExistingTags
static NodeProfile createExistingTagsProfile(OsmNode node) {
// Calculate FOV from existing direction ranges if applicable
double? calculatedFov;
// If node has direction/FOV pairs, check if they all have the same FOV
if (node.directionFovPairs.isNotEmpty) {
final firstFov = node.directionFovPairs.first.fovDegrees;
// If all directions have the same FOV, use it for the profile
if (node.directionFovPairs.every((df) => df.fovDegrees == firstFov)) {
calculatedFov = firstFov;
}
}
return NodeProfile(
id: 'temp-empty-${node.id}',
name: '<Existing tags>', // Will be localized in UI
tags: {}, // Completely empty - all existing tags become additional
builtin: false,
requiresDirection: true,
submittable: true,
editable: false,
fov: calculatedFov, // Use calculated FOV from existing direction ranges
);
}
}

View File

@@ -1,4 +1,4 @@
import 'package:uuid/uuid.dart';
import 'osm_node.dart';
/// A bundle of OSM tags that describe a particular surveillance operator.
/// These are applied on top of camera profile tags during submissions.
@@ -76,4 +76,56 @@ class OperatorProfile {
@override
int get hashCode => id.hashCode;
/// Create a temporary operator profile from existing operator tags on a node
/// First tries to match against saved operator profiles, otherwise creates temporary one
/// Used as the default operator profile when editing nodes
static OperatorProfile? createExistingOperatorProfile(OsmNode node, List<OperatorProfile> savedProfiles) {
final operatorTags = _extractOperatorTags(node.tags);
if (operatorTags.isEmpty) return null;
// First, try to find a perfect match among saved profiles
for (final savedProfile in savedProfiles) {
if (_tagsMatch(savedProfile.tags, operatorTags)) {
return savedProfile;
}
}
// No perfect match found, create temporary profile
final operatorName = operatorTags['operator'] ?? '<existing>';
return OperatorProfile(
id: 'temp-existing-operator-${node.id}',
name: operatorName,
tags: operatorTags,
);
}
/// Check if two tag maps are identical
static bool _tagsMatch(Map<String, String> tags1, Map<String, String> tags2) {
if (tags1.length != tags2.length) return false;
for (final entry in tags1.entries) {
if (tags2[entry.key] != entry.value) return false;
}
return true;
}
/// Extract all operator-related tags from a node's tags
static Map<String, String> _extractOperatorTags(Map<String, String> tags) {
final operatorTags = <String, String>{};
for (final entry in tags.entries) {
// Include operator= and any operator:*= tags
if (entry.key == 'operator' || entry.key.startsWith('operator:')) {
operatorTags[entry.key] = entry.value;
}
}
return operatorTags;
}
/// Returns true if this is a temporary "existing operator" profile
bool get isExistingOperatorProfile => id.startsWith('temp-existing-operator-');
}

View File

@@ -22,6 +22,8 @@ class PendingUpload {
final NodeProfile? profile;
final OperatorProfile? operatorProfile;
final Map<String, String> refinedTags; // User-selected values for empty profile tags
final Map<String, String> additionalExistingTags; // Tags that exist on node but not in profile
final String changesetComment; // User-editable changeset comment
final UploadMode uploadMode; // Capture upload destination when queued
final UploadOperation operation; // Type of operation: create, modify, or delete
final int? originalNodeId; // If this is modify/delete, the ID of the original OSM node
@@ -45,6 +47,8 @@ class PendingUpload {
this.profile,
this.operatorProfile,
Map<String, String>? refinedTags,
Map<String, String>? additionalExistingTags,
required this.changesetComment,
required this.uploadMode,
required this.operation,
this.originalNodeId,
@@ -62,6 +66,7 @@ class PendingUpload {
this.nodeSubmissionAttempts = 0,
this.lastNodeSubmissionAttemptAt,
}) : refinedTags = refinedTags ?? {},
additionalExistingTags = additionalExistingTags ?? {},
assert(
(operation == UploadOperation.create && originalNodeId == null) ||
(operation == UploadOperation.create) || (originalNodeId != null),
@@ -222,7 +227,7 @@ class PendingUpload {
return DateTime.now().isAfter(nextRetryTime);
}
// Get combined tags from node profile, operator profile, and refined tags
// Get combined tags from node profile, operator profile, refined tags, and additional existing tags
Map<String, String> getCombinedTags() {
// Deletions don't need tags
if (operation == UploadOperation.delete || profile == null) {
@@ -231,15 +236,21 @@ class PendingUpload {
final tags = Map<String, String>.from(profile!.tags);
// Add additional existing tags first (these have lower precedence)
tags.addAll(additionalExistingTags);
// Apply profile tags again to ensure they take precedence over additional existing tags
tags.addAll(profile!.tags);
// Apply refined tags (these fill in empty values from the profile)
for (final entry in refinedTags.entries) {
// Only apply refined tags if the profile tag value is empty
if (tags.containsKey(entry.key) && tags[entry.key]?.trim().isEmpty == true) {
if (profile!.tags.containsKey(entry.key) && profile!.tags[entry.key]?.trim().isEmpty == true) {
tags[entry.key] = entry.value;
}
}
// Add operator profile tags (they override node profile tags if there are conflicts)
// Add operator profile tags (they override everything if there are conflicts)
if (operatorProfile != null) {
tags.addAll(operatorProfile!.tags);
}
@@ -269,6 +280,8 @@ class PendingUpload {
'profile': profile?.toJson(),
'operatorProfile': operatorProfile?.toJson(),
'refinedTags': refinedTags,
'additionalExistingTags': additionalExistingTags,
'changesetComment': changesetComment,
'uploadMode': uploadMode.index,
'operation': operation.index,
'originalNodeId': originalNodeId,
@@ -299,6 +312,10 @@ class PendingUpload {
refinedTags: j['refinedTags'] != null
? Map<String, String>.from(j['refinedTags'])
: {}, // Default empty map for legacy entries
additionalExistingTags: j['additionalExistingTags'] != null
? Map<String, String>.from(j['additionalExistingTags'])
: {}, // Default empty map for legacy entries
changesetComment: j['changesetComment'] ?? _generateLegacyComment(j), // Default for legacy entries
uploadMode: j['uploadMode'] != null
? UploadMode.values[j['uploadMode']]
: UploadMode.production, // Default for legacy entries
@@ -338,5 +355,25 @@ class PendingUpload {
if (error) return UploadState.error;
return UploadState.pending;
}
/// Generate a default changeset comment for legacy uploads that don't have one
static String _generateLegacyComment(Map<String, dynamic> j) {
final operation = j['operation'] != null
? UploadOperation.values[j['operation']]
: (j['originalNodeId'] != null ? UploadOperation.modify : UploadOperation.create);
final profileName = j['profile']?['name'] ?? 'surveillance';
switch (operation) {
case UploadOperation.create:
return 'Add $profileName surveillance node';
case UploadOperation.modify:
return 'Update $profileName surveillance node';
case UploadOperation.delete:
return 'Delete $profileName surveillance node';
case UploadOperation.extract:
return 'Extract $profileName surveillance node';
}
}
}

View File

@@ -1,4 +1,5 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
/// A suspected surveillance location from the CSV data
@@ -35,8 +36,8 @@ class SuspectedLocation {
bounds = coordinates.bounds;
} catch (e) {
// If GeoJSON parsing fails, use default coordinates
print('[SuspectedLocation] Failed to parse GeoJSON for ticket $ticketNo: $e');
print('[SuspectedLocation] Location string: $locationString');
debugPrint('[SuspectedLocation] Failed to parse GeoJSON for ticket $ticketNo: $e');
debugPrint('[SuspectedLocation] Location string: $locationString');
}
}
@@ -60,7 +61,7 @@ class SuspectedLocation {
// The geoJson IS the geometry object (not wrapped in a 'geometry' property)
final coordinates = geoJson['coordinates'] as List?;
if (coordinates == null || coordinates.isEmpty) {
print('[SuspectedLocation] No coordinates found in GeoJSON');
debugPrint('[SuspectedLocation] No coordinates found in GeoJSON');
return (centroid: const LatLng(0, 0), bounds: <LatLng>[]);
}
@@ -109,7 +110,7 @@ class SuspectedLocation {
}
break;
default:
print('Unsupported geometry type: $type');
debugPrint('Unsupported geometry type: $type');
}
if (points.isEmpty) {
@@ -127,7 +128,7 @@ class SuspectedLocation {
return (centroid: centroid, bounds: points);
} catch (e) {
print('Error extracting coordinates from GeoJSON: $e');
debugPrint('Error extracting coordinates from GeoJSON: $e');
return (centroid: const LatLng(0, 0), bounds: <LatLng>[]);
}
}

View File

@@ -3,7 +3,6 @@ import 'settings/sections/max_nodes_section.dart';
import 'settings/sections/proximity_alerts_section.dart';
import 'settings/sections/suspected_locations_section.dart';
import 'settings/sections/tile_provider_section.dart';
import 'settings/sections/network_status_section.dart';
import '../services/localization_service.dart';
class AdvancedSettingsScreen extends StatelessWidget {

View File

@@ -138,7 +138,8 @@ class NavigationCoordinator {
// Enter search mode
try {
final center = mapController.mapController.camera.center;
appState.enterSearchMode(center);
final viewbox = mapController.mapController.camera.visibleBounds;
appState.enterSearchMode(center, viewbox: viewbox);
} catch (e) {
debugPrint('[NavigationCoordinator] Could not get map center for search: $e');
// Fallback to default location

View File

@@ -9,7 +9,6 @@ import '../../widgets/add_node_sheet.dart';
import '../../widgets/edit_node_sheet.dart';
import '../../widgets/navigation_sheet.dart';
import '../../widgets/measured_sheet.dart';
import '../../state/settings_state.dart' show FollowMeMode;
/// Coordinates all bottom sheet operations including opening, closing, height tracking,
/// and sheet-related validation logic.
@@ -118,9 +117,8 @@ class SheetCoordinator {
controller.closed.then((_) {
_addSheetHeight = 0.0;
onStateChanged();
// Handle dismissal by canceling session if still active
final appState = context.read<AppState>();
if (appState.session != null) {
debugPrint('[SheetCoordinator] AddNodeSheet dismissed - canceling session');
appState.cancelSession();
@@ -187,9 +185,8 @@ class SheetCoordinator {
_editSheetHeight = 0.0;
_transitioningToEdit = false;
onStateChanged();
// Handle dismissal by canceling session if still active
final appState = context.read<AppState>();
if (appState.editSession != null) {
debugPrint('[SheetCoordinator] EditNodeSheet dismissed - canceling edit session');
appState.cancelEditSession();
@@ -266,7 +263,7 @@ class SheetCoordinator {
/// Restore the follow-me mode that was active before opening a node sheet
void _restoreFollowMeMode(AppState appState) {
if (_followMeModeBeforeSheet != null) {
debugPrint('[SheetCoordinator] Restoring follow-me mode: ${_followMeModeBeforeSheet}');
debugPrint('[SheetCoordinator] Restoring follow-me mode: $_followMeModeBeforeSheet');
appState.setFollowMeMode(_followMeModeBeforeSheet!);
_followMeModeBeforeSheet = null; // Clear stored state
}

View File

@@ -1,8 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map_animations/flutter_map_animations.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import '../app_state.dart';
@@ -10,12 +8,9 @@ import '../dev_config.dart';
import '../widgets/map_view.dart';
import '../services/localization_service.dart';
import '../widgets/add_node_sheet.dart';
import '../widgets/edit_node_sheet.dart';
import '../widgets/node_tag_sheet.dart';
import '../widgets/download_area_dialog.dart';
import '../widgets/measured_sheet.dart';
import '../widgets/navigation_sheet.dart';
import '../widgets/search_bar.dart';
import '../widgets/suspected_location_sheet.dart';
import '../widgets/welcome_dialog.dart';
@@ -27,6 +22,7 @@ import '../services/changelog_service.dart';
import 'coordinators/sheet_coordinator.dart';
import 'coordinators/navigation_coordinator.dart';
import 'coordinators/map_interaction_handler.dart';
import 'package:geolocator/geolocator.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@@ -153,6 +149,33 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
);
}
// Request location permission on first launch
Future<void> _requestLocationPermissionIfFirstLaunch() async {
if (!mounted) return;
try {
// Only request on first launch or if user has never seen welcome
final isFirstLaunch = await ChangelogService().isFirstLaunch();
final hasSeenWelcome = await ChangelogService().hasSeenWelcome();
if (isFirstLaunch || !hasSeenWelcome) {
// Check if location services are enabled
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
debugPrint('[HomeScreen] Location services disabled - skipping permission request');
return;
}
// Request location permission (this will show system dialog if needed)
final permission = await Geolocator.requestPermission();
debugPrint('[HomeScreen] First launch location permission result: $permission');
}
} catch (e) {
// Silently handle errors to avoid breaking the app launch
debugPrint('[HomeScreen] Error requesting location permission: $e');
}
}
// Check for and display welcome/changelog popup
Future<void> _checkForPopup() async {
if (!mounted) return;
@@ -162,6 +185,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
// Run any needed migrations first
final versionsNeedingMigration = await ChangelogService().getVersionsNeedingMigration();
if (!mounted) return;
for (final version in versionsNeedingMigration) {
await ChangelogService().runMigration(version, appState, context);
}
@@ -178,10 +202,15 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
barrierDismissible: false,
builder: (context) => const WelcomeDialog(),
);
// Request location permission right after welcome dialog on first launch
if (!mounted) return;
await _requestLocationPermissionIfFirstLaunch();
break;
case PopupType.changelog:
final changelogContent = await ChangelogService().getChangelogContentForDisplay();
if (!mounted) return;
if (changelogContent != null) {
await showDialog(
context: context,
@@ -220,35 +249,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
);
}
void _zoomAndCenterForRoute(bool followMeEnabled, LatLng? userLocation, LatLng? routeStart) {
try {
LatLng centerLocation;
if (followMeEnabled && userLocation != null) {
// Center on user if follow-me is enabled
centerLocation = userLocation;
debugPrint('[HomeScreen] Centering on user location for route start');
} else if (routeStart != null) {
// Center on start pin if user is far away or no GPS
centerLocation = routeStart;
debugPrint('[HomeScreen] Centering on route start pin');
} else {
debugPrint('[HomeScreen] No valid location to center on');
return;
}
// Animate to zoom 14 and center location
_mapController.animateTo(
dest: centerLocation,
zoom: 14.0,
duration: const Duration(milliseconds: 800),
curve: Curves.easeInOut,
);
} catch (e) {
debugPrint('[HomeScreen] Could not zoom/center for route: $e');
}
}
void _onResumeRoute() {
_navigationCoordinator.resumeRoute(
context: context,
@@ -370,9 +370,11 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
);
// Reset height and clear selection when sheet is dismissed
final appState = context.read<AppState>();
controller.closed.then((_) {
if (!mounted) return;
_sheetCoordinator.resetTagSheetHeight(() => setState(() {}));
context.read<AppState>().clearSuspectedLocationSelection();
appState.clearSuspectedLocationSelection();
});
}
@@ -546,7 +548,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Theme.of(context).shadowColor.withOpacity(0.3),
color: Theme.of(context).shadowColor.withValues(alpha: 0.3),
blurRadius: 10,
offset: Offset(0, -2),
)

View File

@@ -1,79 +1,115 @@
import 'package:flutter/material.dart';
import '../services/localization_service.dart';
import '../services/distance_service.dart';
import '../app_state.dart';
import '../state/settings_state.dart';
import 'package:provider/provider.dart';
class NavigationSettingsScreen extends StatelessWidget {
class NavigationSettingsScreen extends StatefulWidget {
const NavigationSettingsScreen({super.key});
@override
State<NavigationSettingsScreen> createState() => _NavigationSettingsScreenState();
}
class _NavigationSettingsScreenState extends State<NavigationSettingsScreen> {
late TextEditingController _distanceController;
@override
void initState() {
super.initState();
final appState = context.read<AppState>();
final displayValue = DistanceService.convertFromMeters(
appState.navigationAvoidanceDistance.toDouble(),
appState.distanceUnit
);
_distanceController = TextEditingController(
text: displayValue.round().toString(),
);
}
@override
void dispose() {
_distanceController.dispose();
super.dispose();
}
void _updateDistance(AppState appState, String value) {
final displayValue = double.tryParse(value) ?? (appState.distanceUnit == DistanceUnit.metric ? 250.0 : 820.0);
final metersValue = DistanceService.convertToMeters(displayValue, appState.distanceUnit, isSmallDistance: true);
appState.setNavigationAvoidanceDistance(metersValue.round().clamp(0, 2000));
}
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
final locService = LocalizationService.instance;
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) => Scaffold(
appBar: AppBar(
title: Text(locService.t('navigation.navigationSettings')),
),
body: Padding(
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.of(context).padding.bottom,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
leading: const Icon(Icons.social_distance),
title: Text(locService.t('navigation.avoidanceDistance')),
subtitle: Text(locService.t('navigation.avoidanceDistanceSubtitle')),
trailing: SizedBox(
width: 80,
child: TextFormField(
initialValue: appState.navigationAvoidanceDistance.toString(),
keyboardType: const TextInputType.numberWithOptions(signed: false, decimal: false),
textInputAction: TextInputAction.done,
decoration: const InputDecoration(
isDense: true,
contentPadding: EdgeInsets.symmetric(vertical: 6, horizontal: 8),
border: OutlineInputBorder(),
suffixText: 'm',
return Consumer<AppState>(
builder: (context, appState, child) {
// Update the text field when the unit or distance changes
final displayValue = DistanceService.convertFromMeters(
appState.navigationAvoidanceDistance.toDouble(),
appState.distanceUnit
);
if (_distanceController.text != displayValue.round().toString()) {
_distanceController.text = displayValue.round().toString();
}
final locService = LocalizationService.instance;
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
return Scaffold(
appBar: AppBar(
title: Text(locService.t('navigation.navigationSettings')),
),
body: Padding(
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.of(context).padding.bottom,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
leading: const Icon(Icons.social_distance),
title: Text(locService.t('navigation.avoidanceDistance')),
subtitle: Text(locService.t('navigation.avoidanceDistanceSubtitle')),
trailing: SizedBox(
width: 80,
child: TextField(
controller: _distanceController,
keyboardType: const TextInputType.numberWithOptions(signed: false, decimal: false),
textInputAction: TextInputAction.done,
decoration: InputDecoration(
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
border: const OutlineInputBorder(),
suffixText: DistanceService.getSmallDistanceUnit(appState.distanceUnit),
),
onSubmitted: (value) => _updateDistance(appState, value),
onEditingComplete: () => _updateDistance(appState, _distanceController.text),
)
)
),
onFieldSubmitted: (value) {
final distance = int.tryParse(value) ?? 250;
appState.setNavigationAvoidanceDistance(distance.clamp(0, 2000));
}
)
)
const Divider(),
_buildDisabledSetting(
context,
icon: Icons.history,
title: locService.t('navigation.searchHistory'),
subtitle: locService.t('navigation.searchHistorySubtitle'),
value: '10 searches',
),
],
),
),
const Divider(),
_buildDisabledSetting(
context,
icon: Icons.history,
title: locService.t('navigation.searchHistory'),
subtitle: locService.t('navigation.searchHistorySubtitle'),
value: '10 searches',
),
const Divider(),
_buildDisabledSetting(
context,
icon: Icons.straighten,
title: locService.t('navigation.units'),
subtitle: locService.t('navigation.unitsSubtitle'),
value: locService.t('navigation.metric'),
),
],
),
),
),
);
},
);
},
);
}
@@ -96,7 +132,7 @@ class NavigationSettingsScreen extends StatelessWidget {
Text(
value,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.6),
color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
),
),
const SizedBox(width: 8),

View File

@@ -105,7 +105,6 @@ class _OperatorProfileEditorState extends State<OperatorProfileEditor> {
return List.generate(_tags.length, (i) {
final keyController = TextEditingController(text: _tags[i].key);
final valueController = TextEditingController(text: _tags[i].value);
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),

View File

@@ -275,7 +275,7 @@ class _OSMAccountScreenState extends State<OSMAccountScreen> {
Container(
padding: const EdgeInsets.all(12.0),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.5),
color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8.0),
),
child: Row(
@@ -308,7 +308,7 @@ class _OSMAccountScreenState extends State<OSMAccountScreen> {
label: Text(locService.t('auth.deleteAccount')),
style: OutlinedButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
side: BorderSide(color: Theme.of(context).colorScheme.error.withOpacity(0.5)),
side: BorderSide(color: Theme.of(context).colorScheme.error.withValues(alpha: 0.5)),
),
),
),
@@ -354,10 +354,10 @@ class _OSMAccountScreenState extends State<OSMAccountScreen> {
Container(
padding: const EdgeInsets.all(12.0),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.errorContainer.withOpacity(0.1),
color: Theme.of(context).colorScheme.errorContainer.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8.0),
border: Border.all(
color: Theme.of(context).colorScheme.error.withOpacity(0.3),
color: Theme.of(context).colorScheme.error.withValues(alpha: 0.3),
),
),
child: Text(

View File

@@ -153,7 +153,6 @@ class _ProfileEditorState extends State<ProfileEditor> {
return List.generate(_tags.length, (i) {
final keyController = TextEditingController(text: _tags[i].key);
final valueController = TextEditingController(text: _tags[i].value);
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),

View File

@@ -99,7 +99,7 @@ class _ReleaseNotesScreenState extends State<ReleaseNotesScreen> {
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).colorScheme.primary.withOpacity(0.3),
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.3),
),
),
child: Row(
@@ -142,8 +142,8 @@ class _ReleaseNotesScreenState extends State<ReleaseNotesScreen> {
decoration: BoxDecoration(
border: Border.all(
color: isCurrentVersion
? Theme.of(context).colorScheme.primary.withOpacity(0.3)
: Theme.of(context).dividerColor.withOpacity(0.3),
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.3)
: Theme.of(context).dividerColor.withValues(alpha: 0.3),
),
borderRadius: BorderRadius.circular(8),
),

View File

@@ -1,6 +1,9 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:provider/provider.dart';
import '../../../services/localization_service.dart';
import '../../../app_state.dart';
import '../../../state/settings_state.dart';
class LanguageSection extends StatefulWidget {
const LanguageSection({super.key});
@@ -20,27 +23,29 @@ class _LanguageSectionState extends State<LanguageSection> {
_loadLanguageNames();
}
_loadSelectedLanguage() async {
Future<void> _loadSelectedLanguage() async {
final prefs = await SharedPreferences.getInstance();
if (!mounted) return;
setState(() {
_selectedLanguage = prefs.getString('language_code');
});
}
_loadLanguageNames() async {
Future<void> _loadLanguageNames() async {
final locService = LocalizationService.instance;
final Map<String, String> names = {};
for (String langCode in locService.availableLanguages) {
names[langCode] = await locService.getLanguageDisplayName(langCode);
}
if (!mounted) return;
setState(() {
_languageNames = names;
});
}
_setLanguage(String? languageCode) async {
Future<void> _setLanguage(String? languageCode) async {
await LocalizationService.instance.setLanguage(languageCode);
setState(() {
_selectedLanguage = languageCode;
@@ -49,43 +54,95 @@ class _LanguageSectionState extends State<LanguageSection> {
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// System Default option
RadioListTile<String?>(
title: Text(locService.t('settings.systemDefault')),
value: null,
groupValue: _selectedLanguage,
onChanged: _setLanguage,
),
// English always appears second (if available)
if (locService.availableLanguages.contains('en'))
RadioListTile<String>(
title: Text(_languageNames['en'] ?? 'English'),
value: 'en',
groupValue: _selectedLanguage,
onChanged: _setLanguage,
return Consumer<AppState>(
builder: (context, appState, child) {
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Language section
RadioGroup<String?>(
groupValue: _selectedLanguage,
onChanged: _setLanguage,
child: Column(
children: [
// System Default option
RadioListTile<String?>(
title: Text(locService.t('settings.systemDefault')),
value: null,
),
// English always appears second (if available)
if (locService.availableLanguages.contains('en'))
RadioListTile<String?>(
title: Text(_languageNames['en'] ?? 'English'),
value: 'en',
),
// Other language options (excluding English since it's already shown)
...locService.availableLanguages
.where((langCode) => langCode != 'en')
.map((langCode) =>
RadioListTile<String?>(
title: Text(_languageNames[langCode] ?? langCode.toUpperCase()),
value: langCode,
),
),
],
),
),
// Divider between language and units
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 16),
// Distance Units section
Text(
locService.t('settings.distanceUnit'),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
locService.t('settings.distanceUnitSubtitle'),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.7),
),
),
const SizedBox(height: 8),
RadioGroup<DistanceUnit>(
groupValue: appState.distanceUnit,
onChanged: (unit) {
if (unit != null) {
appState.setDistanceUnit(unit);
}
},
child: Column(
children: [
// Metric option
RadioListTile<DistanceUnit>(
title: Text(locService.t('units.metricDescription')),
value: DistanceUnit.metric,
),
// Imperial option
RadioListTile<DistanceUnit>(
title: Text(locService.t('units.imperialDescription')),
value: DistanceUnit.imperial,
),
],
),
),
],
),
// Other language options (excluding English since it's already shown)
...locService.availableLanguages
.where((langCode) => langCode != 'en')
.map((langCode) =>
RadioListTile<String>(
title: Text(_languageNames[langCode] ?? langCode.toUpperCase()),
value: langCode,
groupValue: _selectedLanguage,
onChanged: _setLanguage,
),
),
],
);
},
);
},
);
}
}
}

View File

@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import '../../../app_state.dart';
import '../../../models/node_profile.dart';
import '../../../services/localization_service.dart';
import '../../../widgets/profile_add_choice_dialog.dart';
import '../../profile_editor.dart';
class NodeProfilesSection extends StatelessWidget {
@@ -27,18 +28,7 @@ class NodeProfilesSection extends StatelessWidget {
style: Theme.of(context).textTheme.titleMedium,
),
TextButton.icon(
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ProfileEditor(
profile: NodeProfile(
id: const Uuid().v4(),
name: '',
tags: const {},
),
),
),
),
onPressed: () => _showAddProfileDialog(context),
icon: const Icon(Icons.add),
label: Text(locService.t('profiles.newProfile')),
),
@@ -121,6 +111,34 @@ class NodeProfilesSection extends StatelessWidget {
);
}
void _showAddProfileDialog(BuildContext context) async {
final result = await showDialog<String?>(
context: context,
builder: (context) => const ProfileAddChoiceDialog(),
);
// If user chose to create custom profile, open the profile editor
if (result == 'create' && context.mounted) {
_createNewProfile(context);
}
// If user chose import from website, ProfileAddChoiceDialog handles opening the URL
}
void _createNewProfile(BuildContext context) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ProfileEditor(
profile: NodeProfile(
id: const Uuid().v4(),
name: '',
tags: const {},
),
),
),
);
}
void _showDeleteProfileDialog(BuildContext context, NodeProfile profile) {
final locService = LocalizationService.instance;
final appState = context.read<AppState>();

View File

@@ -87,9 +87,9 @@ class _OfflineAreasSectionState extends State<OfflineAreasSection> {
: "${(area.sizeBytes / 1024).toStringAsFixed(1)} ${locService.t('offlineAreas.kilobytes')}"
: '--';
String subtitle = '${locService.t('offlineAreas.provider')}: ${area.tileProviderDisplay}\n' +
'${locService.t('offlineAreas.maxZoom')}: Z${area.maxZoom}' + '\n' +
'${locService.t('offlineAreas.latitude')}: ${area.bounds.southWest.latitude.toStringAsFixed(3)}, ${area.bounds.southWest.longitude.toStringAsFixed(3)}\n' +
String subtitle = '${locService.t('offlineAreas.provider')}: ${area.tileProviderDisplay}\n'
'${locService.t('offlineAreas.maxZoom')}: Z${area.maxZoom}\n'
'${locService.t('offlineAreas.latitude')}: ${area.bounds.southWest.latitude.toStringAsFixed(3)}, ${area.bounds.southWest.longitude.toStringAsFixed(3)}\n'
'${locService.t('offlineAreas.latitude')}: ${area.bounds.northEast.latitude.toStringAsFixed(3)}, ${area.bounds.northEast.longitude.toStringAsFixed(3)}';
if (area.status == OfflineAreaStatus.downloading) {
@@ -207,7 +207,7 @@ class _OfflineAreasSectionState extends State<OfflineAreasSection> {
: null,
),
);
}).toList(),
}),
],
);
},

View File

@@ -77,27 +77,7 @@ 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

@@ -5,6 +5,8 @@ import 'package:provider/provider.dart';
import '../../../app_state.dart';
import '../../../services/localization_service.dart';
import '../../../services/proximity_alert_service.dart';
import '../../../services/distance_service.dart';
import '../../../state/settings_state.dart';
import '../../../dev_config.dart';
/// Settings section for proximity alerts configuration
@@ -25,8 +27,13 @@ class _ProximityAlertsSectionState extends State<ProximityAlertsSection> {
void initState() {
super.initState();
final appState = context.read<AppState>();
// Convert meters to display units for the text field
final displayValue = DistanceService.convertFromMeters(
appState.proximityAlertDistance.toDouble(),
appState.distanceUnit
);
_distanceController = TextEditingController(
text: appState.proximityAlertDistance.toString(),
text: displayValue.round().toString(),
);
_checkNotificationPermissions();
}
@@ -69,12 +76,18 @@ class _ProximityAlertsSectionState extends State<ProximityAlertsSection> {
void _updateDistance(AppState appState) {
final text = _distanceController.text.trim();
final distance = int.tryParse(text);
if (distance != null) {
appState.setProximityAlertDistance(distance);
final displayValue = double.tryParse(text);
if (displayValue != null) {
// Convert from display units back to meters for storage
final metersValue = DistanceService.convertToMeters(displayValue, appState.distanceUnit, isSmallDistance: true);
appState.setProximityAlertDistance(metersValue.round());
} else {
// Reset to current value if invalid
_distanceController.text = appState.proximityAlertDistance.toString();
final displayValue = DistanceService.convertFromMeters(
appState.proximityAlertDistance.toDouble(),
appState.distanceUnit
);
_distanceController.text = displayValue.round().toString();
}
}
@@ -84,6 +97,15 @@ class _ProximityAlertsSectionState extends State<ProximityAlertsSection> {
builder: (context, appState, child) {
final locService = LocalizationService.instance;
// Update the text field when the unit or distance changes
final displayValue = DistanceService.convertFromMeters(
appState.proximityAlertDistance.toDouble(),
appState.distanceUnit
);
if (_distanceController.text != displayValue.round().toString()) {
_distanceController.text = displayValue.round().toString();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -118,9 +140,9 @@ class _ProximityAlertsSectionState extends State<ProximityAlertsSection> {
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.withOpacity(0.1),
color: Colors.orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.withOpacity(0.3)),
border: Border.all(color: Colors.orange.withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -171,49 +193,50 @@ class _ProximityAlertsSectionState extends State<ProximityAlertsSection> {
),
],
// Distance setting (only show when enabled)
if (appState.proximityAlertsEnabled) ...[
const SizedBox(height: 12),
Row(
children: [
Text(locService.t('proximityAlerts.alertDistance')),
SizedBox(
width: 80,
child: TextField(
controller: _distanceController,
keyboardType: const TextInputType.numberWithOptions(signed: true, decimal: true),
textInputAction: TextInputAction.done,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
decoration: const InputDecoration(
isDense: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 8,
vertical: 8,
// Distance setting (only show when enabled)
if (appState.proximityAlertsEnabled) ...[
const SizedBox(height: 12),
Row(
children: [
Text(locService.t('proximityAlerts.alertDistance')),
SizedBox(
width: 80,
child: TextField(
controller: _distanceController,
keyboardType: const TextInputType.numberWithOptions(signed: true, decimal: true),
textInputAction: TextInputAction.done,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
decoration: const InputDecoration(
isDense: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 8,
vertical: 8,
),
border: OutlineInputBorder(),
),
border: OutlineInputBorder(),
onSubmitted: (_) => _updateDistance(appState),
onEditingComplete: () => _updateDistance(appState),
),
onSubmitted: (_) => _updateDistance(appState),
onEditingComplete: () => _updateDistance(appState),
),
),
const SizedBox(width: 8),
Text(locService.t('proximityAlerts.meters')),
],
),
const SizedBox(height: 8),
Text(
locService.t('proximityAlerts.rangeInfo', params: [
kProximityAlertMinDistance.toString(),
kProximityAlertMaxDistance.toString(),
kProximityAlertDefaultDistance.toString(),
]),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.6),
const SizedBox(width: 8),
Text(locService.t('units.${appState.distanceUnit == DistanceUnit.metric ? 'metersLong' : 'feetLong'}')),
],
),
),
],
const SizedBox(height: 8),
Text(
locService.t('proximityAlerts.rangeInfo', params: [
DistanceService.convertFromMeters(kProximityAlertMinDistance.toDouble(), appState.distanceUnit).round().toString(),
DistanceService.convertFromMeters(kProximityAlertMaxDistance.toDouble(), appState.distanceUnit).round().toString(),
locService.t('units.${appState.distanceUnit == DistanceUnit.metric ? 'metersLong' : 'feetLong'}'),
DistanceService.convertFromMeters(kProximityAlertDefaultDistance.toDouble(), appState.distanceUnit).round().toString(),
]),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
),
),
],
],
);
},

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 '../../../state/settings_state.dart';
class QueueSection extends StatelessWidget {
const QueueSection({super.key});
@@ -112,20 +111,20 @@ class QueueSection extends StatelessWidget {
? Colors.red
: _getUploadModeColor(upload.uploadMode),
),
title: Text(locService.t('queue.cameraWithIndex', params: [(index + 1).toString()]) +
title: Text(locService.t('queue.itemWithIndex', 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
'${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')}" : "")
])}\n'
'${locService.t('queue.attempts', params: [upload.attempts.toString()])}'
'${upload.error ? "\n${locService.t('queue.uploadFailedRetry')}" : ""}'
),
trailing: Row(
mainAxisSize: MainAxisSize.min,

View File

@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../app_state.dart';
import '../../../services/localization_service.dart';
import '../../../services/distance_service.dart';
import '../../../state/settings_state.dart';
class SuspectedLocationsSection extends StatefulWidget {
const SuspectedLocationsSection({super.key});
@@ -188,22 +190,28 @@ class _SuspectedLocationsSectionState extends State<SuspectedLocationsSection> {
ListTile(
leading: const Icon(Icons.social_distance),
title: Text(locService.t('suspectedLocations.minimumDistance')),
subtitle: Text(locService.t('suspectedLocations.minimumDistanceSubtitle', params: [appState.suspectedLocationMinDistance.toString()])),
subtitle: Text(locService.t('suspectedLocations.minimumDistanceSubtitle', params: [
DistanceService.formatDistance(appState.suspectedLocationMinDistance.toDouble(), appState.distanceUnit)
])),
trailing: SizedBox(
width: 80,
child: TextFormField(
initialValue: appState.suspectedLocationMinDistance.toString(),
initialValue: DistanceService.convertFromMeters(
appState.suspectedLocationMinDistance.toDouble(),
appState.distanceUnit
).round().toString(),
keyboardType: const TextInputType.numberWithOptions(signed: true, decimal: true),
textInputAction: TextInputAction.done,
decoration: const InputDecoration(
decoration: InputDecoration(
isDense: true,
contentPadding: EdgeInsets.symmetric(vertical: 6, horizontal: 8),
border: OutlineInputBorder(),
suffixText: 'm',
contentPadding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
border: const OutlineInputBorder(),
suffixText: DistanceService.getSmallDistanceUnit(appState.distanceUnit),
),
onFieldSubmitted: (value) {
final distance = int.tryParse(value) ?? 100;
appState.setSuspectedLocationMinDistance(distance.clamp(0, 1000));
final displayValue = double.tryParse(value) ?? (appState.distanceUnit == DistanceUnit.metric ? 100.0 : 328.0);
final metersValue = DistanceService.convertToMeters(displayValue, appState.distanceUnit, isSmallDistance: true);
appState.setSuspectedLocationMinDistance(metersValue.round().clamp(0, 1000));
},
),
),

View File

@@ -44,7 +44,7 @@ class TileProviderSection extends StatelessWidget {
),
)
else
...providers.map((provider) => _buildProviderTile(context, provider, appState)).toList(),
...providers.map((provider) => _buildProviderTile(context, provider, appState)),
],
);
},
@@ -89,7 +89,7 @@ class TileProviderSection extends StatelessWidget {
leading: CircleAvatar(
backgroundColor: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.surfaceVariant,
: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Icon(
Icons.map,
color: isSelected

View File

@@ -81,7 +81,7 @@ class UploadModeSection extends StatelessWidget {
fontSize: 12,
color: appState.pendingCount > 0
? Theme.of(context).disabledColor
: Theme.of(context).colorScheme.onSurface.withOpacity(0.7)
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7)
)
);
case UploadMode.sandbox:
@@ -95,7 +95,6 @@ class UploadModeSection extends StatelessWidget {
),
);
case UploadMode.simulate:
default:
return Text(
locService.t('uploadMode.simulateDescription'),
style: TextStyle(

View File

@@ -102,7 +102,7 @@ class SettingsScreen extends StatelessWidget {
child: Text(
'Version: ${VersionService().version}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.6),
color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
),
textAlign: TextAlign.center,
),

View File

@@ -2,7 +2,6 @@ import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:http/http.dart' as http;
import 'package:collection/collection.dart';
import '../app_state.dart';
import '../models/tile_provider.dart';

View File

@@ -3,7 +3,6 @@ import 'package:provider/provider.dart';
import '../app_state.dart';
import '../models/pending_upload.dart';
import '../services/localization_service.dart';
import '../state/settings_state.dart';
class UploadQueueScreen extends StatelessWidget {
const UploadQueueScreen({super.key});
@@ -114,8 +113,8 @@ class UploadQueueScreen extends StatelessWidget {
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.withOpacity(0.1),
border: Border.all(color: Colors.orange.withOpacity(0.3)),
color: Colors.orange.withValues(alpha: 0.1),
border: Border.all(color: Colors.orange.withValues(alpha: 0.3)),
borderRadius: BorderRadius.circular(8),
),
child: Row(
@@ -148,6 +147,31 @@ class UploadQueueScreen extends StatelessWidget {
],
),
),
// Pause Upload Queue Toggle
ListTile(
leading: Icon(
Icons.pause_circle_outline,
color: appState.offlineMode
? Theme.of(context).disabledColor
: Theme.of(context).iconTheme.color,
),
title: Text(locService.t('settings.pauseQueueProcessing')),
subtitle: 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),
),
),
const SizedBox(height: 16),
// Clear Upload Queue button - always visible
SizedBox(
width: double.infinity,
@@ -180,7 +204,7 @@ class UploadQueueScreen extends StatelessWidget {
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),
backgroundColor: appState.pendingCount > 0 ? null : Theme.of(context).disabledColor.withValues(alpha: 0.1),
),
),
),
@@ -199,13 +223,13 @@ class UploadQueueScreen extends StatelessWidget {
Icon(
Icons.check_circle_outline,
size: 64,
color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.4),
color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 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),
color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
),
textAlign: TextAlign.center,
),
@@ -247,16 +271,16 @@ class UploadQueueScreen extends StatelessWidget {
_getUploadStateText(upload, locService)
),
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
'${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.uploadState == UploadState.error ? "\n${locService.t('queue.uploadFailedRetry')}" : "")
])}\n'
'${locService.t('queue.attempts', params: [upload.attempts.toString()])}'
'${upload.uploadState == UploadState.error ? "\n${locService.t('queue.uploadFailedRetry')}" : ""}'
),
trailing: Row(
mainAxisSize: MainAxisSize.min,

View File

@@ -1,7 +1,7 @@
import 'dart:convert';
import 'dart:developer';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:oauth2_client/oauth2_client.dart';
import 'package:oauth2_client/oauth2_helper.dart';
import 'package:http/http.dart' as http;
@@ -30,7 +30,6 @@ class AuthService {
case UploadMode.sandbox:
return 'osm_token_sandbox';
case UploadMode.simulate:
default:
return 'osm_token_simulate';
}
}
@@ -97,10 +96,10 @@ class AuthService {
final tokenJson = jsonEncode(tokenMap);
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_tokenKey, tokenJson); // Save token for current mode
_displayName = await _fetchUsername(token!.accessToken!);
_displayName = await _fetchUsername(token.accessToken!);
return _displayName;
} catch (e) {
print('AuthService: OAuth login failed: $e');
debugPrint('AuthService: OAuth login failed: $e');
log('OAuth login failed: $e');
rethrow;
}
@@ -128,7 +127,7 @@ class AuthService {
_displayName = await _fetchUsername(accessToken);
return _displayName;
} catch (e) {
print('AuthService: Error restoring login with stored token: $e');
debugPrint('AuthService: Error restoring login with stored token: $e');
log('Error restoring login with stored token: $e');
// Token might be expired or invalid, clear it
await logout();
@@ -194,7 +193,7 @@ class AuthService {
final displayName = userData['user']?['display_name'];
return displayName;
} catch (e) {
print('AuthService: Error fetching username: $e');
debugPrint('AuthService: Error fetching username: $e');
log('Error fetching username: $e');
return null;
}

View File

@@ -1,5 +1,4 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -304,8 +303,8 @@ class ChangelogService {
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);
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;

View File

@@ -1,7 +1,6 @@
import 'dart:async';
import 'package:app_links/app_links.dart';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import '../models/node_profile.dart';
import 'profile_import_service.dart';
@@ -87,7 +86,7 @@ class DeepLinkService {
}
}
/// Handle profile add deep link: deflockapp://profiles/add?p=<base64>
/// Handle profile add deep link: `deflockapp://profiles/add?p=<base64>`
void _handleAddProfileLink(Uri uri) {
final base64Data = uri.queryParameters['p'];

View File

@@ -1,12 +1,11 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'dart:async';
import 'dart:typed_data';
import 'dart:ui';
import '../app_state.dart';
import '../models/tile_provider.dart' as models;
import 'map_data_provider.dart';
import 'offline_area_service.dart';
@@ -108,7 +107,7 @@ class DeflockTileImageProvider extends ImageProvider<DeflockTileImageProvider> {
// Re-throw the exception and let FlutterMap handle missing tiles gracefully
// This is better than trying to provide fallback images
throw e;
rethrow;
}
}

View File

@@ -0,0 +1,87 @@
import '../state/settings_state.dart';
/// Service for distance unit conversions and formatting
///
/// Follows brutalist principles: simple, explicit conversions without fancy abstractions.
/// All APIs work in metric units (meters/km), this service only handles display formatting.
class DistanceService {
// Conversion constants
static const double _metersToFeet = 3.28084;
static const double _metersToMiles = 0.000621371;
/// Format distance for display based on unit preference
///
/// For metric: uses meters for < 1000m, kilometers for >= 1000m
/// For imperial: uses feet for < 5280ft (1 mile), miles for >= 5280ft
static String formatDistance(double distanceInMeters, DistanceUnit unit) {
switch (unit) {
case DistanceUnit.metric:
if (distanceInMeters < 1000) {
return '${distanceInMeters.round()} m';
} else {
return '${(distanceInMeters / 1000).toStringAsFixed(1)} km';
}
case DistanceUnit.imperial:
final distanceInFeet = distanceInMeters * _metersToFeet;
if (distanceInFeet < 5280) {
return '${distanceInFeet.round()} ft';
} else {
final distanceInMiles = distanceInMeters * _metersToMiles;
return '${distanceInMiles.toStringAsFixed(1)} mi';
}
}
}
/// Format large distances (like route distances) for display
///
/// Always uses the larger unit (km/miles) for routes
static String formatRouteDistance(double distanceInMeters, DistanceUnit unit) {
switch (unit) {
case DistanceUnit.metric:
return '${(distanceInMeters / 1000).toStringAsFixed(1)} km';
case DistanceUnit.imperial:
final distanceInMiles = distanceInMeters * _metersToMiles;
return '${distanceInMiles.toStringAsFixed(1)} mi';
}
}
/// Get the unit suffix for small distances (used in form fields, etc.)
static String getSmallDistanceUnit(DistanceUnit unit) {
switch (unit) {
case DistanceUnit.metric:
return 'm';
case DistanceUnit.imperial:
return 'ft';
}
}
/// Convert displayed distance value back to meters for API usage
///
/// This is for form fields where users enter values in their preferred units
static double convertToMeters(double value, DistanceUnit unit, {bool isSmallDistance = true}) {
switch (unit) {
case DistanceUnit.metric:
return isSmallDistance ? value : value * 1000; // m or km to m
case DistanceUnit.imperial:
if (isSmallDistance) {
return value / _metersToFeet; // ft to m
} else {
return value / _metersToMiles; // miles to m
}
}
}
/// Convert meters to the preferred small distance unit for form display
static double convertFromMeters(double meters, DistanceUnit unit) {
switch (unit) {
case DistanceUnit.metric:
return meters;
case DistanceUnit.imperial:
return meters * _metersToFeet;
}
}
}

View File

@@ -25,42 +25,32 @@ class LocalizationService extends ChangeNotifier {
Future<void> _discoverAvailableLanguages() async {
_availableLanguages = [];
try {
// Get the asset manifest to find all localization files
final manifestContent = await rootBundle.loadString('AssetManifest.json');
final Map<String, dynamic> manifestMap = json.decode(manifestContent);
// Find all .json files in lib/localizations/
final localizationFiles = manifestMap.keys
.where((String key) => key.startsWith('lib/localizations/') && key.endsWith('.json'))
final assetManifest = await AssetManifest.loadFromAssetBundle(rootBundle);
final localizationAssets = assetManifest.listAssets()
.where((path) => path.startsWith('lib/localizations/') && path.endsWith('.json'))
.toList();
for (final filePath in localizationFiles) {
// Extract language code from filename (e.g., 'lib/localizations/pt.json' -> 'pt')
final fileName = filePath.split('/').last;
final languageCode = fileName.substring(0, fileName.length - 5); // Remove '.json'
for (final assetPath in localizationAssets) {
try {
// Try to load and parse the file to ensure it's valid
final jsonString = await rootBundle.loadString(filePath);
final jsonString = await rootBundle.loadString(assetPath);
final parsedJson = json.decode(jsonString);
// Basic validation - ensure it has the expected structure
if (parsedJson is Map && parsedJson.containsKey('language')) {
final languageCode = assetPath.split('/').last.replaceAll('.json', '');
_availableLanguages.add(languageCode);
debugPrint('Found localization: $languageCode');
}
} catch (e) {
debugPrint('Failed to load localization file $filePath: $e');
debugPrint('Failed to load localization file $assetPath: $e');
}
}
} catch (e) {
debugPrint('Failed to read AssetManifest.json: $e');
// If manifest reading fails, we'll have an empty list
// The system will handle this gracefully by falling back to 'en' in _loadSavedLanguage
debugPrint('Failed to load asset manifest: $e');
_availableLanguages = ['en'];
}
debugPrint('Available languages: $_availableLanguages');
}
@@ -119,28 +109,31 @@ class LocalizationService extends ChangeNotifier {
notifyListeners();
}
String t(String key, {List<String>? params}) {
String t(String key, {List<String>? params}) =>
lookup(_strings, key, params: params);
/// Pure lookup function used by [t] and available for testing.
static String lookup(Map<String, dynamic> strings, String key,
{List<String>? params}) {
List<String> keys = key.split('.');
dynamic current = _strings;
dynamic current = strings;
for (String k in keys) {
if (current is Map && current.containsKey(k)) {
current = current[k];
} else {
// Return the key as fallback for missing translations
return key;
}
}
String result = current is String ? current : key;
// Replace parameters if provided - replace first occurrence only for each parameter
if (params != null) {
for (int i = 0; i < params.length; i++) {
result = result.replaceFirst('{}', params[i]);
}
}
return result;
}

View File

@@ -1,17 +1,13 @@
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter/foundation.dart';
import '../models/node_profile.dart';
import '../models/osm_node.dart';
import '../app_state.dart';
import 'map_data_submodules/nodes_from_overpass.dart';
import 'map_data_submodules/nodes_from_osm_api.dart';
import 'map_data_submodules/tiles_from_remote.dart';
import 'map_data_submodules/nodes_from_local.dart';
import 'map_data_submodules/tiles_from_local.dart';
import 'network_status.dart';
import 'prefetch_area_service.dart';
import 'node_data_manager.dart';
import 'node_spatial_cache.dart';
enum MapSource { local, remote, auto } // For future use
@@ -27,103 +23,31 @@ class MapDataProvider {
factory MapDataProvider() => _instance;
MapDataProvider._();
// REMOVED: AppState get _appState => AppState();
final NodeDataManager _nodeDataManager = NodeDataManager();
bool get isOfflineMode => AppState.instance.offlineMode;
void setOfflineMode(bool enabled) {
AppState.instance.setOfflineMode(enabled);
}
/// Fetch surveillance nodes from OSM/Overpass or local storage.
/// Remote is default. If source is MapSource.auto, remote is tried first unless offline.
/// Fetch surveillance nodes using the new simplified system.
/// Returns cached data immediately if available, otherwise fetches from appropriate source.
Future<List<OsmNode>> getNodes({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
UploadMode uploadMode = UploadMode.production,
MapSource source = MapSource.auto,
bool isUserInitiated = false,
}) async {
final offline = AppState.instance.offlineMode;
// Explicit remote request: error if offline, else always remote
if (source == MapSource.remote) {
if (offline) {
throw OfflineModeException("Cannot fetch remote nodes in offline mode.");
}
return _fetchRemoteNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: 0, // No limit - fetch all available data
);
}
// Explicit local request: always use local
if (source == MapSource.local) {
return fetchLocalNodes(
bounds: bounds,
profiles: profiles,
);
}
// AUTO: In offline mode, behavior depends on upload mode
if (offline) {
if (uploadMode == UploadMode.sandbox) {
// Offline + Sandbox = no nodes (local cache is production data)
debugPrint('[MapDataProvider] Offline + Sandbox mode: returning no nodes (local cache is production data)');
return <OsmNode>[];
} else {
// Offline + Production = use local cache
return fetchLocalNodes(
bounds: bounds,
profiles: profiles,
maxNodes: 0, // No limit - get all available data
);
}
} else if (uploadMode == UploadMode.sandbox) {
// Sandbox mode: Only fetch from sandbox API, ignore local production nodes
debugPrint('[MapDataProvider] Sandbox mode: fetching only from sandbox API, ignoring local cache');
return _fetchRemoteNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: 0, // No limit - fetch all available data
);
} else {
// Production mode: use pre-fetch service for efficient area loading
final preFetchService = PrefetchAreaService();
// Always get local nodes first (fast, from cache)
final localNodes = await fetchLocalNodes(
bounds: bounds,
profiles: profiles,
maxNodes: AppState.instance.maxNodes,
);
// Check if we need to trigger a new pre-fetch (spatial or temporal)
final needsFetch = !preFetchService.isWithinPreFetchedArea(bounds, profiles, uploadMode) ||
preFetchService.isDataStale();
if (needsFetch) {
// Outside area OR data stale - start pre-fetch with loading state
debugPrint('[MapDataProvider] Starting pre-fetch with loading state');
NetworkStatus.instance.setWaiting();
preFetchService.requestPreFetchIfNeeded(
viewBounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
);
} else {
debugPrint('[MapDataProvider] Using existing fresh pre-fetched area cache');
}
// Return all local nodes without any rendering limit
// Rendering limits are applied at the UI layer
return localNodes;
}
return _nodeDataManager.getNodesFor(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
isUserInitiated: isUserInitiated,
);
}
/// Bulk/paged node fetch for offline downloads (handling paging, dedup, and Overpass retries)
/// Only use for offline area download, not for map browsing! Ignores maxNodes config.
/// Bulk node fetch for offline downloads using new system
Future<List<OsmNode>> getAllNodesForDownload({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
@@ -131,16 +55,12 @@ class MapDataProvider {
int maxResults = 0, // 0 = no limit for offline downloads
int maxTries = 3,
}) async {
final offline = AppState.instance.offlineMode;
if (offline) {
if (AppState.instance.offlineMode) {
throw OfflineModeException("Cannot fetch remote nodes for offline area download in offline mode.");
}
return _fetchRemoteNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: maxResults, // Pass 0 for unlimited
);
// For downloads, always fetch fresh data (don't use cache)
return _nodeDataManager.fetchWithSplitting(bounds, profiles);
}
/// Fetch tile image bytes. Default is to try local first, then remote if not offline. Honors explicit source.
@@ -202,57 +122,48 @@ class MapDataProvider {
clearRemoteTileQueueSelective(currentBounds);
}
/// Fetch remote nodes with Overpass first, OSM API fallback
Future<List<OsmNode>> _fetchRemoteNodes({
/// Add or update nodes in cache (for upload queue integration)
void addOrUpdateNodes(List<OsmNode> nodes) {
_nodeDataManager.addOrUpdateNodes(nodes);
}
/// NodeCache compatibility - alias for addOrUpdateNodes
void addOrUpdate(List<OsmNode> nodes) {
addOrUpdateNodes(nodes);
}
/// Remove node from cache (for deletions)
void removeNodeById(int nodeId) {
_nodeDataManager.removeNodeById(nodeId);
}
/// Clear cache (when profiles change)
void clearCache() {
_nodeDataManager.clearCache();
}
/// Force refresh current area (manual retry)
Future<void> refreshArea({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
UploadMode uploadMode = UploadMode.production,
required int maxResults,
}) async {
// For sandbox mode, skip Overpass and go directly to OSM API
// (Overpass doesn't have sandbox data)
if (uploadMode == UploadMode.sandbox) {
debugPrint('[MapDataProvider] Sandbox mode detected, using OSM API directly');
return fetchOsmApiNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: maxResults,
);
}
// For production mode, try Overpass first, then fallback to OSM API
try {
final nodes = await fetchOverpassNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: maxResults,
);
// If Overpass returns nodes, we're good
if (nodes.isNotEmpty) {
return nodes;
}
// If Overpass returns empty (could be no data or could be an issue),
// try OSM API as well to be thorough
debugPrint('[MapDataProvider] Overpass returned no nodes, trying OSM API fallback');
return fetchOsmApiNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: maxResults,
);
} catch (e) {
debugPrint('[MapDataProvider] Overpass failed ($e), trying OSM API fallback');
return fetchOsmApiNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: maxResults,
);
}
return _nodeDataManager.refreshArea(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
);
}
/// NodeCache compatibility methods for upload queue
/// These all delegate to the singleton cache to ensure consistency
OsmNode? getNodeById(int nodeId) => NodeSpatialCache().getNodeById(nodeId);
void removePendingEditMarker(int nodeId) => NodeSpatialCache().removePendingEditMarker(nodeId);
void removePendingDeletionMarker(int nodeId) => NodeSpatialCache().removePendingDeletionMarker(nodeId);
void removeTempNodeById(int tempNodeId) => NodeSpatialCache().removeTempNodeById(tempNodeId);
List<OsmNode> findNodesWithinDistance(LatLng coord, double distanceMeters, {int? excludeNodeId}) =>
NodeSpatialCache().findNodesWithinDistance(coord, distanceMeters, excludeNodeId: excludeNodeId);
/// Check if we have good cache coverage for the given area (prevents submission in uncovered areas)
bool hasGoodCoverageFor(LatLngBounds bounds) => NodeSpatialCache().hasDataFor(bounds);
}

View File

@@ -1,4 +1,3 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
@@ -8,7 +7,6 @@ import 'package:xml/xml.dart';
import '../../models/node_profile.dart';
import '../../models/osm_node.dart';
import '../../app_state.dart';
import '../network_status.dart';
/// Fetches surveillance nodes from the direct OSM API using bbox query.
/// This is a fallback for when Overpass is not available (e.g., sandbox mode).
@@ -20,9 +18,6 @@ Future<List<OsmNode>> fetchOsmApiNodes({
}) async {
if (profiles.isEmpty) return [];
// Check if this is a user-initiated fetch (indicated by loading state)
final wasUserInitiated = NetworkStatus.instance.currentStatus == NetworkStatusType.waiting;
try {
final nodes = await _fetchFromOsmApi(
bounds: bounds,
@@ -31,22 +26,8 @@ Future<List<OsmNode>> fetchOsmApiNodes({
maxResults: maxResults,
);
// Only report success at the top level if this was user-initiated
if (wasUserInitiated) {
NetworkStatus.instance.setSuccess();
}
return nodes;
} catch (e) {
// Only report errors at the top level if this was user-initiated
if (wasUserInitiated) {
if (e.toString().contains('timeout') || e.toString().contains('timed out')) {
NetworkStatus.instance.setTimeoutError();
} else {
NetworkStatus.instance.setNetworkError();
}
}
debugPrint('[fetchOsmApiNodes] OSM API operation failed: $e');
return [];
}
@@ -98,7 +79,7 @@ Future<List<OsmNode>> _fetchFromOsmApi({
debugPrint('[fetchOsmApiNodes] Exception: $e');
// Don't report status here - let the top level handle it
throw e; // Re-throw to let caller handle
rethrow; // Re-throw to let caller handle
}
}

View File

@@ -1,419 +0,0 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart';
import '../../models/node_profile.dart';
import '../../models/osm_node.dart';
import '../../models/pending_upload.dart';
import '../../app_state.dart';
import '../../dev_config.dart';
import '../network_status.dart';
import '../overpass_node_limit_exception.dart';
/// Fetches surveillance nodes from the Overpass OSM API for the given bounds and profiles.
/// If the query fails due to too many nodes, automatically splits the area and retries.
Future<List<OsmNode>> fetchOverpassNodes({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
UploadMode uploadMode = UploadMode.production,
required int maxResults,
}) async {
// Check if this is a user-initiated fetch (indicated by loading state)
final wasUserInitiated = NetworkStatus.instance.currentStatus == NetworkStatusType.waiting;
try {
final nodes = await _fetchOverpassNodesWithSplitting(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: maxResults,
splitDepth: 0,
reportStatus: wasUserInitiated, // Only top level reports status
);
// Only report success at the top level if this was user-initiated
if (wasUserInitiated) {
NetworkStatus.instance.setSuccess();
}
return nodes;
} catch (e) {
// Only report errors at the top level if this was user-initiated
if (wasUserInitiated) {
if (e.toString().contains('timeout') || e.toString().contains('timed out')) {
NetworkStatus.instance.setTimeoutError();
} else {
NetworkStatus.instance.setNetworkError();
}
}
debugPrint('[fetchOverpassNodes] Top-level operation failed: $e');
return [];
}
}
/// Internal method that handles splitting when node limit is exceeded.
Future<List<OsmNode>> _fetchOverpassNodesWithSplitting({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
UploadMode uploadMode = UploadMode.production,
required int maxResults,
required int splitDepth,
required bool reportStatus, // Only true for top level
}) async {
if (profiles.isEmpty) return [];
const int maxSplitDepth = kMaxPreFetchSplitDepth; // Maximum times we'll split (4^3 = 64 max sub-areas)
try {
return await _fetchSingleOverpassQuery(
bounds: bounds,
profiles: profiles,
maxResults: maxResults,
reportStatus: reportStatus,
);
} on OverpassRateLimitException catch (e) {
// Rate limits should NOT be split - just fail with extended backoff
debugPrint('[fetchOverpassNodes] Rate limited - using extended backoff, not splitting');
// Report slow progress when backing off
if (reportStatus) {
NetworkStatus.instance.reportSlowProgress();
}
// Wait longer for rate limits before giving up entirely
await Future.delayed(const Duration(seconds: 30));
return []; // Return empty rather than rethrowing - let caller handle error reporting
} on OverpassNodeLimitException {
// If we've hit max split depth, give up to avoid infinite recursion
if (splitDepth >= maxSplitDepth) {
debugPrint('[fetchOverpassNodes] Max split depth reached, giving up on area: $bounds');
return []; // Return empty - let caller handle error reporting
}
// Report slow progress when we start splitting (only at the top level)
if (reportStatus) {
NetworkStatus.instance.reportSlowProgress();
}
// Split the bounds into 4 quadrants and try each separately
debugPrint('[fetchOverpassNodes] Splitting area into quadrants (depth: $splitDepth)');
final quadrants = _splitBounds(bounds);
final List<OsmNode> allNodes = [];
for (final quadrant in quadrants) {
final nodes = await _fetchOverpassNodesWithSplitting(
bounds: quadrant,
profiles: profiles,
uploadMode: uploadMode,
maxResults: 0, // No limit on individual quadrants to avoid double-limiting
splitDepth: splitDepth + 1,
reportStatus: false, // Sub-requests don't report status
);
allNodes.addAll(nodes);
}
debugPrint('[fetchOverpassNodes] Collected ${allNodes.length} nodes from ${quadrants.length} quadrants');
return allNodes;
}
}
/// Perform a single Overpass query without splitting logic.
Future<List<OsmNode>> _fetchSingleOverpassQuery({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
required int maxResults,
required bool reportStatus,
}) async {
const String overpassEndpoint = 'https://overpass-api.de/api/interpreter';
// Build the Overpass query
final query = _buildOverpassQuery(bounds, profiles, maxResults);
try {
debugPrint('[fetchOverpassNodes] Querying Overpass for surveillance nodes...');
debugPrint('[fetchOverpassNodes] Query:\n$query');
final response = await http.post(
Uri.parse(overpassEndpoint),
body: {'data': query.trim()}
);
if (response.statusCode != 200) {
final errorBody = response.body;
debugPrint('[fetchOverpassNodes] Overpass API error: $errorBody');
// Check if it's specifically the 50k node limit error (HTTP 400)
// Exact message: "You requested too many nodes (limit is 50000)"
if (errorBody.contains('too many nodes') &&
errorBody.contains('50000')) {
debugPrint('[fetchOverpassNodes] Detected 50k node limit error, will attempt splitting');
throw OverpassNodeLimitException('Query exceeded node limit', serverResponse: errorBody);
}
// Check for timeout errors that indicate query complexity (should split)
// Common timeout messages from Overpass
if (errorBody.contains('timeout') ||
errorBody.contains('runtime limit exceeded') ||
errorBody.contains('Query timed out')) {
debugPrint('[fetchOverpassNodes] Detected timeout error, will attempt splitting to reduce complexity');
throw OverpassNodeLimitException('Query timed out', serverResponse: errorBody);
}
// Check for rate limiting (should NOT split - needs longer backoff)
if (errorBody.contains('rate limited') ||
errorBody.contains('too many requests') ||
response.statusCode == 429) {
debugPrint('[fetchOverpassNodes] Rate limited by Overpass API - needs extended backoff');
throw OverpassRateLimitException('Rate limited by server', serverResponse: errorBody);
}
// Don't report status here - let the top level handle it
throw Exception('Overpass API error: $errorBody');
}
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} elements (nodes + ways/relations)');
}
// Don't report success here - let the top level handle it
// 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);
return nodes;
} catch (e) {
// Re-throw OverpassNodeLimitException so splitting logic can catch it
if (e is OverpassNodeLimitException) rethrow;
debugPrint('[fetchOverpassNodes] Exception: $e');
// Don't report status here - let the top level handle it
throw e; // Re-throw to let caller handle
}
}
/// 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) {
// Deduplicate profiles to reduce query complexity - broader profiles subsume more specific ones
final deduplicatedProfiles = _deduplicateProfilesForQuery(profiles);
// Safety check: if deduplication removed all profiles (edge case), fall back to original list
final profilesToQuery = deduplicatedProfiles.isNotEmpty ? deduplicatedProfiles : profiles;
if (deduplicatedProfiles.length < profiles.length) {
debugPrint('[Overpass] Deduplicated ${profiles.length} profiles to ${deduplicatedProfiles.length} for query efficiency');
}
// Build node clauses for deduplicated profiles only
final nodeClauses = profilesToQuery.map((profile) {
// Convert profile tags to Overpass filter format, excluding empty values
final tagFilters = profile.tags.entries
.where((entry) => entry.value.trim().isNotEmpty) // Skip empty values
.map((entry) => '["${entry.key}"="${entry.value}"]')
.join();
// 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 ');
return '''
[out:json][timeout:${kOverpassQueryTimeout.inSeconds}];
(
$nodeClauses
);
out body ${maxResults > 0 ? maxResults : ''};
(
way(bn);
rel(bn);
);
out meta;
''';
}
/// Deduplicate profiles for Overpass queries by removing profiles that are subsumed by others.
/// A profile A subsumes profile B if all of A's non-empty tags exist in B with identical values.
/// This optimization reduces query complexity while returning the same nodes (since broader
/// profiles capture all nodes that more specific profiles would).
List<NodeProfile> _deduplicateProfilesForQuery(List<NodeProfile> profiles) {
if (profiles.length <= 1) return profiles;
final result = <NodeProfile>[];
for (final candidate in profiles) {
// Skip profiles that only have empty tags - they would match everything and break queries
final candidateNonEmptyTags = candidate.tags.entries
.where((entry) => entry.value.trim().isNotEmpty)
.toList();
if (candidateNonEmptyTags.isEmpty) continue;
// Check if any existing profile in our result subsumes this candidate
bool isSubsumed = false;
for (final existing in result) {
if (_profileSubsumes(existing, candidate)) {
isSubsumed = true;
break;
}
}
if (!isSubsumed) {
// This candidate is not subsumed, so add it
// But first, remove any existing profiles that this candidate subsumes
result.removeWhere((existing) => _profileSubsumes(candidate, existing));
result.add(candidate);
}
}
return result;
}
/// Check if broaderProfile subsumes specificProfile.
/// Returns true if all non-empty tags in broaderProfile exist in specificProfile with identical values.
bool _profileSubsumes(NodeProfile broaderProfile, NodeProfile specificProfile) {
// Get non-empty tags from both profiles
final broaderTags = Map.fromEntries(
broaderProfile.tags.entries.where((entry) => entry.value.trim().isNotEmpty)
);
final specificTags = Map.fromEntries(
specificProfile.tags.entries.where((entry) => entry.value.trim().isNotEmpty)
);
// If broader has no non-empty tags, it doesn't subsume anything (would match everything)
if (broaderTags.isEmpty) return false;
// If broader has more non-empty tags than specific, it can't subsume
if (broaderTags.length > specificTags.length) return false;
// Check if all broader tags exist in specific with same values
for (final entry in broaderTags.entries) {
if (specificTags[entry.key] != entry.value) return false;
}
return true;
}
/// Split a LatLngBounds into 4 quadrants (NW, NE, SW, SE).
List<LatLngBounds> _splitBounds(LatLngBounds bounds) {
final centerLat = (bounds.north + bounds.south) / 2;
final centerLng = (bounds.east + bounds.west) / 2;
return [
// Southwest quadrant (bottom-left)
LatLngBounds(
LatLng(bounds.south, bounds.west),
LatLng(centerLat, centerLng),
),
// Southeast quadrant (bottom-right)
LatLngBounds(
LatLng(bounds.south, centerLng),
LatLng(centerLat, bounds.east),
),
// Northwest quadrant (top-left)
LatLngBounds(
LatLng(centerLat, bounds.west),
LatLng(bounds.north, centerLng),
),
// Northeast quadrant (top-right)
LatLngBounds(
LatLng(centerLat, centerLng),
LatLng(bounds.north, bounds.east),
),
];
}
/// 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 {
final appState = AppState.instance;
final pendingUploads = appState.pendingUploads;
if (pendingUploads.isEmpty) return;
final overpassNodeIds = overpassNodes.map((n) => n.id).toSet();
// Find pending uploads whose submitted node IDs now appear in Overpass results
final uploadsToRemove = <PendingUpload>[];
for (final upload in pendingUploads) {
if (upload.submittedNodeId != null &&
overpassNodeIds.contains(upload.submittedNodeId!)) {
uploadsToRemove.add(upload);
debugPrint('[OverpassCleanup] Found submitted node ${upload.submittedNodeId} in Overpass results, removing from pending queue');
}
}
// Remove the completed uploads from the queue
for (final upload in uploadsToRemove) {
appState.removeFromQueue(upload);
}
if (uploadsToRemove.isNotEmpty) {
debugPrint('[OverpassCleanup] Cleaned up ${uploadsToRemove.length} completed uploads');
}
} catch (e) {
debugPrint('[OverpassCleanup] Error during cleanup: $e');
// Don't let cleanup errors break the main functionality
}
}

View File

@@ -1,5 +1,4 @@
import 'dart:io';
import 'package:latlong2/latlong.dart';
import '../offline_area_service.dart';
import '../offline_areas/offline_area_models.dart';
import '../offline_areas/offline_tile_utils.dart';

View File

@@ -1,225 +1,117 @@
import 'package:flutter/material.dart';
import 'dart:async';
import '../app_state.dart';
enum NetworkIssueType { overpassApi }
enum NetworkStatusType { waiting, issues, timedOut, noData, ready, success }
/// Simple enum-based network status for surveillance data requests.
/// Only tracks the latest user-initiated request - background requests are ignored.
enum NetworkRequestStatus {
idle, // No active requests
loading, // Request in progress
splitting, // Request being split due to limits/timeouts
success, // Data loaded successfully
timeout, // Request timed out
rateLimited, // API rate limited
noData, // No offline data available
error, // Other network errors
}
class NetworkStatus extends ChangeNotifier {
static final NetworkStatus instance = NetworkStatus._();
NetworkStatus._();
bool _overpassHasIssues = false;
bool _isWaitingForData = false;
bool _isTimedOut = false;
bool _hasNoData = false;
bool _hasSuccess = false;
int _recentOfflineMisses = 0;
Timer? _overpassRecoveryTimer;
Timer? _noDataResetTimer;
Timer? _successResetTimer;
// Getters
bool get hasAnyIssues => _overpassHasIssues;
bool get overpassHasIssues => _overpassHasIssues;
bool get isWaitingForData => _isWaitingForData;
bool get isTimedOut => _isTimedOut;
bool get hasNoData => _hasNoData;
bool get hasSuccess => _hasSuccess;
NetworkRequestStatus _status = NetworkRequestStatus.idle;
Timer? _autoResetTimer;
NetworkStatusType get currentStatus {
// Simple single-path status logic
if (hasAnyIssues) return NetworkStatusType.issues;
if (_isWaitingForData) return NetworkStatusType.waiting;
if (_isTimedOut) return NetworkStatusType.timedOut;
if (_hasNoData) return NetworkStatusType.noData;
if (_hasSuccess) return NetworkStatusType.success;
return NetworkStatusType.ready;
}
NetworkIssueType? get currentIssueType {
if (_overpassHasIssues) return NetworkIssueType.overpassApi;
return null;
}
/// Report Overpass API issues
void reportOverpassIssue() {
if (!_overpassHasIssues) {
_overpassHasIssues = true;
notifyListeners();
debugPrint('[NetworkStatus] Overpass API issues detected');
/// Current network status
NetworkRequestStatus get status => _status;
/// Set status and handle auto-reset timers
void _setStatus(NetworkRequestStatus newStatus) {
if (_status == newStatus) return;
_status = newStatus;
_autoResetTimer?.cancel();
// Auto-reset certain statuses after a delay
switch (newStatus) {
case NetworkRequestStatus.success:
_autoResetTimer = Timer(const Duration(seconds: 2), () {
_setStatus(NetworkRequestStatus.idle);
});
break;
case NetworkRequestStatus.timeout:
case NetworkRequestStatus.error:
_autoResetTimer = Timer(const Duration(seconds: 5), () {
_setStatus(NetworkRequestStatus.idle);
});
break;
case NetworkRequestStatus.noData:
_autoResetTimer = Timer(const Duration(seconds: 3), () {
_setStatus(NetworkRequestStatus.idle);
});
break;
case NetworkRequestStatus.rateLimited:
_autoResetTimer = Timer(const Duration(minutes: 2), () {
_setStatus(NetworkRequestStatus.idle);
});
break;
default:
// No auto-reset for idle, loading, splitting
break;
}
// Reset recovery timer
_overpassRecoveryTimer?.cancel();
_overpassRecoveryTimer = Timer(const Duration(minutes: 2), () {
_overpassHasIssues = false;
notifyListeners();
debugPrint('[NetworkStatus] Overpass API issues cleared');
});
notifyListeners();
}
/// Report successful operations to potentially clear issues faster
void reportOverpassSuccess() {
if (_overpassHasIssues) {
// Quietly clear - don't log routine success
_overpassHasIssues = false;
_overpassRecoveryTimer?.cancel();
notifyListeners();
}
}
/// Report that requests are taking longer than usual (splitting, backoffs, etc.)
void reportSlowProgress() {
if (!_overpassHasIssues) {
_overpassHasIssues = true;
_isWaitingForData = false; // Transition from waiting to slow progress
notifyListeners();
debugPrint('[NetworkStatus] Surveillance data requests taking longer than usual');
}
// Reset recovery timer - we'll clear this when the operation actually completes
_overpassRecoveryTimer?.cancel();
_overpassRecoveryTimer = Timer(const Duration(minutes: 2), () {
_overpassHasIssues = false;
notifyListeners();
debugPrint('[NetworkStatus] Slow progress status cleared');
});
}
/// Set waiting status (show when loading surveillance data)
void setWaiting() {
// Clear any previous timeout/no-data state when starting new wait
_isTimedOut = false;
_hasNoData = false;
_recentOfflineMisses = 0;
_noDataResetTimer?.cancel();
if (!_isWaitingForData) {
_isWaitingForData = true;
notifyListeners();
}
/// Start loading surveillance data
void setLoading() {
debugPrint('[NetworkStatus] Loading surveillance data');
_setStatus(NetworkRequestStatus.loading);
}
/// Show success status briefly when data loads
/// Request is being split due to complexity/limits
void setSplitting() {
debugPrint('[NetworkStatus] Splitting request due to complexity');
_setStatus(NetworkRequestStatus.splitting);
}
/// Data loaded successfully
void setSuccess() {
_isWaitingForData = false;
_isTimedOut = false;
_hasNoData = false;
_hasSuccess = true;
_recentOfflineMisses = 0;
_noDataResetTimer?.cancel();
notifyListeners();
// Auto-clear success status after 2 seconds
_successResetTimer?.cancel();
_successResetTimer = Timer(const Duration(seconds: 2), () {
if (_hasSuccess) {
_hasSuccess = false;
notifyListeners();
}
});
}
/// Show no-data status briefly when tiles aren't available
void setNoData() {
_isWaitingForData = false;
_isTimedOut = false;
_hasSuccess = false;
_hasNoData = true;
_successResetTimer?.cancel();
notifyListeners();
// Auto-clear no-data status after 2 seconds
_noDataResetTimer?.cancel();
_noDataResetTimer = Timer(const Duration(seconds: 2), () {
if (_hasNoData) {
_hasNoData = false;
notifyListeners();
}
});
}
/// Clear waiting/timeout/no-data status (legacy method for compatibility)
void clearWaiting() {
if (_isWaitingForData || _isTimedOut || _hasNoData || _hasSuccess) {
_isWaitingForData = false;
_isTimedOut = false;
_hasNoData = false;
_hasSuccess = false;
_recentOfflineMisses = 0;
_noDataResetTimer?.cancel();
_successResetTimer?.cancel();
notifyListeners();
}
debugPrint('[NetworkStatus] Surveillance data loaded successfully');
_setStatus(NetworkRequestStatus.success);
}
/// Set timeout error state
void setTimeoutError() {
_isWaitingForData = false;
_isTimedOut = true;
_hasNoData = false;
_hasSuccess = false;
_noDataResetTimer?.cancel();
_successResetTimer?.cancel();
notifyListeners();
/// Request timed out
void setTimeout() {
debugPrint('[NetworkStatus] Request timed out');
// Auto-clear timeout after 5 seconds
Timer(const Duration(seconds: 5), () {
if (_isTimedOut) {
_isTimedOut = false;
notifyListeners();
}
});
_setStatus(NetworkRequestStatus.timeout);
}
/// Set network error state (rate limits, connection issues, etc.)
void setNetworkError() {
_isWaitingForData = false;
_isTimedOut = false;
_hasNoData = false;
_hasSuccess = false;
_noDataResetTimer?.cancel();
_successResetTimer?.cancel();
// Use existing issue reporting system
reportOverpassIssue();
/// Rate limited by API
void setRateLimited() {
debugPrint('[NetworkStatus] Rate limited by API');
_setStatus(NetworkRequestStatus.rateLimited);
}
/// No offline data available
void setNoData() {
debugPrint('[NetworkStatus] No offline data available');
_setStatus(NetworkRequestStatus.noData);
}
/// Network or other error
void setError() {
debugPrint('[NetworkStatus] Network error occurred');
_setStatus(NetworkRequestStatus.error);
}
/// Report that a tile was not available offline
void reportOfflineMiss() {
_recentOfflineMisses++;
debugPrint('[NetworkStatus] Offline miss #$_recentOfflineMisses');
// If we get several misses in a short time, show "no data" status
if (_recentOfflineMisses >= 3 && !_hasNoData) {
_isWaitingForData = false;
_isTimedOut = false;
_hasNoData = true;
notifyListeners();
debugPrint('[NetworkStatus] No offline data available for this area');
}
// Reset the miss counter after some time
_noDataResetTimer?.cancel();
_noDataResetTimer = Timer(const Duration(seconds: 5), () {
_recentOfflineMisses = 0;
});
/// Clear status (force to idle)
void clear() {
_setStatus(NetworkRequestStatus.idle);
}
@override
void dispose() {
_overpassRecoveryTimer?.cancel();
_noDataResetTimer?.cancel();
_successResetTimer?.cancel();
_autoResetTimer?.cancel();
super.dispose();
}
}

View File

@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import '../models/osm_node.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
@@ -85,7 +86,7 @@ class NodeCache {
/// Remove a node by ID from the cache (used for successful deletions)
void removeNodeById(int nodeId) {
if (_nodes.remove(nodeId) != null) {
print('[NodeCache] Removed node $nodeId from cache (successful deletion)');
debugPrint('[NodeCache] Removed node $nodeId from cache (successful deletion)');
}
}
@@ -111,19 +112,19 @@ class NodeCache {
}
if (nodesToRemove.isNotEmpty) {
print('[NodeCache] Removed ${nodesToRemove.length} temp nodes at coordinate ${coord.latitude}, ${coord.longitude}');
debugPrint('[NodeCache] Removed ${nodesToRemove.length} temp nodes at coordinate ${coord.latitude}, ${coord.longitude}');
}
}
/// Remove a specific temporary node by its ID (for queue item-specific cleanup)
void removeTempNodeById(int tempNodeId) {
if (tempNodeId >= 0) {
print('[NodeCache] Warning: Attempted to remove non-temp node ID $tempNodeId');
debugPrint('[NodeCache] Warning: Attempted to remove non-temp node ID $tempNodeId');
return;
}
if (_nodes.remove(tempNodeId) != null) {
print('[NodeCache] Removed specific temp node $tempNodeId from cache');
debugPrint('[NodeCache] Removed specific temp node $tempNodeId from cache');
}
}

View File

@@ -0,0 +1,403 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart';
import '../models/node_profile.dart';
import '../models/osm_node.dart';
import '../app_state.dart';
import 'overpass_service.dart';
import 'node_spatial_cache.dart';
import 'network_status.dart';
import 'map_data_submodules/nodes_from_osm_api.dart';
import 'map_data_submodules/nodes_from_local.dart';
import 'offline_area_service.dart';
import 'offline_areas/offline_area_models.dart';
/// Coordinates node data fetching between cache, Overpass, and OSM API.
/// Simple interface: give me nodes for this view with proper caching and error handling.
class NodeDataManager extends ChangeNotifier {
static final NodeDataManager _instance = NodeDataManager._();
factory NodeDataManager() => _instance;
NodeDataManager._();
final OverpassService _overpassService = OverpassService();
final NodeSpatialCache _cache = NodeSpatialCache();
// Track ongoing user-initiated requests for status reporting
final Set<String> _userInitiatedRequests = <String>{};
/// Get nodes for the given bounds and profiles.
/// Returns cached data immediately if available, otherwise fetches from appropriate source.
Future<List<OsmNode>> getNodesFor({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
UploadMode uploadMode = UploadMode.production,
bool isUserInitiated = false,
}) async {
if (profiles.isEmpty) return [];
// Handle offline mode - no loading states needed, data is instant
if (AppState.instance.offlineMode) {
// Clear any existing loading states since offline data is instant
if (isUserInitiated) {
NetworkStatus.instance.clear();
}
if (uploadMode == UploadMode.sandbox) {
// Offline + Sandbox = no nodes (local cache is production data)
debugPrint('[NodeDataManager] Offline + Sandbox mode: returning no nodes');
return [];
} else {
// Offline + Production = use local offline areas (instant)
final offlineNodes = await fetchLocalNodes(bounds: bounds, profiles: profiles);
// Add offline nodes to cache so they integrate with the rest of the system
if (offlineNodes.isNotEmpty) {
_cache.addOrUpdateNodes(offlineNodes);
// Mark this area as having coverage for submit button logic
_cache.markAreaAsFetched(bounds, offlineNodes);
notifyListeners();
}
// Show brief success for user-initiated offline loads with data
if (isUserInitiated && offlineNodes.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
NetworkStatus.instance.setSuccess();
});
} else if (isUserInitiated && offlineNodes.isEmpty) {
// Show no data briefly for offline areas with no surveillance devices
WidgetsBinding.instance.addPostFrameCallback((_) {
NetworkStatus.instance.setNoData();
});
}
return offlineNodes;
}
}
// Handle sandbox mode (always fetch from OSM API, but integrate with cache system for UI)
if (uploadMode == UploadMode.sandbox) {
debugPrint('[NodeDataManager] Sandbox mode: fetching from OSM API');
// Track user-initiated requests for status reporting
final requestKey = '${bounds.hashCode}_${profiles.map((p) => p.id).join('_')}_$uploadMode';
if (isUserInitiated && _userInitiatedRequests.contains(requestKey)) {
debugPrint('[NodeDataManager] Sandbox request already in progress for this area');
return _cache.getNodesFor(bounds);
}
// Start status tracking for user-initiated requests
if (isUserInitiated) {
_userInitiatedRequests.add(requestKey);
NetworkStatus.instance.setLoading();
debugPrint('[NodeDataManager] Starting user-initiated sandbox request');
} else {
debugPrint('[NodeDataManager] Starting background sandbox request (no status reporting)');
}
try {
final nodes = await fetchOsmApiNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: 0,
);
// Add nodes to cache for UI integration (even though we don't rely on cache for subsequent fetches)
if (nodes.isNotEmpty) {
_cache.addOrUpdateNodes(nodes);
_cache.markAreaAsFetched(bounds, nodes);
} else {
// Mark area as fetched even with no nodes so UI knows we've checked this area
_cache.markAreaAsFetched(bounds, []);
}
// Update UI
notifyListeners();
// Set success after the next frame renders, but only for user-initiated requests
if (isUserInitiated) {
WidgetsBinding.instance.addPostFrameCallback((_) {
NetworkStatus.instance.setSuccess();
});
debugPrint('[NodeDataManager] User-initiated sandbox request completed successfully: ${nodes.length} nodes');
}
return nodes;
} catch (e) {
debugPrint('[NodeDataManager] Sandbox fetch failed: $e');
// Only report errors for user-initiated requests
if (isUserInitiated) {
if (e is RateLimitError) {
NetworkStatus.instance.setRateLimited();
} else if (e.toString().contains('timeout')) {
NetworkStatus.instance.setTimeout();
} else {
NetworkStatus.instance.setError();
}
debugPrint('[NodeDataManager] User-initiated sandbox request failed: $e');
}
// Return whatever we have in cache for this area (likely empty for sandbox)
return _cache.getNodesFor(bounds);
} finally {
if (isUserInitiated) {
_userInitiatedRequests.remove(requestKey);
}
}
}
// Production mode: check cache first
if (_cache.hasDataFor(bounds)) {
debugPrint('[NodeDataManager] Using cached data for bounds');
return _cache.getNodesFor(bounds);
}
// Not cached - need to fetch
final requestKey = '${bounds.hashCode}_${profiles.map((p) => p.id).join('_')}_$uploadMode';
// Only allow one user-initiated request per area at a time
if (isUserInitiated && _userInitiatedRequests.contains(requestKey)) {
debugPrint('[NodeDataManager] User request already in progress for this area');
return _cache.getNodesFor(bounds);
}
// Start status tracking for user-initiated requests only
if (isUserInitiated) {
_userInitiatedRequests.add(requestKey);
NetworkStatus.instance.setLoading();
debugPrint('[NodeDataManager] Starting user-initiated request');
} else {
debugPrint('[NodeDataManager] Starting background request (no status reporting)');
}
try {
final nodes = await fetchWithSplitting(bounds, profiles, isUserInitiated: isUserInitiated);
// Update cache and notify listeners
notifyListeners();
// Set success after the next frame renders, but only for user-initiated requests
if (isUserInitiated) {
WidgetsBinding.instance.addPostFrameCallback((_) {
NetworkStatus.instance.setSuccess();
});
debugPrint('[NodeDataManager] User-initiated request completed successfully');
}
return nodes;
} catch (e) {
debugPrint('[NodeDataManager] Fetch failed: $e');
// Only report errors for user-initiated requests
if (isUserInitiated) {
if (e is RateLimitError) {
NetworkStatus.instance.setRateLimited();
} else if (e.toString().contains('timeout')) {
NetworkStatus.instance.setTimeout();
} else {
NetworkStatus.instance.setError();
}
debugPrint('[NodeDataManager] User-initiated request failed: $e');
}
// Return whatever we have in cache for this area
return _cache.getNodesFor(bounds);
} finally {
if (isUserInitiated) {
_userInitiatedRequests.remove(requestKey);
}
}
}
/// Fetch nodes with automatic area splitting if needed
Future<List<OsmNode>> fetchWithSplitting(
LatLngBounds bounds,
List<NodeProfile> profiles, {
int splitDepth = 0,
bool isUserInitiated = false,
}) async {
const maxSplitDepth = 3; // 4^3 = 64 max sub-areas
try {
// Expand bounds slightly to reduce edge effects
final expandedBounds = _expandBounds(bounds, 1.2);
final nodes = await _overpassService.fetchNodes(
bounds: expandedBounds,
profiles: profiles,
);
// Success - cache the data for the expanded area
_cache.markAreaAsFetched(expandedBounds, nodes);
return nodes;
} on NodeLimitError {
// Hit node limit or timeout - split area if not too deep
if (splitDepth >= maxSplitDepth) {
debugPrint('[NodeDataManager] Max split depth reached, giving up');
return [];
}
debugPrint('[NodeDataManager] Splitting area (depth: $splitDepth)');
// Only report splitting status for user-initiated requests
if (isUserInitiated && splitDepth == 0) {
NetworkStatus.instance.setSplitting();
}
return _fetchSplitAreas(bounds, profiles, splitDepth + 1, isUserInitiated: isUserInitiated);
} on RateLimitError {
// Rate limited - wait and return empty
debugPrint('[NodeDataManager] Rate limited, backing off');
await Future.delayed(const Duration(seconds: 30));
return [];
}
}
/// Fetch data by splitting area into quadrants
Future<List<OsmNode>> _fetchSplitAreas(
LatLngBounds bounds,
List<NodeProfile> profiles,
int splitDepth, {
bool isUserInitiated = false,
}) async {
final quadrants = _splitBounds(bounds);
final allNodes = <OsmNode>[];
for (final quadrant in quadrants) {
try {
final nodes = await fetchWithSplitting(
quadrant,
profiles,
splitDepth: splitDepth,
isUserInitiated: isUserInitiated,
);
allNodes.addAll(nodes);
} catch (e) {
debugPrint('[NodeDataManager] Quadrant fetch failed: $e');
// Continue with other quadrants
}
}
debugPrint('[NodeDataManager] Split fetch complete: ${allNodes.length} total nodes');
return allNodes;
}
/// Split bounds into 4 quadrants
List<LatLngBounds> _splitBounds(LatLngBounds bounds) {
final centerLat = (bounds.north + bounds.south) / 2;
final centerLng = (bounds.east + bounds.west) / 2;
return [
// Southwest
LatLngBounds(LatLng(bounds.south, bounds.west), LatLng(centerLat, centerLng)),
// Southeast
LatLngBounds(LatLng(bounds.south, centerLng), LatLng(centerLat, bounds.east)),
// Northwest
LatLngBounds(LatLng(centerLat, bounds.west), LatLng(bounds.north, centerLng)),
// Northeast
LatLngBounds(LatLng(centerLat, centerLng), LatLng(bounds.north, bounds.east)),
];
}
/// Expand bounds by given factor around center point
LatLngBounds _expandBounds(LatLngBounds bounds, double factor) {
final centerLat = (bounds.north + bounds.south) / 2;
final centerLng = (bounds.east + bounds.west) / 2;
final latSpan = (bounds.north - bounds.south) * factor / 2;
final lngSpan = (bounds.east - bounds.west) * factor / 2;
return LatLngBounds(
LatLng(centerLat - latSpan, centerLng - lngSpan),
LatLng(centerLat + latSpan, centerLng + lngSpan),
);
}
/// Add or update nodes in cache (for upload queue integration)
void addOrUpdateNodes(List<OsmNode> nodes) {
_cache.addOrUpdateNodes(nodes);
notifyListeners();
}
/// Remove node from cache (for deletions)
void removeNodeById(int nodeId) {
_cache.removeNodeById(nodeId);
notifyListeners();
}
/// Clear cache (when profiles change significantly)
void clearCache() {
_cache.clear();
notifyListeners();
}
/// Force refresh for current view (manual retry)
Future<void> refreshArea({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
UploadMode uploadMode = UploadMode.production,
}) async {
// Clear any cached data for this area
_cache.clear();
// Re-fetch as user-initiated request
await getNodesFor(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
isUserInitiated: true,
);
}
/// NodeCache compatibility methods
OsmNode? getNodeById(int nodeId) => _cache.getNodeById(nodeId);
void removePendingEditMarker(int nodeId) => _cache.removePendingEditMarker(nodeId);
void removePendingDeletionMarker(int nodeId) => _cache.removePendingDeletionMarker(nodeId);
void removeTempNodeById(int tempNodeId) => _cache.removeTempNodeById(tempNodeId);
List<OsmNode> findNodesWithinDistance(LatLng coord, double distanceMeters, {int? excludeNodeId}) =>
_cache.findNodesWithinDistance(coord, distanceMeters, excludeNodeId: excludeNodeId);
/// Check if we have good cache coverage for the given area
bool hasGoodCoverageFor(LatLngBounds bounds) {
return _cache.hasDataFor(bounds);
}
/// Load all offline nodes into cache (call at app startup)
Future<void> preloadOfflineNodes() async {
try {
final offlineAreaService = OfflineAreaService();
for (final area in offlineAreaService.offlineAreas) {
if (area.status != OfflineAreaStatus.complete) continue;
// Load nodes from this offline area
final nodes = await fetchLocalNodes(
bounds: area.bounds,
profiles: [], // Empty profiles = load all nodes
);
if (nodes.isNotEmpty) {
_cache.addOrUpdateNodes(nodes);
// Mark the offline area as having coverage so submit buttons work
_cache.markAreaAsFetched(area.bounds, nodes);
debugPrint('[NodeDataManager] Preloaded ${nodes.length} offline nodes from area ${area.name}');
}
}
notifyListeners();
} catch (e) {
debugPrint('[NodeDataManager] Error preloading offline nodes: $e');
}
}
/// Get cache statistics
String get cacheStats => _cache.stats.toString();
}

View File

@@ -0,0 +1,190 @@
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart';
import '../models/osm_node.dart';
const Distance _distance = Distance();
/// Simple spatial cache that tracks which areas have been successfully fetched.
/// No temporal expiration - data stays cached until app restart or explicit clear.
class NodeSpatialCache {
static final NodeSpatialCache _instance = NodeSpatialCache._();
factory NodeSpatialCache() => _instance;
NodeSpatialCache._();
final List<CachedArea> _fetchedAreas = [];
final Map<int, OsmNode> _nodes = {}; // nodeId -> node
/// Check if we have cached data covering the given bounds
bool hasDataFor(LatLngBounds bounds) {
return _fetchedAreas.any((area) => area.bounds.containsBounds(bounds));
}
/// Record that we successfully fetched data for this area
void markAreaAsFetched(LatLngBounds bounds, List<OsmNode> nodes) {
// Add the fetched area
_fetchedAreas.add(CachedArea(bounds, DateTime.now()));
// Update nodes in cache
for (final node in nodes) {
_nodes[node.id] = node;
}
debugPrint('[NodeSpatialCache] Cached ${nodes.length} nodes for area ${bounds.south.toStringAsFixed(3)},${bounds.west.toStringAsFixed(3)} to ${bounds.north.toStringAsFixed(3)},${bounds.east.toStringAsFixed(3)}');
debugPrint('[NodeSpatialCache] Total areas cached: ${_fetchedAreas.length}, total nodes: ${_nodes.length}');
}
/// Get all cached nodes within the given bounds
List<OsmNode> getNodesFor(LatLngBounds bounds) {
return _nodes.values
.where((node) => bounds.contains(node.coord))
.toList();
}
/// Add or update individual nodes (for upload queue integration)
void addOrUpdateNodes(List<OsmNode> nodes) {
for (final node in nodes) {
final existing = _nodes[node.id];
if (existing != null) {
// Preserve any tags starting with underscore when updating existing nodes
final mergedTags = Map<String, String>.from(node.tags);
for (final entry in existing.tags.entries) {
if (entry.key.startsWith('_')) {
mergedTags[entry.key] = entry.value;
}
}
_nodes[node.id] = OsmNode(
id: node.id,
coord: node.coord,
tags: mergedTags,
isConstrained: node.isConstrained,
);
} else {
_nodes[node.id] = node;
}
}
}
/// Remove a node by ID (for deletions)
void removeNodeById(int nodeId) {
if (_nodes.remove(nodeId) != null) {
debugPrint('[NodeSpatialCache] Removed node $nodeId from cache');
}
}
/// Get a specific node by ID (returns null if not found)
OsmNode? getNodeById(int nodeId) {
return _nodes[nodeId];
}
/// Remove the _pending_edit marker from a specific node
void removePendingEditMarker(int nodeId) {
final node = _nodes[nodeId];
if (node != null && node.tags.containsKey('_pending_edit')) {
final cleanTags = Map<String, String>.from(node.tags);
cleanTags.remove('_pending_edit');
_nodes[nodeId] = OsmNode(
id: node.id,
coord: node.coord,
tags: cleanTags,
isConstrained: node.isConstrained,
);
}
}
/// Remove the _pending_deletion marker from a specific node
void removePendingDeletionMarker(int nodeId) {
final node = _nodes[nodeId];
if (node != null && node.tags.containsKey('_pending_deletion')) {
final cleanTags = Map<String, String>.from(node.tags);
cleanTags.remove('_pending_deletion');
_nodes[nodeId] = OsmNode(
id: node.id,
coord: node.coord,
tags: cleanTags,
isConstrained: node.isConstrained,
);
}
}
/// Remove a specific temporary node by its ID
void removeTempNodeById(int tempNodeId) {
if (tempNodeId >= 0) {
debugPrint('[NodeSpatialCache] Warning: Attempted to remove non-temp node ID $tempNodeId');
return;
}
if (_nodes.remove(tempNodeId) != null) {
debugPrint('[NodeSpatialCache] Removed temp node $tempNodeId from cache');
}
}
/// Find nodes within distance of a coordinate (for proximity warnings)
List<OsmNode> findNodesWithinDistance(LatLng coord, double distanceMeters, {int? excludeNodeId}) {
final nearbyNodes = <OsmNode>[];
for (final node in _nodes.values) {
// Skip the excluded node
if (excludeNodeId != null && node.id == excludeNodeId) {
continue;
}
// Skip nodes marked for deletion
if (node.tags.containsKey('_pending_deletion')) {
continue;
}
final distanceInMeters = _distance.as(LengthUnit.Meter, coord, node.coord);
if (distanceInMeters <= distanceMeters) {
nearbyNodes.add(node);
}
}
return nearbyNodes;
}
/// Clear all cached data
void clear() {
_fetchedAreas.clear();
_nodes.clear();
debugPrint('[NodeSpatialCache] Cache cleared');
}
/// Get cache statistics for debugging
CacheStats get stats => CacheStats(
areasCount: _fetchedAreas.length,
nodesCount: _nodes.length,
);
}
/// Represents an area that has been successfully fetched
class CachedArea {
final LatLngBounds bounds;
final DateTime fetchedAt;
CachedArea(this.bounds, this.fetchedAt);
}
/// Cache statistics for debugging
class CacheStats {
final int areasCount;
final int nodesCount;
CacheStats({required this.areasCount, required this.nodesCount});
@override
String toString() => 'CacheStats(areas: $areasCount, nodes: $nodesCount)';
}
/// Extension to check if one bounds completely contains another
extension LatLngBoundsExtension on LatLngBounds {
bool containsBounds(LatLngBounds other) {
return north >= other.north &&
south <= other.south &&
east >= other.east &&
west <= other.west;
}
}

View File

@@ -84,8 +84,7 @@ class NSIService {
}
}
// Limit to top 10 suggestions for UI performance
if (suggestions.length >= 10) break;
if (suggestions.length >= kNSIMaxSuggestions) break;
}
return suggestions;

View File

@@ -1,17 +1,14 @@
import 'dart:io';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
import 'package:path_provider/path_provider.dart';
import 'offline_areas/offline_area_models.dart';
import 'offline_areas/offline_tile_utils.dart';
import 'offline_areas/offline_area_downloader.dart';
import '../models/osm_node.dart';
import '../app_state.dart';
import 'map_data_provider.dart';
import 'package:deflockapp/dev_config.dart';
/// Service for managing download, storage, and retrieval of offline map areas and cameras.
class OfflineAreaService {

View File

@@ -0,0 +1,190 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart';
import '../models/node_profile.dart';
import '../models/osm_node.dart';
import '../dev_config.dart';
/// Simple Overpass API client with proper HTTP retry logic.
/// Single responsibility: Make requests, handle network errors, return data.
class OverpassService {
static const String _endpoint = 'https://overpass-api.de/api/interpreter';
final http.Client _client;
OverpassService({http.Client? client}) : _client = client ?? http.Client();
/// Fetch surveillance nodes from Overpass API with proper retry logic.
/// Throws NetworkError for retryable failures, NodeLimitError for area splitting.
Future<List<OsmNode>> fetchNodes({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
int maxRetries = 3,
}) async {
if (profiles.isEmpty) return [];
final query = _buildQuery(bounds, profiles);
for (int attempt = 0; attempt <= maxRetries; attempt++) {
try {
debugPrint('[OverpassService] Attempt ${attempt + 1}/${maxRetries + 1} for ${profiles.length} profiles');
final response = await _client.post(
Uri.parse(_endpoint),
body: {'data': query},
).timeout(kOverpassQueryTimeout);
if (response.statusCode == 200) {
return _parseResponse(response.body);
}
// Check for specific error types
final errorBody = response.body;
// Node limit error - caller should split area
if (response.statusCode == 400 &&
(errorBody.contains('too many nodes') && errorBody.contains('50000'))) {
debugPrint('[OverpassService] Node limit exceeded, area should be split');
throw NodeLimitError('Query exceeded 50k node limit');
}
// Timeout error - also try splitting (complex query)
if (errorBody.contains('timeout') ||
errorBody.contains('runtime limit exceeded') ||
errorBody.contains('Query timed out')) {
debugPrint('[OverpassService] Query timeout, area should be split');
throw NodeLimitError('Query timed out - area too complex');
}
// Rate limit - throw immediately, don't retry
if (response.statusCode == 429 ||
errorBody.contains('rate limited') ||
errorBody.contains('too many requests')) {
debugPrint('[OverpassService] Rate limited by Overpass');
throw RateLimitError('Rate limited by Overpass API');
}
// Other HTTP errors - retry with backoff
if (attempt < maxRetries) {
final delay = Duration(milliseconds: (200 * (1 << attempt)).clamp(200, 5000));
debugPrint('[OverpassService] HTTP ${response.statusCode} error, retrying in ${delay.inMilliseconds}ms');
await Future.delayed(delay);
continue;
}
throw NetworkError('HTTP ${response.statusCode}: $errorBody');
} catch (e) {
// Handle specific error types without retry
if (e is NodeLimitError || e is RateLimitError) {
rethrow;
}
// Network/timeout errors - retry with backoff
if (attempt < maxRetries) {
final delay = Duration(milliseconds: (200 * (1 << attempt)).clamp(200, 5000));
debugPrint('[OverpassService] Network error ($e), retrying in ${delay.inMilliseconds}ms');
await Future.delayed(delay);
continue;
}
throw NetworkError('Network error after $maxRetries retries: $e');
}
}
throw NetworkError('Max retries exceeded');
}
/// Build Overpass QL query for given bounds and profiles
String _buildQuery(LatLngBounds bounds, List<NodeProfile> profiles) {
final nodeClauses = profiles.map((profile) {
// Convert profile tags to Overpass filter format, excluding empty values
final tagFilters = profile.tags.entries
.where((entry) => entry.value.trim().isNotEmpty)
.map((entry) => '["${entry.key}"="${entry.value}"]')
.join();
return 'node$tagFilters(${bounds.southWest.latitude},${bounds.southWest.longitude},${bounds.northEast.latitude},${bounds.northEast.longitude});';
}).join('\n ');
return '''
[out:json][timeout:${kOverpassQueryTimeout.inSeconds}];
(
$nodeClauses
);
out body;
(
way(bn);
rel(bn);
);
out skel;
''';
}
/// Parse Overpass JSON response into OsmNode objects
List<OsmNode> _parseResponse(String responseBody) {
final data = jsonDecode(responseBody) as Map<String, dynamic>;
final elements = data['elements'] as List<dynamic>;
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') {
nodeElements.add(element);
} else if (type == 'way' || type == 'relation') {
// Mark referenced nodes as constrained
final refs = element['nodes'] as List<dynamic>? ??
element['members']?.where((m) => m['type'] == 'node').map((m) => m['ref']) ?? [];
for (final ref in refs) {
final nodeId = ref is int ? ref : int.tryParse(ref.toString());
if (nodeId != null) constrainedNodeIds.add(nodeId);
}
}
}
// Second pass: create OsmNode objects
final nodes = nodeElements.map((element) {
final nodeId = element['id'] as int;
return OsmNode(
id: nodeId,
coord: LatLng(element['lat'], element['lon']),
tags: Map<String, String>.from(element['tags'] ?? {}),
isConstrained: constrainedNodeIds.contains(nodeId),
);
}).toList();
debugPrint('[OverpassService] Parsed ${nodes.length} nodes, ${constrainedNodeIds.length} constrained');
return nodes;
}
}
/// Error thrown when query exceeds node limits or is too complex - area should be split
class NodeLimitError extends Error {
final String message;
NodeLimitError(this.message);
@override
String toString() => 'NodeLimitError: $message';
}
/// Error thrown when rate limited - should not retry immediately
class RateLimitError extends Error {
final String message;
RateLimitError(this.message);
@override
String toString() => 'RateLimitError: $message';
}
/// Error thrown for network/HTTP issues - retryable
class NetworkError extends Error {
final String message;
NetworkError(this.message);
@override
String toString() => 'NetworkError: $message';
}

View File

@@ -1,192 +0,0 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart';
import '../models/node_profile.dart';
import '../models/osm_node.dart';
import '../app_state.dart';
import '../dev_config.dart';
import 'map_data_submodules/nodes_from_overpass.dart';
import 'node_cache.dart';
import 'network_status.dart';
import '../widgets/node_provider_with_cache.dart';
/// Manages pre-fetching larger areas to reduce Overpass API calls.
/// Uses zoom level 10 areas and automatically splits if hitting node limits.
class PrefetchAreaService {
static final PrefetchAreaService _instance = PrefetchAreaService._();
factory PrefetchAreaService() => _instance;
PrefetchAreaService._();
// Current pre-fetched area and associated data
LatLngBounds? _preFetchedArea;
List<NodeProfile>? _preFetchedProfiles;
UploadMode? _preFetchedUploadMode;
DateTime? _lastFetchTime;
bool _preFetchInProgress = false;
// Debounce timer to avoid rapid requests while user is panning
Timer? _debounceTimer;
// Configuration from dev_config
static const double _areaExpansionMultiplier = kPreFetchAreaExpansionMultiplier;
static const int _preFetchZoomLevel = kPreFetchZoomLevel;
/// Check if the given bounds are fully within the current pre-fetched area.
bool isWithinPreFetchedArea(LatLngBounds bounds, List<NodeProfile> profiles, UploadMode uploadMode) {
if (_preFetchedArea == null || _preFetchedProfiles == null || _preFetchedUploadMode == null) {
return false;
}
// Check if profiles and upload mode match
if (_preFetchedUploadMode != uploadMode) {
return false;
}
if (!_profileListsEqual(_preFetchedProfiles!, profiles)) {
return false;
}
// Check if bounds are fully contained within pre-fetched area
return bounds.north <= _preFetchedArea!.north &&
bounds.south >= _preFetchedArea!.south &&
bounds.east <= _preFetchedArea!.east &&
bounds.west >= _preFetchedArea!.west;
}
/// Check if cached data is stale (older than configured refresh interval).
bool isDataStale() {
if (_lastFetchTime == null) return true;
return DateTime.now().difference(_lastFetchTime!).inSeconds > kDataRefreshIntervalSeconds;
}
/// Request pre-fetch for the given view bounds if not already covered or if data is stale.
/// Uses debouncing to avoid rapid requests while user is panning.
void requestPreFetchIfNeeded({
required LatLngBounds viewBounds,
required List<NodeProfile> profiles,
required UploadMode uploadMode,
}) {
// Skip if already in progress
if (_preFetchInProgress) {
debugPrint('[PrefetchAreaService] Pre-fetch already in progress, skipping');
return;
}
// Check both spatial and temporal conditions
final isWithinArea = isWithinPreFetchedArea(viewBounds, profiles, uploadMode);
final isStale = isDataStale();
if (isWithinArea && !isStale) {
debugPrint('[PrefetchAreaService] Current view within fresh pre-fetched area, no fetch needed');
return;
}
if (isStale) {
debugPrint('[PrefetchAreaService] Data is stale (>${kDataRefreshIntervalSeconds}s), refreshing');
} else {
debugPrint('[PrefetchAreaService] Current view outside pre-fetched area, fetching larger area');
}
// Cancel any pending debounced request
_debounceTimer?.cancel();
// Debounce to avoid rapid requests while user is still moving
_debounceTimer = Timer(const Duration(milliseconds: 800), () {
_startPreFetch(
viewBounds: viewBounds,
profiles: profiles,
uploadMode: uploadMode,
);
});
}
/// Start the actual pre-fetch operation.
Future<void> _startPreFetch({
required LatLngBounds viewBounds,
required List<NodeProfile> profiles,
required UploadMode uploadMode,
}) async {
if (_preFetchInProgress) return;
_preFetchInProgress = true;
try {
// Calculate expanded area for pre-fetching
final preFetchArea = _expandBounds(viewBounds, _areaExpansionMultiplier);
debugPrint('[PrefetchAreaService] Starting pre-fetch for area: ${preFetchArea.south},${preFetchArea.west} to ${preFetchArea.north},${preFetchArea.east}');
// Fetch nodes for the expanded area (unlimited - let splitting handle 50k limit)
final nodes = await fetchOverpassNodes(
bounds: preFetchArea,
profiles: profiles,
uploadMode: uploadMode,
maxResults: 0, // Unlimited - our splitting system handles the 50k limit gracefully
);
debugPrint('[PrefetchAreaService] Pre-fetch completed: ${nodes.length} nodes retrieved');
// Update cache with new nodes (fresh data overwrites stale, but preserves underscore tags)
if (nodes.isNotEmpty) {
NodeCache.instance.addOrUpdate(nodes);
}
// Store the pre-fetched area info and timestamp
_preFetchedArea = preFetchArea;
_preFetchedProfiles = List.from(profiles);
_preFetchedUploadMode = uploadMode;
_lastFetchTime = DateTime.now();
// The overpass module already reported success/failure during fetching
// We just need to handle the successful result here
// Notify UI that cache has been updated with fresh data
NodeProviderWithCache.instance.refreshDisplay();
} catch (e) {
debugPrint('[PrefetchAreaService] Pre-fetch failed: $e');
// The overpass module already reported the error status
// Don't update pre-fetched area info on failure
} finally {
_preFetchInProgress = false;
}
}
/// Expand bounds by the given multiplier, maintaining center point.
LatLngBounds _expandBounds(LatLngBounds bounds, double multiplier) {
final centerLat = (bounds.north + bounds.south) / 2;
final centerLng = (bounds.east + bounds.west) / 2;
final latSpan = (bounds.north - bounds.south) * multiplier / 2;
final lngSpan = (bounds.east - bounds.west) * multiplier / 2;
return LatLngBounds(
LatLng(centerLat - latSpan, centerLng - lngSpan), // Southwest
LatLng(centerLat + latSpan, centerLng + lngSpan), // Northeast
);
}
/// Check if two profile lists are equal by comparing IDs.
bool _profileListsEqual(List<NodeProfile> list1, List<NodeProfile> list2) {
if (list1.length != list2.length) return false;
final ids1 = list1.map((p) => p.id).toSet();
final ids2 = list2.map((p) => p.id).toSet();
return ids1.length == ids2.length && ids1.containsAll(ids2);
}
/// Clear the pre-fetched area (e.g., when profiles change significantly).
void clearPreFetchedArea() {
_preFetchedArea = null;
_preFetchedProfiles = null;
_preFetchedUploadMode = null;
_lastFetchTime = null;
debugPrint('[PrefetchAreaService] Pre-fetched area cleared');
}
/// Dispose of resources.
void dispose() {
_debounceTimer?.cancel();
}
}

View File

@@ -41,9 +41,9 @@ class ProximityAlertService {
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
const iosSettings = DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
requestAlertPermission: false,
requestBadgePermission: false,
requestSoundPermission: false,
);
const initSettings = InitializationSettings(
@@ -55,12 +55,10 @@ class ProximityAlertService {
final initialized = await _notifications!.initialize(initSettings);
_isInitialized = initialized ?? false;
// Request notification permissions (especially important for Android 13+)
if (_isInitialized) {
await _requestNotificationPermissions();
}
// Note: We don't request notification permissions here anymore.
// Permissions are requested on-demand when user enables proximity alerts.
debugPrint('[ProximityAlertService] Initialized: $_isInitialized');
debugPrint('[ProximityAlertService] Initialized: $_isInitialized (permissions deferred)');
} catch (e) {
debugPrint('[ProximityAlertService] Failed to initialize: $e');
_isInitialized = false;

View File

@@ -27,7 +27,12 @@ class RouteResult {
class RoutingService {
static const String _baseUrl = 'https://alprwatch.org/api/v1/deflock/directions';
static const String _userAgent = 'DeFlock/1.0 (OSM surveillance mapping app)';
final http.Client _client;
RoutingService({http.Client? client}) : _client = client ?? http.Client();
void close() => _client.close();
// Calculate route between two points using alprwatch
Future<RouteResult> calculateRoute({
required LatLng start,
@@ -36,18 +41,20 @@ class RoutingService {
debugPrint('[RoutingService] Calculating route from $start to $end');
final prefs = await SharedPreferences.getInstance();
final avoidance_distance = await prefs.getInt('navigation_avoidance_distance');
final avoidanceDistance = prefs.getInt('navigation_avoidance_distance') ?? 250;
final enabled_profiles = AppState.instance.enabledProfiles.map((p) {
final enabledProfiles = AppState.instance.enabledProfiles.map((p) {
final full = p.toJson();
final tags = Map<String, String>.from(full['tags'] as Map);
tags.removeWhere((key, value) => value.isEmpty);
return {
'id': full['id'],
'name': full['name'],
'tags': full['tags'],
'tags': tags,
};
}).toList();
final uri = Uri.parse('$_baseUrl');
final uri = Uri.parse(_baseUrl);
final params = {
'start': {
'longitude': start.longitude,
@@ -57,15 +64,15 @@ class RoutingService {
'longitude': end.longitude,
'latitude': end.latitude
},
'avoidance_distance': avoidance_distance,
'enabled_profiles': enabled_profiles,
'avoidance_distance': avoidanceDistance,
'enabled_profiles': enabledProfiles,
'show_exclusion_zone': false, // for debugging: if true, returns a GeoJSON Feature MultiPolygon showing what areas are avoided in calculating the route
};
debugPrint('[RoutingService] alprwatch request: $uri $params');
try {
final response = await http.post(
final response = await _client.post(
uri,
headers: {
'User-Agent': _userAgent,
@@ -75,6 +82,16 @@ class RoutingService {
).timeout(kNavigationRoutingTimeout);
if (response.statusCode != 200) {
if (kDebugMode) {
debugPrint('[RoutingService] Error response body: ${response.body}');
} else {
const maxLen = 500;
final body = response.body;
final truncated = body.length > maxLen
? '${body.substring(0, maxLen)}… [truncated]'
: body;
debugPrint('[RoutingService] Error response body ($maxLen char max): $truncated');
}
throw RoutingException('HTTP ${response.statusCode}: ${response.reasonPhrase}');
}

View File

@@ -1,5 +1,6 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
import 'package:http/http.dart' as http;
import 'package:latlong2/latlong.dart';
@@ -12,19 +13,19 @@ class SearchService {
static const Duration _timeout = Duration(seconds: 10);
/// Search for places using Nominatim geocoding service
Future<List<SearchResult>> search(String query) async {
Future<List<SearchResult>> search(String query, {LatLngBounds? viewbox}) async {
if (query.trim().isEmpty) {
return [];
}
// Check if query looks like coordinates first
final coordResult = _tryParseCoordinates(query.trim());
if (coordResult != null) {
return [coordResult];
}
// Otherwise, use Nominatim API
return await _searchNominatim(query.trim());
return await _searchNominatim(query.trim(), viewbox: viewbox);
}
/// Try to parse various coordinate formats
@@ -52,14 +53,37 @@ class SearchService {
}
/// Search using Nominatim API
Future<List<SearchResult>> _searchNominatim(String query) async {
final uri = Uri.parse('$_baseUrl/search').replace(queryParameters: {
Future<List<SearchResult>> _searchNominatim(String query, {LatLngBounds? viewbox}) async {
final params = {
'q': query,
'format': 'json',
'limit': _maxResults.toString(),
'addressdetails': '1',
'extratags': '1',
});
};
if (viewbox != null) {
double round1(double v) => (v * 10).round() / 10;
var west = round1(viewbox.west);
var east = round1(viewbox.east);
var south = round1(viewbox.south);
var north = round1(viewbox.north);
if (east - west < 0.5) {
final mid = (east + west) / 2;
west = mid - 0.25;
east = mid + 0.25;
}
if (north - south < 0.5) {
final mid = (north + south) / 2;
south = mid - 0.25;
north = mid + 0.25;
}
params['viewbox'] = '$west,$north,$east,$south';
}
final uri = Uri.parse('$_baseUrl/search').replace(queryParameters: params);
debugPrint('[SearchService] Searching Nominatim: $uri');

View File

@@ -1,6 +1,5 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import '../models/suspected_location.dart';
import 'suspected_location_service.dart';

View File

@@ -143,7 +143,6 @@ class SuspectedLocationDatabase {
// Process entries in batches to avoid memory issues
const batchSize = 1000;
int totalInserted = 0;
int validCount = 0;
int errorCount = 0;
@@ -188,7 +187,6 @@ class SuspectedLocationDatabase {
// Commit this batch
await batch.commit(noResult: true);
totalInserted += currentBatch.length;
// Log progress every few batches
if ((i ~/ batchSize) % 5 == 0) {

View File

@@ -1,5 +1,3 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:http/http.dart' as http;
@@ -208,7 +206,7 @@ class SuspectedLocationService {
validRows++;
}
} catch (e, stackTrace) {
} catch (e) {
// Skip rows that can't be parsed
debugPrint('[SuspectedLocationService] Error parsing row $rowIndex: $e');
continue;

View File

@@ -1,4 +1,3 @@
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;

View File

@@ -54,29 +54,13 @@ class Uploader {
return UploadResult.failure(errorMessage: errorMsg);
}
// Generate changeset XML
String action;
switch (p.operation) {
case UploadOperation.create:
action = 'Add';
break;
case UploadOperation.modify:
action = 'Update';
break;
case UploadOperation.delete:
action = 'Delete';
break;
case UploadOperation.extract:
action = 'Extract';
break;
}
final profileName = p.profile?.name ?? 'surveillance';
// Use the user's changeset comment, with XML sanitization
final sanitizedComment = _sanitizeXmlText(p.changesetComment);
final csXml = '''
<osm>
<changeset>
<tag k="created_by" v="$kClientName ${VersionService().version}"/>
<tag k="comment" v="$action $profileName surveillance node"/>
<tag k="comment" v="$sanitizedComment"/>
</changeset>
</osm>''';
@@ -146,7 +130,7 @@ class Uploader {
final currentNodeXml = currentNodeResp.body;
final versionMatch = RegExp(r'version="(\d+)"').firstMatch(currentNodeXml);
if (versionMatch == null) {
final errorMsg = 'Could not parse version from node XML: ${currentNodeXml.length > 200 ? currentNodeXml.substring(0, 200) + "..." : currentNodeXml}';
final errorMsg = 'Could not parse version from node XML: ${currentNodeXml.length > 200 ? '${currentNodeXml.substring(0, 200)}...' : currentNodeXml}';
debugPrint('[Uploader] $errorMsg');
return UploadResult.failure(errorMessage: errorMsg, changesetId: changesetId);
}
@@ -183,7 +167,7 @@ class Uploader {
final currentNodeXml = currentNodeResp.body;
final versionMatch = RegExp(r'version="(\d+)"').firstMatch(currentNodeXml);
if (versionMatch == null) {
final errorMsg = 'Could not parse version from node XML for deletion: ${currentNodeXml.length > 200 ? currentNodeXml.substring(0, 200) + "..." : currentNodeXml}';
final errorMsg = 'Could not parse version from node XML for deletion: ${currentNodeXml.length > 200 ? '${currentNodeXml.substring(0, 200)}...' : currentNodeXml}';
debugPrint('[Uploader] $errorMsg');
return UploadResult.failure(errorMessage: errorMsg, changesetId: changesetId);
}
@@ -349,12 +333,6 @@ class Uploader {
headers: _headers,
).timeout(kUploadHttpTimeout);
Future<http.Response> _post(String path, String body) => http.post(
Uri.https(_host, path),
headers: _headers,
body: body,
).timeout(kUploadHttpTimeout);
Future<http.Response> _put(String path, String body) => http.put(
Uri.https(_host, path),
headers: _headers,
@@ -371,6 +349,21 @@ class Uploader {
'Authorization': 'Bearer $accessToken',
'Content-Type': 'text/xml',
};
/// Sanitize text for safe inclusion in XML attributes and content
/// Removes or escapes characters that could break XML parsing
String _sanitizeXmlText(String input) {
return input
.replaceAll('&', '&amp;') // Must be first to avoid double-escaping
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;')
.replaceAll('\n', ' ') // Replace newlines with spaces
.replaceAll('\r', ' ') // Replace carriage returns with spaces
.replaceAll('\t', ' ') // Replace tabs with spaces
.trim(); // Remove leading/trailing whitespace
}
}
extension StringExtension on String {

View File

@@ -21,7 +21,7 @@ class AuthState extends ChangeNotifier {
_username = await _auth.restoreLogin();
}
} catch (e) {
print("AuthState: Error during auth initialization: $e");
debugPrint("AuthState: Error during auth initialization: $e");
}
}
@@ -29,7 +29,7 @@ class AuthState extends ChangeNotifier {
try {
_username = await _auth.login();
} catch (e) {
print("AuthState: Login error: $e");
debugPrint("AuthState: Login error: $e");
_username = null;
}
notifyListeners();
@@ -49,7 +49,7 @@ class AuthState extends ChangeNotifier {
_username = null;
}
} catch (e) {
print("AuthState: Auth refresh error: $e");
debugPrint("AuthState: Auth refresh error: $e");
_username = null;
}
notifyListeners();
@@ -59,7 +59,7 @@ class AuthState extends ChangeNotifier {
try {
_username = await _auth.forceLogin();
} catch (e) {
print("AuthState: Forced login error: $e");
debugPrint("AuthState: Forced login error: $e");
_username = null;
}
notifyListeners();
@@ -69,7 +69,7 @@ class AuthState extends ChangeNotifier {
try {
return await _auth.isLoggedIn();
} catch (e) {
print("AuthState: Token validation error: $e");
debugPrint("AuthState: Token validation error: $e");
return false;
}
}
@@ -92,7 +92,7 @@ class AuthState extends ChangeNotifier {
}
} catch (e) {
_username = null;
print("AuthState: Mode change user restoration error: $e");
debugPrint("AuthState: Mode change user restoration error: $e");
}
notifyListeners();
}

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
import 'package:latlong2/latlong.dart';
import '../models/search_result.dart';
@@ -31,7 +32,8 @@ class NavigationState extends ChangeNotifier {
bool _isSearchLoading = false;
List<SearchResult> _searchResults = [];
String _lastQuery = '';
LatLngBounds? _searchViewbox;
// Location state
LatLng? _provisionalPinLocation;
String? _provisionalPinAddress;
@@ -106,19 +108,20 @@ class NavigationState extends ChangeNotifier {
}
/// BRUTALIST: Single entry point to search mode
void enterSearchMode(LatLng mapCenter) {
void enterSearchMode(LatLng mapCenter, {LatLngBounds? viewbox}) {
debugPrint('[NavigationState] enterSearchMode - current mode: $_mode');
if (_mode != AppNavigationMode.normal) {
debugPrint('[NavigationState] Cannot enter search mode - not in normal mode');
return;
}
_mode = AppNavigationMode.search;
_provisionalPinLocation = mapCenter;
_provisionalPinAddress = null;
_searchViewbox = viewbox;
_clearSearchResults();
debugPrint('[NavigationState] Entered search mode');
notifyListeners();
}
@@ -149,7 +152,8 @@ class NavigationState extends ChangeNotifier {
_showingOverview = false;
_nextPointIsStart = false;
_routingError = null;
_searchViewbox = null;
// Clear search
_clearSearchResults();
@@ -336,21 +340,21 @@ class NavigationState extends ChangeNotifier {
_clearSearchResults();
return;
}
if (query.trim() == _lastQuery.trim()) return;
_setSearchLoading(true);
_lastQuery = query.trim();
try {
final results = await _searchService.search(query.trim());
final results = await _searchService.search(query.trim(), viewbox: _searchViewbox);
_searchResults = results;
debugPrint('[NavigationState] Found ${results.length} results');
} catch (e) {
debugPrint('[NavigationState] Search failed: $e');
_searchResults = [];
}
_setSearchLoading(false);
}
@@ -372,4 +376,10 @@ class NavigationState extends ChangeNotifier {
notifyListeners();
}
}
@override
void dispose() {
_routingService.close();
super.dispose();
}
}

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
import '../models/search_result.dart';
import '../services/search_service.dart';

View File

@@ -4,6 +4,7 @@ import 'package:latlong2/latlong.dart';
import '../models/node_profile.dart';
import '../models/operator_profile.dart';
import '../models/osm_node.dart';
import '../models/pending_upload.dart'; // For UploadOperation enum
// ------------------ AddNodeSession ------------------
class AddNodeSession {
@@ -13,6 +14,8 @@ class AddNodeSession {
List<double> directions; // All directions [90, 180, 270]
int currentDirectionIndex; // Which direction we're editing (e.g. 1 = editing the 180°)
Map<String, String> refinedTags; // User-selected values for empty profile tags
Map<String, String> additionalExistingTags; // For consistency (always empty for new nodes)
String changesetComment; // User-editable changeset comment
AddNodeSession({
this.profile,
@@ -20,18 +23,29 @@ class AddNodeSession {
this.operatorProfile,
this.target,
Map<String, String>? refinedTags,
Map<String, String>? additionalExistingTags,
String? changesetComment,
}) : directions = [initialDirection],
currentDirectionIndex = 0,
refinedTags = refinedTags ?? {};
refinedTags = refinedTags ?? {},
additionalExistingTags = additionalExistingTags ?? {}, // Always empty for new nodes
changesetComment = changesetComment ?? '';
// Slider always shows the current direction being edited
double get directionDegrees => directions[currentDirectionIndex];
set directionDegrees(double value) => directions[currentDirectionIndex] = value;
double get directionDegrees => directions.isNotEmpty && currentDirectionIndex >= 0
? directions[currentDirectionIndex]
: 0.0;
set directionDegrees(double value) {
if (directions.isNotEmpty && currentDirectionIndex >= 0) {
directions[currentDirectionIndex] = value;
}
}
}
// ------------------ EditNodeSession ------------------
class EditNodeSession {
final OsmNode originalNode; // The original node being edited
final bool originalHadDirections; // Whether original node had any directions
NodeProfile? profile;
OperatorProfile? operatorProfile;
LatLng target; // Current position (can be dragged)
@@ -39,26 +53,41 @@ class EditNodeSession {
int currentDirectionIndex; // Which direction we're editing (e.g. 1 = editing the 180°)
bool extractFromWay; // True if user wants to extract this constrained node
Map<String, String> refinedTags; // User-selected values for empty profile tags
Map<String, String> additionalExistingTags; // Tags that exist on node but not in profile
String changesetComment; // User-editable changeset comment
EditNodeSession({
required this.originalNode,
required this.originalHadDirections,
this.profile,
this.operatorProfile,
required double initialDirection,
required this.target,
this.extractFromWay = false,
Map<String, String>? refinedTags,
Map<String, String>? additionalExistingTags,
String? changesetComment,
}) : directions = [initialDirection],
currentDirectionIndex = 0,
refinedTags = refinedTags ?? {};
refinedTags = refinedTags ?? {},
additionalExistingTags = additionalExistingTags ?? {},
changesetComment = changesetComment ?? '';
// Slider always shows the current direction being edited
double get directionDegrees => directions[currentDirectionIndex];
set directionDegrees(double value) => directions[currentDirectionIndex] = value;
double get directionDegrees => directions.isNotEmpty && currentDirectionIndex >= 0
? directions[currentDirectionIndex]
: 0.0;
set directionDegrees(double value) {
if (directions.isNotEmpty && currentDirectionIndex >= 0) {
directions[currentDirectionIndex] = value;
}
}
}
class SessionState extends ChangeNotifier {
AddNodeSession? _session;
EditNodeSession? _editSession;
OperatorProfile? _detectedOperatorProfile; // Persists across profile changes
// Getters
AddNodeSession? get session => _session;
@@ -66,51 +95,116 @@ class SessionState extends ChangeNotifier {
void startAddSession(List<NodeProfile> enabledProfiles) {
// Start with no profile selected - force user to choose
_session = AddNodeSession();
_session = AddNodeSession(
changesetComment: 'Add surveillance node', // Default comment, will be updated when profile is selected
);
_editSession = null; // Clear any edit session
notifyListeners();
}
void startEditSession(OsmNode node, List<NodeProfile> enabledProfiles) {
final submittableProfiles = enabledProfiles.where((p) => p.isSubmittable).toList();
void startEditSession(OsmNode node, List<NodeProfile> enabledProfiles, List<OperatorProfile> operatorProfiles) {
// Always create and pre-select the temporary "existing tags" profile (now empty)
final existingTagsProfile = NodeProfile.createExistingTagsProfile(node);
// Try to find a matching profile based on the node's tags
NodeProfile? matchingProfile;
// Detect and store operator profile (persists across profile changes)
_detectedOperatorProfile = OperatorProfile.createExistingOperatorProfile(node, operatorProfiles);
// Attempt to find a match by comparing tags
for (final profile in submittableProfiles) {
if (_profileMatchesTags(profile, node.tags)) {
matchingProfile = profile;
break;
}
}
// Initialize edit session with all existing directions, or empty list if none
final existingDirections = node.directionDeg.isNotEmpty ? node.directionDeg : <double>[];
final initialDirection = existingDirections.isNotEmpty ? existingDirections.first : 0.0;
final originalHadDirections = existingDirections.isNotEmpty;
// Start with no profile selected if no match found - force user to choose
// Initialize edit session with all existing directions
final existingDirections = node.directionDeg.isNotEmpty ? node.directionDeg : [0.0];
// Since the "existing tags" profile is now empty, all existing node tags
// (minus special ones) should go into additionalExistingTags
final initialAdditionalTags = _calculateAdditionalExistingTags(existingTagsProfile, node);
// Auto-populate refined tags (empty profile means no refined tags initially)
final initialRefinedTags = _calculateRefinedTags(existingTagsProfile, node);
_editSession = EditNodeSession(
originalNode: node,
profile: matchingProfile,
initialDirection: existingDirections.first,
originalHadDirections: originalHadDirections,
profile: existingTagsProfile,
operatorProfile: _detectedOperatorProfile,
initialDirection: initialDirection,
target: node.coord,
additionalExistingTags: initialAdditionalTags,
refinedTags: initialRefinedTags,
changesetComment: 'Update a surveillance node', // Default comment for existing tags profile
);
// Replace the default single direction with all existing directions
// Replace the default single direction with all existing directions (or empty list)
_editSession!.directions = List<double>.from(existingDirections);
_editSession!.currentDirectionIndex = 0; // Start editing the first direction
_editSession!.currentDirectionIndex = existingDirections.isNotEmpty ? 0 : -1; // -1 indicates no directions
_session = null; // Clear any add session
notifyListeners();
}
bool _profileMatchesTags(NodeProfile profile, Map<String, String> tags) {
// Simple matching: check if all profile tags are present in node tags
/// Calculate additional existing tags for a given profile change
Map<String, String> _calculateAdditionalExistingTags(NodeProfile? newProfile, OsmNode originalNode) {
final additionalTags = <String, String>{};
// Skip if no profile
if (newProfile == null) {
return additionalTags;
}
// Get tags from the original node that are not in the selected profile
final profileTagKeys = newProfile.tags.keys.toSet();
final originalTags = originalNode.tags;
for (final entry in originalTags.entries) {
final key = entry.key;
final value = entry.value;
// Skip tags that are handled elsewhere
if (_shouldSkipTag(key)) continue;
// Skip tags that exist in the selected profile
if (profileTagKeys.contains(key)) continue;
// Include this tag as an additional existing tag
additionalTags[key] = value;
}
return additionalTags;
}
/// Auto-populate refined tags with existing values from the original node
Map<String, String> _calculateRefinedTags(NodeProfile? profile, OsmNode originalNode) {
final refinedTags = <String, String>{};
if (profile == null) return refinedTags;
// For each empty-value tag in the profile, check if original node has a value
for (final entry in profile.tags.entries) {
if (tags[entry.key] != entry.value) {
return false;
final tagKey = entry.key;
final profileValue = entry.value;
// Only auto-populate if profile tag value is empty
if (profileValue.trim().isEmpty) {
final existingValue = originalNode.tags[tagKey];
if (existingValue != null && existingValue.trim().isNotEmpty) {
refinedTags[tagKey] = existingValue;
}
}
}
return true;
return refinedTags;
}
/// Check if a tag should be skipped from additional existing tags
bool _shouldSkipTag(String key) {
// Skip direction tags (handled separately)
if (key == 'direction' || key == 'camera:direction') return true;
// Skip operator tags (handled by operator profile)
if (key == 'operator' || key.startsWith('operator:')) return true;
// Skip internal cache tags
if (key.startsWith('_')) return true;
return false;
}
void updateSession({
@@ -119,6 +213,9 @@ class SessionState extends ChangeNotifier {
OperatorProfile? operatorProfile,
LatLng? target,
Map<String, String>? refinedTags,
Map<String, String>? additionalExistingTags,
String? changesetComment,
bool updateOperatorProfile = false,
}) {
if (_session == null) return;
@@ -129,9 +226,15 @@ class SessionState extends ChangeNotifier {
}
if (profile != null && profile != _session!.profile) {
_session!.profile = profile;
// Regenerate changeset comment when profile changes
_session!.changesetComment = _generateDefaultChangesetComment(
profile: profile,
operation: UploadOperation.create,
);
dirty = true;
}
if (operatorProfile != _session!.operatorProfile) {
// Only update operator profile when explicitly requested
if (updateOperatorProfile && operatorProfile != _session!.operatorProfile) {
_session!.operatorProfile = operatorProfile;
dirty = true;
}
@@ -143,6 +246,14 @@ class SessionState extends ChangeNotifier {
_session!.refinedTags = Map<String, String>.from(refinedTags);
dirty = true;
}
if (additionalExistingTags != null) {
_session!.additionalExistingTags = Map<String, String>.from(additionalExistingTags);
dirty = true;
}
if (changesetComment != null) {
_session!.changesetComment = changesetComment;
dirty = true;
}
if (dirty) notifyListeners();
}
@@ -153,6 +264,9 @@ class SessionState extends ChangeNotifier {
LatLng? target,
bool? extractFromWay,
Map<String, String>? refinedTags,
Map<String, String>? additionalExistingTags,
String? changesetComment,
bool updateOperatorProfile = false,
}) {
if (_editSession == null) return;
@@ -165,11 +279,42 @@ class SessionState extends ChangeNotifier {
dirty = true;
}
if (profile != null && profile != _editSession!.profile) {
final oldProfile = _editSession!.profile;
_editSession!.profile = profile;
// Handle direction requirements when profile changes
_handleDirectionRequirementsOnProfileChange(oldProfile, profile);
// When profile changes and operator profile not being explicitly updated,
// restore the detected operator profile (if any)
if (!updateOperatorProfile && _detectedOperatorProfile != null) {
_editSession!.operatorProfile = _detectedOperatorProfile;
}
// Calculate additional existing tags for non-existing-tags profiles
// Only do this if additionalExistingTags wasn't explicitly provided
if (additionalExistingTags == null) {
_editSession!.additionalExistingTags = _calculateAdditionalExistingTags(profile, _editSession!.originalNode);
}
// Auto-populate refined tags with existing values for empty profile tags
// Only do this if refinedTags wasn't explicitly provided
if (refinedTags == null) {
_editSession!.refinedTags = _calculateRefinedTags(profile, _editSession!.originalNode);
}
// Regenerate changeset comment when profile changes
final operation = _editSession!.extractFromWay ? UploadOperation.extract : UploadOperation.modify;
_editSession!.changesetComment = _generateDefaultChangesetComment(
profile: profile,
operation: operation,
);
dirty = true;
}
if (operatorProfile != _editSession!.operatorProfile) {
_editSession!.operatorProfile = operatorProfile;
// Only update operator profile when explicitly requested
if (updateOperatorProfile && operatorProfile != _editSession!.operatorProfile) {
_editSession!.operatorProfile = operatorProfile; // This can be null
dirty = true;
}
if (target != null && target != _editSession!.target) {
@@ -190,6 +335,14 @@ class SessionState extends ChangeNotifier {
_editSession!.refinedTags = Map<String, String>.from(refinedTags);
dirty = true;
}
if (additionalExistingTags != null) {
_editSession!.additionalExistingTags = Map<String, String>.from(additionalExistingTags);
dirty = true;
}
if (changesetComment != null) {
_editSession!.changesetComment = changesetComment;
dirty = true;
}
if (dirty) notifyListeners();
@@ -222,18 +375,28 @@ class SessionState extends ChangeNotifier {
// Remove currently selected direction
void removeDirection() {
if (_session != null && _session!.directions.length > 1) {
_session!.directions.removeAt(_session!.currentDirectionIndex);
if (_session!.currentDirectionIndex >= _session!.directions.length) {
_session!.currentDirectionIndex = _session!.directions.length - 1;
if (_session != null && _session!.directions.isNotEmpty) {
// For add sessions, keep minimum of 1 direction
if (_session!.directions.length > 1) {
_session!.directions.removeAt(_session!.currentDirectionIndex);
if (_session!.currentDirectionIndex >= _session!.directions.length) {
_session!.currentDirectionIndex = _session!.directions.length - 1;
}
notifyListeners();
}
notifyListeners();
} else if (_editSession != null && _editSession!.directions.length > 1) {
_editSession!.directions.removeAt(_editSession!.currentDirectionIndex);
if (_editSession!.currentDirectionIndex >= _editSession!.directions.length) {
_editSession!.currentDirectionIndex = _editSession!.directions.length - 1;
} else if (_editSession != null && _editSession!.directions.isNotEmpty) {
// For edit sessions, use minimum calculation
final minDirections = _getMinimumDirections();
if (_editSession!.directions.length > minDirections) {
_editSession!.directions.removeAt(_editSession!.currentDirectionIndex);
if (_editSession!.directions.isEmpty) {
_editSession!.currentDirectionIndex = -1; // No directions
} else if (_editSession!.currentDirectionIndex >= _editSession!.directions.length) {
_editSession!.currentDirectionIndex = _editSession!.directions.length - 1;
}
notifyListeners();
}
notifyListeners();
}
}
@@ -242,7 +405,7 @@ class SessionState extends ChangeNotifier {
if (_session != null && _session!.directions.length > 1) {
_session!.currentDirectionIndex = (_session!.currentDirectionIndex + 1) % _session!.directions.length;
notifyListeners();
} else if (_editSession != null && _editSession!.directions.length > 1) {
} else if (_editSession != null && _editSession!.directions.length > 1 && _editSession!.currentDirectionIndex >= 0) {
_editSession!.currentDirectionIndex = (_editSession!.currentDirectionIndex + 1) % _editSession!.directions.length;
notifyListeners();
}
@@ -257,6 +420,7 @@ class SessionState extends ChangeNotifier {
void cancelEditSession() {
_editSession = null;
_detectedOperatorProfile = null;
notifyListeners();
}
@@ -274,7 +438,59 @@ class SessionState extends ChangeNotifier {
final session = _editSession!;
_editSession = null;
_detectedOperatorProfile = null;
notifyListeners();
return session;
}
/// Get the minimum number of directions required for current session state
int _getMinimumDirections() {
if (_editSession == null) return 1;
// Minimum = 0 only if original node had no directions
// Allow preserving the original state (directionless nodes can stay directionless)
return _editSession!.originalHadDirections ? 1 : 0;
}
/// Check if remove direction button should be enabled for edit session
bool get canRemoveDirection {
if (_editSession == null || _editSession!.directions.isEmpty) return false;
return _editSession!.directions.length > _getMinimumDirections();
}
/// Handle direction requirements when profile changes in edit session
void _handleDirectionRequirementsOnProfileChange(NodeProfile? oldProfile, NodeProfile newProfile) {
if (_editSession == null) return;
final minimum = _getMinimumDirections();
// Ensure we meet the minimum (add direction if needed)
if (_editSession!.directions.length < minimum) {
_editSession!.directions = [0.0];
_editSession!.currentDirectionIndex = 0;
}
}
/// Generate a default changeset comment for a submission
/// Handles special case of `<Existing tags>` profile by using "a" instead
String _generateDefaultChangesetComment({
required NodeProfile? profile,
required UploadOperation operation,
}) {
// Handle temp profiles with brackets by using "a"
final profileName = profile?.name.startsWith('<') == true && profile?.name.endsWith('>') == true
? 'a'
: profile?.name ?? 'surveillance';
switch (operation) {
case UploadOperation.create:
return 'Add $profileName surveillance node';
case UploadOperation.modify:
return 'Update $profileName surveillance node';
case UploadOperation.delete:
return 'Delete $profileName surveillance node';
case UploadOperation.extract:
return 'Extract $profileName surveillance node';
}
}
}

View File

@@ -16,6 +16,12 @@ enum FollowMeMode {
rotating, // Follow position and rotation based on heading
}
// Enum for distance units
enum DistanceUnit {
metric, // kilometers, meters
imperial, // miles, feet
}
class SettingsState extends ChangeNotifier {
static const String _offlineModePrefsKey = 'offline_mode';
static const String _maxNodesPrefsKey = 'max_nodes';
@@ -30,6 +36,7 @@ class SettingsState extends ChangeNotifier {
static const String _suspectedLocationMinDistancePrefsKey = 'suspected_location_min_distance';
static const String _pauseQueueProcessingPrefsKey = 'pause_queue_processing';
static const String _navigationAvoidanceDistancePrefsKey = 'navigation_avoidance_distance';
static const String _distanceUnitPrefsKey = 'distance_unit';
bool _offlineMode = false;
bool _pauseQueueProcessing = false;
@@ -43,6 +50,7 @@ class SettingsState extends ChangeNotifier {
List<TileProvider> _tileProviders = [];
String _selectedTileTypeId = '';
int _navigationAvoidanceDistance = 250; // meters
DistanceUnit _distanceUnit = DistanceUnit.metric;
// Getters
bool get offlineMode => _offlineMode;
@@ -57,6 +65,7 @@ class SettingsState extends ChangeNotifier {
List<TileProvider> get tileProviders => List.unmodifiable(_tileProviders);
String get selectedTileTypeId => _selectedTileTypeId;
int get navigationAvoidanceDistance => _navigationAvoidanceDistance;
DistanceUnit get distanceUnit => _distanceUnit;
/// Get the currently selected tile type
TileType? get selectedTileType {
@@ -109,6 +118,14 @@ class SettingsState extends ChangeNotifier {
_navigationAvoidanceDistance = prefs.getInt(_navigationAvoidanceDistancePrefsKey) ?? 250;
}
// Load distance unit
if (prefs.containsKey(_distanceUnitPrefsKey)) {
final unitIndex = prefs.getInt(_distanceUnitPrefsKey) ?? 0;
if (unitIndex >= 0 && unitIndex < DistanceUnit.values.length) {
_distanceUnit = DistanceUnit.values[unitIndex];
}
}
// Load proximity alerts settings
_proximityAlertsEnabled = prefs.getBool(_proximityAlertsEnabledPrefsKey) ?? false;
_proximityAlertDistance = prefs.getInt(_proximityAlertDistancePrefsKey) ?? kProximityAlertDefaultDistance;
@@ -369,4 +386,14 @@ class SettingsState extends ChangeNotifier {
}
}
/// Set distance unit (metric or imperial)
Future<void> setDistanceUnit(DistanceUnit unit) async {
if (_distanceUnit != unit) {
_distanceUnit = unit;
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_distanceUnitPrefsKey, unit.index);
notifyListeners();
}
}
}

View File

@@ -7,7 +7,7 @@ import 'package:latlong2/latlong.dart';
import '../models/pending_upload.dart';
import '../models/osm_node.dart';
import '../models/node_profile.dart';
import '../services/node_cache.dart';
import '../services/map_data_provider.dart';
import '../services/uploader.dart';
import '../widgets/node_provider_with_cache.dart';
import '../dev_config.dart';
@@ -15,6 +15,8 @@ import 'settings_state.dart';
import 'session_state.dart';
class UploadQueueState extends ChangeNotifier {
/// Helper to access the map data provider instance
MapDataProvider get _nodeCache => MapDataProvider();
final List<PendingUpload> _queue = [];
Timer? _uploadTimer;
int _activeUploadCount = 0;
@@ -27,28 +29,28 @@ class UploadQueueState extends ChangeNotifier {
// Initialize by loading queue from storage and repopulate cache with pending nodes
Future<void> init() async {
await _loadQueue();
print('[UploadQueue] Loaded ${_queue.length} items from storage');
debugPrint('[UploadQueue] Loaded ${_queue.length} items from storage');
_repopulateCacheFromQueue();
}
// Repopulate the cache with pending nodes from the queue on startup
void _repopulateCacheFromQueue() {
print('[UploadQueue] Repopulating cache from ${_queue.length} queue items');
debugPrint('[UploadQueue] Repopulating cache from ${_queue.length} queue items');
final nodesToAdd = <OsmNode>[];
for (final upload in _queue) {
// Skip completed uploads - they should already be in OSM and will be fetched normally
if (upload.isComplete) {
print('[UploadQueue] Skipping completed upload at ${upload.coord}');
debugPrint('[UploadQueue] Skipping completed upload at ${upload.coord}');
continue;
}
print('[UploadQueue] Processing ${upload.operation} upload at ${upload.coord}');
debugPrint('[UploadQueue] Processing ${upload.operation} upload at ${upload.coord}');
if (upload.isDeletion) {
// For deletions: mark the original node as pending deletion if it exists in cache
if (upload.originalNodeId != null) {
final existingNode = NodeCache.instance.getNodeById(upload.originalNodeId!);
final existingNode = _nodeCache.getNodeById(upload.originalNodeId!);
if (existingNode != null) {
final deletionTags = Map<String, String>.from(existingNode.tags);
deletionTags['_pending_deletion'] = 'true';
@@ -71,14 +73,12 @@ class UploadQueueState extends ChangeNotifier {
tags['_temp_id'] = tempId.toString();
// Store temp ID for future cleanup if not already set
if (upload.tempNodeId == null) {
upload.tempNodeId = tempId;
}
upload.tempNodeId ??= tempId;
if (upload.isEdit) {
// For edits: also mark original with _pending_edit if it exists
if (upload.originalNodeId != null) {
final existingOriginal = NodeCache.instance.getNodeById(upload.originalNodeId!);
final existingOriginal = _nodeCache.getNodeById(upload.originalNodeId!);
if (existingOriginal != null) {
final originalTags = Map<String, String>.from(existingOriginal.tags);
originalTags['_pending_edit'] = 'true';
@@ -109,8 +109,8 @@ class UploadQueueState extends ChangeNotifier {
}
if (nodesToAdd.isNotEmpty) {
NodeCache.instance.addOrUpdate(nodesToAdd);
print('[UploadQueue] Repopulated cache with ${nodesToAdd.length} pending nodes from queue');
_nodeCache.addOrUpdate(nodesToAdd);
debugPrint('[UploadQueue] Repopulated cache with ${nodesToAdd.length} pending nodes from queue');
// Save queue if we updated any temp IDs for backward compatibility
_saveQueue();
@@ -128,6 +128,8 @@ class UploadQueueState extends ChangeNotifier {
profile: session.profile!, // Safe to use ! because commitSession() checks for null
operatorProfile: session.operatorProfile,
refinedTags: session.refinedTags,
additionalExistingTags: session.additionalExistingTags, // Always empty for new nodes
changesetComment: session.changesetComment,
uploadMode: uploadMode,
operation: UploadOperation.create,
);
@@ -152,7 +154,7 @@ class UploadQueueState extends ChangeNotifier {
tags: tags,
);
NodeCache.instance.addOrUpdate([tempNode]);
_nodeCache.addOrUpdate([tempNode]);
// Notify node provider to update the map
NodeProviderWithCache.instance.notifyListeners();
@@ -185,6 +187,8 @@ class UploadQueueState extends ChangeNotifier {
profile: session.profile!, // Safe to use ! because commitEditSession() checks for null
operatorProfile: session.operatorProfile,
refinedTags: session.refinedTags,
additionalExistingTags: session.additionalExistingTags,
changesetComment: session.changesetComment,
uploadMode: uploadMode,
operation: operation,
originalNodeId: session.originalNode.id, // Track which node we're editing
@@ -211,7 +215,7 @@ class UploadQueueState extends ChangeNotifier {
tags: extractedTags,
);
NodeCache.instance.addOrUpdate([extractedNode]);
_nodeCache.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
@@ -240,7 +244,7 @@ class UploadQueueState extends ChangeNotifier {
tags: editedTags,
);
NodeCache.instance.addOrUpdate([originalNode, editedNode]);
_nodeCache.addOrUpdate([originalNode, editedNode]);
}
// Notify node provider to update the map
NodeProviderWithCache.instance.notifyListeners();
@@ -254,6 +258,7 @@ class UploadQueueState extends ChangeNotifier {
coord: node.coord,
direction: node.directionDeg.isNotEmpty ? node.directionDeg.first : 0, // Direction not used for deletions but required for API
profile: null, // No profile needed for deletions - just delete by node ID
changesetComment: 'Delete a surveillance node', // Default comment for deletions
uploadMode: uploadMode,
operation: UploadOperation.delete,
originalNodeId: node.id,
@@ -272,7 +277,7 @@ class UploadQueueState extends ChangeNotifier {
tags: deletionTags,
);
NodeCache.instance.addOrUpdate([nodeWithDeletionTag]);
_nodeCache.addOrUpdate([nodeWithDeletionTag]);
// Notify node provider to update the map
NodeProviderWithCache.instance.notifyListeners();
@@ -531,8 +536,14 @@ class UploadQueueState extends ChangeNotifier {
debugPrint('[UploadQueue] Simulating node operation (no real API call)');
await Future.delayed(const Duration(milliseconds: 500)); // Simulate network delay
// Store simulated node ID and move to changeset close phase
item.submittedNodeId = DateTime.now().millisecondsSinceEpoch;
// Store appropriate simulated node ID based on operation type
if (item.operation == UploadOperation.modify) {
// For edits, keep the original node ID (same as production behavior)
item.submittedNodeId = item.originalNodeId!;
} else {
// For creates and extracts, generate new simulated ID
item.submittedNodeId = DateTime.now().millisecondsSinceEpoch;
}
item.markNodeOperationComplete();
_saveQueue();
notifyListeners();
@@ -574,7 +585,7 @@ class UploadQueueState extends ChangeNotifier {
// Still have time, will retry after backoff delay
final nextDelay = item.nextNodeSubmissionRetryDelay;
final timeLeft = item.timeUntilAutoClose;
debugPrint('[UploadQueue] Will retry node submission in ${nextDelay}, ${timeLeft?.inMinutes}m remaining');
debugPrint('[UploadQueue] Will retry node submission in $nextDelay, ${timeLeft?.inMinutes}m remaining');
// No state change needed - attempt count was already updated above
}
}
@@ -633,7 +644,7 @@ class UploadQueueState extends ChangeNotifier {
// Note: This will NEVER error out - will keep trying until 59-minute window expires
final nextDelay = item.nextChangesetCloseRetryDelay;
final timeLeft = item.timeUntilAutoClose;
debugPrint('[UploadQueue] Changeset close failed (attempt ${item.changesetCloseAttempts}), will retry in ${nextDelay}, ${timeLeft?.inMinutes}m remaining');
debugPrint('[UploadQueue] Changeset close failed (attempt ${item.changesetCloseAttempts}), will retry in $nextDelay, ${timeLeft?.inMinutes}m remaining');
debugPrint('[UploadQueue] Error: ${result.errorMessage}');
// No additional state change needed - attempt count was already updated above
}
@@ -666,7 +677,9 @@ class UploadQueueState extends ChangeNotifier {
debugPrint('[UploadQueue] Simulated deletion, removing fake node ID: $simulatedNodeId from cache');
_handleSuccessfulDeletion(item);
} else {
debugPrint('[UploadQueue] Simulated upload, fake node ID: $simulatedNodeId');
debugPrint('[UploadQueue] Simulated upload successful, updating cache with fake node ID: $simulatedNodeId');
// Update cache with simulated node ID, same as production mode
_updateCacheWithRealNodeId(item, simulatedNodeId);
}
}
@@ -693,11 +706,11 @@ class UploadQueueState extends ChangeNotifier {
);
// Add/update the cache with the real node
NodeCache.instance.addOrUpdate([realNode]);
_nodeCache.addOrUpdate([realNode]);
// Clean up the specific temp node for this upload
if (item.tempNodeId != null) {
NodeCache.instance.removeTempNodeById(item.tempNodeId!);
_nodeCache.removeTempNodeById(item.tempNodeId!);
}
// For modify operations, clean up the original node's _pending_edit marker
@@ -705,7 +718,7 @@ class UploadQueueState extends ChangeNotifier {
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
NodeCache.instance.removePendingEditMarker(item.originalNodeId!);
_nodeCache.removePendingEditMarker(item.originalNodeId!);
}
// Notify node provider to update the map
@@ -716,7 +729,7 @@ class UploadQueueState extends ChangeNotifier {
void _handleSuccessfulDeletion(PendingUpload item) {
if (item.originalNodeId != null) {
// Remove the node from cache entirely
NodeCache.instance.removeNodeById(item.originalNodeId!);
_nodeCache.removeNodeById(item.originalNodeId!);
// Notify node provider to update the map
NodeProviderWithCache.instance.notifyListeners();
@@ -760,25 +773,25 @@ class UploadQueueState extends ChangeNotifier {
if (upload.isDeletion) {
// For deletions: remove the _pending_deletion marker from the original node
if (upload.originalNodeId != null) {
NodeCache.instance.removePendingDeletionMarker(upload.originalNodeId!);
_nodeCache.removePendingDeletionMarker(upload.originalNodeId!);
}
} else if (upload.isEdit) {
// For edits: remove the specific temp node and the _pending_edit marker from original
if (upload.tempNodeId != null) {
NodeCache.instance.removeTempNodeById(upload.tempNodeId!);
_nodeCache.removeTempNodeById(upload.tempNodeId!);
}
if (upload.originalNodeId != null) {
NodeCache.instance.removePendingEditMarker(upload.originalNodeId!);
_nodeCache.removePendingEditMarker(upload.originalNodeId!);
}
} else if (upload.operation == UploadOperation.extract) {
// For extracts: remove the specific temp node (leave original unchanged)
if (upload.tempNodeId != null) {
NodeCache.instance.removeTempNodeById(upload.tempNodeId!);
_nodeCache.removeTempNodeById(upload.tempNodeId!);
}
} else {
// For creates: remove the specific temp node
if (upload.tempNodeId != null) {
NodeCache.instance.removeTempNodeById(upload.tempNodeId!);
_nodeCache.removeTempNodeById(upload.tempNodeId!);
}
}
}

View File

@@ -1,12 +1,15 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.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 '../services/map_data_provider.dart';
import '../services/node_data_manager.dart';
import '../services/changelog_service.dart';
import 'refine_tags_sheet.dart';
import 'proximity_warning_dialog.dart';
@@ -30,6 +33,13 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
void initState() {
super.initState();
_checkTutorialStatus();
// Listen to node data manager for cache updates
NodeDataManager().addListener(_onCacheUpdated);
}
void _onCacheUpdated() {
// Rebuild when cache updates (e.g., when new data loads)
if (mounted) setState(() {});
}
Future<void> _checkTutorialStatus() async {
@@ -48,23 +58,6 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
}
}
/// Listen for tutorial completion from AppState
void _onTutorialCompleted() {
_hideTutorial();
}
/// Also check periodically if tutorial was completed by another sheet
void _recheckTutorialStatus() async {
if (_showTutorial) {
final hasCompleted = await ChangelogService().hasCompletedPositioningTutorial();
if (hasCompleted && mounted) {
setState(() {
_showTutorial = false;
});
}
}
}
void _hideTutorial() {
if (mounted && _showTutorial) {
setState(() {
@@ -75,6 +68,9 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
@override
void dispose() {
// Remove listener
NodeDataManager().removeListener(_onCacheUpdated);
// Clear tutorial callback when widget is disposed
if (_showTutorial) {
try {
@@ -93,7 +89,8 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
void _checkSubmissionGuideAndProceed(BuildContext context, AppState appState, LocalizationService locService) async {
// Check if user has seen the submission guide
final hasSeenGuide = await ChangelogService().hasSeenSubmissionGuide();
if (!context.mounted) return;
if (!hasSeenGuide) {
// Show submission guide dialog first
final shouldProceed = await showDialog<bool>(
@@ -101,13 +98,14 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
barrierDismissible: false,
builder: (context) => const SubmissionGuideDialog(),
);
if (!context.mounted) return;
// If user canceled the submission guide, don't proceed with submission
if (shouldProceed != true) {
return;
}
}
// Now proceed with proximity check
_checkProximityOnly(context, appState, locService);
}
@@ -120,7 +118,7 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
}
// Check for nearby nodes within the configured distance
final nearbyNodes = NodeCache.instance.findNodesWithinDistance(
final nearbyNodes = MapDataProvider().findNodesWithinDistance(
widget.session.target!,
kNodeProximityWarningDistance,
);
@@ -155,6 +153,100 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
);
}
Widget _buildProfileDropdown(BuildContext context, AppState appState, AddNodeSession session, List<NodeProfile> submittableProfiles, LocalizationService locService) {
return PopupMenuButton<String>(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
session.profile?.name ?? locService.t('addNode.selectProfile'),
style: TextStyle(
fontSize: 16,
color: session.profile != null ? null : Colors.grey.shade600,
),
),
const SizedBox(width: 4),
const Icon(Icons.arrow_drop_down, size: 20),
],
),
),
itemBuilder: (context) => [
// Regular profiles
...submittableProfiles.map(
(profile) => PopupMenuItem<String>(
value: 'profile_${profile.id}',
child: Text(profile.name),
),
),
// Divider
if (submittableProfiles.isNotEmpty) const PopupMenuDivider(),
// Get more... option
PopupMenuItem<String>(
value: 'get_more',
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.language, size: 16),
const SizedBox(width: 8),
Text(
locService.t('profiles.getMore'),
style: const TextStyle(
fontStyle: FontStyle.italic,
),
),
],
),
),
],
onSelected: (value) {
if (value == 'get_more') {
_openIdentifyWebsite(context);
} else if (value.startsWith('profile_')) {
final profileId = value.substring(8); // Remove 'profile_' prefix
final profile = submittableProfiles.firstWhere((p) => p.id == profileId);
appState.updateSession(profile: profile);
}
},
);
}
void _openIdentifyWebsite(BuildContext context) async {
const url = 'https://deflock.me/identify';
try {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(
uri,
mode: LaunchMode.externalApplication, // Force external browser
);
} else {
if (context.mounted) {
_showErrorSnackBar(context, 'Unable to open website');
}
}
} catch (e) {
if (context.mounted) {
_showErrorSnackBar(context, 'Error opening website: $e');
}
}
}
void _showErrorSnackBar(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.red,
),
);
}
Widget _buildDirectionControls(BuildContext context, AppState appState, AddNodeSession session, LocalizationService locService) {
final requiresDirection = session.profile != null && session.profile!.requiresDirection;
final is360Fov = session.profile?.fov == 360;
@@ -294,27 +386,51 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
final locService = LocalizationService.instance;
final appState = context.watch<AppState>();
void _commit() {
void commit() {
_checkProximityAndCommit(context, appState, locService);
}
void _cancel() {
void cancel() {
appState.cancelSession();
Navigator.pop(context);
}
final session = widget.session;
final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList();
// Check if we have good cache coverage around the node position
bool hasGoodCoverage = true;
if (session.target != null) {
// Create a small bounds around the target position to check coverage
const double bufferDegrees = 0.001; // ~100m buffer
final targetBounds = LatLngBounds(
LatLng(session.target!.latitude - bufferDegrees, session.target!.longitude - bufferDegrees),
LatLng(session.target!.latitude + bufferDegrees, session.target!.longitude + bufferDegrees),
);
hasGoodCoverage = MapDataProvider().hasGoodCoverageFor(targetBounds);
// If strict coverage check fails, fall back to checking if we have any nodes nearby
// This handles the timing issue where cache might not be marked as "covered" yet
if (!hasGoodCoverage) {
final nearbyNodes = MapDataProvider().findNodesWithinDistance(
session.target!,
200.0, // 200m radius - if we have nodes nearby, we likely have good data
);
hasGoodCoverage = nearbyNodes.isNotEmpty;
}
}
final allowSubmit = appState.isLoggedIn &&
submittableProfiles.isNotEmpty &&
session.profile != null &&
session.profile!.isSubmittable;
session.profile!.isSubmittable &&
hasGoodCoverage;
void _navigateToLogin() {
void navigateToLogin() {
Navigator.pushNamed(context, '/settings/osm-account');
}
void _openRefineTags() async {
void openRefineTags() async {
final result = await Navigator.push<RefineTagsResult?>(
context,
MaterialPageRoute(
@@ -322,6 +438,7 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
selectedOperatorProfile: session.operatorProfile,
selectedProfile: session.profile,
currentRefinedTags: session.refinedTags,
operation: UploadOperation.create,
),
fullscreenDialog: true,
),
@@ -330,6 +447,8 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
appState.updateSession(
operatorProfile: result.operatorProfile,
refinedTags: result.refinedTags,
changesetComment: result.changesetComment,
updateOperatorProfile: true,
);
}
}
@@ -353,14 +472,7 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
const SizedBox(height: 16),
ListTile(
title: Text(locService.t('addNode.profile')),
trailing: DropdownButton<NodeProfile?>(
value: session.profile,
hint: Text(locService.t('addNode.selectProfile')),
items: submittableProfiles
.map((p) => DropdownMenuItem(value: p, child: Text(p.name)))
.toList(),
onChanged: (p) => appState.updateSession(profile: p),
),
trailing: _buildProfileDropdown(context, appState, session, submittableProfiles, locService),
),
// Direction controls
_buildDirectionControls(context, appState, session, locService),
@@ -428,6 +540,22 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
),
],
),
)
else if (!hasGoodCoverage)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
const Icon(Icons.cloud_download, color: Colors.blue, size: 20),
const SizedBox(width: 6),
Expanded(
child: Text(
locService.t('addNode.loadingAreaData'),
style: const TextStyle(color: Colors.blue, fontSize: 13),
),
),
],
),
),
const SizedBox(height: 8),
Padding(
@@ -435,11 +563,9 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
child: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: session.profile != null ? _openRefineTags : null, // Disabled when no profile selected
onPressed: session.profile != null ? openRefineTags : null, // Disabled when no profile selected
icon: const Icon(Icons.tune),
label: Text(session.operatorProfile != null
? locService.t('addNode.refineTagsWithProfile', params: [session.operatorProfile!.name])
: locService.t('addNode.refineTags')),
label: Text(locService.t('addNode.refineTags')),
),
),
),
@@ -450,14 +576,14 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
children: [
Expanded(
child: OutlinedButton(
onPressed: _cancel,
onPressed: cancel,
child: Text(locService.cancel),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
onPressed: !appState.isLoggedIn ? _navigateToLogin : (allowSubmit ? _commit : null),
onPressed: !appState.isLoggedIn ? navigateToLogin : (allowSubmit ? commit : null),
child: Text(!appState.isLoggedIn ? locService.t('actions.logIn') : locService.t('actions.submit')),
),
),

View File

@@ -185,6 +185,7 @@ class AdvancedEditOptionsSheet extends StatelessWidget {
}
// No custom scheme or app launch failed - redirect to app store
if (!context.mounted) return;
await _redirectToAppStore(context, editor);
}

View File

@@ -40,7 +40,7 @@ class CameraIcon extends StatelessWidget {
height: kNodeIconDiameter,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _ringColor.withOpacity(kNodeDotOpacity),
color: _ringColor.withValues(alpha: kNodeDotOpacity),
border: Border.all(
color: _ringColor,
width: getNodeRingThickness(context),

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import '../services/changelog_service.dart';
import '../services/version_service.dart';
class ChangelogDialog extends StatelessWidget {

View File

@@ -1,7 +1,6 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map_animations/flutter_map_animations.dart';
import 'package:provider/provider.dart';
@@ -97,8 +96,8 @@ class _CompassIndicatorState extends State<CompassIndicator> {
height: 52,
decoration: BoxDecoration(
color: isDisabled
? Colors.grey.withOpacity(0.8)
: Colors.white.withOpacity(0.95),
? Colors.grey.withValues(alpha: 0.8)
: Colors.white.withValues(alpha: 0.95),
shape: BoxShape.circle,
border: Border.all(
color: isDisabled
@@ -108,7 +107,7 @@ class _CompassIndicatorState extends State<CompassIndicator> {
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.25),
color: Colors.black.withValues(alpha: 0.25),
blurRadius: 6,
offset: const Offset(0, 3),
),

View File

@@ -0,0 +1,250 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import '../app_state.dart';
import '../state/settings_state.dart';
/// Custom scale bar widget that respects user's distance unit preference
///
/// Replaces flutter_map's built-in Scalebar to support metric/imperial units.
/// Uses the existing DistanceUnit enum from SettingsState.
///
/// Based on the brutalist code philosophy: simple, explicit, maintainable.
class CustomScaleBar extends StatelessWidget {
const CustomScaleBar({
super.key,
this.maxWidthPx = 120,
this.barHeight = 8,
this.padding = const EdgeInsets.all(10),
this.alignment = Alignment.bottomLeft,
this.textStyle,
});
final double maxWidthPx;
final double barHeight;
final EdgeInsets padding;
final Alignment alignment;
final TextStyle? textStyle;
@override
Widget build(BuildContext context) {
return Consumer<AppState>(
builder: (context, appState, child) {
final camera = MapCamera.of(context);
final center = camera.center;
final zoom = camera.zoom;
// Calculate meters represented by maxWidthPx at current zoom around map center
final maxMeters = _metersForPixelSpan(camera, center, zoom, maxWidthPx);
// Calculate nice intervals in the display unit for better user experience
final niceMeters = _niceDistanceInDisplayUnit(maxMeters, appState.distanceUnit);
// Calculate actual bar width in pixels
final metersPerPx = maxMeters / maxWidthPx;
final barWidthPx = (niceMeters / metersPerPx).clamp(1.0, maxWidthPx);
// Format the label based on user's unit preference
final label = _formatLabel(niceMeters, appState.distanceUnit);
// Use styling that matches the original flutter_map scale bar
final style = textStyle ??
const TextStyle(
fontSize: 12,
color: Colors.black,
fontWeight: FontWeight.bold,
);
return Align(
alignment: alignment,
child: Padding(
padding: padding,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: style),
const SizedBox(height: 2),
CustomPaint(
size: Size(barWidthPx, barHeight + 6),
painter: _ScaleBarPainter(barHeight: barHeight),
),
],
),
),
);
},
);
}
/// Calculate the real-world distance represented by a pixel span at the current zoom level
///
/// Uses a simple approach: calculate the distance between two points that are
/// separated by the pixel span at the map center latitude.
double _metersForPixelSpan(
MapCamera camera,
LatLng center,
double zoom,
double pixelSpan,
) {
// At the equator, 1 degree of longitude = ~111,320 meters
// At other latitudes, it's scaled by cos(latitude)
const metersPerDegreeAtEquator = 111320.0;
final metersPerDegreeLongitude = metersPerDegreeAtEquator * math.cos(center.latitude * math.pi / 180);
// Calculate degrees per pixel at this zoom level
// Web Mercator: 360 degrees spans 2^zoom tiles, each tile is 256 pixels
final tilesAtZoom = math.pow(2, zoom);
final pixelsAtZoom = tilesAtZoom * 256;
final degreesPerPixel = 360.0 / pixelsAtZoom;
// Calculate the longitude span represented by our pixel span
final longitudeSpan = degreesPerPixel * pixelSpan;
// Convert to meters
return longitudeSpan * metersPerDegreeLongitude;
}
/// Convert a maximum distance to a "nice" rounded distance in the display unit
///
/// For metric: Nice intervals like 1m, 2m, 5m, 10m, 1km, 2km, 5km
/// For imperial: Nice intervals like 1ft, 2ft, 5ft, 10ft, 1mi, 2mi, 5mi
double _niceDistanceInDisplayUnit(double maxMeters, DistanceUnit unit) {
if (maxMeters <= 0) return 0;
switch (unit) {
case DistanceUnit.metric:
return _calculateNiceDistance(maxMeters, [
// Small metric intervals (meters)
1, 2, 5, 10, 20, 50, 100, 200, 500,
// Large metric intervals (kilometers, converted to meters)
1000, 2000, 5000, 10000, 20000, 50000, 100000, 200000, 500000,
1000000, 2000000, 5000000, 10000000,
]);
case DistanceUnit.imperial:
const feetToMeters = 0.3048;
const milesToMeters = 1609.34;
return _calculateNiceDistance(maxMeters, [
// Small imperial intervals (feet, converted to meters)
1 * feetToMeters,
2 * feetToMeters,
5 * feetToMeters,
10 * feetToMeters,
20 * feetToMeters,
50 * feetToMeters,
100 * feetToMeters,
200 * feetToMeters,
500 * feetToMeters,
1000 * feetToMeters,
2000 * feetToMeters,
5000 * feetToMeters,
// Large imperial intervals (miles, converted to meters)
1 * milesToMeters,
2 * milesToMeters,
5 * milesToMeters,
10 * milesToMeters,
20 * milesToMeters,
50 * milesToMeters,
100 * milesToMeters,
200 * milesToMeters,
500 * milesToMeters,
1000 * milesToMeters,
]);
}
}
/// Find the largest "nice" distance that fits within the maximum
double _calculateNiceDistance(double maxMeters, List<double> intervals) {
// Find the largest interval that's still smaller than our max
for (int i = intervals.length - 1; i >= 0; i--) {
if (intervals[i] <= maxMeters) {
return intervals[i];
}
}
// Fallback to smallest interval if none fit
return intervals.first;
}
/// Format the distance label according to the user's unit preference
///
/// Uses the same logic as DistanceService for consistency:
/// - Metric: meters < 1000m, kilometers ≥ 1000m
/// - Imperial: feet < 5280ft, miles ≥ 5280ft
String _formatLabel(double meters, DistanceUnit unit) {
switch (unit) {
case DistanceUnit.metric:
if (meters >= 1000) {
final km = meters / 1000.0;
return '${_trim(km)} km';
}
return '${meters.round()} m';
case DistanceUnit.imperial:
final feet = meters * 3.28084;
if (feet >= 5280) {
final miles = feet / 5280.0;
return '${_trim(miles)} mi';
}
return '${feet.round()} ft';
}
}
/// Trim unnecessary decimal places from distance values
String _trim(double v) {
if (v >= 100) return v.toStringAsFixed(0);
if (v >= 10) return v.toStringAsFixed(1).replaceAll(RegExp(r'\.0$'), '');
return v
.toStringAsFixed(2)
.replaceAll(RegExp(r'0+$'), '')
.replaceAll(RegExp(r'\.$'), '');
}
}
/// Custom painter for drawing the scale bar
///
/// Draws a simple horizontal line with vertical end markers,
/// matching the style of the original flutter_map scale bar.
class _ScaleBarPainter extends CustomPainter {
_ScaleBarPainter({required this.barHeight});
final double barHeight;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.black
..strokeWidth = 3 // Match original scale bar stroke width
..style = PaintingStyle.stroke;
final yTop = 2.0;
final yBottom = yTop + barHeight;
// Draw horizontal base line
canvas.drawLine(Offset(0, yBottom), Offset(size.width, yBottom), paint);
// Draw left vertical marker
canvas.drawLine(Offset(0, yTop), Offset(0, yBottom), paint);
// Draw right vertical marker
canvas.drawLine(Offset(size.width, yTop), Offset(size.width, yBottom), paint);
// Draw middle marker for longer scales (visual clarity)
if (size.width >= 40) {
final midX = size.width / 2;
canvas.drawLine(
Offset(midX, yBottom - barHeight * 0.6),
Offset(midX, yBottom),
paint,
);
}
}
@override
bool shouldRepaint(covariant _ScaleBarPainter oldDelegate) =>
oldDelegate.barHeight != barHeight;
}

View File

@@ -57,8 +57,7 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
}
final minZoom = 1; // Always start from zoom 1 to show area overview when zoomed out
final maxZoom = _zoom.toInt();
// Calculate maximum possible zoom based on tile count limit and tile provider max zoom
final maxPossibleZoom = _calculateMaxZoomForTileLimit(bounds, minZoom);
@@ -124,7 +123,6 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
final locService = LocalizationService.instance;
final appState = context.watch<AppState>();
final bounds = widget.controller.camera.visibleBounds;
final maxZoom = _zoom.toInt();
final isOfflineMode = appState.offlineMode;
// Use the calculated max possible zoom instead of fixed span
@@ -191,8 +189,8 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: _tileCount! > kMaxReasonableTileCount
? Colors.orange.withOpacity(0.1)
: Colors.green.withOpacity(0.1),
? Colors.orange.withValues(alpha: 0.1)
: Colors.green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
),
child: Column(
@@ -200,7 +198,7 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
children: [
Text(
_tileCount! > kMaxReasonableTileCount
? 'Above recommended limit (Z${_maxPossibleZoom})'
? 'Above recommended limit (Z$_maxPossibleZoom)'
: locService.t('download.maxRecommendedZoom', params: [_maxPossibleZoom.toString()]),
style: TextStyle(
fontSize: 12,
@@ -213,7 +211,7 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
const SizedBox(height: 2),
Text(
_tileCount! > kMaxReasonableTileCount
? 'Current selection exceeds ${kMaxReasonableTileCount} recommended tile limit but is within ${kAbsoluteMaxTileCount} absolute limit'
? 'Current selection exceeds $kMaxReasonableTileCount recommended tile limit but is within $kAbsoluteMaxTileCount absolute limit'
: locService.t('download.withinTileLimit', params: [kMaxReasonableTileCount.toString()]),
style: TextStyle(
fontSize: 11,
@@ -232,9 +230,9 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.withOpacity(0.1),
color: Colors.orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.withOpacity(0.3)),
border: Border.all(color: Colors.orange.withValues(alpha: 0.3)),
),
child: Row(
children: [
@@ -266,8 +264,9 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
try {
final id = DateTime.now().toIso8601String().replaceAll(':', '-');
final appDocDir = await OfflineAreaService().getOfflineAreaDir();
if (!context.mounted) return;
final dir = "${appDocDir.path}/$id";
// Get current tile provider info
final appState = context.read<AppState>();
final selectedProvider = appState.selectedTileProvider;
@@ -294,6 +293,7 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
builder: (context) => const DownloadStartedDialog(),
);
} catch (e) {
if (!context.mounted) return;
Navigator.pop(context);
showDialog(
context: context,

View File

@@ -1,14 +1,17 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart';
import '../app_state.dart';
import '../dev_config.dart';
import '../models/node_profile.dart';
import '../models/operator_profile.dart';
import '../models/pending_upload.dart';
import '../services/localization_service.dart';
import '../services/node_cache.dart';
import '../services/map_data_provider.dart';
import '../services/node_data_manager.dart';
import '../services/changelog_service.dart';
import '../state/settings_state.dart';
import 'refine_tags_sheet.dart';
import 'advanced_edit_options_sheet.dart';
import 'proximity_warning_dialog.dart';
@@ -32,6 +35,13 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
void initState() {
super.initState();
_checkTutorialStatus();
// Listen to node data manager for cache updates
NodeDataManager().addListener(_onCacheUpdated);
}
void _onCacheUpdated() {
// Rebuild when cache updates (e.g., when new data loads)
if (mounted) setState(() {});
}
Future<void> _checkTutorialStatus() async {
@@ -58,8 +68,12 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
}
}
@override
@override
void dispose() {
// Remove listener
NodeDataManager().removeListener(_onCacheUpdated);
// Clear tutorial callback when widget is disposed
if (_showTutorial) {
try {
@@ -78,7 +92,8 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
void _checkSubmissionGuideAndProceed(BuildContext context, AppState appState, LocalizationService locService) async {
// Check if user has seen the submission guide
final hasSeenGuide = await ChangelogService().hasSeenSubmissionGuide();
if (!context.mounted) return;
if (!hasSeenGuide) {
// Show submission guide dialog first
final shouldProceed = await showDialog<bool>(
@@ -86,20 +101,21 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
barrierDismissible: false,
builder: (context) => const SubmissionGuideDialog(),
);
if (!context.mounted) return;
// If user canceled the submission guide, don't proceed with submission
if (shouldProceed != true) {
return;
}
}
// Now proceed with proximity check
_checkProximityOnly(context, appState, locService);
}
void _checkProximityOnly(BuildContext context, AppState appState, LocalizationService locService) {
// Check for nearby nodes within the configured distance, excluding the node being edited
final nearbyNodes = NodeCache.instance.findNodesWithinDistance(
final nearbyNodes = MapDataProvider().findNodesWithinDistance(
widget.session.target,
kNodeProximityWarningDistance,
excludeNodeId: widget.session.originalNode.id,
@@ -135,10 +151,125 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
);
}
/// Check if the edit session has any actual changes compared to the original node
bool _hasActualChanges(EditNodeSession session) {
// Extract operation is always a change
if (session.extractFromWay) return true;
// Check location change
const double tolerance = 0.0000001; // ~1cm precision
if ((session.target.latitude - session.originalNode.coord.latitude).abs() > tolerance ||
(session.target.longitude - session.originalNode.coord.longitude).abs() > tolerance) {
return true;
}
// Check direction changes
if (!_directionsEqual(session.directions, session.originalNode.directionDeg)) {
return true;
}
// Check tag changes (including operator profile and additional existing tags)
final originalTags = session.originalNode.tags;
final newTags = _getSessionCombinedTags(session);
if (!_tagsEqual(originalTags, newTags)) {
return true;
}
return false;
}
/// Compare two direction lists, handling empty vs [0] cases
bool _directionsEqual(List<double> sessionDirs, List<double> originalDirs) {
// Sort both lists for comparison
final sorted1 = List<double>.from(sessionDirs)..sort();
final sorted2 = List<double>.from(originalDirs)..sort();
// Handle empty list cases
if (sorted1.isEmpty && sorted2.isEmpty) return true;
if (sorted1.isEmpty || sorted2.isEmpty) {
// Special case: if one is empty and the other is [0], consider them different
// because the user either added or removed a direction
return false;
}
if (sorted1.length != sorted2.length) return false;
for (int i = 0; i < sorted1.length; i++) {
if ((sorted1[i] - sorted2[i]).abs() > 0.1) return false; // 0.1° tolerance
}
return true;
}
/// Compare two tag maps, ignoring direction tags (handled separately)
bool _tagsEqual(Map<String, String> tags1, Map<String, String> tags2) {
final filtered1 = Map<String, String>.from(tags1);
final filtered2 = Map<String, String>.from(tags2);
// Remove direction tags - they're handled separately
filtered1.remove('direction');
filtered1.remove('camera:direction');
filtered2.remove('direction');
filtered2.remove('camera:direction');
return _mapEquals(filtered1, filtered2);
}
/// Deep equality check for maps
bool _mapEquals(Map<String, String> map1, Map<String, String> map2) {
if (map1.length != map2.length) return false;
for (final entry in map1.entries) {
if (map2[entry.key] != entry.value) return false;
}
return true;
}
/// Get the combined tags that would be submitted for this session
Map<String, String> _getSessionCombinedTags(EditNodeSession session) {
if (session.profile == null) return <String, String>{};
// Create a temporary PendingUpload to use its getCombinedTags logic
final tempUpload = PendingUpload(
coord: session.target,
direction: session.directions.isNotEmpty ? session.directions.first : 0.0,
profile: session.profile,
operatorProfile: session.operatorProfile,
refinedTags: session.refinedTags,
additionalExistingTags: session.additionalExistingTags, // Include additional existing tags!
changesetComment: session.changesetComment, // Required parameter
uploadMode: UploadMode.production, // Mode doesn't matter for tag combination
operation: UploadOperation.modify,
originalNodeId: session.originalNode.id, // Required for modify operations
);
return tempUpload.getCombinedTags();
}
/// Show dialog explaining why submission is disabled due to no changes
void _showNoChangesDialog(BuildContext context, LocalizationService locService) {
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: Text(locService.t('editNode.noChangesTitle')),
content: Text(locService.t('editNode.noChangesMessage')),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(locService.ok),
),
],
),
);
}
Widget _buildDirectionControls(BuildContext context, AppState appState, EditNodeSession session, LocalizationService locService) {
final requiresDirection = session.profile != null && session.profile!.requiresDirection;
final is360Fov = session.profile?.fov == 360;
final enableDirectionControls = requiresDirection && !is360Fov;
final hasDirections = session.directions.isNotEmpty;
final enableDirectionControls = requiresDirection && !is360Fov && hasDirections;
final enableAddButton = requiresDirection && !is360Fov;
// Force direction to 0 when FOV is 360 (omnidirectional)
if (is360Fov && session.directionDegrees != 0) {
@@ -149,7 +280,7 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
// Format direction display text with bold for current direction
String directionsText = '';
if (requiresDirection) {
if (requiresDirection && hasDirections) {
final directionsWithBold = <String>[];
for (int i = 0; i < session.directions.length; i++) {
final dirStr = session.directions[i].round().toString();
@@ -180,7 +311,12 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
fontWeight: isEven ? FontWeight.normal : FontWeight.bold,
),
);
}),
})
else
const TextSpan(
text: 'None',
style: TextStyle(fontStyle: FontStyle.italic, color: Colors.grey),
),
],
),
)
@@ -205,12 +341,16 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
icon: Icon(
Icons.remove,
size: 20,
color: enableDirectionControls ? null : Theme.of(context).disabledColor,
color: enableDirectionControls && appState.canRemoveDirection ? null : Theme.of(context).disabledColor,
),
onPressed: enableDirectionControls && session.directions.length > 1
onPressed: enableDirectionControls && appState.canRemoveDirection
? () => appState.removeDirection()
: null,
tooltip: requiresDirection ? 'Remove current direction' : 'Direction not required for this profile',
tooltip: requiresDirection
? (hasDirections
? (appState.canRemoveDirection ? 'Remove current direction' : 'Cannot remove - minimum reached')
: 'No directions to remove')
: 'Direction not required for this profile',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
),
@@ -219,9 +359,9 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
icon: Icon(
Icons.add,
size: 20,
color: enableDirectionControls && session.directions.length < 8 ? null : Theme.of(context).disabledColor,
color: enableAddButton && session.directions.length < 8 ? null : Theme.of(context).disabledColor,
),
onPressed: enableDirectionControls && session.directions.length < 8 ? () => appState.addDirection() : null,
onPressed: enableAddButton && 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',
@@ -233,19 +373,23 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
icon: Icon(
Icons.repeat,
size: 20,
color: enableDirectionControls ? null : Theme.of(context).disabledColor,
color: enableDirectionControls && session.directions.length > 1 ? null : Theme.of(context).disabledColor,
),
onPressed: enableDirectionControls && session.directions.length > 1
? () => appState.cycleDirection()
: null,
tooltip: requiresDirection ? 'Cycle through directions' : 'Direction not required for this profile',
tooltip: requiresDirection
? (hasDirections
? (session.directions.length > 1 ? 'Cycle through directions' : 'Only one direction')
: 'No directions to cycle')
: 'Direction not required for this profile',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
),
],
),
),
// Show info text when profile doesn't require direction
// Show info text when profile doesn't require direction or when no directions exist
if (!requiresDirection)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
@@ -261,6 +405,22 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
),
],
),
)
else if (requiresDirection && !hasDirections)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
const Icon(Icons.info_outline, color: Colors.blue, size: 16),
const SizedBox(width: 6),
Expanded(
child: Text(
'This device currently has no direction. Tap the + button to add one.',
style: const TextStyle(color: Colors.blue, fontSize: 12),
),
),
],
),
),
],
);
@@ -274,29 +434,55 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
final locService = LocalizationService.instance;
final appState = context.watch<AppState>();
void _commit() {
void commit() {
// Check if there are any actual changes to submit
if (!_hasActualChanges(widget.session)) {
_showNoChangesDialog(context, locService);
return;
}
_checkProximityAndCommit(context, appState, locService);
}
void _cancel() {
void cancel() {
appState.cancelEditSession();
Navigator.pop(context);
}
final session = widget.session;
final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList();
final isSandboxMode = appState.uploadMode == UploadMode.sandbox;
// Check if we have good cache coverage around the node position
bool hasGoodCoverage = true;
final nodeCoord = session.originalNode.coord;
const double bufferDegrees = 0.001; // ~100m buffer
final targetBounds = LatLngBounds(
LatLng(nodeCoord.latitude - bufferDegrees, nodeCoord.longitude - bufferDegrees),
LatLng(nodeCoord.latitude + bufferDegrees, nodeCoord.longitude + bufferDegrees),
);
hasGoodCoverage = MapDataProvider().hasGoodCoverageFor(targetBounds);
// If strict coverage check fails, fall back to checking if we have any nodes nearby
// This handles the timing issue where cache might not be marked as "covered" yet
if (!hasGoodCoverage) {
final nearbyNodes = MapDataProvider().findNodesWithinDistance(
nodeCoord,
200.0, // 200m radius - if we have nodes nearby, we likely have good data
);
hasGoodCoverage = nearbyNodes.isNotEmpty;
}
final allowSubmit = kEnableNodeEdits &&
appState.isLoggedIn &&
submittableProfiles.isNotEmpty &&
session.profile != null &&
session.profile!.isSubmittable;
session.profile!.isSubmittable &&
hasGoodCoverage;
void _navigateToLogin() {
void navigateToLogin() {
Navigator.pushNamed(context, '/settings/osm-account');
}
void _openRefineTags() async {
void openRefineTags() async {
final result = await Navigator.push<RefineTagsResult?>(
context,
MaterialPageRoute(
@@ -304,6 +490,9 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
selectedOperatorProfile: session.operatorProfile,
selectedProfile: session.profile,
currentRefinedTags: session.refinedTags,
currentAdditionalExistingTags: session.additionalExistingTags,
originalNodeTags: session.originalNode.tags,
operation: session.extractFromWay ? UploadOperation.extract : UploadOperation.modify,
),
fullscreenDialog: true,
),
@@ -312,6 +501,9 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
appState.updateEditSession(
operatorProfile: result.operatorProfile,
refinedTags: result.refinedTags,
additionalExistingTags: result.additionalExistingTags,
changesetComment: result.changesetComment,
updateOperatorProfile: true,
);
}
}
@@ -340,14 +532,7 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
const SizedBox(height: 16),
ListTile(
title: Text(locService.t('editNode.profile')),
trailing: DropdownButton<NodeProfile?>(
value: session.profile,
hint: Text(locService.t('editNode.selectProfile')),
items: submittableProfiles
.map((p) => DropdownMenuItem(value: p, child: Text(p.name)))
.toList(),
onChanged: (p) => appState.updateEditSession(profile: p),
),
trailing: _buildProfileDropdown(context, appState, session, submittableProfiles, locService),
),
// Direction controls
_buildDirectionControls(context, appState, session, locService),
@@ -484,6 +669,22 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
),
],
),
)
else if (!hasGoodCoverage)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
const Icon(Icons.cloud_download, color: Colors.blue, size: 20),
const SizedBox(width: 6),
Expanded(
child: Text(
locService.t('editNode.loadingAreaData'),
style: const TextStyle(color: Colors.blue, fontSize: 13),
),
),
],
),
),
const SizedBox(height: 8),
Padding(
@@ -491,11 +692,9 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
child: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: session.profile != null ? _openRefineTags : null, // Disabled when no profile selected
onPressed: session.profile != null ? openRefineTags : null, // Disabled when no profile selected
icon: const Icon(Icons.tune),
label: Text(session.operatorProfile != null
? locService.t('editNode.refineTagsWithProfile', params: [session.operatorProfile!.name])
: locService.t('editNode.refineTags')),
label: Text(locService.t('editNode.refineTags')),
),
),
),
@@ -506,14 +705,14 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
children: [
Expanded(
child: OutlinedButton(
onPressed: _cancel,
onPressed: cancel,
child: Text(locService.cancel),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
onPressed: !appState.isLoggedIn ? _navigateToLogin : (allowSubmit ? _commit : null),
onPressed: !appState.isLoggedIn ? navigateToLogin : (allowSubmit ? commit : null),
child: Text(!appState.isLoggedIn ? locService.t('actions.logIn') : locService.t('actions.saveEdit')),
),
),
@@ -535,6 +734,121 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
);
}
Widget _buildProfileDropdown(BuildContext context, AppState appState, EditNodeSession session, List<NodeProfile> submittableProfiles, LocalizationService locService) {
// Display name for the current profile - localize the existing tags profile
String getDisplayName(NodeProfile? profile) {
if (profile == null) return locService.t('editNode.selectProfile');
if (profile.id.startsWith('temp-empty-')) {
return locService.t('editNode.existingTags');
}
return profile.name;
}
return PopupMenuButton<String>(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
getDisplayName(session.profile),
style: TextStyle(
fontSize: 16,
color: session.profile != null ? null : Colors.grey.shade600,
),
),
const SizedBox(width: 4),
const Icon(Icons.arrow_drop_down, size: 20),
],
),
),
itemBuilder: (context) => [
// Existing tags profile (always first in edit mode)
PopupMenuItem<String>(
value: 'existing_tags',
child: Text(locService.t('editNode.existingTags')),
),
// Divider after existing tags profile
if (submittableProfiles.isNotEmpty)
const PopupMenuDivider(),
// Regular profiles
...submittableProfiles.map(
(profile) => PopupMenuItem<String>(
value: 'profile_${profile.id}',
child: Text(profile.name),
),
),
// Divider
if (submittableProfiles.isNotEmpty) const PopupMenuDivider(),
// Get more... option
PopupMenuItem<String>(
value: 'get_more',
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.language, size: 16),
const SizedBox(width: 8),
Text(
locService.t('profiles.getMore'),
style: const TextStyle(
fontStyle: FontStyle.italic,
),
),
],
),
),
],
onSelected: (value) {
if (value == 'get_more') {
_openIdentifyWebsite(context);
} else if (value == 'existing_tags') {
// Re-create and select the existing tags profile
final existingTagsProfile = NodeProfile.createExistingTagsProfile(session.originalNode);
appState.updateEditSession(profile: existingTagsProfile);
} else if (value.startsWith('profile_')) {
final profileId = value.substring(8); // Remove 'profile_' prefix
final profile = submittableProfiles.firstWhere((p) => p.id == profileId);
appState.updateEditSession(profile: profile);
}
},
);
}
void _openIdentifyWebsite(BuildContext context) async {
const url = 'https://deflock.me/identify';
try {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(
uri,
mode: LaunchMode.externalApplication, // Force external browser
);
} else {
if (context.mounted) {
_showErrorSnackBar(context, 'Unable to open website');
}
}
} catch (e) {
if (context.mounted) {
_showErrorSnackBar(context, 'Error opening website: $e');
}
}
}
void _showErrorSnackBar(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.red,
),
);
}
void _openAdvancedEdit(BuildContext context) {
showModalBottomSheet(
context: context,

View File

@@ -6,7 +6,6 @@ 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 {
@@ -19,8 +18,11 @@ class DirectionConesBuilder {
}) {
final overlays = <Polygon>[];
// Add session cones if in add-camera mode and profile requires direction
if (session != null && session.target != null && session.profile?.requiresDirection == true) {
// Add session cones if in add-camera mode and profile requires direction AND we have directions
if (session != null &&
session.target != null &&
session.profile?.requiresDirection == true &&
session.directions.isNotEmpty) {
final sessionFov = session.profile?.fov ?? (kDirectionConeHalfAngle * 2);
// Add current working direction (full opacity)
@@ -50,8 +52,10 @@ class DirectionConesBuilder {
}
}
// Add edit session cones if in edit-camera mode and profile requires direction
if (editSession != null && editSession.profile?.requiresDirection == true) {
// Add edit session cones if in edit-camera mode and profile requires direction AND we have directions
if (editSession != null &&
editSession.profile?.requiresDirection == true &&
editSession.directions.isNotEmpty) {
final sessionFov = editSession.profile?.fov ?? (kDirectionConeHalfAngle * 2);
// Add current working direction (full opacity)
@@ -108,11 +112,6 @@ class DirectionConesBuilder {
node.coord.longitude.abs() <= 180;
}
static bool _isPendingUpload(OsmNode node) {
return node.tags.containsKey('_pending_upload') &&
node.tags['_pending_upload'] == 'true';
}
/// Build cone with variable FOV width - new method for range notation support
static Polygon _buildConeWithFov(
LatLng origin,
@@ -136,28 +135,6 @@ class DirectionConesBuilder {
);
}
/// Legacy method for backward compatibility - uses dev_config FOV
static Polygon _buildCone(
LatLng origin,
double bearingDeg,
double zoom, {
required BuildContext context,
bool isPending = false,
bool isSession = false,
bool isActiveDirection = true,
}) {
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,
@@ -171,9 +148,7 @@ class DirectionConesBuilder {
}) {
// Handle full circle case (360-degree FOV)
// Use 179.5 threshold to account for floating point precision
print("DEBUG: halfAngleDeg = $halfAngleDeg, bearing = $bearingDeg");
if (halfAngleDeg >= 179.5) {
print("DEBUG: Using full circle for 360° FOV");
return _buildFullCircle(
origin: origin,
zoom: zoom,
@@ -182,7 +157,6 @@ class DirectionConesBuilder {
isActiveDirection: isActiveDirection,
);
}
print("DEBUG: Using normal cone for FOV = ${halfAngleDeg * 2}°");
// Calculate pixel-based radii
final outerRadiusPx = kNodeIconDiameter + (kNodeIconDiameter * kDirectionConeBaseLength);
@@ -229,7 +203,7 @@ class DirectionConesBuilder {
return Polygon(
points: points,
color: kDirectionConeColor.withOpacity(opacity),
color: kDirectionConeColor.withValues(alpha: opacity),
borderColor: kDirectionConeColor,
borderStrokeWidth: getDirectionConeBorderWidth(context),
);
@@ -244,8 +218,6 @@ class DirectionConesBuilder {
bool isSession = false,
bool isActiveDirection = true,
}) {
print("DEBUG: Building full circle - isSession: $isSession, isActiveDirection: $isActiveDirection");
// Calculate pixel-based radii
final outerRadiusPx = kNodeIconDiameter + (kNodeIconDiameter * kDirectionConeBaseLength);
@@ -253,8 +225,6 @@ class DirectionConesBuilder {
final pixelToCoordinate = 0.00001 * math.pow(2, 15 - zoom);
final outerRadius = outerRadiusPx * pixelToCoordinate;
print("DEBUG: Outer radius: $outerRadius, zoom: $zoom");
// Create simple filled circle - no donut complexity
const int circlePoints = 60;
final points = <LatLng>[];
@@ -273,8 +243,6 @@ class DirectionConesBuilder {
points.add(project(angle, outerRadius));
}
print("DEBUG: Created ${points.length} points for full circle");
// Adjust opacity based on direction state
double opacity = kDirectionConeOpacity;
if (isSession && !isActiveDirection) {
@@ -283,7 +251,7 @@ class DirectionConesBuilder {
return Polygon(
points: points,
color: kDirectionConeColor.withOpacity(opacity),
color: kDirectionConeColor.withValues(alpha: opacity),
borderColor: kDirectionConeColor,
borderStrokeWidth: getDirectionConeBorderWidth(context),
);

View File

@@ -32,6 +32,7 @@ class GpsController {
List<OsmNode> Function()? _getNearbyNodes;
List<NodeProfile> Function()? _getEnabledProfiles;
VoidCallback? _onMapMovedProgrammatically;
bool Function()? _isUserInteracting;
/// Get the current GPS location (if available)
LatLng? get currentLocation => _currentLocation;
@@ -49,6 +50,7 @@ class GpsController {
required List<OsmNode> Function() getNearbyNodes,
required List<NodeProfile> Function() getEnabledProfiles,
VoidCallback? onMapMovedProgrammatically,
bool Function()? isUserInteracting,
}) async {
debugPrint('[GpsController] Initializing GPS controller');
@@ -61,7 +63,8 @@ class GpsController {
_getNearbyNodes = getNearbyNodes;
_getEnabledProfiles = getEnabledProfiles;
_onMapMovedProgrammatically = onMapMovedProgrammatically;
_isUserInteracting = isUserInteracting;
// Start location tracking
await _startLocationTracking();
}
@@ -235,9 +238,10 @@ class GpsController {
if (followMeMode == FollowMeMode.off || _mapController == null) {
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
try {
if (_isUserInteracting?.call() == true) return;
if (followMeMode == FollowMeMode.follow) {
// Follow position, preserve rotation
_mapController!.animateTo(
@@ -352,5 +356,6 @@ class GpsController {
_getNearbyNodes = null;
_getEnabledProfiles = null;
_onMapMovedProgrammatically = null;
_isUserInteracting = null;
}
}

View File

@@ -81,7 +81,7 @@ class _LayerSelectorDialogState extends State<_LayerSelectorDialog> {
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
),
child: Row(

View File

@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map_animations/flutter_map_animations.dart';
import 'package:latlong2/latlong.dart';
import '../../models/osm_node.dart';

View File

@@ -1,12 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map_animations/flutter_map_animations.dart';
import 'package:provider/provider.dart';
import '../../app_state.dart';
import '../../dev_config.dart';
import '../../services/localization_service.dart';
import '../camera_icon.dart';
import '../compass_indicator.dart';
import 'layer_selector_button.dart';
@@ -64,8 +62,8 @@ class MapOverlays extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: uploadMode == UploadMode.sandbox
? Colors.orange.withOpacity(0.90)
: Colors.deepPurple.withOpacity(0.80),
? Colors.orange.withValues(alpha: 0.90)
: Colors.deepPurple.withValues(alpha: 0.80),
borderRadius: BorderRadius.circular(8),
boxShadow: const [
BoxShadow(color: Colors.black26, blurRadius: 5, offset: Offset(0,2)),
@@ -98,7 +96,7 @@ class MapOverlays extends StatelessWidget {
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.52),
color: Colors.black.withValues(alpha: 0.52),
borderRadius: BorderRadius.circular(7),
),
child: Builder(
@@ -131,7 +129,7 @@ class MapOverlays extends StatelessWidget {
onTap: () => _showAttributionDialog(context, attribution!),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface.withOpacity(0.9),
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(4),
),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),

View File

@@ -6,7 +6,6 @@ import 'package:latlong2/latlong.dart';
import '../../models/osm_node.dart';
import '../../models/suspected_location.dart';
import '../../app_state.dart';
import '../../state/session_state.dart';
import '../../dev_config.dart';
import '../camera_icon.dart';
import '../provisional_pin.dart';

View File

@@ -20,8 +20,8 @@ class NodeMapMarker extends StatefulWidget {
required this.mapController,
this.onNodeTap,
this.enabled = true,
Key? key,
}) : super(key: key);
super.key,
});
@override
State<NodeMapMarker> createState() => _NodeMapMarkerState();

View File

@@ -1,11 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_map_animations/flutter_map_animations.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import '../../models/node_profile.dart';
import '../../app_state.dart' show UploadMode;
import '../../services/prefetch_area_service.dart';
import '../node_provider_with_cache.dart';
import '../../dev_config.dart';
@@ -44,8 +43,6 @@ class NodeRefreshController {
WidgetsBinding.instance.addPostFrameCallback((_) {
// Clear node cache to ensure fresh data for new profile combination
_nodeProvider.clearCache();
// Clear pre-fetch area since profiles changed
PrefetchAreaService().clearPreFetchedArea();
// Force display refresh first (for immediate UI update)
_nodeProvider.refreshDisplay();
// Notify that profiles changed (triggers node refresh)
@@ -72,12 +69,18 @@ class NodeRefreshController {
}
final zoom = controller.mapController.camera.zoom;
if (zoom < kNodeMinZoomLevel) {
// Show a snackbar-style bubble warning
// Use the correct minimum zoom level based on upload mode
final minZoom = uploadMode == UploadMode.sandbox ? kOsmApiMinZoomLevel : kNodeMinZoomLevel;
if (zoom < minZoom) {
// Show a snackbar-style bubble warning with mode-specific message
if (context.mounted) {
final message = uploadMode == UploadMode.sandbox
? 'Nodes not drawn below zoom level $minZoom in sandbox mode (OSM API limits)'
: 'Nodes not drawn below zoom level $minZoom';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Nodes not drawn below zoom level $kNodeMinZoomLevel'),
content: Text(message),
duration: const Duration(seconds: 2),
),
);

View File

@@ -4,7 +4,6 @@ import 'package:latlong2/latlong.dart';
import '../../models/osm_node.dart';
import '../../app_state.dart';
import '../../state/session_state.dart';
import '../../dev_config.dart';
import 'direction_cones.dart';
@@ -38,7 +37,7 @@ class OverlayLayerBuilder {
overlays.add(
Polygon(
points: selectedLocation.bounds,
color: Colors.orange.withOpacity(0.3),
color: Colors.orange.withValues(alpha: 0.3),
borderColor: Colors.orange,
borderStrokeWidth: 2.0,
),

View File

@@ -20,8 +20,8 @@ class SuspectedLocationMapMarker extends StatefulWidget {
required this.mapController,
this.onLocationTap,
this.enabled = true,
Key? key,
}) : super(key: key);
super.key,
});
@override
State<SuspectedLocationMapMarker> createState() => _SuspectedLocationMapMarkerState();

View File

@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import '../../models/tile_provider.dart' as models;
import '../../services/deflock_tile_provider.dart';
@@ -93,7 +92,7 @@ class TileLayerManager {
return TileLayer(
urlTemplate: urlTemplate, // Critical for cache key generation
userAgentPackageName: 'me.deflock.deflockapp',
maxZoom: selectedTileType?.maxZoom?.toDouble() ?? 18.0,
maxZoom: selectedTileType?.maxZoom.toDouble() ?? 18.0,
tileProvider: _tileProvider!,
);
}

View File

@@ -4,15 +4,11 @@ import 'package:flutter_map_animations/flutter_map_animations.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import '../app_state.dart' show AppState, FollowMeMode, UploadMode;
import '../app_state.dart' show AppState, FollowMeMode;
import '../services/offline_area_service.dart';
import '../services/network_status.dart';
import '../services/prefetch_area_service.dart';
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 'node_provider_with_cache.dart';
import 'map/map_overlays.dart';
@@ -30,6 +26,7 @@ import 'proximity_alert_banner.dart';
import '../dev_config.dart';
import '../services/proximity_alert_service.dart';
import 'sheet_aware_map.dart';
import 'custom_scale_bar.dart';
class MapView extends StatefulWidget {
final AnimatedMapController controller;
@@ -83,6 +80,9 @@ class MapViewState extends State<MapView> {
// State for proximity alert banner
bool _showProximityBanner = false;
// Track active pointers to suppress follow-me animations during touch
int _activePointers = 0;
@@ -163,15 +163,13 @@ class MapViewState extends State<MapView> {
getNearbyNodes: () {
if (mounted) {
try {
LatLngBounds? mapBounds;
final LatLngBounds mapBounds;
try {
mapBounds = _controller.mapController.camera.visibleBounds;
} catch (_) {
return [];
}
return mapBounds != null
? NodeProviderWithCache.instance.getCachedNodesForBounds(mapBounds)
: [];
return NodeProviderWithCache.instance.getCachedNodesForBounds(mapBounds);
} catch (e) {
debugPrint('[MapView] Could not get nearby nodes: $e');
return [];
@@ -194,6 +192,7 @@ class MapViewState extends State<MapView> {
// Refresh nodes when GPS controller moves the map
_refreshNodesFromProvider();
},
isUserInteracting: () => _activePointers > 0,
);
// Fetch initial cameras
@@ -214,7 +213,7 @@ class MapViewState extends State<MapView> {
_nodeController.dispose();
_tileManager.dispose();
_gpsController.dispose();
PrefetchAreaService().dispose();
// PrefetchAreaService no longer used - replaced with NodeDataManager
super.dispose();
}
@@ -385,10 +384,21 @@ class MapViewState extends State<MapView> {
children: [
SheetAwareMap(
sheetHeight: widget.sheetHeight,
child: FlutterMap(
key: ValueKey('map_${appState.offlineMode}_${appState.selectedTileType?.id ?? 'none'}_${_tileManager.mapRebuildKey}'),
mapController: _controller.mapController,
options: MapOptions(
child: Listener(
onPointerDown: (_) {
_activePointers++;
_controller.stopAnimations();
},
onPointerUp: (_) {
if (_activePointers > 0) _activePointers--;
},
onPointerCancel: (_) {
if (_activePointers > 0) _activePointers--;
},
child: FlutterMap(
key: ValueKey('map_${appState.offlineMode}_${appState.selectedTileType?.id ?? 'none'}_${_tileManager.mapRebuildKey}'),
mapController: _controller.mapController,
options: MapOptions(
initialCenter: _gpsController.currentLocation ?? _positionManager.initialLocation ?? LatLng(37.7749, -122.4194),
initialZoom: _positionManager.initialZoom ?? 15,
minZoom: 1.0,
@@ -491,32 +501,31 @@ class MapViewState extends State<MapView> {
_dataManager.showZoomWarningIfNeeded(context, pos.zoom, appState.uploadMode);
}
},
),
children: [
_tileManager.buildTileLayer(
selectedProvider: appState.selectedTileProvider,
selectedTileType: appState.selectedTileType,
),
cameraLayers,
// Custom scale bar that respects user's distance unit preference
Builder(
builder: (context) {
final safeArea = MediaQuery.of(context).padding;
return CustomScaleBar(
alignment: Alignment.bottomLeft,
padding: EdgeInsets.only(
left: leftPositionWithSafeArea(8, safeArea),
bottom: bottomPositionFromButtonBar(kScaleBarSpacingAboveButtonBar, safeArea.bottom),
),
maxWidthPx: 120,
barHeight: 8,
);
},
),
],
),
),
children: [
_tileManager.buildTileLayer(
selectedProvider: appState.selectedTileProvider,
selectedTileType: appState.selectedTileType,
),
cameraLayers,
// 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)
);
},
),
],
),
),
// All map overlays (mode indicator, zoom, attribution, add pin)

View File

@@ -1,4 +1,3 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:latlong2/latlong.dart';
@@ -6,6 +5,7 @@ import 'package:latlong2/latlong.dart';
import '../app_state.dart';
import '../dev_config.dart';
import '../services/localization_service.dart';
import '../services/distance_service.dart';
class NavigationSheet extends StatelessWidget {
final VoidCallback? onStartRoute;
@@ -166,7 +166,7 @@ class NavigationSheet extends StatelessWidget {
// Show distance from first point
if (appState.distanceFromFirstPoint != null) ...[
Text(
'Distance: ${(appState.distanceFromFirstPoint! / 1000).toStringAsFixed(1)} km',
'Distance: ${DistanceService.formatRouteDistance(appState.distanceFromFirstPoint!.toDouble(), appState.distanceUnit)}',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
@@ -181,9 +181,9 @@ class NavigationSheet extends StatelessWidget {
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.amber.withOpacity(0.1),
color: Colors.amber.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.amber.withOpacity(0.3)),
border: Border.all(color: Colors.amber.withValues(alpha: 0.3)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -192,7 +192,7 @@ class NavigationSheet extends StatelessWidget {
const SizedBox(width: 8),
Expanded(
child: Text(
'Trips longer than ${(kNavigationDistanceWarningThreshold / 1000).toStringAsFixed(0)} km are likely to time out. We are working to improve this; stay tuned.',
'Trips longer than ${DistanceService.formatRouteDistance(kNavigationDistanceWarningThreshold.toDouble(), appState.distanceUnit)} are likely to time out. We are working to improve this; stay tuned.',
style: TextStyle(
fontSize: 14,
color: Colors.amber[700],
@@ -211,9 +211,9 @@ class NavigationSheet extends StatelessWidget {
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.withOpacity(0.1),
color: Colors.orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.withOpacity(0.3)),
border: Border.all(color: Colors.orange.withValues(alpha: 0.3)),
),
child: Row(
children: [
@@ -343,7 +343,7 @@ class NavigationSheet extends StatelessWidget {
],
if (appState.routeDistance != null) ...[
Text(
LocalizationService.instance.t('navigation.distance', params: [(appState.routeDistance! / 1000).toStringAsFixed(1)]),
'Distance: ${DistanceService.formatRouteDistance(appState.routeDistance!.toDouble(), appState.distanceUnit)}',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),

View File

@@ -26,49 +26,55 @@ class NetworkStatusIndicator extends StatelessWidget {
IconData icon;
Color color;
switch (networkStatus.currentStatus) {
case NetworkStatusType.waiting:
switch (networkStatus.status) {
case NetworkRequestStatus.loading:
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;
case NetworkRequestStatus.splitting:
message = locService.t('networkStatus.nodeDataSlow');
icon = Icons.camera_alt_outlined;
color = Colors.orange;
break;
case NetworkStatusType.noData:
message = locService.t('networkStatus.noData');
icon = Icons.cloud_off;
color = Colors.grey;
break;
case NetworkStatusType.success:
case NetworkRequestStatus.success:
message = locService.t('networkStatus.success');
icon = Icons.check_circle;
color = Colors.green;
break;
case NetworkStatusType.issues:
switch (networkStatus.currentIssueType) {
case NetworkIssueType.overpassApi:
message = locService.t('networkStatus.nodeDataSlow');
icon = Icons.camera_alt_outlined;
color = Colors.orange;
break;
default:
return const SizedBox.shrink();
}
case NetworkRequestStatus.timeout:
message = locService.t('networkStatus.timedOut');
icon = Icons.hourglass_disabled;
color = Colors.orange;
break;
case NetworkStatusType.ready:
case NetworkRequestStatus.rateLimited:
message = locService.t('networkStatus.rateLimited');
icon = Icons.speed;
color = Colors.red;
break;
case NetworkRequestStatus.noData:
message = locService.t('networkStatus.noData');
icon = Icons.cloud_off;
color = Colors.grey;
break;
case NetworkRequestStatus.error:
message = locService.t('networkStatus.networkError');
icon = Icons.error_outline;
color = Colors.red;
break;
case NetworkRequestStatus.idle:
return const SizedBox.shrink();
}
return Positioned(
top: top, // Position dynamically based on other indicators
top: top,
left: left,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),

Some files were not shown because too many files have changed in this diff Show More