Compare commits

..

39 Commits

Author SHA1 Message Date
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
103 changed files with 1197 additions and 907 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

@@ -800,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,43 +107,7 @@ cp lib/keys.dart.example lib/keys.dart
## Roadmap
### Needed Bugfixes
- Make submission guide scarier
- 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
### Current Development
- Support check_date= tag, update on all edits, quick button to update that only
- Support source= tag, default to survey, let user pick a different value
- 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
- Update default profiles from the website on launch to capture changes
### Future Features & Wishlist
- Tap direction slider to enter integer directly
- Tap pending queue item to edit again before submitting
- Optional reason message when deleting
- Update offline area data while browsing?
- Save named locations to more easily navigate to home or work
- Offline navigation (pending vector map tiles)
### Maybes
- Icons/glyphs for profiles
- "Universal Links" for better handling of profile import when app is not installed
- Yellow ring for devices missing specific tag details
- Android Auto / CarPlay
- "Cache accumulating" offline area? Most recent / most viewed?
- Grab the full latest database for each profile just like for suspected locations (instead of overpass)?
- Custom data providers? (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,9 @@
{
"2.7.1": {
"content": [
"• Fixed operator profile selection being lost when moving node position, adjusting direction, or changing profiles"
]
},
"2.6.4": {
"content": [
"• Added imperial units support (miles, feet) in addition to metric units (km, meters)",

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,6 @@
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';
@@ -23,7 +22,6 @@ 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';
@@ -366,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);
}
}
@@ -448,6 +447,7 @@ class AppState extends ChangeNotifier {
Map<String, String>? refinedTags,
Map<String, String>? additionalExistingTags,
String? changesetComment,
bool updateOperatorProfile = false,
}) {
_sessionState.updateSession(
directionDeg: directionDeg,
@@ -457,6 +457,7 @@ class AppState extends ChangeNotifier {
refinedTags: refinedTags,
additionalExistingTags: additionalExistingTags,
changesetComment: changesetComment,
updateOperatorProfile: updateOperatorProfile,
);
// Check tutorial completion if position changed
@@ -474,6 +475,7 @@ class AppState extends ChangeNotifier {
Map<String, String>? refinedTags,
Map<String, String>? additionalExistingTags,
String? changesetComment,
bool updateOperatorProfile = false,
}) {
_sessionState.updateEditSession(
directionDeg: directionDeg,
@@ -484,6 +486,7 @@ class AppState extends ChangeNotifier {
refinedTags: refinedTags,
additionalExistingTags: additionalExistingTags,
changesetComment: changesetComment,
updateOperatorProfile: updateOperatorProfile,
);
// Check tutorial completion if position changed
@@ -814,7 +817,7 @@ 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
/// Handles special case of `<Existing tags>` profile by using "a" instead
static String generateDefaultChangesetComment({
required NodeProfile? profile,
required UploadOperation operation,

View File

@@ -72,7 +72,7 @@ const Duration kOverpassQueryTimeout = Duration(seconds: 45); // Timeout for Ove
const String kSuspectedLocationsCsvUrl = 'https://alprwatch.org/suspected-locations/deflock-latest.csv';
// Development/testing features - set to false for production builds
const bool kEnableDevelopmentModes = false; // Set to false to hide sandbox/simulate modes and force production mode
const bool kEnableDevelopmentModes = true; // Set to false to hide sandbox/simulate modes and force production mode
// Navigation features - set to false to hide navigation UI elements while in development
const bool kEnableNavigationFeatures = true; // Hide navigation until fully implemented
@@ -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

@@ -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,3 @@
import 'package:uuid/uuid.dart';
import 'osm_node.dart';
/// Sentinel value for copyWith methods to distinguish between null and not provided
@@ -267,7 +266,7 @@ class NodeProfile {
int get hashCode => id.hashCode;
/// Create a temporary empty profile for editing existing nodes
/// Used as the default "<Existing tags>" option when editing 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

View File

@@ -1,4 +1,3 @@
import 'package:uuid/uuid.dart';
import 'osm_node.dart';
/// A bundle of OSM tags that describe a particular surveillance operator.

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

@@ -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';
@@ -190,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);
}
@@ -214,6 +210,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
case PopupType.changelog:
final changelogContent = await ChangelogService().getChangelogContentForDisplay();
if (!mounted) return;
if (changelogContent != null) {
await showDialog(
context: context,
@@ -252,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,
@@ -402,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();
});
}
@@ -578,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

@@ -132,7 +132,7 @@ class _NavigationSettingsScreenState extends State<NavigationSettingsScreen> {
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

@@ -23,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;
@@ -58,44 +60,46 @@ class _LanguageSectionState extends State<LanguageSection> {
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Language section
// System Default option
RadioListTile<String?>(
title: Text(locService.t('settings.systemDefault')),
value: null,
RadioGroup<String?>(
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,
),
// 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,
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'),
@@ -105,33 +109,33 @@ class _LanguageSectionState extends State<LanguageSection> {
Text(
locService.t('settings.distanceUnitSubtitle'),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.7),
color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.7),
),
),
const SizedBox(height: 8),
// Metric option
RadioListTile<DistanceUnit>(
title: Text(locService.t('units.metricDescription')),
value: DistanceUnit.metric,
groupValue: appState.distanceUnit,
onChanged: (unit) {
if (unit != null) {
appState.setDistanceUnit(unit);
}
},
),
// Imperial option
RadioListTile<DistanceUnit>(
title: Text(locService.t('units.imperialDescription')),
value: DistanceUnit.imperial,
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,
),
],
),
),
],
),
@@ -141,4 +145,4 @@ class _LanguageSectionState extends State<LanguageSection> {
},
);
}
}
}

View File

@@ -118,7 +118,7 @@ class NodeProfilesSection extends StatelessWidget {
);
// If user chose to create custom profile, open the profile editor
if (result == 'create') {
if (result == 'create' && context.mounted) {
_createNewProfile(context);
}
// If user chose import from website, ProfileAddChoiceDialog handles opening the URL

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

@@ -140,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,
@@ -233,7 +233,7 @@ class _ProximityAlertsSectionState extends State<ProximityAlertsSection> {
DistanceService.convertFromMeters(kProximityAlertDefaultDistance.toDouble(), appState.distanceUnit).round().toString(),
]),
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),
),
),
],

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

@@ -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(
@@ -205,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),
),
),
),
@@ -224,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,
),
@@ -272,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

@@ -8,7 +8,6 @@ class DistanceService {
// Conversion constants
static const double _metersToFeet = 3.28084;
static const double _metersToMiles = 0.000621371;
static const double _kmToMiles = 0.621371;
/// Format distance for display based on unit preference
///

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,6 +1,5 @@
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';

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).
@@ -81,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,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

@@ -5,7 +5,7 @@ import 'dart:async';
/// Only tracks the latest user-initiated request - background requests are ignored.
enum NetworkRequestStatus {
idle, // No active requests
loading, // Request in progress
loading, // Request in progress
splitting, // Request being split due to limits/timeouts
success, // Data loaded successfully
timeout, // Request timed out

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

@@ -1,6 +1,4 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart';

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

@@ -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,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,23 +54,6 @@ 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;
}
// Use the user's changeset comment, with XML sanitization
final sanitizedComment = _sanitizeXmlText(p.changesetComment);
final csXml = '''
@@ -147,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);
}
@@ -184,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);
}
@@ -350,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,

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

@@ -376,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

@@ -140,16 +140,6 @@ class SessionState extends ChangeNotifier {
notifyListeners();
}
bool _profileMatchesTags(NodeProfile profile, Map<String, String> tags) {
// Simple matching: check if all profile tags are present in node tags
for (final entry in profile.tags.entries) {
if (tags[entry.key] != entry.value) {
return false;
}
}
return true;
}
/// Calculate additional existing tags for a given profile change
Map<String, String> _calculateAdditionalExistingTags(NodeProfile? newProfile, OsmNode originalNode) {
final additionalTags = <String, String>{};
@@ -225,6 +215,7 @@ class SessionState extends ChangeNotifier {
Map<String, String>? refinedTags,
Map<String, String>? additionalExistingTags,
String? changesetComment,
bool updateOperatorProfile = false,
}) {
if (_session == null) return;
@@ -242,7 +233,8 @@ class SessionState extends ChangeNotifier {
);
dirty = true;
}
if (operatorProfile != _session!.operatorProfile) {
// Only update operator profile when explicitly requested
if (updateOperatorProfile && operatorProfile != _session!.operatorProfile) {
_session!.operatorProfile = operatorProfile;
dirty = true;
}
@@ -274,6 +266,7 @@ class SessionState extends ChangeNotifier {
Map<String, String>? refinedTags,
Map<String, String>? additionalExistingTags,
String? changesetComment,
bool updateOperatorProfile = false,
}) {
if (_editSession == null) return;
@@ -292,9 +285,9 @@ class SessionState extends ChangeNotifier {
// Handle direction requirements when profile changes
_handleDirectionRequirementsOnProfileChange(oldProfile, profile);
// When profile changes but operator profile not explicitly provided,
// When profile changes and operator profile not being explicitly updated,
// restore the detected operator profile (if any)
if (operatorProfile == null && _detectedOperatorProfile != null) {
if (!updateOperatorProfile && _detectedOperatorProfile != null) {
_editSession!.operatorProfile = _detectedOperatorProfile;
}
@@ -319,8 +312,8 @@ class SessionState extends ChangeNotifier {
dirty = true;
}
// Only update operator profile if explicitly provided (including null) and different from current
if (operatorProfile != _editSession!.operatorProfile) {
// Only update operator profile when explicitly requested
if (updateOperatorProfile && operatorProfile != _editSession!.operatorProfile) {
_editSession!.operatorProfile = operatorProfile; // This can be null
dirty = true;
}
@@ -479,7 +472,7 @@ class SessionState extends ChangeNotifier {
}
/// Generate a default changeset comment for a submission
/// Handles special case of <Existing tags> profile by using "a" instead
/// Handles special case of `<Existing tags>` profile by using "a" instead
String _generateDefaultChangesetComment({
required NodeProfile? profile,
required UploadOperation operation,

View File

@@ -29,23 +29,23 @@ 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
@@ -73,9 +73,7 @@ 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
@@ -112,7 +110,7 @@ class UploadQueueState extends ChangeNotifier {
if (nodesToAdd.isNotEmpty) {
_nodeCache.addOrUpdate(nodesToAdd);
print('[UploadQueue] Repopulated cache with ${nodesToAdd.length} pending nodes from queue');
debugPrint('[UploadQueue] Repopulated cache with ${nodesToAdd.length} pending nodes from queue');
// Save queue if we updated any temp IDs for backward compatibility
_saveQueue();
@@ -587,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
}
}
@@ -646,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
}

View File

@@ -7,7 +7,6 @@ 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/map_data_provider.dart';
import '../services/node_data_manager.dart';
@@ -59,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(() {
@@ -107,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>(
@@ -115,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);
}
@@ -402,11 +386,11 @@ 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);
}
@@ -442,11 +426,11 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
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(
@@ -464,6 +448,7 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
operatorProfile: result.operatorProfile,
refinedTags: result.refinedTags,
changesetComment: result.changesetComment,
updateOperatorProfile: true,
);
}
}
@@ -578,7 +563,7 @@ 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(locService.t('addNode.refineTags')),
),
@@ -591,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

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

@@ -7,14 +7,11 @@ 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/map_data_provider.dart';
import '../services/node_data_manager.dart';
import '../services/changelog_service.dart';
import '../state/settings_state.dart';
import '../state/session_state.dart';
import 'refine_tags_sheet.dart';
import 'advanced_edit_options_sheet.dart';
import 'proximity_warning_dialog.dart';
@@ -95,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>(
@@ -103,13 +101,14 @@ 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);
}
@@ -435,25 +434,23 @@ 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;
@@ -481,11 +478,11 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
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(
@@ -506,6 +503,7 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
refinedTags: result.refinedTags,
additionalExistingTags: result.additionalExistingTags,
changesetComment: result.changesetComment,
updateOperatorProfile: true,
);
}
}
@@ -694,7 +692,7 @@ 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(locService.t('editNode.refineTags')),
),
@@ -707,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')),
),
),

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 {
@@ -113,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,
@@ -141,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,
@@ -231,7 +203,7 @@ class DirectionConesBuilder {
return Polygon(
points: points,
color: kDirectionConeColor.withOpacity(opacity),
color: kDirectionConeColor.withValues(alpha: opacity),
borderColor: kDirectionConeColor,
borderStrokeWidth: getDirectionConeBorderWidth(context),
);
@@ -279,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

@@ -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,7 +1,6 @@
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;

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 '../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';
@@ -164,15 +160,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 [];

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';
@@ -182,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,
@@ -212,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: [

View File

@@ -1,12 +1,9 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
import '../services/map_data_provider.dart';
import '../services/node_data_manager.dart';
import '../services/node_spatial_cache.dart';
import '../services/network_status.dart';
import '../models/node_profile.dart';
import '../models/osm_node.dart';
import '../app_state.dart';

View File

@@ -40,7 +40,7 @@ class NodeTagSheet extends StatelessWidget {
final isRealOSMNode = !node.tags.containsKey('_pending_upload') &&
node.id > 0; // Real OSM nodes have positive IDs
void _openEditSheet() {
void openEditSheet() {
// Check if node limit is active and warn user
if (isNodeLimitActive) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -64,7 +64,7 @@ class NodeTagSheet extends StatelessWidget {
}
}
void _deleteNode() async {
void deleteNode() async {
final shouldDelete = await showDialog<bool>(
context: context,
builder: (BuildContext context) {
@@ -95,7 +95,7 @@ class NodeTagSheet extends StatelessWidget {
}
}
void _viewOnOSM() async {
void viewOnOSM() async {
final url = 'https://www.openstreetmap.org/node/${node.id}';
try {
final uri = Uri.parse(url);
@@ -117,7 +117,7 @@ class NodeTagSheet extends StatelessWidget {
}
}
void _openAdvancedEdit() {
void openAdvancedEdit() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
@@ -177,7 +177,7 @@ class NodeTagSheet extends StatelessWidget {
},
text: e.value,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
),
linkStyle: TextStyle(
color: Theme.of(context).colorScheme.primary,
@@ -201,7 +201,7 @@ class NodeTagSheet extends StatelessWidget {
children: [
if (isRealOSMNode) ...[
TextButton.icon(
onPressed: () => _viewOnOSM(),
onPressed: () => viewOnOSM(),
icon: const Icon(Icons.open_in_new, size: 16),
label: Text(locService.t('actions.viewOnOSM')),
),
@@ -209,7 +209,7 @@ class NodeTagSheet extends StatelessWidget {
],
if (isEditable) ...[
OutlinedButton.icon(
onPressed: _openAdvancedEdit,
onPressed: openAdvancedEdit,
icon: const Icon(Icons.open_in_new, size: 18),
label: Text(locService.t('actions.advanced')),
style: OutlinedButton.styleFrom(
@@ -226,7 +226,7 @@ class NodeTagSheet extends StatelessWidget {
children: [
if (isEditable) ...[
ElevatedButton.icon(
onPressed: _openEditSheet,
onPressed: openEditSheet,
icon: const Icon(Icons.edit, size: 18),
label: Text(locService.edit),
style: ElevatedButton.styleFrom(
@@ -235,7 +235,7 @@ class NodeTagSheet extends StatelessWidget {
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: node.isConstrained ? null : _deleteNode,
onPressed: node.isConstrained ? null : deleteNode,
icon: const Icon(Icons.delete, size: 18),
label: Text(locService.t('actions.delete')),
style: ElevatedButton.styleFrom(

View File

@@ -25,33 +25,26 @@ class NSITagValueField extends StatefulWidget {
class _NSITagValueFieldState extends State<NSITagValueField> {
late TextEditingController _controller;
List<String> _suggestions = [];
bool _showingSuggestions = false;
final LayerLink _layerLink = LayerLink();
late OverlayEntry _overlayEntry;
final FocusNode _focusNode = FocusNode();
List<String> _suggestions = [];
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.initialValue);
_loadSuggestions();
_focusNode.addListener(_onFocusChanged);
_controller.addListener(_onTextChanged);
}
@override
void didUpdateWidget(NSITagValueField oldWidget) {
super.didUpdateWidget(oldWidget);
// If the tag key changed, reload suggestions
if (oldWidget.tagKey != widget.tagKey) {
_hideSuggestions(); // Hide old suggestions immediately
_suggestions.clear();
_loadSuggestions(); // Load new suggestions for new key
_loadSuggestions();
}
// If the initial value changed, update the controller
if (oldWidget.initialValue != widget.initialValue) {
_controller.text = widget.initialValue;
@@ -62,38 +55,17 @@ class _NSITagValueFieldState extends State<NSITagValueField> {
void dispose() {
_controller.dispose();
_focusNode.dispose();
_hideSuggestions();
super.dispose();
}
/// Get filtered suggestions based on current text input (case-sensitive)
List<String> _getFilteredSuggestions() {
final currentText = _controller.text;
if (currentText.isEmpty) {
return _suggestions;
}
return _suggestions
.where((suggestion) => suggestion.contains(currentText))
.toList();
}
/// Handle text changes to update suggestion filtering
void _onTextChanged() {
if (_showingSuggestions) {
// Update the overlay with filtered suggestions
_updateSuggestionsOverlay();
}
}
void _loadSuggestions() async {
if (widget.tagKey.trim().isEmpty) return;
try {
final suggestions = await NSIService().getAllSuggestions(widget.tagKey);
if (mounted) {
setState(() {
_suggestions = suggestions.take(10).toList(); // Limit to 10 suggestions
_suggestions = suggestions;
});
}
} catch (e) {
@@ -106,52 +78,78 @@ class _NSITagValueFieldState extends State<NSITagValueField> {
}
}
void _onFocusChanged() {
final filteredSuggestions = _getFilteredSuggestions();
if (_focusNode.hasFocus && filteredSuggestions.isNotEmpty && !widget.readOnly) {
_showSuggestions();
} else {
_hideSuggestions();
}
InputDecoration _buildDecoration({required bool showDropdownIcon}) {
return InputDecoration(
hintText: widget.hintText,
border: const OutlineInputBorder(),
isDense: true,
suffixIcon: showDropdownIcon
? Icon(
Icons.arrow_drop_down,
color: _focusNode.hasFocus
? Theme.of(context).primaryColor
: Colors.grey,
)
: null,
);
}
void _showSuggestions() {
final filteredSuggestions = _getFilteredSuggestions();
if (_showingSuggestions || filteredSuggestions.isEmpty) return;
_overlayEntry = _buildSuggestionsOverlay(filteredSuggestions);
Overlay.of(context).insert(_overlayEntry);
setState(() {
_showingSuggestions = true;
});
}
/// Update the suggestions overlay with current filtered suggestions
void _updateSuggestionsOverlay() {
final filteredSuggestions = _getFilteredSuggestions();
if (filteredSuggestions.isEmpty) {
_hideSuggestions();
return;
@override
Widget build(BuildContext context) {
if (widget.readOnly) {
return TextField(
controller: _controller,
focusNode: _focusNode,
readOnly: true,
decoration: _buildDecoration(showDropdownIcon: false),
);
}
if (_showingSuggestions) {
// Remove current overlay and create new one with filtered suggestions
_overlayEntry.remove();
_overlayEntry = _buildSuggestionsOverlay(filteredSuggestions);
Overlay.of(context).insert(_overlayEntry);
}
}
/// Build the suggestions overlay with the given suggestions list
OverlayEntry _buildSuggestionsOverlay(List<String> suggestions) {
return OverlayEntry(
builder: (context) => Positioned(
width: 250, // Slightly wider to fit more content in refine tags
child: CompositedTransformFollower(
link: _layerLink,
showWhenUnlinked: false,
offset: const Offset(0.0, 35.0), // Below the text field
return RawAutocomplete<String>(
textEditingController: _controller,
focusNode: _focusNode,
optionsBuilder: (TextEditingValue textEditingValue) {
if (_suggestions.isEmpty) return const Iterable<String>.empty();
if (textEditingValue.text.isEmpty) return _suggestions;
return _suggestions
.where((s) => s.contains(textEditingValue.text));
},
onSelected: (String selection) {
widget.onChanged(selection);
},
fieldViewBuilder: (
BuildContext context,
TextEditingController controller,
FocusNode focusNode,
VoidCallback onFieldSubmitted,
) {
return TextField(
controller: controller,
focusNode: focusNode,
decoration: _buildDecoration(
showDropdownIcon: _suggestions.isNotEmpty,
),
onChanged: (value) {
widget.onChanged(value);
},
onSubmitted: (_) {
// Only auto-complete when there's text to match against.
// Otherwise, pressing Done on an empty field would auto-select
// the first suggestion, preventing users from clearing values.
if (controller.text.isNotEmpty) {
onFieldSubmitted();
}
},
);
},
optionsViewBuilder: (
BuildContext context,
AutocompleteOnSelected<String> onSelected,
Iterable<String> options,
) {
final optionList = options.toList(growable: false);
return Align(
alignment: Alignment.topLeft,
child: Material(
elevation: 4.0,
borderRadius: BorderRadius.circular(8.0),
@@ -160,68 +158,20 @@ class _NSITagValueFieldState extends State<NSITagValueField> {
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
itemCount: suggestions.length,
itemCount: optionList.length,
itemBuilder: (context, index) {
final suggestion = suggestions[index];
final option = optionList[index];
return ListTile(
dense: true,
title: Text(suggestion, style: const TextStyle(fontSize: 14)),
onTap: () => _selectSuggestion(suggestion),
title: Text(option, style: const TextStyle(fontSize: 14)),
onTap: () => onSelected(option),
);
},
),
),
),
),
),
);
},
);
}
void _hideSuggestions() {
if (!_showingSuggestions) return;
_overlayEntry.remove();
setState(() {
_showingSuggestions = false;
});
}
void _selectSuggestion(String suggestion) {
_controller.text = suggestion;
widget.onChanged(suggestion);
_hideSuggestions();
}
@override
Widget build(BuildContext context) {
final filteredSuggestions = _getFilteredSuggestions();
return CompositedTransformTarget(
link: _layerLink,
child: TextField(
controller: _controller,
focusNode: _focusNode,
readOnly: widget.readOnly,
decoration: InputDecoration(
hintText: widget.hintText,
border: const OutlineInputBorder(),
isDense: true,
suffixIcon: _suggestions.isNotEmpty && !widget.readOnly
? Icon(
Icons.arrow_drop_down,
color: _showingSuggestions ? Theme.of(context).primaryColor : Colors.grey,
)
: null,
),
onChanged: widget.readOnly ? null : (value) {
widget.onChanged(value);
},
onTap: () {
if (!widget.readOnly && filteredSuggestions.isNotEmpty) {
_showSuggestions();
}
},
),
);
}
}
}

View File

@@ -8,15 +8,15 @@ class NuclearResetDialog extends StatelessWidget {
final String errorReport;
const NuclearResetDialog({
Key? key,
super.key,
required this.errorReport,
}) : super(key: key);
});
@override
Widget build(BuildContext context) {
return WillPopScope(
return PopScope(
// Prevent back button from closing dialog
onWillPop: () async => false,
canPop: false,
child: AlertDialog(
title: const Row(
children: [
@@ -96,7 +96,8 @@ class NuclearResetDialog extends StatelessWidget {
// Clear all app data
await NuclearResetService.clearEverything();
if (!context.mounted) return;
// Show non-dismissible dialog
await showDialog(
context: context,

View File

@@ -30,7 +30,7 @@ class PositioningTutorialOverlay extends StatelessWidget {
),
child: Container(
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.3), // Semi-transparent overlay
color: Colors.black.withValues(alpha: 0.3), // Semi-transparent overlay
),
child: Center(
child: Padding(
@@ -73,7 +73,7 @@ class PositioningTutorialOverlay extends StatelessWidget {
Text(
locService.t('positioningTutorial.hint'),
style: TextStyle(
color: Colors.white.withOpacity(0.8),
color: Colors.white.withValues(alpha: 0.8),
fontSize: 14,
fontStyle: FontStyle.italic,
),

View File

@@ -43,7 +43,7 @@ class LocationPin extends StatelessWidget {
width: size * 0.4,
height: size * 0.2,
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.3),
color: Colors.black.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(size * 0.1),
),
),
@@ -64,7 +64,7 @@ class LocationPin extends StatelessWidget {
color: Colors.white,
shape: BoxShape.circle,
border: Border.all(
color: _pinColor.withOpacity(0.8),
color: _pinColor.withValues(alpha: 0.8),
width: 1.5,
),
),

View File

@@ -82,7 +82,7 @@ class _ProximityAlertBannerState extends State<ProximityAlertBanner>
color: Colors.red.shade600,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 10,
offset: const Offset(0, 2),
),

View File

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

View File

@@ -38,7 +38,7 @@ class ReauthMessagesDialog extends StatelessWidget {
Container(
padding: const EdgeInsets.all(12.0),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3),
color: Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(8.0),
),
child: Row(

View File

@@ -248,34 +248,32 @@ class _RefineTagsSheetState extends State<RefineTagsSheet> {
)
else ...[
Card(
child: Column(
children: [
// Show existing operator profile first if it exists
if (hasExistingOperatorProfile) ...[
child: RadioGroup<OperatorProfile?>(
groupValue: _selectedOperatorProfile,
onChanged: (value) => setState(() => _selectedOperatorProfile = value),
child: Column(
children: [
// Show existing operator profile first if it exists
if (hasExistingOperatorProfile) ...[
RadioListTile<OperatorProfile?>(
title: Text(locService.t('refineTagsSheet.existingOperator')),
subtitle: Text('${widget.selectedOperatorProfile!.tags.length} ${locService.t('refineTagsSheet.existingOperatorTags')}'),
value: widget.selectedOperatorProfile,
),
const Divider(height: 1),
],
RadioListTile<OperatorProfile?>(
title: Text(locService.t('refineTagsSheet.existingOperator')),
subtitle: Text('${widget.selectedOperatorProfile!.tags.length} ${locService.t('refineTagsSheet.existingOperatorTags')}'),
value: widget.selectedOperatorProfile,
groupValue: _selectedOperatorProfile,
onChanged: (value) => setState(() => _selectedOperatorProfile = value),
title: Text(locService.t('refineTagsSheet.none')),
subtitle: Text(locService.t('refineTagsSheet.noAdditionalOperatorTags')),
value: null,
),
const Divider(height: 1),
...operatorProfiles.map((profile) => RadioListTile<OperatorProfile?>(
title: Text(profile.name),
subtitle: Text('${profile.tags.length} ${locService.t('refineTagsSheet.additionalTags')}'),
value: profile,
)),
],
RadioListTile<OperatorProfile?>(
title: Text(locService.t('refineTagsSheet.none')),
subtitle: Text(locService.t('refineTagsSheet.noAdditionalOperatorTags')),
value: null,
groupValue: _selectedOperatorProfile,
onChanged: (value) => setState(() => _selectedOperatorProfile = value),
),
...operatorProfiles.map((profile) => RadioListTile<OperatorProfile?>(
title: Text(profile.name),
subtitle: Text('${profile.tags.length} ${locService.t('refineTagsSheet.additionalTags')}'),
value: profile,
groupValue: _selectedOperatorProfile,
onChanged: (value) => setState(() => _selectedOperatorProfile = value),
)),
],
),
),
),
const SizedBox(height: 16),

View File

@@ -107,7 +107,7 @@ class _LocationSearchBarState extends State<LocationSearchBar> {
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(12)),
boxShadow: [
BoxShadow(
color: Theme.of(context).shadowColor.withOpacity(0.2),
color: Theme.of(context).shadowColor.withValues(alpha: 0.2),
blurRadius: 8,
offset: const Offset(0, 2),
),
@@ -152,7 +152,7 @@ class _LocationSearchBarState extends State<LocationSearchBar> {
: null,
dense: true,
onTap: () => _onResultTap(result),
)).toList(),
)),
],
),
);
@@ -184,7 +184,7 @@ class _LocationSearchBarState extends State<LocationSearchBar> {
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Theme.of(context).shadowColor.withOpacity(0.2),
color: Theme.of(context).shadowColor.withValues(alpha: 0.2),
blurRadius: 8,
offset: const Offset(0, 2),
),

View File

@@ -81,9 +81,9 @@ class _SubmissionGuideDialogState extends State<SubmissionGuideDialog> {
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.1),
color: Colors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.withOpacity(0.3)),
border: Border.all(color: Colors.blue.withValues(alpha: 0.3)),
),
child: Text(
locService.t('submissionGuide.bestPractices'),
@@ -171,10 +171,10 @@ class _SubmissionGuideDialogState extends State<SubmissionGuideDialog> {
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: Theme.of(context).colorScheme.primary.withOpacity(0.3),
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.3),
),
),
child: Text(

View File

@@ -1,9 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../models/suspected_location.dart';
import '../app_state.dart';
import '../services/localization_service.dart';
import '../dev_config.dart';
@@ -17,7 +15,6 @@ class SuspectedLocationSheet extends StatelessWidget {
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final appState = context.watch<AppState>();
final locService = LocalizationService.instance;
// Get all fields except location and ticket_no
@@ -97,7 +94,7 @@ class SuspectedLocationSheet extends StatelessWidget {
: Text(
e.value,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
),
softWrap: true,
),
@@ -131,7 +128,7 @@ class SuspectedLocationSheet extends StatelessWidget {
child: Text(
'${location.centroid.latitude.toStringAsFixed(6)}, ${location.centroid.longitude.toStringAsFixed(6)}',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
),
softWrap: true,
),

View File

@@ -88,9 +88,9 @@ class _WelcomeDialogState extends State<WelcomeDialog> {
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: Text(
locService.t('welcome.firsthandKnowledge'),
@@ -171,10 +171,10 @@ class _WelcomeDialogState extends State<WelcomeDialog> {
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: Theme.of(context).colorScheme.primary.withOpacity(0.3),
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.3),
),
),
child: Text(

View File

@@ -65,6 +65,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.13.0"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
characters:
dependency: transitive
description:
@@ -98,7 +106,7 @@ packages:
source: hosted
version: "1.1.2"
collection:
dependency: transitive
dependency: "direct main"
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
@@ -161,6 +169,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.2.3"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
@@ -206,6 +222,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_local_notifications:
dependency: "direct main"
description:
@@ -310,6 +334,11 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_auth_2:
dependency: "direct main"
description:
@@ -443,6 +472,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.9.1"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
linkify:
dependency: transitive
description:
@@ -451,6 +504,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.0.0"
lints:
dependency: transitive
description:
name: lints
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
lists:
dependency: transitive
description:
@@ -467,6 +528,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.6.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev"
source: hosted
version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
@@ -479,10 +548,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.16.0"
version: "1.17.0"
mgrs_dart:
dependency: transitive
description:
@@ -491,6 +560,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.0"
mocktail:
dependency: "direct dev"
description:
name: mocktail
sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8"
url: "https://pub.dev"
source: hosted
version: "1.0.4"
nested:
dependency: transitive
description:
@@ -540,7 +617,7 @@ packages:
source: hosted
version: "1.1.0"
path_provider:
dependency: transitive
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
@@ -760,6 +837,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.0"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
@@ -784,6 +877,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
version: "0.7.7"
timezone:
dependency: transitive
description:
@@ -916,10 +1017,18 @@ packages:
dependency: transitive
description:
name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.1.4"
version: "2.2.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
url: "https://pub.dev"
source: hosted
version: "15.0.2"
web:
dependency: transitive
description:

View File

@@ -1,10 +1,10 @@
name: deflockapp
description: Map public surveillance infrastructure with OpenStreetMap
publish_to: "none"
version: 2.6.4+47 # The thing after the + is the version code, incremented with each release
version: 2.7.0+47 # The thing after the + is the version code, incremented with each release
environment:
sdk: ">=3.5.0 <4.0.0" # oauth2_client 4.x needs Dart 3.5+
sdk: ">=3.8.0 <4.0.0" # RadioGroup widget requires Dart 3.8+ (Flutter 3.35+)
dependencies:
flutter:
@@ -33,12 +33,18 @@ dependencies:
shared_preferences: ^2.2.2
sqflite: ^2.4.1
path: ^1.8.3
path_provider: ^2.1.0
uuid: ^4.0.0
package_info_plus: ^8.0.0
csv: ^6.0.0
collection: ^1.18.0
dev_dependencies:
flutter_test:
sdk: flutter
mocktail: ^1.0.4
flutter_launcher_icons: ^0.14.4
flutter_lints: ^6.0.0
flutter_native_splash: ^2.4.6
flutter:

View File

@@ -1,154 +0,0 @@
#!/usr/bin/env dart
import 'dart:convert';
import 'dart:io';
const String localizationsDir = 'lib/localizations';
const String referenceFile = 'en.json';
void main() async {
print('🌍 Validating localization files...\n');
try {
final result = await validateLocalizations();
if (result) {
print('✅ All localization files are valid!');
exit(0);
} else {
print('❌ Localization validation failed!');
exit(1);
}
} catch (e) {
print('💥 Error during validation: $e');
exit(1);
}
}
Future<bool> validateLocalizations() async {
// Get all JSON files in localizations directory
final locDir = Directory(localizationsDir);
if (!locDir.existsSync()) {
print('❌ Localizations directory not found: $localizationsDir');
return false;
}
final jsonFiles = locDir
.listSync()
.where((file) => file.path.endsWith('.json'))
.map((file) => file.path.split('/').last)
.toList();
if (jsonFiles.isEmpty) {
print('❌ No JSON localization files found');
return false;
}
print('📁 Found ${jsonFiles.length} localization files:');
for (final file in jsonFiles) {
print('$file');
}
print('');
// Load reference file (English)
final refFile = File('$localizationsDir/$referenceFile');
if (!refFile.existsSync()) {
print('❌ Reference file not found: $referenceFile');
return false;
}
Map<String, dynamic> referenceData;
try {
final refContent = await refFile.readAsString();
referenceData = json.decode(refContent) as Map<String, dynamic>;
} catch (e) {
print('❌ Failed to parse reference file $referenceFile: $e');
return false;
}
final referenceKeys = _extractAllKeys(referenceData);
print('🔑 Reference file ($referenceFile) has ${referenceKeys.length} keys');
bool allValid = true;
// Validate each localization file
for (final fileName in jsonFiles) {
if (fileName == referenceFile) continue; // Skip reference file
print('\n🔍 Validating $fileName...');
final file = File('$localizationsDir/$fileName');
Map<String, dynamic> fileData;
try {
final content = await file.readAsString();
fileData = json.decode(content) as Map<String, dynamic>;
} catch (e) {
print(' ❌ Failed to parse $fileName: $e');
allValid = false;
continue;
}
final fileKeys = _extractAllKeys(fileData);
final validation = _validateKeys(referenceKeys, fileKeys, fileName);
if (validation.isValid) {
print(' ✅ Structure matches reference (${fileKeys.length} keys)');
} else {
print(' ❌ Structure validation failed:');
for (final error in validation.errors) {
print('$error');
}
allValid = false;
}
}
return allValid;
}
/// Extract all nested keys from a JSON object using dot notation
/// Example: {"user": {"name": "John"}} -> ["user.name"]
Set<String> _extractAllKeys(Map<String, dynamic> data, {String prefix = ''}) {
final keys = <String>{};
for (final entry in data.entries) {
final key = prefix.isEmpty ? entry.key : '$prefix.${entry.key}';
if (entry.value is Map<String, dynamic>) {
// Recurse into nested objects
keys.addAll(_extractAllKeys(entry.value as Map<String, dynamic>, prefix: key));
} else {
// Add leaf key
keys.add(key);
}
}
return keys;
}
class ValidationResult {
final bool isValid;
final List<String> errors;
ValidationResult({required this.isValid, required this.errors});
}
ValidationResult _validateKeys(Set<String> referenceKeys, Set<String> fileKeys, String fileName) {
final errors = <String>[];
// Find missing keys
final missingKeys = referenceKeys.difference(fileKeys);
if (missingKeys.isNotEmpty) {
errors.add('Missing ${missingKeys.length} keys: ${missingKeys.take(5).join(', ')}${missingKeys.length > 5 ? '...' : ''}');
}
// Find extra keys
final extraKeys = fileKeys.difference(referenceKeys);
if (extraKeys.isNotEmpty) {
errors.add('Extra ${extraKeys.length} keys not in reference: ${extraKeys.take(5).join(', ')}${extraKeys.length > 5 ? '...' : ''}');
}
return ValidationResult(
isValid: errors.isEmpty,
errors: errors,
);
}

View File

@@ -0,0 +1,76 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:deflockapp/models/node_profile.dart';
void main() {
group('NodeProfile', () {
test('toJson/fromJson round-trip preserves all fields', () {
final profile = NodeProfile(
id: 'test-id',
name: 'Test Profile',
tags: const {'man_made': 'surveillance', 'camera:type': 'fixed'},
builtin: true,
requiresDirection: false,
submittable: true,
editable: false,
fov: 90.0,
);
final json = profile.toJson();
final restored = NodeProfile.fromJson(json);
expect(restored.id, equals(profile.id));
expect(restored.name, equals(profile.name));
expect(restored.tags, equals(profile.tags));
expect(restored.builtin, equals(profile.builtin));
expect(restored.requiresDirection, equals(profile.requiresDirection));
expect(restored.submittable, equals(profile.submittable));
expect(restored.editable, equals(profile.editable));
expect(restored.fov, equals(profile.fov));
});
test('getDefaults returns expected profiles', () {
final defaults = NodeProfile.getDefaults();
expect(defaults.length, greaterThanOrEqualTo(10));
final ids = defaults.map((p) => p.id).toSet();
expect(ids, contains('builtin-flock'));
expect(ids, contains('builtin-generic-alpr'));
expect(ids, contains('builtin-motorola'));
expect(ids, contains('builtin-shotspotter'));
});
test('empty tag values exist in default profiles', () {
// Documents that profiles like builtin-flock ship with camera:mount: ''
// This is the root cause of the HTTP 400 bug — the routing service must
// filter these out before sending to the API.
final defaults = NodeProfile.getDefaults();
final flock = defaults.firstWhere((p) => p.id == 'builtin-flock');
expect(flock.tags.containsKey('camera:mount'), isTrue);
expect(flock.tags['camera:mount'], equals(''));
});
test('equality is based on id', () {
final a = NodeProfile(
id: 'same-id',
name: 'Profile A',
tags: const {'tag': 'a'},
);
final b = NodeProfile(
id: 'same-id',
name: 'Profile B',
tags: const {'tag': 'b'},
);
final c = NodeProfile(
id: 'different-id',
name: 'Profile A',
tags: const {'tag': 'a'},
);
expect(a, equals(b));
expect(a.hashCode, equals(b.hashCode));
expect(a, isNot(equals(c)));
});
});
}

View File

@@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flock_map_app/models/tile_provider.dart';
import 'package:deflockapp/models/tile_provider.dart';
void main() {
group('TileType', () {
@@ -31,16 +31,16 @@ void main() {
);
// Test 0-3 range
final url_0_3_a = tileType0_3.getTileUrl(1, 0, 0);
final url_0_3_b = tileType0_3.getTileUrl(1, 3, 0);
expect(url_0_3_a, contains('s0.example.com'));
expect(url_0_3_b, contains('s3.example.com'));
final url03A = tileType0_3.getTileUrl(1, 0, 0);
final url03B = tileType0_3.getTileUrl(1, 3, 0);
expect(url03A, contains('s0.example.com'));
expect(url03B, contains('s3.example.com'));
// Test 1-4 range
final url_1_4_a = tileType1_4.getTileUrl(1, 0, 0);
final url_1_4_b = tileType1_4.getTileUrl(1, 3, 0);
expect(url_1_4_a, contains('s1.example.com'));
expect(url_1_4_b, contains('s4.example.com'));
final url14A = tileType1_4.getTileUrl(1, 0, 0);
final url14B = tileType1_4.getTileUrl(1, 3, 0);
expect(url14A, contains('s1.example.com'));
expect(url14B, contains('s4.example.com'));
// Test consistency
final url1 = tileType0_3.getTileUrl(1, 2, 3);
@@ -127,10 +127,17 @@ void main() {
expect(satelliteType.attribution, '© Microsoft Corporation');
});
test('all default providers are usable', () {
test('providers without API key requirements are usable', () {
final providers = DefaultTileProviders.createDefaults();
for (final provider in providers) {
expect(provider.isUsable, isTrue, reason: '${provider.name} should be usable');
final needsKey = provider.tileTypes.any((t) => t.requiresApiKey);
if (needsKey) {
expect(provider.isUsable, isFalse,
reason: '${provider.name} requires API key and has none set');
} else {
expect(provider.isUsable, isTrue,
reason: '${provider.name} should be usable without API key');
}
}
});
});

View File

@@ -1,40 +1,55 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:mocktail/mocktail.dart';
import '../../lib/services/deflock_tile_provider.dart';
import '../../lib/services/map_data_provider.dart';
import 'package:deflockapp/app_state.dart';
import 'package:deflockapp/services/deflock_tile_provider.dart';
import 'package:deflockapp/services/map_data_provider.dart';
class MockAppState extends Mock implements AppState {}
void main() {
group('DeflockTileProvider', () {
late DeflockTileProvider provider;
late MockAppState mockAppState;
setUp(() {
provider = DeflockTileProvider();
mockAppState = MockAppState();
when(() => mockAppState.selectedTileProvider).thenReturn(null);
when(() => mockAppState.selectedTileType).thenReturn(null);
AppState.instance = mockAppState;
});
tearDown(() {
// Reset to a clean mock so stubbed state doesn't leak to other tests
AppState.instance = MockAppState();
});
test('creates image provider for tile coordinates', () {
const coordinates = TileCoordinates(0, 0, 0);
const options = TileLayer(
final options = TileLayer(
urlTemplate: 'test/{z}/{x}/{y}',
);
final imageProvider = provider.getImage(coordinates, options);
expect(imageProvider, isA<DeflockTileImageProvider>());
expect((imageProvider as DeflockTileImageProvider).coordinates, equals(coordinates));
expect((imageProvider as DeflockTileImageProvider).coordinates,
equals(coordinates));
});
});
group('DeflockTileImageProvider', () {
test('generates consistent keys for same coordinates', () {
const coordinates1 = TileCoordinates(1, 2, 3);
const coordinates2 = TileCoordinates(1, 2, 3);
const coordinates3 = TileCoordinates(1, 2, 4);
const options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}');
final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}');
final mapDataProvider = MapDataProvider();
final provider1 = DeflockTileImageProvider(
coordinates: coordinates1,
options: options,
@@ -56,20 +71,20 @@ void main() {
providerId: 'test_provider',
tileTypeId: 'test_type',
);
// Same coordinates should be equal
expect(provider1, equals(provider2));
expect(provider1.hashCode, equals(provider2.hashCode));
// Different coordinates should not be equal
expect(provider1, isNot(equals(provider3)));
});
test('generates different keys for different providers/types', () {
const coordinates = TileCoordinates(1, 2, 3);
const options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}');
final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}');
final mapDataProvider = MapDataProvider();
final provider1 = DeflockTileImageProvider(
coordinates: coordinates,
options: options,
@@ -91,14 +106,14 @@ void main() {
providerId: 'provider_a',
tileTypeId: 'type_2',
);
// Different providers should not be equal (even with same coordinates)
expect(provider1, isNot(equals(provider2)));
expect(provider1.hashCode, isNot(equals(provider2.hashCode)));
// Different tile types should not be equal (even with same coordinates and provider)
expect(provider1, isNot(equals(provider3)));
expect(provider1.hashCode, isNot(equals(provider3.hashCode)));
});
});
}
}

View File

@@ -0,0 +1,176 @@
import 'dart:convert';
import 'dart:io';
import 'package:deflockapp/services/localization_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
const String localizationsDir = 'lib/localizations';
/// Recursively extract all dot-notation leaf keys from a JSON map.
Set<String> extractLeafKeys(Map<String, dynamic> data, {String prefix = ''}) {
final keys = <String>{};
for (final entry in data.entries) {
final key = prefix.isEmpty ? entry.key : '$prefix.${entry.key}';
if (entry.value is Map<String, dynamic>) {
keys.addAll(
extractLeafKeys(entry.value as Map<String, dynamic>, prefix: key),
);
} else {
keys.add(key);
}
}
return keys;
}
void main() {
// ── Group 1: Localization file integrity ──────────────────────────────
group('Localization file integrity', () {
late Directory locDir;
late List<File> jsonFiles;
setUpAll(() {
locDir = Directory(localizationsDir);
if (locDir.existsSync()) {
jsonFiles = locDir
.listSync()
.whereType<File>()
.where((f) => f.path.endsWith('.json'))
.toList();
} else {
jsonFiles = <File>[];
}
});
test('localization directory exists and contains JSON files', () {
expect(locDir.existsSync(), isTrue);
expect(jsonFiles, isNotEmpty);
});
test('en.json exists (required fallback language)', () {
final enFile = File('$localizationsDir/en.json');
expect(enFile.existsSync(), isTrue);
});
test('every JSON file is valid JSON with a language.name key', () {
for (final file in jsonFiles) {
final name = p.basename(file.path);
final content = file.readAsStringSync();
final Map<String, dynamic> data;
try {
data = json.decode(content) as Map<String, dynamic>;
} catch (e) {
fail('$name is not valid JSON: $e');
}
expect(
data['language'],
isA<Map>(),
reason: '$name missing "language" object',
);
expect(
(data['language'] as Map)['name'],
isA<String>(),
reason: '$name missing "language.name" string',
);
}
});
test('file names are valid 2-3 letter language codes', () {
final codePattern = RegExp(r'^[a-z]{2,3}$');
for (final file in jsonFiles) {
final code = p.basenameWithoutExtension(file.path);
expect(
codePattern.hasMatch(code),
isTrue,
reason: '"$code" is not a valid 2-3 letter language code',
);
}
});
test('every locale file has exactly the same keys as en.json', () {
final enData = json.decode(
File('$localizationsDir/en.json').readAsStringSync(),
) as Map<String, dynamic>;
final referenceKeys = extractLeafKeys(enData);
for (final file in jsonFiles) {
final name = p.basename(file.path);
if (name == 'en.json') continue;
final data = json.decode(file.readAsStringSync())
as Map<String, dynamic>;
final fileKeys = extractLeafKeys(data);
final missing = referenceKeys.difference(fileKeys);
final extra = fileKeys.difference(referenceKeys);
expect(
missing,
isEmpty,
reason: '$name is missing keys: $missing',
);
expect(
extra,
isEmpty,
reason: '$name has extra keys not in en.json: $extra',
);
}
});
});
// ── Group 2: t() translation lookup ───────────────────────────────────
group('t() translation lookup', () {
late Map<String, dynamic> enData;
setUpAll(() {
enData = json.decode(
File('$localizationsDir/en.json').readAsStringSync(),
) as Map<String, dynamic>;
});
test('simple nested key lookup', () {
expect(
LocalizationService.lookup(enData, 'app.title'),
equals('DeFlock'),
);
});
test('deeper nested key lookup', () {
expect(
LocalizationService.lookup(enData, 'actions.cancel'),
equals('Cancel'),
);
});
test('missing key returns the key string as fallback', () {
expect(
LocalizationService.lookup(enData, 'this.key.does.not.exist'),
equals('this.key.does.not.exist'),
);
});
test('single {} parameter substitution', () {
expect(
LocalizationService.lookup(enData, 'node.title', params: ['42']),
equals('Node #42'),
);
});
test('multiple {} parameter substitution', () {
expect(
LocalizationService.lookup(enData, 'proximityAlerts.rangeInfo',
params: ['50', '500', 'm', '200']),
equals('Range: 50-500 m (default: 200)'),
);
});
test('partial path resolving to a Map returns the key as fallback', () {
expect(
LocalizationService.lookup(enData, 'actions'),
equals('actions'),
);
});
});
}

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