mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-02-13 01:03:03 +00:00
Compare commits
109 Commits
v1.1.0-rel
...
v1.4.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ac43b0c4e | ||
|
|
3174e0bfe1 | ||
|
|
5404daa704 | ||
|
|
20870623f0 | ||
|
|
8ed92dcd7e | ||
|
|
0143c74415 | ||
|
|
6c53d988de | ||
|
|
26cebcc60e | ||
|
|
7c2b9ea087 | ||
|
|
b2645f1341 | ||
|
|
05eedbb910 | ||
|
|
3ea6d6b2ff | ||
|
|
326b7ec523 | ||
|
|
192c6e5158 | ||
|
|
ac53f7f74e | ||
|
|
5b9810b9de | ||
|
|
49e9c673b1 | ||
|
|
fb8260d346 | ||
|
|
fee557330d | ||
|
|
4c0e3b822c | ||
|
|
181852766a | ||
|
|
f108929dce | ||
|
|
2cf840e74d | ||
|
|
3810dfa8d2 | ||
|
|
d57b2f64b1 | ||
|
|
e45f10e496 | ||
|
|
4ae0737016 | ||
|
|
ae93cff719 | ||
|
|
abdd494727 | ||
|
|
4ccf3cace3 | ||
|
|
ca049033e4 | ||
|
|
5cf8bb7725 | ||
|
|
e5ff4ac233 | ||
|
|
4040429865 | ||
|
|
90b7783aaf | ||
|
|
65cc6747bf | ||
|
|
5bd450eb14 | ||
|
|
b0a4128bb7 | ||
|
|
4cdbb9f404 | ||
|
|
8d05406ef5 | ||
|
|
7842848152 | ||
|
|
07ced1bc11 | ||
|
|
335eb33613 | ||
|
|
c9a7045212 | ||
|
|
e861d00b68 | ||
|
|
d9f415c527 | ||
|
|
1993714752 | ||
|
|
b80e1094af | ||
|
|
0db4c0f80d | ||
|
|
f1f145a35f | ||
|
|
618d31d016 | ||
|
|
c8ae925dc1 | ||
|
|
2a7004e5a2 | ||
|
|
c7f4164f12 | ||
|
|
27c404687a | ||
|
|
c8e2727f69 | ||
|
|
728ec08ab0 | ||
|
|
23a056bfe5 | ||
|
|
76d0ece314 | ||
|
|
cd2ab00042 | ||
|
|
ee3906df80 | ||
|
|
ca68bd6059 | ||
|
|
aea4ac1102 | ||
|
|
62cf70e36e | ||
|
|
de0bd7f275 | ||
|
|
2ccd01c691 | ||
|
|
d696e1dfb6 | ||
|
|
07fe869eec | ||
|
|
5176c62e72 | ||
|
|
3f35c2d6a1 | ||
|
|
60b826d00e | ||
|
|
ca63aa95e3 | ||
|
|
fe0f298c0e | ||
|
|
0ac158eb4a | ||
|
|
7eb680c677 | ||
|
|
a30dace404 | ||
|
|
50d2c6cbf6 | ||
|
|
925804e546 | ||
|
|
4076d9657a | ||
|
|
789930049a | ||
|
|
09019915e7 | ||
|
|
16e1927ff1 | ||
|
|
02e43f78c3 | ||
|
|
8a109029ca | ||
|
|
cd5315b919 | ||
|
|
03f3419f72 | ||
|
|
7ace123b4b | ||
|
|
08f017fb0f | ||
|
|
7a199a3258 | ||
|
|
8c999c04cd | ||
|
|
dc8dc9f11b | ||
|
|
93f0d9edae | ||
|
|
793e735452 | ||
|
|
6a2c1230d2 | ||
|
|
b8834cd256 | ||
|
|
b8b9d4b797 | ||
|
|
4b1111a0a3 | ||
|
|
f80f125599 | ||
|
|
afa0ff94b2 | ||
|
|
02f3cb0077 | ||
|
|
c671f29930 | ||
|
|
68068214bb | ||
|
|
b00db130d7 | ||
|
|
5c28057fa1 | ||
|
|
106277faf4 | ||
|
|
f9351ba272 | ||
|
|
4a44ab96d6 | ||
|
|
904af42cbf | ||
|
|
cc0386ee97 |
183
.github/workflows/workflow.yml
vendored
183
.github/workflows/workflow.yml
vendored
@@ -1,32 +1,40 @@
|
||||
name: Build Release
|
||||
name: Build and Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
get-version:
|
||||
name: Get Version
|
||||
name: Get Version and Release Info
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.set-version.outputs.version }}
|
||||
is_prerelease: ${{ steps.set-info.outputs.is_prerelease }}
|
||||
should_upload_to_stores: ${{ steps.set-info.outputs.should_upload_to_stores }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Get version from lib/dev_config.dart
|
||||
- name: Get version from pubspec.yaml
|
||||
id: set-version
|
||||
run: |
|
||||
echo version=$(grep "version:" pubspec.yaml | head -1 | cut -d ':' -f 2 | tr -d ' ') >> $GITHUB_OUTPUT
|
||||
echo version=$(grep "version:" pubspec.yaml | head -1 | cut -d ':' -f 2 | tr -d ' ' | cut -d '+' -f 1) >> $GITHUB_OUTPUT
|
||||
|
||||
# - name: Extract version from pubspec.yaml
|
||||
# id: extract_version
|
||||
# run: |
|
||||
# version=$(grep '^version: ' pubspec.yaml | cut -d ' ' -f 2 | tr -d '\r')
|
||||
# echo "VERSION=$version" >> $GITHUB_ENV
|
||||
- name: Determine release actions
|
||||
id: set-info
|
||||
run: |
|
||||
echo "is_prerelease=${{ github.event.release.prerelease }}" >> $GITHUB_OUTPUT
|
||||
|
||||
if [ "${{ github.event.release.prerelease }}" = "true" ]; then
|
||||
echo "should_upload_to_stores=false" >> $GITHUB_OUTPUT
|
||||
echo "✅ Pre-release - will build and attach assets, no store uploads"
|
||||
else
|
||||
echo "should_upload_to_stores=true" >> $GITHUB_OUTPUT
|
||||
echo "✅ Full release - will build, attach assets, and upload to stores"
|
||||
fi
|
||||
|
||||
build-android-apk:
|
||||
name: Build Android APK
|
||||
@@ -50,6 +58,9 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Validate localizations
|
||||
run: dart run scripts/validate_localizations.dart
|
||||
|
||||
- name: Generate icons and splash screens
|
||||
run: |
|
||||
dart run flutter_launcher_icons
|
||||
@@ -67,7 +78,7 @@ jobs:
|
||||
echo "storeFile=keystore.jks" >> android/key.properties
|
||||
|
||||
- name: Build Android .apk
|
||||
run: flutter build apk --release
|
||||
run: flutter build apk --release --dart-define=OSM_PROD_CLIENTID='${{ secrets.OSM_PROD_CLIENTID }}' --dart-define=OSM_SANDBOX_CLIENTID='${{ secrets.OSM_SANDBOX_CLIENTID }}'
|
||||
|
||||
- name: Upload .apk artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -99,6 +110,9 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Validate localizations
|
||||
run: dart run scripts/validate_localizations.dart
|
||||
|
||||
- name: Generate icons and splash screens
|
||||
run: |
|
||||
dart run flutter_launcher_icons
|
||||
@@ -116,7 +130,7 @@ jobs:
|
||||
echo "storeFile=keystore.jks" >> android/key.properties
|
||||
|
||||
- name: Build Android appBundle
|
||||
run: flutter build appbundle
|
||||
run: flutter build appbundle --dart-define=OSM_PROD_CLIENTID='${{ secrets.OSM_PROD_CLIENTID }}' --dart-define=OSM_SANDBOX_CLIENTID='${{ secrets.OSM_SANDBOX_CLIENTID }}'
|
||||
|
||||
- name: Upload .aab artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -141,18 +155,96 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Validate localizations
|
||||
run: dart run scripts/validate_localizations.dart
|
||||
|
||||
- name: Generate icons and splash screens
|
||||
run: |
|
||||
dart run flutter_launcher_icons
|
||||
dart run flutter_native_splash:create
|
||||
|
||||
# - name: Build iOS .ipa
|
||||
# run: flutter build ipa --release
|
||||
|
||||
- name: Build iOS .app
|
||||
- name: Install Apple certificate and provisioning profile
|
||||
env:
|
||||
BUILD_CERTIFICATE_BASE64: ${{ secrets.IOS_DISTRIBUTION_CERTIFICATE_BASE64 }}
|
||||
P12_PASSWORD: ""
|
||||
BUILD_PROVISION_PROFILE_BASE64: ${{ secrets.IOS_APPSTORE_PROVISIONING_PROFILE_BASE64 }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.IOS_KEYCHAIN_PASSWORD }}
|
||||
run: |
|
||||
flutter build ios --release --no-codesign
|
||||
./app2ipa.sh build/ios/iphoneos/Runner.app
|
||||
# create variables
|
||||
CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
|
||||
PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
|
||||
# import certificate and provisioning profile from secrets
|
||||
echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
|
||||
echo -n "$BUILD_PROVISION_PROFILE_BASE64" | base64 --decode -o $PP_PATH
|
||||
|
||||
# create temporary keychain
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
|
||||
# import certificate to keychain
|
||||
security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
|
||||
# Set this keychain as the default
|
||||
security list-keychain -d user -s $KEYCHAIN_PATH
|
||||
security default-keychain -s $KEYCHAIN_PATH
|
||||
|
||||
# install provisioning profile
|
||||
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
|
||||
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles/61f9fdb9-bf2d-4d94-b249-63155ee71e74.mobileprovision
|
||||
|
||||
# Also install using the profile's internal UUID for better compatibility
|
||||
UUID=$(security cms -D -i $PP_PATH | plutil -extract UUID xml1 -o - - | xmllint --xpath "//string/text()" -)
|
||||
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles/$UUID.mobileprovision
|
||||
|
||||
# Debug: Check what we actually have
|
||||
echo "=== Certificates in keychain ==="
|
||||
security find-identity -v -p codesigning $KEYCHAIN_PATH
|
||||
echo "=== Provisioning profiles ==="
|
||||
ls -la ~/Library/MobileDevice/Provisioning\ Profiles/
|
||||
echo "=== Profile UUID extracted: $UUID ==="
|
||||
|
||||
- name: Create export options
|
||||
run: |
|
||||
cat > ios/exportOptions.plist << EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>destination</key>
|
||||
<string>export</string>
|
||||
<key>method</key>
|
||||
<string>app-store</string>
|
||||
<key>teamID</key>
|
||||
<string>7XG8T28436</string>
|
||||
<key>provisioningProfiles</key>
|
||||
<dict>
|
||||
<key>me.deflock.deflockapp</key>
|
||||
<string>61f9fdb9-bf2d-4d94-b249-63155ee71e74</string>
|
||||
</dict>
|
||||
<key>signingStyle</key>
|
||||
<string>manual</string>
|
||||
<key>stripSwiftSymbols</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
- name: Build iOS .ipa
|
||||
run: |
|
||||
flutter build ipa --release \
|
||||
--export-options-plist=ios/exportOptions.plist \
|
||||
--dart-define=OSM_PROD_CLIENTID='${{ secrets.OSM_PROD_CLIENTID }}' \
|
||||
--dart-define=OSM_SANDBOX_CLIENTID='${{ secrets.OSM_SANDBOX_CLIENTID }}'
|
||||
cp build/ios/ipa/*.ipa Runner.ipa
|
||||
|
||||
- name: Clean up keychain and provisioning profile
|
||||
run: |
|
||||
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db
|
||||
rm ~/Library/MobileDevice/Provisioning\ Profiles/61f9fdb9-bf2d-4d94-b249-63155ee71e74.mobileprovision
|
||||
|
||||
- name: Upload IPA artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -194,3 +286,54 @@ jobs:
|
||||
deflock_v${{ needs.get-version.outputs.version }}.apk
|
||||
deflock_v${{ needs.get-version.outputs.version }}.aab
|
||||
deflock_v${{ needs.get-version.outputs.version }}.ipa
|
||||
|
||||
upload-to-stores:
|
||||
name: Upload to App Stores
|
||||
needs: [get-version, build-android-aab, build-ios]
|
||||
runs-on: macos-latest # Need macOS for iOS uploads
|
||||
if: needs.get-version.outputs.should_upload_to_stores == 'true'
|
||||
steps:
|
||||
- name: Download AAB artifact for Google Play
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: deflock_v${{ needs.get-version.outputs.version }}.aab
|
||||
|
||||
- name: Download IPA artifact for App Store
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: deflock_v${{ needs.get-version.outputs.version }}.ipa
|
||||
|
||||
# Temporarily disabled - uncomment when Google Play service account is ready
|
||||
# - name: Upload to Google Play Store
|
||||
# uses: r0adkll/upload-google-play@v1
|
||||
# with:
|
||||
# serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
# packageName: me.deflock.deflockapp
|
||||
# releaseFiles: app-release.aab
|
||||
# track: internal # Uploads to Internal Testing track for review before production
|
||||
# status: completed
|
||||
# inAppUpdatePriority: 0
|
||||
|
||||
- name: Upload to App Store Connect
|
||||
env:
|
||||
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
|
||||
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
|
||||
APP_STORE_CONNECT_API_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_KEY_BASE64 }}
|
||||
run: |
|
||||
# Create the private keys directory and decode API key
|
||||
mkdir -p ~/private_keys
|
||||
echo -n "$APP_STORE_CONNECT_API_KEY_BASE64" | base64 --decode > ~/private_keys/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8
|
||||
|
||||
# Upload to App Store Connect / TestFlight
|
||||
xcrun altool --upload-app \
|
||||
--type ios \
|
||||
--file Runner.ipa \
|
||||
--apiKey $APP_STORE_CONNECT_API_KEY_ID \
|
||||
--apiIssuer $APP_STORE_CONNECT_ISSUER_ID
|
||||
|
||||
# Clean up sensitive files
|
||||
rm -rf ~/private_keys
|
||||
|
||||
- name: Clean up artifacts
|
||||
run: |
|
||||
rm -f app-release.aab Runner.ipa
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -93,6 +93,9 @@ Thumbs.db
|
||||
*.keystore
|
||||
.env
|
||||
|
||||
# Local OSM client ID configuration (contains secrets)
|
||||
build_keys.conf
|
||||
|
||||
# ───────────────────────────────
|
||||
# For now - not targeting these
|
||||
# ───────────────────────────────
|
||||
|
||||
302
DEVELOPER.md
302
DEVELOPER.md
@@ -33,7 +33,10 @@ AppState (main coordinator)
|
||||
├── ProfileState (node profiles & toggles)
|
||||
├── SessionState (add/edit sessions)
|
||||
├── SettingsState (preferences & tile providers)
|
||||
└── UploadQueueState (pending operations)
|
||||
├── UploadQueueState (pending operations)
|
||||
├── SuspectedLocationState (permit data & display)
|
||||
├── NavigationState (routing & search)
|
||||
└── SearchState (location search results)
|
||||
```
|
||||
|
||||
**Why this approach:**
|
||||
@@ -62,25 +65,75 @@ External APIs (OSM, Overpass, Tile providers)
|
||||
|
||||
---
|
||||
|
||||
## Changelog & First Launch System
|
||||
|
||||
The app includes a comprehensive system for welcoming new users and notifying existing users of updates.
|
||||
|
||||
### Components
|
||||
- **ChangelogService**: Manages version tracking and changelog loading
|
||||
- **WelcomeDialog**: First launch popup with privacy information and quick links
|
||||
- **ChangelogDialog**: Update notification popup for version changes
|
||||
- **ReleaseNotesScreen**: Settings page for viewing all changelog history
|
||||
|
||||
### Content Management
|
||||
Changelog content is stored in `assets/changelog.json`:
|
||||
```json
|
||||
{
|
||||
"1.2.4": {
|
||||
"content": "• New feature description\n• Bug fixes\n• Other improvements"
|
||||
},
|
||||
"1.2.3": {
|
||||
"content": "" // Empty string = skip popup for this version
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Developer Workflow
|
||||
1. **For each release**: Add entry to `changelog.json` with version from `pubspec.yaml`
|
||||
2. **Content required**: Every version must have an entry (can be empty string to skip)
|
||||
3. **Localization**: Welcome dialog supports i18n, changelog content is English-only
|
||||
4. **Testing**: Clear app data to test first launch, change version to test updates
|
||||
|
||||
### User Experience Flow
|
||||
- **First Launch**: Welcome popup with "don't show again" option
|
||||
- **Version Updates**: Changelog popup (only if content exists, no "don't show again")
|
||||
- **Settings Access**: Complete changelog history available in Settings > About > Release Notes
|
||||
|
||||
### Privacy Integration
|
||||
The welcome popup explains that the app:
|
||||
- Runs entirely locally on device
|
||||
- Uses OpenStreetMap API for data storage only
|
||||
- DeFlock collects no user data
|
||||
- DeFlock is not responsible for OSM account management
|
||||
|
||||
---
|
||||
|
||||
## Core Components
|
||||
|
||||
### 1. MapDataProvider
|
||||
### 1. MapDataProvider & Smart Area Caching
|
||||
|
||||
**Purpose**: Unified interface for fetching map tiles and surveillance nodes
|
||||
**Purpose**: Unified interface for fetching map tiles and surveillance nodes with intelligent area caching
|
||||
|
||||
**Design decisions:**
|
||||
- **Pluggable sources**: Local (cached) vs Remote (live API)
|
||||
- **Offline-first**: Always try local first, graceful degradation
|
||||
- **Single fetch strategy**: Uses PrefetchAreaService for smart 3x area caching instead of dual immediate/background fetching
|
||||
- **Spatial + temporal refresh**: Fetches larger areas (3x visible bounds) and refreshes stale data (>60s old)
|
||||
- **Offline-first**: Always try local cache first, graceful degradation
|
||||
- **Mode-aware**: Different behavior for production vs sandbox
|
||||
- **Failure handling**: Never crash the UI, always provide fallbacks
|
||||
|
||||
**Key methods:**
|
||||
- `getNodes()`: Smart fetching with local/remote merging
|
||||
- `getTile()`: Tile fetching with caching
|
||||
- `getNodes()`: Returns cache immediately, triggers pre-fetch if needed (spatial or temporal)
|
||||
- `getTile()`: Tile fetching with enhanced retry strategy (6 attempts, 1-8s delays)
|
||||
- `_fetchRemoteNodes()`: Handles Overpass → OSM API fallback
|
||||
|
||||
**Why unified interface:**
|
||||
The app needs to seamlessly switch between multiple data sources (local cache, Overpass API, OSM API, offline areas) based on network status, upload mode, and zoom level. A single interface prevents the UI from needing to know about these complexities.
|
||||
**Smart caching flow:**
|
||||
1. Check if current view within cached area AND data <60s old
|
||||
2. If not: trigger pre-fetch of 3x larger area, show loading state
|
||||
3. Return cache immediately for responsive UI
|
||||
4. When pre-fetch completes: update cache, refresh UI, report success
|
||||
|
||||
**Why this approach:**
|
||||
Reduces API load by 3-4x while ensuring data freshness. User sees instant responses from cache while background fetching keeps data current. Eliminates complex dual-path logic in favor of simple spatial/temporal triggers.
|
||||
|
||||
### 2. Node Operations (Create/Edit/Delete)
|
||||
|
||||
@@ -95,10 +148,29 @@ enum UploadOperation { create, modify, delete }
|
||||
- **Clear intent**: `operation == UploadOperation.delete` is unambiguous
|
||||
|
||||
**Session Pattern:**
|
||||
- `AddNodeSession`: For creating new nodes
|
||||
- `EditNodeSession`: For modifying existing nodes
|
||||
- `AddNodeSession`: For creating new nodes with single or multiple directions
|
||||
- `EditNodeSession`: For modifying existing nodes, preserving all existing directions
|
||||
- No "DeleteSession": Deletions are immediate (simpler)
|
||||
|
||||
**Multi-Direction Support:**
|
||||
Sessions use a simple model for handling multiple directions:
|
||||
```dart
|
||||
class AddNodeSession {
|
||||
List<double> directions; // [90, 180, 270] - all directions
|
||||
int currentDirectionIndex; // Which direction is being edited
|
||||
|
||||
// Slider always shows the current direction
|
||||
double get directionDegrees => directions[currentDirectionIndex];
|
||||
set directionDegrees(value) => directions[currentDirectionIndex] = value;
|
||||
}
|
||||
```
|
||||
|
||||
**Direction Interaction:**
|
||||
- **Add**: New directions start at 0° and are automatically selected for editing
|
||||
- **Remove**: Current direction removed from list (minimum 1 direction)
|
||||
- **Cycle**: Switch between existing directions in the list
|
||||
- **Submit**: All directions combined as semicolon-separated string (e.g., "90;180;270")
|
||||
|
||||
**Why no delete session:**
|
||||
Deletions don't need position dragging or tag editing - they just need confirmation and queuing. A session would add complexity without benefit.
|
||||
|
||||
@@ -129,6 +201,11 @@ Users expect instant response to their actions. By immediately updating the cach
|
||||
- **Orange ring**: Node currently being edited
|
||||
- **Red ring**: Nodes pending deletion
|
||||
|
||||
**Direction cone visual states:**
|
||||
- **Full opacity**: Active session direction (currently being edited)
|
||||
- **Reduced opacity (40%)**: Inactive session directions
|
||||
- **Standard opacity**: Existing node directions (when not in edit mode)
|
||||
|
||||
**Cache tags for state tracking:**
|
||||
```dart
|
||||
'_pending_upload' // New node waiting to upload
|
||||
@@ -137,10 +214,21 @@ Users expect instant response to their actions. By immediately updating the cach
|
||||
'_original_node_id' // For drawing connection lines
|
||||
```
|
||||
|
||||
**Multi-direction parsing:**
|
||||
The app supports nodes with multiple directions specified as semicolon-separated values:
|
||||
```dart
|
||||
// OSM tag: direction="90;180;270"
|
||||
List<double> get directionDeg {
|
||||
final raw = tags['direction'] ?? tags['camera:direction'];
|
||||
// Splits on semicolons, parses each direction, normalizes to 0-359°
|
||||
return [90.0, 180.0, 270.0]; // Results in multiple FOV cones
|
||||
}
|
||||
```
|
||||
|
||||
**Why underscore prefix:**
|
||||
These are internal app tags, not OSM tags. The underscore prefix makes this explicit and prevents accidental upload to OSM.
|
||||
|
||||
### 5. Multi-API Data Sources
|
||||
### 5. Enhanced Overpass Integration & Error Handling
|
||||
|
||||
**Production mode:** Overpass API → OSM API fallback
|
||||
**Sandbox mode:** OSM API only (Overpass doesn't have sandbox data)
|
||||
@@ -149,8 +237,19 @@ These are internal app tags, not OSM tags. The underscore prefix makes this expl
|
||||
- **Production (Overpass)**: Zoom ≥ 10 (established limit)
|
||||
- **Sandbox (OSM API)**: Zoom ≥ 13 (stricter due to bbox limits)
|
||||
|
||||
**Why different zoom limits:**
|
||||
The OSM API returns ALL data types (nodes, ways, relations) in a bounding box and has stricter size limits. Overpass is more efficient for large areas. The zoom restrictions prevent API errors and excessive data transfer.
|
||||
**Smart error handling & splitting:**
|
||||
- **50k node limit**: Automatically splits query into 4 quadrants, recursively up to 3 levels deep
|
||||
- **Timeout errors**: Also triggers splitting (dense areas with many profiles)
|
||||
- **Rate limiting**: Extended backoff (30s), no splitting (would make it worse)
|
||||
- **Surgical detection**: Only splits on actual limit errors, not network issues
|
||||
|
||||
**Query optimization:**
|
||||
- **Pre-fetch limit**: 4x user's display limit (e.g., 1000 nodes for 250 display limit)
|
||||
- **User-initiated detection**: Only reports loading status for user-facing operations
|
||||
- **Background operations**: Pre-fetch runs silently, doesn't trigger loading states
|
||||
|
||||
**Why this approach:**
|
||||
Dense urban areas (SF, NYC) with many profiles enabled can easily exceed both 50k node limits and 25s timeouts. Splitting reduces query complexity while surgical error detection avoids unnecessary API load from network issues.
|
||||
|
||||
### 6. Offline vs Online Mode Behavior
|
||||
|
||||
@@ -165,6 +264,87 @@ Sandbox + Offline → No nodes (cache is production data)
|
||||
**Why sandbox + offline = no nodes:**
|
||||
Local cache contains production data. Showing production nodes in sandbox mode would be confusing and could lead to users trying to edit production nodes with sandbox credentials.
|
||||
|
||||
### 7. Proximity Alerts & Background Monitoring
|
||||
|
||||
**Design approach:**
|
||||
- **Simple cooldown system**: In-memory tracking to prevent notification spam
|
||||
- **Dual alert types**: Push notifications (background) and visual banners (foreground)
|
||||
- **Configurable distance**: 25-200 meter alert radius
|
||||
- **Battery awareness**: Users explicitly opt into background location monitoring
|
||||
|
||||
**Implementation notes:**
|
||||
- Uses Flutter Local Notifications for cross-platform background alerts
|
||||
- Simple RecentAlert tracking prevents duplicate notifications
|
||||
- Visual callback system for in-app alerts when app is active
|
||||
|
||||
### 8. Compass Indicator & North Lock
|
||||
|
||||
**Purpose**: Visual compass showing map orientation with optional north-lock functionality
|
||||
|
||||
**Design decisions:**
|
||||
- **Separate from follow mode**: North lock is independent of GPS following behavior
|
||||
- **Smart rotation detection**: Distinguishes intentional rotation (>5°) from zoom gestures
|
||||
- **Visual feedback**: Clear skeumorphic compass design with red north indicator
|
||||
- **Mode awareness**: Disabled during follow+rotate mode (incompatible)
|
||||
|
||||
**Key behaviors:**
|
||||
- **North indicator**: Red arrow always points toward true north regardless of map rotation
|
||||
- **Tap to toggle**: Enable/disable north lock with visual animation to north
|
||||
- **Auto-disable**: North lock turns off when switching to follow+rotate mode
|
||||
- **Gesture intelligence**: Only disables on significant rotation changes, ignores zoom artifacts
|
||||
|
||||
**Visual states:**
|
||||
- **Normal**: White background, grey border, red north arrow
|
||||
- **North locked**: White background, blue border, bright red north arrow
|
||||
- **Disabled**: Grey background, muted colors (during follow+rotate mode)
|
||||
|
||||
**Why separate from follow mode:**
|
||||
Users often want to follow their location while keeping the map oriented north. Previous "north up" follow mode was confusing because it didn't actually keep north up. This separation provides clear, predictable behavior.
|
||||
|
||||
### 9. Suspected Locations
|
||||
|
||||
**Data pipeline:**
|
||||
- **CSV ingestion**: Downloads utility permit data from alprwatch.org
|
||||
- **Dynamic field parsing**: Stores all CSV columns (except `location` and `ticket_no`) for flexible display
|
||||
- **GeoJSON processing**: Handles Point, Polygon, and MultiPolygon geometries
|
||||
- **Proximity filtering**: Hides suspected locations near confirmed devices
|
||||
- **Regional availability**: Currently select locations, expanding regularly
|
||||
|
||||
**Display approach:**
|
||||
- **Required fields**: `ticket_no` (for heading) and `location` (for map positioning)
|
||||
- **Dynamic display**: All other CSV fields shown automatically, no hardcoded field list
|
||||
- **Server control**: Field names and content controlled server-side via CSV headers
|
||||
- **Brutalist rendering**: Fields displayed as-is from CSV, empty fields hidden
|
||||
|
||||
**Why utility permits:**
|
||||
Utility companies often must file permits when installing surveillance infrastructure. This creates a paper trail that can indicate potential surveillance sites before devices are confirmed through direct observation.
|
||||
|
||||
### 10. Upload Mode Simplification
|
||||
|
||||
**Release vs Debug builds:**
|
||||
- **Release builds**: Production OSM only (simplified UX)
|
||||
- **Debug builds**: Full sandbox/simulate options available
|
||||
Most users should contribute to production; testing modes add complexity
|
||||
|
||||
**Implementation:**
|
||||
```dart
|
||||
// Upload mode selection disabled in release builds
|
||||
bool get showUploadModeSelector => kDebugMode;
|
||||
```
|
||||
|
||||
### 11. Navigation & Routing (Implemented, Awaiting Integration)
|
||||
|
||||
**Current state:**
|
||||
- **Search functionality**: Fully implemented and active
|
||||
- **Basic routing**: Complete but disabled pending API integration
|
||||
- **Avoidance routing**: Awaiting alprwatch.org/directions API
|
||||
- **Offline routing**: Requires vector map tiles
|
||||
|
||||
**Architecture:**
|
||||
- NavigationState manages routing computation and turn-by-turn instructions
|
||||
- RoutingService handles API communication and route calculation
|
||||
- SearchService provides location lookup and geocoding
|
||||
|
||||
---
|
||||
|
||||
## Key Design Decisions & Rationales
|
||||
@@ -227,6 +407,22 @@ Local cache contains production data. Showing production nodes in sandbox mode w
|
||||
- **Battery life**: Excessive network requests drain battery
|
||||
- **Clear feedback**: Users understand why nodes aren't showing
|
||||
|
||||
### 6. Why Separate Compass Indicator from Follow Mode?
|
||||
|
||||
**Alternative**: Combined "follow with north up" mode
|
||||
|
||||
**Why separate controls:**
|
||||
- **Clear user mental model**: "Follow me" vs "lock to north" are distinct concepts
|
||||
- **Flexible combinations**: Users can follow without north lock, or vice versa
|
||||
- **Avoid mode conflicts**: Follow+rotate is incompatible with north lock
|
||||
- **Reduced confusion**: Previous "north up" mode didn't actually keep north up
|
||||
|
||||
**Design benefits:**
|
||||
- **Brutalist approach**: Two simple, independent features instead of complex mode combinations
|
||||
- **Visual feedback**: Compass shows exact map orientation regardless of follow state
|
||||
- **Smart gesture detection**: Differentiates intentional rotation from zoom artifacts
|
||||
- **Predictable behavior**: Each control does exactly what it says
|
||||
|
||||
---
|
||||
|
||||
## Development Guidelines
|
||||
@@ -298,6 +494,84 @@ void updateMultipleThings() {
|
||||
|
||||
---
|
||||
|
||||
## Release Process & GitHub Actions
|
||||
|
||||
The app uses a **clean, release-triggered workflow** that rebuilds from scratch for maximum reliability:
|
||||
|
||||
### How It Works
|
||||
|
||||
**Trigger: GitHub Release Creation**
|
||||
- Create a GitHub release → Workflow automatically builds, attaches assets, and optionally uploads to stores
|
||||
- **Pre-release checkbox** controls store uploads:
|
||||
- ✅ **Checked** → Build + attach assets (no store uploads)
|
||||
- ✅ **Unchecked** → Build + attach assets + upload to App/Play stores
|
||||
|
||||
### Release Types
|
||||
|
||||
**Development/Beta Releases**
|
||||
1. Create GitHub release from any tag/branch
|
||||
2. ✅ **Check "pre-release"** checkbox
|
||||
3. Publish → Assets built and attached, no store uploads
|
||||
|
||||
**Production Releases**
|
||||
1. Create GitHub release from main/stable branch
|
||||
2. ❌ **Leave "pre-release" unchecked**
|
||||
3. Publish → Assets built and attached + uploaded to stores
|
||||
|
||||
### Store Upload Destinations
|
||||
|
||||
**Google Play Store:**
|
||||
- Uploads to **Internal Testing** track
|
||||
- Requires manual promotion to Beta/Production
|
||||
- You maintain full control over public release
|
||||
|
||||
**App Store Connect:**
|
||||
- Uploads to **TestFlight**
|
||||
- Requires manual App Store submission
|
||||
- You maintain full control over public release
|
||||
|
||||
### Required Secrets
|
||||
|
||||
**For Google Play Store Upload:**
|
||||
- `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` - Complete JSON service account key (plain text)
|
||||
|
||||
**For iOS App Store Upload:**
|
||||
- `APP_STORE_CONNECT_API_KEY_ID` - App Store Connect API key ID
|
||||
- `APP_STORE_CONNECT_ISSUER_ID` - App Store Connect issuer ID
|
||||
- `APP_STORE_CONNECT_API_KEY_BASE64` - Base64-encoded .p8 API key file
|
||||
|
||||
**For Building:**
|
||||
- `OSM_PROD_CLIENTID` - OpenStreetMap production OAuth2 client ID
|
||||
- `OSM_SANDBOX_CLIENTID` - OpenStreetMap sandbox OAuth2 client ID
|
||||
- Android signing secrets (keystore, passwords, etc.)
|
||||
- iOS signing certificates and provisioning profiles
|
||||
|
||||
### Google Play Store Setup
|
||||
|
||||
1. **Google Cloud Console:**
|
||||
- Create Service Account with "Project Editor" role
|
||||
- Enable Google Play Android Developer API
|
||||
- Download JSON key file
|
||||
|
||||
2. **Google Play Console:**
|
||||
- Add service account email to Users & Permissions
|
||||
- Grant "Release Manager" permissions for your app
|
||||
- Complete first manual release to activate app listing
|
||||
|
||||
3. **GitHub Secrets:**
|
||||
- Store entire JSON key as `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` (plain text)
|
||||
|
||||
### Workflow Benefits
|
||||
|
||||
✅ **Brutalist simplicity** - One trigger, clear behavior
|
||||
✅ **No external dependencies** - Only uses trusted `r0adkll/upload-google-play@v1`
|
||||
✅ **Explicit control** - GitHub's UI checkbox controls store uploads
|
||||
✅ **Always rebuilds** - No stale artifacts or cross-workflow complexity
|
||||
✅ **Safe defaults** - Pre-release prevents accidental production uploads
|
||||
✅ **No tag coordination** - Works with any commit, tag, or branch
|
||||
|
||||
---
|
||||
|
||||
## Build & Development Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
66
README.md
66
README.md
@@ -6,6 +6,14 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
|
||||
|
||||
**For complete documentation, tutorials, and community info, visit [deflock.me](https://deflock.me)**
|
||||
|
||||
<a href="https://apps.apple.com/us/app/deflock-me/id6752760780" style="display: inline-block;">
|
||||
<img src="https://toolbox.marketingtools.apple.com/api/v2/badges/download-on-the-app-store/black/en-us?releaseDate=1695859200" alt="Download on the App Store" style="width: 246px; height: 82px; vertical-align: middle; object-fit: contain;" />
|
||||
</a>
|
||||
|
||||
<a href="https://play.google.com/store/apps/details?id=me.deflock.deflockapp" style="display: inline-block;">
|
||||
<img src="assets/GetItOnGooglePlay_Badge_Web_color_English.png" alt="Download on the Google Play Store" style="width: 246px; height: 82px; vertical-align: middle; object-fit: contain;" />
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
## What This App Does
|
||||
@@ -24,15 +32,21 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
|
||||
### Map & Navigation
|
||||
- **Multi-source tiles**: Switch between OpenStreetMap, USGS imagery, Esri imagery, Mapbox, OpenTopoMap, and any custom providers
|
||||
- **Offline-first design**: Download a region for complete offline operation
|
||||
- **Smooth UX**: Intuitive controls, follow-me mode with GPS rotation, and gesture-friendly interactions
|
||||
- **Smooth UX**: Intuitive controls, follow-me mode with GPS rotation, compass indicator with north-lock, and gesture-friendly interactions
|
||||
- **Device visualization**: Color-coded markers showing real devices (blue), pending uploads (purple), pending edits (grey), devices being edited (orange), and pending deletions (red)
|
||||
|
||||
### Device Management
|
||||
- **Comprehensive profiles**: Built-in profiles for major manufacturers (Flock Safety, Motorola/Vigilant, Genetec, Leonardo/ELSAG, Neology) plus custom profile creation
|
||||
- **Full CRUD operations**: Create, edit, and delete surveillance devices
|
||||
- **Direction visualization**: Interactive field-of-view cones showing camera viewing angles
|
||||
- **Multi-direction support**: Devices can have multiple viewing directions (e.g. "90;180") with individual field-of-view cones
|
||||
- **Direction visualization**: Interactive field-of-view cones showing camera viewing angles with opacity-based selection
|
||||
- **Bulk operations**: Tag multiple devices efficiently with profile-based workflow
|
||||
|
||||
### Surveillance Intelligence
|
||||
- **Suspected locations**: Display potential surveillance sites from utility permit data with dynamic field display (select locations, more added regularly)
|
||||
- **Proximity alerts**: Get notified when approaching mapped surveillance devices, with configurable distance and background notifications
|
||||
- **Location search**: Find addresses and points of interest to aid in mapping missions
|
||||
|
||||
### Professional Upload & Sync
|
||||
- **OpenStreetMap integration**: Direct upload with full OAuth2 authentication
|
||||
- **Upload modes**: Production OSM, testing sandbox, or simulate-only mode
|
||||
@@ -49,14 +63,16 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Install** the app on iOS or Android
|
||||
1. **Install** the app on iOS or Android - a welcome popup will guide you through key information
|
||||
2. **Enable location** permissions
|
||||
3. **Log into OpenStreetMap**: Choose upload mode and get OAuth2 credentials
|
||||
4. **Add your first device**: Tap the "New Node" button, position the pin, set direction, select a profile, and tap submit
|
||||
4. **Add your first device**: Tap the "New Node" button, position the pin, set direction(s), select a profile, and tap submit
|
||||
5. **Edit or delete devices**: Tap any device marker to view details, then use Edit or Delete buttons
|
||||
|
||||
**New to OpenStreetMap?** Visit [deflock.me](https://deflock.me) for complete setup instructions and community guidelines.
|
||||
|
||||
**App Updates**: The app will automatically show you what's new when you update. You can always view release notes in Settings > About.
|
||||
|
||||
---
|
||||
|
||||
## For Developers
|
||||
@@ -64,6 +80,7 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
|
||||
**See [DEVELOPER.md](DEVELOPER.md)** for comprehensive technical documentation including:
|
||||
- Architecture overview and design decisions
|
||||
- Development setup and build instructions
|
||||
- Release process and GitHub Actions automation
|
||||
- Code organization and contribution guidelines
|
||||
- Debugging tips and troubleshooting
|
||||
|
||||
@@ -74,40 +91,43 @@ cp lib/keys.dart.example lib/keys.dart
|
||||
# Add OAuth2 client IDs, then: flutter run
|
||||
```
|
||||
|
||||
**Releases**: The app uses GitHub's release system for automated building and store uploads. Simply create a GitHub release and use the "pre-release" checkbox to control whether builds go to app stores - checked for beta releases, unchecked for production releases.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Needed Bugfixes
|
||||
- Are offline areas really working? Are they preferred for fast loading even when online? Check working.
|
||||
- Are offline areas preferred for fast loading even when online? Check working.
|
||||
- Fix network indicator - only done when fetch queue is empty!
|
||||
|
||||
### Current Development
|
||||
- Add some builtin satellite tile provider
|
||||
- Option to pull in profiles from NSI (man_made=surveillance only)
|
||||
- Persistent cache for MY submissions: clean up when we see that node appear in overpass results or when older than 24h
|
||||
- Dropdown on "refine tags" page to select acceptable options for camera:mount=
|
||||
- Tutorial / info guide before submitting first node
|
||||
- Link to "my changes" on osm (username edit history)
|
||||
- Option to "extract node from way" for nodes attached to a way to allow moving
|
||||
|
||||
### On Pause
|
||||
- Suspected locations expansion to more regions
|
||||
- Import/Export map providers
|
||||
- Swap in alprwatch.org/directions avoidance routing API
|
||||
- Help button with links to email, discord, and website
|
||||
- Clean cache when nodes have disappeared / been deleted by others / queue item was deleted
|
||||
- Improve offline area node refresh live display
|
||||
- Add default operator profiles (Lowe’s etc)
|
||||
|
||||
### Future Features & Wishlist
|
||||
- Update offline area nodes while browsing?
|
||||
- Suspected locations toggle (alprwatch.com/flock/utilities)
|
||||
- Offline navigation
|
||||
- Offline navigation (pending vector map tiles)
|
||||
|
||||
### Maybes
|
||||
- Yellow ring for devices missing specific tag details?
|
||||
- "Cache accumulating" offline area?
|
||||
- "Offline areas" as tile provider?
|
||||
- Optional custom icons for camera profiles?
|
||||
- Upgrade device marker design? (considering nullplate's svg)
|
||||
- Custom device providers and OSM/Overpass alternatives?
|
||||
- More map data providers:
|
||||
https://gis.sanramon.ca.gov/arcgis_js_api/sdk/jsapi/esri.basemaps-amd.html#osm
|
||||
https://www.icgc.cat/en/Geoinformation-and-Maps/Base-Map-Service
|
||||
https://github.com/CartoDB/basemap-styles
|
||||
https://forum.inductiveautomation.com/t/perspective-map-theming-internet-tile-server-options/40164
|
||||
https://github.com/roblabs/xyz-raster-sources
|
||||
https://github.com/geopandas/xyzservices/blob/main/provider_sources/xyzservices-providers.json
|
||||
https://medium.com/@go2garret/free-basemap-tiles-for-maplibre-18374fab60cb
|
||||
- Yellow ring for devices missing specific tag details
|
||||
- "Cache accumulating" offline area
|
||||
- "Offline areas" as tile provider
|
||||
- Grab the full latest database for each profile just like for suspected locations (instead of overpass)
|
||||
- Optional custom icons for profiles to aid identification
|
||||
- Custom device providers and OSM/Overpass alternatives
|
||||
|
||||
---
|
||||
|
||||
|
||||
BIN
assets/GetItOnGooglePlay_Badge_Web_color_English.png
Normal file
BIN
assets/GetItOnGooglePlay_Badge_Web_color_English.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
104
assets/changelog.json
Normal file
104
assets/changelog.json
Normal file
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"1.4.0": {
|
||||
"content": [
|
||||
"• IMPROVED: Advanced editing options now only show apps available on your platform (iOS/Android)",
|
||||
"• IMPROVED: When an OSM editor app isn't installed, automatically redirect to the appropriate app store",
|
||||
"• IMPROVED: Better error handling for external editor launches with app store fallback",
|
||||
"• Supported editors: Vespucci (Android), StreetComplete (Android), EveryDoor (both), Go Map!! (iOS)",
|
||||
"• Web editors (iD, RapiD) remain available on all platforms as before"
|
||||
]
|
||||
},
|
||||
"1.3.4": {
|
||||
"content": [
|
||||
"• NEW: 'Pause Upload Queue' toggle in Offline Settings - stops uploads while keeping live data access",
|
||||
"• Useful for metered connections or when you want to batch uploads later",
|
||||
"• Upload queue is now disabled if either full offline mode OR pause queue processing is enabled",
|
||||
"• FIXED: Sheet buttons now remain visible when rotating from portrait to landscape mode",
|
||||
"• FIXED: Sheets now properly resize when rotating between orientations without requiring user interaction",
|
||||
"• IMPROVED: Tag list height adapts automatically for landscape orientation to prevent covering map",
|
||||
"• IMPROVED: Sheets with few tags now shrink to appropriate size rather than maintaining fixed height",
|
||||
"• IMPROVED: More reliable sheet layout using proper flexible height constraints",
|
||||
"• CLEANED: Fixed minor code formatting inconsistencies"
|
||||
]
|
||||
},
|
||||
"1.3.3": {
|
||||
"content": [
|
||||
"• NEW: Added builtin surveillance device profiles for Rekor and Axis Communications ALPR cameras",
|
||||
"• Both profiles include proper OSM tags for manufacturer identification and require direction setting",
|
||||
"• NEW: Advanced editing options - access iD Editor, RapiD, Vespucci, StreetComplete, and other OSM editors",
|
||||
"• NEW: 'View on OSM' links to see nodes directly on OpenStreetMap website",
|
||||
"• UX: Constrained nodes (part of ways/relations) cannot be moved to prevent data corruption",
|
||||
"• UX: Auto-clickable URLs in all tag values - any URL becomes a tappable link",
|
||||
"• UX: Tag lists now scroll with max height to keep buttons and map visible",
|
||||
"• UX: Improved button layout on mobile with two rows for better accessibility",
|
||||
"• UX: Localized network status messages in all supported languages",
|
||||
"• FIXED: Duplicate changelog service calls eliminated"
|
||||
]
|
||||
},
|
||||
"1.3.2": {
|
||||
"content": [
|
||||
"• HOTFIX: Temporarily disabled node editing to prevent OSM database issues while a bug is resolved",
|
||||
"• UX: Fixed Android navigation bar covering settings page content"
|
||||
]
|
||||
},
|
||||
"1.3.1": {
|
||||
"content": [
|
||||
"• UX: Network status indicator always enabled",
|
||||
"• UX: Direction slider wider on small screens",
|
||||
"• UX: Fixed iOS keyboard missing 'Done' in settings",
|
||||
"• UX: Fixed multi-direction nodes in upload queue",
|
||||
"• UX: Improved suspected locations loading indicator; removed popup, fixed stuck spinner"
|
||||
]
|
||||
},
|
||||
"1.2.8": {
|
||||
"content": [
|
||||
"• UX: Profile selection is now a required step to prevent accidental submission of default profile",
|
||||
"• NEW: Note in welcome message about not submitting data you cannot vouch for personally (no street view etc)",
|
||||
"• NEW: Added default operator profiles for the most common private operators nationwide (Lowe's, Home Depot, et al)",
|
||||
"• NEW: Support for cardinal directions in OSM data, multiple directions on a node"
|
||||
]
|
||||
},
|
||||
"1.2.7": {
|
||||
"content": [
|
||||
"• NEW: Compass indicator shows map orientation; tap to spin north-up",
|
||||
"• Smart area caching: Loads 3x larger areas and refreshes data every 60 seconds for much faster browsing",
|
||||
"• Enhanced tile loading: Increased retry attempts with faster delays - tiles load much more reliably",
|
||||
"• Better network status: Simplified loading indicator logic",
|
||||
"• Instant node display: Surveillance devices now appear immediately when data finishes loading",
|
||||
"• Node limit alerts: Get notified when some nodes are not drawn"
|
||||
]
|
||||
},
|
||||
"1.2.4": {
|
||||
"content": [
|
||||
"• New welcome popup for first-time users with essential privacy information",
|
||||
"• Automatic changelog display when app updates (like this one!)",
|
||||
"• Added Release Notes viewer in Settings > About",
|
||||
"• Enhanced user onboarding and transparency about data handling",
|
||||
"• Improved documentation for contributors"
|
||||
]
|
||||
},
|
||||
"1.2.3": {
|
||||
"content": [
|
||||
"• Enhanced map performance and stability",
|
||||
"• Improved offline sync reliability",
|
||||
"• Added better error handling for uploads",
|
||||
"• Various bug fixes and improvements"
|
||||
]
|
||||
},
|
||||
"1.2.2": {
|
||||
"content": [
|
||||
"• New surveillance device profiles added",
|
||||
"• Improved tile loading performance",
|
||||
"• Fixed issue with GPS accuracy",
|
||||
"• Updated translations"
|
||||
]
|
||||
},
|
||||
"1.2.0": {
|
||||
"content": [
|
||||
"• Major UI improvements",
|
||||
"• Added proximity alerts",
|
||||
"• Enhanced offline capabilities",
|
||||
"• New suspected locations feature"
|
||||
]
|
||||
}
|
||||
}
|
||||
10
build_keys.conf.example
Normal file
10
build_keys.conf.example
Normal file
@@ -0,0 +1,10 @@
|
||||
# Local OSM client ID configuration for builds
|
||||
# Copy this file to build_keys.conf and fill in your values
|
||||
# This file is gitignored to keep your keys secret
|
||||
#
|
||||
# Get your client IDs from:
|
||||
# Production: https://www.openstreetmap.org/oauth2/applications
|
||||
# Sandbox: https://master.apis.dev.openstreetmap.org/oauth2/applications
|
||||
|
||||
OSM_PROD_CLIENTID=your_production_client_id_here
|
||||
OSM_SANDBOX_CLIENTID=your_sandbox_client_id_here
|
||||
68
do_builds.sh
68
do_builds.sh
@@ -4,6 +4,35 @@
|
||||
BUILD_IOS=true
|
||||
BUILD_ANDROID=true
|
||||
|
||||
# Function to read key=value from file
|
||||
read_from_file() {
|
||||
local key="$1"
|
||||
local file="build_keys.conf"
|
||||
|
||||
if [ ! -f "$file" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Read key=value pairs, ignoring comments and empty lines
|
||||
while IFS='=' read -r k v; do
|
||||
# Skip comments and empty lines
|
||||
if [[ "$k" =~ ^[[:space:]]*# ]] || [[ -z "$k" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Remove leading/trailing whitespace
|
||||
k=$(echo "$k" | xargs)
|
||||
v=$(echo "$v" | xargs)
|
||||
|
||||
if [ "$k" = "$key" ]; then
|
||||
echo "$v"
|
||||
return 0
|
||||
fi
|
||||
done < "$file"
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
@@ -18,19 +47,52 @@ for arg in "$@"; do
|
||||
echo " --ios Build only iOS"
|
||||
echo " --android Build only Android"
|
||||
echo " (default builds both)"
|
||||
echo ""
|
||||
echo "OSM client IDs must be configured in build_keys.conf"
|
||||
echo "See build_keys.conf.example for format"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
appver=$(grep "version:" pubspec.yaml | head -1 | cut -d ':' -f 2 | tr -d ' ')
|
||||
# Load client IDs from build_keys.conf
|
||||
if [ ! -f "build_keys.conf" ]; then
|
||||
echo "Error: build_keys.conf not found"
|
||||
echo "Copy build_keys.conf.example to build_keys.conf and fill in your OSM client IDs"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Loading OSM client IDs from build_keys.conf..."
|
||||
OSM_PROD_CLIENTID=$(read_from_file "OSM_PROD_CLIENTID")
|
||||
OSM_SANDBOX_CLIENTID=$(read_from_file "OSM_SANDBOX_CLIENTID")
|
||||
|
||||
# Check required keys
|
||||
if [ -z "$OSM_PROD_CLIENTID" ]; then
|
||||
echo "Error: OSM_PROD_CLIENTID not found in build_keys.conf"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$OSM_SANDBOX_CLIENTID" ]; then
|
||||
echo "Error: OSM_SANDBOX_CLIENTID not found in build_keys.conf"
|
||||
exit 1
|
||||
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
|
||||
echo
|
||||
|
||||
appver=$(grep "version:" pubspec.yaml | head -1 | cut -d ':' -f 2 | tr -d ' ' | cut -d '+' -f 1)
|
||||
echo
|
||||
echo "Building app version ${appver}..."
|
||||
echo
|
||||
|
||||
if [ "$BUILD_IOS" = true ]; then
|
||||
echo "Building iOS..."
|
||||
flutter build ios --no-codesign || exit 1
|
||||
flutter build ios --no-codesign $DART_DEFINE_ARGS || exit 1
|
||||
|
||||
echo "Converting .app to .ipa..."
|
||||
./app2ipa.sh build/ios/iphoneos/Runner.app || exit 1
|
||||
@@ -42,7 +104,7 @@ fi
|
||||
|
||||
if [ "$BUILD_ANDROID" = true ]; then
|
||||
echo "Building Android..."
|
||||
flutter build apk || exit 1
|
||||
flutter build apk $DART_DEFINE_ARGS || exit 1
|
||||
|
||||
echo "Moving Android files..."
|
||||
cp build/app/outputs/flutter-apk/app-release.apk "../deflock_v${appver}.apk" || exit 1
|
||||
|
||||
@@ -470,7 +470,10 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = 7XG8T28436;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -479,6 +482,7 @@
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = me.deflock.deflockapp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "61f9fdb9-bf2d-4d94-b249-63155ee71e74";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
@@ -652,7 +656,10 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = 7XG8T28436;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -661,6 +668,7 @@
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = me.deflock.deflockapp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "61f9fdb9-bf2d-4d94-b249-63155ee71e74";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -674,7 +682,10 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = 7XG8T28436;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -683,6 +694,7 @@
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = me.deflock.deflockapp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "61f9fdb9-bf2d-4d94-b249-63155ee71e74";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
|
||||
@@ -25,7 +25,9 @@
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>This app needs your location to show nearby cameras.</string>
|
||||
<string>This app optionally uses your location to show nearby cameras by centering the map on your location.</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>This app optionally uses your location to center the map on your current position and provide proximity alerts for nearby surveillance devices. These features are entirely optional.</string>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'models/node_profile.dart';
|
||||
import 'models/operator_profile.dart';
|
||||
import 'models/osm_node.dart';
|
||||
import 'models/pending_upload.dart';
|
||||
import 'models/suspected_location.dart';
|
||||
import 'models/tile_provider.dart';
|
||||
import 'models/search_result.dart';
|
||||
import 'services/offline_area_service.dart';
|
||||
import 'services/node_cache.dart';
|
||||
import 'services/tile_preview_service.dart';
|
||||
import 'services/changelog_service.dart';
|
||||
import 'services/operator_profile_service.dart';
|
||||
import 'services/profile_service.dart';
|
||||
import 'widgets/camera_provider_with_cache.dart';
|
||||
import 'state/auth_state.dart';
|
||||
import 'state/navigation_state.dart';
|
||||
@@ -19,6 +24,7 @@ import 'state/profile_state.dart';
|
||||
import 'state/search_state.dart';
|
||||
import 'state/session_state.dart';
|
||||
import 'state/settings_state.dart';
|
||||
import 'state/suspected_location_state.dart';
|
||||
import 'state/upload_queue_state.dart';
|
||||
|
||||
// Re-export types
|
||||
@@ -38,6 +44,7 @@ class AppState extends ChangeNotifier {
|
||||
late final SearchState _searchState;
|
||||
late final SessionState _sessionState;
|
||||
late final SettingsState _settingsState;
|
||||
late final SuspectedLocationState _suspectedLocationState;
|
||||
late final UploadQueueState _uploadQueueState;
|
||||
|
||||
bool _isInitialized = false;
|
||||
@@ -51,6 +58,7 @@ class AppState extends ChangeNotifier {
|
||||
_searchState = SearchState();
|
||||
_sessionState = SessionState();
|
||||
_settingsState = SettingsState();
|
||||
_suspectedLocationState = SuspectedLocationState();
|
||||
_uploadQueueState = UploadQueueState();
|
||||
|
||||
// Set up state change listeners
|
||||
@@ -61,6 +69,7 @@ class AppState extends ChangeNotifier {
|
||||
_searchState.addListener(_onStateChanged);
|
||||
_sessionState.addListener(_onStateChanged);
|
||||
_settingsState.addListener(_onStateChanged);
|
||||
_suspectedLocationState.addListener(_onStateChanged);
|
||||
_uploadQueueState.addListener(_onStateChanged);
|
||||
|
||||
_init();
|
||||
@@ -121,12 +130,15 @@ class AppState extends ChangeNotifier {
|
||||
|
||||
// Settings state
|
||||
bool get offlineMode => _settingsState.offlineMode;
|
||||
bool get pauseQueueProcessing => _settingsState.pauseQueueProcessing;
|
||||
int get maxCameras => _settingsState.maxCameras;
|
||||
UploadMode get uploadMode => _settingsState.uploadMode;
|
||||
FollowMeMode get followMeMode => _settingsState.followMeMode;
|
||||
|
||||
bool get proximityAlertsEnabled => _settingsState.proximityAlertsEnabled;
|
||||
int get proximityAlertDistance => _settingsState.proximityAlertDistance;
|
||||
bool get networkStatusIndicatorEnabled => _settingsState.networkStatusIndicatorEnabled;
|
||||
int get suspectedLocationMinDistance => _settingsState.suspectedLocationMinDistance;
|
||||
|
||||
// Tile provider state
|
||||
List<TileProvider> get tileProviders => _settingsState.tileProviders;
|
||||
@@ -139,6 +151,12 @@ class AppState extends ChangeNotifier {
|
||||
int get pendingCount => _uploadQueueState.pendingCount;
|
||||
List<PendingUpload> get pendingUploads => _uploadQueueState.pendingUploads;
|
||||
|
||||
// Suspected location state
|
||||
SuspectedLocation? get selectedSuspectedLocation => _suspectedLocationState.selectedLocation;
|
||||
bool get suspectedLocationsEnabled => _suspectedLocationState.isEnabled;
|
||||
bool get suspectedLocationsLoading => _suspectedLocationState.isLoading;
|
||||
DateTime? get suspectedLocationsLastFetch => _suspectedLocationState.lastFetchTime;
|
||||
|
||||
void _onStateChanged() {
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -148,11 +166,33 @@ class AppState extends ChangeNotifier {
|
||||
// Initialize all state modules
|
||||
await _settingsState.init();
|
||||
|
||||
// Initialize changelog service
|
||||
await ChangelogService().init();
|
||||
|
||||
// Attempt to fetch missing tile type preview tiles (fails silently)
|
||||
_fetchMissingTilePreviews();
|
||||
|
||||
await _operatorProfileState.init();
|
||||
await _profileState.init();
|
||||
// Check if we should add default profiles (first launch OR no profiles of each type exist)
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
const firstLaunchKey = 'profiles_defaults_initialized';
|
||||
final isFirstLaunch = !(prefs.getBool(firstLaunchKey) ?? false);
|
||||
|
||||
// Load existing profiles to check each type independently
|
||||
final existingOperatorProfiles = await OperatorProfileService().load();
|
||||
final existingNodeProfiles = await ProfileService().load();
|
||||
|
||||
final shouldAddOperatorDefaults = isFirstLaunch || existingOperatorProfiles.isEmpty;
|
||||
final shouldAddNodeDefaults = isFirstLaunch || existingNodeProfiles.isEmpty;
|
||||
|
||||
await _operatorProfileState.init(addDefaults: shouldAddOperatorDefaults);
|
||||
await _profileState.init(addDefaults: shouldAddNodeDefaults);
|
||||
|
||||
// Mark defaults as initialized if this was first launch
|
||||
if (isFirstLaunch) {
|
||||
await prefs.setBool(firstLaunchKey, true);
|
||||
}
|
||||
|
||||
await _suspectedLocationState.init(offlineMode: _settingsState.offlineMode);
|
||||
await _uploadQueueState.init();
|
||||
await _authState.init(_settingsState.uploadMode);
|
||||
|
||||
@@ -246,6 +286,20 @@ class AppState extends ChangeNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
void addDirection() {
|
||||
_sessionState.addDirection();
|
||||
}
|
||||
|
||||
void removeDirection() {
|
||||
_sessionState.removeDirection();
|
||||
}
|
||||
|
||||
void cycleDirection() {
|
||||
_sessionState.cycleDirection();
|
||||
}
|
||||
|
||||
|
||||
|
||||
void cancelSession() {
|
||||
_sessionState.cancelSession();
|
||||
}
|
||||
@@ -358,6 +412,15 @@ class AppState extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setPauseQueueProcessing(bool enabled) async {
|
||||
await _settingsState.setPauseQueueProcessing(enabled);
|
||||
if (!enabled) {
|
||||
_startUploader(); // Resume upload queue processing
|
||||
} else {
|
||||
_uploadQueueState.stopUploader(); // Stop uploader when paused
|
||||
}
|
||||
}
|
||||
|
||||
set maxCameras(int n) {
|
||||
_settingsState.maxCameras = n;
|
||||
}
|
||||
@@ -392,7 +455,7 @@ class AppState extends ChangeNotifier {
|
||||
Future<void> setFollowMeMode(FollowMeMode mode) async {
|
||||
await _settingsState.setFollowMeMode(mode);
|
||||
}
|
||||
|
||||
|
||||
/// Set proximity alerts enabled/disabled
|
||||
Future<void> setProximityAlertsEnabled(bool enabled) async {
|
||||
await _settingsState.setProximityAlertsEnabled(enabled);
|
||||
@@ -408,6 +471,11 @@ class AppState extends ChangeNotifier {
|
||||
await _settingsState.setNetworkStatusIndicatorEnabled(enabled);
|
||||
}
|
||||
|
||||
/// Set suspected location minimum distance from real nodes
|
||||
Future<void> setSuspectedLocationMinDistance(int distance) async {
|
||||
await _settingsState.setSuspectedLocationMinDistance(distance);
|
||||
}
|
||||
|
||||
// ---------- Queue Methods ----------
|
||||
void clearQueue() {
|
||||
_uploadQueueState.clearQueue();
|
||||
@@ -422,6 +490,37 @@ class AppState extends ChangeNotifier {
|
||||
_startUploader(); // resume uploader if not busy
|
||||
}
|
||||
|
||||
// ---------- Suspected Location Methods ----------
|
||||
Future<void> setSuspectedLocationsEnabled(bool enabled) async {
|
||||
await _suspectedLocationState.setEnabled(enabled);
|
||||
}
|
||||
|
||||
Future<bool> refreshSuspectedLocations() async {
|
||||
return await _suspectedLocationState.refreshData();
|
||||
}
|
||||
|
||||
void selectSuspectedLocation(SuspectedLocation location) {
|
||||
_suspectedLocationState.selectLocation(location);
|
||||
}
|
||||
|
||||
void clearSuspectedLocationSelection() {
|
||||
_suspectedLocationState.clearSelection();
|
||||
}
|
||||
|
||||
List<SuspectedLocation> getSuspectedLocationsInBounds({
|
||||
required double north,
|
||||
required double south,
|
||||
required double east,
|
||||
required double west,
|
||||
}) {
|
||||
return _suspectedLocationState.getLocationsInBounds(
|
||||
north: north,
|
||||
south: south,
|
||||
east: east,
|
||||
west: west,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Private Methods ----------
|
||||
/// Attempts to fetch missing tile preview images in the background (fire and forget)
|
||||
void _fetchMissingTilePreviews() {
|
||||
@@ -435,6 +534,7 @@ class AppState extends ChangeNotifier {
|
||||
void _startUploader() {
|
||||
_uploadQueueState.startUploader(
|
||||
offlineMode: offlineMode,
|
||||
pauseQueueProcessing: pauseQueueProcessing,
|
||||
uploadMode: uploadMode,
|
||||
getAccessToken: _authState.getAccessToken,
|
||||
);
|
||||
@@ -449,6 +549,7 @@ class AppState extends ChangeNotifier {
|
||||
_searchState.removeListener(_onStateChanged);
|
||||
_sessionState.removeListener(_onStateChanged);
|
||||
_settingsState.removeListener(_onStateChanged);
|
||||
_suspectedLocationState.removeListener(_onStateChanged);
|
||||
_uploadQueueState.removeListener(_onStateChanged);
|
||||
|
||||
_uploadQueueState.dispose();
|
||||
|
||||
@@ -12,9 +12,12 @@ const int kPreviewTileY = 101300;
|
||||
const int kPreviewTileX = 41904;
|
||||
|
||||
// Direction cone for map view
|
||||
const double kDirectionConeHalfAngle = 30.0; // degrees
|
||||
const double kDirectionConeBaseLength = 0.001; // multiplier
|
||||
const Color kDirectionConeColor = Color(0xFF000000); // FOV cone color
|
||||
const double kDirectionConeHalfAngle = 35.0; // degrees
|
||||
const double kDirectionConeBaseLength = 5; // multiplier
|
||||
const Color kDirectionConeColor = Color(0xD0767474); // FOV cone color
|
||||
const double kDirectionConeOpacity = 0.5; // Fill opacity for FOV cones
|
||||
// Base values for thickness - use helper functions below for pixel-ratio scaling
|
||||
const double _kDirectionConeBorderWidthBase = 1.6;
|
||||
|
||||
// Bottom button bar positioning
|
||||
const double kBottomButtonBarOffset = 4.0; // Distance from screen bottom (above safe area)
|
||||
@@ -31,18 +34,37 @@ double bottomPositionFromButtonBar(double spacingAboveButtonBar, double safeArea
|
||||
return safeAreaBottom + kBottomButtonBarOffset + kButtonBarHeight + spacingAboveButtonBar;
|
||||
}
|
||||
|
||||
// Helper to get left positioning that accounts for safe area (for landscape mode)
|
||||
double leftPositionWithSafeArea(double baseLeft, EdgeInsets safeArea) {
|
||||
return baseLeft + safeArea.left;
|
||||
}
|
||||
|
||||
// Helper to get right positioning that accounts for safe area (for landscape mode)
|
||||
double rightPositionWithSafeArea(double baseRight, EdgeInsets safeArea) {
|
||||
return baseRight + safeArea.right;
|
||||
}
|
||||
|
||||
// Helper to get top positioning that accounts for safe area
|
||||
double topPositionWithSafeArea(double baseTop, EdgeInsets safeArea) {
|
||||
return baseTop + safeArea.top;
|
||||
}
|
||||
|
||||
// Client name for OSM uploads ("created_by" tag)
|
||||
const String kClientName = 'DeFlock';
|
||||
// Note: Version is now dynamically retrieved from VersionService
|
||||
|
||||
// Suspected locations CSV URL
|
||||
const String kSuspectedLocationsCsvUrl = 'https://stopflock.com/app/flock_utilities_mini_latest.csv';
|
||||
|
||||
// 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
|
||||
|
||||
// Navigation features - set to false to hide navigation UI elements while in development
|
||||
const bool kEnableNavigationFeatures = kEnableDevelopmentModes; // Hide navigation until fully implemented
|
||||
|
||||
// Node editing features - set to false to temporarily disable editing
|
||||
const bool kEnableNodeEdits = true; // Set to false to temporarily disable node editing
|
||||
|
||||
/// Navigation availability: only dev builds, and only when online
|
||||
bool enableNavigationFeatures({required bool offlineMode}) {
|
||||
if (!kEnableDevelopmentModes) {
|
||||
@@ -58,24 +80,48 @@ const int kOsmApiMinZoomLevel = 13; // Minimum zoom for OSM API bbox queries (sa
|
||||
const Duration kMarkerTapTimeout = Duration(milliseconds: 250);
|
||||
const Duration kDebounceCameraRefresh = Duration(milliseconds: 500);
|
||||
|
||||
// Pre-fetch area configuration
|
||||
const double kPreFetchAreaExpansionMultiplier = 3.0; // Expand visible bounds by this factor for pre-fetching
|
||||
const int kPreFetchZoomLevel = 10; // Always pre-fetch at this zoom level for consistent area sizes
|
||||
const int kMaxPreFetchSplitDepth = 3; // Maximum recursive splits when hitting Overpass node limit
|
||||
|
||||
// Data refresh configuration
|
||||
const int kDataRefreshIntervalSeconds = 60; // Refresh cached data after this many seconds
|
||||
|
||||
// Follow-me mode smooth transitions
|
||||
const Duration kFollowMeAnimationDuration = Duration(milliseconds: 600);
|
||||
const double kMinSpeedForRotationMps = 1.0; // Minimum speed (m/s) to apply rotation
|
||||
|
||||
// Sheet content configuration
|
||||
const double kMaxTagListHeightRatioPortrait = 0.3; // Maximum height for tag lists in portrait mode
|
||||
const double kMaxTagListHeightRatioLandscape = 0.2; // Maximum height for tag lists in landscape mode
|
||||
|
||||
/// Get appropriate tag list height ratio based on screen orientation
|
||||
double getTagListHeightRatio(BuildContext context) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
final isLandscape = size.width > size.height;
|
||||
return isLandscape ? kMaxTagListHeightRatioLandscape : kMaxTagListHeightRatioPortrait;
|
||||
}
|
||||
|
||||
// Proximity alerts configuration
|
||||
const int kProximityAlertDefaultDistance = 200; // meters
|
||||
const int kProximityAlertMinDistance = 50; // meters
|
||||
const int kProximityAlertMaxDistance = 1000; // meters
|
||||
const Duration kProximityAlertCooldown = Duration(minutes: 10); // Cooldown between alerts for same node
|
||||
|
||||
// Tile/OSM fetch retry parameters (for tunable backoff)
|
||||
const int kTileFetchMaxAttempts = 3;
|
||||
const int kTileFetchInitialDelayMs = 4000;
|
||||
const int kTileFetchJitter1Ms = 1000;
|
||||
const int kTileFetchSecondDelayMs = 15000;
|
||||
const int kTileFetchJitter2Ms = 4000;
|
||||
const int kTileFetchThirdDelayMs = 60000;
|
||||
const int kTileFetchJitter3Ms = 5000;
|
||||
// Map interaction configuration
|
||||
const double kNodeDoubleTapZoomDelta = 1.0; // How much to zoom in when double-tapping nodes (was 1.0)
|
||||
const double kScrollWheelVelocity = 0.01; // Mouse scroll wheel zoom speed (default 0.005)
|
||||
const double kPinchZoomThreshold = 0.2; // How much pinch required to start zoom (reduced for gesture race)
|
||||
const double kPinchMoveThreshold = 30.0; // How much drag required for two-finger pan (default 40.0)
|
||||
const double kRotationThreshold = 6.0; // Degrees of rotation required before map actually rotates (Google Maps style)
|
||||
|
||||
// Tile fetch retry parameters (configurable backoff system)
|
||||
const int kTileFetchMaxAttempts = 16; // Number of retry attempts before giving up
|
||||
const int kTileFetchInitialDelayMs = 500; // Base delay for first retry (1 second)
|
||||
const double kTileFetchBackoffMultiplier = 1.5; // Multiply delay by this each attempt
|
||||
const int kTileFetchMaxDelayMs = 10000; // Cap delays at this value (8 seconds max)
|
||||
const int kTileFetchRandomJitterMs = 250; // Random fuzz to add (0 to 500ms)
|
||||
|
||||
// User download max zoom span (user can download up to kMaxUserDownloadZoomSpan zooms above min)
|
||||
const int kMaxUserDownloadZoomSpan = 7;
|
||||
@@ -86,12 +132,27 @@ const int kAbsoluteMaxTileCount = 50000;
|
||||
const int kAbsoluteMaxZoom = 23;
|
||||
|
||||
// Node icon configuration
|
||||
const double kNodeIconDiameter = 20.0;
|
||||
const double kNodeRingThickness = 4.0;
|
||||
const double kNodeDotOpacity = 0.4; // Opacity for the grey dot interior
|
||||
const Color kNodeRingColorReal = Color(0xC43F55F3); // Real nodes from OSM - blue
|
||||
const Color kNodeRingColorMock = Color(0xC4FFFFFF); // Add node mock point - white
|
||||
const Color kNodeRingColorPending = Color(0xC49C27B0); // Submitted/pending nodes - purple
|
||||
const Color kNodeRingColorEditing = Color(0xC4FF9800); // Node being edited - orange
|
||||
const Color kNodeRingColorPendingEdit = Color(0xC4757575); // Original node with pending edit - grey
|
||||
const Color kNodeRingColorPendingDeletion = Color(0xA4F44336); // Node pending deletion - red, slightly transparent
|
||||
const double kNodeIconDiameter = 18.0;
|
||||
const double _kNodeRingThicknessBase = 2.5;
|
||||
const double kNodeDotOpacity = 0.3; // Opacity for the grey dot interior
|
||||
const Color kNodeRingColorReal = Color(0xFF3036F0); // Real nodes from OSM - blue
|
||||
const Color kNodeRingColorMock = Color(0xD0FFFFFF); // Add node mock point - white
|
||||
const Color kNodeRingColorPending = Color(0xD09C27B0); // Submitted/pending nodes - purple
|
||||
const Color kNodeRingColorEditing = Color(0xD0FF9800); // Node being edited - orange
|
||||
const Color kNodeRingColorPendingEdit = Color(0xD0757575); // Original node with pending edit - grey
|
||||
const Color kNodeRingColorPendingDeletion = Color(0xC0F44336); // Node pending deletion - red, slightly transparent
|
||||
|
||||
// Direction slider control buttons configuration
|
||||
const double kDirectionButtonMinWidth = 22.0;
|
||||
const double kDirectionButtonMinHeight = 32.0;
|
||||
|
||||
// Helper functions for pixel-ratio scaling
|
||||
double getDirectionConeBorderWidth(BuildContext context) {
|
||||
// return _kDirectionConeBorderWidthBase * MediaQuery.of(context).devicePixelRatio;
|
||||
return _kDirectionConeBorderWidthBase;
|
||||
}
|
||||
|
||||
double getNodeRingThickness(BuildContext context) {
|
||||
// return _kNodeRingThicknessBase * MediaQuery.of(context).devicePixelRatio;
|
||||
return _kNodeRingThicknessBase;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
// OpenStreetMap OAuth client IDs for this app.
|
||||
//
|
||||
// NEVER commit real secrets to public repos. For open source, use keys.dart.example instead.
|
||||
// These must be provided via --dart-define at build time.
|
||||
|
||||
const String kOsmProdClientId = 'U8p_n6IjZfQiL1KtdiwbB0-o9nto6CAKz7LC2GifJzk'; // example - replace with real
|
||||
const String kOsmSandboxClientId = 'SBHWpWTKf31EdSiTApnah3Fj2rLnk2pEwBORlX0NyZI'; // example - replace with real
|
||||
String get kOsmProdClientId {
|
||||
const fromBuild = String.fromEnvironment('OSM_PROD_CLIENTID');
|
||||
if (fromBuild.isNotEmpty) return fromBuild;
|
||||
|
||||
throw Exception('OSM_PROD_CLIENTID not configured. Use --dart-define=OSM_PROD_CLIENTID=your_id');
|
||||
}
|
||||
|
||||
String get kOsmSandboxClientId {
|
||||
const fromBuild = String.fromEnvironment('OSM_SANDBOX_CLIENTID');
|
||||
if (fromBuild.isNotEmpty) return fromBuild;
|
||||
|
||||
throw Exception('OSM_SANDBOX_CLIENTID not configured. Use --dart-define=OSM_SANDBOX_CLIENTID=your_id');
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
// Example OSM OAuth key config
|
||||
const String kOsmProdClientId = 'YOUR_PROD_CLIENT_ID_HERE';
|
||||
const String kOsmSandboxClientId = 'YOUR_SANDBOX_CLIENT_ID_HERE';
|
||||
@@ -16,11 +16,14 @@
|
||||
"close": "Schließen",
|
||||
"submit": "Senden",
|
||||
"saveEdit": "Bearbeitung Speichern",
|
||||
"clear": "Löschen"
|
||||
"clear": "Löschen",
|
||||
"viewOnOSM": "Auf OSM anzeigen",
|
||||
"advanced": "Erweitert",
|
||||
"useAdvancedEditor": "Erweiterten Editor verwenden"
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Verfolgung aktivieren (Norden oben)",
|
||||
"northUp": "Verfolgung aktivieren (Rotation)",
|
||||
"off": "Verfolgung aktivieren",
|
||||
"follow": "Verfolgung aktivieren (Rotation)",
|
||||
"rotating": "Verfolgung deaktivieren"
|
||||
},
|
||||
"settings": {
|
||||
@@ -36,6 +39,8 @@
|
||||
"maxNodesWarning": "Sie möchten das wahrscheinlich nicht tun, es sei denn, Sie sind absolut sicher, dass Sie einen guten Grund dafür haben.",
|
||||
"offlineMode": "Offline-Modus",
|
||||
"offlineModeSubtitle": "Alle Netzwerkanfragen außer für lokale/Offline-Bereiche deaktivieren.",
|
||||
"pauseQueueProcessing": "Upload-Warteschlange pausieren",
|
||||
"pauseQueueProcessingSubtitle": "Upload von wartenden Änderungen stoppen, aber Live-Datenzugriff beibehalten.",
|
||||
"offlineModeWarningTitle": "Aktive Downloads",
|
||||
"offlineModeWarningMessage": "Die Aktivierung des Offline-Modus bricht alle aktiven Bereichsdownloads ab. Möchten Sie fortfahren?",
|
||||
"enableOfflineMode": "Offline-Modus Aktivieren",
|
||||
@@ -72,6 +77,8 @@
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "Profil",
|
||||
"selectProfile": "Profil auswählen...",
|
||||
"profileRequired": "Bitte wählen Sie ein Profil aus, um fortzufahren.",
|
||||
"direction": "Richtung {}°",
|
||||
"profileNoDirectionInfo": "Dieses Profil benötigt keine Richtung.",
|
||||
"mustBeLoggedIn": "Sie müssen angemeldet sein, um neue Knoten zu übertragen. Bitte melden Sie sich über die Einstellungen an.",
|
||||
@@ -83,12 +90,16 @@
|
||||
"editNode": {
|
||||
"title": "Knoten #{} Bearbeiten",
|
||||
"profile": "Profil",
|
||||
"selectProfile": "Profil auswählen...",
|
||||
"profileRequired": "Bitte wählen Sie ein Profil aus, um fortzufahren.",
|
||||
"direction": "Richtung {}°",
|
||||
"profileNoDirectionInfo": "Dieses Profil benötigt keine Richtung.",
|
||||
"temporarilyDisabled": "Bearbeitungen wurden vorübergehend deaktiviert, während wir einen Fehler beheben - Entschuldigung - schauen Sie bald wieder vorbei.",
|
||||
"mustBeLoggedIn": "Sie müssen angemeldet sein, um Knoten zu bearbeiten. Bitte melden Sie sich über die Einstellungen an.",
|
||||
"sandboxModeWarning": "Bearbeitungen von Produktionsknoten können nicht an die Sandbox übertragen werden. Wechseln Sie in den Produktionsmodus in den Einstellungen, um Knoten zu bearbeiten.",
|
||||
"enableSubmittableProfile": "Aktivieren Sie ein übertragbares Profil in den Einstellungen, um Knoten zu bearbeiten.",
|
||||
"profileViewOnlyWarning": "Dieses Profil ist nur zum Anzeigen der Karte gedacht. Bitte wählen Sie ein übertragbares Profil aus, um Knoten zu bearbeiten.",
|
||||
"cannotMoveConstrainedNode": "Kann diese Kamera nicht verschieben - sie ist mit einem anderen Kartenelement verbunden (OSM-Weg/Relation). Sie können trotzdem ihre Tags und Richtung bearbeiten.",
|
||||
"refineTags": "Tags Verfeinern",
|
||||
"refineTagsWithProfile": "Tags Verfeinern ({})"
|
||||
},
|
||||
@@ -124,7 +135,12 @@
|
||||
"testConnection": "Verbindung Testen",
|
||||
"testConnectionSubtitle": "OSM-Anmeldedaten überprüfen",
|
||||
"connectionOK": "Verbindung OK - Anmeldedaten sind gültig",
|
||||
"connectionFailed": "Verbindung fehlgeschlagen - bitte erneut anmelden"
|
||||
"connectionFailed": "Verbindung fehlgeschlagen - bitte erneut anmelden",
|
||||
"deleteAccount": "OSM-Konto Löschen",
|
||||
"deleteAccountSubtitle": "Ihr OpenStreetMap-Konto verwalten",
|
||||
"deleteAccountExplanation": "Um Ihr OpenStreetMap-Konto zu löschen, müssen Sie die OpenStreetMap-Website besuchen. Dies entfernt dauerhaft Ihr OSM-Konto und alle zugehörigen Daten.",
|
||||
"deleteAccountWarning": "Warnung: Diese Aktion kann nicht rückgängig gemacht werden und löscht Ihr OSM-Konto dauerhaft.",
|
||||
"goToOSM": "Zu OpenStreetMap gehen"
|
||||
},
|
||||
"queue": {
|
||||
"pendingUploads": "Ausstehende Uploads: {}",
|
||||
@@ -295,9 +311,38 @@
|
||||
"selectMapLayer": "Kartenschicht Auswählen",
|
||||
"noTileProvidersAvailable": "Keine Kachel-Anbieter verfügbar"
|
||||
},
|
||||
"advancedEdit": {
|
||||
"title": "Erweiterte Bearbeitungsoptionen",
|
||||
"subtitle": "Diese Editoren bieten erweiterte Funktionen für komplexe Bearbeitungen.",
|
||||
"webEditors": "Web-Editoren",
|
||||
"mobileEditors": "Mobile Editoren",
|
||||
"iDEditor": "iD Editor",
|
||||
"iDEditorSubtitle": "Voll ausgestatteter Web-Editor - funktioniert immer",
|
||||
"rapidEditor": "RapiD Editor",
|
||||
"rapidEditorSubtitle": "KI-unterstütztes Bearbeiten mit Facebook-Daten",
|
||||
"vespucci": "Vespucci",
|
||||
"vespucciSubtitle": "Erweiterte Android OSM-Editor",
|
||||
"streetComplete": "StreetComplete",
|
||||
"streetCompleteSubtitle": "Umfragebasierte Mapping-App",
|
||||
"everyDoor": "EveryDoor",
|
||||
"everyDoorSubtitle": "Schnelle POI-Bearbeitung",
|
||||
"goMap": "Go Map!!",
|
||||
"goMapSubtitle": "iOS OSM-Editor",
|
||||
"couldNotOpenEditor": "Editor konnte nicht geöffnet werden - App möglicherweise nicht installiert",
|
||||
"couldNotOpenURL": "URL konnte nicht geöffnet werden",
|
||||
"couldNotOpenOSMWebsite": "OSM-Website konnte nicht geöffnet werden"
|
||||
},
|
||||
"networkStatus": {
|
||||
"showIndicator": "Netzwerkstatus-Anzeige anzeigen",
|
||||
"showIndicatorSubtitle": "Netzwerk-Ladestatus und Fehlerstatus auf der Karte anzeigen"
|
||||
"showIndicatorSubtitle": "Netzwerk-Ladestatus und Fehlerstatus auf der Karte anzeigen",
|
||||
"loading": "Lädt...",
|
||||
"timedOut": "Zeitüberschreitung",
|
||||
"noData": "Keine Kacheln hier",
|
||||
"success": "Fertig",
|
||||
"nodeLimitReached": "Limit erreicht - in Einstellungen erhöhen",
|
||||
"tileProviderSlow": "Kartenanbieter langsam",
|
||||
"nodeDataSlow": "Knotendaten langsam",
|
||||
"networkIssues": "Netzwerkprobleme"
|
||||
},
|
||||
"about": {
|
||||
"title": "DeFlock - Überwachungs-Transparenz",
|
||||
@@ -306,6 +351,17 @@
|
||||
"initiative": "Teil der breiteren DeFlock-Initiative zur Förderung von Überwachungstransparenz.",
|
||||
"footer": "Besuchen Sie: deflock.me\nGebaut mit Flutter • Open Source"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Willkommen bei DeFlock",
|
||||
"description": "DeFlock wurde auf der Idee gegründet, dass öffentliche Überwachungsinstrumente transparent sein sollten. In dieser mobilen App, wie auch auf der Website, können Sie die Standorte von ALPRs und anderer Überwachungsinfrastruktur in Ihrer Umgebung und weltweit einsehen.",
|
||||
"mission": "Dieses Projekt ist jedoch nicht automatisiert; es braucht uns alle, um dieses Projekt zu verbessern. Bei der Kartenansicht können Sie auf \"Neuer Knoten\" tippen, um eine bisher unbekannte Installation hinzuzufügen. Mit Ihrer Hilfe können wir unser Ziel erreichen: mehr Transparenz und öffentliches Bewusstsein für Überwachungsinfrastruktur.",
|
||||
"firsthandKnowledge": "WICHTIG: Tragen Sie nur Überwachungsgeräte bei, die Sie persönlich aus erster Hand beobachtet haben. OpenStreetMap- und Google-Richtlinien verbieten die Nutzung von Quellen wie Street View-Bildern für Beiträge. Ihre Beiträge sollten auf Ihren eigenen direkten, persönlichen Beobachtungen basieren.",
|
||||
"privacy": "Datenschutzhinweis: Diese App läuft vollständig lokal auf Ihrem Gerät und nutzt die OpenStreetMap-API von Drittanbietern nur für Datenspeicherung und Übermittlungen. DeFlock sammelt oder speichert keinerlei Nutzerdaten und ist nicht für die Kontoverwaltung verantwortlich.",
|
||||
"tileNote": "HINWEIS: Die kostenlosen Kartenkacheln von OpenStreetMap können sehr langsam laden. Alternative Kartenanbieter können unter Einstellungen > Erweitert konfiguriert werden.",
|
||||
"moreInfo": "Weitere Links finden Sie unter Einstellungen > Über.",
|
||||
"dontShowAgain": "Diese Willkommensnachricht nicht mehr anzeigen",
|
||||
"getStarted": "Los geht's mit DeFlocking!"
|
||||
},
|
||||
"navigation": {
|
||||
"searchLocation": "Ort suchen",
|
||||
"searchPlaceholder": "Orte oder Koordinaten suchen...",
|
||||
@@ -341,5 +397,40 @@
|
||||
"imperial": "Britisch (mi, ft)",
|
||||
"meters": "Meter",
|
||||
"feet": "Fuß"
|
||||
},
|
||||
"suspectedLocations": {
|
||||
"title": "Verdächtige Standorte",
|
||||
"showSuspectedLocations": "Verdächtige Standorte anzeigen",
|
||||
"showSuspectedLocationsSubtitle": "Fragezeichen-Marker für vermutete Überwachungsstandorte aus Versorgungsgenehmigungsdaten anzeigen",
|
||||
"lastUpdated": "Zuletzt aktualisiert",
|
||||
"refreshNow": "Jetzt aktualisieren",
|
||||
"dataSource": "Datenquelle",
|
||||
"dataSourceDescription": "Versorgungsgenehmigungsdaten, die auf potenzielle Installationsstandorte für Überwachungsinfrastruktur hinweisen",
|
||||
"dataSourceCredit": "Datensammlung und -hosting bereitgestellt von alprwatch.org",
|
||||
"minimumDistance": "Mindestabstand zu echten Geräten",
|
||||
"minimumDistanceSubtitle": "Verdächtige Standorte innerhalb von {}m vorhandener Überwachungsgeräte ausblenden",
|
||||
"updating": "Verdächtige Standorte werden aktualisiert",
|
||||
"downloadingAndProcessing": "Daten werden heruntergeladen und verarbeitet...",
|
||||
"updateSuccess": "Verdächtige Standorte erfolgreich aktualisiert",
|
||||
"updateFailed": "Aktualisierung der verdächtigen Standorte fehlgeschlagen",
|
||||
"neverFetched": "Nie abgerufen",
|
||||
"daysAgo": "vor {} Tagen",
|
||||
"hoursAgo": "vor {} Stunden",
|
||||
"minutesAgo": "vor {} Minuten",
|
||||
"justNow": "Gerade eben"
|
||||
},
|
||||
"suspectedLocation": {
|
||||
"title": "Verdächtiger Standort #{}",
|
||||
"ticketNo": "Ticket-Nr.",
|
||||
"address": "Adresse",
|
||||
"street": "Straße",
|
||||
"city": "Stadt",
|
||||
"state": "Bundesland",
|
||||
"intersectingStreet": "Kreuzende Straße",
|
||||
"workDoneFor": "Arbeit ausgeführt für",
|
||||
"remarks": "Bemerkungen",
|
||||
"url": "URL",
|
||||
"coordinates": "Koordinaten",
|
||||
"noAddressAvailable": "Keine Adresse verfügbar"
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,17 @@
|
||||
"initiative": "Part of the broader DeFlock initiative to promote surveillance transparency.",
|
||||
"footer": "Visit: deflock.me\nBuilt with Flutter • Open Source"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Welcome to DeFlock",
|
||||
"description": "DeFlock was founded on the idea that public surveillance tools should be transparent. Within this mobile app, as on the website, you will be able to view the location of ALPRs and other surveillance infrastructure in your local area and abroad.",
|
||||
"mission": "However, this project isn't automated; it takes all of us to make this project better. When viewing the map, you can tap \"New Node\" to add a previously unknown installation. With your help, we can achieve our goal of increased transparency and public awareness of surveillance infrastructure.",
|
||||
"firsthandKnowledge": "IMPORTANT: Only contribute surveillance devices that you have personally observed firsthand. OpenStreetMap and Google policies prohibit using sources like Street View imagery for submissions. Your contributions should be based on your own direct observations.",
|
||||
"privacy": "Privacy Note: This app runs entirely locally on your device and uses the third-party OpenStreetMap API for data storage and submissions. DeFlock does not collect or store any user data of any kind, and is not responsible for account management.",
|
||||
"tileNote": "NOTE: The free map tiles from OpenStreetMap can be very slow to load. Alternate tile providers can be configured in Settings > Advanced.",
|
||||
"moreInfo": "You can find more links under Settings > About.",
|
||||
"dontShowAgain": "Don't show this welcome message again",
|
||||
"getStarted": "Let's Get DeFlocking!"
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "New Node",
|
||||
"download": "Download",
|
||||
@@ -23,11 +34,14 @@
|
||||
"close": "Close",
|
||||
"submit": "Submit",
|
||||
"saveEdit": "Save Edit",
|
||||
"clear": "Clear"
|
||||
"clear": "Clear",
|
||||
"viewOnOSM": "View on OSM",
|
||||
"advanced": "Advanced",
|
||||
"useAdvancedEditor": "Use Advanced Editor"
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Enable follow-me (north up)",
|
||||
"northUp": "Enable follow-me (rotating)",
|
||||
"off": "Enable follow-me",
|
||||
"follow": "Enable follow-me (rotating)",
|
||||
"rotating": "Disable follow-me"
|
||||
},
|
||||
"settings": {
|
||||
@@ -43,6 +57,8 @@
|
||||
"maxNodesWarning": "You probably don't want to do that unless you are absolutely sure you have a good reason for it.",
|
||||
"offlineMode": "Offline Mode",
|
||||
"offlineModeSubtitle": "Disable all network requests except for local/offline areas.",
|
||||
"pauseQueueProcessing": "Pause Upload Queue",
|
||||
"pauseQueueProcessingSubtitle": "Stop uploading queued changes while keeping live data access.",
|
||||
"offlineModeWarningTitle": "Active Downloads",
|
||||
"offlineModeWarningMessage": "Enabling offline mode will cancel any active area downloads. Do you want to continue?",
|
||||
"enableOfflineMode": "Enable Offline Mode",
|
||||
@@ -79,6 +95,8 @@
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "Profile",
|
||||
"selectProfile": "Select a profile...",
|
||||
"profileRequired": "Please select a profile to continue.",
|
||||
"direction": "Direction {}°",
|
||||
"profileNoDirectionInfo": "This profile does not require a direction.",
|
||||
"mustBeLoggedIn": "You must be logged in to submit new nodes. Please log in via Settings.",
|
||||
@@ -90,12 +108,16 @@
|
||||
"editNode": {
|
||||
"title": "Edit Node #{}",
|
||||
"profile": "Profile",
|
||||
"selectProfile": "Select a profile...",
|
||||
"profileRequired": "Please select a profile to continue.",
|
||||
"direction": "Direction {}°",
|
||||
"profileNoDirectionInfo": "This profile does not require a direction.",
|
||||
"temporarilyDisabled": "Edits have been temporarily disabled while we sort out a bug - apologies - check back soon.",
|
||||
"mustBeLoggedIn": "You must be logged in to edit nodes. Please log in via Settings.",
|
||||
"sandboxModeWarning": "Cannot submit edits on production nodes to sandbox. Switch to Production mode in Settings to edit nodes.",
|
||||
"enableSubmittableProfile": "Enable a submittable profile in Settings to edit nodes.",
|
||||
"profileViewOnlyWarning": "This profile is for map viewing only. Please select a submittable profile to edit nodes.",
|
||||
"cannotMoveConstrainedNode": "Cannot move this camera - it's connected to another map element (OSM way/relation). You can still edit its tags and direction.",
|
||||
"refineTags": "Refine Tags",
|
||||
"refineTagsWithProfile": "Refine Tags ({})"
|
||||
},
|
||||
@@ -131,7 +153,12 @@
|
||||
"testConnection": "Test Connection",
|
||||
"testConnectionSubtitle": "Verify OSM credentials are working",
|
||||
"connectionOK": "Connection OK - credentials are valid",
|
||||
"connectionFailed": "Connection failed - please re-login"
|
||||
"connectionFailed": "Connection failed - please re-login",
|
||||
"deleteAccount": "Delete OSM Account",
|
||||
"deleteAccountSubtitle": "Manage your OpenStreetMap account",
|
||||
"deleteAccountExplanation": "To delete your OpenStreetMap account, you'll need to visit the OpenStreetMap website. This will permanently remove your OSM account and all associated data.",
|
||||
"deleteAccountWarning": "Warning: This action cannot be undone and will permanently delete your OSM account.",
|
||||
"goToOSM": "Go to OpenStreetMap"
|
||||
},
|
||||
"queue": {
|
||||
"pendingUploads": "Pending uploads: {}",
|
||||
@@ -302,9 +329,38 @@
|
||||
"selectMapLayer": "Select Map Layer",
|
||||
"noTileProvidersAvailable": "No tile providers available"
|
||||
},
|
||||
"advancedEdit": {
|
||||
"title": "Advanced Editing Options",
|
||||
"subtitle": "These editors offer more advanced features for complex edits.",
|
||||
"webEditors": "Web Editors",
|
||||
"mobileEditors": "Mobile Editors",
|
||||
"iDEditor": "iD Editor",
|
||||
"iDEditorSubtitle": "Full-featured web editor - always works",
|
||||
"rapidEditor": "RapiD Editor",
|
||||
"rapidEditorSubtitle": "AI-assisted editing with Facebook data",
|
||||
"vespucci": "Vespucci",
|
||||
"vespucciSubtitle": "Advanced Android OSM editor",
|
||||
"streetComplete": "StreetComplete",
|
||||
"streetCompleteSubtitle": "Survey-based mapping app",
|
||||
"everyDoor": "EveryDoor",
|
||||
"everyDoorSubtitle": "Fast POI editing",
|
||||
"goMap": "Go Map!!",
|
||||
"goMapSubtitle": "iOS OSM editor",
|
||||
"couldNotOpenEditor": "Could not open editor - app may not be installed",
|
||||
"couldNotOpenURL": "Could not open URL",
|
||||
"couldNotOpenOSMWebsite": "Could not open OSM website"
|
||||
},
|
||||
"networkStatus": {
|
||||
"showIndicator": "Show network status indicator",
|
||||
"showIndicatorSubtitle": "Display network loading and error status on the map"
|
||||
"showIndicatorSubtitle": "Display network loading and error status on the map",
|
||||
"loading": "Loading...",
|
||||
"timedOut": "Timed out",
|
||||
"noData": "No tiles here",
|
||||
"success": "Done",
|
||||
"nodeLimitReached": "Showing limit - increase in settings",
|
||||
"tileProviderSlow": "Tile provider slow",
|
||||
"nodeDataSlow": "Node data slow",
|
||||
"networkIssues": "Network issues"
|
||||
},
|
||||
"navigation": {
|
||||
"searchLocation": "Search Location",
|
||||
@@ -341,5 +397,40 @@
|
||||
"imperial": "Imperial (mi, ft)",
|
||||
"meters": "meters",
|
||||
"feet": "feet"
|
||||
},
|
||||
"suspectedLocations": {
|
||||
"title": "Suspected Locations",
|
||||
"showSuspectedLocations": "Show Suspected Locations",
|
||||
"showSuspectedLocationsSubtitle": "Show question mark markers for suspected surveillance sites from utility permit data",
|
||||
"lastUpdated": "Last Updated",
|
||||
"refreshNow": "Refresh now",
|
||||
"dataSource": "Data Source",
|
||||
"dataSourceDescription": "Utility permit data indicating potential surveillance infrastructure installation sites",
|
||||
"dataSourceCredit": "Data collection and hosting provided by alprwatch.org",
|
||||
"minimumDistance": "Minimum Distance from Real Nodes",
|
||||
"minimumDistanceSubtitle": "Hide suspected locations within {}m of existing surveillance devices",
|
||||
"updating": "Updating Suspected Locations",
|
||||
"downloadingAndProcessing": "Downloading and processing data...",
|
||||
"updateSuccess": "Suspected locations updated successfully",
|
||||
"updateFailed": "Failed to update suspected locations",
|
||||
"neverFetched": "Never fetched",
|
||||
"daysAgo": "{} days ago",
|
||||
"hoursAgo": "{} hours ago",
|
||||
"minutesAgo": "{} minutes ago",
|
||||
"justNow": "Just now"
|
||||
},
|
||||
"suspectedLocation": {
|
||||
"title": "Suspected Location #{}",
|
||||
"ticketNo": "Ticket No",
|
||||
"address": "Address",
|
||||
"street": "Street",
|
||||
"city": "City",
|
||||
"state": "State",
|
||||
"intersectingStreet": "Intersecting Street",
|
||||
"workDoneFor": "Work Done For",
|
||||
"remarks": "Remarks",
|
||||
"url": "URL",
|
||||
"coordinates": "Coordinates",
|
||||
"noAddressAvailable": "No address available"
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,17 @@
|
||||
"initiative": "Parte de la iniciativa más amplia DeFlock para promover la transparencia en vigilancia.",
|
||||
"footer": "Visita: deflock.me\nConstruido con Flutter • Código Abierto"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Bienvenido a DeFlock",
|
||||
"description": "DeFlock fue fundado sobre la idea de que las herramientas de vigilancia pública deben ser transparentes. Dentro de esta aplicación móvil, como en el sitio web, podrás ver la ubicación de ALPRs y otra infraestructura de vigilancia en tu área local y en el extranjero.",
|
||||
"mission": "Sin embargo, este proyecto no es automatizado; todos nosotros somos necesarios para mejorarlo. Al ver el mapa, puedes tocar \"Nuevo Nodo\" para agregar una instalación previamente desconocida. Con tu ayuda, podemos lograr nuestro objetivo de mayor transparencia y conciencia pública sobre la infraestructura de vigilancia.",
|
||||
"firsthandKnowledge": "IMPORTANTE: Solo contribuye con dispositivos de vigilancia que hayas observado personalmente de primera mano. Las políticas de OpenStreetMap y Google prohíben el uso de fuentes como imágenes de Street View para las contribuciones. Tus contribuciones deben basarse en tus propias observaciones directas y en persona.",
|
||||
"privacy": "Nota de Privacidad: Esta aplicación funciona completamente de forma local en tu dispositivo y utiliza la API de terceros de OpenStreetMap solo para almacenamiento y envío de datos. DeFlock no recopila ni almacena ningún tipo de datos de usuario, y no es responsable de la gestión de cuentas.",
|
||||
"tileNote": "NOTA: Los mosaicos gratuitos de mapa de OpenStreetMap pueden tardar mucho en cargar. Se pueden configurar proveedores alternativos de mosaicos en Configuración > Avanzado.",
|
||||
"moreInfo": "Puedes encontrar más enlaces en Configuración > Acerca de.",
|
||||
"dontShowAgain": "No mostrar este mensaje de bienvenida otra vez",
|
||||
"getStarted": "¡Comencemos con DeFlock!"
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "Nuevo Nodo",
|
||||
"download": "Descargar",
|
||||
@@ -23,11 +34,14 @@
|
||||
"close": "Cerrar",
|
||||
"submit": "Enviar",
|
||||
"saveEdit": "Guardar Edición",
|
||||
"clear": "Limpiar"
|
||||
"clear": "Limpiar",
|
||||
"viewOnOSM": "Ver en OSM",
|
||||
"advanced": "Avanzado",
|
||||
"useAdvancedEditor": "Usar Editor Avanzado"
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Activar seguimiento (norte arriba)",
|
||||
"northUp": "Activar seguimiento (rotación)",
|
||||
"off": "Activar seguimiento",
|
||||
"follow": "Activar seguimiento (rotación)",
|
||||
"rotating": "Desactivar seguimiento"
|
||||
},
|
||||
"settings": {
|
||||
@@ -43,6 +57,8 @@
|
||||
"maxNodesWarning": "Probablemente no quieras hacer eso a menos que estés absolutamente seguro de que tienes una buena razón para ello.",
|
||||
"offlineMode": "Modo Sin Conexión",
|
||||
"offlineModeSubtitle": "Deshabilitar todas las solicitudes de red excepto para áreas locales/sin conexión.",
|
||||
"pauseQueueProcessing": "Pausar Cola de Subida",
|
||||
"pauseQueueProcessingSubtitle": "Detener la subida de cambios en cola manteniendo acceso a datos en vivo.",
|
||||
"offlineModeWarningTitle": "Descargas Activas",
|
||||
"offlineModeWarningMessage": "Habilitar el modo sin conexión cancelará cualquier descarga de área activa. ¿Desea continuar?",
|
||||
"enableOfflineMode": "Habilitar Modo Sin Conexión",
|
||||
@@ -79,6 +95,8 @@
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "Perfil",
|
||||
"selectProfile": "Seleccionar un perfil...",
|
||||
"profileRequired": "Por favor, seleccione un perfil para continuar.",
|
||||
"direction": "Dirección {}°",
|
||||
"profileNoDirectionInfo": "Este perfil no requiere una dirección.",
|
||||
"mustBeLoggedIn": "Debe estar conectado para enviar nuevos nodos. Por favor, inicie sesión a través de Configuración.",
|
||||
@@ -90,12 +108,16 @@
|
||||
"editNode": {
|
||||
"title": "Editar Nodo #{}",
|
||||
"profile": "Perfil",
|
||||
"selectProfile": "Seleccionar un perfil...",
|
||||
"profileRequired": "Por favor, seleccione un perfil para continuar.",
|
||||
"direction": "Dirección {}°",
|
||||
"profileNoDirectionInfo": "Este perfil no requiere una dirección.",
|
||||
"temporarilyDisabled": "Las ediciones han sido temporalmente deshabilitadas mientras solucionamos un error - disculpas - regrese pronto.",
|
||||
"mustBeLoggedIn": "Debe estar conectado para editar nodos. Por favor, inicie sesión a través de Configuración.",
|
||||
"sandboxModeWarning": "No se pueden enviar ediciones de nodos de producción al sandbox. Cambie al modo Producción en Configuración para editar nodos.",
|
||||
"enableSubmittableProfile": "Habilite un perfil envíable en Configuración para editar nodos.",
|
||||
"profileViewOnlyWarning": "Este perfil es solo para visualización del mapa. Por favor, seleccione un perfil envíable para editar nodos.",
|
||||
"cannotMoveConstrainedNode": "No se puede mover esta cámara - está conectada a otro elemento del mapa (OSM way/relation). Aún puede editar sus etiquetas y dirección.",
|
||||
"refineTags": "Refinar Etiquetas",
|
||||
"refineTagsWithProfile": "Refinar Etiquetas ({})"
|
||||
},
|
||||
@@ -131,7 +153,12 @@
|
||||
"testConnection": "Probar Conexión",
|
||||
"testConnectionSubtitle": "Verificar que las credenciales de OSM funcionen",
|
||||
"connectionOK": "Conexión OK - las credenciales son válidas",
|
||||
"connectionFailed": "Conexión falló - por favor, inicie sesión nuevamente"
|
||||
"connectionFailed": "Conexión falló - por favor, inicie sesión nuevamente",
|
||||
"deleteAccount": "Eliminar Cuenta OSM",
|
||||
"deleteAccountSubtitle": "Gestiona tu cuenta de OpenStreetMap",
|
||||
"deleteAccountExplanation": "Para eliminar tu cuenta de OpenStreetMap, necesitarás visitar el sitio web de OpenStreetMap. Esto eliminará permanentemente tu cuenta OSM y todos los datos asociados.",
|
||||
"deleteAccountWarning": "Advertencia: Esta acción no se puede deshacer y eliminará permanentemente tu cuenta OSM.",
|
||||
"goToOSM": "Ir a OpenStreetMap"
|
||||
},
|
||||
"queue": {
|
||||
"pendingUploads": "Subidas pendientes: {}",
|
||||
@@ -302,9 +329,38 @@
|
||||
"selectMapLayer": "Seleccionar Capa del Mapa",
|
||||
"noTileProvidersAvailable": "No hay proveedores de teselas disponibles"
|
||||
},
|
||||
"advancedEdit": {
|
||||
"title": "Opciones de Edición Avanzada",
|
||||
"subtitle": "Estos editores ofrecen funciones más avanzadas para ediciones complejas.",
|
||||
"webEditors": "Editores Web",
|
||||
"mobileEditors": "Editores Móviles",
|
||||
"iDEditor": "Editor iD",
|
||||
"iDEditorSubtitle": "Editor web completo - siempre funciona",
|
||||
"rapidEditor": "Editor RapiD",
|
||||
"rapidEditorSubtitle": "Edición asistida por IA con datos de Facebook",
|
||||
"vespucci": "Vespucci",
|
||||
"vespucciSubtitle": "Editor OSM avanzado para Android",
|
||||
"streetComplete": "StreetComplete",
|
||||
"streetCompleteSubtitle": "Aplicación de mapeo basada en encuestas",
|
||||
"everyDoor": "EveryDoor",
|
||||
"everyDoorSubtitle": "Edición rápida de POI",
|
||||
"goMap": "Go Map!!",
|
||||
"goMapSubtitle": "Editor OSM para iOS",
|
||||
"couldNotOpenEditor": "No se pudo abrir el editor - la aplicación puede no estar instalada",
|
||||
"couldNotOpenURL": "No se pudo abrir la URL",
|
||||
"couldNotOpenOSMWebsite": "No se pudo abrir el sitio web de OSM"
|
||||
},
|
||||
"networkStatus": {
|
||||
"showIndicator": "Mostrar indicador de estado de red",
|
||||
"showIndicatorSubtitle": "Mostrar estado de carga y errores de red en el mapa"
|
||||
"showIndicatorSubtitle": "Mostrar estado de carga y errores de red en el mapa",
|
||||
"loading": "Cargando...",
|
||||
"timedOut": "Tiempo agotado",
|
||||
"noData": "Sin mosaicos aquí",
|
||||
"success": "Hecho",
|
||||
"nodeLimitReached": "Mostrando límite - aumentar en ajustes",
|
||||
"tileProviderSlow": "Proveedor de mosaicos lento",
|
||||
"nodeDataSlow": "Datos de nodo lentos",
|
||||
"networkIssues": "Problemas de red"
|
||||
},
|
||||
"navigation": {
|
||||
"searchLocation": "Buscar ubicación",
|
||||
@@ -341,5 +397,40 @@
|
||||
"imperial": "Imperial (mi, ft)",
|
||||
"meters": "metros",
|
||||
"feet": "pies"
|
||||
},
|
||||
"suspectedLocations": {
|
||||
"title": "Ubicaciones Sospechosas",
|
||||
"showSuspectedLocations": "Mostrar Ubicaciones Sospechosas",
|
||||
"showSuspectedLocationsSubtitle": "Mostrar marcadores de interrogación para sitios de vigilancia sospechosos de datos de permisos de servicios públicos",
|
||||
"lastUpdated": "Última Actualización",
|
||||
"refreshNow": "Actualizar ahora",
|
||||
"dataSource": "Fuente de Datos",
|
||||
"dataSourceDescription": "Datos de permisos de servicios públicos que indican posibles sitios de instalación de infraestructura de vigilancia",
|
||||
"dataSourceCredit": "Recopilación y alojamiento de datos proporcionado por alprwatch.org",
|
||||
"minimumDistance": "Distancia Mínima de Nodos Reales",
|
||||
"minimumDistanceSubtitle": "Ocultar ubicaciones sospechosas dentro de {}m de dispositivos de vigilancia existentes",
|
||||
"updating": "Actualizando Ubicaciones Sospechosas",
|
||||
"downloadingAndProcessing": "Descargando y procesando datos...",
|
||||
"updateSuccess": "Ubicaciones sospechosas actualizadas exitosamente",
|
||||
"updateFailed": "Error al actualizar ubicaciones sospechosas",
|
||||
"neverFetched": "Nunca obtenido",
|
||||
"daysAgo": "hace {} días",
|
||||
"hoursAgo": "hace {} horas",
|
||||
"minutesAgo": "hace {} minutos",
|
||||
"justNow": "Ahora mismo"
|
||||
},
|
||||
"suspectedLocation": {
|
||||
"title": "Ubicación Sospechosa #{}",
|
||||
"ticketNo": "No. de Ticket",
|
||||
"address": "Dirección",
|
||||
"street": "Calle",
|
||||
"city": "Ciudad",
|
||||
"state": "Estado",
|
||||
"intersectingStreet": "Calle que Intersecta",
|
||||
"workDoneFor": "Trabajo Realizado Para",
|
||||
"remarks": "Observaciones",
|
||||
"url": "URL",
|
||||
"coordinates": "Coordenadas",
|
||||
"noAddressAvailable": "No hay dirección disponible"
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,17 @@
|
||||
"initiative": "Partie de l'initiative plus large DeFlock pour promouvoir la transparence de la surveillance.",
|
||||
"footer": "Visitez : deflock.me\nConstruit avec Flutter • Source Ouverte"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Bienvenue dans DeFlock",
|
||||
"description": "DeFlock a été fondé sur l'idée que les outils de surveillance publique devraient être transparents. Dans cette application mobile, comme sur le site web, vous pourrez voir l'emplacement des ALPRs et autres infrastructures de surveillance dans votre région et à l'étranger.",
|
||||
"mission": "Cependant, ce projet n'est pas automatisé ; il nous faut tous pour améliorer ce projet. En visualisant la carte, vous pouvez appuyer sur \"Nouveau Nœud\" pour ajouter une installation précédemment inconnue. Avec votre aide, nous pouvons atteindre notre objectif d'augmenter la transparence et la sensibilisation du public à l'infrastructure de surveillance.",
|
||||
"firsthandKnowledge": "IMPORTANT : Ne contribuez qu'aux dispositifs de surveillance que vous avez personnellement observés de première main. Les politiques d'OpenStreetMap et de Google interdisent l'utilisation de sources comme les images Street View pour les contributions. Vos contributions doivent être basées sur vos propres observations directes et en personne.",
|
||||
"privacy": "Note de Confidentialité : Cette application fonctionne entièrement localement sur votre appareil et utilise l'API tierce OpenStreetMap uniquement pour le stockage et la soumission de données. DeFlock ne collecte ni ne stocke aucune donnée utilisateur de quelque nature que ce soit, et n'est pas responsable de la gestion des comptes.",
|
||||
"tileNote": "NOTE : Les tuiles de carte gratuites d'OpenStreetMap peuvent être très lentes à charger. Des fournisseurs de tuiles alternatifs peuvent être configurés dans Paramètres > Avancé.",
|
||||
"moreInfo": "Vous pouvez trouver plus de liens sous Paramètres > À propos.",
|
||||
"dontShowAgain": "Ne plus afficher ce message de bienvenue",
|
||||
"getStarted": "Commençons le DeFlock !"
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "Nouveau Nœud",
|
||||
"download": "Télécharger",
|
||||
@@ -23,11 +34,14 @@
|
||||
"close": "Fermer",
|
||||
"submit": "Soumettre",
|
||||
"saveEdit": "Sauvegarder Modification",
|
||||
"clear": "Effacer"
|
||||
"clear": "Effacer",
|
||||
"viewOnOSM": "Voir sur OSM",
|
||||
"advanced": "Avancé",
|
||||
"useAdvancedEditor": "Utiliser l'Éditeur Avancé"
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Activer le suivi (nord en haut)",
|
||||
"northUp": "Activer le suivi (rotation)",
|
||||
"off": "Activer le suivi",
|
||||
"follow": "Activer le suivi (rotation)",
|
||||
"rotating": "Désactiver le suivi"
|
||||
},
|
||||
"settings": {
|
||||
@@ -43,6 +57,8 @@
|
||||
"maxNodesWarning": "Vous ne voulez probablement pas faire cela à moins d'être absolument sûr d'avoir une bonne raison de le faire.",
|
||||
"offlineMode": "Mode Hors Ligne",
|
||||
"offlineModeSubtitle": "Désactiver toutes les requêtes réseau sauf pour les zones locales/hors ligne.",
|
||||
"pauseQueueProcessing": "Suspendre la File d'Upload",
|
||||
"pauseQueueProcessingSubtitle": "Arrêter l'upload des modifications en attente tout en gardant l'accès aux données en direct.",
|
||||
"offlineModeWarningTitle": "Téléchargements Actifs",
|
||||
"offlineModeWarningMessage": "L'activation du mode hors ligne annulera tous les téléchargements de zone actifs. Voulez-vous continuer?",
|
||||
"enableOfflineMode": "Activer le Mode Hors Ligne",
|
||||
@@ -79,6 +95,8 @@
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "Profil",
|
||||
"selectProfile": "Sélectionner un profil...",
|
||||
"profileRequired": "Veuillez sélectionner un profil pour continuer.",
|
||||
"direction": "Direction {}°",
|
||||
"profileNoDirectionInfo": "Ce profil ne nécessite pas de direction.",
|
||||
"mustBeLoggedIn": "Vous devez être connecté pour soumettre de nouveaux nœuds. Veuillez vous connecter via les Paramètres.",
|
||||
@@ -90,12 +108,16 @@
|
||||
"editNode": {
|
||||
"title": "Modifier Nœud #{}",
|
||||
"profile": "Profil",
|
||||
"selectProfile": "Sélectionner un profil...",
|
||||
"profileRequired": "Veuillez sélectionner un profil pour continuer.",
|
||||
"direction": "Direction {}°",
|
||||
"profileNoDirectionInfo": "Ce profil ne nécessite pas de direction.",
|
||||
"temporarilyDisabled": "Les modifications ont été temporairement désactivées pendant que nous résolvons un bug - désolés - revenez bientôt.",
|
||||
"mustBeLoggedIn": "Vous devez être connecté pour modifier les nœuds. Veuillez vous connecter via les Paramètres.",
|
||||
"sandboxModeWarning": "Impossible de soumettre des modifications de nœuds de production au sandbox. Passez au mode Production dans les Paramètres pour modifier les nœuds.",
|
||||
"enableSubmittableProfile": "Activez un profil soumissible dans les Paramètres pour modifier les nœuds.",
|
||||
"profileViewOnlyWarning": "Ce profil est uniquement pour la visualisation de la carte. Veuillez sélectionner un profil soumissible pour modifier les nœuds.",
|
||||
"cannotMoveConstrainedNode": "Impossible de déplacer cette caméra - elle est connectée à un autre élément de carte (OSM way/relation). Vous pouvez toujours modifier ses balises et sa direction.",
|
||||
"refineTags": "Affiner Balises",
|
||||
"refineTagsWithProfile": "Affiner Balises ({})"
|
||||
},
|
||||
@@ -131,7 +153,12 @@
|
||||
"testConnection": "Tester Connexion",
|
||||
"testConnectionSubtitle": "Vérifier que les identifiants OSM fonctionnent",
|
||||
"connectionOK": "Connexion OK - les identifiants sont valides",
|
||||
"connectionFailed": "Connexion échouée - veuillez vous reconnecter"
|
||||
"connectionFailed": "Connexion échouée - veuillez vous reconnecter",
|
||||
"deleteAccount": "Supprimer Compte OSM",
|
||||
"deleteAccountSubtitle": "Gérez votre compte OpenStreetMap",
|
||||
"deleteAccountExplanation": "Pour supprimer votre compte OpenStreetMap, vous devrez visiter le site web OpenStreetMap. Cela supprimera définitivement votre compte OSM et toutes les données associées.",
|
||||
"deleteAccountWarning": "Attention : Cette action ne peut pas être annulée et supprimera définitivement votre compte OSM.",
|
||||
"goToOSM": "Aller à OpenStreetMap"
|
||||
},
|
||||
"queue": {
|
||||
"pendingUploads": "Téléchargements en attente: {}",
|
||||
@@ -302,9 +329,38 @@
|
||||
"selectMapLayer": "Sélectionner la Couche de Carte",
|
||||
"noTileProvidersAvailable": "Aucun fournisseur de tuiles disponible"
|
||||
},
|
||||
"advancedEdit": {
|
||||
"title": "Options d'Édition Avancées",
|
||||
"subtitle": "Ces éditeurs offrent des fonctionnalités plus avancées pour les modifications complexes.",
|
||||
"webEditors": "Éditeurs Web",
|
||||
"mobileEditors": "Éditeurs Mobiles",
|
||||
"iDEditor": "Éditeur iD",
|
||||
"iDEditorSubtitle": "Éditeur web complet - fonctionne toujours",
|
||||
"rapidEditor": "Éditeur RapiD",
|
||||
"rapidEditorSubtitle": "Édition assistée par IA avec des données Facebook",
|
||||
"vespucci": "Vespucci",
|
||||
"vespucciSubtitle": "Éditeur OSM avancé Android",
|
||||
"streetComplete": "StreetComplete",
|
||||
"streetCompleteSubtitle": "Application de cartographie basée sur des enquêtes",
|
||||
"everyDoor": "EveryDoor",
|
||||
"everyDoorSubtitle": "Édition rapide de POI",
|
||||
"goMap": "Go Map!!",
|
||||
"goMapSubtitle": "Éditeur OSM iOS",
|
||||
"couldNotOpenEditor": "Impossible d'ouvrir l'éditeur - l'application peut ne pas être installée",
|
||||
"couldNotOpenURL": "Impossible d'ouvrir l'URL",
|
||||
"couldNotOpenOSMWebsite": "Impossible d'ouvrir le site web OSM"
|
||||
},
|
||||
"networkStatus": {
|
||||
"showIndicator": "Afficher l'indicateur de statut réseau",
|
||||
"showIndicatorSubtitle": "Afficher l'état de chargement et d'erreur réseau sur la carte"
|
||||
"showIndicatorSubtitle": "Afficher l'état de chargement et d'erreur réseau sur la carte",
|
||||
"loading": "Chargement...",
|
||||
"timedOut": "Temps dépassé",
|
||||
"noData": "Aucune tuile ici",
|
||||
"success": "Terminé",
|
||||
"nodeLimitReached": "Limite affichée - augmenter dans les paramètres",
|
||||
"tileProviderSlow": "Fournisseur de tuiles lent",
|
||||
"nodeDataSlow": "Données de nœud lentes",
|
||||
"networkIssues": "Problèmes réseau"
|
||||
},
|
||||
"navigation": {
|
||||
"searchLocation": "Rechercher lieu",
|
||||
@@ -341,5 +397,40 @@
|
||||
"imperial": "Impérial (mi, ft)",
|
||||
"meters": "mètres",
|
||||
"feet": "pieds"
|
||||
},
|
||||
"suspectedLocations": {
|
||||
"title": "Emplacements Suspects",
|
||||
"showSuspectedLocations": "Afficher les Emplacements Suspects",
|
||||
"showSuspectedLocationsSubtitle": "Afficher des marqueurs en point d'interrogation pour les sites de surveillance suspectés à partir des données de permis de services publics",
|
||||
"lastUpdated": "Dernière Mise à Jour",
|
||||
"refreshNow": "Actualiser maintenant",
|
||||
"dataSource": "Source de Données",
|
||||
"dataSourceDescription": "Données de permis de services publics indiquant des sites d'installation potentiels d'infrastructure de surveillance",
|
||||
"dataSourceCredit": "Collecte et hébergement des données fournis par alprwatch.org",
|
||||
"minimumDistance": "Distance Minimale des Nœuds Réels",
|
||||
"minimumDistanceSubtitle": "Masquer les emplacements suspects dans un rayon de {}m des dispositifs de surveillance existants",
|
||||
"updating": "Mise à Jour des Emplacements Suspects",
|
||||
"downloadingAndProcessing": "Téléchargement et traitement des données...",
|
||||
"updateSuccess": "Emplacements suspects mis à jour avec succès",
|
||||
"updateFailed": "Échec de la mise à jour des emplacements suspects",
|
||||
"neverFetched": "Jamais récupéré",
|
||||
"daysAgo": "il y a {} jours",
|
||||
"hoursAgo": "il y a {} heures",
|
||||
"minutesAgo": "il y a {} minutes",
|
||||
"justNow": "À l'instant"
|
||||
},
|
||||
"suspectedLocation": {
|
||||
"title": "Emplacement Suspect #{}",
|
||||
"ticketNo": "N° de Ticket",
|
||||
"address": "Adresse",
|
||||
"street": "Rue",
|
||||
"city": "Ville",
|
||||
"state": "État",
|
||||
"intersectingStreet": "Rue Transversale",
|
||||
"workDoneFor": "Travail Effectué Pour",
|
||||
"remarks": "Remarques",
|
||||
"url": "URL",
|
||||
"coordinates": "Coordonnées",
|
||||
"noAddressAvailable": "Aucune adresse disponible"
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,17 @@
|
||||
"initiative": "Parte della più ampia iniziativa DeFlock per promuovere la trasparenza della sorveglianza.",
|
||||
"footer": "Visita: deflock.me\nCostruito con Flutter • Open Source"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Benvenuto in DeFlock",
|
||||
"description": "DeFlock è stato fondato sull'idea che gli strumenti di sorveglianza pubblica dovrebbero essere trasparenti. All'interno di questa app mobile, come sul sito web, sarai in grado di visualizzare la posizione di ALPR e altre infrastrutture di sorveglianza nella tua zona locale e all'estero.",
|
||||
"mission": "Tuttavia, questo progetto non è automatizzato; servono tutti noi per migliorare questo progetto. Durante la visualizzazione della mappa, puoi toccare \"Nuovo Nodo\" per aggiungere un'installazione precedentemente sconosciuta. Con il tuo aiuto, possiamo raggiungere il nostro obiettivo di maggiore trasparenza e consapevolezza pubblica dell'infrastruttura di sorveglianza.",
|
||||
"firsthandKnowledge": "IMPORTANTE: Contribuisci solo con dispositivi di sorveglianza che hai osservato personalmente di prima mano. Le politiche di OpenStreetMap e Google vietano l'uso di fonti come le immagini di Street View per i contributi. I tuoi contributi dovrebbero essere basati sulle tue osservazioni dirette e di persona.",
|
||||
"privacy": "Nota sulla Privacy: Questa app funziona interamente localmente sul tuo dispositivo e utilizza l'API di terze parti OpenStreetMap solo per l'archiviazione e l'invio dei dati. DeFlock non raccoglie né memorizza alcun tipo di dati utente e non è responsabile della gestione degli account.",
|
||||
"tileNote": "NOTA: Le tessere mappa gratuite di OpenStreetMap possono essere molto lente a caricare. Fornitori di tessere alternativi possono essere configurati in Impostazioni > Avanzate.",
|
||||
"moreInfo": "Puoi trovare altri collegamenti in Impostazioni > Informazioni.",
|
||||
"dontShowAgain": "Non mostrare più questo messaggio di benvenuto",
|
||||
"getStarted": "Iniziamo con DeFlock!"
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "Nuovo Nodo",
|
||||
"download": "Scarica",
|
||||
@@ -23,11 +34,14 @@
|
||||
"close": "Chiudi",
|
||||
"submit": "Invia",
|
||||
"saveEdit": "Salva Modifica",
|
||||
"clear": "Pulisci"
|
||||
"clear": "Pulisci",
|
||||
"viewOnOSM": "Visualizza su OSM",
|
||||
"advanced": "Avanzato",
|
||||
"useAdvancedEditor": "Usa Editor Avanzato"
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Attiva seguimi (nord in alto)",
|
||||
"northUp": "Attiva seguimi (rotazione)",
|
||||
"off": "Attiva seguimi",
|
||||
"follow": "Attiva seguimi (rotazione)",
|
||||
"rotating": "Disattiva seguimi"
|
||||
},
|
||||
"settings": {
|
||||
@@ -43,6 +57,8 @@
|
||||
"maxNodesWarning": "Probabilmente non vuoi farlo a meno che non sei assolutamente sicuro di avere una buona ragione per farlo.",
|
||||
"offlineMode": "Modalità Offline",
|
||||
"offlineModeSubtitle": "Disabilita tutte le richieste di rete tranne per aree locali/offline.",
|
||||
"pauseQueueProcessing": "Pausa Coda Upload",
|
||||
"pauseQueueProcessingSubtitle": "Ferma l'upload delle modifiche in coda mantenendo l'accesso ai dati dal vivo.",
|
||||
"offlineModeWarningTitle": "Download Attivi",
|
||||
"offlineModeWarningMessage": "L'attivazione della modalità offline cancellerà qualsiasi download di area attivo. Vuoi continuare?",
|
||||
"enableOfflineMode": "Attiva Modalità Offline",
|
||||
@@ -79,6 +95,8 @@
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "Profilo",
|
||||
"selectProfile": "Seleziona un profilo...",
|
||||
"profileRequired": "Per favore seleziona un profilo per continuare.",
|
||||
"direction": "Direzione {}°",
|
||||
"profileNoDirectionInfo": "Questo profilo non richiede una direzione.",
|
||||
"mustBeLoggedIn": "Devi essere loggato per inviare nuovi nodi. Per favore accedi tramite Impostazioni.",
|
||||
@@ -90,12 +108,16 @@
|
||||
"editNode": {
|
||||
"title": "Modifica Nodo #{}",
|
||||
"profile": "Profilo",
|
||||
"selectProfile": "Seleziona un profilo...",
|
||||
"profileRequired": "Per favore seleziona un profilo per continuare.",
|
||||
"direction": "Direzione {}°",
|
||||
"profileNoDirectionInfo": "Questo profilo non richiede una direzione.",
|
||||
"temporarilyDisabled": "Le modifiche sono state temporaneamente disabilitate mentre risolviamo un bug - scuse - torna presto.",
|
||||
"mustBeLoggedIn": "Devi essere loggato per modificare i nodi. Per favore accedi tramite Impostazioni.",
|
||||
"sandboxModeWarning": "Impossibile inviare modifiche di nodi di produzione alla sandbox. Passa alla modalità Produzione nelle Impostazioni per modificare i nodi.",
|
||||
"enableSubmittableProfile": "Abilita un profilo inviabile nelle Impostazioni per modificare i nodi.",
|
||||
"profileViewOnlyWarning": "Questo profilo è solo per la visualizzazione della mappa. Per favore seleziona un profilo inviabile per modificare i nodi.",
|
||||
"cannotMoveConstrainedNode": "Impossibile spostare questa telecamera - è collegata a un altro elemento della mappa (OSM way/relation). Puoi ancora modificare i suoi tag e direzione.",
|
||||
"refineTags": "Affina Tag",
|
||||
"refineTagsWithProfile": "Affina Tag ({})"
|
||||
},
|
||||
@@ -131,7 +153,12 @@
|
||||
"testConnection": "Testa Connessione",
|
||||
"testConnectionSubtitle": "Verifica che le credenziali OSM funzionino",
|
||||
"connectionOK": "Connessione OK - le credenziali sono valide",
|
||||
"connectionFailed": "Connessione fallita - per favore accedi di nuovo"
|
||||
"connectionFailed": "Connessione fallita - per favore accedi di nuovo",
|
||||
"deleteAccount": "Elimina Account OSM",
|
||||
"deleteAccountSubtitle": "Gestisci il tuo account OpenStreetMap",
|
||||
"deleteAccountExplanation": "Per eliminare il tuo account OpenStreetMap, dovrai visitare il sito web di OpenStreetMap. Questo rimuoverà permanentemente il tuo account OSM e tutti i dati associati.",
|
||||
"deleteAccountWarning": "Attenzione: Questa azione non può essere annullata e eliminerà permanentemente il tuo account OSM.",
|
||||
"goToOSM": "Vai a OpenStreetMap"
|
||||
},
|
||||
"queue": {
|
||||
"pendingUploads": "Upload in sospeso: {}",
|
||||
@@ -302,9 +329,38 @@
|
||||
"selectMapLayer": "Seleziona Livello Mappa",
|
||||
"noTileProvidersAvailable": "Nessun fornitore di tile disponibile"
|
||||
},
|
||||
"advancedEdit": {
|
||||
"title": "Opzioni di Modifica Avanzate",
|
||||
"subtitle": "Questi editor offrono funzionalità più avanzate per modifiche complesse.",
|
||||
"webEditors": "Editor Web",
|
||||
"mobileEditors": "Editor Mobili",
|
||||
"iDEditor": "Editor iD",
|
||||
"iDEditorSubtitle": "Editor web completo - funziona sempre",
|
||||
"rapidEditor": "Editor RapiD",
|
||||
"rapidEditorSubtitle": "Modifica assistita da IA con dati Facebook",
|
||||
"vespucci": "Vespucci",
|
||||
"vespucciSubtitle": "Editor OSM avanzato Android",
|
||||
"streetComplete": "StreetComplete",
|
||||
"streetCompleteSubtitle": "App di mappatura basata su sondaggi",
|
||||
"everyDoor": "EveryDoor",
|
||||
"everyDoorSubtitle": "Modifica rapida POI",
|
||||
"goMap": "Go Map!!",
|
||||
"goMapSubtitle": "Editor OSM iOS",
|
||||
"couldNotOpenEditor": "Impossibile aprire l'editor - l'app potrebbe non essere installata",
|
||||
"couldNotOpenURL": "Impossibile aprire l'URL",
|
||||
"couldNotOpenOSMWebsite": "Impossibile aprire il sito web OSM"
|
||||
},
|
||||
"networkStatus": {
|
||||
"showIndicator": "Mostra indicatore di stato di rete",
|
||||
"showIndicatorSubtitle": "Visualizza lo stato di caricamento e errori di rete sulla mappa"
|
||||
"showIndicatorSubtitle": "Visualizza lo stato di caricamento e errori di rete sulla mappa",
|
||||
"loading": "Caricamento...",
|
||||
"timedOut": "Tempo scaduto",
|
||||
"noData": "Nessuna tessera qui",
|
||||
"success": "Fatto",
|
||||
"nodeLimitReached": "Limite visualizzato - aumentare nelle impostazioni",
|
||||
"tileProviderSlow": "Provider di tessere lento",
|
||||
"nodeDataSlow": "Dati del nodo lenti",
|
||||
"networkIssues": "Problemi di rete"
|
||||
},
|
||||
"navigation": {
|
||||
"searchLocation": "Cerca posizione",
|
||||
@@ -341,5 +397,40 @@
|
||||
"imperial": "Imperiale (mi, ft)",
|
||||
"meters": "metri",
|
||||
"feet": "piedi"
|
||||
},
|
||||
"suspectedLocations": {
|
||||
"title": "Posizioni Sospette",
|
||||
"showSuspectedLocations": "Mostra Posizioni Sospette",
|
||||
"showSuspectedLocationsSubtitle": "Mostra marcatori punto interrogativo per siti di sorveglianza sospetti dai dati dei permessi dei servizi pubblici",
|
||||
"lastUpdated": "Ultimo Aggiornamento",
|
||||
"refreshNow": "Aggiorna ora",
|
||||
"dataSource": "Fonte Dati",
|
||||
"dataSourceDescription": "Dati dei permessi dei servizi pubblici che indicano potenziali siti di installazione di infrastrutture di sorveglianza",
|
||||
"dataSourceCredit": "Raccolta e hosting dei dati forniti da alprwatch.org",
|
||||
"minimumDistance": "Distanza Minima dai Nodi Reali",
|
||||
"minimumDistanceSubtitle": "Nascondi posizioni sospette entro {}m dai dispositivi di sorveglianza esistenti",
|
||||
"updating": "Aggiornamento Posizioni Sospette",
|
||||
"downloadingAndProcessing": "Scaricamento e elaborazione dati...",
|
||||
"updateSuccess": "Posizioni sospette aggiornate con successo",
|
||||
"updateFailed": "Aggiornamento posizioni sospette fallito",
|
||||
"neverFetched": "Mai recuperato",
|
||||
"daysAgo": "{} giorni fa",
|
||||
"hoursAgo": "{} ore fa",
|
||||
"minutesAgo": "{} minuti fa",
|
||||
"justNow": "Proprio ora"
|
||||
},
|
||||
"suspectedLocation": {
|
||||
"title": "Posizione Sospetta #{}",
|
||||
"ticketNo": "N. Ticket",
|
||||
"address": "Indirizzo",
|
||||
"street": "Via",
|
||||
"city": "Città",
|
||||
"state": "Stato",
|
||||
"intersectingStreet": "Via che Interseca",
|
||||
"workDoneFor": "Lavoro Svolto Per",
|
||||
"remarks": "Osservazioni",
|
||||
"url": "URL",
|
||||
"coordinates": "Coordinate",
|
||||
"noAddressAvailable": "Nessun indirizzo disponibile"
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,17 @@
|
||||
"initiative": "Parte da iniciativa mais ampla DeFlock para promover transparência na vigilância.",
|
||||
"footer": "Visite: deflock.me\nConstruído com Flutter • Código Aberto"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Bem-vindo ao DeFlock",
|
||||
"description": "DeFlock foi fundado na ideia de que ferramentas de vigilância pública devem ser transparentes. Dentro deste aplicativo móvel, como no site, você poderá ver a localização de ALPRs e outras infraestruturas de vigilância em sua área local e no exterior.",
|
||||
"mission": "No entanto, este projeto não é automatizado; precisamos de todos nós para tornar este projeto melhor. Ao visualizar o mapa, você pode tocar em \"Novo Nó\" para adicionar uma instalação anteriormente desconhecida. Com sua ajuda, podemos alcançar nosso objetivo de maior transparência e conscientização pública sobre infraestrutura de vigilância.",
|
||||
"firsthandKnowledge": "IMPORTANTE: Contribua apenas com dispositivos de vigilância que você observou pessoalmente em primeira mão. As políticas do OpenStreetMap e Google proíbem o uso de fontes como imagens do Street View para contribuições. Suas contribuições devem ser baseadas em suas próprias observações diretas e presenciais.",
|
||||
"privacy": "Nota de Privacidade: Este aplicativo funciona inteiramente localmente em seu dispositivo e usa a API de terceiros OpenStreetMap apenas para armazenamento e envio de dados. DeFlock não coleta nem armazena qualquer tipo de dados do usuário e não é responsável pelo gerenciamento de contas.",
|
||||
"tileNote": "NOTA: Os tiles gratuitos de mapa do OpenStreetMap podem ser muito lentos para carregar. Provedores alternativos de tiles podem ser configurados em Configurações > Avançado.",
|
||||
"moreInfo": "Você pode encontrar mais links em Configurações > Sobre.",
|
||||
"dontShowAgain": "Não mostrar esta mensagem de boas-vindas novamente",
|
||||
"getStarted": "Vamos começar com o DeFlock!"
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "Novo Nó",
|
||||
"download": "Baixar",
|
||||
@@ -23,11 +34,14 @@
|
||||
"close": "Fechar",
|
||||
"submit": "Enviar",
|
||||
"saveEdit": "Salvar Edição",
|
||||
"clear": "Limpar"
|
||||
"clear": "Limpar",
|
||||
"viewOnOSM": "Ver no OSM",
|
||||
"advanced": "Avançado",
|
||||
"useAdvancedEditor": "Usar Editor Avançado"
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Ativar seguir-me (norte para cima)",
|
||||
"northUp": "Ativar seguir-me (rotação)",
|
||||
"off": "Ativar seguir-me",
|
||||
"follow": "Ativar seguir-me (rotação)",
|
||||
"rotating": "Desativar seguir-me"
|
||||
},
|
||||
"settings": {
|
||||
@@ -43,6 +57,8 @@
|
||||
"maxNodesWarning": "Você provavelmente não quer fazer isso a menos que tenha certeza absoluta de que tem uma boa razão para isso.",
|
||||
"offlineMode": "Modo Offline",
|
||||
"offlineModeSubtitle": "Desabilitar todas as requisições de rede exceto para áreas locais/offline.",
|
||||
"pauseQueueProcessing": "Pausar Fila de Upload",
|
||||
"pauseQueueProcessingSubtitle": "Parar upload de alterações na fila mantendo acesso a dados ao vivo.",
|
||||
"offlineModeWarningTitle": "Downloads Ativos",
|
||||
"offlineModeWarningMessage": "Ativar o modo offline cancelará qualquer download de área ativo. Deseja continuar?",
|
||||
"enableOfflineMode": "Ativar Modo Offline",
|
||||
@@ -79,6 +95,8 @@
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "Perfil",
|
||||
"selectProfile": "Selecionar um perfil...",
|
||||
"profileRequired": "Por favor, selecione um perfil para continuar.",
|
||||
"direction": "Direção {}°",
|
||||
"profileNoDirectionInfo": "Este perfil não requer uma direção.",
|
||||
"mustBeLoggedIn": "Você deve estar logado para enviar novos nós. Por favor, faça login via Configurações.",
|
||||
@@ -90,12 +108,16 @@
|
||||
"editNode": {
|
||||
"title": "Editar Nó #{}",
|
||||
"profile": "Perfil",
|
||||
"selectProfile": "Selecionar um perfil...",
|
||||
"profileRequired": "Por favor, selecione um perfil para continuar.",
|
||||
"direction": "Direção {}°",
|
||||
"profileNoDirectionInfo": "Este perfil não requer uma direção.",
|
||||
"temporarilyDisabled": "As edições foram temporariamente desabilitadas enquanto resolvemos um bug - desculpe - volte em breve.",
|
||||
"mustBeLoggedIn": "Você deve estar logado para editar nós. Por favor, faça login via Configurações.",
|
||||
"sandboxModeWarning": "Não é possível enviar edições de nós de produção para o sandbox. Mude para o modo Produção nas Configurações para editar nós.",
|
||||
"enableSubmittableProfile": "Ative um perfil enviável nas Configurações para editar nós.",
|
||||
"profileViewOnlyWarning": "Este perfil é apenas para visualização do mapa. Por favor, selecione um perfil enviável para editar nós.",
|
||||
"cannotMoveConstrainedNode": "Não é possível mover esta câmera - ela está conectada a outro elemento do mapa (OSM way/relation). Você ainda pode editar suas tags e direção.",
|
||||
"refineTags": "Refinar Tags",
|
||||
"refineTagsWithProfile": "Refinar Tags ({})"
|
||||
},
|
||||
@@ -131,7 +153,12 @@
|
||||
"testConnection": "Testar Conexão",
|
||||
"testConnectionSubtitle": "Verificar se as credenciais OSM estão funcionando",
|
||||
"connectionOK": "Conexão OK - credenciais são válidas",
|
||||
"connectionFailed": "Conexão falhou - por favor, faça login novamente"
|
||||
"connectionFailed": "Conexão falhou - por favor, faça login novamente",
|
||||
"deleteAccount": "Excluir Conta OSM",
|
||||
"deleteAccountSubtitle": "Gerencie sua conta OpenStreetMap",
|
||||
"deleteAccountExplanation": "Para excluir sua conta OpenStreetMap, você precisará visitar o site do OpenStreetMap. Isso removerá permanentemente sua conta OSM e todos os dados associados.",
|
||||
"deleteAccountWarning": "Aviso: Esta ação não pode ser desfeita e excluirá permanentemente sua conta OSM.",
|
||||
"goToOSM": "Ir para OpenStreetMap"
|
||||
},
|
||||
"queue": {
|
||||
"pendingUploads": "Uploads pendentes: {}",
|
||||
@@ -302,9 +329,38 @@
|
||||
"selectMapLayer": "Selecionar Camada do Mapa",
|
||||
"noTileProvidersAvailable": "Nenhum provedor de tiles disponível"
|
||||
},
|
||||
"advancedEdit": {
|
||||
"title": "Opções de Edição Avançada",
|
||||
"subtitle": "Estes editores oferecem recursos mais avançados para edições complexas.",
|
||||
"webEditors": "Editores Web",
|
||||
"mobileEditors": "Editores Móveis",
|
||||
"iDEditor": "Editor iD",
|
||||
"iDEditorSubtitle": "Editor web completo - sempre funciona",
|
||||
"rapidEditor": "Editor RapiD",
|
||||
"rapidEditorSubtitle": "Edição assistida por IA com dados do Facebook",
|
||||
"vespucci": "Vespucci",
|
||||
"vespucciSubtitle": "Editor OSM avançado para Android",
|
||||
"streetComplete": "StreetComplete",
|
||||
"streetCompleteSubtitle": "Aplicativo de mapeamento baseado em pesquisas",
|
||||
"everyDoor": "EveryDoor",
|
||||
"everyDoorSubtitle": "Edição rápida de POI",
|
||||
"goMap": "Go Map!!",
|
||||
"goMapSubtitle": "Editor OSM iOS",
|
||||
"couldNotOpenEditor": "Não foi possível abrir o editor - aplicativo pode não estar instalado",
|
||||
"couldNotOpenURL": "Não foi possível abrir a URL",
|
||||
"couldNotOpenOSMWebsite": "Não foi possível abrir o site do OSM"
|
||||
},
|
||||
"networkStatus": {
|
||||
"showIndicator": "Exibir indicador de status de rede",
|
||||
"showIndicatorSubtitle": "Mostrar status de carregamento e erro de rede no mapa"
|
||||
"showIndicatorSubtitle": "Mostrar status de carregamento e erro de rede no mapa",
|
||||
"loading": "Carregando...",
|
||||
"timedOut": "Tempo esgotado",
|
||||
"noData": "Nenhum tile aqui",
|
||||
"success": "Concluído",
|
||||
"nodeLimitReached": "Limite exibido - aumentar nas configurações",
|
||||
"tileProviderSlow": "Provedor de tiles lento",
|
||||
"nodeDataSlow": "Dados do nó lentos",
|
||||
"networkIssues": "Problemas de rede"
|
||||
},
|
||||
"navigation": {
|
||||
"searchLocation": "Buscar localização",
|
||||
@@ -341,5 +397,40 @@
|
||||
"imperial": "Imperial (mi, ft)",
|
||||
"meters": "metros",
|
||||
"feet": "pés"
|
||||
},
|
||||
"suspectedLocations": {
|
||||
"title": "Localizações Suspeitas",
|
||||
"showSuspectedLocations": "Mostrar Localizações Suspeitas",
|
||||
"showSuspectedLocationsSubtitle": "Mostrar marcadores de ponto de interrogação para sites de vigilância suspeitos de dados de licenças de serviços públicos",
|
||||
"lastUpdated": "Última Atualização",
|
||||
"refreshNow": "Atualizar agora",
|
||||
"dataSource": "Fonte de Dados",
|
||||
"dataSourceDescription": "Dados de licenças de serviços públicos indicando possíveis locais de instalação de infraestrutura de vigilância",
|
||||
"dataSourceCredit": "Coleta e hospedagem de dados fornecidas por alprwatch.org",
|
||||
"minimumDistance": "Distância Mínima de Nós Reais",
|
||||
"minimumDistanceSubtitle": "Ocultar localizações suspeitas dentro de {}m de dispositivos de vigilância existentes",
|
||||
"updating": "Atualizando Localizações Suspeitas",
|
||||
"downloadingAndProcessing": "Baixando e processando dados...",
|
||||
"updateSuccess": "Localizações suspeitas atualizadas com sucesso",
|
||||
"updateFailed": "Falha ao atualizar localizações suspeitas",
|
||||
"neverFetched": "Nunca buscado",
|
||||
"daysAgo": "{} dias atrás",
|
||||
"hoursAgo": "{} horas atrás",
|
||||
"minutesAgo": "{} minutos atrás",
|
||||
"justNow": "Agora mesmo"
|
||||
},
|
||||
"suspectedLocation": {
|
||||
"title": "Localização Suspeita #{}",
|
||||
"ticketNo": "N° do Ticket",
|
||||
"address": "Endereço",
|
||||
"street": "Rua",
|
||||
"city": "Cidade",
|
||||
"state": "Estado",
|
||||
"intersectingStreet": "Rua que Cruza",
|
||||
"workDoneFor": "Trabalho Feito Para",
|
||||
"remarks": "Observações",
|
||||
"url": "URL",
|
||||
"coordinates": "Coordenadas",
|
||||
"noAddressAvailable": "Nenhum endereço disponível"
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,17 @@
|
||||
"initiative": "DeFlock 更广泛倡议的一部分,旨在促进监控透明化。",
|
||||
"footer": "访问:deflock.me\n使用 Flutter 构建 • 开源"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "欢迎使用 DeFlock",
|
||||
"description": "DeFlock 的创立基于公共监控工具应该透明的理念。在这个移动应用程序中,就像在网站上一样,您将能够查看您当地和国外的车牌识别系统和其他监控基础设施的位置。",
|
||||
"mission": "然而,这个项目不是自动化的;需要我们所有人来改善这个项目。在查看地图时,您可以点击\"新建节点\"来添加一个之前未知的装置。在您的帮助下,我们可以实现增强监控基础设施透明度和公众意识的目标。",
|
||||
"firsthandKnowledge": "重要提醒:只贡献您亲自第一手观察到的监控设备。OpenStreetMap 和 Google 的政策禁止使用街景图像等来源进行贡献。您的贡献应该基于您自己的直接、亲身观察。",
|
||||
"privacy": "隐私说明:此应用程序完全在您的设备上本地运行,仅使用第三方 OpenStreetMap API 进行数据存储和提交。DeFlock 不收集或存储任何类型的用户数据,也不负责账户管理。",
|
||||
"tileNote": "注意:来自 OpenStreetMap 的免费地图图块可能加载很慢。可以在设置 > 高级中配置替代图块提供商。",
|
||||
"moreInfo": "您可以在设置 > 关于中找到更多链接。",
|
||||
"dontShowAgain": "不再显示此欢迎消息",
|
||||
"getStarted": "开始使用 DeFlock!"
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "新建节点",
|
||||
"download": "下载",
|
||||
@@ -23,11 +34,14 @@
|
||||
"close": "关闭",
|
||||
"submit": "提交",
|
||||
"saveEdit": "保存编辑",
|
||||
"clear": "清空"
|
||||
"clear": "清空",
|
||||
"viewOnOSM": "在OSM上查看",
|
||||
"advanced": "高级",
|
||||
"useAdvancedEditor": "使用高级编辑器"
|
||||
},
|
||||
"followMe": {
|
||||
"off": "启用跟随模式(北向上)",
|
||||
"northUp": "启用跟随模式(旋转)",
|
||||
"off": "启用跟随模式",
|
||||
"follow": "启用跟随模式(旋转)",
|
||||
"rotating": "禁用跟随模式"
|
||||
},
|
||||
"settings": {
|
||||
@@ -43,6 +57,8 @@
|
||||
"maxNodesWarning": "除非您确定有充分的理由,否则您可能不想这样做。",
|
||||
"offlineMode": "离线模式",
|
||||
"offlineModeSubtitle": "禁用除本地/离线区域外的所有网络请求。",
|
||||
"pauseQueueProcessing": "暂停上传队列",
|
||||
"pauseQueueProcessingSubtitle": "停止上传排队的更改,同时保持实时数据访问。",
|
||||
"offlineModeWarningTitle": "活动下载",
|
||||
"offlineModeWarningMessage": "启用离线模式将取消任何活动的区域下载。您要继续吗?",
|
||||
"enableOfflineMode": "启用离线模式",
|
||||
@@ -79,6 +95,8 @@
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "配置文件",
|
||||
"selectProfile": "选择配置文件...",
|
||||
"profileRequired": "请选择配置文件以继续。",
|
||||
"direction": "方向 {}°",
|
||||
"profileNoDirectionInfo": "此配置文件不需要方向。",
|
||||
"mustBeLoggedIn": "您必须登录才能提交新节点。请通过设置登录。",
|
||||
@@ -90,12 +108,16 @@
|
||||
"editNode": {
|
||||
"title": "编辑节点 #{}",
|
||||
"profile": "配置文件",
|
||||
"selectProfile": "选择配置文件...",
|
||||
"profileRequired": "请选择配置文件以继续。",
|
||||
"direction": "方向 {}°",
|
||||
"profileNoDirectionInfo": "此配置文件不需要方向。",
|
||||
"temporarilyDisabled": "编辑功能已暂时禁用,我们正在修复一个错误 - 抱歉 - 请稍后再试。",
|
||||
"mustBeLoggedIn": "您必须登录才能编辑节点。请通过设置登录。",
|
||||
"sandboxModeWarning": "无法将生产节点的编辑提交到沙盒。在设置中切换到生产模式以编辑节点。",
|
||||
"enableSubmittableProfile": "在设置中启用可提交的配置文件以编辑节点。",
|
||||
"profileViewOnlyWarning": "此配置文件仅用于地图查看。请选择可提交的配置文件来编辑节点。",
|
||||
"cannotMoveConstrainedNode": "无法移动此相机 - 它连接到另一个地图元素(OSM way/relation)。您仍可以编辑其标签和方向。",
|
||||
"refineTags": "细化标签",
|
||||
"refineTagsWithProfile": "细化标签({})"
|
||||
},
|
||||
@@ -131,7 +153,12 @@
|
||||
"testConnection": "测试连接",
|
||||
"testConnectionSubtitle": "验证 OSM 凭据是否有效",
|
||||
"connectionOK": "连接正常 - 凭据有效",
|
||||
"connectionFailed": "连接失败 - 请重新登录"
|
||||
"connectionFailed": "连接失败 - 请重新登录",
|
||||
"deleteAccount": "删除 OSM 账户",
|
||||
"deleteAccountSubtitle": "管理您的 OpenStreetMap 账户",
|
||||
"deleteAccountExplanation": "要删除您的 OpenStreetMap 账户,您需要访问 OpenStreetMap 网站。这将永久删除您的 OSM 账户和所有相关数据。",
|
||||
"deleteAccountWarning": "警告:此操作无法撤销,将永久删除您的 OSM 账户。",
|
||||
"goToOSM": "前往 OpenStreetMap"
|
||||
},
|
||||
"queue": {
|
||||
"pendingUploads": "待上传:{}",
|
||||
@@ -302,9 +329,38 @@
|
||||
"selectMapLayer": "选择地图图层",
|
||||
"noTileProvidersAvailable": "无可用瓦片提供商"
|
||||
},
|
||||
"advancedEdit": {
|
||||
"title": "高级编辑选项",
|
||||
"subtitle": "这些编辑器为复杂编辑提供更高级的功能。",
|
||||
"webEditors": "网页编辑器",
|
||||
"mobileEditors": "移动编辑器",
|
||||
"iDEditor": "iD 编辑器",
|
||||
"iDEditorSubtitle": "功能完整的网页编辑器 - 始终有效",
|
||||
"rapidEditor": "RapiD 编辑器",
|
||||
"rapidEditorSubtitle": "使用Facebook数据的AI辅助编辑",
|
||||
"vespucci": "Vespucci",
|
||||
"vespucciSubtitle": "高级Android OSM编辑器",
|
||||
"streetComplete": "StreetComplete",
|
||||
"streetCompleteSubtitle": "基于调查的地图应用",
|
||||
"everyDoor": "EveryDoor",
|
||||
"everyDoorSubtitle": "快速POI编辑",
|
||||
"goMap": "Go Map!!",
|
||||
"goMapSubtitle": "iOS OSM编辑器",
|
||||
"couldNotOpenEditor": "无法打开编辑器 - 应用可能未安装",
|
||||
"couldNotOpenURL": "无法打开URL",
|
||||
"couldNotOpenOSMWebsite": "无法打开OSM网站"
|
||||
},
|
||||
"networkStatus": {
|
||||
"showIndicator": "显示网络状态指示器",
|
||||
"showIndicatorSubtitle": "在地图上显示网络加载和错误状态"
|
||||
"showIndicatorSubtitle": "在地图上显示网络加载和错误状态",
|
||||
"loading": "加载中...",
|
||||
"timedOut": "超时",
|
||||
"noData": "这里没有瓦片",
|
||||
"success": "完成",
|
||||
"nodeLimitReached": "显示限制 - 在设置中增加",
|
||||
"tileProviderSlow": "瓦片提供商缓慢",
|
||||
"nodeDataSlow": "节点数据缓慢",
|
||||
"networkIssues": "网络问题"
|
||||
},
|
||||
"navigation": {
|
||||
"searchLocation": "搜索位置",
|
||||
@@ -341,5 +397,40 @@
|
||||
"imperial": "英制(英里,英尺)",
|
||||
"meters": "米",
|
||||
"feet": "英尺"
|
||||
},
|
||||
"suspectedLocations": {
|
||||
"title": "疑似位置",
|
||||
"showSuspectedLocations": "显示疑似位置",
|
||||
"showSuspectedLocationsSubtitle": "根据公用事业许可数据显示疑似监控站点的问号标记",
|
||||
"lastUpdated": "最后更新",
|
||||
"refreshNow": "立即刷新",
|
||||
"dataSource": "数据源",
|
||||
"dataSourceDescription": "公用事业许可数据,表明潜在的监控基础设施安装站点",
|
||||
"dataSourceCredit": "数据收集和托管由 alprwatch.org 提供",
|
||||
"minimumDistance": "与真实节点的最小距离",
|
||||
"minimumDistanceSubtitle": "隐藏现有监控设备{}米范围内的疑似位置",
|
||||
"updating": "正在更新疑似位置",
|
||||
"downloadingAndProcessing": "正在下载和处理数据...",
|
||||
"updateSuccess": "疑似位置更新成功",
|
||||
"updateFailed": "疑似位置更新失败",
|
||||
"neverFetched": "从未获取",
|
||||
"daysAgo": "{}天前",
|
||||
"hoursAgo": "{}小时前",
|
||||
"minutesAgo": "{}分钟前",
|
||||
"justNow": "刚刚"
|
||||
},
|
||||
"suspectedLocation": {
|
||||
"title": "疑似位置 #{}",
|
||||
"ticketNo": "工单号",
|
||||
"address": "地址",
|
||||
"street": "街道",
|
||||
"city": "城市",
|
||||
"state": "州/省",
|
||||
"intersectingStreet": "交叉街道",
|
||||
"workDoneFor": "工作完成方",
|
||||
"remarks": "备注",
|
||||
"url": "网址",
|
||||
"coordinates": "坐标",
|
||||
"noAddressAvailable": "无可用地址"
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import 'screens/offline_settings_screen.dart';
|
||||
import 'screens/advanced_settings_screen.dart';
|
||||
import 'screens/language_settings_screen.dart';
|
||||
import 'screens/about_screen.dart';
|
||||
import 'screens/release_notes_screen.dart';
|
||||
import 'services/localization_service.dart';
|
||||
import 'services/version_service.dart';
|
||||
|
||||
@@ -74,6 +75,7 @@ class DeFlockApp extends StatelessWidget {
|
||||
'/settings/advanced': (context) => const AdvancedSettingsScreen(),
|
||||
'/settings/language': (context) => const LanguageSettingsScreen(),
|
||||
'/settings/about': (context) => const AboutScreen(),
|
||||
'/settings/release-notes': (context) => const ReleaseNotesScreen(),
|
||||
},
|
||||
initialRoute: '/',
|
||||
);
|
||||
|
||||
@@ -20,161 +20,182 @@ class NodeProfile {
|
||||
this.editable = true,
|
||||
});
|
||||
|
||||
/// Built‑in default: Generic ALPR camera (customizable template, not submittable)
|
||||
factory NodeProfile.genericAlpr() => NodeProfile(
|
||||
id: 'builtin-generic-alpr',
|
||||
name: 'Generic ALPR',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance:type': 'ALPR',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: true,
|
||||
submittable: false,
|
||||
editable: false,
|
||||
);
|
||||
/// Get all built-in default node profiles
|
||||
static List<NodeProfile> getDefaults() => [
|
||||
NodeProfile(
|
||||
id: 'builtin-generic-alpr',
|
||||
name: 'Generic ALPR',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance:type': 'ALPR',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: true,
|
||||
submittable: false,
|
||||
editable: false,
|
||||
),
|
||||
NodeProfile(
|
||||
id: 'builtin-flock',
|
||||
name: 'Flock',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'manufacturer': 'Flock Safety',
|
||||
'manufacturer:wikidata': 'Q108485435',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
),
|
||||
NodeProfile(
|
||||
id: 'builtin-motorola',
|
||||
name: 'Motorola/Vigilant',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'manufacturer': 'Motorola Solutions',
|
||||
'manufacturer:wikidata': 'Q634815',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
),
|
||||
NodeProfile(
|
||||
id: 'builtin-genetec',
|
||||
name: 'Genetec',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'manufacturer': 'Genetec',
|
||||
'manufacturer:wikidata': 'Q30295174',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
),
|
||||
NodeProfile(
|
||||
id: 'builtin-leonardo',
|
||||
name: 'Leonardo/ELSAG',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'manufacturer': 'Leonardo',
|
||||
'manufacturer:wikidata': 'Q910379',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
),
|
||||
NodeProfile(
|
||||
id: 'builtin-neology',
|
||||
name: 'Neology',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'manufacturer': 'Neology, Inc.',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
),
|
||||
NodeProfile(
|
||||
id: 'builtin-rekor',
|
||||
name: 'Rekor',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'manufacturer': 'Rekor',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
),
|
||||
NodeProfile(
|
||||
id: 'builtin-axis',
|
||||
name: 'Axis Communications',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'manufacturer': 'Axis Communications',
|
||||
'manufacturer:wikidata': 'Q2347731',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
),
|
||||
NodeProfile(
|
||||
id: 'builtin-generic-gunshot',
|
||||
name: 'Generic Gunshot Detector',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance:type': 'gunshot_detector',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: false,
|
||||
submittable: false,
|
||||
editable: false,
|
||||
),
|
||||
NodeProfile(
|
||||
id: 'builtin-shotspotter',
|
||||
name: 'ShotSpotter',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:type': 'gunshot_detector',
|
||||
'surveillance:brand': 'ShotSpotter',
|
||||
'surveillance:brand:wikidata': 'Q107740188',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: false,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
),
|
||||
NodeProfile(
|
||||
id: 'builtin-flock-raven',
|
||||
name: 'Flock Raven',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:type': 'gunshot_detector',
|
||||
'brand': 'Flock Safety',
|
||||
'brand:wikidata': 'Q108485435',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: false,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
),
|
||||
];
|
||||
|
||||
/// Built‑in: Flock Safety ALPR camera
|
||||
factory NodeProfile.flock() => NodeProfile(
|
||||
id: 'builtin-flock',
|
||||
name: 'Flock',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'manufacturer': 'Flock Safety',
|
||||
'manufacturer:wikidata': 'Q108485435',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
);
|
||||
|
||||
/// Built‑in: Motorola Solutions/Vigilant ALPR camera
|
||||
factory NodeProfile.motorola() => NodeProfile(
|
||||
id: 'builtin-motorola',
|
||||
name: 'Motorola/Vigilant',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'manufacturer': 'Motorola Solutions',
|
||||
'manufacturer:wikidata': 'Q634815',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
);
|
||||
|
||||
/// Built‑in: Genetec ALPR camera
|
||||
factory NodeProfile.genetec() => NodeProfile(
|
||||
id: 'builtin-genetec',
|
||||
name: 'Genetec',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'manufacturer': 'Genetec',
|
||||
'manufacturer:wikidata': 'Q30295174',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
);
|
||||
|
||||
/// Built‑in: Leonardo/ELSAG ALPR camera
|
||||
factory NodeProfile.leonardo() => NodeProfile(
|
||||
id: 'builtin-leonardo',
|
||||
name: 'Leonardo/ELSAG',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'manufacturer': 'Leonardo',
|
||||
'manufacturer:wikidata': 'Q910379',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
);
|
||||
|
||||
/// Built‑in: Neology ALPR camera
|
||||
factory NodeProfile.neology() => NodeProfile(
|
||||
id: 'builtin-neology',
|
||||
name: 'Neology',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'manufacturer': 'Neology, Inc.',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
);
|
||||
|
||||
/// Built‑in: Generic gunshot detector (customizable template, not submittable)
|
||||
factory NodeProfile.genericGunshotDetector() => NodeProfile(
|
||||
id: 'builtin-generic-gunshot',
|
||||
name: 'Generic Gunshot Detector',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance:type': 'gunshot_detector',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: false,
|
||||
submittable: false,
|
||||
editable: false,
|
||||
);
|
||||
|
||||
/// Built‑in: ShotSpotter gunshot detector
|
||||
factory NodeProfile.shotspotter() => NodeProfile(
|
||||
id: 'builtin-shotspotter',
|
||||
name: 'ShotSpotter',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:type': 'gunshot_detector',
|
||||
'surveillance:brand': 'ShotSpotter',
|
||||
'surveillance:brand:wikidata': 'Q107740188',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: false,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
);
|
||||
|
||||
/// Built‑in: Flock Raven gunshot detector
|
||||
factory NodeProfile.flockRaven() => NodeProfile(
|
||||
id: 'builtin-flock-raven',
|
||||
name: 'Flock Raven',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:type': 'gunshot_detector',
|
||||
'brand': 'Flock Safety',
|
||||
'brand:wikidata': 'Q108485435',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: false,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
);
|
||||
|
||||
/// Returns true if this profile can be used for submissions
|
||||
bool get isSubmittable => submittable;
|
||||
|
||||
@@ -13,6 +13,37 @@ class OperatorProfile {
|
||||
required this.tags,
|
||||
});
|
||||
|
||||
/// Get all built-in default operator profiles
|
||||
static List<OperatorProfile> getDefaults() => [
|
||||
OperatorProfile(
|
||||
id: 'builtin-lowes',
|
||||
name: "Lowe's",
|
||||
tags: const {
|
||||
'operator': "Lowe's",
|
||||
'operator:wikidata': 'Q1373493',
|
||||
'operator:type': 'private',
|
||||
},
|
||||
),
|
||||
OperatorProfile(
|
||||
id: 'builtin-home-depot',
|
||||
name: 'The Home Depot',
|
||||
tags: const {
|
||||
'operator': 'The Home Depot',
|
||||
'operator:wikidata': 'Q864407',
|
||||
'operator:type': 'private',
|
||||
},
|
||||
),
|
||||
OperatorProfile(
|
||||
id: 'builtin-simon-property-group',
|
||||
name: 'Simon Property Group',
|
||||
tags: const {
|
||||
'operator': 'Simon Property Group',
|
||||
'operator:wikidata': 'Q2287759',
|
||||
'operator:type': 'private',
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
OperatorProfile copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
|
||||
@@ -4,11 +4,13 @@ class OsmNode {
|
||||
final int id;
|
||||
final LatLng coord;
|
||||
final Map<String, String> tags;
|
||||
final bool isConstrained; // true if part of any way/relation
|
||||
|
||||
OsmNode({
|
||||
required this.id,
|
||||
required this.coord,
|
||||
required this.tags,
|
||||
this.isConstrained = false, // Default to unconstrained for backward compatibility
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
@@ -16,6 +18,7 @@ class OsmNode {
|
||||
'lat': coord.latitude,
|
||||
'lon': coord.longitude,
|
||||
'tags': tags,
|
||||
'isConstrained': isConstrained,
|
||||
};
|
||||
|
||||
factory OsmNode.fromJson(Map<String, dynamic> json) {
|
||||
@@ -29,26 +32,51 @@ class OsmNode {
|
||||
id: json['id'] is int ? json['id'] as int : int.tryParse(json['id'].toString()) ?? 0,
|
||||
coord: LatLng((json['lat'] as num).toDouble(), (json['lon'] as num).toDouble()),
|
||||
tags: tags,
|
||||
isConstrained: json['isConstrained'] as bool? ?? false, // Default to false for backward compatibility
|
||||
);
|
||||
}
|
||||
|
||||
bool get hasDirection =>
|
||||
tags.containsKey('direction') || tags.containsKey('camera:direction');
|
||||
bool get hasDirection => directionDeg.isNotEmpty;
|
||||
|
||||
double? get directionDeg {
|
||||
List<double> get directionDeg {
|
||||
final raw = tags['direction'] ?? tags['camera:direction'];
|
||||
if (raw == null) return null;
|
||||
if (raw == null) return [];
|
||||
|
||||
// Keep digits, optional dot, optional leading sign.
|
||||
final match = RegExp(r'[-+]?\d*\.?\d+').firstMatch(raw);
|
||||
if (match == null) return null;
|
||||
// Compass direction to degree mapping
|
||||
const compassDirections = {
|
||||
'N': 0.0, 'NNE': 22.5, 'NE': 45.0, 'ENE': 67.5,
|
||||
'E': 90.0, 'ESE': 112.5, 'SE': 135.0, 'SSE': 157.5,
|
||||
'S': 180.0, 'SSW': 202.5, 'SW': 225.0, 'WSW': 247.5,
|
||||
'W': 270.0, 'WNW': 292.5, 'NW': 315.0, 'NNW': 337.5,
|
||||
};
|
||||
|
||||
final numStr = match.group(0);
|
||||
final val = double.tryParse(numStr ?? '');
|
||||
if (val == null) return null;
|
||||
// Split on semicolons and parse each direction
|
||||
final directions = <double>[];
|
||||
final parts = raw.split(';');
|
||||
|
||||
for (final part in parts) {
|
||||
final trimmed = part.trim().toUpperCase();
|
||||
if (trimmed.isEmpty) continue;
|
||||
|
||||
// First try compass direction lookup
|
||||
if (compassDirections.containsKey(trimmed)) {
|
||||
directions.add(compassDirections[trimmed]!);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Then try numeric parsing
|
||||
final match = RegExp(r'[-+]?\d*\.?\d+').firstMatch(trimmed);
|
||||
if (match == null) continue;
|
||||
|
||||
// Normalize: wrap negative or >360 into 0‑359 range.
|
||||
final normalized = ((val % 360) + 360) % 360;
|
||||
return normalized;
|
||||
final numStr = match.group(0);
|
||||
final val = double.tryParse(numStr ?? '');
|
||||
if (val == null) continue;
|
||||
|
||||
// Normalize: wrap negative or >360 into 0‑359 range
|
||||
final normalized = ((val % 360) + 360) % 360;
|
||||
directions.add(normalized);
|
||||
}
|
||||
|
||||
return directions;
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,8 @@ enum UploadOperation { create, modify, delete }
|
||||
|
||||
class PendingUpload {
|
||||
final LatLng coord;
|
||||
final double direction;
|
||||
final NodeProfile profile;
|
||||
final dynamic direction; // Can be double or String for multiple directions
|
||||
final NodeProfile? profile;
|
||||
final OperatorProfile? operatorProfile;
|
||||
final UploadMode uploadMode; // Capture upload destination when queued
|
||||
final UploadOperation operation; // Type of operation: create, modify, or delete
|
||||
@@ -21,7 +21,7 @@ class PendingUpload {
|
||||
PendingUpload({
|
||||
required this.coord,
|
||||
required this.direction,
|
||||
required this.profile,
|
||||
this.profile,
|
||||
this.operatorProfile,
|
||||
required this.uploadMode,
|
||||
required this.operation,
|
||||
@@ -34,6 +34,10 @@ class PendingUpload {
|
||||
(operation == UploadOperation.create && originalNodeId == null) ||
|
||||
(operation != UploadOperation.create && originalNodeId != null),
|
||||
'originalNodeId must be null for create operations and non-null for modify/delete operations'
|
||||
),
|
||||
assert(
|
||||
(operation == UploadOperation.delete) || (profile != null),
|
||||
'profile is required for create and modify operations'
|
||||
);
|
||||
|
||||
// True if this is an edit of an existing node, false if it's a new node
|
||||
@@ -56,7 +60,12 @@ class PendingUpload {
|
||||
|
||||
// Get combined tags from node profile and operator profile
|
||||
Map<String, String> getCombinedTags() {
|
||||
final tags = Map<String, String>.from(profile.tags);
|
||||
// Deletions don't need tags
|
||||
if (operation == UploadOperation.delete || profile == null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
final tags = Map<String, String>.from(profile!.tags);
|
||||
|
||||
// Add operator profile tags (they override node profile tags if there are conflicts)
|
||||
if (operatorProfile != null) {
|
||||
@@ -64,8 +73,14 @@ class PendingUpload {
|
||||
}
|
||||
|
||||
// Add direction if required
|
||||
if (profile.requiresDirection) {
|
||||
tags['direction'] = direction.toStringAsFixed(0);
|
||||
if (profile!.requiresDirection) {
|
||||
if (direction is String) {
|
||||
tags['direction'] = direction;
|
||||
} else if (direction is double) {
|
||||
tags['direction'] = direction.toStringAsFixed(0);
|
||||
} else {
|
||||
tags['direction'] = '0';
|
||||
}
|
||||
}
|
||||
|
||||
return tags;
|
||||
@@ -75,7 +90,7 @@ class PendingUpload {
|
||||
'lat': coord.latitude,
|
||||
'lon': coord.longitude,
|
||||
'dir': direction,
|
||||
'profile': profile.toJson(),
|
||||
'profile': profile?.toJson(),
|
||||
'operatorProfile': operatorProfile?.toJson(),
|
||||
'uploadMode': uploadMode.index,
|
||||
'operation': operation.index,
|
||||
@@ -91,7 +106,7 @@ class PendingUpload {
|
||||
direction: j['dir'],
|
||||
profile: j['profile'] is Map<String, dynamic>
|
||||
? NodeProfile.fromJson(j['profile'])
|
||||
: NodeProfile.genericAlpr(),
|
||||
: null, // Profile is optional for deletions
|
||||
operatorProfile: j['operatorProfile'] != null
|
||||
? OperatorProfile.fromJson(j['operatorProfile'])
|
||||
: null,
|
||||
|
||||
189
lib/models/suspected_location.dart
Normal file
189
lib/models/suspected_location.dart
Normal file
@@ -0,0 +1,189 @@
|
||||
import 'dart:convert';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
/// A suspected surveillance location from the CSV data
|
||||
class SuspectedLocation {
|
||||
final String ticketNo;
|
||||
final LatLng centroid;
|
||||
final List<LatLng> bounds;
|
||||
final Map<String, dynamic>? geoJson;
|
||||
final Map<String, dynamic> allFields; // All CSV fields except location and ticket_no
|
||||
|
||||
SuspectedLocation({
|
||||
required this.ticketNo,
|
||||
required this.centroid,
|
||||
required this.bounds,
|
||||
this.geoJson,
|
||||
required this.allFields,
|
||||
});
|
||||
|
||||
/// Create from CSV row data
|
||||
factory SuspectedLocation.fromCsvRow(Map<String, dynamic> row) {
|
||||
final locationString = row['location'] as String?;
|
||||
final ticketNo = row['ticket_no']?.toString() ?? '';
|
||||
|
||||
LatLng centroid = const LatLng(0, 0);
|
||||
List<LatLng> bounds = [];
|
||||
Map<String, dynamic>? geoJson;
|
||||
|
||||
// Parse GeoJSON if available
|
||||
if (locationString != null && locationString.isNotEmpty) {
|
||||
try {
|
||||
geoJson = jsonDecode(locationString) as Map<String, dynamic>;
|
||||
final coordinates = _extractCoordinatesFromGeoJson(geoJson);
|
||||
centroid = coordinates.centroid;
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
// Store all fields except location and ticket_no
|
||||
final allFields = Map<String, dynamic>.from(row);
|
||||
allFields.remove('location');
|
||||
allFields.remove('ticket_no');
|
||||
|
||||
return SuspectedLocation(
|
||||
ticketNo: ticketNo,
|
||||
centroid: centroid,
|
||||
bounds: bounds,
|
||||
geoJson: geoJson,
|
||||
allFields: allFields,
|
||||
);
|
||||
}
|
||||
|
||||
/// Extract coordinates from GeoJSON
|
||||
static ({LatLng centroid, List<LatLng> bounds}) _extractCoordinatesFromGeoJson(Map<String, dynamic> geoJson) {
|
||||
try {
|
||||
// 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');
|
||||
return (centroid: const LatLng(0, 0), bounds: <LatLng>[]);
|
||||
}
|
||||
|
||||
final List<LatLng> points = [];
|
||||
|
||||
// Handle different geometry types
|
||||
final type = geoJson['type'] as String?;
|
||||
switch (type) {
|
||||
case 'Point':
|
||||
if (coordinates.length >= 2) {
|
||||
final point = LatLng(
|
||||
(coordinates[1] as num).toDouble(),
|
||||
(coordinates[0] as num).toDouble(),
|
||||
);
|
||||
points.add(point);
|
||||
}
|
||||
break;
|
||||
case 'Polygon':
|
||||
// Polygon coordinates are [[[lng, lat], ...]]
|
||||
if (coordinates.isNotEmpty) {
|
||||
final ring = coordinates[0] as List;
|
||||
for (final coord in ring) {
|
||||
if (coord is List && coord.length >= 2) {
|
||||
points.add(LatLng(
|
||||
(coord[1] as num).toDouble(),
|
||||
(coord[0] as num).toDouble(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'MultiPolygon':
|
||||
// MultiPolygon coordinates are [[[[lng, lat], ...], ...], ...]
|
||||
for (final polygon in coordinates) {
|
||||
if (polygon is List && polygon.isNotEmpty) {
|
||||
final ring = polygon[0] as List;
|
||||
for (final coord in ring) {
|
||||
if (coord is List && coord.length >= 2) {
|
||||
points.add(LatLng(
|
||||
(coord[1] as num).toDouble(),
|
||||
(coord[0] as num).toDouble(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
print('Unsupported geometry type: $type');
|
||||
}
|
||||
|
||||
if (points.isEmpty) {
|
||||
return (centroid: const LatLng(0, 0), bounds: <LatLng>[]);
|
||||
}
|
||||
|
||||
// Calculate centroid
|
||||
double sumLat = 0;
|
||||
double sumLng = 0;
|
||||
for (final point in points) {
|
||||
sumLat += point.latitude;
|
||||
sumLng += point.longitude;
|
||||
}
|
||||
final centroid = LatLng(sumLat / points.length, sumLng / points.length);
|
||||
|
||||
return (centroid: centroid, bounds: points);
|
||||
} catch (e) {
|
||||
print('Error extracting coordinates from GeoJSON: $e');
|
||||
return (centroid: const LatLng(0, 0), bounds: <LatLng>[]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert to JSON for storage
|
||||
Map<String, dynamic> toJson() => {
|
||||
'ticket_no': ticketNo,
|
||||
'geo_json': geoJson,
|
||||
'centroid_lat': centroid.latitude,
|
||||
'centroid_lng': centroid.longitude,
|
||||
'bounds': bounds.map((p) => [p.latitude, p.longitude]).toList(),
|
||||
'all_fields': allFields,
|
||||
};
|
||||
|
||||
/// Create from stored JSON
|
||||
factory SuspectedLocation.fromJson(Map<String, dynamic> json) {
|
||||
final boundsData = json['bounds'] as List?;
|
||||
final bounds = boundsData?.map((b) => LatLng(
|
||||
(b[0] as num).toDouble(),
|
||||
(b[1] as num).toDouble(),
|
||||
)).toList() ?? <LatLng>[];
|
||||
|
||||
return SuspectedLocation(
|
||||
ticketNo: json['ticket_no'] ?? '',
|
||||
geoJson: json['geo_json'],
|
||||
centroid: LatLng(
|
||||
(json['centroid_lat'] as num).toDouble(),
|
||||
(json['centroid_lng'] as num).toDouble(),
|
||||
),
|
||||
bounds: bounds,
|
||||
allFields: Map<String, dynamic>.from(json['all_fields'] ?? {}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Get a formatted display address
|
||||
String get displayAddress {
|
||||
final parts = <String>[];
|
||||
final addr = allFields['addr']?.toString();
|
||||
final street = allFields['street']?.toString();
|
||||
final city = allFields['city']?.toString();
|
||||
final state = allFields['state']?.toString();
|
||||
|
||||
if (addr?.isNotEmpty == true) parts.add(addr!);
|
||||
if (street?.isNotEmpty == true) parts.add(street!);
|
||||
if (city?.isNotEmpty == true) parts.add(city!);
|
||||
if (state?.isNotEmpty == true) parts.add(state!);
|
||||
return parts.isNotEmpty ? parts.join(', ') : 'No address available';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is SuspectedLocation &&
|
||||
runtimeType == other.runtimeType &&
|
||||
ticketNo == other.ticketNo;
|
||||
|
||||
@override
|
||||
int get hashCode => ticketNo.hashCode;
|
||||
}
|
||||
@@ -1,9 +1,25 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
class AboutScreen extends StatelessWidget {
|
||||
const AboutScreen({super.key});
|
||||
|
||||
Future<void> _launchUrl(String url, BuildContext context) async {
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} else {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Could not open URL: $url'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final locService = LocalizationService.instance;
|
||||
@@ -15,30 +31,39 @@ class AboutScreen extends StatelessWidget {
|
||||
title: Text(locService.t('settings.aboutThisApp')),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
16 + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('about.title'),
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.start,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
locService.t('about.description'),
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
textAlign: TextAlign.start,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
locService.t('about.features'),
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
textAlign: TextAlign.start,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
locService.t('about.initiative'),
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
textAlign: TextAlign.start,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
@@ -48,10 +73,121 @@ class AboutScreen extends StatelessWidget {
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// Release Notes button
|
||||
Center(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.pushNamed(context, '/settings/release-notes');
|
||||
},
|
||||
icon: const Icon(Icons.article_outlined),
|
||||
label: const Text('View Release Notes'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildHelpLinks(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHelpLinks(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildLinkText(context, 'About DeFlock', 'https://deflock.me/about'),
|
||||
const SizedBox(height: 8),
|
||||
_buildLinkText(context, 'Privacy Policy', 'https://deflock.me/privacy'),
|
||||
const SizedBox(height: 8),
|
||||
_buildLinkText(context, 'DeFlock Discord', 'https://discord.gg/aV7v4R3sKT'),
|
||||
const SizedBox(height: 8),
|
||||
_buildLinkText(context, 'Source Code', 'https://github.com/FoggedLens/deflock-app'),
|
||||
const SizedBox(height: 8),
|
||||
_buildLinkText(context, 'Contact', 'https://deflock.me/contact'),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Divider for account management section
|
||||
Divider(
|
||||
color: Theme.of(context).dividerColor.withOpacity(0.3),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Account deletion link (less prominent)
|
||||
_buildAccountDeletionLink(context),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAccountDeletionLink(BuildContext context) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _showDeleteAccountDialog(context, locService),
|
||||
child: Text(
|
||||
locService.t('auth.deleteAccount'),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.error.withOpacity(0.7),
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showDeleteAccountDialog(BuildContext context, LocalizationService locService) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(locService.t('auth.deleteAccount')),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(locService.t('auth.deleteAccountExplanation')),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
locService.t('auth.deleteAccountWarning'),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(locService.t('actions.cancel')),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
_launchUrl('https://www.openstreetmap.org/account/deletion', context);
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
child: Text(locService.t('auth.goToOSM')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLinkText(BuildContext context, String text, String url) {
|
||||
return GestureDetector(
|
||||
onTap: () => _launchUrl(url, context),
|
||||
child: Text(
|
||||
text,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
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';
|
||||
@@ -19,14 +20,21 @@ class AdvancedSettingsScreen extends StatelessWidget {
|
||||
title: Text(locService.t('settings.advancedSettings')),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
16 + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
children: const [
|
||||
MaxNodesSection(),
|
||||
Divider(),
|
||||
ProximityAlertsSection(),
|
||||
Divider(),
|
||||
NetworkStatusSection(),
|
||||
SuspectedLocationsSection(),
|
||||
Divider(),
|
||||
// NetworkStatusSection(), // Commented out - network status indicator now defaults to enabled
|
||||
// Divider(),
|
||||
TileProviderSection(),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -18,8 +18,13 @@ 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';
|
||||
import '../widgets/changelog_dialog.dart';
|
||||
import '../models/osm_node.dart';
|
||||
import '../models/suspected_location.dart';
|
||||
import '../models/search_result.dart';
|
||||
import '../services/changelog_service.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
@@ -46,6 +51,9 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
|
||||
// Track selected node for highlighting
|
||||
int? _selectedNodeId;
|
||||
|
||||
// Track popup display to avoid showing multiple times
|
||||
bool _hasCheckedForPopup = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -64,8 +72,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
switch (mode) {
|
||||
case FollowMeMode.off:
|
||||
return locService.t('followMe.off');
|
||||
case FollowMeMode.northUp:
|
||||
return locService.t('followMe.northUp');
|
||||
case FollowMeMode.follow:
|
||||
return locService.t('followMe.follow');
|
||||
case FollowMeMode.rotating:
|
||||
return locService.t('followMe.rotating');
|
||||
}
|
||||
@@ -75,7 +83,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
switch (mode) {
|
||||
case FollowMeMode.off:
|
||||
return Icons.gps_off;
|
||||
case FollowMeMode.northUp:
|
||||
case FollowMeMode.follow:
|
||||
return Icons.gps_fixed;
|
||||
case FollowMeMode.rotating:
|
||||
return Icons.navigation;
|
||||
@@ -85,8 +93,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
FollowMeMode _getNextFollowMeMode(FollowMeMode mode) {
|
||||
switch (mode) {
|
||||
case FollowMeMode.off:
|
||||
return FollowMeMode.northUp;
|
||||
case FollowMeMode.northUp:
|
||||
return FollowMeMode.follow;
|
||||
case FollowMeMode.follow:
|
||||
return FollowMeMode.rotating;
|
||||
case FollowMeMode.rotating:
|
||||
return FollowMeMode.off;
|
||||
@@ -122,6 +130,13 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
setState(() {
|
||||
_addSheetHeight = 0.0;
|
||||
});
|
||||
|
||||
// Handle dismissal by canceling session if still active
|
||||
final appState = context.read<AppState>();
|
||||
if (appState.session != null) {
|
||||
debugPrint('[HomeScreen] AddNodeSheet dismissed - canceling session');
|
||||
appState.cancelSession();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -130,6 +145,25 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
// Disable follow-me when editing a camera so the map doesn't jump around
|
||||
appState.setFollowMeMode(FollowMeMode.off);
|
||||
|
||||
final session = appState.editSession!; // should be non-null when this is called
|
||||
|
||||
// Center map on the node being edited (same animation as openNodeTagSheet)
|
||||
try {
|
||||
_mapController.animateTo(
|
||||
dest: session.originalNode.coord,
|
||||
zoom: _mapController.mapController.camera.zoom,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
} catch (_) {
|
||||
// Map controller not ready, fallback to immediate move
|
||||
try {
|
||||
_mapController.mapController.move(session.originalNode.coord, _mapController.mapController.camera.zoom);
|
||||
} catch (_) {
|
||||
// Controller really not ready, skip centering
|
||||
}
|
||||
}
|
||||
|
||||
// Set transition flag to prevent map bounce
|
||||
_transitioningToEdit = true;
|
||||
|
||||
@@ -137,8 +171,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
if (_tagSheetHeight > 0) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
final session = appState.editSession!; // should be non-null when this is called
|
||||
|
||||
// Small delay to let tag sheet close smoothly
|
||||
Future.delayed(const Duration(milliseconds: 150), () {
|
||||
@@ -172,6 +204,13 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
_editSheetHeight = 0.0;
|
||||
_transitioningToEdit = false;
|
||||
});
|
||||
|
||||
// Handle dismissal by canceling edit session if still active
|
||||
final appState = context.read<AppState>();
|
||||
if (appState.editSession != null) {
|
||||
debugPrint('[HomeScreen] EditNodeSheet dismissed - canceling edit session');
|
||||
appState.cancelEditSession();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -217,6 +256,65 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
});
|
||||
}
|
||||
|
||||
// Check for and display welcome/changelog popup
|
||||
Future<void> _checkForPopup() async {
|
||||
if (!mounted) return;
|
||||
|
||||
try {
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
// Run any needed migrations first
|
||||
final versionsNeedingMigration = await ChangelogService().getVersionsNeedingMigration();
|
||||
for (final version in versionsNeedingMigration) {
|
||||
await ChangelogService().runMigration(version, appState);
|
||||
}
|
||||
|
||||
// Determine what popup to show
|
||||
final popupType = await ChangelogService().getPopupType();
|
||||
|
||||
if (!mounted) return; // Check again after async operation
|
||||
|
||||
switch (popupType) {
|
||||
case PopupType.welcome:
|
||||
await showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => const WelcomeDialog(),
|
||||
);
|
||||
break;
|
||||
|
||||
case PopupType.changelog:
|
||||
final changelogContent = await ChangelogService().getChangelogContentForDisplay();
|
||||
if (changelogContent != null) {
|
||||
await showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => ChangelogDialog(changelogContent: changelogContent),
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case PopupType.none:
|
||||
// No popup needed
|
||||
break;
|
||||
}
|
||||
|
||||
// Complete the version change workflow (updates last seen version)
|
||||
await ChangelogService().completeVersionChange();
|
||||
|
||||
} catch (e) {
|
||||
// Silently handle errors to avoid breaking the app launch
|
||||
debugPrint('[HomeScreen] Error checking for popup: $e');
|
||||
|
||||
// Still complete version change to avoid getting stuck
|
||||
try {
|
||||
await ChangelogService().completeVersionChange();
|
||||
} catch (e2) {
|
||||
debugPrint('[HomeScreen] Error completing version change: $e2');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onStartRoute() {
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
@@ -228,7 +326,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
userLocation = _mapViewKey.currentState?.getUserLocation();
|
||||
if (userLocation != null && appState.shouldAutoEnableFollowMe(userLocation)) {
|
||||
debugPrint('[HomeScreen] Auto-enabling follow-me mode - user within 1km of start');
|
||||
appState.setFollowMeMode(FollowMeMode.northUp);
|
||||
appState.setFollowMeMode(FollowMeMode.follow);
|
||||
enableFollowMe = true;
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -455,6 +553,52 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
});
|
||||
}
|
||||
|
||||
void openSuspectedLocationSheet(SuspectedLocation location) {
|
||||
final appState = context.read<AppState>();
|
||||
appState.selectSuspectedLocation(location);
|
||||
|
||||
// Start smooth centering animation simultaneously with sheet opening
|
||||
try {
|
||||
_mapController.animateTo(
|
||||
dest: location.centroid,
|
||||
zoom: _mapController.mapController.camera.zoom,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
} catch (_) {
|
||||
// Map controller not ready, fallback to immediate move
|
||||
try {
|
||||
_mapController.mapController.move(location.centroid, _mapController.mapController.camera.zoom);
|
||||
} catch (_) {
|
||||
// Controller really not ready, skip centering
|
||||
}
|
||||
}
|
||||
|
||||
final controller = _scaffoldKey.currentState!.showBottomSheet(
|
||||
(ctx) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).padding.bottom, // Only safe area, no keyboard
|
||||
),
|
||||
child: MeasuredSheet(
|
||||
onHeightChanged: (height) {
|
||||
setState(() {
|
||||
_tagSheetHeight = height + MediaQuery.of(context).padding.bottom;
|
||||
});
|
||||
},
|
||||
child: SuspectedLocationSheet(location: location),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Reset height and clear selection when sheet is dismissed
|
||||
controller.closed.then((_) {
|
||||
setState(() {
|
||||
_tagSheetHeight = 0.0;
|
||||
});
|
||||
appState.clearSuspectedLocationSelection();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
@@ -478,6 +622,12 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for welcome/changelog popup after app is fully initialized
|
||||
if (appState.isInitialized && !_hasCheckedForPopup) {
|
||||
_hasCheckedForPopup = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _checkForPopup());
|
||||
}
|
||||
|
||||
// Pass the active sheet height directly to the map
|
||||
final activeSheetHeight = _addSheetHeight > 0
|
||||
? _addSheetHeight
|
||||
@@ -536,6 +686,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
sheetHeight: activeSheetHeight,
|
||||
selectedNodeId: _selectedNodeId,
|
||||
onNodeTap: openNodeTagSheet,
|
||||
onSuspectedLocationTap: openSuspectedLocationSheet,
|
||||
onSearchPressed: _onNavigationButtonPressed,
|
||||
onUserGesture: () {
|
||||
if (appState.followMeMode != FollowMeMode.off) {
|
||||
@@ -557,71 +708,76 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
// Bottom button bar (restored to original)
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).padding.bottom + kBottomButtonBarOffset,
|
||||
left: 8,
|
||||
right: 8,
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 600), // Match typical sheet width
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Theme.of(context).shadowColor.withOpacity(0.3),
|
||||
blurRadius: 10,
|
||||
offset: Offset(0, -2),
|
||||
)
|
||||
],
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final safeArea = MediaQuery.of(context).padding;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: safeArea.bottom + kBottomButtonBarOffset,
|
||||
left: leftPositionWithSafeArea(8, safeArea),
|
||||
right: rightPositionWithSafeArea(8, safeArea),
|
||||
),
|
||||
margin: EdgeInsets.only(bottom: kBottomButtonBarOffset),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 7, // 70% for primary action
|
||||
child: AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => ElevatedButton.icon(
|
||||
icon: Icon(Icons.add_location_alt),
|
||||
label: Text(LocalizationService.instance.tagNode),
|
||||
onPressed: _openAddNodeSheet,
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: Size(0, 48),
|
||||
textStyle: TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 600), // Match typical sheet width
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Theme.of(context).shadowColor.withOpacity(0.3),
|
||||
blurRadius: 10,
|
||||
offset: Offset(0, -2),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 3, // 30% for secondary action
|
||||
child: AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: ElevatedButton.icon(
|
||||
icon: Icon(Icons.download_for_offline),
|
||||
label: Text(LocalizationService.instance.download),
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: Size(0, 48),
|
||||
textStyle: TextStyle(fontSize: 16),
|
||||
margin: EdgeInsets.only(bottom: kBottomButtonBarOffset),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 7, // 70% for primary action
|
||||
child: AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => ElevatedButton.icon(
|
||||
icon: Icon(Icons.add_location_alt),
|
||||
label: Text(LocalizationService.instance.tagNode),
|
||||
onPressed: _openAddNodeSheet,
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: Size(0, 48),
|
||||
textStyle: TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 3, // 30% for secondary action
|
||||
child: AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: ElevatedButton.icon(
|
||||
icon: Icon(Icons.download_for_offline),
|
||||
label: Text(LocalizationService.instance.download),
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: Size(0, 48),
|
||||
textStyle: TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -15,9 +15,14 @@ class LanguageSettingsScreen extends StatelessWidget {
|
||||
appBar: AppBar(
|
||||
title: Text(locService.t('settings.language')),
|
||||
),
|
||||
body: const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: LanguageSection(),
|
||||
body: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
16 + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
child: const LanguageSection(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -17,7 +17,12 @@ class NavigationSettingsScreen extends StatelessWidget {
|
||||
title: Text(locService.t('navigation.navigationSettings')),
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
16 + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
@@ -17,7 +17,12 @@ class OfflineSettingsScreen extends StatelessWidget {
|
||||
title: Text(locService.t('settings.offlineSettings')),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
16 + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
children: const [
|
||||
OfflineModeSection(),
|
||||
Divider(),
|
||||
|
||||
@@ -56,7 +56,12 @@ class _OperatorProfileEditorState extends State<OperatorProfileEditor> {
|
||||
title: Text(widget.profile.name.isEmpty ? locService.t('operatorProfileEditor.newOperatorProfile') : locService.t('operatorProfileEditor.editOperatorProfile')),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
16 + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
children: [
|
||||
TextField(
|
||||
controller: _nameCtrl,
|
||||
|
||||
@@ -67,7 +67,12 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
: (widget.profile.name.isEmpty ? locService.t('profileEditor.newProfile') : locService.t('profileEditor.editProfile'))),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
16 + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
children: [
|
||||
TextField(
|
||||
controller: _nameCtrl,
|
||||
|
||||
@@ -17,7 +17,12 @@ class ProfilesSettingsScreen extends StatelessWidget {
|
||||
title: Text(locService.t('settings.profiles')),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
16 + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
children: const [
|
||||
NodeProfilesSection(),
|
||||
Divider(),
|
||||
|
||||
196
lib/screens/release_notes_screen.dart
Normal file
196
lib/screens/release_notes_screen.dart
Normal file
@@ -0,0 +1,196 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/changelog_service.dart';
|
||||
import '../services/version_service.dart';
|
||||
|
||||
class ReleaseNotesScreen extends StatefulWidget {
|
||||
const ReleaseNotesScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ReleaseNotesScreen> createState() => _ReleaseNotesScreenState();
|
||||
}
|
||||
|
||||
class _ReleaseNotesScreenState extends State<ReleaseNotesScreen> {
|
||||
Map<String, String>? _changelogs;
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadChangelogs();
|
||||
}
|
||||
|
||||
Future<void> _loadChangelogs() async {
|
||||
try {
|
||||
// Ensure changelog service is initialized
|
||||
if (!ChangelogService().isInitialized) {
|
||||
await ChangelogService().init();
|
||||
}
|
||||
|
||||
final changelogs = ChangelogService().getAllChangelogs();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_changelogs = changelogs;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[ReleaseNotesScreen] Error loading changelogs: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_changelogs = {};
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _sortVersions(List<String> versions) {
|
||||
// Simple version sorting - splits by '.' and compares numerically
|
||||
versions.sort((a, b) {
|
||||
final aParts = a.split('.').map(int.tryParse).where((v) => v != null).cast<int>().toList();
|
||||
final bParts = b.split('.').map(int.tryParse).where((v) => v != null).cast<int>().toList();
|
||||
|
||||
// Compare version parts (reverse order for newest first)
|
||||
for (int i = 0; i < aParts.length && i < bParts.length; i++) {
|
||||
final comparison = bParts[i].compareTo(aParts[i]); // Reverse for desc order
|
||||
if (comparison != 0) return comparison;
|
||||
}
|
||||
|
||||
// If one version has more parts, the longer one is newer
|
||||
return bParts.length.compareTo(aParts.length);
|
||||
});
|
||||
|
||||
return versions;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Release Notes'),
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _changelogs == null || _changelogs!.isEmpty
|
||||
? const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24.0),
|
||||
child: Text(
|
||||
'No release notes available.',
|
||||
style: TextStyle(fontSize: 16),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
16 + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
children: [
|
||||
// Current version indicator
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Current Version: ${VersionService().version}',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Changelog entries
|
||||
..._buildChangelogEntries(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildChangelogEntries() {
|
||||
if (_changelogs == null || _changelogs!.isEmpty) return [];
|
||||
|
||||
final sortedVersions = _sortVersions(_changelogs!.keys.toList());
|
||||
final currentVersion = VersionService().version;
|
||||
|
||||
return sortedVersions.map((version) {
|
||||
final content = _changelogs![version]!;
|
||||
final isCurrentVersion = version == currentVersion;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: isCurrentVersion
|
||||
? Theme.of(context).colorScheme.primary.withOpacity(0.3)
|
||||
: Theme.of(context).dividerColor.withOpacity(0.3),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: ExpansionTile(
|
||||
title: Row(
|
||||
children: [
|
||||
Text(
|
||||
'Version $version',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isCurrentVersion
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: null,
|
||||
),
|
||||
),
|
||||
if (isCurrentVersion) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'CURRENT',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
content,
|
||||
style: const TextStyle(height: 1.4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,8 @@ class _MaxNodesSectionState extends State<MaxNodesSection> {
|
||||
width: 80,
|
||||
child: TextFormField(
|
||||
controller: _controller,
|
||||
keyboardType: TextInputType.number,
|
||||
keyboardType: const TextInputType.numberWithOptions(signed: true, decimal: true),
|
||||
textInputAction: TextInputAction.done,
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
||||
|
||||
@@ -77,6 +77,27 @@ class OfflineModeSection extends StatelessWidget {
|
||||
onChanged: (value) => _handleOfflineModeChange(context, appState, value),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
Icons.pause_circle_outline,
|
||||
color: appState.offlineMode
|
||||
? Theme.of(context).disabledColor
|
||||
: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
title: Text(
|
||||
locService.t('settings.pauseQueueProcessingSubtitle'),
|
||||
style: appState.offlineMode
|
||||
? TextStyle(color: Theme.of(context).disabledColor)
|
||||
: null,
|
||||
),
|
||||
trailing: Switch(
|
||||
value: appState.pauseQueueProcessing,
|
||||
onChanged: appState.offlineMode
|
||||
? null // Disable when offline mode is on
|
||||
: (value) => appState.setPauseQueueProcessing(value),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -181,7 +181,8 @@ class _ProximityAlertsSectionState extends State<ProximityAlertsSection> {
|
||||
width: 80,
|
||||
child: TextField(
|
||||
controller: _distanceController,
|
||||
keyboardType: TextInputType.number,
|
||||
keyboardType: const TextInputType.numberWithOptions(signed: true, decimal: true),
|
||||
textInputAction: TextInputAction.done,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
|
||||
@@ -119,7 +119,11 @@ class QueueSection extends StatelessWidget {
|
||||
locService.t('queue.destination', params: [_getUploadModeDisplayName(upload.uploadMode)]) + '\n' +
|
||||
locService.t('queue.latitude', params: [upload.coord.latitude.toStringAsFixed(6)]) + '\n' +
|
||||
locService.t('queue.longitude', params: [upload.coord.longitude.toStringAsFixed(6)]) + '\n' +
|
||||
locService.t('queue.direction', params: [upload.direction.round().toString()]) + '\n' +
|
||||
locService.t('queue.direction', params: [
|
||||
upload.direction is String
|
||||
? upload.direction.toString()
|
||||
: upload.direction.round().toString()
|
||||
]) + '\n' +
|
||||
locService.t('queue.attempts', params: [upload.attempts.toString()]) +
|
||||
(upload.error ? "\n${locService.t('queue.uploadFailedRetry')}" : "")
|
||||
),
|
||||
|
||||
150
lib/screens/settings/sections/suspected_locations_section.dart
Normal file
150
lib/screens/settings/sections/suspected_locations_section.dart
Normal file
@@ -0,0 +1,150 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../../app_state.dart';
|
||||
import '../../../services/localization_service.dart';
|
||||
|
||||
class SuspectedLocationsSection extends StatelessWidget {
|
||||
const SuspectedLocationsSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.watch<AppState>();
|
||||
final isEnabled = appState.suspectedLocationsEnabled;
|
||||
final isLoading = appState.suspectedLocationsLoading;
|
||||
final lastFetch = appState.suspectedLocationsLastFetch;
|
||||
|
||||
String getLastFetchText() {
|
||||
if (lastFetch == null) {
|
||||
return locService.t('suspectedLocations.neverFetched');
|
||||
} else {
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(lastFetch);
|
||||
if (diff.inDays > 0) {
|
||||
return locService.t('suspectedLocations.daysAgo', params: [diff.inDays.toString()]);
|
||||
} else if (diff.inHours > 0) {
|
||||
return locService.t('suspectedLocations.hoursAgo', params: [diff.inHours.toString()]);
|
||||
} else if (diff.inMinutes > 0) {
|
||||
return locService.t('suspectedLocations.minutesAgo', params: [diff.inMinutes.toString()]);
|
||||
} else {
|
||||
return locService.t('suspectedLocations.justNow');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handleRefresh() async {
|
||||
if (!context.mounted) return;
|
||||
|
||||
// Use the inline loading indicator by calling refreshSuspectedLocations
|
||||
// The loading state will be managed by suspected location state
|
||||
final success = await appState.refreshSuspectedLocations();
|
||||
|
||||
// Show result snackbar
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(success
|
||||
? locService.t('suspectedLocations.updateSuccess')
|
||||
: locService.t('suspectedLocations.updateFailed')),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('suspectedLocations.title'),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Enable/disable switch
|
||||
ListTile(
|
||||
leading: const Icon(Icons.help_outline),
|
||||
title: Text(locService.t('suspectedLocations.showSuspectedLocations')),
|
||||
subtitle: Text(locService.t('suspectedLocations.showSuspectedLocationsSubtitle')),
|
||||
trailing: Switch(
|
||||
value: isEnabled,
|
||||
onChanged: (enabled) {
|
||||
appState.setSuspectedLocationsEnabled(enabled);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
if (isEnabled) ...[
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Last update time
|
||||
ListTile(
|
||||
leading: const Icon(Icons.schedule),
|
||||
title: Text(locService.t('suspectedLocations.lastUpdated')),
|
||||
subtitle: Text(getLastFetchText()),
|
||||
trailing: isLoading
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: handleRefresh,
|
||||
tooltip: locService.t('suspectedLocations.refreshNow'),
|
||||
),
|
||||
),
|
||||
|
||||
// Data info with credit
|
||||
ListTile(
|
||||
leading: const Icon(Icons.info_outline),
|
||||
title: Text(locService.t('suspectedLocations.dataSource')),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(locService.t('suspectedLocations.dataSourceDescription')),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
locService.t('suspectedLocations.dataSourceCredit'),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Minimum distance setting
|
||||
ListTile(
|
||||
leading: const Icon(Icons.social_distance),
|
||||
title: Text(locService.t('suspectedLocations.minimumDistance')),
|
||||
subtitle: Text(locService.t('suspectedLocations.minimumDistanceSubtitle', params: [appState.suspectedLocationMinDistance.toString()])),
|
||||
trailing: SizedBox(
|
||||
width: 80,
|
||||
child: TextFormField(
|
||||
initialValue: appState.suspectedLocationMinDistance.toString(),
|
||||
keyboardType: const TextInputType.numberWithOptions(signed: true, decimal: true),
|
||||
textInputAction: TextInputAction.done,
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
||||
border: OutlineInputBorder(),
|
||||
suffixText: 'm',
|
||||
),
|
||||
onFieldSubmitted: (value) {
|
||||
final distance = int.tryParse(value) ?? 100;
|
||||
appState.setSuspectedLocationMinDistance(distance.clamp(0, 1000));
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,12 @@ class SettingsScreen extends StatelessWidget {
|
||||
builder: (context, child) => Scaffold(
|
||||
appBar: AppBar(title: Text(locService.t('settings.title'))),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
16 + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
children: [
|
||||
// Only show upload mode section in development builds
|
||||
if (kEnableDevelopmentModes) ...[
|
||||
|
||||
@@ -64,7 +64,12 @@ class _TileProviderEditorScreenState extends State<TileProviderEditorScreen> {
|
||||
body: Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
16 + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
|
||||
318
lib/services/changelog_service.dart
Normal file
318
lib/services/changelog_service.dart
Normal file
@@ -0,0 +1,318 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'version_service.dart';
|
||||
import '../app_state.dart';
|
||||
|
||||
/// Service for managing changelog data and first launch detection
|
||||
class ChangelogService {
|
||||
static final ChangelogService _instance = ChangelogService._internal();
|
||||
factory ChangelogService() => _instance;
|
||||
ChangelogService._internal();
|
||||
|
||||
static const String _lastSeenVersionKey = 'last_seen_version';
|
||||
static const String _hasSeenWelcomeKey = 'has_seen_welcome';
|
||||
|
||||
Map<String, dynamic>? _changelogData;
|
||||
bool _initialized = false;
|
||||
|
||||
/// Parse changelog content from either string or array format
|
||||
String? _parseChangelogContent(dynamic content) {
|
||||
if (content == null) return null;
|
||||
|
||||
if (content is String) {
|
||||
// Legacy format: single string with \n
|
||||
return content.isEmpty ? null : content;
|
||||
} else if (content is List) {
|
||||
// New format: array of strings
|
||||
final lines = content.whereType<String>().where((line) => line.isNotEmpty).toList();
|
||||
return lines.isEmpty ? null : lines.join('\n');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Initialize the service by loading changelog data
|
||||
Future<void> init() async {
|
||||
if (_initialized) return;
|
||||
|
||||
try {
|
||||
final String jsonString = await rootBundle.loadString('assets/changelog.json');
|
||||
_changelogData = json.decode(jsonString);
|
||||
_initialized = true;
|
||||
debugPrint('[ChangelogService] Loaded changelog with ${_changelogData?.keys.length ?? 0} versions');
|
||||
} catch (e) {
|
||||
debugPrint('[ChangelogService] Failed to load changelog: $e');
|
||||
_changelogData = {};
|
||||
_initialized = true; // Mark as initialized even on failure to prevent repeated attempts
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this is the first app launch ever
|
||||
Future<bool> isFirstLaunch() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return !prefs.containsKey(_lastSeenVersionKey);
|
||||
}
|
||||
|
||||
/// Check if user has seen the welcome popup (separate from version tracking)
|
||||
Future<bool> hasSeenWelcome() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_hasSeenWelcomeKey) ?? false;
|
||||
}
|
||||
|
||||
/// Mark that user has seen the welcome popup
|
||||
Future<void> markWelcomeSeen() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_hasSeenWelcomeKey, true);
|
||||
}
|
||||
|
||||
/// Check if app version has changed since last launch
|
||||
Future<bool> hasVersionChanged() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final lastSeenVersion = prefs.getString(_lastSeenVersionKey);
|
||||
final currentVersion = VersionService().version;
|
||||
|
||||
return lastSeenVersion != currentVersion;
|
||||
}
|
||||
|
||||
/// Update the stored version to current version
|
||||
Future<void> updateLastSeenVersion() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final currentVersion = VersionService().version;
|
||||
await prefs.setString(_lastSeenVersionKey, currentVersion);
|
||||
debugPrint('[ChangelogService] Updated last seen version to: $currentVersion');
|
||||
}
|
||||
|
||||
/// Get the last seen version (for migration purposes)
|
||||
Future<String?> getLastSeenVersion() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_lastSeenVersionKey);
|
||||
}
|
||||
|
||||
/// Get changelog content for the current version
|
||||
String? getChangelogForCurrentVersion() {
|
||||
if (!_initialized || _changelogData == null) {
|
||||
debugPrint('[ChangelogService] Not initialized or no changelog data');
|
||||
return null;
|
||||
}
|
||||
|
||||
final currentVersion = VersionService().version;
|
||||
final versionData = _changelogData![currentVersion] as Map<String, dynamic>?;
|
||||
|
||||
if (versionData == null) {
|
||||
debugPrint('[ChangelogService] No changelog entry found for version: $currentVersion');
|
||||
return null;
|
||||
}
|
||||
|
||||
return _parseChangelogContent(versionData['content']);
|
||||
}
|
||||
|
||||
/// Get the changelog content that should be displayed (may be combined from multiple versions)
|
||||
/// This is the method home_screen should use to get content for the changelog popup
|
||||
Future<String?> getChangelogContentForDisplay() async {
|
||||
return await getCombinedChangelogContent();
|
||||
}
|
||||
|
||||
/// Complete the version change workflow - call this after showing popups
|
||||
/// This updates the last seen version so migrations don't run again
|
||||
Future<void> completeVersionChange() async {
|
||||
await updateLastSeenVersion();
|
||||
}
|
||||
|
||||
/// Get changelog content for a specific version
|
||||
String? getChangelogForVersion(String version) {
|
||||
if (!_initialized || _changelogData == null) return null;
|
||||
|
||||
final versionData = _changelogData![version] as Map<String, dynamic>?;
|
||||
if (versionData == null) return null;
|
||||
|
||||
return _parseChangelogContent(versionData['content']);
|
||||
}
|
||||
|
||||
/// Get all changelog entries (for settings page)
|
||||
Map<String, String> getAllChangelogs() {
|
||||
if (!_initialized || _changelogData == null) return {};
|
||||
|
||||
final Map<String, String> result = {};
|
||||
|
||||
for (final entry in _changelogData!.entries) {
|
||||
final version = entry.key;
|
||||
final versionData = entry.value as Map<String, dynamic>?;
|
||||
final content = _parseChangelogContent(versionData?['content']);
|
||||
|
||||
// Only include versions with non-empty content
|
||||
if (content != null && content.isNotEmpty) {
|
||||
result[version] = content;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Determine what popup (if any) should be shown
|
||||
Future<PopupType> getPopupType() async {
|
||||
// Ensure services are initialized
|
||||
if (!_initialized) await init();
|
||||
|
||||
final isFirstLaunch = await this.isFirstLaunch();
|
||||
final hasSeenWelcome = await this.hasSeenWelcome();
|
||||
final hasVersionChanged = await this.hasVersionChanged();
|
||||
|
||||
// First launch and haven't seen welcome
|
||||
if (isFirstLaunch || !hasSeenWelcome) {
|
||||
return PopupType.welcome;
|
||||
}
|
||||
|
||||
// Version changed and there's changelog content
|
||||
if (hasVersionChanged) {
|
||||
final changelogContent = await getCombinedChangelogContent();
|
||||
if (changelogContent != null) {
|
||||
return PopupType.changelog;
|
||||
}
|
||||
}
|
||||
|
||||
return PopupType.none;
|
||||
}
|
||||
|
||||
/// Check if version-change migrations need to be run
|
||||
/// Returns list of version strings that need migrations
|
||||
Future<List<String>> getVersionsNeedingMigration() async {
|
||||
final lastSeenVersion = await getLastSeenVersion();
|
||||
final currentVersion = VersionService().version;
|
||||
|
||||
if (lastSeenVersion == null) return []; // First launch, no migrations needed
|
||||
|
||||
final versionsNeedingMigration = <String>[];
|
||||
|
||||
// Check each version that could need migration
|
||||
if (needsMigration(lastSeenVersion, currentVersion, '1.3.1')) {
|
||||
versionsNeedingMigration.add('1.3.1');
|
||||
}
|
||||
|
||||
// Future versions can be added here
|
||||
// if (needsMigration(lastSeenVersion, currentVersion, '2.0.0')) {
|
||||
// versionsNeedingMigration.add('2.0.0');
|
||||
// }
|
||||
|
||||
return versionsNeedingMigration;
|
||||
}
|
||||
|
||||
/// Get combined changelog content for all versions between last seen and current
|
||||
/// Returns null if no changelog content exists for any intermediate version
|
||||
Future<String?> getCombinedChangelogContent() async {
|
||||
if (!_initialized || _changelogData == null) return null;
|
||||
|
||||
final lastSeenVersion = await getLastSeenVersion();
|
||||
final currentVersion = VersionService().version;
|
||||
|
||||
if (lastSeenVersion == null) {
|
||||
// First launch - just return current version changelog
|
||||
return getChangelogForCurrentVersion();
|
||||
}
|
||||
|
||||
final intermediateVersions = <String>[];
|
||||
|
||||
// Collect all relevant versions between lastSeen and current (exclusive of lastSeen, inclusive of current)
|
||||
for (final entry in _changelogData!.entries) {
|
||||
final version = entry.key;
|
||||
final versionData = entry.value as Map<String, dynamic>?;
|
||||
final content = _parseChangelogContent(versionData?['content']);
|
||||
|
||||
// Skip versions with empty content
|
||||
if (content == null || content.isEmpty) continue;
|
||||
|
||||
// Include versions where: lastSeenVersion < version <= currentVersion
|
||||
if (needsMigration(lastSeenVersion, currentVersion, version)) {
|
||||
intermediateVersions.add(version);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort versions in descending order (newest first)
|
||||
intermediateVersions.sort((a, b) => compareVersions(b, a));
|
||||
|
||||
// Build changelog content
|
||||
final intermediateChangelogs = intermediateVersions.map((version) {
|
||||
final versionData = _changelogData![version] as Map<String, dynamic>;
|
||||
final content = _parseChangelogContent(versionData['content'])!; // Safe to use ! here since we filtered empty content above
|
||||
return '**Version $version:**\n$content';
|
||||
}).toList();
|
||||
|
||||
return intermediateChangelogs.isNotEmpty ? intermediateChangelogs.join('\n\n---\n\n') : null;
|
||||
}
|
||||
|
||||
/// Check if the service is properly initialized
|
||||
bool get isInitialized => _initialized;
|
||||
|
||||
/// Run a specific migration by version number
|
||||
Future<void> runMigration(String version, AppState appState) async {
|
||||
debugPrint('[ChangelogService] Running $version migration');
|
||||
|
||||
switch (version) {
|
||||
case '1.3.1':
|
||||
// Enable network status indicator for all existing users
|
||||
await appState.setNetworkStatusIndicatorEnabled(true);
|
||||
debugPrint('[ChangelogService] 1.3.1 migration completed: enabled network status indicator');
|
||||
break;
|
||||
|
||||
// Future migrations can be added here
|
||||
// case '2.0.0':
|
||||
// await appState.doSomethingNew();
|
||||
// debugPrint('[ChangelogService] 2.0.0 migration completed');
|
||||
// break;
|
||||
|
||||
default:
|
||||
debugPrint('[ChangelogService] Unknown migration version: $version');
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a migration should run
|
||||
/// Migration runs if: lastSeenVersion < migrationVersion <= currentVersion
|
||||
bool needsMigration(String lastSeenVersion, String currentVersion, String migrationVersion) {
|
||||
final lastVsMigration = compareVersions(lastSeenVersion, migrationVersion);
|
||||
final migrationVsCurrent = compareVersions(migrationVersion, currentVersion);
|
||||
|
||||
return lastVsMigration < 0 && migrationVsCurrent <= 0;
|
||||
}
|
||||
|
||||
/// Compare two version strings
|
||||
/// Returns -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2
|
||||
/// Versions are expected in format "major.minor.patch" (e.g., "1.3.1")
|
||||
int compareVersions(String v1, String v2) {
|
||||
try {
|
||||
final v1Parts = v1.split('.').map(int.parse).toList();
|
||||
final v2Parts = v2.split('.').map(int.parse).toList();
|
||||
|
||||
// Ensure we have at least 3 parts (major.minor.patch)
|
||||
while (v1Parts.length < 3) v1Parts.add(0);
|
||||
while (v2Parts.length < 3) v2Parts.add(0);
|
||||
|
||||
// Compare major version first
|
||||
if (v1Parts[0] < v2Parts[0]) return -1;
|
||||
if (v1Parts[0] > v2Parts[0]) return 1;
|
||||
|
||||
// Major versions equal, compare minor version
|
||||
if (v1Parts[1] < v2Parts[1]) return -1;
|
||||
if (v1Parts[1] > v2Parts[1]) return 1;
|
||||
|
||||
// Major and minor equal, compare patch version
|
||||
if (v1Parts[2] < v2Parts[2]) return -1;
|
||||
if (v1Parts[2] > v2Parts[2]) return 1;
|
||||
|
||||
// All parts equal
|
||||
return 0;
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[ChangelogService] Error comparing versions "$v1" vs "$v2": $e');
|
||||
// Safe fallback: assume they're different so we run migrations
|
||||
return v1 == v2 ? 0 : -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Types of popups that can be shown
|
||||
enum PopupType {
|
||||
none,
|
||||
welcome,
|
||||
changelog,
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import 'map_data_submodules/tiles_from_remote.dart';
|
||||
import 'map_data_submodules/nodes_from_local.dart';
|
||||
import 'map_data_submodules/tiles_from_local.dart';
|
||||
import 'network_status.dart';
|
||||
import 'prefetch_area_service.dart';
|
||||
|
||||
enum MapSource { local, remote, auto } // For future use
|
||||
|
||||
@@ -41,7 +42,6 @@ class MapDataProvider {
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
MapSource source = MapSource.auto,
|
||||
}) async {
|
||||
try {
|
||||
final offline = AppState.instance.offlineMode;
|
||||
|
||||
// Explicit remote request: error if offline, else always remote
|
||||
@@ -89,52 +89,40 @@ class MapDataProvider {
|
||||
maxResults: AppState.instance.maxCameras,
|
||||
);
|
||||
} else {
|
||||
// Production mode: fetch both remote and local, then merge with deduplication
|
||||
final List<Future<List<OsmNode>>> futures = [];
|
||||
// Production mode: use pre-fetch service for efficient area loading
|
||||
final preFetchService = PrefetchAreaService();
|
||||
|
||||
// Always try to get local nodes (fast, cached)
|
||||
futures.add(fetchLocalNodes(
|
||||
// Always get local nodes first (fast, from cache)
|
||||
final localNodes = await fetchLocalNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
maxNodes: AppState.instance.maxCameras,
|
||||
));
|
||||
);
|
||||
|
||||
// Always try to get remote nodes (slower, fresh data)
|
||||
futures.add(_fetchRemoteNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: AppState.instance.maxCameras,
|
||||
).catchError((e) {
|
||||
debugPrint('[MapDataProvider] Remote node fetch failed, error: $e. Continuing with local only.');
|
||||
return <OsmNode>[]; // Return empty list on remote failure
|
||||
}));
|
||||
// Check if we need to trigger a new pre-fetch (spatial or temporal)
|
||||
final needsFetch = !preFetchService.isWithinPreFetchedArea(bounds, profiles, uploadMode) ||
|
||||
preFetchService.isDataStale();
|
||||
|
||||
// Wait for both, then merge with deduplication by node ID
|
||||
final results = await Future.wait(futures);
|
||||
final localNodes = results[0];
|
||||
final remoteNodes = results[1];
|
||||
|
||||
// Merge with deduplication - prefer remote data over local for same node ID
|
||||
final Map<int, OsmNode> mergedNodes = {};
|
||||
|
||||
// Add local nodes first
|
||||
for (final node in localNodes) {
|
||||
mergedNodes[node.id] = node;
|
||||
if (needsFetch) {
|
||||
// Outside area OR data stale - start pre-fetch with loading state
|
||||
debugPrint('[MapDataProvider] Starting pre-fetch with loading state');
|
||||
NetworkStatus.instance.setWaiting();
|
||||
preFetchService.requestPreFetchIfNeeded(
|
||||
viewBounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
);
|
||||
} else {
|
||||
debugPrint('[MapDataProvider] Using existing fresh pre-fetched area cache');
|
||||
}
|
||||
|
||||
// Add remote nodes, overwriting any local duplicates
|
||||
for (final node in remoteNodes) {
|
||||
mergedNodes[node.id] = node;
|
||||
// Apply rendering limit and warn if nodes are being excluded
|
||||
final maxNodes = AppState.instance.maxCameras;
|
||||
if (localNodes.length > maxNodes) {
|
||||
NetworkStatus.instance.reportNodeLimitReached(localNodes.length, maxNodes);
|
||||
}
|
||||
|
||||
// Apply maxCameras limit to the merged result
|
||||
final finalNodes = mergedNodes.values.take(AppState.instance.maxCameras).toList();
|
||||
return finalNodes;
|
||||
}
|
||||
} finally {
|
||||
// Always report node completion, regardless of success or failure
|
||||
NetworkStatus.instance.reportNodeComplete();
|
||||
return localNodes.take(maxNodes).toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +200,11 @@ class MapDataProvider {
|
||||
void clearTileQueue() {
|
||||
clearRemoteTileQueue();
|
||||
}
|
||||
|
||||
/// Clear only tile requests that are no longer visible in the current bounds
|
||||
void clearTileQueueSelective(LatLngBounds currentBounds) {
|
||||
clearRemoteTileQueueSelective(currentBounds);
|
||||
}
|
||||
|
||||
/// Fetch remote nodes with Overpass first, OSM API fallback
|
||||
Future<List<OsmNode>> _fetchRemoteNodes({
|
||||
|
||||
@@ -47,44 +47,7 @@ Future<List<OsmNode>> fetchOsmApiNodes({
|
||||
|
||||
// Parse XML response
|
||||
final document = XmlDocument.parse(response.body);
|
||||
final nodes = <OsmNode>[];
|
||||
|
||||
// Find all node elements
|
||||
for (final nodeElement in document.findAllElements('node')) {
|
||||
final id = int.tryParse(nodeElement.getAttribute('id') ?? '');
|
||||
final latStr = nodeElement.getAttribute('lat');
|
||||
final lonStr = nodeElement.getAttribute('lon');
|
||||
|
||||
if (id == null || latStr == null || lonStr == null) continue;
|
||||
|
||||
final lat = double.tryParse(latStr);
|
||||
final lon = double.tryParse(lonStr);
|
||||
if (lat == null || lon == null) continue;
|
||||
|
||||
// Parse tags
|
||||
final tags = <String, String>{};
|
||||
for (final tagElement in nodeElement.findElements('tag')) {
|
||||
final key = tagElement.getAttribute('k');
|
||||
final value = tagElement.getAttribute('v');
|
||||
if (key != null && value != null) {
|
||||
tags[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this node matches any of our profiles
|
||||
if (_nodeMatchesProfiles(tags, profiles)) {
|
||||
nodes.add(OsmNode(
|
||||
id: id,
|
||||
coord: LatLng(lat, lon),
|
||||
tags: tags,
|
||||
));
|
||||
}
|
||||
|
||||
// Respect maxResults limit if set
|
||||
if (maxResults > 0 && nodes.length >= maxResults) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
final nodes = _parseOsmApiResponseWithConstraints(document, profiles, maxResults);
|
||||
|
||||
if (nodes.isNotEmpty) {
|
||||
debugPrint('[fetchOsmApiNodes] Retrieved ${nodes.length} matching surveillance nodes');
|
||||
@@ -107,6 +70,93 @@ Future<List<OsmNode>> fetchOsmApiNodes({
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse OSM API XML response to create OsmNode objects with constraint information.
|
||||
List<OsmNode> _parseOsmApiResponseWithConstraints(XmlDocument document, List<NodeProfile> profiles, int maxResults) {
|
||||
final surveillanceNodes = <int, Map<String, dynamic>>{}; // nodeId -> node data
|
||||
final constrainedNodeIds = <int>{};
|
||||
|
||||
// First pass: collect surveillance nodes
|
||||
for (final nodeElement in document.findAllElements('node')) {
|
||||
final id = int.tryParse(nodeElement.getAttribute('id') ?? '');
|
||||
final latStr = nodeElement.getAttribute('lat');
|
||||
final lonStr = nodeElement.getAttribute('lon');
|
||||
|
||||
if (id == null || latStr == null || lonStr == null) continue;
|
||||
|
||||
final lat = double.tryParse(latStr);
|
||||
final lon = double.tryParse(lonStr);
|
||||
if (lat == null || lon == null) continue;
|
||||
|
||||
// Parse tags
|
||||
final tags = <String, String>{};
|
||||
for (final tagElement in nodeElement.findElements('tag')) {
|
||||
final key = tagElement.getAttribute('k');
|
||||
final value = tagElement.getAttribute('v');
|
||||
if (key != null && value != null) {
|
||||
tags[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this node matches any of our profiles
|
||||
if (_nodeMatchesProfiles(tags, profiles)) {
|
||||
surveillanceNodes[id] = {
|
||||
'id': id,
|
||||
'lat': lat,
|
||||
'lon': lon,
|
||||
'tags': tags,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: identify constrained nodes from ways
|
||||
for (final wayElement in document.findAllElements('way')) {
|
||||
for (final ndElement in wayElement.findElements('nd')) {
|
||||
final ref = int.tryParse(ndElement.getAttribute('ref') ?? '');
|
||||
if (ref != null && surveillanceNodes.containsKey(ref)) {
|
||||
constrainedNodeIds.add(ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Third pass: identify constrained nodes from relations
|
||||
for (final relationElement in document.findAllElements('relation')) {
|
||||
for (final memberElement in relationElement.findElements('member')) {
|
||||
if (memberElement.getAttribute('type') == 'node') {
|
||||
final ref = int.tryParse(memberElement.getAttribute('ref') ?? '');
|
||||
if (ref != null && surveillanceNodes.containsKey(ref)) {
|
||||
constrainedNodeIds.add(ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create OsmNode objects with constraint information
|
||||
final nodes = <OsmNode>[];
|
||||
for (final nodeData in surveillanceNodes.values) {
|
||||
final nodeId = nodeData['id'] as int;
|
||||
final isConstrained = constrainedNodeIds.contains(nodeId);
|
||||
|
||||
nodes.add(OsmNode(
|
||||
id: nodeId,
|
||||
coord: LatLng(nodeData['lat'], nodeData['lon']),
|
||||
tags: nodeData['tags'] as Map<String, String>,
|
||||
isConstrained: isConstrained,
|
||||
));
|
||||
|
||||
// Respect maxResults limit if set
|
||||
if (maxResults > 0 && nodes.length >= maxResults) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
final constrainedCount = nodes.where((n) => n.isConstrained).length;
|
||||
if (constrainedCount > 0) {
|
||||
debugPrint('[fetchOsmApiNodes] Found $constrainedCount constrained nodes out of ${nodes.length} total');
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/// Check if a node's tags match any of the given profiles
|
||||
bool _nodeMatchesProfiles(Map<String, String> nodeTags, List<NodeProfile> profiles) {
|
||||
for (final profile in profiles) {
|
||||
|
||||
@@ -8,17 +8,101 @@ import '../../models/node_profile.dart';
|
||||
import '../../models/osm_node.dart';
|
||||
import '../../models/pending_upload.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../../dev_config.dart';
|
||||
import '../network_status.dart';
|
||||
import '../overpass_node_limit_exception.dart';
|
||||
|
||||
/// Fetches surveillance nodes from the Overpass OSM API for the given bounds and profiles.
|
||||
/// If the query fails due to too many nodes, automatically splits the area and retries.
|
||||
Future<List<OsmNode>> fetchOverpassNodes({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
required int maxResults,
|
||||
}) async {
|
||||
// Check if this is a user-initiated fetch (indicated by loading state)
|
||||
final wasUserInitiated = NetworkStatus.instance.currentStatus == NetworkStatusType.waiting;
|
||||
|
||||
return _fetchOverpassNodesWithSplitting(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: maxResults,
|
||||
splitDepth: 0,
|
||||
wasUserInitiated: wasUserInitiated,
|
||||
);
|
||||
}
|
||||
|
||||
/// Internal method that handles splitting when node limit is exceeded.
|
||||
Future<List<OsmNode>> _fetchOverpassNodesWithSplitting({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
required int maxResults,
|
||||
required int splitDepth,
|
||||
required bool wasUserInitiated,
|
||||
}) async {
|
||||
if (profiles.isEmpty) return [];
|
||||
|
||||
const int maxSplitDepth = kMaxPreFetchSplitDepth; // Maximum times we'll split (4^3 = 64 max sub-areas)
|
||||
|
||||
try {
|
||||
return await _fetchSingleOverpassQuery(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
maxResults: maxResults,
|
||||
);
|
||||
} on OverpassRateLimitException catch (e) {
|
||||
// Rate limits should NOT be split - just fail with extended backoff
|
||||
debugPrint('[fetchOverpassNodes] Rate limited - using extended backoff, not splitting');
|
||||
|
||||
// Report error if user was waiting
|
||||
if (wasUserInitiated) {
|
||||
NetworkStatus.instance.setNetworkError();
|
||||
}
|
||||
|
||||
// Wait longer for rate limits before giving up entirely
|
||||
await Future.delayed(const Duration(seconds: 30));
|
||||
return []; // Return empty rather than rethrowing
|
||||
} on OverpassNodeLimitException {
|
||||
// If we've hit max split depth, give up to avoid infinite recursion
|
||||
if (splitDepth >= maxSplitDepth) {
|
||||
debugPrint('[fetchOverpassNodes] Max split depth reached, giving up on area: $bounds');
|
||||
// Report timeout if this was user-initiated (can't split further)
|
||||
if (wasUserInitiated) {
|
||||
NetworkStatus.instance.setTimeoutError();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// Split the bounds into 4 quadrants and try each separately
|
||||
debugPrint('[fetchOverpassNodes] Splitting area into quadrants (depth: $splitDepth)');
|
||||
final quadrants = _splitBounds(bounds);
|
||||
final List<OsmNode> allNodes = [];
|
||||
|
||||
for (final quadrant in quadrants) {
|
||||
final nodes = await _fetchOverpassNodesWithSplitting(
|
||||
bounds: quadrant,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: 0, // No limit on individual quadrants to avoid double-limiting
|
||||
splitDepth: splitDepth + 1,
|
||||
wasUserInitiated: wasUserInitiated,
|
||||
);
|
||||
allNodes.addAll(nodes);
|
||||
}
|
||||
|
||||
debugPrint('[fetchOverpassNodes] Collected ${allNodes.length} nodes from ${quadrants.length} quadrants');
|
||||
return allNodes;
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform a single Overpass query without splitting logic.
|
||||
Future<List<OsmNode>> _fetchSingleOverpassQuery({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
required int maxResults,
|
||||
}) async {
|
||||
const String overpassEndpoint = 'https://overpass-api.de/api/interpreter';
|
||||
|
||||
// Build the Overpass query
|
||||
@@ -34,27 +118,49 @@ Future<List<OsmNode>> fetchOverpassNodes({
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
debugPrint('[fetchOverpassNodes] Overpass API error: ${response.body}');
|
||||
final errorBody = response.body;
|
||||
debugPrint('[fetchOverpassNodes] Overpass API error: $errorBody');
|
||||
|
||||
// Check if it's specifically the 50k node limit error (HTTP 400)
|
||||
// Exact message: "You requested too many nodes (limit is 50000)"
|
||||
if (errorBody.contains('too many nodes') &&
|
||||
errorBody.contains('50000')) {
|
||||
debugPrint('[fetchOverpassNodes] Detected 50k node limit error, will attempt splitting');
|
||||
throw OverpassNodeLimitException('Query exceeded node limit', serverResponse: errorBody);
|
||||
}
|
||||
|
||||
// Check for timeout errors that indicate query complexity (should split)
|
||||
// Common timeout messages from Overpass
|
||||
if (errorBody.contains('timeout') ||
|
||||
errorBody.contains('runtime limit exceeded') ||
|
||||
errorBody.contains('Query timed out')) {
|
||||
debugPrint('[fetchOverpassNodes] Detected timeout error, will attempt splitting to reduce complexity');
|
||||
throw OverpassNodeLimitException('Query timed out', serverResponse: errorBody);
|
||||
}
|
||||
|
||||
// Check for rate limiting (should NOT split - needs longer backoff)
|
||||
if (errorBody.contains('rate limited') ||
|
||||
errorBody.contains('too many requests') ||
|
||||
response.statusCode == 429) {
|
||||
debugPrint('[fetchOverpassNodes] Rate limited by Overpass API - needs extended backoff');
|
||||
throw OverpassRateLimitException('Rate limited by server', serverResponse: errorBody);
|
||||
}
|
||||
|
||||
NetworkStatus.instance.reportOverpassIssue();
|
||||
return [];
|
||||
}
|
||||
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final data = await compute(jsonDecode, response.body) as Map<String, dynamic>;
|
||||
final elements = data['elements'] as List<dynamic>;
|
||||
|
||||
if (elements.length > 20) {
|
||||
debugPrint('[fetchOverpassNodes] Retrieved ${elements.length} surveillance nodes');
|
||||
debugPrint('[fetchOverpassNodes] Retrieved ${elements.length} elements (nodes + ways/relations)');
|
||||
}
|
||||
|
||||
NetworkStatus.instance.reportOverpassSuccess();
|
||||
|
||||
final nodes = elements.whereType<Map<String, dynamic>>().map((element) {
|
||||
return OsmNode(
|
||||
id: element['id'],
|
||||
coord: LatLng(element['lat'], element['lon']),
|
||||
tags: Map<String, String>.from(element['tags'] ?? {}),
|
||||
);
|
||||
}).toList();
|
||||
// Parse response to determine which nodes are constrained
|
||||
final nodes = _parseOverpassResponseWithConstraints(elements);
|
||||
|
||||
// Clean up any pending uploads that now appear in Overpass results
|
||||
_cleanupCompletedUploads(nodes);
|
||||
@@ -62,6 +168,9 @@ Future<List<OsmNode>> fetchOverpassNodes({
|
||||
return nodes;
|
||||
|
||||
} catch (e) {
|
||||
// Re-throw OverpassNodeLimitException so splitting logic can catch it
|
||||
if (e is OverpassNodeLimitException) rethrow;
|
||||
|
||||
debugPrint('[fetchOverpassNodes] Exception: $e');
|
||||
|
||||
// Report network issues for connection errors
|
||||
@@ -76,6 +185,7 @@ Future<List<OsmNode>> fetchOverpassNodes({
|
||||
}
|
||||
|
||||
/// Builds an Overpass API query for surveillance nodes matching the given profiles within bounds.
|
||||
/// Also fetches ways and relations that reference these nodes to determine constraint status.
|
||||
String _buildOverpassQuery(LatLngBounds bounds, List<NodeProfile> profiles, int maxResults) {
|
||||
// Build node clauses for each profile
|
||||
final nodeClauses = profiles.map((profile) {
|
||||
@@ -86,20 +196,101 @@ String _buildOverpassQuery(LatLngBounds bounds, List<NodeProfile> profiles, int
|
||||
|
||||
// Build the node query with tag filters and bounding box
|
||||
return 'node$tagFilters(${bounds.southWest.latitude},${bounds.southWest.longitude},${bounds.northEast.latitude},${bounds.northEast.longitude});';
|
||||
}).join('\n ');
|
||||
}).join('\n ');
|
||||
|
||||
// Use unlimited output if maxResults is 0
|
||||
final outputClause = maxResults > 0 ? 'out body $maxResults;' : 'out body;';
|
||||
|
||||
return '''
|
||||
[out:json][timeout:25];
|
||||
(
|
||||
$nodeClauses
|
||||
);
|
||||
$outputClause
|
||||
out body ${maxResults > 0 ? maxResults : ''};
|
||||
(
|
||||
way(bn);
|
||||
rel(bn);
|
||||
);
|
||||
out meta;
|
||||
''';
|
||||
}
|
||||
|
||||
/// Split a LatLngBounds into 4 quadrants (NW, NE, SW, SE).
|
||||
List<LatLngBounds> _splitBounds(LatLngBounds bounds) {
|
||||
final centerLat = (bounds.north + bounds.south) / 2;
|
||||
final centerLng = (bounds.east + bounds.west) / 2;
|
||||
|
||||
return [
|
||||
// Southwest quadrant (bottom-left)
|
||||
LatLngBounds(
|
||||
LatLng(bounds.south, bounds.west),
|
||||
LatLng(centerLat, centerLng),
|
||||
),
|
||||
// Southeast quadrant (bottom-right)
|
||||
LatLngBounds(
|
||||
LatLng(bounds.south, centerLng),
|
||||
LatLng(centerLat, bounds.east),
|
||||
),
|
||||
// Northwest quadrant (top-left)
|
||||
LatLngBounds(
|
||||
LatLng(centerLat, bounds.west),
|
||||
LatLng(bounds.north, centerLng),
|
||||
),
|
||||
// Northeast quadrant (top-right)
|
||||
LatLngBounds(
|
||||
LatLng(centerLat, centerLng),
|
||||
LatLng(bounds.north, bounds.east),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Parse Overpass response elements to create OsmNode objects with constraint information.
|
||||
List<OsmNode> _parseOverpassResponseWithConstraints(List<dynamic> elements) {
|
||||
final nodeElements = <Map<String, dynamic>>[];
|
||||
final constrainedNodeIds = <int>{};
|
||||
|
||||
// First pass: collect surveillance nodes and identify constrained nodes
|
||||
for (final element in elements.whereType<Map<String, dynamic>>()) {
|
||||
final type = element['type'] as String?;
|
||||
|
||||
if (type == 'node') {
|
||||
// This is a surveillance node - collect it
|
||||
nodeElements.add(element);
|
||||
} else if (type == 'way' || type == 'relation') {
|
||||
// This is a way/relation that references some of our nodes
|
||||
final refs = element['nodes'] as List<dynamic>? ??
|
||||
element['members']?.where((m) => m['type'] == 'node').map((m) => m['ref']) ?? [];
|
||||
|
||||
// Mark all referenced nodes as constrained
|
||||
for (final ref in refs) {
|
||||
if (ref is int) {
|
||||
constrainedNodeIds.add(ref);
|
||||
} else if (ref is String) {
|
||||
final nodeId = int.tryParse(ref);
|
||||
if (nodeId != null) constrainedNodeIds.add(nodeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: create OsmNode objects with constraint info
|
||||
final nodes = nodeElements.map((element) {
|
||||
final nodeId = element['id'] as int;
|
||||
final isConstrained = constrainedNodeIds.contains(nodeId);
|
||||
|
||||
return OsmNode(
|
||||
id: nodeId,
|
||||
coord: LatLng(element['lat'], element['lon']),
|
||||
tags: Map<String, String>.from(element['tags'] ?? {}),
|
||||
isConstrained: isConstrained,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
final constrainedCount = nodes.where((n) => n.isConstrained).length;
|
||||
if (constrainedCount > 0) {
|
||||
debugPrint('[fetchOverpassNodes] Found $constrainedCount constrained nodes out of ${nodes.length} total');
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/// Clean up pending uploads that now appear in Overpass results
|
||||
void _cleanupCompletedUploads(List<OsmNode> overpassNodes) {
|
||||
try {
|
||||
|
||||
@@ -3,6 +3,8 @@ import 'dart:io';
|
||||
import 'dart:async';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:deflockapp/dev_config.dart';
|
||||
import '../network_status.dart';
|
||||
|
||||
@@ -18,6 +20,77 @@ void clearRemoteTileQueue() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear only tile requests that are no longer visible in the given bounds
|
||||
void clearRemoteTileQueueSelective(LatLngBounds currentBounds) {
|
||||
final clearedCount = _tileFetchSemaphore.clearStaleRequests((z, x, y) {
|
||||
// Return true if tile should be cleared (i.e., is NOT visible)
|
||||
return !_isTileVisible(z, x, y, currentBounds);
|
||||
});
|
||||
|
||||
if (clearedCount > 0) {
|
||||
debugPrint('[RemoteTiles] Selectively cleared $clearedCount non-visible tile requests');
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate retry delay using configurable backoff strategy.
|
||||
/// Uses: initialDelay * (multiplier ^ (attempt - 1)) + randomJitter, capped at maxDelay
|
||||
int _calculateRetryDelay(int attempt, Random random) {
|
||||
// Calculate exponential backoff: initialDelay * (multiplier ^ (attempt - 1))
|
||||
final baseDelay = (kTileFetchInitialDelayMs *
|
||||
pow(kTileFetchBackoffMultiplier, attempt - 1)).round();
|
||||
|
||||
// Add random jitter to avoid thundering herd
|
||||
final jitter = random.nextInt(kTileFetchRandomJitterMs + 1);
|
||||
|
||||
// Apply max delay cap
|
||||
return (baseDelay + jitter).clamp(0, kTileFetchMaxDelayMs);
|
||||
}
|
||||
|
||||
/// Convert tile coordinates to lat/lng bounds for spatial filtering
|
||||
class _TileBounds {
|
||||
final double north, south, east, west;
|
||||
_TileBounds({required this.north, required this.south, required this.east, required this.west});
|
||||
}
|
||||
|
||||
/// Calculate the lat/lng bounds for a given tile
|
||||
_TileBounds _tileToBounds(int z, int x, int y) {
|
||||
final n = pow(2, z);
|
||||
final lon1 = (x / n) * 360.0 - 180.0;
|
||||
final lon2 = ((x + 1) / n) * 360.0 - 180.0;
|
||||
final lat1 = _yToLatitude(y, z);
|
||||
final lat2 = _yToLatitude(y + 1, z);
|
||||
|
||||
return _TileBounds(
|
||||
north: max(lat1, lat2),
|
||||
south: min(lat1, lat2),
|
||||
east: max(lon1, lon2),
|
||||
west: min(lon1, lon2),
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert tile Y coordinate to latitude
|
||||
double _yToLatitude(int y, int z) {
|
||||
final n = pow(2, z);
|
||||
final latRad = atan(_sinh(pi * (1 - 2 * y / n)));
|
||||
return latRad * 180.0 / pi;
|
||||
}
|
||||
|
||||
/// Hyperbolic sine function: sinh(x) = (e^x - e^(-x)) / 2
|
||||
double _sinh(double x) {
|
||||
return (exp(x) - exp(-x)) / 2;
|
||||
}
|
||||
|
||||
/// Check if a tile intersects with the current view bounds
|
||||
bool _isTileVisible(int z, int x, int y, LatLngBounds viewBounds) {
|
||||
final tileBounds = _tileToBounds(z, x, y);
|
||||
|
||||
// Check if tile bounds intersect with view bounds
|
||||
return !(tileBounds.east < viewBounds.west ||
|
||||
tileBounds.west > viewBounds.east ||
|
||||
tileBounds.north < viewBounds.south ||
|
||||
tileBounds.south > viewBounds.north);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Fetches a tile from any remote provider, with in-memory retries/backoff, and global concurrency limit.
|
||||
@@ -31,16 +104,10 @@ Future<List<int>> fetchRemoteTile({
|
||||
const int maxAttempts = kTileFetchMaxAttempts;
|
||||
int attempt = 0;
|
||||
final random = Random();
|
||||
final delays = [
|
||||
kTileFetchInitialDelayMs + random.nextInt(kTileFetchJitter1Ms),
|
||||
kTileFetchSecondDelayMs + random.nextInt(kTileFetchJitter2Ms),
|
||||
kTileFetchThirdDelayMs + random.nextInt(kTileFetchJitter3Ms),
|
||||
];
|
||||
|
||||
final hostInfo = Uri.parse(url).host; // For logging
|
||||
|
||||
while (true) {
|
||||
await _tileFetchSemaphore.acquire();
|
||||
await _tileFetchSemaphore.acquire(z: z, x: x, y: y);
|
||||
try {
|
||||
// Only log on first attempt or errors
|
||||
if (attempt == 1) {
|
||||
@@ -71,7 +138,7 @@ Future<List<int>> fetchRemoteTile({
|
||||
rethrow;
|
||||
}
|
||||
|
||||
final delay = delays[attempt - 1].clamp(0, 60000);
|
||||
final delay = _calculateRetryDelay(attempt, random);
|
||||
if (attempt == 1) {
|
||||
debugPrint("[fetchRemoteTile] Attempt $attempt for $z/$x/$y from $hostInfo failed: $e. Retrying in ${delay}ms.");
|
||||
}
|
||||
@@ -97,28 +164,42 @@ Future<List<int>> fetchOSMTile({
|
||||
);
|
||||
}
|
||||
|
||||
/// Simple counting semaphore, suitable for single-thread Flutter concurrency
|
||||
/// Enhanced tile request entry that tracks coordinates for spatial filtering
|
||||
class _TileRequest {
|
||||
final int z, x, y;
|
||||
final VoidCallback callback;
|
||||
|
||||
_TileRequest({required this.z, required this.x, required this.y, required this.callback});
|
||||
}
|
||||
|
||||
/// Spatially-aware counting semaphore for tile requests
|
||||
class _SimpleSemaphore {
|
||||
final int _max;
|
||||
int _current = 0;
|
||||
final List<VoidCallback> _queue = [];
|
||||
final List<_TileRequest> _queue = [];
|
||||
_SimpleSemaphore(this._max);
|
||||
|
||||
Future<void> acquire() async {
|
||||
Future<void> acquire({int? z, int? x, int? y}) async {
|
||||
if (_current < _max) {
|
||||
_current++;
|
||||
return;
|
||||
} else {
|
||||
final c = Completer<void>();
|
||||
_queue.add(() => c.complete());
|
||||
final request = _TileRequest(
|
||||
z: z ?? -1,
|
||||
x: x ?? -1,
|
||||
y: y ?? -1,
|
||||
callback: () => c.complete(),
|
||||
);
|
||||
_queue.add(request);
|
||||
await c.future;
|
||||
}
|
||||
}
|
||||
|
||||
void release() {
|
||||
if (_queue.isNotEmpty) {
|
||||
final callback = _queue.removeAt(0);
|
||||
callback();
|
||||
final request = _queue.removeAt(0);
|
||||
request.callback();
|
||||
} else {
|
||||
_current--;
|
||||
}
|
||||
@@ -130,4 +211,17 @@ class _SimpleSemaphore {
|
||||
_queue.clear();
|
||||
return clearedCount;
|
||||
}
|
||||
|
||||
/// Clear only tiles that don't pass the visibility filter
|
||||
int clearStaleRequests(bool Function(int z, int x, int y) isStale) {
|
||||
final initialCount = _queue.length;
|
||||
_queue.removeWhere((request) => isStale(request.z, request.x, request.y));
|
||||
final clearedCount = initialCount - _queue.length;
|
||||
|
||||
if (clearedCount > 0) {
|
||||
debugPrint('[SimpleSemaphore] Cleared $clearedCount stale tile requests, kept ${_queue.length}');
|
||||
}
|
||||
|
||||
return clearedCount;
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,9 @@ import 'dart:async';
|
||||
import '../app_state.dart';
|
||||
|
||||
enum NetworkIssueType { osmTiles, overpassApi, both }
|
||||
enum NetworkStatusType { waiting, issues, timedOut, noData, ready, success }
|
||||
enum NetworkStatusType { waiting, issues, timedOut, noData, ready, success, nodeLimitReached }
|
||||
|
||||
|
||||
/// Simple loading state for dual-source async operations (brutalist approach)
|
||||
enum LoadingState { ready, waiting, success, timeout }
|
||||
|
||||
class NetworkStatus extends ChangeNotifier {
|
||||
static final NetworkStatus instance = NetworkStatus._();
|
||||
@@ -25,13 +24,8 @@ class NetworkStatus extends ChangeNotifier {
|
||||
Timer? _waitingTimer;
|
||||
Timer? _noDataResetTimer;
|
||||
Timer? _successResetTimer;
|
||||
|
||||
// New dual-source loading state (brutalist approach)
|
||||
LoadingState _tileLoadingState = LoadingState.ready;
|
||||
LoadingState _nodeLoadingState = LoadingState.ready;
|
||||
Timer? _tileTimeoutTimer;
|
||||
Timer? _nodeTimeoutTimer;
|
||||
Timer? _successDisplayTimer;
|
||||
bool _nodeLimitReached = false;
|
||||
Timer? _nodeLimitResetTimer;
|
||||
|
||||
// Getters
|
||||
bool get hasAnyIssues => _osmTilesHaveIssues || _overpassHasIssues;
|
||||
@@ -41,28 +35,16 @@ class NetworkStatus extends ChangeNotifier {
|
||||
bool get isTimedOut => _isTimedOut;
|
||||
bool get hasNoData => _hasNoData;
|
||||
bool get hasSuccess => _hasSuccess;
|
||||
|
||||
// New dual-source getters (brutalist approach)
|
||||
LoadingState get tileLoadingState => _tileLoadingState;
|
||||
LoadingState get nodeLoadingState => _nodeLoadingState;
|
||||
|
||||
/// Derive overall loading status from dual sources
|
||||
bool get isDualSourceLoading => _tileLoadingState == LoadingState.waiting || _nodeLoadingState == LoadingState.waiting;
|
||||
bool get isDualSourceTimeout => _tileLoadingState == LoadingState.timeout || _nodeLoadingState == LoadingState.timeout;
|
||||
bool get isDualSourceSuccess => _tileLoadingState == LoadingState.success && _nodeLoadingState == LoadingState.success;
|
||||
bool get nodeLimitReached => _nodeLimitReached;
|
||||
|
||||
NetworkStatusType get currentStatus {
|
||||
// Check new dual-source states first
|
||||
if (isDualSourceTimeout) return NetworkStatusType.timedOut;
|
||||
if (isDualSourceLoading) return NetworkStatusType.waiting;
|
||||
if (isDualSourceSuccess) return NetworkStatusType.success;
|
||||
|
||||
// Fall back to legacy states for compatibility
|
||||
// Simple single-path status logic
|
||||
if (hasAnyIssues) return NetworkStatusType.issues;
|
||||
if (_isWaitingForData) return NetworkStatusType.waiting;
|
||||
if (_isTimedOut) return NetworkStatusType.timedOut;
|
||||
if (_hasNoData) return NetworkStatusType.noData;
|
||||
if (_hasSuccess) return NetworkStatusType.success;
|
||||
if (_nodeLimitReached) return NetworkStatusType.nodeLimitReached;
|
||||
return NetworkStatusType.ready;
|
||||
}
|
||||
|
||||
@@ -194,18 +176,72 @@ class NetworkStatus extends ChangeNotifier {
|
||||
|
||||
/// Clear waiting/timeout/no-data status (legacy method for compatibility)
|
||||
void clearWaiting() {
|
||||
if (_isWaitingForData || _isTimedOut || _hasNoData || _hasSuccess) {
|
||||
if (_isWaitingForData || _isTimedOut || _hasNoData || _hasSuccess || _nodeLimitReached) {
|
||||
_isWaitingForData = false;
|
||||
_isTimedOut = false;
|
||||
_hasNoData = false;
|
||||
_hasSuccess = false;
|
||||
_nodeLimitReached = false;
|
||||
_recentOfflineMisses = 0;
|
||||
_waitingTimer?.cancel();
|
||||
_noDataResetTimer?.cancel();
|
||||
_successResetTimer?.cancel();
|
||||
_nodeLimitResetTimer?.cancel();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Set timeout error state
|
||||
void setTimeoutError() {
|
||||
_isWaitingForData = false;
|
||||
_isTimedOut = true;
|
||||
_hasNoData = false;
|
||||
_hasSuccess = false;
|
||||
_waitingTimer?.cancel();
|
||||
_noDataResetTimer?.cancel();
|
||||
_successResetTimer?.cancel();
|
||||
notifyListeners();
|
||||
debugPrint('[NetworkStatus] Request timed out');
|
||||
|
||||
// Auto-clear timeout after 5 seconds
|
||||
Timer(const Duration(seconds: 5), () {
|
||||
if (_isTimedOut) {
|
||||
_isTimedOut = false;
|
||||
notifyListeners();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Set network error state (rate limits, connection issues, etc.)
|
||||
void setNetworkError() {
|
||||
_isWaitingForData = false;
|
||||
_isTimedOut = false;
|
||||
_hasNoData = false;
|
||||
_hasSuccess = false;
|
||||
_waitingTimer?.cancel();
|
||||
_noDataResetTimer?.cancel();
|
||||
_successResetTimer?.cancel();
|
||||
|
||||
// Use existing issue reporting system
|
||||
reportOverpassIssue();
|
||||
debugPrint('[NetworkStatus] Network error occurred');
|
||||
}
|
||||
|
||||
/// Show notification that node display limit was reached
|
||||
void reportNodeLimitReached(int totalNodes, int maxNodes) {
|
||||
_nodeLimitReached = true;
|
||||
notifyListeners();
|
||||
debugPrint('[NetworkStatus] Node display limit reached: $totalNodes found, showing $maxNodes');
|
||||
|
||||
// Auto-clear after 8 seconds
|
||||
_nodeLimitResetTimer?.cancel();
|
||||
_nodeLimitResetTimer = Timer(const Duration(seconds: 8), () {
|
||||
if (_nodeLimitReached) {
|
||||
_nodeLimitReached = false;
|
||||
notifyListeners();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -231,81 +267,7 @@ class NetworkStatus extends ChangeNotifier {
|
||||
});
|
||||
}
|
||||
|
||||
// New dual-source loading methods (brutalist approach)
|
||||
|
||||
/// Start waiting for both tiles and nodes
|
||||
void setDualSourceWaiting() {
|
||||
_tileLoadingState = LoadingState.waiting;
|
||||
_nodeLoadingState = LoadingState.waiting;
|
||||
|
||||
// Set timeout timers for both
|
||||
_tileTimeoutTimer?.cancel();
|
||||
_tileTimeoutTimer = Timer(const Duration(seconds: 8), () {
|
||||
if (_tileLoadingState == LoadingState.waiting) {
|
||||
_tileLoadingState = LoadingState.timeout;
|
||||
debugPrint('[NetworkStatus] Tile loading timed out');
|
||||
notifyListeners();
|
||||
}
|
||||
});
|
||||
|
||||
_nodeTimeoutTimer?.cancel();
|
||||
_nodeTimeoutTimer = Timer(const Duration(seconds: 8), () {
|
||||
if (_nodeLoadingState == LoadingState.waiting) {
|
||||
_nodeLoadingState = LoadingState.timeout;
|
||||
debugPrint('[NetworkStatus] Node loading timed out');
|
||||
notifyListeners();
|
||||
}
|
||||
});
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Report tile loading completion
|
||||
void reportTileComplete() {
|
||||
if (_tileLoadingState == LoadingState.waiting) {
|
||||
_tileLoadingState = LoadingState.success;
|
||||
_tileTimeoutTimer?.cancel();
|
||||
_checkDualSourceComplete();
|
||||
}
|
||||
}
|
||||
|
||||
/// Report node loading completion
|
||||
void reportNodeComplete() {
|
||||
if (_nodeLoadingState == LoadingState.waiting) {
|
||||
_nodeLoadingState = LoadingState.success;
|
||||
_nodeTimeoutTimer?.cancel();
|
||||
_checkDualSourceComplete();
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if both sources are complete and show success briefly
|
||||
void _checkDualSourceComplete() {
|
||||
if (_tileLoadingState == LoadingState.success && _nodeLoadingState == LoadingState.success) {
|
||||
debugPrint('[NetworkStatus] Both tiles and nodes loaded successfully');
|
||||
notifyListeners();
|
||||
|
||||
// Auto-reset to ready after showing success briefly
|
||||
_successDisplayTimer?.cancel();
|
||||
_successDisplayTimer = Timer(const Duration(seconds: 2), () {
|
||||
_tileLoadingState = LoadingState.ready;
|
||||
_nodeLoadingState = LoadingState.ready;
|
||||
notifyListeners();
|
||||
});
|
||||
} else {
|
||||
// Just notify if one completed but not both yet
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset dual-source state to ready
|
||||
void resetDualSourceState() {
|
||||
_tileLoadingState = LoadingState.ready;
|
||||
_nodeLoadingState = LoadingState.ready;
|
||||
_tileTimeoutTimer?.cancel();
|
||||
_nodeTimeoutTimer?.cancel();
|
||||
_successDisplayTimer?.cancel();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -313,9 +275,8 @@ class NetworkStatus extends ChangeNotifier {
|
||||
_overpassRecoveryTimer?.cancel();
|
||||
_waitingTimer?.cancel();
|
||||
_noDataResetTimer?.cancel();
|
||||
_tileTimeoutTimer?.cancel();
|
||||
_nodeTimeoutTimer?.cancel();
|
||||
_successDisplayTimer?.cancel();
|
||||
_successResetTimer?.cancel();
|
||||
_nodeLimitResetTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ class NodeCache {
|
||||
final Map<int, OsmNode> _nodes = {};
|
||||
|
||||
/// Add or update a batch of nodes in the cache.
|
||||
/// TODO: Consider moving to compute() if cache operations cause ANR
|
||||
void addOrUpdate(List<OsmNode> nodes) {
|
||||
for (var node in nodes) {
|
||||
final existing = _nodes[node.id];
|
||||
@@ -26,6 +27,7 @@ class NodeCache {
|
||||
id: node.id,
|
||||
coord: node.coord,
|
||||
tags: mergedTags,
|
||||
isConstrained: node.isConstrained, // Preserve constraint information
|
||||
);
|
||||
} else {
|
||||
_nodes[node.id] = node;
|
||||
@@ -57,6 +59,7 @@ class NodeCache {
|
||||
id: node.id,
|
||||
coord: node.coord,
|
||||
tags: cleanTags,
|
||||
isConstrained: node.isConstrained, // Preserve constraint information
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
23
lib/services/overpass_node_limit_exception.dart
Normal file
23
lib/services/overpass_node_limit_exception.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
/// Exception thrown when Overpass API returns an error indicating too many nodes were requested.
|
||||
/// This typically happens when querying large areas that would return more than 50k nodes.
|
||||
class OverpassNodeLimitException implements Exception {
|
||||
final String message;
|
||||
final String? serverResponse;
|
||||
|
||||
OverpassNodeLimitException(this.message, {this.serverResponse});
|
||||
|
||||
@override
|
||||
String toString() => 'OverpassNodeLimitException: $message';
|
||||
}
|
||||
|
||||
/// Exception thrown when Overpass API rate limits the request.
|
||||
/// Should trigger longer backoff delays, not area splitting.
|
||||
class OverpassRateLimitException implements Exception {
|
||||
final String message;
|
||||
final String? serverResponse;
|
||||
|
||||
OverpassRateLimitException(this.message, {this.serverResponse});
|
||||
|
||||
@override
|
||||
String toString() => 'OverpassRateLimitException: $message';
|
||||
}
|
||||
197
lib/services/prefetch_area_service.dart
Normal file
197
lib/services/prefetch_area_service.dart
Normal file
@@ -0,0 +1,197 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/osm_node.dart';
|
||||
import '../app_state.dart';
|
||||
import '../dev_config.dart';
|
||||
import 'map_data_submodules/nodes_from_overpass.dart';
|
||||
import 'node_cache.dart';
|
||||
import 'network_status.dart';
|
||||
import '../widgets/camera_provider_with_cache.dart';
|
||||
|
||||
/// Manages pre-fetching larger areas to reduce Overpass API calls.
|
||||
/// Uses zoom level 10 areas and automatically splits if hitting node limits.
|
||||
class PrefetchAreaService {
|
||||
static final PrefetchAreaService _instance = PrefetchAreaService._();
|
||||
factory PrefetchAreaService() => _instance;
|
||||
PrefetchAreaService._();
|
||||
|
||||
// Current pre-fetched area and associated data
|
||||
LatLngBounds? _preFetchedArea;
|
||||
List<NodeProfile>? _preFetchedProfiles;
|
||||
UploadMode? _preFetchedUploadMode;
|
||||
DateTime? _lastFetchTime;
|
||||
bool _preFetchInProgress = false;
|
||||
|
||||
// Debounce timer to avoid rapid requests while user is panning
|
||||
Timer? _debounceTimer;
|
||||
|
||||
// Configuration from dev_config
|
||||
static const double _areaExpansionMultiplier = kPreFetchAreaExpansionMultiplier;
|
||||
static const int _preFetchZoomLevel = kPreFetchZoomLevel;
|
||||
|
||||
/// Check if the given bounds are fully within the current pre-fetched area.
|
||||
bool isWithinPreFetchedArea(LatLngBounds bounds, List<NodeProfile> profiles, UploadMode uploadMode) {
|
||||
if (_preFetchedArea == null || _preFetchedProfiles == null || _preFetchedUploadMode == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if profiles and upload mode match
|
||||
if (_preFetchedUploadMode != uploadMode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_profileListsEqual(_preFetchedProfiles!, profiles)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if bounds are fully contained within pre-fetched area
|
||||
return bounds.north <= _preFetchedArea!.north &&
|
||||
bounds.south >= _preFetchedArea!.south &&
|
||||
bounds.east <= _preFetchedArea!.east &&
|
||||
bounds.west >= _preFetchedArea!.west;
|
||||
}
|
||||
|
||||
/// Check if cached data is stale (older than configured refresh interval).
|
||||
bool isDataStale() {
|
||||
if (_lastFetchTime == null) return true;
|
||||
return DateTime.now().difference(_lastFetchTime!).inSeconds > kDataRefreshIntervalSeconds;
|
||||
}
|
||||
|
||||
/// Request pre-fetch for the given view bounds if not already covered or if data is stale.
|
||||
/// Uses debouncing to avoid rapid requests while user is panning.
|
||||
void requestPreFetchIfNeeded({
|
||||
required LatLngBounds viewBounds,
|
||||
required List<NodeProfile> profiles,
|
||||
required UploadMode uploadMode,
|
||||
}) {
|
||||
// Skip if already in progress
|
||||
if (_preFetchInProgress) {
|
||||
debugPrint('[PrefetchAreaService] Pre-fetch already in progress, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check both spatial and temporal conditions
|
||||
final isWithinArea = isWithinPreFetchedArea(viewBounds, profiles, uploadMode);
|
||||
final isStale = isDataStale();
|
||||
|
||||
if (isWithinArea && !isStale) {
|
||||
debugPrint('[PrefetchAreaService] Current view within fresh pre-fetched area, no fetch needed');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isStale) {
|
||||
debugPrint('[PrefetchAreaService] Data is stale (>${kDataRefreshIntervalSeconds}s), refreshing');
|
||||
} else {
|
||||
debugPrint('[PrefetchAreaService] Current view outside pre-fetched area, fetching larger area');
|
||||
}
|
||||
|
||||
// Cancel any pending debounced request
|
||||
_debounceTimer?.cancel();
|
||||
|
||||
// Debounce to avoid rapid requests while user is still moving
|
||||
_debounceTimer = Timer(const Duration(milliseconds: 800), () {
|
||||
_startPreFetch(
|
||||
viewBounds: viewBounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Start the actual pre-fetch operation.
|
||||
Future<void> _startPreFetch({
|
||||
required LatLngBounds viewBounds,
|
||||
required List<NodeProfile> profiles,
|
||||
required UploadMode uploadMode,
|
||||
}) async {
|
||||
if (_preFetchInProgress) return;
|
||||
|
||||
_preFetchInProgress = true;
|
||||
|
||||
try {
|
||||
// Calculate expanded area for pre-fetching
|
||||
final preFetchArea = _expandBounds(viewBounds, _areaExpansionMultiplier);
|
||||
|
||||
debugPrint('[PrefetchAreaService] Starting pre-fetch for area: ${preFetchArea.south},${preFetchArea.west} to ${preFetchArea.north},${preFetchArea.east}');
|
||||
|
||||
// Fetch nodes for the expanded area (unlimited - let splitting handle 50k limit)
|
||||
final nodes = await fetchOverpassNodes(
|
||||
bounds: preFetchArea,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: 0, // Unlimited - our splitting system handles the 50k limit gracefully
|
||||
);
|
||||
|
||||
debugPrint('[PrefetchAreaService] Pre-fetch completed: ${nodes.length} nodes retrieved');
|
||||
|
||||
// Update cache with new nodes (fresh data overwrites stale, but preserves underscore tags)
|
||||
if (nodes.isNotEmpty) {
|
||||
NodeCache.instance.addOrUpdate(nodes);
|
||||
}
|
||||
|
||||
// Store the pre-fetched area info and timestamp
|
||||
_preFetchedArea = preFetchArea;
|
||||
_preFetchedProfiles = List.from(profiles);
|
||||
_preFetchedUploadMode = uploadMode;
|
||||
_lastFetchTime = DateTime.now();
|
||||
|
||||
// Report completion to network status (only if user was waiting)
|
||||
NetworkStatus.instance.setSuccess();
|
||||
|
||||
// Notify UI that cache has been updated with fresh data
|
||||
CameraProviderWithCache.instance.refreshDisplay();
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[PrefetchAreaService] Pre-fetch failed: $e');
|
||||
// Report failure to network status (only if user was waiting)
|
||||
if (e.toString().contains('timeout') || e.toString().contains('timed out')) {
|
||||
NetworkStatus.instance.setTimeoutError();
|
||||
} else {
|
||||
NetworkStatus.instance.setNetworkError();
|
||||
}
|
||||
// Don't update pre-fetched area info on failure
|
||||
} finally {
|
||||
_preFetchInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Expand bounds by the given multiplier, maintaining center point.
|
||||
LatLngBounds _expandBounds(LatLngBounds bounds, double multiplier) {
|
||||
final centerLat = (bounds.north + bounds.south) / 2;
|
||||
final centerLng = (bounds.east + bounds.west) / 2;
|
||||
|
||||
final latSpan = (bounds.north - bounds.south) * multiplier / 2;
|
||||
final lngSpan = (bounds.east - bounds.west) * multiplier / 2;
|
||||
|
||||
return LatLngBounds(
|
||||
LatLng(centerLat - latSpan, centerLng - lngSpan), // Southwest
|
||||
LatLng(centerLat + latSpan, centerLng + lngSpan), // Northeast
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if two profile lists are equal by comparing IDs.
|
||||
bool _profileListsEqual(List<NodeProfile> list1, List<NodeProfile> list2) {
|
||||
if (list1.length != list2.length) return false;
|
||||
final ids1 = list1.map((p) => p.id).toSet();
|
||||
final ids2 = list2.map((p) => p.id).toSet();
|
||||
return ids1.length == ids2.length && ids1.containsAll(ids2);
|
||||
}
|
||||
|
||||
/// Clear the pre-fetched area (e.g., when profiles change significantly).
|
||||
void clearPreFetchedArea() {
|
||||
_preFetchedArea = null;
|
||||
_preFetchedProfiles = null;
|
||||
_preFetchedUploadMode = null;
|
||||
_lastFetchTime = null;
|
||||
debugPrint('[PrefetchAreaService] Pre-fetched area cleared');
|
||||
}
|
||||
|
||||
/// Dispose of resources.
|
||||
void dispose() {
|
||||
_debounceTimer?.cancel();
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,6 @@ class ProfileService {
|
||||
// MUST convert to List before jsonEncode; the previous MappedIterable
|
||||
// caused "Converting object to an encodable object failed".
|
||||
final encodable = profiles
|
||||
.where((p) => !p.builtin)
|
||||
.map((p) => p.toJson())
|
||||
.toList(); // <- crucial
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import 'map_data_provider.dart';
|
||||
@@ -84,7 +86,10 @@ class SimpleTileHttpClient extends http.BaseClient {
|
||||
// Decrement pending counter and report completion when all done
|
||||
_pendingTileRequests--;
|
||||
if (_pendingTileRequests == 0) {
|
||||
NetworkStatus.instance.reportTileComplete();
|
||||
// Only report tile completion if we were in loading state (user-initiated)
|
||||
if (NetworkStatus.instance.currentStatus == NetworkStatusType.waiting) {
|
||||
NetworkStatus.instance.setSuccess();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,6 +98,11 @@ class SimpleTileHttpClient extends http.BaseClient {
|
||||
void clearTileQueue() {
|
||||
_mapDataProvider.clearTileQueue();
|
||||
}
|
||||
|
||||
/// Clear only tile requests that are no longer visible in the current bounds
|
||||
void clearStaleRequests(LatLngBounds currentBounds) {
|
||||
_mapDataProvider.clearTileQueueSelective(currentBounds);
|
||||
}
|
||||
|
||||
/// Format date for HTTP headers (RFC 7231)
|
||||
String _httpDateFormat(DateTime date) {
|
||||
|
||||
227
lib/services/suspected_location_cache.dart
Normal file
227
lib/services/suspected_location_cache.dart
Normal file
@@ -0,0 +1,227 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../models/suspected_location.dart';
|
||||
import 'suspected_location_service.dart';
|
||||
|
||||
/// Lightweight entry with pre-calculated centroid for efficient bounds checking
|
||||
class SuspectedLocationEntry {
|
||||
final Map<String, dynamic> rawData;
|
||||
final LatLng centroid;
|
||||
|
||||
SuspectedLocationEntry({required this.rawData, required this.centroid});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'rawData': rawData,
|
||||
'centroid': [centroid.latitude, centroid.longitude],
|
||||
};
|
||||
|
||||
factory SuspectedLocationEntry.fromJson(Map<String, dynamic> json) {
|
||||
final centroidList = json['centroid'] as List;
|
||||
return SuspectedLocationEntry(
|
||||
rawData: Map<String, dynamic>.from(json['rawData']),
|
||||
centroid: LatLng(
|
||||
(centroidList[0] as num).toDouble(),
|
||||
(centroidList[1] as num).toDouble(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SuspectedLocationCache extends ChangeNotifier {
|
||||
static final SuspectedLocationCache _instance = SuspectedLocationCache._();
|
||||
factory SuspectedLocationCache() => _instance;
|
||||
SuspectedLocationCache._();
|
||||
|
||||
static const String _prefsKeyProcessedData = 'suspected_locations_processed_data';
|
||||
static const String _prefsKeyLastFetch = 'suspected_locations_last_fetch';
|
||||
|
||||
List<SuspectedLocationEntry> _processedEntries = [];
|
||||
DateTime? _lastFetchTime;
|
||||
final Map<String, List<SuspectedLocation>> _boundsCache = {};
|
||||
|
||||
/// Get suspected locations within specific bounds (cached)
|
||||
List<SuspectedLocation> getLocationsForBounds(LatLngBounds bounds) {
|
||||
if (!SuspectedLocationService().isEnabled) {
|
||||
debugPrint('[SuspectedLocationCache] Service not enabled');
|
||||
return [];
|
||||
}
|
||||
|
||||
final boundsKey = '${bounds.north.toStringAsFixed(4)},${bounds.south.toStringAsFixed(4)},${bounds.east.toStringAsFixed(4)},${bounds.west.toStringAsFixed(4)}';
|
||||
|
||||
// debugPrint('[SuspectedLocationCache] Getting locations for bounds: $boundsKey, processed entries count: ${_processedEntries.length}');
|
||||
|
||||
// Check cache first
|
||||
if (_boundsCache.containsKey(boundsKey)) {
|
||||
debugPrint('[SuspectedLocationCache] Using cached result: ${_boundsCache[boundsKey]!.length} locations');
|
||||
return _boundsCache[boundsKey]!;
|
||||
}
|
||||
|
||||
// Filter processed entries for this bounds (very fast since centroids are pre-calculated)
|
||||
final locations = <SuspectedLocation>[];
|
||||
int inBoundsCount = 0;
|
||||
|
||||
for (final entry in _processedEntries) {
|
||||
// Quick bounds check using pre-calculated centroid
|
||||
final lat = entry.centroid.latitude;
|
||||
final lng = entry.centroid.longitude;
|
||||
|
||||
if (lat <= bounds.north && lat >= bounds.south &&
|
||||
lng <= bounds.east && lng >= bounds.west) {
|
||||
try {
|
||||
// Only create SuspectedLocation object if it's in bounds
|
||||
final location = SuspectedLocation.fromCsvRow(entry.rawData);
|
||||
locations.add(location);
|
||||
inBoundsCount++;
|
||||
} catch (e) {
|
||||
// Skip invalid entries
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// debugPrint('[SuspectedLocationCache] Checked ${_processedEntries.length} entries, $inBoundsCount in bounds, result: ${locations.length} locations');
|
||||
|
||||
// Cache the result
|
||||
_boundsCache[boundsKey] = locations;
|
||||
|
||||
// Limit cache size to prevent memory issues
|
||||
if (_boundsCache.length > 100) {
|
||||
final oldestKey = _boundsCache.keys.first;
|
||||
_boundsCache.remove(oldestKey);
|
||||
}
|
||||
|
||||
return locations;
|
||||
}
|
||||
|
||||
/// Load processed data from storage
|
||||
Future<void> loadFromStorage() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// Load last fetch time
|
||||
final lastFetchMs = prefs.getInt(_prefsKeyLastFetch);
|
||||
if (lastFetchMs != null) {
|
||||
_lastFetchTime = DateTime.fromMillisecondsSinceEpoch(lastFetchMs);
|
||||
}
|
||||
|
||||
// Load processed data
|
||||
final processedDataString = prefs.getString(_prefsKeyProcessedData);
|
||||
if (processedDataString != null) {
|
||||
final List<dynamic> processedDataList = jsonDecode(processedDataString);
|
||||
_processedEntries = processedDataList
|
||||
.map((json) => SuspectedLocationEntry.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
debugPrint('[SuspectedLocationCache] Loaded ${_processedEntries.length} processed entries from storage');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[SuspectedLocationCache] Error loading from storage: $e');
|
||||
_processedEntries.clear();
|
||||
_lastFetchTime = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Process raw CSV data and save to storage (calculates centroids once)
|
||||
Future<void> processAndSave(
|
||||
List<Map<String, dynamic>> rawData,
|
||||
DateTime fetchTime,
|
||||
) async {
|
||||
try {
|
||||
debugPrint('[SuspectedLocationCache] Processing ${rawData.length} raw entries...');
|
||||
|
||||
final processedEntries = <SuspectedLocationEntry>[];
|
||||
int validCount = 0;
|
||||
int errorCount = 0;
|
||||
int zeroCoordCount = 0;
|
||||
|
||||
for (int i = 0; i < rawData.length; i++) {
|
||||
final rowData = rawData[i];
|
||||
|
||||
// Log progress every 1000 entries for debugging
|
||||
if (i % 1000 == 0) {
|
||||
debugPrint('[SuspectedLocationCache] Processed ${i + 1}/${rawData.length} entries...');
|
||||
}
|
||||
|
||||
try {
|
||||
// Create a temporary SuspectedLocation to extract the centroid
|
||||
final tempLocation = SuspectedLocation.fromCsvRow(rowData);
|
||||
|
||||
// Only save if we have a valid centroid (not at 0,0)
|
||||
if (tempLocation.centroid.latitude != 0 || tempLocation.centroid.longitude != 0) {
|
||||
processedEntries.add(SuspectedLocationEntry(
|
||||
rawData: rowData,
|
||||
centroid: tempLocation.centroid,
|
||||
));
|
||||
validCount++;
|
||||
} else {
|
||||
zeroCoordCount++;
|
||||
}
|
||||
} catch (e) {
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('[SuspectedLocationCache] Processing complete - Valid: $validCount, Zero coords: $zeroCoordCount, Errors: $errorCount');
|
||||
|
||||
_processedEntries = processedEntries;
|
||||
_lastFetchTime = fetchTime;
|
||||
|
||||
// Clear bounds cache since data changed
|
||||
_boundsCache.clear();
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// Save processed data
|
||||
final processedDataString = jsonEncode(processedEntries.map((e) => e.toJson()).toList());
|
||||
await prefs.setString(_prefsKeyProcessedData, processedDataString);
|
||||
|
||||
// Save last fetch time
|
||||
await prefs.setInt(_prefsKeyLastFetch, fetchTime.millisecondsSinceEpoch);
|
||||
|
||||
// Log coordinate ranges for debugging
|
||||
if (processedEntries.isNotEmpty) {
|
||||
double minLat = processedEntries.first.centroid.latitude;
|
||||
double maxLat = minLat;
|
||||
double minLng = processedEntries.first.centroid.longitude;
|
||||
double maxLng = minLng;
|
||||
|
||||
for (final entry in processedEntries) {
|
||||
final lat = entry.centroid.latitude;
|
||||
final lng = entry.centroid.longitude;
|
||||
if (lat < minLat) minLat = lat;
|
||||
if (lat > maxLat) maxLat = lat;
|
||||
if (lng < minLng) minLng = lng;
|
||||
if (lng > maxLng) maxLng = lng;
|
||||
}
|
||||
|
||||
debugPrint('[SuspectedLocationCache] Coordinate ranges - Lat: $minLat to $maxLat, Lng: $minLng to $maxLng');
|
||||
}
|
||||
|
||||
debugPrint('[SuspectedLocationCache] Processed and saved $validCount valid entries (${processedEntries.length} total)');
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('[SuspectedLocationCache] Error processing and saving: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all cached data
|
||||
void clear() {
|
||||
_processedEntries.clear();
|
||||
_boundsCache.clear();
|
||||
_lastFetchTime = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Get last fetch time
|
||||
DateTime? get lastFetchTime => _lastFetchTime;
|
||||
|
||||
/// Get total count of processed entries
|
||||
int get totalCount => _processedEntries.length;
|
||||
|
||||
/// Check if we have data
|
||||
bool get hasData => _processedEntries.isNotEmpty;
|
||||
}
|
||||
215
lib/services/suspected_location_service.dart
Normal file
215
lib/services/suspected_location_service.dart
Normal file
@@ -0,0 +1,215 @@
|
||||
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;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:csv/csv.dart';
|
||||
|
||||
import '../dev_config.dart';
|
||||
import '../models/suspected_location.dart';
|
||||
import 'suspected_location_cache.dart';
|
||||
|
||||
class SuspectedLocationService {
|
||||
static final SuspectedLocationService _instance = SuspectedLocationService._();
|
||||
factory SuspectedLocationService() => _instance;
|
||||
SuspectedLocationService._();
|
||||
|
||||
static const String _prefsKeyEnabled = 'suspected_locations_enabled';
|
||||
static const Duration _maxAge = Duration(days: 7);
|
||||
static const Duration _timeout = Duration(seconds: 30);
|
||||
|
||||
final SuspectedLocationCache _cache = SuspectedLocationCache();
|
||||
bool _isEnabled = false;
|
||||
|
||||
/// Get last fetch time
|
||||
DateTime? get lastFetchTime => _cache.lastFetchTime;
|
||||
|
||||
/// Check if suspected locations are enabled
|
||||
bool get isEnabled => _isEnabled;
|
||||
|
||||
/// Initialize the service - load from storage and check if refresh needed
|
||||
Future<void> init({bool offlineMode = false}) async {
|
||||
await _loadFromStorage();
|
||||
|
||||
// Load cache data
|
||||
await _cache.loadFromStorage();
|
||||
|
||||
// Only auto-fetch if enabled, data is stale or missing, and we are not offline
|
||||
if (_isEnabled && _shouldRefresh() && !offlineMode) {
|
||||
debugPrint('[SuspectedLocationService] Auto-refreshing CSV data on startup (older than $_maxAge or missing)');
|
||||
await _fetchData();
|
||||
} else if (_isEnabled && _shouldRefresh() && offlineMode) {
|
||||
debugPrint('[SuspectedLocationService] Skipping auto-refresh due to offline mode - data is ${_cache.lastFetchTime != null ? 'outdated' : 'missing'}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable or disable suspected locations
|
||||
Future<void> setEnabled(bool enabled) async {
|
||||
_isEnabled = enabled;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_prefsKeyEnabled, enabled);
|
||||
|
||||
// If disabling, clear the cache
|
||||
if (!enabled) {
|
||||
_cache.clear();
|
||||
}
|
||||
// Note: If enabling and no data, the state layer will call fetchDataIfNeeded()
|
||||
}
|
||||
|
||||
/// Check if cache has any data
|
||||
bool get hasData => _cache.hasData;
|
||||
|
||||
/// Get last fetch time
|
||||
DateTime? get lastFetch => _cache.lastFetchTime;
|
||||
|
||||
/// Fetch data if needed (for enabling suspected locations when no data exists)
|
||||
Future<bool> fetchDataIfNeeded() async {
|
||||
if (!_shouldRefresh()) {
|
||||
debugPrint('[SuspectedLocationService] Data is fresh, skipping fetch');
|
||||
return true; // Already have fresh data
|
||||
}
|
||||
return await _fetchData();
|
||||
}
|
||||
|
||||
/// Force refresh the data (for manual refresh button)
|
||||
Future<bool> forceRefresh() async {
|
||||
return await _fetchData();
|
||||
}
|
||||
|
||||
/// Check if data should be refreshed
|
||||
bool _shouldRefresh() {
|
||||
if (!_cache.hasData) return true;
|
||||
if (_cache.lastFetchTime == null) return true;
|
||||
return DateTime.now().difference(_cache.lastFetchTime!) > _maxAge;
|
||||
}
|
||||
|
||||
/// Load settings from shared preferences
|
||||
Future<void> _loadFromStorage() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// Load enabled state
|
||||
_isEnabled = prefs.getBool(_prefsKeyEnabled) ?? false;
|
||||
|
||||
debugPrint('[SuspectedLocationService] Loaded settings - enabled: $_isEnabled');
|
||||
} catch (e) {
|
||||
debugPrint('[SuspectedLocationService] Error loading from storage: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch data from the CSV URL
|
||||
Future<bool> _fetchData() async {
|
||||
try {
|
||||
debugPrint('[SuspectedLocationService] Fetching CSV data from $kSuspectedLocationsCsvUrl');
|
||||
|
||||
final response = await http.get(
|
||||
Uri.parse(kSuspectedLocationsCsvUrl),
|
||||
headers: {
|
||||
'User-Agent': 'DeFlock/1.0 (OSM surveillance mapping app)',
|
||||
},
|
||||
).timeout(_timeout);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
debugPrint('[SuspectedLocationService] HTTP error ${response.statusCode}');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse CSV with proper field separator and quote handling
|
||||
final csvData = await compute(_parseCSV, response.body);
|
||||
debugPrint('[SuspectedLocationService] Parsed ${csvData.length} rows from CSV');
|
||||
|
||||
if (csvData.isEmpty) {
|
||||
debugPrint('[SuspectedLocationService] Empty CSV data');
|
||||
return false;
|
||||
}
|
||||
|
||||
// First row should be headers
|
||||
final headers = csvData.first.map((h) => h.toString().toLowerCase()).toList();
|
||||
debugPrint('[SuspectedLocationService] Headers: $headers');
|
||||
final dataRows = csvData.skip(1);
|
||||
debugPrint('[SuspectedLocationService] Data rows count: ${dataRows.length}');
|
||||
|
||||
// Find required column indices - we only need ticket_no and location
|
||||
final ticketNoIndex = headers.indexOf('ticket_no');
|
||||
final locationIndex = headers.indexOf('location');
|
||||
|
||||
debugPrint('[SuspectedLocationService] Column indices - ticket_no: $ticketNoIndex, location: $locationIndex');
|
||||
|
||||
if (ticketNoIndex == -1 || locationIndex == -1) {
|
||||
debugPrint('[SuspectedLocationService] Required columns not found in CSV. Headers: $headers');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse rows and store all data dynamically
|
||||
final List<Map<String, dynamic>> rawDataList = [];
|
||||
int rowIndex = 0;
|
||||
int validRows = 0;
|
||||
for (final row in dataRows) {
|
||||
rowIndex++;
|
||||
try {
|
||||
final Map<String, dynamic> rowData = {};
|
||||
|
||||
// Store all columns dynamically
|
||||
for (int i = 0; i < headers.length && i < row.length; i++) {
|
||||
final headerName = headers[i];
|
||||
final cellValue = row[i];
|
||||
if (cellValue != null) {
|
||||
rowData[headerName] = cellValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Basic validation - must have ticket_no and location
|
||||
if (rowData['ticket_no']?.toString().isNotEmpty == true &&
|
||||
rowData['location']?.toString().isNotEmpty == true) {
|
||||
rawDataList.add(rowData);
|
||||
validRows++;
|
||||
}
|
||||
|
||||
} catch (e, stackTrace) {
|
||||
// Skip rows that can't be parsed
|
||||
debugPrint('[SuspectedLocationService] Error parsing row $rowIndex: $e');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('[SuspectedLocationService] Parsed $validRows valid rows from ${dataRows.length} total rows');
|
||||
|
||||
final fetchTime = DateTime.now();
|
||||
|
||||
// Process raw data and save (calculates centroids once)
|
||||
await _cache.processAndSave(rawDataList, fetchTime);
|
||||
|
||||
debugPrint('[SuspectedLocationService] Successfully fetched and stored $validRows valid raw entries (${rawDataList.length} total)');
|
||||
return true;
|
||||
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('[SuspectedLocationService] Error fetching data: $e');
|
||||
debugPrint('[SuspectedLocationService] Stack trace: $stackTrace');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get suspected locations within a bounding box
|
||||
List<SuspectedLocation> getLocationsInBounds({
|
||||
required double north,
|
||||
required double south,
|
||||
required double east,
|
||||
required double west,
|
||||
}) {
|
||||
return _cache.getLocationsForBounds(LatLngBounds(
|
||||
LatLng(north, west),
|
||||
LatLng(south, east),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple CSV parser for compute() - must be top-level function
|
||||
List<List<dynamic>> _parseCSV(String csvBody) {
|
||||
return const CsvToListConverter(
|
||||
fieldDelimiter: ',',
|
||||
textDelimiter: '"',
|
||||
eol: '\n',
|
||||
).convert(csvBody);
|
||||
}
|
||||
@@ -17,6 +17,12 @@ class Uploader {
|
||||
try {
|
||||
print('Uploader: Starting upload for node at ${p.coord.latitude}, ${p.coord.longitude}');
|
||||
|
||||
// Safety check: create and modify operations MUST have profiles
|
||||
if ((p.operation == UploadOperation.create || p.operation == UploadOperation.modify) && p.profile == null) {
|
||||
print('Uploader: ERROR - ${p.operation.name} operation attempted without profile data');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 1. open changeset
|
||||
String action;
|
||||
switch (p.operation) {
|
||||
@@ -30,11 +36,13 @@ class Uploader {
|
||||
action = 'Delete';
|
||||
break;
|
||||
}
|
||||
// Generate appropriate comment based on operation type
|
||||
final profileName = p.profile?.name ?? 'surveillance';
|
||||
final csXml = '''
|
||||
<osm>
|
||||
<changeset>
|
||||
<tag k="created_by" v="$kClientName ${VersionService().version}"/>
|
||||
<tag k="comment" v="$action ${p.profile.name} surveillance node"/>
|
||||
<tag k="comment" v="$action $profileName surveillance node"/>
|
||||
</changeset>
|
||||
</osm>''';
|
||||
print('Uploader: Creating changeset...');
|
||||
|
||||
@@ -8,8 +8,14 @@ class OperatorProfileState extends ChangeNotifier {
|
||||
|
||||
List<OperatorProfile> get profiles => List.unmodifiable(_profiles);
|
||||
|
||||
Future<void> init() async {
|
||||
Future<void> init({bool addDefaults = false}) async {
|
||||
_profiles.addAll(await OperatorProfileService().load());
|
||||
|
||||
// Add default operator profiles if this is first launch
|
||||
if (addDefaults) {
|
||||
_profiles.addAll(OperatorProfile.getDefaults());
|
||||
await OperatorProfileService().save(_profiles);
|
||||
}
|
||||
}
|
||||
|
||||
void addOrUpdateProfile(OperatorProfile p) {
|
||||
|
||||
@@ -17,19 +17,16 @@ class ProfileState extends ChangeNotifier {
|
||||
_profiles.where(isEnabled).toList(growable: false);
|
||||
|
||||
// Initialize profiles from built-in and custom sources
|
||||
Future<void> init() async {
|
||||
// Initialize profiles: built-in + custom
|
||||
_profiles.add(NodeProfile.genericAlpr());
|
||||
_profiles.add(NodeProfile.flock());
|
||||
_profiles.add(NodeProfile.motorola());
|
||||
_profiles.add(NodeProfile.genetec());
|
||||
_profiles.add(NodeProfile.leonardo());
|
||||
_profiles.add(NodeProfile.neology());
|
||||
_profiles.add(NodeProfile.genericGunshotDetector());
|
||||
_profiles.add(NodeProfile.shotspotter());
|
||||
_profiles.add(NodeProfile.flockRaven());
|
||||
Future<void> init({bool addDefaults = false}) async {
|
||||
// Load custom profiles from storage
|
||||
_profiles.addAll(await ProfileService().load());
|
||||
|
||||
// Add built-in profiles if this is first launch
|
||||
if (addDefaults) {
|
||||
_profiles.addAll(NodeProfile.getDefaults());
|
||||
await ProfileService().save(_profiles);
|
||||
}
|
||||
|
||||
// Load enabled profile IDs from prefs
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final enabledIds = prefs.getStringList(_enabledPrefsKey);
|
||||
|
||||
@@ -7,27 +7,45 @@ import '../models/osm_node.dart';
|
||||
|
||||
// ------------------ AddNodeSession ------------------
|
||||
class AddNodeSession {
|
||||
AddNodeSession({required this.profile, this.directionDegrees = 0});
|
||||
NodeProfile profile;
|
||||
NodeProfile? profile;
|
||||
OperatorProfile? operatorProfile;
|
||||
double directionDegrees;
|
||||
LatLng? target;
|
||||
List<double> directions; // All directions [90, 180, 270]
|
||||
int currentDirectionIndex; // Which direction we're editing (e.g. 1 = editing the 180°)
|
||||
|
||||
AddNodeSession({
|
||||
this.profile,
|
||||
double initialDirection = 0,
|
||||
this.operatorProfile,
|
||||
this.target,
|
||||
}) : directions = [initialDirection],
|
||||
currentDirectionIndex = 0;
|
||||
|
||||
// Slider always shows the current direction being edited
|
||||
double get directionDegrees => directions[currentDirectionIndex];
|
||||
set directionDegrees(double value) => directions[currentDirectionIndex] = value;
|
||||
}
|
||||
|
||||
// ------------------ EditNodeSession ------------------
|
||||
class EditNodeSession {
|
||||
final OsmNode originalNode; // The original node being edited
|
||||
NodeProfile? profile;
|
||||
OperatorProfile? operatorProfile;
|
||||
LatLng target; // Current position (can be dragged)
|
||||
List<double> directions; // All directions [90, 180, 270]
|
||||
int currentDirectionIndex; // Which direction we're editing (e.g. 1 = editing the 180°)
|
||||
|
||||
EditNodeSession({
|
||||
required this.originalNode,
|
||||
required this.profile,
|
||||
required this.directionDegrees,
|
||||
this.profile,
|
||||
required double initialDirection,
|
||||
required this.target,
|
||||
});
|
||||
}) : directions = [initialDirection],
|
||||
currentDirectionIndex = 0;
|
||||
|
||||
final OsmNode originalNode; // The original node being edited
|
||||
NodeProfile profile;
|
||||
OperatorProfile? operatorProfile;
|
||||
double directionDegrees;
|
||||
LatLng target; // Current position (can be dragged)
|
||||
// Slider always shows the current direction being edited
|
||||
double get directionDegrees => directions[currentDirectionIndex];
|
||||
set directionDegrees(double value) => directions[currentDirectionIndex] = value;
|
||||
}
|
||||
|
||||
class SessionState extends ChangeNotifier {
|
||||
@@ -39,11 +57,8 @@ class SessionState extends ChangeNotifier {
|
||||
EditNodeSession? get editSession => _editSession;
|
||||
|
||||
void startAddSession(List<NodeProfile> enabledProfiles) {
|
||||
final submittableProfiles = enabledProfiles.where((p) => p.isSubmittable).toList();
|
||||
final defaultProfile = submittableProfiles.isNotEmpty
|
||||
? submittableProfiles.first
|
||||
: enabledProfiles.first; // Fallback to any enabled profile
|
||||
_session = AddNodeSession(profile: defaultProfile);
|
||||
// Start with no profile selected - force user to choose
|
||||
_session = AddNodeSession();
|
||||
_editSession = null; // Clear any edit session
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -52,11 +67,9 @@ class SessionState extends ChangeNotifier {
|
||||
final submittableProfiles = enabledProfiles.where((p) => p.isSubmittable).toList();
|
||||
|
||||
// Try to find a matching profile based on the node's tags
|
||||
NodeProfile matchingProfile = submittableProfiles.isNotEmpty
|
||||
? submittableProfiles.first
|
||||
: enabledProfiles.first;
|
||||
NodeProfile? matchingProfile;
|
||||
|
||||
// Attempt to find a better match by comparing tags
|
||||
// Attempt to find a match by comparing tags
|
||||
for (final profile in submittableProfiles) {
|
||||
if (_profileMatchesTags(profile, node.tags)) {
|
||||
matchingProfile = profile;
|
||||
@@ -64,12 +77,20 @@ class SessionState extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// Start with no profile selected if no match found - force user to choose
|
||||
// Initialize edit session with all existing directions
|
||||
final existingDirections = node.directionDeg.isNotEmpty ? node.directionDeg : [0.0];
|
||||
|
||||
_editSession = EditNodeSession(
|
||||
originalNode: node,
|
||||
profile: matchingProfile,
|
||||
directionDegrees: node.directionDeg ?? 0,
|
||||
initialDirection: existingDirections.first,
|
||||
target: node.coord,
|
||||
);
|
||||
|
||||
// Replace the default single direction with all existing directions
|
||||
_editSession!.directions = List<double>.from(existingDirections);
|
||||
_editSession!.currentDirectionIndex = 0; // Start editing the first direction
|
||||
_session = null; // Clear any add session
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -140,6 +161,49 @@ class SessionState extends ChangeNotifier {
|
||||
if (dirty) notifyListeners();
|
||||
}
|
||||
|
||||
// Add new direction at 0° and switch to editing it
|
||||
void addDirection() {
|
||||
if (_session != null) {
|
||||
_session!.directions.add(0.0);
|
||||
_session!.currentDirectionIndex = _session!.directions.length - 1;
|
||||
notifyListeners();
|
||||
} else if (_editSession != null) {
|
||||
_editSession!.directions.add(0.0);
|
||||
_editSession!.currentDirectionIndex = _editSession!.directions.length - 1;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Remove currently selected direction
|
||||
void removeDirection() {
|
||||
if (_session != null && _session!.directions.length > 1) {
|
||||
_session!.directions.removeAt(_session!.currentDirectionIndex);
|
||||
if (_session!.currentDirectionIndex >= _session!.directions.length) {
|
||||
_session!.currentDirectionIndex = _session!.directions.length - 1;
|
||||
}
|
||||
notifyListeners();
|
||||
} else if (_editSession != null && _editSession!.directions.length > 1) {
|
||||
_editSession!.directions.removeAt(_editSession!.currentDirectionIndex);
|
||||
if (_editSession!.currentDirectionIndex >= _editSession!.directions.length) {
|
||||
_editSession!.currentDirectionIndex = _editSession!.directions.length - 1;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Cycle to next direction
|
||||
void cycleDirection() {
|
||||
if (_session != null && _session!.directions.length > 1) {
|
||||
_session!.currentDirectionIndex = (_session!.currentDirectionIndex + 1) % _session!.directions.length;
|
||||
notifyListeners();
|
||||
} else if (_editSession != null && _editSession!.directions.length > 1) {
|
||||
_editSession!.currentDirectionIndex = (_editSession!.currentDirectionIndex + 1) % _editSession!.directions.length;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
void cancelSession() {
|
||||
_session = null;
|
||||
notifyListeners();
|
||||
@@ -151,7 +215,7 @@ class SessionState extends ChangeNotifier {
|
||||
}
|
||||
|
||||
AddNodeSession? commitSession() {
|
||||
if (_session?.target == null) return null;
|
||||
if (_session?.target == null || _session?.profile == null) return null;
|
||||
|
||||
final session = _session!;
|
||||
_session = null;
|
||||
@@ -160,7 +224,7 @@ class SessionState extends ChangeNotifier {
|
||||
}
|
||||
|
||||
EditNodeSession? commitEditSession() {
|
||||
if (_editSession == null) return null;
|
||||
if (_editSession?.profile == null) return null;
|
||||
|
||||
final session = _editSession!;
|
||||
_editSession = null;
|
||||
|
||||
@@ -12,8 +12,8 @@ enum UploadMode { production, sandbox, simulate }
|
||||
// Enum for follow-me mode (moved from HomeScreen to centralized state)
|
||||
enum FollowMeMode {
|
||||
off, // No following
|
||||
northUp, // Follow position, keep north up
|
||||
rotating, // Follow position and rotation
|
||||
follow, // Follow position, preserve current rotation
|
||||
rotating, // Follow position and rotation based on heading
|
||||
}
|
||||
|
||||
class SettingsState extends ChangeNotifier {
|
||||
@@ -27,25 +27,31 @@ class SettingsState extends ChangeNotifier {
|
||||
static const String _proximityAlertsEnabledPrefsKey = 'proximity_alerts_enabled';
|
||||
static const String _proximityAlertDistancePrefsKey = 'proximity_alert_distance';
|
||||
static const String _networkStatusIndicatorEnabledPrefsKey = 'network_status_indicator_enabled';
|
||||
static const String _suspectedLocationMinDistancePrefsKey = 'suspected_location_min_distance';
|
||||
static const String _pauseQueueProcessingPrefsKey = 'pause_queue_processing';
|
||||
|
||||
bool _offlineMode = false;
|
||||
bool _pauseQueueProcessing = false;
|
||||
int _maxCameras = 250;
|
||||
UploadMode _uploadMode = kEnableDevelopmentModes ? UploadMode.simulate : UploadMode.production;
|
||||
FollowMeMode _followMeMode = FollowMeMode.northUp;
|
||||
FollowMeMode _followMeMode = FollowMeMode.follow;
|
||||
bool _proximityAlertsEnabled = false;
|
||||
int _proximityAlertDistance = kProximityAlertDefaultDistance;
|
||||
bool _networkStatusIndicatorEnabled = false;
|
||||
bool _networkStatusIndicatorEnabled = true;
|
||||
int _suspectedLocationMinDistance = 100; // meters
|
||||
List<TileProvider> _tileProviders = [];
|
||||
String _selectedTileTypeId = '';
|
||||
|
||||
// Getters
|
||||
bool get offlineMode => _offlineMode;
|
||||
bool get pauseQueueProcessing => _pauseQueueProcessing;
|
||||
int get maxCameras => _maxCameras;
|
||||
UploadMode get uploadMode => _uploadMode;
|
||||
FollowMeMode get followMeMode => _followMeMode;
|
||||
bool get proximityAlertsEnabled => _proximityAlertsEnabled;
|
||||
int get proximityAlertDistance => _proximityAlertDistance;
|
||||
bool get networkStatusIndicatorEnabled => _networkStatusIndicatorEnabled;
|
||||
int get suspectedLocationMinDistance => _suspectedLocationMinDistance;
|
||||
List<TileProvider> get tileProviders => List.unmodifiable(_tileProviders);
|
||||
String get selectedTileTypeId => _selectedTileTypeId;
|
||||
|
||||
@@ -89,6 +95,9 @@ class SettingsState extends ChangeNotifier {
|
||||
// Load offline mode
|
||||
_offlineMode = prefs.getBool(_offlineModePrefsKey) ?? false;
|
||||
|
||||
// Load queue processing setting
|
||||
_pauseQueueProcessing = prefs.getBool(_pauseQueueProcessingPrefsKey) ?? false;
|
||||
|
||||
// Load max cameras
|
||||
if (prefs.containsKey(_maxCamerasPrefsKey)) {
|
||||
_maxCameras = prefs.getInt(_maxCamerasPrefsKey) ?? 250;
|
||||
@@ -99,7 +108,10 @@ class SettingsState extends ChangeNotifier {
|
||||
_proximityAlertDistance = prefs.getInt(_proximityAlertDistancePrefsKey) ?? kProximityAlertDefaultDistance;
|
||||
|
||||
// Load network status indicator setting
|
||||
_networkStatusIndicatorEnabled = prefs.getBool(_networkStatusIndicatorEnabledPrefsKey) ?? false;
|
||||
_networkStatusIndicatorEnabled = prefs.getBool(_networkStatusIndicatorEnabledPrefsKey) ?? true;
|
||||
|
||||
// Load suspected location minimum distance
|
||||
_suspectedLocationMinDistance = prefs.getInt(_suspectedLocationMinDistancePrefsKey) ?? 100;
|
||||
|
||||
// Load upload mode (including migration from old test_mode bool)
|
||||
if (prefs.containsKey(_uploadModePrefsKey)) {
|
||||
@@ -206,6 +218,13 @@ class SettingsState extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setPauseQueueProcessing(bool enabled) async {
|
||||
_pauseQueueProcessing = enabled;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_pauseQueueProcessingPrefsKey, enabled);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
set maxCameras(int n) {
|
||||
if (n < 10) n = 10; // minimum
|
||||
_maxCameras = n;
|
||||
@@ -285,12 +304,13 @@ class SettingsState extends ChangeNotifier {
|
||||
Future<void> setFollowMeMode(FollowMeMode mode) async {
|
||||
if (_followMeMode != mode) {
|
||||
_followMeMode = mode;
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_followMeModePrefsKey, mode.index);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Set proximity alerts enabled/disabled
|
||||
Future<void> setProximityAlertsEnabled(bool enabled) async {
|
||||
if (_proximityAlertsEnabled != enabled) {
|
||||
@@ -323,4 +343,14 @@ class SettingsState extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set suspected location minimum distance from real nodes
|
||||
Future<void> setSuspectedLocationMinDistance(int distance) async {
|
||||
if (_suspectedLocationMinDistance != distance) {
|
||||
_suspectedLocationMinDistance = distance;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_suspectedLocationMinDistancePrefsKey, distance);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
98
lib/state/suspected_location_state.dart
Normal file
98
lib/state/suspected_location_state.dart
Normal file
@@ -0,0 +1,98 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/suspected_location.dart';
|
||||
import '../services/suspected_location_service.dart';
|
||||
|
||||
class SuspectedLocationState extends ChangeNotifier {
|
||||
final SuspectedLocationService _service = SuspectedLocationService();
|
||||
|
||||
SuspectedLocation? _selectedLocation;
|
||||
bool _isLoading = false;
|
||||
|
||||
/// Currently selected suspected location (for detail view)
|
||||
SuspectedLocation? get selectedLocation => _selectedLocation;
|
||||
|
||||
/// Get suspected locations in bounds (this should be called by the map view)
|
||||
List<SuspectedLocation> getLocationsInBounds({
|
||||
required double north,
|
||||
required double south,
|
||||
required double east,
|
||||
required double west,
|
||||
}) {
|
||||
return _service.getLocationsInBounds(
|
||||
north: north,
|
||||
south: south,
|
||||
east: east,
|
||||
west: west,
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether suspected locations are enabled
|
||||
bool get isEnabled => _service.isEnabled;
|
||||
|
||||
/// Whether currently loading data
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
/// Last time data was fetched
|
||||
DateTime? get lastFetchTime => _service.lastFetchTime;
|
||||
|
||||
/// Initialize the state
|
||||
Future<void> init({bool offlineMode = false}) async {
|
||||
await _service.init(offlineMode: offlineMode);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Enable or disable suspected locations
|
||||
Future<void> setEnabled(bool enabled) async {
|
||||
await _service.setEnabled(enabled);
|
||||
|
||||
// If enabling and no data exists, fetch it now
|
||||
if (enabled && !_service.hasData) {
|
||||
await _fetchData();
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Manually refresh the data (force refresh)
|
||||
Future<bool> refreshData() async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final success = await _service.forceRefresh();
|
||||
return success;
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal method to fetch data if needed with loading state management
|
||||
Future<bool> _fetchData() async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final success = await _service.fetchDataIfNeeded();
|
||||
return success;
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Select a suspected location for detail view
|
||||
void selectLocation(SuspectedLocation location) {
|
||||
_selectedLocation = location;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Clear the selected location
|
||||
void clearSelection() {
|
||||
_selectedLocation = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -29,8 +29,8 @@ class UploadQueueState extends ChangeNotifier {
|
||||
void addFromSession(AddNodeSession session, {required UploadMode uploadMode}) {
|
||||
final upload = PendingUpload(
|
||||
coord: session.target!,
|
||||
direction: session.directionDegrees,
|
||||
profile: session.profile,
|
||||
direction: _formatDirectionsAsString(session.directions),
|
||||
profile: session.profile!, // Safe to use ! because commitSession() checks for null
|
||||
operatorProfile: session.operatorProfile,
|
||||
uploadMode: uploadMode,
|
||||
operation: UploadOperation.create,
|
||||
@@ -61,10 +61,15 @@ class UploadQueueState extends ChangeNotifier {
|
||||
|
||||
// Add a completed edit session to the upload queue
|
||||
void addFromEditSession(EditNodeSession session, {required UploadMode uploadMode}) {
|
||||
// For constrained nodes, always use original position regardless of session.target
|
||||
final coordToUse = session.originalNode.isConstrained
|
||||
? session.originalNode.coord
|
||||
: session.target;
|
||||
|
||||
final upload = PendingUpload(
|
||||
coord: session.target,
|
||||
direction: session.directionDegrees,
|
||||
profile: session.profile,
|
||||
coord: coordToUse,
|
||||
direction: _formatDirectionsAsString(session.directions),
|
||||
profile: session.profile!, // Safe to use ! because commitEditSession() checks for null
|
||||
operatorProfile: session.operatorProfile,
|
||||
uploadMode: uploadMode,
|
||||
operation: UploadOperation.modify,
|
||||
@@ -109,8 +114,8 @@ class UploadQueueState extends ChangeNotifier {
|
||||
void addFromNodeDeletion(OsmNode node, {required UploadMode uploadMode}) {
|
||||
final upload = PendingUpload(
|
||||
coord: node.coord,
|
||||
direction: node.directionDeg ?? 0, // Use existing direction or default to 0
|
||||
profile: NodeProfile.genericAlpr(), // Dummy profile - not used for deletions
|
||||
direction: node.directionDeg.isNotEmpty ? node.directionDeg.first : 0, // Direction not used for deletions but required for API
|
||||
profile: null, // No profile needed for deletions - just delete by node ID
|
||||
uploadMode: uploadMode,
|
||||
operation: UploadOperation.delete,
|
||||
originalNodeId: node.id,
|
||||
@@ -158,16 +163,17 @@ class UploadQueueState extends ChangeNotifier {
|
||||
// Start the upload processing loop
|
||||
void startUploader({
|
||||
required bool offlineMode,
|
||||
required bool pauseQueueProcessing,
|
||||
required UploadMode uploadMode,
|
||||
required Future<String?> Function() getAccessToken,
|
||||
}) {
|
||||
_uploadTimer?.cancel();
|
||||
|
||||
// No uploads without queue, or if offline mode is enabled.
|
||||
if (_queue.isEmpty || offlineMode) return;
|
||||
// No uploads if queue is empty, offline mode is enabled, or queue processing is paused
|
||||
if (_queue.isEmpty || offlineMode || pauseQueueProcessing) return;
|
||||
|
||||
_uploadTimer = Timer.periodic(const Duration(seconds: 10), (t) async {
|
||||
if (_queue.isEmpty || offlineMode) {
|
||||
if (_queue.isEmpty || offlineMode || pauseQueueProcessing) {
|
||||
_uploadTimer?.cancel();
|
||||
return;
|
||||
}
|
||||
@@ -293,6 +299,13 @@ class UploadQueueState extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to format multiple directions as a string or number
|
||||
dynamic _formatDirectionsAsString(List<double> directions) {
|
||||
if (directions.isEmpty) return 0.0;
|
||||
if (directions.length == 1) return directions.first;
|
||||
return directions.map((d) => d.round().toString()).join(';');
|
||||
}
|
||||
|
||||
// ---------- Queue persistence ----------
|
||||
Future<void> _saveQueue() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../dev_config.dart';
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/operator_profile.dart';
|
||||
import '../services/localization_service.dart';
|
||||
@@ -12,6 +13,126 @@ class AddNodeSheet extends StatelessWidget {
|
||||
|
||||
final AddNodeSession session;
|
||||
|
||||
Widget _buildDirectionControls(BuildContext context, AppState appState, AddNodeSession session, LocalizationService locService) {
|
||||
final requiresDirection = session.profile != null && session.profile!.requiresDirection;
|
||||
|
||||
// Format direction display text with bold for current direction
|
||||
String directionsText = '';
|
||||
if (requiresDirection) {
|
||||
final directionsWithBold = <String>[];
|
||||
for (int i = 0; i < session.directions.length; i++) {
|
||||
final dirStr = session.directions[i].round().toString();
|
||||
if (i == session.currentDirectionIndex) {
|
||||
directionsWithBold.add('**$dirStr**'); // Mark for bold formatting
|
||||
} else {
|
||||
directionsWithBold.add(dirStr);
|
||||
}
|
||||
}
|
||||
directionsText = directionsWithBold.join(', ');
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
title: requiresDirection
|
||||
? RichText(
|
||||
text: TextSpan(
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
children: [
|
||||
const TextSpan(text: 'Directions: '),
|
||||
if (directionsText.isNotEmpty)
|
||||
...directionsText.split('**').asMap().entries.map((entry) {
|
||||
final isEven = entry.key % 2 == 0;
|
||||
return TextSpan(
|
||||
text: entry.value,
|
||||
style: TextStyle(
|
||||
fontWeight: isEven ? FontWeight.normal : FontWeight.bold,
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
)
|
||||
: Text(locService.t('addNode.direction', params: [session.directionDegrees.round().toString()])),
|
||||
subtitle: Row(
|
||||
children: [
|
||||
// Slider takes most of the space
|
||||
Expanded(
|
||||
child: Slider(
|
||||
min: 0,
|
||||
max: 359,
|
||||
divisions: 359,
|
||||
value: session.directionDegrees,
|
||||
label: session.directionDegrees.round().toString(),
|
||||
onChanged: requiresDirection ? (v) => appState.updateSession(directionDeg: v) : null,
|
||||
),
|
||||
),
|
||||
// Direction control buttons - always show but grey out when direction not required
|
||||
const SizedBox(width: 8),
|
||||
// Remove button
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.remove,
|
||||
size: 20,
|
||||
color: requiresDirection ? null : Theme.of(context).disabledColor,
|
||||
),
|
||||
onPressed: requiresDirection && session.directions.length > 1
|
||||
? () => appState.removeDirection()
|
||||
: null,
|
||||
tooltip: requiresDirection ? 'Remove current direction' : 'Direction not required for this profile',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
|
||||
),
|
||||
// Add button
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.add,
|
||||
size: 20,
|
||||
color: requiresDirection ? null : Theme.of(context).disabledColor,
|
||||
),
|
||||
onPressed: requiresDirection ? () => appState.addDirection() : null,
|
||||
tooltip: requiresDirection ? 'Add new direction' : 'Direction not required for this profile',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
|
||||
),
|
||||
// Cycle button
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.repeat,
|
||||
size: 20,
|
||||
color: requiresDirection ? null : Theme.of(context).disabledColor,
|
||||
),
|
||||
onPressed: requiresDirection && session.directions.length > 1
|
||||
? () => appState.cycleDirection()
|
||||
: null,
|
||||
tooltip: requiresDirection ? 'Cycle through directions' : 'Direction not required for this profile',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Show info text when profile doesn't require direction
|
||||
if (!requiresDirection)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, color: Colors.grey, size: 16),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
locService.t('addNode.profileNoDirectionInfo'),
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
@@ -34,7 +155,10 @@ class AddNodeSheet extends StatelessWidget {
|
||||
}
|
||||
|
||||
final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList();
|
||||
final allowSubmit = appState.isLoggedIn && submittableProfiles.isNotEmpty && session.profile.isSubmittable;
|
||||
final allowSubmit = appState.isLoggedIn &&
|
||||
submittableProfiles.isNotEmpty &&
|
||||
session.profile != null &&
|
||||
session.profile!.isSubmittable;
|
||||
|
||||
void _openRefineTags() async {
|
||||
final result = await Navigator.push<OperatorProfile?>(
|
||||
@@ -66,44 +190,18 @@ class AddNodeSheet extends StatelessWidget {
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
title: Text(locService.t('addNode.profile')),
|
||||
trailing: DropdownButton<NodeProfile>(
|
||||
trailing: DropdownButton<NodeProfile?>(
|
||||
value: session.profile,
|
||||
hint: Text(locService.t('addNode.selectProfile')),
|
||||
items: submittableProfiles
|
||||
.map((p) => DropdownMenuItem(value: p, child: Text(p.name)))
|
||||
.toList(),
|
||||
onChanged: (p) =>
|
||||
appState.updateSession(profile: p ?? session.profile),
|
||||
onChanged: (p) => appState.updateSession(profile: p),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: Text(locService.t('addNode.direction', params: [session.directionDegrees.round().toString()])),
|
||||
subtitle: Slider(
|
||||
min: 0,
|
||||
max: 359,
|
||||
divisions: 359,
|
||||
value: session.directionDegrees,
|
||||
label: session.directionDegrees.round().toString(),
|
||||
onChanged: session.profile.requiresDirection
|
||||
? (v) => appState.updateSession(directionDeg: v)
|
||||
: null, // Disables slider when requiresDirection is false
|
||||
),
|
||||
),
|
||||
if (!session.profile.requiresDirection)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, color: Colors.grey, size: 16),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
locService.t('addNode.profileNoDirectionInfo'),
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Direction controls
|
||||
_buildDirectionControls(context, appState, session, locService),
|
||||
|
||||
if (!appState.isLoggedIn)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
@@ -136,7 +234,23 @@ class AddNodeSheet extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (!session.profile.isSubmittable)
|
||||
else if (session.profile == null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, color: Colors.orange, size: 20),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
locService.t('addNode.profileRequired'),
|
||||
style: const TextStyle(color: Colors.orange, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (!session.profile!.isSubmittable)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
@@ -158,7 +272,7 @@ class AddNodeSheet extends StatelessWidget {
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _openRefineTags,
|
||||
onPressed: session.profile != null ? _openRefineTags : null, // Disabled when no profile selected
|
||||
icon: const Icon(Icons.tune),
|
||||
label: Text(session.operatorProfile != null
|
||||
? locService.t('addNode.refineTagsWithProfile', params: [session.operatorProfile!.name])
|
||||
|
||||
238
lib/widgets/advanced_edit_options_sheet.dart
Normal file
238
lib/widgets/advanced_edit_options_sheet.dart
Normal file
@@ -0,0 +1,238 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../models/osm_node.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
/// Information about an OSM editor app
|
||||
class EditorInfo {
|
||||
final String name;
|
||||
final String subtitle;
|
||||
final IconData icon;
|
||||
final String? urlScheme; // null means no custom scheme - go straight to store
|
||||
final String? androidStoreUrl;
|
||||
final String? iosStoreUrl;
|
||||
final bool availableOnAndroid;
|
||||
final bool availableOnIOS;
|
||||
|
||||
const EditorInfo({
|
||||
required this.name,
|
||||
required this.subtitle,
|
||||
required this.icon,
|
||||
this.urlScheme, // Made optional
|
||||
this.androidStoreUrl,
|
||||
this.iosStoreUrl,
|
||||
required this.availableOnAndroid,
|
||||
required this.availableOnIOS,
|
||||
});
|
||||
}
|
||||
|
||||
class AdvancedEditOptionsSheet extends StatelessWidget {
|
||||
final OsmNode node;
|
||||
|
||||
const AdvancedEditOptionsSheet({super.key, required this.node});
|
||||
|
||||
/// Mobile editor apps with their platform availability and store URLs
|
||||
List<EditorInfo> get _mobileEditors => [
|
||||
EditorInfo(
|
||||
name: LocalizationService.instance.t('advancedEdit.vespucci'),
|
||||
subtitle: LocalizationService.instance.t('advancedEdit.vespucciSubtitle'),
|
||||
icon: Icons.android,
|
||||
urlScheme: 'josm:/load_and_zoom?select=node${node.id}', // Has documented deep link support
|
||||
androidStoreUrl: 'https://play.google.com/store/apps/details?id=de.blau.android',
|
||||
availableOnAndroid: true,
|
||||
availableOnIOS: false,
|
||||
),
|
||||
EditorInfo(
|
||||
name: LocalizationService.instance.t('advancedEdit.streetComplete'),
|
||||
subtitle: LocalizationService.instance.t('advancedEdit.streetCompleteSubtitle'),
|
||||
icon: Icons.place,
|
||||
urlScheme: null, // No documented deep link support - go straight to store
|
||||
androidStoreUrl: 'https://play.google.com/store/apps/details?id=de.westnordost.streetcomplete',
|
||||
availableOnAndroid: true,
|
||||
availableOnIOS: false,
|
||||
),
|
||||
EditorInfo(
|
||||
name: LocalizationService.instance.t('advancedEdit.everyDoor'),
|
||||
subtitle: LocalizationService.instance.t('advancedEdit.everyDoorSubtitle'),
|
||||
icon: Icons.map,
|
||||
urlScheme: null, // No documented deep link support - go straight to store
|
||||
androidStoreUrl: 'https://play.google.com/store/apps/details?id=info.zverev.ilya.every_door',
|
||||
iosStoreUrl: 'https://apps.apple.com/app/every-door/id1621945342',
|
||||
availableOnAndroid: true,
|
||||
availableOnIOS: true,
|
||||
),
|
||||
EditorInfo(
|
||||
name: LocalizationService.instance.t('advancedEdit.goMap'),
|
||||
subtitle: LocalizationService.instance.t('advancedEdit.goMapSubtitle'),
|
||||
icon: Icons.phone_iphone,
|
||||
urlScheme: null, // No documented deep link support - go straight to store
|
||||
iosStoreUrl: 'https://apps.apple.com/app/go-map/id592990211',
|
||||
availableOnAndroid: false,
|
||||
availableOnIOS: true,
|
||||
),
|
||||
];
|
||||
|
||||
/// Web editor apps (always available on all platforms)
|
||||
List<EditorInfo> get _webEditors => [
|
||||
EditorInfo(
|
||||
name: LocalizationService.instance.t('advancedEdit.iDEditor'),
|
||||
subtitle: LocalizationService.instance.t('advancedEdit.iDEditorSubtitle'),
|
||||
icon: Icons.public,
|
||||
urlScheme: 'https://www.openstreetmap.org/edit?editor=id&node=${node.id}',
|
||||
availableOnAndroid: true,
|
||||
availableOnIOS: true,
|
||||
),
|
||||
EditorInfo(
|
||||
name: LocalizationService.instance.t('advancedEdit.rapidEditor'),
|
||||
subtitle: LocalizationService.instance.t('advancedEdit.rapidEditorSubtitle'),
|
||||
icon: Icons.speed,
|
||||
urlScheme: 'https://rapideditor.org/edit#map=19/0/0&nodes=${node.id}',
|
||||
availableOnAndroid: true,
|
||||
availableOnIOS: true,
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
// Filter mobile editors based on current platform
|
||||
final availableMobileEditors = _mobileEditors.where((editor) {
|
||||
if (Platform.isAndroid) return editor.availableOnAndroid;
|
||||
if (Platform.isIOS) return editor.availableOnIOS;
|
||||
return false; // Other platforms don't have mobile editors
|
||||
}).toList();
|
||||
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('advancedEdit.title'),
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
locService.t('advancedEdit.subtitle'),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).textTheme.bodySmall?.color,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Web Editors Section
|
||||
Text(
|
||||
locService.t('advancedEdit.webEditors'),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
..._webEditors.map((editor) => _buildEditorTile(context, editor)),
|
||||
|
||||
// Mobile Editors Section (only show if there are available editors)
|
||||
if (availableMobileEditors.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
locService.t('advancedEdit.mobileEditors'),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...availableMobileEditors.map((editor) => _buildEditorTile(context, editor)),
|
||||
],
|
||||
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(locService.t('actions.close')),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEditorTile(BuildContext context, EditorInfo editor) {
|
||||
return ListTile(
|
||||
leading: Icon(editor.icon, size: 24),
|
||||
title: Text(editor.name),
|
||||
subtitle: Text(editor.subtitle),
|
||||
trailing: const Icon(Icons.launch, size: 18),
|
||||
onTap: () => _launchEditor(context, editor),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 0, vertical: 4),
|
||||
);
|
||||
}
|
||||
|
||||
void _launchEditor(BuildContext context, EditorInfo editor) async {
|
||||
Navigator.pop(context); // Close the sheet first
|
||||
|
||||
// If app has a custom URL scheme, try to open it
|
||||
if (editor.urlScheme != null) {
|
||||
try {
|
||||
final uri = Uri.parse(editor.urlScheme!);
|
||||
final launched = await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
if (launched) return; // Success - app opened
|
||||
} catch (e) {
|
||||
// App launch failed - continue to app store
|
||||
}
|
||||
}
|
||||
|
||||
// No custom scheme or app launch failed - redirect to app store
|
||||
await _redirectToAppStore(context, editor);
|
||||
}
|
||||
|
||||
Future<void> _redirectToAppStore(BuildContext context, EditorInfo editor) async {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
try {
|
||||
if (Platform.isAndroid && editor.androidStoreUrl != null) {
|
||||
// Try native Play Store first, then web fallback
|
||||
final packageName = _extractAndroidPackageName(editor.androidStoreUrl!);
|
||||
if (packageName != null) {
|
||||
final marketUri = Uri.parse('market://details?id=$packageName');
|
||||
try {
|
||||
final launched = await launchUrl(marketUri, mode: LaunchMode.externalApplication);
|
||||
if (launched) return;
|
||||
} catch (e) {
|
||||
// Fall back to web Play Store
|
||||
}
|
||||
}
|
||||
|
||||
// Web Play Store fallback
|
||||
final webStoreUri = Uri.parse(editor.androidStoreUrl!);
|
||||
await launchUrl(webStoreUri, mode: LaunchMode.externalApplication);
|
||||
return;
|
||||
} else if (Platform.isIOS && editor.iosStoreUrl != null) {
|
||||
// iOS App Store
|
||||
final iosStoreUri = Uri.parse(editor.iosStoreUrl!);
|
||||
await launchUrl(iosStoreUri, mode: LaunchMode.externalApplication);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// Fall through to show error message
|
||||
}
|
||||
|
||||
// Could not open app or store - show error message
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(locService.t('advancedEdit.couldNotOpenEditor'))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract Android package name from Play Store URL for market:// scheme
|
||||
String? _extractAndroidPackageName(String playStoreUrl) {
|
||||
final uri = Uri.tryParse(playStoreUrl);
|
||||
if (uri == null) return null;
|
||||
|
||||
// Extract from "id=" parameter in Play Store URLs
|
||||
return uri.queryParameters['id'];
|
||||
}
|
||||
}
|
||||
@@ -40,10 +40,10 @@ class CameraIcon extends StatelessWidget {
|
||||
height: kNodeIconDiameter,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.black.withOpacity(kNodeDotOpacity),
|
||||
color: _ringColor.withOpacity(kNodeDotOpacity),
|
||||
border: Border.all(
|
||||
color: _ringColor,
|
||||
width: kNodeRingThickness,
|
||||
width: getNodeRingThickness(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
54
lib/widgets/changelog_dialog.dart
Normal file
54
lib/widgets/changelog_dialog.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/changelog_service.dart';
|
||||
import '../services/version_service.dart';
|
||||
|
||||
class ChangelogDialog extends StatelessWidget {
|
||||
final String changelogContent;
|
||||
|
||||
const ChangelogDialog({
|
||||
super.key,
|
||||
required this.changelogContent,
|
||||
});
|
||||
|
||||
void _onClose(BuildContext context) async {
|
||||
// Note: Version tracking is updated by completeVersionChange() after all dialogs
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text('What\'s New in v${VersionService().version}'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
changelogContent,
|
||||
style: const TextStyle(fontSize: 14, height: 1.4),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Thank you for keeping DeFlock up to date!',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => _onClose(context),
|
||||
child: const Text('Continue'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
171
lib/widgets/compass_indicator.dart
Normal file
171
lib/widgets/compass_indicator.dart
Normal file
@@ -0,0 +1,171 @@
|
||||
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';
|
||||
|
||||
import '../app_state.dart';
|
||||
|
||||
/// A compass indicator widget that shows the current map rotation and allows tapping to animate to north.
|
||||
/// The compass appears in the top-right corner of the map and is disabled (non-interactive) when in follow+rotate mode.
|
||||
class CompassIndicator extends StatefulWidget {
|
||||
final AnimatedMapController mapController;
|
||||
final EdgeInsets safeArea;
|
||||
|
||||
const CompassIndicator({
|
||||
super.key,
|
||||
required this.mapController,
|
||||
required this.safeArea,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CompassIndicator> createState() => _CompassIndicatorState();
|
||||
}
|
||||
|
||||
class _CompassIndicatorState extends State<CompassIndicator> {
|
||||
Timer? _animationTimer;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
// Get current map rotation in degrees
|
||||
double rotationDegrees = 0.0;
|
||||
try {
|
||||
rotationDegrees = widget.mapController.mapController.camera.rotation;
|
||||
} catch (_) {
|
||||
// Map controller not ready yet
|
||||
}
|
||||
|
||||
// Convert degrees to radians for Transform.rotate (flutter_map uses degrees)
|
||||
final rotationRadians = rotationDegrees * (pi / 180);
|
||||
|
||||
// Check if we're in follow+rotate mode (compass should be disabled)
|
||||
final isDisabled = appState.followMeMode == FollowMeMode.rotating;
|
||||
|
||||
final baseTop = (appState.uploadMode == UploadMode.sandbox || appState.uploadMode == UploadMode.simulate) ? 60 : 18;
|
||||
|
||||
// Add extra spacing when search bar is visible
|
||||
final searchBarOffset = (!appState.offlineMode && appState.isInSearchMode) ? 60 : 0;
|
||||
|
||||
return Positioned(
|
||||
top: baseTop + widget.safeArea.top + searchBarOffset,
|
||||
right: 16 + widget.safeArea.right,
|
||||
child: GestureDetector(
|
||||
onTap: isDisabled ? null : () {
|
||||
// Animate to north-up orientation
|
||||
try {
|
||||
// Cancel any existing animation timer
|
||||
_animationTimer?.cancel();
|
||||
|
||||
// Start animation
|
||||
widget.mapController.animateTo(
|
||||
dest: widget.mapController.mapController.camera.center,
|
||||
zoom: widget.mapController.mapController.camera.zoom,
|
||||
rotation: 0.0,
|
||||
duration: const Duration(milliseconds: 500),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
|
||||
// Start timer to force compass updates during animation
|
||||
// Update every 16ms (~60fps) for smooth visual rotation
|
||||
_animationTimer = Timer.periodic(const Duration(milliseconds: 16), (timer) {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
|
||||
// Stop the timer after animation completes (with small buffer)
|
||||
Timer(const Duration(milliseconds: 550), () {
|
||||
_animationTimer?.cancel();
|
||||
_animationTimer = null;
|
||||
if (mounted) {
|
||||
setState(() {}); // Final update to ensure correct end state
|
||||
}
|
||||
});
|
||||
} catch (_) {
|
||||
// Controller not ready, ignore
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: isDisabled
|
||||
? Colors.grey.withOpacity(0.8)
|
||||
: Colors.white.withOpacity(0.95),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: isDisabled
|
||||
? Colors.grey.shade400
|
||||
: Colors.grey.shade300,
|
||||
width: 2.0,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.25),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Compass face with cardinal directions
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isDisabled
|
||||
? Colors.grey.shade200
|
||||
: Colors.grey.shade50,
|
||||
),
|
||||
),
|
||||
),
|
||||
// North indicator that rotates with map rotation
|
||||
Transform.rotate(
|
||||
angle: rotationRadians, // Rotate same direction as map rotation to counter-act it
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// North arrow (red triangle pointing up)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 6),
|
||||
child: Icon(
|
||||
Icons.keyboard_arrow_up,
|
||||
size: 20,
|
||||
color: isDisabled
|
||||
? Colors.grey.shade600
|
||||
: Colors.red.shade600,
|
||||
),
|
||||
),
|
||||
// Small 'N' label
|
||||
Text(
|
||||
'N',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDisabled
|
||||
? Colors.grey.shade600
|
||||
: Colors.red.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,139 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../dev_config.dart';
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/operator_profile.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../state/settings_state.dart';
|
||||
import 'refine_tags_sheet.dart';
|
||||
import 'advanced_edit_options_sheet.dart';
|
||||
|
||||
class EditNodeSheet extends StatelessWidget {
|
||||
const EditNodeSheet({super.key, required this.session});
|
||||
|
||||
final EditNodeSession session;
|
||||
|
||||
Widget _buildDirectionControls(BuildContext context, AppState appState, EditNodeSession session, LocalizationService locService) {
|
||||
final requiresDirection = session.profile != null && session.profile!.requiresDirection;
|
||||
|
||||
// Format direction display text with bold for current direction
|
||||
String directionsText = '';
|
||||
if (requiresDirection) {
|
||||
final directionsWithBold = <String>[];
|
||||
for (int i = 0; i < session.directions.length; i++) {
|
||||
final dirStr = session.directions[i].round().toString();
|
||||
if (i == session.currentDirectionIndex) {
|
||||
directionsWithBold.add('**$dirStr**'); // Mark for bold formatting
|
||||
} else {
|
||||
directionsWithBold.add(dirStr);
|
||||
}
|
||||
}
|
||||
directionsText = directionsWithBold.join(', ');
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
title: requiresDirection
|
||||
? RichText(
|
||||
text: TextSpan(
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
children: [
|
||||
const TextSpan(text: 'Directions: '),
|
||||
if (directionsText.isNotEmpty)
|
||||
...directionsText.split('**').asMap().entries.map((entry) {
|
||||
final isEven = entry.key % 2 == 0;
|
||||
return TextSpan(
|
||||
text: entry.value,
|
||||
style: TextStyle(
|
||||
fontWeight: isEven ? FontWeight.normal : FontWeight.bold,
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
)
|
||||
: Text(locService.t('editNode.direction', params: [session.directionDegrees.round().toString()])),
|
||||
subtitle: Row(
|
||||
children: [
|
||||
// Slider takes most of the space
|
||||
Expanded(
|
||||
child: Slider(
|
||||
min: 0,
|
||||
max: 359,
|
||||
divisions: 359,
|
||||
value: session.directionDegrees,
|
||||
label: session.directionDegrees.round().toString(),
|
||||
onChanged: requiresDirection ? (v) => appState.updateEditSession(directionDeg: v) : null,
|
||||
),
|
||||
),
|
||||
// Direction control buttons - always show but grey out when direction not required
|
||||
const SizedBox(width: 8),
|
||||
// Remove button
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.remove,
|
||||
size: 20,
|
||||
color: requiresDirection ? null : Theme.of(context).disabledColor,
|
||||
),
|
||||
onPressed: requiresDirection && session.directions.length > 1
|
||||
? () => appState.removeDirection()
|
||||
: null,
|
||||
tooltip: requiresDirection ? 'Remove current direction' : 'Direction not required for this profile',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
|
||||
),
|
||||
// Add button
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.add,
|
||||
size: 20,
|
||||
color: requiresDirection ? null : Theme.of(context).disabledColor,
|
||||
),
|
||||
onPressed: requiresDirection ? () => appState.addDirection() : null,
|
||||
tooltip: requiresDirection ? 'Add new direction' : 'Direction not required for this profile',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
|
||||
),
|
||||
// Cycle button
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.repeat,
|
||||
size: 20,
|
||||
color: requiresDirection ? null : Theme.of(context).disabledColor,
|
||||
),
|
||||
onPressed: requiresDirection && session.directions.length > 1
|
||||
? () => appState.cycleDirection()
|
||||
: null,
|
||||
tooltip: requiresDirection ? 'Cycle through directions' : 'Direction not required for this profile',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Show info text when profile doesn't require direction
|
||||
if (!requiresDirection)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, color: Colors.grey, size: 16),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'This profile does not require a direction.',
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
@@ -36,7 +158,11 @@ class EditNodeSheet extends StatelessWidget {
|
||||
|
||||
final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList();
|
||||
final isSandboxMode = appState.uploadMode == UploadMode.sandbox;
|
||||
final allowSubmit = appState.isLoggedIn && submittableProfiles.isNotEmpty && session.profile.isSubmittable;
|
||||
final allowSubmit = kEnableNodeEdits &&
|
||||
appState.isLoggedIn &&
|
||||
submittableProfiles.isNotEmpty &&
|
||||
session.profile != null &&
|
||||
session.profile!.isSubmittable;
|
||||
|
||||
void _openRefineTags() async {
|
||||
final result = await Navigator.push<OperatorProfile?>(
|
||||
@@ -73,45 +199,71 @@ class EditNodeSheet extends StatelessWidget {
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
title: Text(locService.t('editNode.profile')),
|
||||
trailing: DropdownButton<NodeProfile>(
|
||||
trailing: DropdownButton<NodeProfile?>(
|
||||
value: session.profile,
|
||||
hint: Text(locService.t('editNode.selectProfile')),
|
||||
items: submittableProfiles
|
||||
.map((p) => DropdownMenuItem(value: p, child: Text(p.name)))
|
||||
.toList(),
|
||||
onChanged: (p) =>
|
||||
appState.updateEditSession(profile: p ?? session.profile),
|
||||
onChanged: (p) => appState.updateEditSession(profile: p),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: Text(locService.t('editNode.direction', params: [session.directionDegrees.round().toString()])),
|
||||
subtitle: Slider(
|
||||
min: 0,
|
||||
max: 359,
|
||||
divisions: 359,
|
||||
value: session.directionDegrees,
|
||||
label: session.directionDegrees.round().toString(),
|
||||
onChanged: session.profile.requiresDirection
|
||||
? (v) => appState.updateEditSession(directionDeg: v)
|
||||
: null, // Disables slider when requiresDirection is false
|
||||
),
|
||||
),
|
||||
if (!session.profile.requiresDirection)
|
||||
// Direction controls
|
||||
_buildDirectionControls(context, appState, session, locService),
|
||||
|
||||
// Constraint message for nodes that cannot be moved
|
||||
if (session.originalNode.isConstrained)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, color: Colors.grey, size: 16),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
locService.t('editNode.profileNoDirectionInfo'),
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
locService.t('editNode.cannotMoveConstrainedNode'),
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _openAdvancedEdit(context),
|
||||
icon: const Icon(Icons.open_in_new, size: 16),
|
||||
label: Text(locService.t('actions.useAdvancedEditor')),
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(0, 32),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!appState.isLoggedIn)
|
||||
|
||||
if (!kEnableNodeEdits)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.construction, color: Colors.orange, size: 20),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
locService.t('editNode.temporarilyDisabled'),
|
||||
style: const TextStyle(color: Colors.orange, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (!appState.isLoggedIn)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
@@ -143,7 +295,23 @@ class EditNodeSheet extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (!session.profile.isSubmittable)
|
||||
else if (session.profile == null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, color: Colors.orange, size: 20),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
locService.t('editNode.profileRequired'),
|
||||
style: const TextStyle(color: Colors.orange, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (!session.profile!.isSubmittable)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
@@ -165,7 +333,7 @@ class EditNodeSheet extends StatelessWidget {
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _openRefineTags,
|
||||
onPressed: session.profile != null ? _openRefineTags : null, // Disabled when no profile selected
|
||||
icon: const Icon(Icons.tune),
|
||||
label: Text(session.operatorProfile != null
|
||||
? locService.t('editNode.refineTagsWithProfile', params: [session.operatorProfile!.name])
|
||||
@@ -200,4 +368,12 @@ class EditNodeSheet extends StatelessWidget {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _openAdvancedEdit(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => AdvancedEditOptionsSheet(node: session.originalNode),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ class _CameraMapMarkerState extends State<CameraMapMarker> {
|
||||
|
||||
void _onDoubleTap() {
|
||||
_tapTimer?.cancel();
|
||||
widget.mapController.move(widget.node.coord, widget.mapController.camera.zoom + 1);
|
||||
widget.mapController.move(widget.node.coord, widget.mapController.camera.zoom + kNodeDoubleTapZoomDelta);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -95,6 +95,7 @@ class CameraMarkersBuilder {
|
||||
LatLng? userLocation,
|
||||
int? selectedNodeId,
|
||||
void Function(OsmNode)? onNodeTap,
|
||||
bool shouldDim = false,
|
||||
}) {
|
||||
final markers = <Marker>[
|
||||
// Camera markers
|
||||
@@ -103,14 +104,14 @@ class CameraMarkersBuilder {
|
||||
.map((n) {
|
||||
// Check if this node should be highlighted (selected) or dimmed
|
||||
final isSelected = selectedNodeId == n.id;
|
||||
final shouldDim = selectedNodeId != null && !isSelected;
|
||||
final shouldDimNode = shouldDim || (selectedNodeId != null && !isSelected);
|
||||
|
||||
return Marker(
|
||||
point: n.coord,
|
||||
width: kNodeIconDiameter,
|
||||
height: kNodeIconDiameter,
|
||||
child: Opacity(
|
||||
opacity: shouldDim ? 0.5 : 1.0,
|
||||
opacity: shouldDimNode ? 0.5 : 1.0,
|
||||
child: CameraMapMarker(
|
||||
node: n,
|
||||
mapController: mapController,
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../../models/node_profile.dart';
|
||||
import '../../app_state.dart' show UploadMode;
|
||||
import '../../services/prefetch_area_service.dart';
|
||||
import '../camera_provider_with_cache.dart';
|
||||
import '../../dev_config.dart';
|
||||
|
||||
@@ -43,6 +44,8 @@ class CameraRefreshController {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// Clear camera cache to ensure fresh data for new profile combination
|
||||
_cameraProvider.clearCache();
|
||||
// Clear pre-fetch area since profiles changed
|
||||
PrefetchAreaService().clearPreFetchedArea();
|
||||
// Force display refresh first (for immediate UI update)
|
||||
_cameraProvider.refreshDisplay();
|
||||
// Notify that profiles changed (triggers camera refresh)
|
||||
|
||||
@@ -14,47 +14,85 @@ class DirectionConesBuilder {
|
||||
required double zoom,
|
||||
AddNodeSession? session,
|
||||
EditNodeSession? editSession,
|
||||
required BuildContext context,
|
||||
}) {
|
||||
final overlays = <Polygon>[];
|
||||
|
||||
// Add session cone if in add-camera mode and profile requires direction
|
||||
if (session != null && session.target != null && session.profile.requiresDirection) {
|
||||
// Add session cones if in add-camera mode and profile requires direction
|
||||
if (session != null && session.target != null && session.profile?.requiresDirection == true) {
|
||||
// Add current working direction (full opacity)
|
||||
overlays.add(_buildCone(
|
||||
session.target!,
|
||||
session.directionDegrees,
|
||||
zoom,
|
||||
context: context,
|
||||
isSession: true,
|
||||
isActiveDirection: true,
|
||||
));
|
||||
|
||||
// Add other directions (reduced opacity)
|
||||
for (int i = 0; i < session.directions.length; i++) {
|
||||
if (i != session.currentDirectionIndex) {
|
||||
overlays.add(_buildCone(
|
||||
session.target!,
|
||||
session.directions[i],
|
||||
zoom,
|
||||
context: context,
|
||||
isSession: true,
|
||||
isActiveDirection: false,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add edit session cone if in edit-camera mode and profile requires direction
|
||||
if (editSession != null && editSession.profile.requiresDirection) {
|
||||
// Add edit session cones if in edit-camera mode and profile requires direction
|
||||
if (editSession != null && editSession.profile?.requiresDirection == true) {
|
||||
// Add current working direction (full opacity)
|
||||
overlays.add(_buildCone(
|
||||
editSession.target,
|
||||
editSession.directionDegrees,
|
||||
zoom,
|
||||
context: context,
|
||||
isSession: true,
|
||||
isActiveDirection: true,
|
||||
));
|
||||
|
||||
// Add other directions (reduced opacity)
|
||||
for (int i = 0; i < editSession.directions.length; i++) {
|
||||
if (i != editSession.currentDirectionIndex) {
|
||||
overlays.add(_buildCone(
|
||||
editSession.target,
|
||||
editSession.directions[i],
|
||||
zoom,
|
||||
context: context,
|
||||
isSession: true,
|
||||
isActiveDirection: false,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add cones for cameras with direction (but exclude camera being edited)
|
||||
overlays.addAll(
|
||||
cameras
|
||||
.where((n) => _isValidCameraWithDirection(n) &&
|
||||
(editSession == null || n.id != editSession.originalNode.id))
|
||||
.map((n) => _buildCone(
|
||||
n.coord,
|
||||
n.directionDeg!,
|
||||
zoom,
|
||||
))
|
||||
);
|
||||
for (final node in cameras) {
|
||||
if (_isValidCameraWithDirection(node) &&
|
||||
(editSession == null || node.id != editSession.originalNode.id)) {
|
||||
// Build a cone for each direction
|
||||
for (final direction in node.directionDeg) {
|
||||
overlays.add(_buildCone(
|
||||
node.coord,
|
||||
direction,
|
||||
zoom,
|
||||
context: context,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return overlays;
|
||||
}
|
||||
|
||||
static bool _isValidCameraWithDirection(OsmNode node) {
|
||||
return node.hasDirection &&
|
||||
node.directionDeg != null &&
|
||||
(node.coord.latitude != 0 || node.coord.longitude != 0) &&
|
||||
node.coord.latitude.abs() <= 90 &&
|
||||
node.coord.longitude.abs() <= 180;
|
||||
@@ -69,40 +107,59 @@ class DirectionConesBuilder {
|
||||
LatLng origin,
|
||||
double bearingDeg,
|
||||
double zoom, {
|
||||
required BuildContext context,
|
||||
bool isPending = false,
|
||||
bool isSession = false,
|
||||
bool isActiveDirection = true,
|
||||
}) {
|
||||
final halfAngle = kDirectionConeHalfAngle;
|
||||
final length = kDirectionConeBaseLength * math.pow(2, 15 - zoom);
|
||||
|
||||
// Number of points to create the arc (more = smoother curve)
|
||||
// Calculate pixel-based radii
|
||||
final outerRadiusPx = kNodeIconDiameter + (kNodeIconDiameter * kDirectionConeBaseLength);
|
||||
final innerRadiusPx = kNodeIconDiameter + (2 * getNodeRingThickness(context));
|
||||
|
||||
// Convert pixels to coordinate distances with zoom scaling
|
||||
final pixelToCoordinate = 0.00001 * math.pow(2, 15 - zoom);
|
||||
final outerRadius = outerRadiusPx * pixelToCoordinate;
|
||||
final innerRadius = innerRadiusPx * pixelToCoordinate;
|
||||
|
||||
// Number of points for the outer arc (within our directional range)
|
||||
const int arcPoints = 12;
|
||||
|
||||
LatLng project(double deg) {
|
||||
LatLng project(double deg, double distance) {
|
||||
final rad = deg * math.pi / 180;
|
||||
final dLat = length * math.cos(rad);
|
||||
final dLat = distance * math.cos(rad);
|
||||
final dLon =
|
||||
length * math.sin(rad) / math.cos(origin.latitude * math.pi / 180);
|
||||
distance * math.sin(rad) / math.cos(origin.latitude * math.pi / 180);
|
||||
return LatLng(origin.latitude + dLat, origin.longitude + dLon);
|
||||
}
|
||||
|
||||
// Build pizza slice with curved edge
|
||||
final points = <LatLng>[origin];
|
||||
// Build outer arc points only within our directional sector
|
||||
final points = <LatLng>[];
|
||||
|
||||
// Add arc points from left to right
|
||||
// Add outer arc points from left to right (counterclockwise for proper polygon winding)
|
||||
for (int i = 0; i <= arcPoints; i++) {
|
||||
final angle = bearingDeg - halfAngle + (i * 2 * halfAngle / arcPoints);
|
||||
points.add(project(angle));
|
||||
points.add(project(angle, outerRadius));
|
||||
}
|
||||
|
||||
// Close the shape back to origin
|
||||
points.add(origin);
|
||||
// Add inner arc points from right to left (to close the donut shape)
|
||||
for (int i = arcPoints; i >= 0; i--) {
|
||||
final angle = bearingDeg - halfAngle + (i * 2 * halfAngle / arcPoints);
|
||||
points.add(project(angle, innerRadius));
|
||||
}
|
||||
|
||||
// Adjust opacity based on direction state
|
||||
double opacity = kDirectionConeOpacity;
|
||||
if (isSession && !isActiveDirection) {
|
||||
opacity = kDirectionConeOpacity * 0.4; // Reduced opacity for inactive session directions
|
||||
}
|
||||
|
||||
return Polygon(
|
||||
points: points,
|
||||
color: kDirectionConeColor.withOpacity(0.25),
|
||||
color: kDirectionConeColor.withOpacity(opacity),
|
||||
borderColor: kDirectionConeColor,
|
||||
borderStrokeWidth: 1,
|
||||
borderStrokeWidth: getDirectionConeBorderWidth(context),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ class GpsController {
|
||||
required FollowMeMode newMode,
|
||||
required FollowMeMode oldMode,
|
||||
required AnimatedMapController controller,
|
||||
VoidCallback? onMapMovedProgrammatically,
|
||||
}) {
|
||||
debugPrint('[GpsController] Follow-me mode changed: $oldMode → $newMode');
|
||||
|
||||
@@ -55,13 +56,14 @@ class GpsController {
|
||||
_currentLatLng != null) {
|
||||
|
||||
try {
|
||||
if (newMode == FollowMeMode.northUp) {
|
||||
if (newMode == FollowMeMode.follow) {
|
||||
controller.animateTo(
|
||||
dest: _currentLatLng!,
|
||||
zoom: controller.mapController.camera.zoom,
|
||||
duration: kFollowMeAnimationDuration,
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
onMapMovedProgrammatically?.call();
|
||||
} else if (newMode == FollowMeMode.rotating) {
|
||||
// When switching to rotating mode, reset to north-up first
|
||||
controller.animateTo(
|
||||
@@ -71,6 +73,7 @@ class GpsController {
|
||||
duration: kFollowMeAnimationDuration,
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
onMapMovedProgrammatically?.call();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[GpsController] MapController not ready for follow-me change: $e');
|
||||
@@ -89,6 +92,9 @@ class GpsController {
|
||||
int proximityAlertDistance = 200,
|
||||
List<OsmNode> nearbyNodes = const [],
|
||||
List<NodeProfile> enabledProfiles = const [],
|
||||
// Optional callback when map is moved programmatically
|
||||
VoidCallback? onMapMovedProgrammatically,
|
||||
|
||||
}) {
|
||||
final latLng = LatLng(position.latitude, position.longitude);
|
||||
_currentLatLng = latLng;
|
||||
@@ -111,14 +117,18 @@ class GpsController {
|
||||
debugPrint('[GpsController] GPS position update: ${latLng.latitude}, ${latLng.longitude}, follow-me: $followMeMode');
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
try {
|
||||
if (followMeMode == FollowMeMode.northUp) {
|
||||
if (followMeMode == FollowMeMode.follow) {
|
||||
// Follow position only, keep current rotation
|
||||
controller.animateTo(
|
||||
dest: latLng,
|
||||
zoom: controller.mapController.camera.zoom,
|
||||
rotation: controller.mapController.camera.rotation,
|
||||
duration: kFollowMeAnimationDuration,
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
|
||||
// Notify that we moved the map programmatically (for node refresh)
|
||||
onMapMovedProgrammatically?.call();
|
||||
} else if (followMeMode == FollowMeMode.rotating) {
|
||||
// Follow position and rotation based on heading
|
||||
final heading = position.heading;
|
||||
@@ -135,6 +145,9 @@ class GpsController {
|
||||
duration: kFollowMeAnimationDuration,
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
|
||||
// Notify that we moved the map programmatically (for node refresh)
|
||||
onMapMovedProgrammatically?.call();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[GpsController] MapController not ready for position animation: $e');
|
||||
@@ -153,6 +166,8 @@ class GpsController {
|
||||
required int Function() getProximityAlertDistance,
|
||||
required List<OsmNode> Function() getNearbyNodes,
|
||||
required List<NodeProfile> Function() getEnabledProfiles,
|
||||
VoidCallback? onMapMovedProgrammatically,
|
||||
|
||||
}) async {
|
||||
final perm = await Geolocator.requestPermission();
|
||||
if (perm == LocationPermission.denied ||
|
||||
@@ -168,7 +183,6 @@ class GpsController {
|
||||
final proximityAlertDistance = getProximityAlertDistance();
|
||||
final nearbyNodes = getNearbyNodes();
|
||||
final enabledProfiles = getEnabledProfiles();
|
||||
|
||||
processPositionUpdate(
|
||||
position: position,
|
||||
followMeMode: currentFollowMeMode,
|
||||
@@ -178,6 +192,7 @@ class GpsController {
|
||||
proximityAlertDistance: proximityAlertDistance,
|
||||
nearbyNodes: nearbyNodes,
|
||||
enabledProfiles: enabledProfiles,
|
||||
onMapMovedProgrammatically: onMapMovedProgrammatically,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
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';
|
||||
|
||||
/// Widget that renders all map overlay UI elements
|
||||
class MapOverlays extends StatelessWidget {
|
||||
final MapController mapController;
|
||||
final AnimatedMapController mapController;
|
||||
final UploadMode uploadMode;
|
||||
final AddNodeSession? session;
|
||||
final EditNodeSession? editSession;
|
||||
final String? attribution; // Attribution for current tile provider
|
||||
final VoidCallback? onSearchPressed; // Callback for search button
|
||||
|
||||
const MapOverlays({
|
||||
super.key,
|
||||
required this.mapController,
|
||||
@@ -50,13 +51,15 @@ class MapOverlays extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final safeArea = MediaQuery.of(context).padding;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// MODE INDICATOR badge (top-right)
|
||||
if (uploadMode == UploadMode.sandbox || uploadMode == UploadMode.simulate)
|
||||
Positioned(
|
||||
top: 18,
|
||||
right: 14,
|
||||
top: topPositionWithSafeArea(18, safeArea),
|
||||
right: rightPositionWithSafeArea(14, safeArea),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
@@ -82,10 +85,16 @@ class MapOverlays extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
|
||||
// Zoom indicator, positioned relative to button bar
|
||||
// Compass indicator (top-right, below mode indicator)
|
||||
CompassIndicator(
|
||||
mapController: mapController,
|
||||
safeArea: safeArea,
|
||||
),
|
||||
|
||||
// Zoom indicator, positioned relative to button bar with left safe area
|
||||
Positioned(
|
||||
left: 10,
|
||||
bottom: bottomPositionFromButtonBar(kZoomIndicatorSpacingAboveButtonBar, MediaQuery.of(context).padding.bottom),
|
||||
left: leftPositionWithSafeArea(10, safeArea),
|
||||
bottom: bottomPositionFromButtonBar(kZoomIndicatorSpacingAboveButtonBar, safeArea.bottom),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
@@ -96,7 +105,7 @@ class MapOverlays extends StatelessWidget {
|
||||
builder: (context) {
|
||||
double zoom = 15.0; // fallback
|
||||
try {
|
||||
zoom = mapController.camera.zoom;
|
||||
zoom = mapController.mapController.camera.zoom;
|
||||
} catch (_) {
|
||||
// Map controller not ready yet
|
||||
}
|
||||
@@ -113,11 +122,11 @@ class MapOverlays extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
|
||||
// Attribution overlay, positioned relative to button bar
|
||||
// Attribution overlay, positioned relative to button bar with left safe area
|
||||
if (attribution != null)
|
||||
Positioned(
|
||||
bottom: bottomPositionFromButtonBar(kAttributionSpacingAboveButtonBar, MediaQuery.of(context).padding.bottom),
|
||||
left: 10,
|
||||
bottom: bottomPositionFromButtonBar(kAttributionSpacingAboveButtonBar, safeArea.bottom),
|
||||
left: leftPositionWithSafeArea(10, safeArea),
|
||||
child: GestureDetector(
|
||||
onTap: () => _showAttributionDialog(context, attribution!),
|
||||
child: Container(
|
||||
@@ -140,10 +149,10 @@ class MapOverlays extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
|
||||
// Zoom and layer controls (bottom-right), positioned relative to button bar
|
||||
// Zoom and layer controls (bottom-right), positioned relative to button bar with right safe area
|
||||
Positioned(
|
||||
bottom: bottomPositionFromButtonBar(kZoomControlsSpacingAboveButtonBar, MediaQuery.of(context).padding.bottom),
|
||||
right: 16,
|
||||
bottom: bottomPositionFromButtonBar(kZoomControlsSpacingAboveButtonBar, safeArea.bottom),
|
||||
right: rightPositionWithSafeArea(16, safeArea),
|
||||
child: Consumer<AppState>(
|
||||
builder: (context, appState, child) {
|
||||
return Column(
|
||||
@@ -173,8 +182,8 @@ class MapOverlays extends StatelessWidget {
|
||||
heroTag: "zoom_in",
|
||||
onPressed: () {
|
||||
try {
|
||||
final zoom = mapController.camera.zoom;
|
||||
mapController.move(mapController.camera.center, zoom + 1);
|
||||
final zoom = mapController.mapController.camera.zoom;
|
||||
mapController.mapController.move(mapController.mapController.camera.center, zoom + 1);
|
||||
} catch (_) {
|
||||
// Map controller not ready yet
|
||||
}
|
||||
@@ -188,8 +197,8 @@ class MapOverlays extends StatelessWidget {
|
||||
heroTag: "zoom_out",
|
||||
onPressed: () {
|
||||
try {
|
||||
final zoom = mapController.camera.zoom;
|
||||
mapController.move(mapController.camera.center, zoom - 1);
|
||||
final zoom = mapController.mapController.camera.zoom;
|
||||
mapController.mapController.move(mapController.mapController.camera.center, zoom - 1);
|
||||
} catch (_) {
|
||||
// Map controller not ready yet
|
||||
}
|
||||
|
||||
111
lib/widgets/map/suspected_location_markers.dart
Normal file
111
lib/widgets/map/suspected_location_markers.dart
Normal file
@@ -0,0 +1,111 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../../dev_config.dart';
|
||||
import '../../models/suspected_location.dart';
|
||||
import '../suspected_location_sheet.dart';
|
||||
import '../suspected_location_icon.dart';
|
||||
|
||||
/// Smart marker widget for suspected location with single/double tap distinction
|
||||
class SuspectedLocationMapMarker extends StatefulWidget {
|
||||
final SuspectedLocation location;
|
||||
final MapController mapController;
|
||||
final void Function(SuspectedLocation)? onLocationTap;
|
||||
|
||||
const SuspectedLocationMapMarker({
|
||||
required this.location,
|
||||
required this.mapController,
|
||||
this.onLocationTap,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<SuspectedLocationMapMarker> createState() => _SuspectedLocationMapMarkerState();
|
||||
}
|
||||
|
||||
class _SuspectedLocationMapMarkerState extends State<SuspectedLocationMapMarker> {
|
||||
Timer? _tapTimer;
|
||||
// From dev_config.dart for build-time parameters
|
||||
static const Duration tapTimeout = kMarkerTapTimeout;
|
||||
|
||||
void _onTap() {
|
||||
_tapTimer = Timer(tapTimeout, () {
|
||||
// Use callback if provided, otherwise fallback to direct modal
|
||||
if (widget.onLocationTap != null) {
|
||||
widget.onLocationTap!(widget.location);
|
||||
} else {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (_) => SuspectedLocationSheet(location: widget.location),
|
||||
showDragHandle: true,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onDoubleTap() {
|
||||
_tapTimer?.cancel();
|
||||
widget.mapController.move(widget.location.centroid, widget.mapController.camera.zoom + kNodeDoubleTapZoomDelta);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tapTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: _onTap,
|
||||
onDoubleTap: _onDoubleTap,
|
||||
child: const SuspectedLocationIcon(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper class to build marker layers for suspected locations
|
||||
class SuspectedLocationMarkersBuilder {
|
||||
static List<Marker> buildSuspectedLocationMarkers({
|
||||
required List<SuspectedLocation> locations,
|
||||
required MapController mapController,
|
||||
String? selectedLocationId,
|
||||
void Function(SuspectedLocation)? onLocationTap,
|
||||
}) {
|
||||
final markers = <Marker>[];
|
||||
|
||||
for (final location in locations) {
|
||||
if (!_isValidCoordinate(location.centroid)) continue;
|
||||
|
||||
// Check if this location should be highlighted (selected) or dimmed
|
||||
final isSelected = selectedLocationId == location.ticketNo;
|
||||
final shouldDim = selectedLocationId != null && !isSelected;
|
||||
|
||||
markers.add(
|
||||
Marker(
|
||||
point: location.centroid,
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: Opacity(
|
||||
opacity: shouldDim ? 0.5 : 1.0,
|
||||
child: SuspectedLocationMapMarker(
|
||||
location: location,
|
||||
mapController: mapController,
|
||||
onLocationTap: onLocationTap,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return markers;
|
||||
}
|
||||
|
||||
static bool _isValidCoordinate(LatLng coord) {
|
||||
return (coord.latitude != 0 || coord.longitude != 0) &&
|
||||
coord.latitude.abs() <= 90 &&
|
||||
coord.longitude.abs() <= 180;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
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/simple_tile_service.dart';
|
||||
@@ -64,6 +65,11 @@ class TileLayerManager {
|
||||
void clearTileQueueImmediate() {
|
||||
_tileHttpClient.clearTileQueue();
|
||||
}
|
||||
|
||||
/// Clear only tiles that are no longer visible in the current bounds
|
||||
void clearStaleRequests({required LatLngBounds currentBounds}) {
|
||||
_tileHttpClient.clearStaleRequests(currentBounds);
|
||||
}
|
||||
|
||||
/// Build tile layer widget with current provider and type.
|
||||
/// Uses fake domain that SimpleTileHttpClient can parse for cache separation.
|
||||
|
||||
@@ -7,9 +7,12 @@ import 'package:provider/provider.dart';
|
||||
import '../app_state.dart';
|
||||
import '../services/offline_area_service.dart';
|
||||
import '../services/network_status.dart';
|
||||
import '../services/prefetch_area_service.dart';
|
||||
import '../models/osm_node.dart';
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/suspected_location.dart';
|
||||
import '../models/tile_provider.dart';
|
||||
import '../state/session_state.dart';
|
||||
import 'debouncer.dart';
|
||||
import 'camera_provider_with_cache.dart';
|
||||
import 'camera_icon.dart';
|
||||
@@ -20,6 +23,7 @@ import 'map/map_position_manager.dart';
|
||||
import 'map/tile_layer_manager.dart';
|
||||
import 'map/camera_refresh_controller.dart';
|
||||
import 'map/gps_controller.dart';
|
||||
import 'map/suspected_location_markers.dart';
|
||||
import 'network_status_indicator.dart';
|
||||
import 'provisional_pin.dart';
|
||||
import 'proximity_alert_banner.dart';
|
||||
@@ -38,6 +42,7 @@ class MapView extends StatefulWidget {
|
||||
this.sheetHeight = 0.0,
|
||||
this.selectedNodeId,
|
||||
this.onNodeTap,
|
||||
this.onSuspectedLocationTap,
|
||||
this.onSearchPressed,
|
||||
});
|
||||
|
||||
@@ -46,6 +51,7 @@ class MapView extends StatefulWidget {
|
||||
final double sheetHeight;
|
||||
final int? selectedNodeId;
|
||||
final void Function(OsmNode)? onNodeTap;
|
||||
final void Function(SuspectedLocation)? onSuspectedLocationTap;
|
||||
final VoidCallback? onSearchPressed;
|
||||
|
||||
@override
|
||||
@@ -57,6 +63,7 @@ class MapViewState extends State<MapView> {
|
||||
final Debouncer _cameraDebounce = Debouncer(kDebounceCameraRefresh);
|
||||
final Debouncer _tileDebounce = Debouncer(const Duration(milliseconds: 150));
|
||||
final Debouncer _mapPositionDebounce = Debouncer(const Duration(milliseconds: 1000));
|
||||
final Debouncer _constrainedNodeSnapBack = Debouncer(const Duration(milliseconds: 100));
|
||||
|
||||
late final MapPositionManager _positionManager;
|
||||
late final TileLayerManager _tileManager;
|
||||
@@ -66,8 +73,15 @@ class MapViewState extends State<MapView> {
|
||||
// Track zoom to clear queue on zoom changes
|
||||
double? _lastZoom;
|
||||
|
||||
// Track map center to clear queue on significant panning
|
||||
LatLng? _lastCenter;
|
||||
|
||||
|
||||
|
||||
// State for proximity alert banner
|
||||
bool _showProximityBanner = false;
|
||||
|
||||
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -78,7 +92,7 @@ class MapViewState extends State<MapView> {
|
||||
_tileManager = TileLayerManager();
|
||||
_tileManager.initialize();
|
||||
_cameraController = CameraRefreshController();
|
||||
_cameraController.initialize(onCamerasUpdated: _onCamerasUpdated);
|
||||
_cameraController.initialize(onCamerasUpdated: _onNodesUpdated);
|
||||
_gpsController = GpsController();
|
||||
|
||||
// Initialize proximity alert service
|
||||
@@ -170,11 +184,16 @@ class MapViewState extends State<MapView> {
|
||||
}
|
||||
return [];
|
||||
},
|
||||
onMapMovedProgrammatically: () {
|
||||
// Refresh nodes when GPS controller moves the map
|
||||
_refreshNodesFromProvider();
|
||||
},
|
||||
|
||||
);
|
||||
|
||||
// Fetch initial cameras
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_refreshCamerasFromProvider();
|
||||
_refreshNodesFromProvider();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -190,12 +209,13 @@ class MapViewState extends State<MapView> {
|
||||
_cameraController.dispose();
|
||||
_tileManager.dispose();
|
||||
_gpsController.dispose();
|
||||
PrefetchAreaService().dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
|
||||
void _onCamerasUpdated() {
|
||||
void _onNodesUpdated() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
@@ -213,8 +233,8 @@ class MapViewState extends State<MapView> {
|
||||
static Future<void> clearStoredMapPosition() =>
|
||||
MapPositionManager.clearStoredMapPosition();
|
||||
|
||||
/// Get minimum zoom level for camera fetching based on upload mode
|
||||
int _getMinZoomForCameras(BuildContext context) {
|
||||
/// Get minimum zoom level for node fetching based on upload mode
|
||||
int _getMinZoomForNodes(BuildContext context) {
|
||||
final appState = context.read<AppState>();
|
||||
final uploadMode = appState.uploadMode;
|
||||
|
||||
@@ -226,6 +246,53 @@ class MapViewState extends State<MapView> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the map has moved significantly enough to cancel stale tile requests.
|
||||
/// Uses a simple distance threshold - roughly equivalent to 1/4 screen width at zoom 15.
|
||||
bool _mapMovedSignificantly(LatLng? newCenter, LatLng? oldCenter) {
|
||||
if (newCenter == null || oldCenter == null) return false;
|
||||
|
||||
// Calculate approximate distance in meters (rough calculation for performance)
|
||||
final latDiff = (newCenter.latitude - oldCenter.latitude).abs();
|
||||
final lngDiff = (newCenter.longitude - oldCenter.longitude).abs();
|
||||
|
||||
// Threshold: ~500 meters (roughly 1/4 screen at zoom 15)
|
||||
// This prevents excessive cancellations on small movements while catching real pans
|
||||
const double significantMovementThreshold = 0.005; // degrees (~500m at equator)
|
||||
|
||||
return latDiff > significantMovementThreshold || lngDiff > significantMovementThreshold;
|
||||
}
|
||||
|
||||
/// Get interaction options for the map based on whether we're editing a constrained node.
|
||||
/// Allows zoom and rotation but disables all forms of panning for constrained nodes.
|
||||
InteractionOptions _getInteractionOptions(EditNodeSession? editSession) {
|
||||
// Check if we're editing a constrained node
|
||||
if (editSession?.originalNode.isConstrained == true) {
|
||||
// Constrained node: only allow pinch zoom and rotation, disable ALL panning
|
||||
return const InteractionOptions(
|
||||
enableMultiFingerGestureRace: true,
|
||||
flags: InteractiveFlag.pinchZoom | InteractiveFlag.rotate,
|
||||
scrollWheelVelocity: kScrollWheelVelocity,
|
||||
pinchZoomThreshold: kPinchZoomThreshold,
|
||||
pinchMoveThreshold: kPinchMoveThreshold,
|
||||
);
|
||||
}
|
||||
|
||||
// Normal case: all interactions allowed with gesture race to prevent accidental rotation during zoom
|
||||
return const InteractionOptions(
|
||||
enableMultiFingerGestureRace: true,
|
||||
flags: InteractiveFlag.doubleTapDragZoom |
|
||||
InteractiveFlag.doubleTapZoom |
|
||||
InteractiveFlag.drag |
|
||||
InteractiveFlag.flingAnimation |
|
||||
InteractiveFlag.pinchZoom |
|
||||
InteractiveFlag.rotate |
|
||||
InteractiveFlag.scrollWheelZoom,
|
||||
scrollWheelVelocity: kScrollWheelVelocity,
|
||||
pinchZoomThreshold: kPinchZoomThreshold,
|
||||
pinchMoveThreshold: kPinchMoveThreshold,
|
||||
);
|
||||
}
|
||||
|
||||
/// Show zoom warning if user is below minimum zoom level
|
||||
void _showZoomWarningIfNeeded(BuildContext context, double currentZoom, int minZoom) {
|
||||
// Only show warning once per zoom level to avoid spam
|
||||
@@ -250,8 +317,7 @@ class MapViewState extends State<MapView> {
|
||||
}
|
||||
|
||||
|
||||
|
||||
void _refreshCamerasFromProvider() {
|
||||
void _refreshNodesFromProvider() {
|
||||
final appState = context.read<AppState>();
|
||||
_cameraController.refreshCamerasFromProvider(
|
||||
controller: _controller,
|
||||
@@ -262,9 +328,6 @@ class MapViewState extends State<MapView> {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant MapView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
@@ -274,18 +337,14 @@ class MapViewState extends State<MapView> {
|
||||
newMode: widget.followMeMode,
|
||||
oldMode: oldWidget.followMeMode,
|
||||
controller: _controller,
|
||||
onMapMovedProgrammatically: () {
|
||||
_refreshNodesFromProvider();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
@@ -295,7 +354,7 @@ class MapViewState extends State<MapView> {
|
||||
// Check if enabled profiles changed and refresh cameras if needed
|
||||
_cameraController.checkAndHandleProfileChanges(
|
||||
currentEnabledProfiles: appState.enabledProfiles,
|
||||
onProfilesChanged: _refreshCamerasFromProvider,
|
||||
onProfilesChanged: _refreshNodesFromProvider,
|
||||
);
|
||||
|
||||
// Check if tile type OR offline mode changed and clear cache if needed
|
||||
@@ -326,26 +385,74 @@ class MapViewState extends State<MapView> {
|
||||
// Fetch cached cameras for current map bounds (using Consumer so overlays redraw instantly)
|
||||
Widget cameraLayers = Consumer<CameraProviderWithCache>(
|
||||
builder: (context, cameraProvider, child) {
|
||||
// Get current zoom level and map bounds (shared by all logic)
|
||||
double currentZoom = 15.0; // fallback
|
||||
LatLngBounds? mapBounds;
|
||||
try {
|
||||
currentZoom = _controller.mapController.camera.zoom;
|
||||
mapBounds = _controller.mapController.camera.visibleBounds;
|
||||
} catch (_) {
|
||||
// Controller not ready yet, use fallback values
|
||||
mapBounds = null;
|
||||
}
|
||||
final cameras = (mapBounds != null)
|
||||
? cameraProvider.getCachedNodesForBounds(mapBounds)
|
||||
: <OsmNode>[];
|
||||
|
||||
final minZoom = _getMinZoomForNodes(context);
|
||||
List<OsmNode> nodes;
|
||||
|
||||
if (currentZoom >= minZoom) {
|
||||
// Above minimum zoom - get cached nodes
|
||||
nodes = (mapBounds != null)
|
||||
? cameraProvider.getCachedNodesForBounds(mapBounds)
|
||||
: <OsmNode>[];
|
||||
} else {
|
||||
// Below minimum zoom - don't render any nodes
|
||||
nodes = <OsmNode>[];
|
||||
}
|
||||
|
||||
// Determine if we should dim node markers (when suspected location is selected)
|
||||
final shouldDimNodes = appState.selectedSuspectedLocation != null;
|
||||
|
||||
final markers = CameraMarkersBuilder.buildCameraMarkers(
|
||||
cameras: cameras,
|
||||
cameras: nodes,
|
||||
mapController: _controller.mapController,
|
||||
userLocation: _gpsController.currentLocation,
|
||||
selectedNodeId: widget.selectedNodeId,
|
||||
onNodeTap: widget.onNodeTap,
|
||||
shouldDim: shouldDimNodes,
|
||||
);
|
||||
|
||||
// Get current zoom level for direction cones
|
||||
double currentZoom = 15.0; // fallback
|
||||
// Build suspected location markers (respect same zoom and count limits as nodes)
|
||||
final suspectedLocationMarkers = <Marker>[];
|
||||
if (appState.suspectedLocationsEnabled && mapBounds != null && currentZoom >= minZoom) {
|
||||
final suspectedLocations = appState.getSuspectedLocationsInBounds(
|
||||
north: mapBounds.north,
|
||||
south: mapBounds.south,
|
||||
east: mapBounds.east,
|
||||
west: mapBounds.west,
|
||||
);
|
||||
|
||||
// Apply same node count limit as surveillance nodes
|
||||
final maxNodes = appState.maxCameras;
|
||||
final limitedSuspectedLocations = suspectedLocations.take(maxNodes).toList();
|
||||
|
||||
// Filter out suspected locations that are too close to real nodes
|
||||
final filteredSuspectedLocations = _filterSuspectedLocationsByProximity(
|
||||
suspectedLocations: limitedSuspectedLocations,
|
||||
realNodes: nodes,
|
||||
minDistance: appState.suspectedLocationMinDistance,
|
||||
);
|
||||
|
||||
suspectedLocationMarkers.addAll(
|
||||
SuspectedLocationMarkersBuilder.buildSuspectedLocationMarkers(
|
||||
locations: filteredSuspectedLocations,
|
||||
mapController: _controller.mapController,
|
||||
selectedLocationId: appState.selectedSuspectedLocation?.ticketNo,
|
||||
onLocationTap: widget.onSuspectedLocationTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Get current zoom level for direction cones (already have currentZoom)
|
||||
try {
|
||||
currentZoom = _controller.mapController.camera.zoom;
|
||||
} catch (_) {
|
||||
@@ -353,14 +460,30 @@ class MapViewState extends State<MapView> {
|
||||
}
|
||||
|
||||
final overlays = DirectionConesBuilder.buildDirectionCones(
|
||||
cameras: cameras,
|
||||
cameras: nodes,
|
||||
zoom: currentZoom,
|
||||
session: session,
|
||||
editSession: editSession,
|
||||
context: context,
|
||||
);
|
||||
|
||||
// Build edit lines connecting original cameras to their edited positions
|
||||
final editLines = _buildEditLines(cameras);
|
||||
// Add suspected location bounds if one is selected
|
||||
if (appState.selectedSuspectedLocation != null) {
|
||||
final selectedLocation = appState.selectedSuspectedLocation!;
|
||||
if (selectedLocation.bounds.isNotEmpty) {
|
||||
overlays.add(
|
||||
Polygon(
|
||||
points: selectedLocation.bounds,
|
||||
color: Colors.orange.withOpacity(0.3),
|
||||
borderColor: Colors.orange,
|
||||
borderStrokeWidth: 2.0,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Build edit lines connecting original nodes to their edited positions
|
||||
final editLines = _buildEditLines(nodes);
|
||||
|
||||
// Build center marker for add/edit sessions
|
||||
final centerMarkers = <Marker>[];
|
||||
@@ -436,7 +559,7 @@ class MapViewState extends State<MapView> {
|
||||
PolygonLayer(polygons: overlays),
|
||||
if (editLines.isNotEmpty) PolylineLayer(polylines: editLines),
|
||||
if (routeLines.isNotEmpty) PolylineLayer(polylines: routeLines),
|
||||
MarkerLayer(markers: [...markers, ...centerMarkers]),
|
||||
MarkerLayer(markers: [...suspectedLocationMarkers, ...markers, ...centerMarkers]),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -453,15 +576,46 @@ class MapViewState extends State<MapView> {
|
||||
initialCenter: _gpsController.currentLocation ?? _positionManager.initialLocation ?? LatLng(37.7749, -122.4194),
|
||||
initialZoom: _positionManager.initialZoom ?? 15,
|
||||
maxZoom: (appState.selectedTileType?.maxZoom ?? 18).toDouble(),
|
||||
interactionOptions: _getInteractionOptions(editSession),
|
||||
onPositionChanged: (pos, gesture) {
|
||||
setState(() {}); // Instant UI update for zoom, etc.
|
||||
if (gesture) widget.onUserGesture();
|
||||
if (gesture) {
|
||||
widget.onUserGesture();
|
||||
}
|
||||
|
||||
if (session != null) {
|
||||
appState.updateSession(target: pos.center);
|
||||
}
|
||||
if (editSession != null) {
|
||||
appState.updateEditSession(target: pos.center);
|
||||
// For constrained nodes, always snap back to original position
|
||||
if (editSession.originalNode.isConstrained) {
|
||||
final originalPos = editSession.originalNode.coord;
|
||||
|
||||
// Always keep session target as original position
|
||||
appState.updateEditSession(target: originalPos);
|
||||
|
||||
// Only snap back if position actually drifted, and debounce to wait for gesture completion
|
||||
if (pos.center.latitude != originalPos.latitude || pos.center.longitude != originalPos.longitude) {
|
||||
_constrainedNodeSnapBack(() {
|
||||
// Only animate if we're still in a constrained edit session and still drifted
|
||||
final currentEditSession = appState.editSession;
|
||||
if (currentEditSession?.originalNode.isConstrained == true) {
|
||||
final currentPos = _controller.mapController.camera.center;
|
||||
if (currentPos.latitude != originalPos.latitude || currentPos.longitude != originalPos.longitude) {
|
||||
_controller.animateTo(
|
||||
dest: originalPos,
|
||||
zoom: _controller.mapController.camera.zoom,
|
||||
curve: Curves.easeOut,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Normal unconstrained node - allow position updates
|
||||
appState.updateEditSession(target: pos.center);
|
||||
}
|
||||
}
|
||||
|
||||
// Update provisional pin location during navigation search/routing
|
||||
@@ -469,34 +623,41 @@ class MapViewState extends State<MapView> {
|
||||
appState.updateProvisionalPinLocation(pos.center);
|
||||
}
|
||||
|
||||
// Start dual-source waiting when map moves (user is expecting new tiles AND nodes)
|
||||
NetworkStatus.instance.setDualSourceWaiting();
|
||||
|
||||
// Only clear tile queue on significant ZOOM changes (not panning)
|
||||
// Clear tile queue on tile level changes OR significant panning
|
||||
final currentZoom = pos.zoom;
|
||||
final zoomChanged = _lastZoom != null && (currentZoom - _lastZoom!).abs() > 0.5;
|
||||
final currentCenter = pos.center;
|
||||
final currentTileLevel = currentZoom.round();
|
||||
final lastTileLevel = _lastZoom?.round();
|
||||
final tileLevelChanged = lastTileLevel != null && currentTileLevel != lastTileLevel;
|
||||
final centerMoved = _mapMovedSignificantly(currentCenter, _lastCenter);
|
||||
|
||||
if (zoomChanged) {
|
||||
if (tileLevelChanged || centerMoved) {
|
||||
_tileDebounce(() {
|
||||
// Clear stale tile requests on zoom change (quietly)
|
||||
_tileManager.clearTileQueueImmediate();
|
||||
// Use selective clearing to only cancel tiles that are no longer visible
|
||||
try {
|
||||
final currentBounds = _controller.mapController.camera.visibleBounds;
|
||||
_tileManager.clearStaleRequests(currentBounds: currentBounds);
|
||||
} catch (e) {
|
||||
// Fallback to clearing all if bounds calculation fails
|
||||
debugPrint('[MapView] Could not get current bounds for selective clearing: $e');
|
||||
_tileManager.clearTileQueueImmediate();
|
||||
}
|
||||
});
|
||||
}
|
||||
_lastZoom = currentZoom;
|
||||
_lastCenter = currentCenter;
|
||||
|
||||
// Save map position (debounced to avoid excessive writes)
|
||||
_mapPositionDebounce(() {
|
||||
_positionManager.saveMapPosition(pos.center, pos.zoom);
|
||||
});
|
||||
|
||||
// Request more cameras on any map movement/zoom at valid zoom level (slower debounce)
|
||||
final minZoom = _getMinZoomForCameras(context);
|
||||
// Request more nodes on any map movement/zoom at valid zoom level (slower debounce)
|
||||
final minZoom = _getMinZoomForNodes(context);
|
||||
if (pos.zoom >= minZoom) {
|
||||
_cameraDebounce(_refreshCamerasFromProvider);
|
||||
_cameraDebounce(_refreshNodesFromProvider);
|
||||
} else {
|
||||
// Skip nodes at low zoom - report immediate completion (brutalist approach)
|
||||
NetworkStatus.instance.reportNodeComplete();
|
||||
|
||||
// Skip nodes at low zoom - no loading state needed
|
||||
// Show zoom warning if needed
|
||||
_showZoomWarningIfNeeded(context, pos.zoom, minZoom);
|
||||
}
|
||||
@@ -508,17 +669,22 @@ class MapViewState extends State<MapView> {
|
||||
selectedTileType: appState.selectedTileType,
|
||||
),
|
||||
cameraLayers,
|
||||
// Built-in scale bar from flutter_map, positioned relative to button bar
|
||||
Scalebar(
|
||||
alignment: Alignment.bottomLeft,
|
||||
padding: EdgeInsets.only(
|
||||
left: 8,
|
||||
bottom: bottomPositionFromButtonBar(kScaleBarSpacingAboveButtonBar, MediaQuery.of(context).padding.bottom)
|
||||
),
|
||||
textStyle: TextStyle(color: Colors.black, fontWeight: FontWeight.bold),
|
||||
lineColor: Colors.black,
|
||||
strokeWidth: 3,
|
||||
// backgroundColor removed in flutter_map >=8 (wrap in Container if needed)
|
||||
// Built-in scale bar from flutter_map, positioned relative to button bar with safe area
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final safeArea = MediaQuery.of(context).padding;
|
||||
return Scalebar(
|
||||
alignment: Alignment.bottomLeft,
|
||||
padding: EdgeInsets.only(
|
||||
left: leftPositionWithSafeArea(8, safeArea),
|
||||
bottom: bottomPositionFromButtonBar(kScaleBarSpacingAboveButtonBar, safeArea.bottom)
|
||||
),
|
||||
textStyle: TextStyle(color: Colors.black, fontWeight: FontWeight.bold),
|
||||
lineColor: Colors.black,
|
||||
strokeWidth: 3,
|
||||
// backgroundColor removed in flutter_map >=8 (wrap in Container if needed)
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -526,7 +692,7 @@ class MapViewState extends State<MapView> {
|
||||
|
||||
// All map overlays (mode indicator, zoom, attribution, add pin)
|
||||
MapOverlays(
|
||||
mapController: _controller.mapController,
|
||||
mapController: _controller,
|
||||
uploadMode: appState.uploadMode,
|
||||
session: session,
|
||||
editSession: editSession,
|
||||
@@ -552,27 +718,27 @@ class MapViewState extends State<MapView> {
|
||||
}
|
||||
|
||||
/// Build polylines connecting original cameras to their edited positions
|
||||
List<Polyline> _buildEditLines(List<OsmNode> cameras) {
|
||||
List<Polyline> _buildEditLines(List<OsmNode> nodes) {
|
||||
final lines = <Polyline>[];
|
||||
|
||||
// Create a lookup map of original node IDs to their coordinates
|
||||
final originalNodes = <int, LatLng>{};
|
||||
for (final camera in cameras) {
|
||||
if (camera.tags['_pending_edit'] == 'true') {
|
||||
originalNodes[camera.id] = camera.coord;
|
||||
for (final node in nodes) {
|
||||
if (node.tags['_pending_edit'] == 'true') {
|
||||
originalNodes[node.id] = node.coord;
|
||||
}
|
||||
}
|
||||
|
||||
// Find edited cameras and draw lines to their originals
|
||||
for (final camera in cameras) {
|
||||
final originalIdStr = camera.tags['_original_node_id'];
|
||||
if (originalIdStr != null && camera.tags['_pending_upload'] == 'true') {
|
||||
// Find edited nodes and draw lines to their originals
|
||||
for (final node in nodes) {
|
||||
final originalIdStr = node.tags['_original_node_id'];
|
||||
if (originalIdStr != null && node.tags['_pending_upload'] == 'true') {
|
||||
final originalId = int.tryParse(originalIdStr);
|
||||
final originalCoord = originalId != null ? originalNodes[originalId] : null;
|
||||
|
||||
if (originalCoord != null) {
|
||||
lines.add(Polyline(
|
||||
points: [originalCoord, camera.coord],
|
||||
points: [originalCoord, node.coord],
|
||||
color: kNodeRingColorPending,
|
||||
strokeWidth: 3.0,
|
||||
));
|
||||
@@ -582,5 +748,40 @@ class MapViewState extends State<MapView> {
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/// Filter suspected locations that are too close to real nodes
|
||||
List<SuspectedLocation> _filterSuspectedLocationsByProximity({
|
||||
required List<SuspectedLocation> suspectedLocations,
|
||||
required List<OsmNode> realNodes,
|
||||
required int minDistance, // in meters
|
||||
}) {
|
||||
if (minDistance <= 0) return suspectedLocations;
|
||||
|
||||
const distance = Distance();
|
||||
final filteredLocations = <SuspectedLocation>[];
|
||||
|
||||
for (final suspected in suspectedLocations) {
|
||||
bool tooClose = false;
|
||||
|
||||
for (final realNode in realNodes) {
|
||||
final distanceMeters = distance.as(
|
||||
LengthUnit.Meter,
|
||||
suspected.centroid,
|
||||
realNode.coord,
|
||||
);
|
||||
|
||||
if (distanceMeters < minDistance) {
|
||||
tooClose = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!tooClose) {
|
||||
filteredLocations.add(suspected);
|
||||
}
|
||||
}
|
||||
|
||||
return filteredLocations;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,103 +1,114 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../services/network_status.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
class NetworkStatusIndicator extends StatelessWidget {
|
||||
const NetworkStatusIndicator({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ChangeNotifierProvider.value(
|
||||
value: NetworkStatus.instance,
|
||||
child: Consumer<NetworkStatus>(
|
||||
builder: (context, networkStatus, child) {
|
||||
String message;
|
||||
IconData icon;
|
||||
Color color;
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => ChangeNotifierProvider.value(
|
||||
value: NetworkStatus.instance,
|
||||
child: Consumer<NetworkStatus>(
|
||||
builder: (context, networkStatus, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
String message;
|
||||
IconData icon;
|
||||
Color color;
|
||||
|
||||
switch (networkStatus.currentStatus) {
|
||||
case NetworkStatusType.waiting:
|
||||
message = 'Loading...';
|
||||
icon = Icons.hourglass_empty;
|
||||
color = Colors.blue;
|
||||
break;
|
||||
|
||||
case NetworkStatusType.timedOut:
|
||||
message = 'Timed out';
|
||||
icon = Icons.hourglass_disabled;
|
||||
color = Colors.orange;
|
||||
break;
|
||||
|
||||
case NetworkStatusType.noData:
|
||||
message = 'No tiles here';
|
||||
icon = Icons.cloud_off;
|
||||
color = Colors.grey;
|
||||
break;
|
||||
switch (networkStatus.currentStatus) {
|
||||
case NetworkStatusType.waiting:
|
||||
message = locService.t('networkStatus.loading');
|
||||
icon = Icons.hourglass_empty;
|
||||
color = Colors.blue;
|
||||
break;
|
||||
|
||||
case NetworkStatusType.timedOut:
|
||||
message = locService.t('networkStatus.timedOut');
|
||||
icon = Icons.hourglass_disabled;
|
||||
color = Colors.orange;
|
||||
break;
|
||||
|
||||
case NetworkStatusType.noData:
|
||||
message = locService.t('networkStatus.noData');
|
||||
icon = Icons.cloud_off;
|
||||
color = Colors.grey;
|
||||
break;
|
||||
|
||||
case NetworkStatusType.success:
|
||||
message = 'Done';
|
||||
icon = Icons.check_circle;
|
||||
color = Colors.green;
|
||||
break;
|
||||
|
||||
case NetworkStatusType.issues:
|
||||
switch (networkStatus.currentIssueType) {
|
||||
case NetworkIssueType.osmTiles:
|
||||
message = 'Tile provider slow';
|
||||
icon = Icons.map_outlined;
|
||||
color = Colors.orange;
|
||||
break;
|
||||
case NetworkIssueType.overpassApi:
|
||||
message = 'Camera data slow';
|
||||
icon = Icons.camera_alt_outlined;
|
||||
color = Colors.orange;
|
||||
break;
|
||||
case NetworkIssueType.both:
|
||||
message = 'Network issues';
|
||||
icon = Icons.cloud_off_outlined;
|
||||
color = Colors.red;
|
||||
break;
|
||||
default:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
break;
|
||||
|
||||
case NetworkStatusType.ready:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
case NetworkStatusType.success:
|
||||
message = locService.t('networkStatus.success');
|
||||
icon = Icons.check_circle;
|
||||
color = Colors.green;
|
||||
break;
|
||||
|
||||
case NetworkStatusType.nodeLimitReached:
|
||||
message = locService.t('networkStatus.nodeLimitReached');
|
||||
icon = Icons.visibility_off;
|
||||
color = Colors.amber;
|
||||
break;
|
||||
|
||||
case NetworkStatusType.issues:
|
||||
switch (networkStatus.currentIssueType) {
|
||||
case NetworkIssueType.osmTiles:
|
||||
message = locService.t('networkStatus.tileProviderSlow');
|
||||
icon = Icons.map_outlined;
|
||||
color = Colors.orange;
|
||||
break;
|
||||
case NetworkIssueType.overpassApi:
|
||||
message = locService.t('networkStatus.nodeDataSlow');
|
||||
icon = Icons.camera_alt_outlined;
|
||||
color = Colors.orange;
|
||||
break;
|
||||
case NetworkIssueType.both:
|
||||
message = locService.t('networkStatus.networkIssues');
|
||||
icon = Icons.cloud_off_outlined;
|
||||
color = Colors.red;
|
||||
break;
|
||||
default:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
break;
|
||||
|
||||
case NetworkStatusType.ready:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Positioned(
|
||||
top: 8, // Position relative to the map area (not the screen)
|
||||
left: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black87,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: color, width: 1),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: color,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
message,
|
||||
style: TextStyle(
|
||||
return Positioned(
|
||||
top: 8, // Position relative to the map area (not the screen)
|
||||
left: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black87,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: color, width: 1),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: color,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
message,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import '../models/osm_node.dart';
|
||||
import '../app_state.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../dev_config.dart';
|
||||
import 'advanced_edit_options_sheet.dart';
|
||||
|
||||
class NodeTagSheet extends StatelessWidget {
|
||||
final OsmNode node;
|
||||
@@ -67,82 +71,166 @@ class NodeTagSheet extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('node.title').replaceAll('{}', node.id.toString()),
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
void _viewOnOSM() async {
|
||||
final url = 'https://www.openstreetmap.org/node/${node.id}';
|
||||
try {
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} else {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(locService.t('advancedEdit.couldNotOpenOSMWebsite'))),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(locService.t('advancedEdit.couldNotOpenOSMWebsite'))),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _openAdvancedEdit() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => AdvancedEditOptionsSheet(node: node),
|
||||
);
|
||||
}
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('node.title').replaceAll('{}', node.id.toString()),
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Tag list with flexible height constraint
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * getTagListHeightRatio(context),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...node.tags.entries.map(
|
||||
(e) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
e.key,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
...node.tags.entries.map(
|
||||
(e) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
e.key,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Linkify(
|
||||
onOpen: (link) async {
|
||||
final uri = Uri.parse(link.url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} else if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('${LocalizationService.instance.t('advancedEdit.couldNotOpenURL')}: ${link.url}')),
|
||||
);
|
||||
}
|
||||
},
|
||||
text: e.value,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
linkStyle: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
options: const LinkifyOptions(humanize: false),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
e.value,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
softWrap: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (isEditable) ...[
|
||||
ElevatedButton.icon(
|
||||
onPressed: _openEditSheet,
|
||||
icon: const Icon(Icons.edit, size: 18),
|
||||
label: Text(locService.edit),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(0, 36),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// First row: View and Advanced buttons
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: () => _viewOnOSM(),
|
||||
icon: const Icon(Icons.open_in_new, size: 16),
|
||||
label: Text(locService.t('actions.viewOnOSM')),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (isEditable) ...[
|
||||
OutlinedButton.icon(
|
||||
onPressed: _openAdvancedEdit,
|
||||
icon: const Icon(Icons.open_in_new, size: 18),
|
||||
label: Text(locService.t('actions.advanced')),
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(0, 36),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _deleteNode,
|
||||
icon: const Icon(Icons.delete, size: 18),
|
||||
label: Text(locService.t('actions.delete')),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(0, 36),
|
||||
foregroundColor: Colors.red,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(locService.t('actions.close')),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Second row: Edit, Delete, and Close buttons
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (isEditable) ...[
|
||||
ElevatedButton.icon(
|
||||
onPressed: _openEditSheet,
|
||||
icon: const Icon(Icons.edit, size: 18),
|
||||
label: Text(locService.edit),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(0, 36),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _deleteNode,
|
||||
icon: const Icon(Icons.delete, size: 18),
|
||||
label: Text(locService.t('actions.delete')),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(0, 36),
|
||||
foregroundColor: Colors.red,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(locService.t('actions.close')),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
26
lib/widgets/suspected_location_icon.dart
Normal file
26
lib/widgets/suspected_location_icon.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SuspectedLocationIcon extends StatelessWidget {
|
||||
const SuspectedLocationIcon({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.orange,
|
||||
border: Border.all(
|
||||
color: Colors.white,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.help_outline,
|
||||
color: Colors.white,
|
||||
size: 12,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
55
lib/widgets/suspected_location_progress_dialog.dart
Normal file
55
lib/widgets/suspected_location_progress_dialog.dart
Normal file
@@ -0,0 +1,55 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
class SuspectedLocationProgressDialog extends StatelessWidget {
|
||||
final String title;
|
||||
final String message;
|
||||
final double? progress; // 0.0 to 1.0, null for indeterminate
|
||||
final VoidCallback? onCancel;
|
||||
|
||||
const SuspectedLocationProgressDialog({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.message,
|
||||
this.progress,
|
||||
this.onCancel,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
const Icon(Icons.help_outline, color: Colors.orange),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(title)),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(message),
|
||||
const SizedBox(height: 16),
|
||||
if (progress != null)
|
||||
LinearProgressIndicator(value: progress)
|
||||
else
|
||||
const LinearProgressIndicator(),
|
||||
const SizedBox(height: 8),
|
||||
if (progress != null)
|
||||
Text(
|
||||
'${(progress! * 100).toInt()}%',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
if (onCancel != null)
|
||||
TextButton(
|
||||
onPressed: onCancel,
|
||||
child: Text(LocalizationService.instance.cancel),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
164
lib/widgets/suspected_location_sheet.dart
Normal file
164
lib/widgets/suspected_location_sheet.dart
Normal file
@@ -0,0 +1,164 @@
|
||||
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';
|
||||
|
||||
class SuspectedLocationSheet extends StatelessWidget {
|
||||
final SuspectedLocation location;
|
||||
|
||||
const SuspectedLocationSheet({super.key, required this.location});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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
|
||||
final displayData = <String, String>{};
|
||||
for (final entry in location.allFields.entries) {
|
||||
final value = entry.value?.toString();
|
||||
if (value != null && value.isNotEmpty) {
|
||||
displayData[entry.key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('suspectedLocation.title', params: [location.ticketNo]),
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Field list with flexible height constraint
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * getTagListHeightRatio(context),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Display all fields
|
||||
...displayData.entries.map(
|
||||
(e) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
e.key,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: e.key.toLowerCase().contains('url') && e.value.isNotEmpty
|
||||
? GestureDetector(
|
||||
onTap: () async {
|
||||
final uri = Uri.parse(e.value);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} else {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Could not open URL: ${e.value}'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Text(
|
||||
e.value,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
softWrap: true,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
e.value,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
softWrap: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Coordinates info
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('suspectedLocation.coordinates'),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${location.centroid.latitude.toStringAsFixed(6)}, ${location.centroid.longitude.toStringAsFixed(6)}',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
softWrap: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Close button
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(locService.t('actions.close')),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
162
lib/widgets/welcome_dialog.dart
Normal file
162
lib/widgets/welcome_dialog.dart
Normal file
@@ -0,0 +1,162 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../services/changelog_service.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
class WelcomeDialog extends StatefulWidget {
|
||||
const WelcomeDialog({super.key});
|
||||
|
||||
@override
|
||||
State<WelcomeDialog> createState() => _WelcomeDialogState();
|
||||
}
|
||||
|
||||
class _WelcomeDialogState extends State<WelcomeDialog> {
|
||||
bool _dontShowAgain = false;
|
||||
|
||||
Future<void> _launchUrl(String url) async {
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
|
||||
void _onClose() async {
|
||||
if (_dontShowAgain) {
|
||||
await ChangelogService().markWelcomeSeen();
|
||||
}
|
||||
|
||||
// Note: Version tracking is updated by completeVersionChange() after all dialogs
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => AlertDialog(
|
||||
title: Text(locService.t('welcome.title')),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Scrollable content
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('welcome.description'),
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
locService.t('welcome.mission'),
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.orange.withOpacity(0.3)),
|
||||
),
|
||||
child: Text(
|
||||
locService.t('welcome.firsthandKnowledge'),
|
||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: Colors.deepOrange),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
locService.t('welcome.privacy'),
|
||||
style: const TextStyle(fontSize: 13, fontStyle: FontStyle.italic),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
locService.t('welcome.tileNote'),
|
||||
style: const TextStyle(fontSize: 13, color: Colors.orange),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
locService.t('welcome.moreInfo'),
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Quick links row
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_buildLinkButton('Website', 'https://deflock.me'),
|
||||
_buildLinkButton('GitHub', 'https://github.com/FoggedLens/deflock-app'),
|
||||
_buildLinkButton('Discord', 'https://discord.gg/aV7v4R3sKT'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Always visible checkbox at the bottom
|
||||
Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: _dontShowAgain,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_dontShowAgain = value ?? false;
|
||||
});
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
locService.t('welcome.dontShowAgain'),
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _onClose,
|
||||
child: Text(locService.t('welcome.getStarted')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLinkButton(String text, String url) {
|
||||
return Flexible(
|
||||
child: GestureDetector(
|
||||
onTap: () => _launchUrl(url),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -38,5 +38,13 @@ end
|
||||
post_install do |installer|
|
||||
installer.pods_project.targets.each do |target|
|
||||
flutter_additional_macos_build_settings(target)
|
||||
|
||||
# Ensure minimum deployment target is 10.13 or higher for all pods
|
||||
target.build_configurations.each do |config|
|
||||
deployment_target = config.build_settings['MACOSX_DEPLOYMENT_TARGET']
|
||||
if deployment_target && deployment_target.to_f < 10.13
|
||||
config.build_settings['MACOSX_DEPLOYMENT_TARGET'] = '10.13'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -572,7 +572,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
|
||||
@@ -2,17 +2,13 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)me.deflock.deflockapp</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
26
pubspec.lock
26
pubspec.lock
@@ -89,6 +89,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
csv:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: csv
|
||||
sha256: c6aa2679b2a18cb57652920f674488d89712efaf4d3fdf2e537215b35fc19d6c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
dart_earcut:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -158,6 +166,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.14.4"
|
||||
flutter_linkify:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_linkify
|
||||
sha256: "74669e06a8f358fee4512b4320c0b80e51cffc496607931de68d28f099254073"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_local_notifications:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -387,6 +403,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.1"
|
||||
linkify:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: linkify
|
||||
sha256: "4139ea77f4651ab9c315b577da2dd108d9aa0bd84b5d03d33323f1970c645832"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.0"
|
||||
lists:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -705,7 +729,7 @@ packages:
|
||||
source: hosted
|
||||
version: "2.2.2"
|
||||
url_launcher:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: url_launcher
|
||||
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: deflockapp
|
||||
description: Map public surveillance infrastructure with OpenStreetMap
|
||||
publish_to: "none"
|
||||
version: 1.1.0+2 # The thing after the + is the google versionCode
|
||||
version: 1.4.0+13 # The thing after the + is the version code, incremented with each release
|
||||
|
||||
environment:
|
||||
sdk: ">=3.5.0 <4.0.0" # oauth2_client 4.x needs Dart 3.5+
|
||||
@@ -20,6 +20,8 @@ dependencies:
|
||||
flutter_svg: ^2.0.10
|
||||
xml: ^6.4.2
|
||||
flutter_local_notifications: ^17.2.2
|
||||
url_launcher: ^6.3.0
|
||||
flutter_linkify: ^6.0.0
|
||||
|
||||
# Auth, storage, prefs
|
||||
oauth2_client: ^4.2.0
|
||||
@@ -30,6 +32,7 @@ dependencies:
|
||||
shared_preferences: ^2.2.2
|
||||
uuid: ^4.0.0
|
||||
package_info_plus: ^8.0.0
|
||||
csv: ^6.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_launcher_icons: ^0.14.4
|
||||
@@ -43,6 +46,7 @@ flutter:
|
||||
- assets/android_app_icon.png
|
||||
- assets/transparent_1x1.png
|
||||
- assets/deflock-logo.svg
|
||||
- assets/changelog.json
|
||||
- lib/localizations/
|
||||
|
||||
flutter_launcher_icons:
|
||||
|
||||
154
scripts/validate_localizations.dart
Normal file
154
scripts/validate_localizations.dart
Normal file
@@ -0,0 +1,154 @@
|
||||
#!/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,
|
||||
);
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:deflockapp/models/pending_upload.dart';
|
||||
import 'package:deflockapp/models/node_profile.dart';
|
||||
import 'package:deflockapp/state/settings_state.dart';
|
||||
|
||||
void main() {
|
||||
group('PendingUpload', () {
|
||||
test('should serialize and deserialize upload mode correctly', () {
|
||||
// Test each upload mode
|
||||
final testModes = [
|
||||
UploadMode.production,
|
||||
UploadMode.sandbox,
|
||||
UploadMode.simulate,
|
||||
];
|
||||
|
||||
for (final mode in testModes) {
|
||||
final original = PendingUpload(
|
||||
coord: LatLng(37.7749, -122.4194),
|
||||
direction: 90.0,
|
||||
profile: NodeProfile.flock(),
|
||||
uploadMode: mode,
|
||||
);
|
||||
|
||||
// Serialize to JSON
|
||||
final json = original.toJson();
|
||||
|
||||
// Deserialize from JSON
|
||||
final restored = PendingUpload.fromJson(json);
|
||||
|
||||
// Verify upload mode is preserved
|
||||
expect(restored.uploadMode, equals(mode));
|
||||
expect(restored.uploadModeDisplayName, equals(original.uploadModeDisplayName));
|
||||
|
||||
// Verify other fields too
|
||||
expect(restored.coord.latitude, equals(original.coord.latitude));
|
||||
expect(restored.coord.longitude, equals(original.coord.longitude));
|
||||
expect(restored.direction, equals(original.direction));
|
||||
expect(restored.profile.id, equals(original.profile.id));
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle legacy JSON without uploadMode', () {
|
||||
// Simulate old JSON format without uploadMode field
|
||||
final legacyJson = {
|
||||
'lat': 37.7749,
|
||||
'lon': -122.4194,
|
||||
'dir': 90.0,
|
||||
'profile': NodeProfile.flock().toJson(),
|
||||
'originalNodeId': null,
|
||||
'attempts': 0,
|
||||
'error': false,
|
||||
// Note: no 'uploadMode' field
|
||||
};
|
||||
|
||||
final upload = PendingUpload.fromJson(legacyJson);
|
||||
|
||||
// Should default to production mode for legacy entries
|
||||
expect(upload.uploadMode, equals(UploadMode.production));
|
||||
expect(upload.uploadModeDisplayName, equals('Production'));
|
||||
});
|
||||
|
||||
test('should correctly identify edits vs new cameras', () {
|
||||
final newCamera = PendingUpload(
|
||||
coord: LatLng(37.7749, -122.4194),
|
||||
direction: 90.0,
|
||||
profile: NodeProfile.flock(),
|
||||
uploadMode: UploadMode.production,
|
||||
);
|
||||
|
||||
final editCamera = PendingUpload(
|
||||
coord: LatLng(37.7749, -122.4194),
|
||||
direction: 90.0,
|
||||
profile: CameraProfile.flock(),
|
||||
uploadMode: UploadMode.production,
|
||||
originalNodeId: 12345,
|
||||
);
|
||||
|
||||
expect(newCamera.isEdit, isFalse);
|
||||
expect(editCamera.isEdit, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user