Compare commits

..

49 Commits

Author SHA1 Message Date
stopflock
ec63aed459 v1 2025-10-01 13:57:08 -05:00
stopflock
b440629ad6 Simultaneous animations for smoothness 2025-10-01 13:45:23 -05:00
stopflock
322b9fae62 bump version, disable dev mode 2025-10-01 13:37:36 -05:00
stopflock
792f94065d highlight selected node 2025-10-01 13:36:51 -05:00
stopflock
1aeae18ebc Better handoff from tags to edit sheet 2025-10-01 13:27:17 -05:00
stopflock
96b82ef416 Proper centering for all node sheets. still jumps between tags and edit. 2025-10-01 12:34:02 -05:00
stopflock
adec4b175f Tablet thing fixed 2025-10-01 12:01:33 -05:00
stopflock
583499ccd1 Tablet thing fixed - tweaking needed 2025-10-01 11:52:46 -05:00
stopflock
7ff9273f47 fix readme edits.. 2025-09-30 15:16:40 -05:00
stopflock
03654f354e update readme todos 2025-09-30 15:13:58 -05:00
stopflock
9e97b69b85 Passes sniff test, needs sandbox testing 2025-09-30 14:43:36 -05:00
stopflock
fe554230b6 Constrain button bar width 2025-09-30 13:54:05 -05:00
stopflock
41ee9cab10 language order; english always first after system default 2025-09-30 09:57:40 -05:00
stopflock
ab567b1da4 languages and about get their own section 2025-09-30 09:50:24 -05:00
stopflock
e79790c30d Proximity alerts localizations 2025-09-30 09:14:21 -05:00
stopflock
d397610121 Move settings around part 4 2025-09-30 00:59:27 -05:00
stopflock
3a985d2f8f Move settings around part 3 2025-09-30 00:17:25 -05:00
stopflock
b3a87fc56a Move settings around part 2 2025-09-29 23:56:28 -05:00
stopflock
82501a3131 Move settings around part 1 2025-09-29 23:21:25 -05:00
stopflock
e71adab87e Change preview tile, add to dev_config 2025-09-29 22:18:33 -05:00
stopflock
2d29c93145 Area size estimates based on preview tile size if available 2025-09-29 21:43:55 -05:00
stopflock
caa20140b4 Fix display issue when refreshing offline areas 2025-09-29 21:32:39 -05:00
stopflock
2b26bf9188 Preview tile fetching on startup, offline area refresh, more cameras->nodes 2025-09-29 21:28:40 -05:00
stopflock
1140e6300a Merge pull request #16 from FoggedLens/approach-notifications
Approach notifications
2025-09-29 19:40:28 -05:00
stopflock
7a1b1befb4 Ask for notification permission on android, test button 2025-09-29 19:30:10 -05:00
stopflock
71fa212d71 More camera -> node, notifications for approaching 2025-09-29 19:22:22 -05:00
stopflock
6b5f05d036 Italian and Chinese coz why not 2025-09-29 12:20:35 -05:00
stopflock
87256e2c74 Bump version, dynamic localizations, add portuguese 2025-09-29 11:56:28 -05:00
stopflock
4a7a99502c Finish localizations, remove now-inaccurate sandbox note 2025-09-29 11:42:48 -05:00
stopflock
5c80fdc169 Update readme, create dev notes 2025-09-29 11:16:35 -05:00
stopflock
5c525900f1 bump version, disable dev mode 2025-09-28 23:18:51 -05:00
stopflock
28828fbac0 Merge pull request #15 from FoggedLens/sandbox-enhancements
Sandbox enhancements
2025-09-28 23:17:52 -05:00
stopflock
9bf46721f0 Clear node cache when switching to/from sandbox 2025-09-28 23:16:01 -05:00
stopflock
363439f712 Edits and deletes now working in sandbox 2025-09-28 22:49:07 -05:00
stopflock
38f15a1f8b fetch from sandbox 2025-09-28 22:18:59 -05:00
stopflock
a05abd8bd8 Deletions! 2025-09-28 21:44:28 -05:00
stopflock
c8a8d4c81f update readme todos 2025-09-28 20:44:30 -05:00
stopflock
63e8934490 turn dev mode back off - oops 2025-09-28 20:20:28 -05:00
stopflock
4053c9b39b use real node id for pending uploads, more camera -> node 2025-09-28 20:20:00 -05:00
stopflock
4ad33d17e0 Remove world map area 2025-09-28 19:00:07 -05:00
stopflock
c9f1ecf7d0 give do_build --ios and --android options. bump version. 2025-09-28 18:14:08 -05:00
stopflock
7c49b38230 Fix offline area size estimates, zoom level metadata display 2025-09-28 17:43:03 -05:00
stopflock
25f0e358a3 clean up debug logging 2025-09-28 17:02:58 -05:00
stopflock
0cbcec7017 improve data indicator, offline fetching, offline area loading 2025-09-28 17:00:34 -05:00
stopflock
68289135bd bump version 2025-09-26 17:21:54 -05:00
stopflock
23b7586e25 tag node -> add node, better element offsets, fix splash background on iOS 2025-09-26 15:40:30 -05:00
stopflock
a2b842fb67 offline areas localization 2025-09-26 14:43:57 -05:00
stopflock
175bc8831a new icons, remove upload dests 2025-09-26 14:31:50 -05:00
stopflock
99ce659064 macos specific files for permissions etc, bundle id fix for ios 2025-09-01 11:02:27 -05:00
125 changed files with 5110 additions and 1192 deletions

425
DEVELOPER.md Normal file
View File

@@ -0,0 +1,425 @@
# Developer Documentation
This document provides detailed technical information about the DeFlock app architecture, key design decisions, and development guidelines.
---
## Philosophy: Brutalist Code
Our development approach prioritizes **simplicity over cleverness**:
- **Explicit over implicit**: Clear, readable code that states its intent
- **Few edge cases by design**: Avoid complex branching and special cases
- **Maintainable over efficient**: Choose the approach that's easier to understand and modify
- **Delete before adding**: Remove complexity when possible rather than adding features
**Hierarchy of preferred code:**
1. **Code we don't write** (through thoughtful design and removing edge cases)
2. **Code we can remove** (by seeing problems from a new angle)
3. **Code that sadly must exist** (simple, explicit, maintainable)
---
## Architecture Overview
### State Management
The app uses **Provider pattern** with modular state classes:
```
AppState (main coordinator)
├── AuthState (OAuth2 login/logout)
├── OperatorProfileState (operator tag sets)
├── ProfileState (node profiles & toggles)
├── SessionState (add/edit sessions)
├── SettingsState (preferences & tile providers)
└── UploadQueueState (pending operations)
```
**Why this approach:**
- **Separation of concerns**: Each state handles one domain
- **Testability**: Individual state classes can be unit tested
- **Brutalist**: No complex state orchestration, just simple delegation
### Data Flow Architecture
```
UI Layer (Widgets)
↕️
AppState (Coordinator)
↕️
State Modules (AuthState, ProfileState, etc.)
↕️
Services (MapDataProvider, NodeCache, Uploader)
↕️
External APIs (OSM, Overpass, Tile providers)
```
**Key principles:**
- **Unidirectional data flow**: UI → AppState → Services → APIs
- **No direct service access from UI**: Everything goes through AppState
- **Clean boundaries**: Each layer has a clear responsibility
---
## Core Components
### 1. MapDataProvider
**Purpose**: Unified interface for fetching map tiles and surveillance nodes
**Design decisions:**
- **Pluggable sources**: Local (cached) vs Remote (live API)
- **Offline-first**: Always try local 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
- `_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.
### 2. Node Operations (Create/Edit/Delete)
**Upload Operations Enum:**
```dart
enum UploadOperation { create, modify, delete }
```
**Why explicit enum vs boolean flags:**
- **Brutalist**: Three explicit states instead of nullable booleans
- **Extensible**: Easy to add new operations (like bulk operations)
- **Clear intent**: `operation == UploadOperation.delete` is unambiguous
**Session Pattern:**
- `AddNodeSession`: For creating new nodes
- `EditNodeSession`: For modifying existing nodes
- No "DeleteSession": Deletions are immediate (simpler)
**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.
### 3. Upload Queue System
**Design principles:**
- **Operation-agnostic**: Same queue handles create/modify/delete
- **Offline-capable**: Queue persists between app sessions
- **Visual feedback**: Each operation type has distinct UI state
- **Error recovery**: Retry mechanism with exponential backoff
**Queue workflow:**
1. User action (add/edit/delete) → `PendingUpload` created
2. Immediate visual feedback (cache updated with temp markers)
3. Background uploader processes queue when online
4. Success → cache updated with real data, temp markers removed
5. Failure → error state, retry available
**Why immediate visual feedback:**
Users expect instant response to their actions. By immediately updating the cache with temporary markers (e.g., `_pending_deletion`), the UI stays responsive while the actual API calls happen in background.
### 4. Cache & Visual States
**Node visual states:**
- **Blue ring**: Real nodes from OSM
- **Purple ring**: Pending uploads (new nodes)
- **Grey ring**: Original nodes with pending edits
- **Orange ring**: Node currently being edited
- **Red ring**: Nodes pending deletion
**Cache tags for state tracking:**
```dart
'_pending_upload' // New node waiting to upload
'_pending_edit' // Original node has pending edits
'_pending_deletion' // Node queued for deletion
'_original_node_id' // For drawing connection lines
```
**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
**Production mode:** Overpass API → OSM API fallback
**Sandbox mode:** OSM API only (Overpass doesn't have sandbox data)
**Zoom level restrictions:**
- **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.
### 6. Offline vs Online Mode Behavior
**Mode combinations:**
```
Production + Online → Local cache + Overpass API
Production + Offline → Local cache only
Sandbox + Online → OSM API only (no cache mixing)
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.
---
## Key Design Decisions & Rationales
### 1. Why Provider Pattern?
**Alternatives considered:**
- BLoC: Too verbose for our needs
- Riverpod: Added complexity without clear benefit
- setState: Doesn't scale beyond single widgets
**Why Provider won:**
- **Familiar**: Most Flutter developers know Provider
- **Simple**: Minimal boilerplate
- **Flexible**: Easy to compose multiple providers
- **Battle-tested**: Mature, stable library
### 2. Why Separate State Classes?
**Alternative**: Single monolithic AppState
**Why modular state:**
- **Single responsibility**: Each state class has one concern
- **Testability**: Easier to unit test individual features
- **Maintainability**: Changes to auth don't affect profile logic
- **Team development**: Different developers can work on different states
### 3. Why Upload Queue vs Direct API Calls?
**Alternative**: Direct API calls from UI actions
**Why queue approach:**
- **Offline capability**: Actions work without internet
- **User experience**: Instant feedback, no waiting for API calls
- **Error recovery**: Failed uploads can be retried
- **Batch processing**: Could optimize multiple operations
- **Visual feedback**: Users can see pending operations
### 4. Why Overpass + OSM API vs Just One?
**Why not just Overpass:**
- Overpass doesn't have sandbox data
- Overpass can be unreliable/slow
- OSM API is canonical source
**Why not just OSM API:**
- OSM API has strict bbox size limits
- OSM API returns all data types (inefficient)
- Overpass is optimized for surveillance device queries
**Result**: Use the best tool for each situation
### 5. Why Zoom Level Restrictions?
**Alternative**: Always fetch, handle errors gracefully
**Why restrictions:**
- **Prevents API abuse**: Large bbox queries can overload servers
- **User experience**: Fetching 10,000 nodes causes UI lag
- **Battery life**: Excessive network requests drain battery
- **Clear feedback**: Users understand why nodes aren't showing
---
## Development Guidelines
### 1. Adding New Features
**Before writing code:**
1. Can we solve this by removing existing code?
2. Can we simplify the problem to avoid edge cases?
3. Does this fit the existing patterns?
**When adding new upload operations:**
1. Add to `UploadOperation` enum
2. Update `PendingUpload` serialization
3. Add visual state (color, icon)
4. Update uploader logic
5. Add cache cleanup handling
### 2. Testing Philosophy
**Priority order:**
1. **Integration tests**: Test complete user workflows
2. **Widget tests**: Test UI components with mock data
3. **Unit tests**: Test individual state classes
**Why integration tests first:**
The most important thing is that user workflows work end-to-end. Unit tests can pass while the app is broken from a user perspective.
### 3. Error Handling
**Principles:**
- **Never crash the UI**: Always provide fallbacks
- **Fail gracefully**: Empty list is better than exception
- **User feedback**: Show meaningful error messages
- **Logging**: Use debugPrint for troubleshooting
**Example pattern:**
```dart
try {
final result = await riskyOperation();
return result;
} catch (e) {
debugPrint('Operation failed: $e');
// Show user-friendly message
showSnackBar('Unable to load data. Please try again.');
return <EmptyResult>[];
}
```
### 4. State Updates
**Always notify listeners:**
```dart
void updateSomething() {
_something = newValue;
notifyListeners(); // Don't forget this!
}
```
**Batch related updates:**
```dart
void updateMultipleThings() {
_thing1 = value1;
_thing2 = value2;
_thing3 = value3;
notifyListeners(); // Single notification for all changes
}
```
---
## Build & Development Setup
### Prerequisites
- **Flutter SDK**: Latest stable version
- **Xcode**: For iOS builds (macOS only)
- **Android Studio**: For Android builds
- **Git**: For version control
### OAuth2 Setup
**Required registrations:**
1. **Production OSM**: https://www.openstreetmap.org/oauth2/applications
2. **Sandbox OSM**: https://master.apis.dev.openstreetmap.org/oauth2/applications
**Configuration:**
```bash
cp lib/keys.dart.example lib/keys.dart
# Edit keys.dart with your OAuth2 client IDs
```
### iOS Setup
```bash
cd ios && pod install
```
### Running
```bash
flutter pub get
flutter run
```
### Testing
```bash
# Run all tests
flutter test
# Run with coverage
flutter test --coverage
```
---
## Code Organization
```
lib/
├── models/ # Data classes
│ ├── osm_camera_node.dart
│ ├── pending_upload.dart
│ └── node_profile.dart
├── services/ # Business logic
│ ├── map_data_provider.dart
│ ├── uploader.dart
│ └── node_cache.dart
├── state/ # State management
│ ├── app_state.dart
│ ├── auth_state.dart
│ └── upload_queue_state.dart
├── widgets/ # UI components
│ ├── map_view.dart
│ ├── edit_node_sheet.dart
│ └── map/ # Map-specific widgets
├── screens/ # Full screens
│ ├── home_screen.dart
│ └── settings_screen.dart
└── localizations/ # i18n strings
├── en.json
├── de.json
├── es.json
└── fr.json
```
**Principles:**
- **Models**: Pure data, no business logic
- **Services**: Stateless business logic
- **State**: Stateful coordination
- **Widgets**: UI only, delegate to AppState
- **Screens**: Compose widgets, handle navigation
---
## Debugging Tips
### Common Issues
**Nodes not appearing:**
- Check zoom level (≥10 production, ≥13 sandbox)
- Check upload mode vs expected data source
- Check network connectivity
- Look for console errors
**Upload failures:**
- Verify OAuth2 credentials
- Check upload mode matches login (production vs sandbox)
- Ensure node has required tags
- Check network connectivity
**Cache issues:**
- Clear app data to reset cache
- Check if offline mode is affecting behavior
- Verify upload mode switches clear cache
### Debug Logging
**Enable verbose logging:**
```dart
debugPrint('[ComponentName] Detailed message: $data');
```
**Key areas to log:**
- Network requests and responses
- Cache operations
- State transitions
- User actions
### Performance
**Monitor:**
- Memory usage during large node fetches
- UI responsiveness during background uploads
- Battery usage during GPS tracking
---
This documentation should be updated as the architecture evolves. When making significant changes, update both the relevant section here and add a brief note explaining the rationale for the change.

View File

@@ -25,11 +25,11 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
- **Multi-source tiles**: Switch between OpenStreetMap, Google Satellite, Esri imagery, Mapbox, 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
- **Device visualization**: Color-coded markers showing real devices (blue), pending uploads (purple), new devices (white), edited devices (grey), and devices being edited (orange)
- **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
- **Editing capabilities**: Update location, direction, and tags of existing devices
- **Full CRUD operations**: Create, edit, and delete surveillance devices
- **Direction visualization**: Interactive field-of-view cones showing camera viewing angles
- **Bulk operations**: Tag multiple devices efficiently with profile-based workflow
@@ -52,7 +52,8 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
1. **Install** the app on iOS or Android
2. **Enable location** permissions
3. **Log into OpenStreetMap**: Choose upload mode and get OAuth2 credentials
4. **Add your first device**: Tap the "tag 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, 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.
@@ -60,50 +61,42 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
## For Developers
### Architecture Highlights
- **Unified data provider**: All map tiles and surveillance device data route through `MapDataProvider` with pluggable remote/local sources
- **Modular settings**: Each settings section is a separate widget for maintainability
- **State management**: Provider pattern with clean separation of concerns
- **Offline-first**: Network calls are optional; app functions fully offline with downloaded data and queues uploads until online
### Build Setup
**Prerequisites**: Flutter SDK, Xcode (iOS), Android Studio
**OAuth Setup**: Register apps at [openstreetmap.org/oauth2](https://www.openstreetmap.org/oauth2/applications) and [OSM Sandbox](https://master.apis.dev.openstreetmap.org/oauth2/applications) to get a client ID
**See [DEVELOPER.md](DEVELOPER.md)** for comprehensive technical documentation including:
- Architecture overview and design decisions
- Development setup and build instructions
- Code organization and contribution guidelines
- Debugging tips and troubleshooting
**Quick setup:**
```shell
# Basic setup
flutter pub get
cp lib/keys.dart.example lib/keys.dart
# Add your OAuth2 client IDs to keys.dart
# iOS additional setup
cd ios && pod install
# Run
flutter run
# Add OAuth2 client IDs, then: flutter run
```
---
## Roadmap
### v1 todo/bug List
- Fix "tiles loaded" indicator accuracy across different providers
- Generic tile provider error messages (not always "OSM tiles slow")
- Optional custom icons for camera profiles
- Camera deletions
- Clean up cache when submitted changesets appear in Overpass results
- Upgrade device marker design (considering nullplate's svg)
### Current Development
- Clean cache when nodes have disappeared / been deletd by others
- Clean up dev_config
- Improve offline area node refresh live display
- Add default operator profiles (Lowes etc)
### Future Features & Wishlist
- Location-based notifications when approaching surveillance devices
- Red/yellow ring for devices missing specific tag details
- iOS/Android native themes and dark mode support
- "Cache accumulating" offline areas?
- "Offline areas" as tile provider?
- Update offline area nodes while browsing?
- Suspected locations toggle (alprwatch.com/flock/utilities)
- Jump to location by coordinates, address, or POI name
- Route planning that avoids surveillance devices
- Custom device providers and OSM/Overpass alternatives
- Route planning that avoids surveillance devices (alprwatch.com/directions)
### 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?
---

View File

@@ -17,6 +17,7 @@ android {
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
isCoreLibraryDesugaringEnabled = true
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.toString()
@@ -50,3 +51,7 @@ flutter {
source = "../.."
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
}

View File

@@ -6,6 +6,9 @@
<!-- Location permissions for bluedot positioning -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<!-- Notification permission for proximity alerts -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application
android:name="${applicationName}"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 435 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 805 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 69 B

View File

@@ -1,7 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/launch_background" />
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 757 KiB

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -1,16 +1,53 @@
#!/bin/bash
appver=$(cat lib/dev_config.dart | grep "kClientVersion" | cut -d '=' -f 2 | tr -d ';' | tr -d "\'" | tr -d " ")
# Default options
BUILD_IOS=true
BUILD_ANDROID=true
# Parse arguments
for arg in "$@"; do
case $arg in
--ios)
BUILD_ANDROID=false
;;
--android)
BUILD_IOS=false
;;
*)
echo "Usage: $0 [--ios | --android]"
echo " --ios Build only iOS"
echo " --android Build only Android"
echo " (default builds both)"
exit 1
;;
esac
done
appver=$(grep "kClientVersion" lib/dev_config.dart | cut -d '=' -f 2 | tr -d ';' | tr -d "\'" | tr -d " ")
echo
echo "Building app version ${appver}..."
flutter build ios --no-codesign
flutter build apk
echo
echo "Converting .app to .ipa..."
./app2ipa.sh build/ios/iphoneos/Runner.app
echo
echo "Moving files..."
cp build/app/outputs/flutter-apk/app-release.apk ../flockmap_v${appver}.apk
mv Runner.ipa ../flockmap_v${appver}.ipa
echo
if [ "$BUILD_IOS" = true ]; then
echo "Building iOS..."
flutter build ios --no-codesign || exit 1
echo "Converting .app to .ipa..."
./app2ipa.sh build/ios/iphoneos/Runner.app || exit 1
echo "Moving iOS files..."
mv Runner.ipa "../deflock_v${appver}.ipa" || exit 1
echo
fi
if [ "$BUILD_ANDROID" = true ]; then
echo "Building Android..."
flutter build apk || exit 1
echo "Moving Android files..."
cp build/app/outputs/flutter-apk/app-release.apk "../deflock_v${appver}.apk" || exit 1
echo
fi
echo "Done."

View File

@@ -512,7 +512,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.flockMapApp.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = me.deflock.deflockapp.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -659,7 +659,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.flockMapApp;
PRODUCT_BUNDLE_IDENTIFIER = me.deflock.deflockapp;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";

Binary file not shown.

Before

Width:  |  Height:  |  Size: 805 KiB

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 875 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 435 KiB

After

Width:  |  Height:  |  Size: 57 KiB

View File

@@ -16,13 +16,19 @@
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleToFill" image="LaunchBackground" translatesAutoresizingMaskIntoConstraints="NO" id="tWc-Dq-wcI"/>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"></imageView>
</subviews>
<color key="backgroundColor" red="0.125" green="0.125" blue="0.125" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="3T2-ad-Qdv"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="RPx-PI-7Xg"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="SdS-ul-q2q"/>
<constraint firstAttribute="trailing" secondItem="tWc-Dq-wcI" secondAttribute="trailing" id="Swv-Gf-Rwn"/>
<constraint firstAttribute="trailing" secondItem="YRO-k0-Ey4" secondAttribute="trailing" id="TQA-XW-tRk"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="duK-uY-Gun"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="kV7-tw-vXt"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="xPn-NY-SIU"/>
</constraints>
</view>
</viewController>
@@ -32,6 +38,7 @@
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
<image name="LaunchImage" width="512" height="512"/>
<image name="LaunchBackground" width="1" height="1"/>
</resources>
</document>

View File

@@ -4,10 +4,13 @@ import 'package:latlong2/latlong.dart';
import 'models/node_profile.dart';
import 'models/operator_profile.dart';
import 'models/osm_camera_node.dart';
import 'models/osm_node.dart';
import 'models/pending_upload.dart';
import 'models/tile_provider.dart';
import 'services/offline_area_service.dart';
import 'services/node_cache.dart';
import 'services/tile_preview_service.dart';
import 'widgets/camera_provider_with_cache.dart';
import 'state/auth_state.dart';
import 'state/operator_profile_state.dart';
import 'state/profile_state.dart';
@@ -77,6 +80,8 @@ class AppState extends ChangeNotifier {
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;
// Tile provider state
List<TileProvider> get tileProviders => _settingsState.tileProviders;
@@ -97,6 +102,10 @@ class AppState extends ChangeNotifier {
Future<void> _init() async {
// Initialize all state modules
await _settingsState.init();
// Attempt to fetch missing tile type preview tiles (fails silently)
_fetchMissingTilePreviews();
await _operatorProfileState.init();
await _profileState.init();
await _uploadQueueState.init();
@@ -160,7 +169,7 @@ class AppState extends ChangeNotifier {
_sessionState.startAddSession(enabledProfiles);
}
void startEditSession(OsmCameraNode node) {
void startEditSession(OsmNode node) {
_sessionState.startEditSession(node, enabledProfiles);
}
@@ -216,6 +225,11 @@ class AppState extends ChangeNotifier {
}
}
void deleteNode(OsmNode node) {
_uploadQueueState.addFromNodeDeletion(node, uploadMode: uploadMode);
_startUploader();
}
// ---------- Settings Methods ----------
Future<void> setOfflineMode(bool enabled) async {
await _settingsState.setOfflineMode(enabled);
@@ -233,6 +247,11 @@ class AppState extends ChangeNotifier {
}
Future<void> setUploadMode(UploadMode mode) async {
// Clear node cache when switching upload modes to prevent mixing production/sandbox data
NodeCache.instance.clear();
CameraProviderWithCache.instance.notifyListeners();
debugPrint('[AppState] Cleared node cache due to upload mode change');
await _settingsState.setUploadMode(mode);
await _authState.onUploadModeChanged(mode);
_startUploader(); // Restart uploader with new mode
@@ -258,6 +277,16 @@ class AppState extends ChangeNotifier {
await _settingsState.setFollowMeMode(mode);
}
/// Set proximity alerts enabled/disabled
Future<void> setProximityAlertsEnabled(bool enabled) async {
await _settingsState.setProximityAlertsEnabled(enabled);
}
/// Set proximity alert distance
Future<void> setProximityAlertDistance(int distance) async {
await _settingsState.setProximityAlertDistance(distance);
}
// ---------- Queue Methods ----------
void clearQueue() {
_uploadQueueState.clearQueue();
@@ -273,6 +302,15 @@ class AppState extends ChangeNotifier {
}
// ---------- Private Methods ----------
/// Attempts to fetch missing tile preview images in the background (fire and forget)
void _fetchMissingTilePreviews() {
// Run asynchronously without awaiting to avoid blocking app startup
TilePreviewService.fetchMissingPreviews(_settingsState).catchError((error) {
// Silently ignore errors - this is best effort
debugPrint('AppState: Tile preview fetching failed silently: $error');
});
}
void _startUploader() {
_uploadQueueState.startUploader(
offlineMode: offlineMode,

View File

@@ -2,34 +2,48 @@
import 'package:flutter/material.dart';
/// Developer/build-time configuration for global/non-user-tunable constants.
const int kWorldMinZoom = 1;
const int kWorldMaxZoom = 5;
// Example: Default tile storage estimate (KB per tile), for size estimates
const double kTileEstimateKb = 25.0;
// Fallback tile storage estimate (KB per tile), used when no preview tile data is available
const double kFallbackTileEstimateKb = 25.0;
// Preview tile coordinates for tile provider previews and size estimates
const int kPreviewTileZoom = 18;
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
// Margin (bottom) for positioning the floating bottom button bar
const double kBottomButtonBarMargin = 4.0;
// Bottom button bar positioning
const double kBottomButtonBarOffset = 4.0; // Distance from screen bottom (above safe area)
const double kButtonBarHeight = 60.0; // Button height (48) + padding (12)
// Map overlay (attribution, scale bar, zoom) vertical offset from bottom edge
const double kAttributionBottomOffset = 110.0;
const double kZoomIndicatorBottomOffset = 142.0;
const double kScaleBarBottomOffset = 170.0;
// Map overlay spacing relative to button bar top
const double kAttributionSpacingAboveButtonBar = 10.0; // Attribution above button bar top
const double kZoomIndicatorSpacingAboveButtonBar = 40.0; // Zoom indicator above button bar top
const double kScaleBarSpacingAboveButtonBar = 70.0; // Scale bar above button bar top
const double kZoomControlsSpacingAboveButtonBar = 20.0; // Zoom controls above button bar top
// Helper to calculate bottom position relative to button bar
double bottomPositionFromButtonBar(double spacingAboveButtonBar, double safeAreaBottom) {
return safeAreaBottom + kBottomButtonBarOffset + kButtonBarHeight + spacingAboveButtonBar;
}
// Add Camera icon vertical offset (no offset needed since circle is centered)
const double kAddPinYOffset = 0.0;
// Client name and version for OSM uploads ("created_by" tag)
const String kClientName = 'DeFlock';
const String kClientVersion = '0.9.8';
const String kClientVersion = '1.0.0';
// 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
// Marker/node interaction
const int kCameraMinZoomLevel = 10; // Minimum zoom to show nodes or warning
const int kCameraMinZoomLevel = 10; // Minimum zoom to show nodes (Overpass)
const int kOsmApiMinZoomLevel = 13; // Minimum zoom for OSM API bbox queries (sandbox mode)
const Duration kMarkerTapTimeout = Duration(milliseconds: 250);
const Duration kDebounceCameraRefresh = Duration(milliseconds: 500);
@@ -37,6 +51,12 @@ const Duration kDebounceCameraRefresh = Duration(milliseconds: 500);
const Duration kFollowMeAnimationDuration = Duration(milliseconds: 600);
const double kMinSpeedForRotationMps = 1.0; // Minimum speed (m/s) to apply rotation
// 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
// Last map location and settings storage
const String kLastMapLatKey = 'last_map_latitude';
const String kLastMapLngKey = 'last_map_longitude';
@@ -56,6 +76,7 @@ const int kMaxUserDownloadZoomSpan = 7;
// Download area limits and constants
const int kMaxReasonableTileCount = 20000;
const int kAbsoluteMaxTileCount = 50000;
const int kAbsoluteMaxZoom = 19;
// Camera icon configuration
@@ -67,3 +88,4 @@ const Color kCameraRingColorMock = Color(0xC4FFFFFF); // Add node mock point - w
const Color kCameraRingColorPending = Color(0xC49C27B0); // Submitted/pending nodes - purple
const Color kCameraRingColorEditing = Color(0xC4FF9800); // Node being edited - orange
const Color kCameraRingColorPendingEdit = Color(0xC4757575); // Original node with pending edit - grey
const Color kCameraRingColorPendingDeletion = Color(0xA4F44336); // Node pending deletion - red, slightly transparent

View File

@@ -6,10 +6,11 @@
"title": "DeFlock"
},
"actions": {
"tagNode": "Knoten Markieren",
"tagNode": "Neuer Knoten",
"download": "Herunterladen",
"settings": "Einstellungen",
"edit": "Bearbeiten",
"delete": "Löschen",
"cancel": "Abbrechen",
"ok": "OK",
"close": "Schließen",
@@ -28,20 +29,45 @@
"systemDefault": "Systemstandard",
"aboutInfo": "Über / Informationen",
"aboutThisApp": "Über Diese App",
"maxNodes": "Max. geladene/angezeigte Knoten",
"aboutSubtitle": "App-Informationen und Credits",
"languageSubtitle": "Wählen Sie Ihre bevorzugte Sprache",
"maxNodes": "Max. angezeigte Knoten",
"maxNodesSubtitle": "Obergrenze für die Anzahl der Knoten auf der Karte festlegen (Standard: 250).",
"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.",
"offlineModeWarningTitle": "Aktive Downloads",
"offlineModeWarningMessage": "Die Aktivierung des Offline-Modus bricht alle aktiven Bereichsdownloads ab. Möchten Sie fortfahren?",
"enableOfflineMode": "Offline-Modus Aktivieren"
"enableOfflineMode": "Offline-Modus Aktivieren",
"profiles": "Profile",
"profilesSubtitle": "Knoten- und Betreiberprofile verwalten",
"offlineSettings": "Offline-Einstellungen",
"offlineSettingsSubtitle": "Offline-Modus und heruntergeladene Bereiche verwalten",
"advancedSettings": "Erweiterte Einstellungen",
"advancedSettingsSubtitle": "Leistungs-, Warnungs- und Kachelanbieter-Einstellungen",
"proximityAlerts": "Näherungswarnungen"
},
"proximityAlerts": {
"getNotified": "Benachrichtigung erhalten beim Annähern an Überwachungsgeräte",
"batteryUsage": "Verbraucht zusätzlich Batterie für kontinuierliche Standortüberwachung",
"notificationsEnabled": "✓ Benachrichtigungen aktiviert",
"notificationsDisabled": "⚠ Benachrichtigungen deaktiviert",
"permissionRequired": "Benachrichtigungsberechtigung erforderlich",
"permissionExplanation": "Push-Benachrichtigungen sind deaktiviert. Sie sehen nur In-App-Warnungen und werden nicht benachrichtigt, wenn die App im Hintergrund läuft.",
"enableNotifications": "Benachrichtigungen Aktivieren",
"checkingPermissions": "Berechtigungen prüfen...",
"alertDistance": "Warnentfernung: ",
"meters": "Meter",
"rangeInfo": "Bereich: {}-{} Meter (Standard: {})"
},
"node": {
"title": "Knoten #{}",
"tagSheetTitle": "Gerät-Tags",
"queuedForUpload": "Knoten zum Upload eingereiht",
"editQueuedForUpload": "Knotenbearbeitung zum Upload eingereiht"
"editQueuedForUpload": "Knotenbearbeitung zum Upload eingereiht",
"deleteQueuedForUpload": "Knoten-Löschung zum Upload eingereiht",
"confirmDeleteTitle": "Knoten löschen",
"confirmDeleteMessage": "Sind Sie sicher, dass Sie Knoten #{} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden."
},
"addNode": {
"profile": "Profil",
@@ -75,7 +101,7 @@
"withinTileLimit": "Innerhalb {} Kachel-Limit",
"exceedsTileLimit": "Aktuelle Auswahl überschreitet {} Kachel-Limit",
"offlineModeWarning": "Downloads im Offline-Modus deaktiviert. Deaktivieren Sie den Offline-Modus, um neue Bereiche herunterzuladen.",
"downloadStarted": "Download gestartet! Lade Kacheln und Kameras...",
"downloadStarted": "Download gestartet! Lade Kacheln und Knoten...",
"downloadFailed": "Download konnte nicht gestartet werden: {}"
},
"uploadMode": {
@@ -86,7 +112,6 @@
"simulate": "Simulieren",
"productionDescription": "Hochladen in die Live-OSM-Datenbank (für alle Benutzer sichtbar)",
"sandboxDescription": "Uploads gehen an die OSM Sandbox (sicher zum Testen, wird regelmäßig zurückgesetzt).",
"sandboxNote": "HINWEIS: Aufgrund von OpenStreetMap-Limitierungen werden Kameras, die an die Sandbox übermittelt werden, NICHT in der Karte dieser App angezeigt.",
"simulateDescription": "Uploads simulieren (kontaktiert OSM-Server nicht)"
},
"auth": {
@@ -211,5 +236,56 @@
"deleteOperatorProfile": "Betreiber-Profil Löschen",
"deleteOperatorProfileConfirm": "Sind Sie sicher, dass Sie \"{}\" löschen möchten?",
"operatorProfileDeleted": "Betreiber-Profil gelöscht"
},
"offlineAreas": {
"title": "Offline-Bereiche",
"noAreasTitle": "Keine Offline-Bereiche",
"noAreasSubtitle": "Laden Sie einen Kartenbereich für die Offline-Nutzung herunter.",
"provider": "Anbieter",
"maxZoom": "Max Zoom",
"zoomLevels": "Z{}-{}",
"latitude": "Breite",
"longitude": "Länge",
"tiles": "Kacheln",
"size": "Größe",
"nodes": "Knoten",
"areaIdFallback": "Bereich {}...",
"renameArea": "Bereich umbenennen",
"refreshWorldTiles": "Welt-Kacheln aktualisieren/neu herunterladen",
"deleteOfflineArea": "Offline-Bereich löschen",
"cancelDownload": "Download abbrechen",
"renameAreaDialogTitle": "Offline-Bereich Umbenennen",
"areaNameLabel": "Bereichsname",
"renameButton": "Umbenennen",
"megabytes": "MB",
"kilobytes": "KB",
"progress": "{}%",
"refreshArea": "Bereich aktualisieren",
"refreshAreaDialogTitle": "Offline-Bereich aktualisieren",
"refreshAreaDialogSubtitle": "Wählen Sie aus, was für diesen Bereich aktualisiert werden soll:",
"refreshTiles": "Karten-Kacheln aktualisieren",
"refreshTilesSubtitle": "Alle Kacheln für aktualisierte Bilder erneut herunterladen",
"refreshNodes": "Knoten aktualisieren",
"refreshNodesSubtitle": "Knotendaten für diesen Bereich erneut abrufen",
"startRefresh": "Aktualisierung starten",
"refreshStarted": "Aktualisierung gestartet!",
"refreshFailed": "Aktualisierung fehlgeschlagen: {}"
},
"refineTagsSheet": {
"title": "Tags Verfeinern",
"operatorProfile": "Betreiber-Profil",
"done": "Fertig",
"none": "Keine",
"noAdditionalOperatorTags": "Keine zusätzlichen Betreiber-Tags",
"additionalTags": "zusätzliche Tags",
"additionalTagsTitle": "Zusätzliche Tags",
"noTagsDefinedForProfile": "Keine Tags für dieses Betreiber-Profil definiert.",
"noOperatorProfiles": "Keine Betreiber-Profile definiert",
"noOperatorProfilesMessage": "Erstellen Sie Betreiber-Profile in den Einstellungen, um zusätzliche Tags auf Ihre Knoten-Übertragungen anzuwenden."
},
"layerSelector": {
"cannotChangeTileTypes": "Kachel-Typen können während des Herunterladens von Offline-Bereichen nicht geändert werden",
"selectMapLayer": "Kartenschicht Auswählen",
"noTileProvidersAvailable": "Keine Kachel-Anbieter verfügbar"
}
}

View File

@@ -6,10 +6,11 @@
"title": "DeFlock"
},
"actions": {
"tagNode": "Tag Node",
"tagNode": "New Node",
"download": "Download",
"settings": "Settings",
"edit": "Edit",
"delete": "Delete",
"cancel": "Cancel",
"ok": "OK",
"close": "Close",
@@ -28,20 +29,45 @@
"systemDefault": "System Default",
"aboutInfo": "About / Info",
"aboutThisApp": "About This App",
"maxNodes": "Max nodes fetched/drawn",
"aboutSubtitle": "App information and credits",
"languageSubtitle": "Choose your preferred language",
"maxNodes": "Max nodes drawn",
"maxNodesSubtitle": "Set an upper limit for the number of nodes on the map (default: 250).",
"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.",
"offlineModeWarningTitle": "Active Downloads",
"offlineModeWarningMessage": "Enabling offline mode will cancel any active area downloads. Do you want to continue?",
"enableOfflineMode": "Enable Offline Mode"
"enableOfflineMode": "Enable Offline Mode",
"profiles": "Profiles",
"profilesSubtitle": "Manage node and operator profiles",
"offlineSettings": "Offline Settings",
"offlineSettingsSubtitle": "Manage offline mode and downloaded areas",
"advancedSettings": "Advanced Settings",
"advancedSettingsSubtitle": "Performance, alerts, and tile provider settings",
"proximityAlerts": "Proximity Alerts"
},
"proximityAlerts": {
"getNotified": "Get notified when approaching surveillance devices",
"batteryUsage": "Uses extra battery for continuous location monitoring",
"notificationsEnabled": "✓ Notifications enabled",
"notificationsDisabled": "⚠ Notifications disabled",
"permissionRequired": "Notification permission required",
"permissionExplanation": "Push notifications are disabled. You'll only see in-app alerts and won't be notified when the app is in background.",
"enableNotifications": "Enable Notifications",
"checkingPermissions": "Checking permissions...",
"alertDistance": "Alert distance: ",
"meters": "meters",
"rangeInfo": "Range: {}-{} meters (default: {})"
},
"node": {
"title": "Node #{}",
"tagSheetTitle": "Surveillance Device Tags",
"queuedForUpload": "Node queued for upload",
"editQueuedForUpload": "Node edit queued for upload"
"editQueuedForUpload": "Node edit queued for upload",
"deleteQueuedForUpload": "Node deletion queued for upload",
"confirmDeleteTitle": "Delete Node",
"confirmDeleteMessage": "Are you sure you want to delete node #{}? This action cannot be undone."
},
"addNode": {
"profile": "Profile",
@@ -75,7 +101,7 @@
"withinTileLimit": "Within {} tile limit",
"exceedsTileLimit": "Current selection exceeds {} tile limit",
"offlineModeWarning": "Downloads disabled while in offline mode. Disable offline mode to download new areas.",
"downloadStarted": "Download started! Fetching tiles and cameras...",
"downloadStarted": "Download started! Fetching tiles and nodes...",
"downloadFailed": "Failed to start download: {}"
},
"uploadMode": {
@@ -86,7 +112,6 @@
"simulate": "Simulate",
"productionDescription": "Upload to the live OSM database (visible to all users)",
"sandboxDescription": "Uploads go to the OSM Sandbox (safe for testing, resets regularly).",
"sandboxNote": "NOTE: Due to OpenStreetMap limitations, cameras submitted to the sandbox will NOT appear on the map in this app.",
"simulateDescription": "Simulate uploads (does not contact OSM servers)"
},
"auth": {
@@ -211,5 +236,56 @@
"deleteOperatorProfile": "Delete Operator Profile",
"deleteOperatorProfileConfirm": "Are you sure you want to delete \"{}\"?",
"operatorProfileDeleted": "Operator profile deleted"
},
"offlineAreas": {
"title": "Offline Areas",
"noAreasTitle": "No offline areas",
"noAreasSubtitle": "Download a map area for offline use.",
"provider": "Provider",
"maxZoom": "Max zoom",
"zoomLevels": "Z{}-{}",
"latitude": "Lat",
"longitude": "Lon",
"tiles": "Tiles",
"size": "Size",
"nodes": "Nodes",
"areaIdFallback": "Area {}...",
"renameArea": "Rename area",
"refreshWorldTiles": "Refresh/re-download world tiles",
"deleteOfflineArea": "Delete offline area",
"cancelDownload": "Cancel download",
"renameAreaDialogTitle": "Rename Offline Area",
"areaNameLabel": "Area Name",
"renameButton": "Rename",
"megabytes": "MB",
"kilobytes": "KB",
"progress": "{}%",
"refreshArea": "Refresh area",
"refreshAreaDialogTitle": "Refresh Offline Area",
"refreshAreaDialogSubtitle": "Choose what to refresh for this area:",
"refreshTiles": "Refresh Map Tiles",
"refreshTilesSubtitle": "Re-download all tiles for updated imagery",
"refreshNodes": "Refresh Nodes",
"refreshNodesSubtitle": "Re-fetch node data for this area",
"startRefresh": "Start Refresh",
"refreshStarted": "Refresh started!",
"refreshFailed": "Refresh failed: {}"
},
"refineTagsSheet": {
"title": "Refine Tags",
"operatorProfile": "Operator Profile",
"done": "Done",
"none": "None",
"noAdditionalOperatorTags": "No additional operator tags",
"additionalTags": "additional tags",
"additionalTagsTitle": "Additional Tags",
"noTagsDefinedForProfile": "No tags defined for this operator profile.",
"noOperatorProfiles": "No operator profiles defined",
"noOperatorProfilesMessage": "Create operator profiles in Settings to apply additional tags to your node submissions."
},
"layerSelector": {
"cannotChangeTileTypes": "Cannot change tile types while downloading offline areas",
"selectMapLayer": "Select Map Layer",
"noTileProvidersAvailable": "No tile providers available"
}
}

View File

@@ -6,10 +6,11 @@
"title": "DeFlock"
},
"actions": {
"tagNode": "Etiquetar Nodo",
"tagNode": "Nuevo Nodo",
"download": "Descargar",
"settings": "Configuración",
"edit": "Editar",
"delete": "Eliminar",
"cancel": "Cancelar",
"ok": "Aceptar",
"close": "Cerrar",
@@ -28,20 +29,45 @@
"systemDefault": "Sistema por Defecto",
"aboutInfo": "Acerca de / Información",
"aboutThisApp": "Acerca de Esta App",
"maxNodes": "Máx. nodos obtenidos/dibujados",
"aboutSubtitle": "Información de la aplicación y créditos",
"languageSubtitle": "Elige tu idioma preferido",
"maxNodes": "Máx. nodos dibujados",
"maxNodesSubtitle": "Establecer un límite superior para el número de nodos en el mapa (predeterminado: 250).",
"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.",
"offlineModeWarningTitle": "Descargas Activas",
"offlineModeWarningMessage": "Habilitar el modo sin conexión cancelará cualquier descarga de área activa. ¿Desea continuar?",
"enableOfflineMode": "Habilitar Modo Sin Conexión"
"enableOfflineMode": "Habilitar Modo Sin Conexión",
"profiles": "Perfiles",
"profilesSubtitle": "Gestionar perfiles de nodos y operadores",
"offlineSettings": "Configuración Sin Conexión",
"offlineSettingsSubtitle": "Gestionar modo sin conexión y áreas descargadas",
"advancedSettings": "Configuración Avanzada",
"advancedSettingsSubtitle": "Configuración de rendimiento, alertas y proveedores de teselas",
"proximityAlerts": "Alertas de Proximidad"
},
"proximityAlerts": {
"getNotified": "Recibe notificaciones al acercarte a dispositivos de vigilancia",
"batteryUsage": "Usa batería extra para monitoreo continuo de ubicación",
"notificationsEnabled": "✓ Notificaciones habilitadas",
"notificationsDisabled": "⚠ Notificaciones deshabilitadas",
"permissionRequired": "Permiso de notificación requerido",
"permissionExplanation": "Las notificaciones push están deshabilitadas. Solo verás alertas dentro de la app y no serás notificado cuando la app esté en segundo plano.",
"enableNotifications": "Habilitar Notificaciones",
"checkingPermissions": "Verificando permisos...",
"alertDistance": "Distancia de alerta: ",
"meters": "metros",
"rangeInfo": "Rango: {}-{} metros (predeterminado: {})"
},
"node": {
"title": "Nodo #{}",
"tagSheetTitle": "Etiquetas del Dispositivo",
"queuedForUpload": "Nodo en cola para subir",
"editQueuedForUpload": "Edición de nodo en cola para subir"
"editQueuedForUpload": "Edición de nodo en cola para subir",
"deleteQueuedForUpload": "Eliminación de nodo en cola para subir",
"confirmDeleteTitle": "Eliminar Nodo",
"confirmDeleteMessage": "¿Estás seguro de que quieres eliminar el nodo #{}? Esta acción no se puede deshacer."
},
"addNode": {
"profile": "Perfil",
@@ -75,7 +101,7 @@
"withinTileLimit": "Dentro del límite de {} mosaicos",
"exceedsTileLimit": "La selección actual excede el límite de {} mosaicos",
"offlineModeWarning": "Descargas deshabilitadas en modo sin conexión. Deshabilite el modo sin conexión para descargar nuevas áreas.",
"downloadStarted": "¡Descarga iniciada! Obteniendo mosaicos y cámaras...",
"downloadStarted": "¡Descarga iniciada! Obteniendo mosaicos y nodos...",
"downloadFailed": "Error al iniciar la descarga: {}"
},
"uploadMode": {
@@ -86,7 +112,6 @@
"simulate": "Simular",
"productionDescription": "Subir a la base de datos OSM en vivo (visible para todos los usuarios)",
"sandboxDescription": "Las subidas van al Sandbox de OSM (seguro para pruebas, se reinicia regularmente).",
"sandboxNote": "NOTA: Debido a las limitaciones de OpenStreetMap, las cámaras enviadas al sandbox NO aparecerán en el mapa de esta aplicación.",
"simulateDescription": "Simular subidas (no contacta servidores OSM)"
},
"auth": {
@@ -211,5 +236,56 @@
"deleteOperatorProfile": "Eliminar Perfil de Operador",
"deleteOperatorProfileConfirm": "¿Está seguro de que desea eliminar \"{}\"?",
"operatorProfileDeleted": "Perfil de operador eliminado"
},
"offlineAreas": {
"title": "Áreas Sin Conexión",
"noAreasTitle": "Sin áreas sin conexión",
"noAreasSubtitle": "Descarga un área del mapa para uso sin conexión.",
"provider": "Proveedor",
"maxZoom": "Zoom máx",
"zoomLevels": "Z{}-{}",
"latitude": "Lat",
"longitude": "Lon",
"tiles": "Teselas",
"size": "Tamaño",
"nodes": "Nodos",
"areaIdFallback": "Área {}...",
"renameArea": "Renombrar área",
"refreshWorldTiles": "Actualizar/re-descargar teselas mundiales",
"deleteOfflineArea": "Eliminar área sin conexión",
"cancelDownload": "Cancelar descarga",
"renameAreaDialogTitle": "Renombrar Área Sin Conexión",
"areaNameLabel": "Nombre del Área",
"renameButton": "Renombrar",
"megabytes": "MB",
"kilobytes": "KB",
"progress": "{}%",
"refreshArea": "Actualizar área",
"refreshAreaDialogTitle": "Actualizar Área sin Conexión",
"refreshAreaDialogSubtitle": "Elija qué actualizar para esta área:",
"refreshTiles": "Actualizar Mosaicos del Mapa",
"refreshTilesSubtitle": "Volver a descargar todos los mosaicos para imágenes actualizadas",
"refreshNodes": "Actualizar Nodos",
"refreshNodesSubtitle": "Volver a obtener datos de nodos para esta área",
"startRefresh": "Iniciar Actualización",
"refreshStarted": "¡Actualización iniciada!",
"refreshFailed": "Actualización falló: {}"
},
"refineTagsSheet": {
"title": "Refinar Etiquetas",
"operatorProfile": "Perfil de Operador",
"done": "Listo",
"none": "Ninguno",
"noAdditionalOperatorTags": "Sin etiquetas adicionales de operador",
"additionalTags": "etiquetas adicionales",
"additionalTagsTitle": "Etiquetas Adicionales",
"noTagsDefinedForProfile": "No hay etiquetas definidas para este perfil de operador.",
"noOperatorProfiles": "No hay perfiles de operador definidos",
"noOperatorProfilesMessage": "Cree perfiles de operador en Configuración para aplicar etiquetas adicionales a sus envíos de nodos."
},
"layerSelector": {
"cannotChangeTileTypes": "No se pueden cambiar los tipos de teselas mientras se descargan áreas sin conexión",
"selectMapLayer": "Seleccionar Capa del Mapa",
"noTileProvidersAvailable": "No hay proveedores de teselas disponibles"
}
}

View File

@@ -6,10 +6,11 @@
"title": "DeFlock"
},
"actions": {
"tagNode": "Marquer Nœud",
"tagNode": "Nouveau Nœud",
"download": "Télécharger",
"settings": "Paramètres",
"edit": "Modifier",
"delete": "Supprimer",
"cancel": "Annuler",
"ok": "OK",
"close": "Fermer",
@@ -28,20 +29,45 @@
"systemDefault": "Par Défaut du Système",
"aboutInfo": "À Propos / Informations",
"aboutThisApp": "À Propos de Cette App",
"maxNodes": "Max. nœuds récupérés/dessinés",
"aboutSubtitle": "Informations sur l'application et crédits",
"languageSubtitle": "Choisissez votre langue préférée",
"maxNodes": "Max. nœuds dessinés",
"maxNodesSubtitle": "Définir une limite supérieure pour le nombre de nœuds sur la carte (par défaut: 250).",
"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.",
"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"
"enableOfflineMode": "Activer le Mode Hors Ligne",
"profiles": "Profils",
"profilesSubtitle": "Gérer les profils de nœuds et d'opérateurs",
"offlineSettings": "Paramètres Hors Ligne",
"offlineSettingsSubtitle": "Gérer le mode hors ligne et les zones téléchargées",
"advancedSettings": "Paramètres Avancés",
"advancedSettingsSubtitle": "Paramètres de performance, alertes et fournisseurs de tuiles",
"proximityAlerts": "Alertes de Proximité"
},
"proximityAlerts": {
"getNotified": "Recevoir des notifications en s'approchant de dispositifs de surveillance",
"batteryUsage": "Utilise de la batterie supplémentaire pour la surveillance continue de la localisation",
"notificationsEnabled": "✓ Notifications activées",
"notificationsDisabled": "⚠ Notifications désactivées",
"permissionRequired": "Autorisation de notification requise",
"permissionExplanation": "Les notifications push sont désactivées. Vous ne verrez que des alertes dans l'application et ne serez pas notifié lorsque l'application est en arrière-plan.",
"enableNotifications": "Activer les Notifications",
"checkingPermissions": "Vérification des autorisations...",
"alertDistance": "Distance d'alerte : ",
"meters": "mètres",
"rangeInfo": "Plage : {}-{} mètres (par défaut : {})"
},
"node": {
"title": "Nœud #{}",
"tagSheetTitle": "Balises du Dispositif",
"queuedForUpload": "Nœud mis en file pour envoi",
"editQueuedForUpload": "Modification de nœud mise en file pour envoi"
"editQueuedForUpload": "Modification de nœud mise en file pour envoi",
"deleteQueuedForUpload": "Suppression de nœud mise en file pour envoi",
"confirmDeleteTitle": "Supprimer le Nœud",
"confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer le nœud #{} ? Cette action ne peut pas être annulée."
},
"addNode": {
"profile": "Profil",
@@ -75,7 +101,7 @@
"withinTileLimit": "Dans la limite de {} tuiles",
"exceedsTileLimit": "La sélection actuelle dépasse la limite de {} tuiles",
"offlineModeWarning": "Téléchargements désactivés en mode hors ligne. Désactivez le mode hors ligne pour télécharger de nouvelles zones.",
"downloadStarted": "Téléchargement démarré! Récupération des tuiles et caméras...",
"downloadStarted": "Téléchargement démarré! Récupération des tuiles et nœuds...",
"downloadFailed": "Échec du démarrage du téléchargement: {}"
},
"uploadMode": {
@@ -86,7 +112,6 @@
"simulate": "Simuler",
"productionDescription": "Télécharger vers la base de données OSM en direct (visible pour tous les utilisateurs)",
"sandboxDescription": "Les téléchargements vont vers le Sandbox OSM (sûr pour les tests, réinitialisé régulièrement).",
"sandboxNote": "NOTE: En raison des limitations d'OpenStreetMap, les caméras soumises au sandbox n'apparaîtront PAS sur la carte dans cette application.",
"simulateDescription": "Simuler les téléchargements (ne contacte pas les serveurs OSM)"
},
"auth": {
@@ -211,5 +236,56 @@
"deleteOperatorProfile": "Supprimer Profil d'Opérateur",
"deleteOperatorProfileConfirm": "Êtes-vous sûr de vouloir supprimer \"{}\"?",
"operatorProfileDeleted": "Profil d'opérateur supprimé"
},
"offlineAreas": {
"title": "Zones Hors Ligne",
"noAreasTitle": "Aucune zone hors ligne",
"noAreasSubtitle": "Téléchargez une zone de carte pour utilisation hors ligne.",
"provider": "Fournisseur",
"maxZoom": "Zoom max",
"zoomLevels": "Z{}-{}",
"latitude": "Lat",
"longitude": "Lon",
"tiles": "Tuiles",
"size": "Taille",
"nodes": "Nœuds",
"areaIdFallback": "Zone {}...",
"renameArea": "Renommer la zone",
"refreshWorldTiles": "Actualiser/re-télécharger les tuiles mondiales",
"deleteOfflineArea": "Supprimer la zone hors ligne",
"cancelDownload": "Annuler le téléchargement",
"renameAreaDialogTitle": "Renommer la Zone Hors Ligne",
"areaNameLabel": "Nom de la Zone",
"renameButton": "Renommer",
"megabytes": "Mo",
"kilobytes": "Ko",
"progress": "{}%",
"refreshArea": "Actualiser la zone",
"refreshAreaDialogTitle": "Actualiser la Zone Hors Ligne",
"refreshAreaDialogSubtitle": "Choisissez quoi actualiser pour cette zone :",
"refreshTiles": "Actualiser les Tuiles de Carte",
"refreshTilesSubtitle": "Télécharger à nouveau toutes les tuiles pour des images mises à jour",
"refreshNodes": "Actualiser les Nœuds",
"refreshNodesSubtitle": "Récupérer à nouveau les données de nœuds pour cette zone",
"startRefresh": "Démarrer l'Actualisation",
"refreshStarted": "Actualisation démarrée !",
"refreshFailed": "Actualisation échouée : {}"
},
"refineTagsSheet": {
"title": "Affiner les Étiquettes",
"operatorProfile": "Profil d'Opérateur",
"done": "Terminé",
"none": "Aucun",
"noAdditionalOperatorTags": "Aucune étiquette d'opérateur supplémentaire",
"additionalTags": "étiquettes supplémentaires",
"additionalTagsTitle": "Étiquettes Supplémentaires",
"noTagsDefinedForProfile": "Aucune étiquette définie pour ce profil d'opérateur.",
"noOperatorProfiles": "Aucun profil d'opérateur défini",
"noOperatorProfilesMessage": "Créez des profils d'opérateur dans les Paramètres pour appliquer des étiquettes supplémentaires à vos soumissions de nœuds."
},
"layerSelector": {
"cannotChangeTileTypes": "Impossible de changer les types de tuiles pendant le téléchargement des zones hors ligne",
"selectMapLayer": "Sélectionner la Couche de Carte",
"noTileProvidersAvailable": "Aucun fournisseur de tuiles disponible"
}
}

291
lib/localizations/it.json Normal file
View File

@@ -0,0 +1,291 @@
{
"language": {
"name": "Italiano"
},
"app": {
"title": "DeFlock"
},
"actions": {
"tagNode": "Nuovo Nodo",
"download": "Scarica",
"settings": "Impostazioni",
"edit": "Modifica",
"delete": "Elimina",
"cancel": "Annulla",
"ok": "OK",
"close": "Chiudi",
"submit": "Invia",
"saveEdit": "Salva Modifica",
"clear": "Pulisci"
},
"followMe": {
"off": "Attiva seguimi (nord in alto)",
"northUp": "Attiva seguimi (rotazione)",
"rotating": "Disattiva seguimi"
},
"settings": {
"title": "Impostazioni",
"language": "Lingua",
"systemDefault": "Predefinito del Sistema",
"aboutInfo": "Informazioni",
"aboutThisApp": "Informazioni su questa App",
"aboutSubtitle": "Informazioni sull'applicazione e crediti",
"languageSubtitle": "Scegli la tua lingua preferita",
"maxNodes": "Max nodi disegnati",
"maxNodesSubtitle": "Imposta un limite superiore per il numero di nodi sulla mappa (predefinito: 250).",
"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.",
"offlineModeWarningTitle": "Download Attivi",
"offlineModeWarningMessage": "L'attivazione della modalità offline cancellerà qualsiasi download di area attivo. Vuoi continuare?",
"enableOfflineMode": "Attiva Modalità Offline",
"profiles": "Profili",
"profilesSubtitle": "Gestisci profili di nodi e operatori",
"offlineSettings": "Impostazioni Offline",
"offlineSettingsSubtitle": "Gestisci modalità offline e aree scaricate",
"advancedSettings": "Impostazioni Avanzate",
"advancedSettingsSubtitle": "Impostazioni di prestazioni, avvisi e fornitori di tessere",
"proximityAlerts": "Avvisi di Prossimità"
},
"proximityAlerts": {
"getNotified": "Ricevi notifiche quando ti avvicini a dispositivi di sorveglianza",
"batteryUsage": "Utilizza batteria extra per il monitoraggio continuo della posizione",
"notificationsEnabled": "✓ Notifiche abilitate",
"notificationsDisabled": "⚠ Notifiche disabilitate",
"permissionRequired": "Autorizzazione notifica richiesta",
"permissionExplanation": "Le notifiche push sono disabilitate. Vedrai solo avvisi nell'app e non sarai notificato quando l'app è in background.",
"enableNotifications": "Abilita Notifiche",
"checkingPermissions": "Controllo autorizzazioni...",
"alertDistance": "Distanza di avviso: ",
"meters": "metri",
"rangeInfo": "Intervallo: {}-{} metri (predefinito: {})"
},
"node": {
"title": "Nodo #{}",
"tagSheetTitle": "Tag Dispositivo di Sorveglianza",
"queuedForUpload": "Nodo in coda per il caricamento",
"editQueuedForUpload": "Modifica nodo in coda per il caricamento",
"deleteQueuedForUpload": "Eliminazione nodo in coda per il caricamento",
"confirmDeleteTitle": "Elimina Nodo",
"confirmDeleteMessage": "Sei sicuro di voler eliminare il nodo #{}? Questa azione non può essere annullata."
},
"addNode": {
"profile": "Profilo",
"direction": "Direzione {}°",
"profileNoDirectionInfo": "Questo profilo non richiede una direzione.",
"mustBeLoggedIn": "Devi essere loggato per inviare nuovi nodi. Per favore accedi tramite Impostazioni.",
"enableSubmittableProfile": "Abilita un profilo inviabile nelle Impostazioni per inviare nuovi nodi.",
"profileViewOnlyWarning": "Questo profilo è solo per la visualizzazione della mappa. Per favore seleziona un profilo inviabile per inviare nuovi nodi.",
"refineTags": "Affina Tag",
"refineTagsWithProfile": "Affina Tag ({})"
},
"editNode": {
"title": "Modifica Nodo #{}",
"profile": "Profilo",
"direction": "Direzione {}°",
"profileNoDirectionInfo": "Questo profilo non richiede una direzione.",
"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.",
"refineTags": "Affina Tag",
"refineTagsWithProfile": "Affina Tag ({})"
},
"download": {
"title": "Scarica Area Mappa",
"maxZoomLevel": "Livello zoom max",
"storageEstimate": "Stima archiviazione:",
"tilesAndSize": "{} tile, {} MB",
"minZoom": "Zoom min:",
"maxRecommendedZoom": "Zoom max raccomandato: Z{}",
"withinTileLimit": "Entro il limite di {} tile",
"exceedsTileLimit": "La selezione corrente supera il limite di {} tile",
"offlineModeWarning": "Download disabilitati in modalità offline. Disabilita la modalità offline per scaricare nuove aree.",
"downloadStarted": "Download avviato! Recupero tile e nodi...",
"downloadFailed": "Impossibile avviare il download: {}"
},
"uploadMode": {
"title": "Destinazione Upload",
"subtitle": "Scegli dove vengono caricate le telecamere",
"production": "Produzione",
"sandbox": "Sandbox",
"simulate": "Simula",
"productionDescription": "Carica nel database OSM dal vivo (visibile a tutti gli utenti)",
"sandboxDescription": "Gli upload vanno alla Sandbox OSM (sicuro per i test, si resetta regolarmente).",
"simulateDescription": "Simula upload (non contatta i server OSM)"
},
"auth": {
"loggedInAs": "Loggato come {}",
"loginToOSM": "Accedi a OpenStreetMap",
"tapToLogout": "Tocca per disconnetterti",
"requiredToSubmit": "Richiesto per inviare dati delle telecamere",
"loggedOut": "Disconnesso",
"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"
},
"queue": {
"pendingUploads": "Upload in sospeso: {}",
"simulateModeEnabled": "Modalità simulazione abilitata upload simulati",
"sandboxMode": "Modalità sandbox upload vanno alla Sandbox OSM",
"tapToViewQueue": "Tocca per vedere la coda",
"clearUploadQueue": "Pulisci Coda Upload",
"removeAllPending": "Rimuovi tutti i {} upload in sospeso",
"clearQueueTitle": "Pulisci Coda",
"clearQueueConfirm": "Rimuovere tutti i {} upload in sospeso?",
"queueCleared": "Coda pulita",
"uploadQueueTitle": "Coda Upload ({} elementi)",
"queueIsEmpty": "La coda è vuota",
"cameraWithIndex": "Telecamera {}",
"error": " (Errore)",
"completing": " (Completamento...)",
"destination": "Dest: {}",
"latitude": "Lat: {}",
"longitude": "Lon: {}",
"direction": "Direzione: {}°",
"attempts": "Tentativi: {}",
"uploadFailedRetry": "Upload fallito. Tocca riprova per tentare di nuovo.",
"retryUpload": "Riprova upload",
"clearAll": "Pulisci Tutto"
},
"tileProviders": {
"title": "Fornitori di Tile",
"noProvidersConfigured": "Nessun fornitore di tile configurato",
"tileTypesCount": "{} tipi di tile",
"apiKeyConfigured": "Chiave API configurata",
"needsApiKey": "Richiede chiave API",
"editProvider": "Modifica Fornitore",
"addProvider": "Aggiungi Fornitore",
"deleteProvider": "Elimina Fornitore",
"deleteProviderConfirm": "Sei sicuro di voler eliminare \"{}\"?",
"providerName": "Nome Fornitore",
"providerNameHint": "es., Mappe Personalizzate Inc.",
"providerNameRequired": "Il nome del fornitore è obbligatorio",
"apiKey": "Chiave API (Opzionale)",
"apiKeyHint": "Inserisci la chiave API se richiesta dai tipi di tile",
"tileTypes": "Tipi di Tile",
"addType": "Aggiungi Tipo",
"noTileTypesConfigured": "Nessun tipo di tile configurato",
"atLeastOneTileTypeRequired": "È richiesto almeno un tipo di tile",
"manageTileProviders": "Gestisci Fornitori"
},
"tileTypeEditor": {
"editTileType": "Modifica Tipo Tile",
"addTileType": "Aggiungi Tipo Tile",
"name": "Nome",
"nameHint": "es., Satellite",
"nameRequired": "Il nome è obbligatorio",
"urlTemplate": "Template URL",
"urlTemplateHint": "https://esempio.com/{z}/{x}/{y}.png",
"urlTemplateRequired": "Il template URL è obbligatorio",
"urlTemplatePlaceholders": "L'URL deve contenere i segnaposto {z}, {x} e {y}",
"attribution": "Attribuzione",
"attributionHint": "© Fornitore Mappe",
"attributionRequired": "L'attribuzione è obbligatoria",
"fetchPreview": "Ottieni Anteprima",
"previewTileLoaded": "Tile di anteprima caricato con successo",
"previewTileFailed": "Impossibile ottenere l'anteprima: {}",
"save": "Salva"
},
"profiles": {
"nodeProfiles": "Profili Nodo",
"newProfile": "Nuovo Profilo",
"builtIn": "Integrato",
"custom": "Personalizzato",
"view": "Visualizza",
"deleteProfile": "Elimina Profilo",
"deleteProfileConfirm": "Sei sicuro di voler eliminare \"{}\"?",
"profileDeleted": "Profilo eliminato"
},
"mapTiles": {
"title": "Tile Mappa",
"manageProviders": "Gestisci Fornitori"
},
"profileEditor": {
"viewProfile": "Visualizza Profilo",
"newProfile": "Nuovo Profilo",
"editProfile": "Modifica Profilo",
"profileName": "Nome profilo",
"profileNameHint": "es., Telecamera ALPR Personalizzata",
"profileNameRequired": "Il nome del profilo è obbligatorio",
"requiresDirection": "Richiede Direzione",
"requiresDirectionSubtitle": "Se le telecamere di questo tipo necessitano di un tag direzione",
"submittable": "Inviabile",
"submittableSubtitle": "Se questo profilo può essere usato per invii di telecamere",
"osmTags": "Tag OSM",
"addTag": "Aggiungi Tag",
"saveProfile": "Salva Profilo",
"keyHint": "chiave",
"valueHint": "valore",
"atLeastOneTagRequired": "È richiesto almeno un tag",
"profileSaved": "Profilo \"{}\" salvato"
},
"operatorProfileEditor": {
"newOperatorProfile": "Nuovo Profilo Operatore",
"editOperatorProfile": "Modifica Profilo Operatore",
"operatorName": "Nome operatore",
"operatorNameHint": "es., Dipartimento di Polizia di Austin",
"operatorNameRequired": "Il nome dell'operatore è obbligatorio",
"operatorProfileSaved": "Profilo operatore \"{}\" salvato"
},
"operatorProfiles": {
"title": "Profili Operatore",
"noProfilesMessage": "Nessun profilo operatore definito. Creane uno per applicare tag operatore agli invii di nodi.",
"tagsCount": "{} tag",
"deleteOperatorProfile": "Elimina Profilo Operatore",
"deleteOperatorProfileConfirm": "Sei sicuro di voler eliminare \"{}\"?",
"operatorProfileDeleted": "Profilo operatore eliminato"
},
"offlineAreas": {
"title": "Aree Offline",
"noAreasTitle": "Nessuna area offline",
"noAreasSubtitle": "Scarica un'area mappa per l'uso offline.",
"provider": "Fornitore",
"maxZoom": "Zoom max",
"zoomLevels": "Z{}-{}",
"latitude": "Lat",
"longitude": "Lon",
"tiles": "Tile",
"size": "Dimensione",
"nodes": "Nodi",
"areaIdFallback": "Area {}...",
"renameArea": "Rinomina area",
"refreshWorldTiles": "Aggiorna/ri-scarica tile mondiali",
"deleteOfflineArea": "Elimina area offline",
"cancelDownload": "Annulla download",
"renameAreaDialogTitle": "Rinomina Area Offline",
"areaNameLabel": "Nome Area",
"renameButton": "Rinomina",
"megabytes": "MB",
"kilobytes": "KB",
"progress": "{}%",
"refreshArea": "Aggiorna area",
"refreshAreaDialogTitle": "Aggiorna Area Offline",
"refreshAreaDialogSubtitle": "Scegli cosa aggiornare per quest'area:",
"refreshTiles": "Aggiorna Tile Mappa",
"refreshTilesSubtitle": "Riscarica tutte le tile per immagini aggiornate",
"refreshNodes": "Aggiorna Nodi",
"refreshNodesSubtitle": "Ricarica i dati dei nodi per quest'area",
"startRefresh": "Avvia Aggiornamento",
"refreshStarted": "Aggiornamento avviato!",
"refreshFailed": "Aggiornamento fallito: {}"
},
"refineTagsSheet": {
"title": "Affina Tag",
"operatorProfile": "Profilo Operatore",
"done": "Fatto",
"none": "Nessuno",
"noAdditionalOperatorTags": "Nessun tag operatore aggiuntivo",
"additionalTags": "tag aggiuntivi",
"additionalTagsTitle": "Tag Aggiuntivi",
"noTagsDefinedForProfile": "Nessun tag definito per questo profilo operatore.",
"noOperatorProfiles": "Nessun profilo operatore definito",
"noOperatorProfilesMessage": "Crea profili operatore nelle Impostazioni per applicare tag aggiuntivi ai tuoi invii di nodi."
},
"layerSelector": {
"cannotChangeTileTypes": "Impossibile cambiare tipi di tile durante il download di aree offline",
"selectMapLayer": "Seleziona Livello Mappa",
"noTileProvidersAvailable": "Nessun fornitore di tile disponibile"
}
}

291
lib/localizations/pt.json Normal file
View File

@@ -0,0 +1,291 @@
{
"language": {
"name": "Português"
},
"app": {
"title": "DeFlock"
},
"actions": {
"tagNode": "Novo Nó",
"download": "Baixar",
"settings": "Configurações",
"edit": "Editar",
"delete": "Excluir",
"cancel": "Cancelar",
"ok": "OK",
"close": "Fechar",
"submit": "Enviar",
"saveEdit": "Salvar Edição",
"clear": "Limpar"
},
"followMe": {
"off": "Ativar seguir-me (norte para cima)",
"northUp": "Ativar seguir-me (rotação)",
"rotating": "Desativar seguir-me"
},
"settings": {
"title": "Configurações",
"language": "Idioma",
"systemDefault": "Padrão do Sistema",
"aboutInfo": "Sobre / Informações",
"aboutThisApp": "Sobre este App",
"aboutSubtitle": "Informações do aplicativo e créditos",
"languageSubtitle": "Escolha seu idioma preferido",
"maxNodes": "Máx. de nós desenhados",
"maxNodesSubtitle": "Definir um limite superior para o número de nós no mapa (padrão: 250).",
"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.",
"offlineModeWarningTitle": "Downloads Ativos",
"offlineModeWarningMessage": "Ativar o modo offline cancelará qualquer download de área ativo. Deseja continuar?",
"enableOfflineMode": "Ativar Modo Offline",
"profiles": "Perfis",
"profilesSubtitle": "Gerenciar perfis de nós e operadores",
"offlineSettings": "Configurações Offline",
"offlineSettingsSubtitle": "Gerenciar modo offline e áreas baixadas",
"advancedSettings": "Configurações Avançadas",
"advancedSettingsSubtitle": "Configurações de desempenho, alertas e provedores de mapas",
"proximityAlerts": "Alertas de Proximidade"
},
"proximityAlerts": {
"getNotified": "Receba notificações ao se aproximar de dispositivos de vigilância",
"batteryUsage": "Usa bateria extra para monitoramento contínuo de localização",
"notificationsEnabled": "✓ Notificações habilitadas",
"notificationsDisabled": "⚠ Notificações desabilitadas",
"permissionRequired": "Permissão de notificação necessária",
"permissionExplanation": "Notificações push estão desabilitadas. Você só verá alertas dentro do app e não será notificado quando o app estiver em segundo plano.",
"enableNotifications": "Habilitar Notificações",
"checkingPermissions": "Verificando permissões...",
"alertDistance": "Distância de alerta: ",
"meters": "metros",
"rangeInfo": "Faixa: {}-{} metros (padrão: {})"
},
"node": {
"title": "Nó #{}",
"tagSheetTitle": "Tags do Dispositivo de Vigilância",
"queuedForUpload": "Nó na fila para envio",
"editQueuedForUpload": "Edição de nó na fila para envio",
"deleteQueuedForUpload": "Exclusão de nó na fila para envio",
"confirmDeleteTitle": "Excluir Nó",
"confirmDeleteMessage": "Tem certeza de que deseja excluir o nó #{}? Esta ação não pode ser desfeita."
},
"addNode": {
"profile": "Perfil",
"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.",
"enableSubmittableProfile": "Ative um perfil enviável nas Configurações para enviar novos nós.",
"profileViewOnlyWarning": "Este perfil é apenas para visualização do mapa. Por favor, selecione um perfil enviável para enviar novos nós.",
"refineTags": "Refinar Tags",
"refineTagsWithProfile": "Refinar Tags ({})"
},
"editNode": {
"title": "Editar Nó #{}",
"profile": "Perfil",
"direction": "Direção {}°",
"profileNoDirectionInfo": "Este perfil não requer uma direção.",
"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.",
"refineTags": "Refinar Tags",
"refineTagsWithProfile": "Refinar Tags ({})"
},
"download": {
"title": "Baixar Área do Mapa",
"maxZoomLevel": "Nível máx. de zoom",
"storageEstimate": "Estimativa de armazenamento:",
"tilesAndSize": "{} tiles, {} MB",
"minZoom": "Zoom mín.:",
"maxRecommendedZoom": "Zoom máx. recomendado: Z{}",
"withinTileLimit": "Dentro do limite de {} tiles",
"exceedsTileLimit": "A seleção atual excede o limite de {} tiles",
"offlineModeWarning": "Downloads desabilitados no modo offline. Desative o modo offline para baixar novas áreas.",
"downloadStarted": "Download iniciado! Buscando tiles e nós...",
"downloadFailed": "Falha ao iniciar o download: {}"
},
"uploadMode": {
"title": "Destino do Upload",
"subtitle": "Escolha onde as câmeras são enviadas",
"production": "Produção",
"sandbox": "Sandbox",
"simulate": "Simular",
"productionDescription": "Enviar para o banco de dados OSM ao vivo (visível para todos os usuários)",
"sandboxDescription": "Uploads vão para o Sandbox OSM (seguro para testes, redefine regularmente).",
"simulateDescription": "Simular uploads (não contacta servidores OSM)"
},
"auth": {
"loggedInAs": "Logado como {}",
"loginToOSM": "Fazer login no OpenStreetMap",
"tapToLogout": "Toque para sair",
"requiredToSubmit": "Necessário para enviar dados de câmeras",
"loggedOut": "Deslogado",
"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"
},
"queue": {
"pendingUploads": "Uploads pendentes: {}",
"simulateModeEnabled": "Modo simulação ativado uploads simulados",
"sandboxMode": "Modo sandbox uploads vão para o Sandbox OSM",
"tapToViewQueue": "Toque para ver a fila",
"clearUploadQueue": "Limpar Fila de Upload",
"removeAllPending": "Remover todos os {} uploads pendentes",
"clearQueueTitle": "Limpar Fila",
"clearQueueConfirm": "Remover todos os {} uploads pendentes?",
"queueCleared": "Fila limpa",
"uploadQueueTitle": "Fila de Upload ({} itens)",
"queueIsEmpty": "A fila está vazia",
"cameraWithIndex": "Câmera {}",
"error": " (Erro)",
"completing": " (Completando...)",
"destination": "Dest: {}",
"latitude": "Lat: {}",
"longitude": "Lon: {}",
"direction": "Direção: {}°",
"attempts": "Tentativas: {}",
"uploadFailedRetry": "Upload falhou. Toque em tentar novamente para tentar novamente.",
"retryUpload": "Tentar upload novamente",
"clearAll": "Limpar Tudo"
},
"tileProviders": {
"title": "Provedores de Tiles",
"noProvidersConfigured": "Nenhum provedor de tiles configurado",
"tileTypesCount": "{} tipos de tiles",
"apiKeyConfigured": "Chave API configurada",
"needsApiKey": "Precisa de chave API",
"editProvider": "Editar Provedor",
"addProvider": "Adicionar Provedor",
"deleteProvider": "Excluir Provedor",
"deleteProviderConfirm": "Tem certeza de que deseja excluir \"{}\"?",
"providerName": "Nome do Provedor",
"providerNameHint": "ex., Mapas Personalizados Inc.",
"providerNameRequired": "Nome do provedor é obrigatório",
"apiKey": "Chave API (Opcional)",
"apiKeyHint": "Insira a chave API se necessária pelos tipos de tiles",
"tileTypes": "Tipos de Tiles",
"addType": "Adicionar Tipo",
"noTileTypesConfigured": "Nenhum tipo de tile configurado",
"atLeastOneTileTypeRequired": "Pelo menos um tipo de tile é obrigatório",
"manageTileProviders": "Gerenciar Provedores"
},
"tileTypeEditor": {
"editTileType": "Editar Tipo de Tile",
"addTileType": "Adicionar Tipo de Tile",
"name": "Nome",
"nameHint": "ex., Satélite",
"nameRequired": "Nome é obrigatório",
"urlTemplate": "Modelo de URL",
"urlTemplateHint": "https://exemplo.com/{z}/{x}/{y}.png",
"urlTemplateRequired": "Modelo de URL é obrigatório",
"urlTemplatePlaceholders": "URL deve conter os marcadores {z}, {x} e {y}",
"attribution": "Atribuição",
"attributionHint": "© Provedor de Mapas",
"attributionRequired": "Atribuição é obrigatória",
"fetchPreview": "Buscar Preview",
"previewTileLoaded": "Tile de preview carregado com sucesso",
"previewTileFailed": "Falha ao buscar preview: {}",
"save": "Salvar"
},
"profiles": {
"nodeProfiles": "Perfis de Nó",
"newProfile": "Novo Perfil",
"builtIn": "Integrado",
"custom": "Personalizado",
"view": "Ver",
"deleteProfile": "Excluir Perfil",
"deleteProfileConfirm": "Tem certeza de que deseja excluir \"{}\"?",
"profileDeleted": "Perfil excluído"
},
"mapTiles": {
"title": "Tiles do Mapa",
"manageProviders": "Gerenciar Provedores"
},
"profileEditor": {
"viewProfile": "Ver Perfil",
"newProfile": "Novo Perfil",
"editProfile": "Editar Perfil",
"profileName": "Nome do perfil",
"profileNameHint": "ex., Câmera ALPR Personalizada",
"profileNameRequired": "Nome do perfil é obrigatório",
"requiresDirection": "Requer Direção",
"requiresDirectionSubtitle": "Se câmeras deste tipo precisam de uma tag de direção",
"submittable": "Enviável",
"submittableSubtitle": "Se este perfil pode ser usado para envios de câmeras",
"osmTags": "Tags OSM",
"addTag": "Adicionar Tag",
"saveProfile": "Salvar Perfil",
"keyHint": "chave",
"valueHint": "valor",
"atLeastOneTagRequired": "Pelo menos uma tag é obrigatória",
"profileSaved": "Perfil \"{}\" salvo"
},
"operatorProfileEditor": {
"newOperatorProfile": "Novo Perfil de Operador",
"editOperatorProfile": "Editar Perfil de Operador",
"operatorName": "Nome do operador",
"operatorNameHint": "ex., Departamento de Polícia de Austin",
"operatorNameRequired": "Nome do operador é obrigatório",
"operatorProfileSaved": "Perfil de operador \"{}\" salvo"
},
"operatorProfiles": {
"title": "Perfis de Operador",
"noProfilesMessage": "Nenhum perfil de operador definido. Crie um para aplicar tags de operador aos envios de nós.",
"tagsCount": "{} tags",
"deleteOperatorProfile": "Excluir Perfil de Operador",
"deleteOperatorProfileConfirm": "Tem certeza de que deseja excluir \"{}\"?",
"operatorProfileDeleted": "Perfil de operador excluído"
},
"offlineAreas": {
"title": "Áreas Offline",
"noAreasTitle": "Nenhuma área offline",
"noAreasSubtitle": "Baixe uma área do mapa para uso offline.",
"provider": "Provedor",
"maxZoom": "Zoom máx",
"zoomLevels": "Z{}-{}",
"latitude": "Lat",
"longitude": "Lon",
"tiles": "Tiles",
"size": "Tamanho",
"nodes": "Nós",
"areaIdFallback": "Área {}...",
"renameArea": "Renomear área",
"refreshWorldTiles": "Atualizar/rebaixar tiles mundiais",
"deleteOfflineArea": "Excluir área offline",
"cancelDownload": "Cancelar download",
"renameAreaDialogTitle": "Renomear Área Offline",
"areaNameLabel": "Nome da Área",
"renameButton": "Renomear",
"megabytes": "MB",
"kilobytes": "KB",
"progress": "{}%",
"refreshArea": "Atualizar área",
"refreshAreaDialogTitle": "Atualizar Área Offline",
"refreshAreaDialogSubtitle": "Escolha o que atualizar para esta área:",
"refreshTiles": "Atualizar Tiles do Mapa",
"refreshTilesSubtitle": "Baixar novamente todos os tiles para imagens atualizadas",
"refreshNodes": "Atualizar Nós",
"refreshNodesSubtitle": "Buscar novamente os dados dos nós para esta área",
"startRefresh": "Iniciar Atualização",
"refreshStarted": "Atualização iniciada!",
"refreshFailed": "Atualização falhou: {}"
},
"refineTagsSheet": {
"title": "Refinar Tags",
"operatorProfile": "Perfil de Operador",
"done": "Concluído",
"none": "Nenhum",
"noAdditionalOperatorTags": "Nenhuma tag adicional de operador",
"additionalTags": "tags adicionais",
"additionalTagsTitle": "Tags Adicionais",
"noTagsDefinedForProfile": "Nenhuma tag definida para este perfil de operador.",
"noOperatorProfiles": "Nenhum perfil de operador definido",
"noOperatorProfilesMessage": "Crie perfis de operador nas Configurações para aplicar tags adicionais aos seus envios de nós."
},
"layerSelector": {
"cannotChangeTileTypes": "Não é possível alterar tipos de tiles durante o download de áreas offline",
"selectMapLayer": "Selecionar Camada do Mapa",
"noTileProvidersAvailable": "Nenhum provedor de tiles disponível"
}
}

291
lib/localizations/zh.json Normal file
View File

@@ -0,0 +1,291 @@
{
"language": {
"name": "中文"
},
"app": {
"title": "DeFlock"
},
"actions": {
"tagNode": "新建节点",
"download": "下载",
"settings": "设置",
"edit": "编辑",
"delete": "删除",
"cancel": "取消",
"ok": "确定",
"close": "关闭",
"submit": "提交",
"saveEdit": "保存编辑",
"clear": "清空"
},
"followMe": {
"off": "启用跟随模式(北向上)",
"northUp": "启用跟随模式(旋转)",
"rotating": "禁用跟随模式"
},
"settings": {
"title": "设置",
"language": "语言",
"systemDefault": "系统默认",
"aboutInfo": "关于 / 信息",
"aboutThisApp": "关于此应用",
"aboutSubtitle": "应用程序信息和鸣谢",
"languageSubtitle": "选择您的首选语言",
"maxNodes": "最大节点绘制数",
"maxNodesSubtitle": "设置地图上节点数量的上限默认250。",
"maxNodesWarning": "除非您确定有充分的理由,否则您可能不想这样做。",
"offlineMode": "离线模式",
"offlineModeSubtitle": "禁用除本地/离线区域外的所有网络请求。",
"offlineModeWarningTitle": "活动下载",
"offlineModeWarningMessage": "启用离线模式将取消任何活动的区域下载。您要继续吗?",
"enableOfflineMode": "启用离线模式",
"profiles": "配置文件",
"profilesSubtitle": "管理节点和操作员配置文件",
"offlineSettings": "离线设置",
"offlineSettingsSubtitle": "管理离线模式和已下载区域",
"advancedSettings": "高级设置",
"advancedSettingsSubtitle": "性能、警报和地图提供商设置",
"proximityAlerts": "邻近警报"
},
"proximityAlerts": {
"getNotified": "接近监控设备时接收通知",
"batteryUsage": "使用额外电量进行连续位置监控",
"notificationsEnabled": "✓ 通知已启用",
"notificationsDisabled": "⚠ 通知已禁用",
"permissionRequired": "需要通知权限",
"permissionExplanation": "推送通知已禁用。您只会看到应用内警报,当应用在后台时不会收到通知。",
"enableNotifications": "启用通知",
"checkingPermissions": "检查权限中...",
"alertDistance": "警报距离:",
"meters": "米",
"rangeInfo": "范围:{}-{} 米(默认:{}"
},
"node": {
"title": "节点 #{}",
"tagSheetTitle": "监控设备标签",
"queuedForUpload": "节点已排队上传",
"editQueuedForUpload": "节点编辑已排队上传",
"deleteQueuedForUpload": "节点删除已排队上传",
"confirmDeleteTitle": "删除节点",
"confirmDeleteMessage": "您确定要删除节点 #{} 吗?此操作无法撤销。"
},
"addNode": {
"profile": "配置文件",
"direction": "方向 {}°",
"profileNoDirectionInfo": "此配置文件不需要方向。",
"mustBeLoggedIn": "您必须登录才能提交新节点。请通过设置登录。",
"enableSubmittableProfile": "在设置中启用可提交的配置文件以提交新节点。",
"profileViewOnlyWarning": "此配置文件仅用于地图查看。请选择可提交的配置文件来提交新节点。",
"refineTags": "细化标签",
"refineTagsWithProfile": "细化标签({}"
},
"editNode": {
"title": "编辑节点 #{}",
"profile": "配置文件",
"direction": "方向 {}°",
"profileNoDirectionInfo": "此配置文件不需要方向。",
"mustBeLoggedIn": "您必须登录才能编辑节点。请通过设置登录。",
"sandboxModeWarning": "无法将生产节点的编辑提交到沙盒。在设置中切换到生产模式以编辑节点。",
"enableSubmittableProfile": "在设置中启用可提交的配置文件以编辑节点。",
"profileViewOnlyWarning": "此配置文件仅用于地图查看。请选择可提交的配置文件来编辑节点。",
"refineTags": "细化标签",
"refineTagsWithProfile": "细化标签({}"
},
"download": {
"title": "下载地图区域",
"maxZoomLevel": "最大缩放级别",
"storageEstimate": "存储估算:",
"tilesAndSize": "{} 瓦片,{} MB",
"minZoom": "最小缩放:",
"maxRecommendedZoom": "最大推荐缩放Z{}",
"withinTileLimit": "在 {} 瓦片限制内",
"exceedsTileLimit": "当前选择超出 {} 瓦片限制",
"offlineModeWarning": "离线模式下禁用下载。禁用离线模式以下载新区域。",
"downloadStarted": "下载已开始!正在获取瓦片和节点...",
"downloadFailed": "启动下载失败:{}"
},
"uploadMode": {
"title": "上传目标",
"subtitle": "选择摄像头上传位置",
"production": "生产环境",
"sandbox": "沙盒",
"simulate": "模拟",
"productionDescription": "上传到实时 OSM 数据库(对所有用户可见)",
"sandboxDescription": "上传到 OSM 沙盒(测试安全,定期重置)。",
"simulateDescription": "模拟上传(不联系 OSM 服务器)"
},
"auth": {
"loggedInAs": "已登录为 {}",
"loginToOSM": "登录 OpenStreetMap",
"tapToLogout": "点击登出",
"requiredToSubmit": "提交摄像头数据所需",
"loggedOut": "已登出",
"testConnection": "测试连接",
"testConnectionSubtitle": "验证 OSM 凭据是否有效",
"connectionOK": "连接正常 - 凭据有效",
"connectionFailed": "连接失败 - 请重新登录"
},
"queue": {
"pendingUploads": "待上传:{}",
"simulateModeEnabled": "模拟模式已启用 上传已模拟",
"sandboxMode": "沙盒模式 上传到 OSM 沙盒",
"tapToViewQueue": "点击查看队列",
"clearUploadQueue": "清空上传队列",
"removeAllPending": "移除所有 {} 个待上传项",
"clearQueueTitle": "清空队列",
"clearQueueConfirm": "移除所有 {} 个待上传项?",
"queueCleared": "队列已清空",
"uploadQueueTitle": "上传队列({} 项)",
"queueIsEmpty": "队列为空",
"cameraWithIndex": "摄像头 {}",
"error": "(错误)",
"completing": "(完成中...",
"destination": "目标:{}",
"latitude": "纬度:{}",
"longitude": "经度:{}",
"direction": "方向:{}°",
"attempts": "尝试次数:{}",
"uploadFailedRetry": "上传失败。点击重试再次尝试。",
"retryUpload": "重试上传",
"clearAll": "全部清空"
},
"tileProviders": {
"title": "瓦片提供商",
"noProvidersConfigured": "未配置瓦片提供商",
"tileTypesCount": "{} 种瓦片类型",
"apiKeyConfigured": "API 密钥已配置",
"needsApiKey": "需要 API 密钥",
"editProvider": "编辑提供商",
"addProvider": "添加提供商",
"deleteProvider": "删除提供商",
"deleteProviderConfirm": "您确定要删除 \"{}\" 吗?",
"providerName": "提供商名称",
"providerNameHint": "例如,自定义地图公司",
"providerNameRequired": "提供商名称为必填项",
"apiKey": "API 密钥(可选)",
"apiKeyHint": "如果瓦片类型需要,请输入 API 密钥",
"tileTypes": "瓦片类型",
"addType": "添加类型",
"noTileTypesConfigured": "未配置瓦片类型",
"atLeastOneTileTypeRequired": "至少需要一种瓦片类型",
"manageTileProviders": "管理提供商"
},
"tileTypeEditor": {
"editTileType": "编辑瓦片类型",
"addTileType": "添加瓦片类型",
"name": "名称",
"nameHint": "例如,卫星",
"nameRequired": "名称为必填项",
"urlTemplate": "URL 模板",
"urlTemplateHint": "https://example.com/{z}/{x}/{y}.png",
"urlTemplateRequired": "URL 模板为必填项",
"urlTemplatePlaceholders": "URL 必须包含 {z}、{x} 和 {y} 占位符",
"attribution": "归属",
"attributionHint": "© 地图提供商",
"attributionRequired": "归属为必填项",
"fetchPreview": "获取预览",
"previewTileLoaded": "预览瓦片加载成功",
"previewTileFailed": "获取预览失败:{}",
"save": "保存"
},
"profiles": {
"nodeProfiles": "节点配置文件",
"newProfile": "新建配置文件",
"builtIn": "内置",
"custom": "自定义",
"view": "查看",
"deleteProfile": "删除配置文件",
"deleteProfileConfirm": "您确定要删除 \"{}\" 吗?",
"profileDeleted": "配置文件已删除"
},
"mapTiles": {
"title": "地图瓦片",
"manageProviders": "管理提供商"
},
"profileEditor": {
"viewProfile": "查看配置文件",
"newProfile": "新建配置文件",
"editProfile": "编辑配置文件",
"profileName": "配置文件名称",
"profileNameHint": "例如,自定义 ALPR 摄像头",
"profileNameRequired": "配置文件名称为必填项",
"requiresDirection": "需要方向",
"requiresDirectionSubtitle": "此类型的摄像头是否需要方向标签",
"submittable": "可提交",
"submittableSubtitle": "此配置文件是否可用于摄像头提交",
"osmTags": "OSM 标签",
"addTag": "添加标签",
"saveProfile": "保存配置文件",
"keyHint": "键",
"valueHint": "值",
"atLeastOneTagRequired": "至少需要一个标签",
"profileSaved": "配置文件 \"{}\" 已保存"
},
"operatorProfileEditor": {
"newOperatorProfile": "新建运营商配置文件",
"editOperatorProfile": "编辑运营商配置文件",
"operatorName": "运营商名称",
"operatorNameHint": "例如,奥斯汀警察局",
"operatorNameRequired": "运营商名称为必填项",
"operatorProfileSaved": "运营商配置文件 \"{}\" 已保存"
},
"operatorProfiles": {
"title": "运营商配置文件",
"noProfilesMessage": "未定义运营商配置文件。创建一个以将运营商标签应用于节点提交。",
"tagsCount": "{} 个标签",
"deleteOperatorProfile": "删除运营商配置文件",
"deleteOperatorProfileConfirm": "您确定要删除 \"{}\" 吗?",
"operatorProfileDeleted": "运营商配置文件已删除"
},
"offlineAreas": {
"title": "离线区域",
"noAreasTitle": "无离线区域",
"noAreasSubtitle": "下载地图区域以供离线使用。",
"provider": "提供商",
"maxZoom": "最大缩放",
"zoomLevels": "Z{}-{}",
"latitude": "纬度",
"longitude": "经度",
"tiles": "瓦片",
"size": "大小",
"nodes": "节点",
"areaIdFallback": "区域 {}...",
"renameArea": "重命名区域",
"refreshWorldTiles": "刷新/重新下载世界瓦片",
"deleteOfflineArea": "删除离线区域",
"cancelDownload": "取消下载",
"renameAreaDialogTitle": "重命名离线区域",
"areaNameLabel": "区域名称",
"renameButton": "重命名",
"megabytes": "MB",
"kilobytes": "KB",
"progress": "{}%",
"refreshArea": "刷新区域",
"refreshAreaDialogTitle": "刷新离线区域",
"refreshAreaDialogSubtitle": "选择要为此区域刷新的内容:",
"refreshTiles": "刷新地图瓦片",
"refreshTilesSubtitle": "重新下载所有瓦片以获取更新的图像",
"refreshNodes": "刷新节点",
"refreshNodesSubtitle": "重新获取此区域的节点数据",
"startRefresh": "开始刷新",
"refreshStarted": "刷新已开始!",
"refreshFailed": "刷新失败:{}"
},
"refineTagsSheet": {
"title": "细化标签",
"operatorProfile": "运营商配置文件",
"done": "完成",
"none": "无",
"noAdditionalOperatorTags": "无额外运营商标签",
"additionalTags": "额外标签",
"additionalTagsTitle": "额外标签",
"noTagsDefinedForProfile": "此运营商配置文件未定义标签。",
"noOperatorProfiles": "未定义运营商配置文件",
"noOperatorProfilesMessage": "在设置中创建运营商配置文件,以将额外标签应用于您的节点提交。"
},
"layerSelector": {
"cannotChangeTileTypes": "在下载离线区域时无法更改瓦片类型",
"selectMapLayer": "选择地图图层",
"noTileProvidersAvailable": "无可用瓦片提供商"
}
}

View File

@@ -4,6 +4,11 @@ import 'package:provider/provider.dart';
import 'app_state.dart';
import 'screens/home_screen.dart';
import 'screens/settings_screen.dart';
import 'screens/profiles_settings_screen.dart';
import 'screens/offline_settings_screen.dart';
import 'screens/advanced_settings_screen.dart';
import 'screens/language_settings_screen.dart';
import 'screens/about_screen.dart';
import 'services/localization_service.dart';
@@ -23,7 +28,7 @@ Future<void> main() async {
// You can customize this splash/loading screen as needed
return MaterialApp(
home: Scaffold(
backgroundColor: Color(0xFF202020),
backgroundColor: Color(0xFF152131),
body: Center(
child: Image.asset(
'assets/app_icon.png',
@@ -58,6 +63,11 @@ class DeFlockApp extends StatelessWidget {
routes: {
'/': (context) => const HomeScreen(),
'/settings': (context) => const SettingsScreen(),
'/settings/profiles': (context) => const ProfilesSettingsScreen(),
'/settings/offline': (context) => const OfflineSettingsScreen(),
'/settings/advanced': (context) => const AdvancedSettingsScreen(),
'/settings/language': (context) => const LanguageSettingsScreen(),
'/settings/about': (context) => const AboutScreen(),
},
initialRoute: '/',
);

View File

@@ -1,11 +1,11 @@
import 'package:latlong2/latlong.dart';
class OsmCameraNode {
class OsmNode {
final int id;
final LatLng coord;
final Map<String, String> tags;
OsmCameraNode({
OsmNode({
required this.id,
required this.coord,
required this.tags,
@@ -18,14 +18,14 @@ class OsmCameraNode {
'tags': tags,
};
factory OsmCameraNode.fromJson(Map<String, dynamic> json) {
factory OsmNode.fromJson(Map<String, dynamic> json) {
final tags = <String, String>{};
if (json['tags'] != null) {
(json['tags'] as Map<String, dynamic>).forEach((k, v) {
tags[k.toString()] = v.toString();
});
}
return OsmCameraNode(
return 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,
@@ -51,5 +51,4 @@ class OsmCameraNode {
final normalized = ((val % 360) + 360) % 360;
return normalized;
}
}
}

View File

@@ -3,13 +3,17 @@ import 'node_profile.dart';
import 'operator_profile.dart';
import '../state/settings_state.dart';
enum UploadOperation { create, modify, delete }
class PendingUpload {
final LatLng coord;
final double direction;
final NodeProfile profile;
final OperatorProfile? operatorProfile;
final UploadMode uploadMode; // Capture upload destination when queued
final int? originalNodeId; // If this is an edit, the ID of the original OSM node
final UploadOperation operation; // Type of operation: create, modify, or delete
final int? originalNodeId; // If this is modify/delete, the ID of the original OSM node
int? submittedNodeId; // The actual node ID returned by OSM after successful submission
int attempts;
bool error;
bool completing; // True when upload succeeded but item is showing checkmark briefly
@@ -20,14 +24,23 @@ class PendingUpload {
required this.profile,
this.operatorProfile,
required this.uploadMode,
required this.operation,
this.originalNodeId,
this.submittedNodeId,
this.attempts = 0,
this.error = false,
this.completing = false,
});
}) : assert(
(operation == UploadOperation.create && originalNodeId == null) ||
(operation != UploadOperation.create && originalNodeId != null),
'originalNodeId must be null for create operations and non-null for modify/delete operations'
);
// True if this is an edit of an existing camera, false if it's a new camera
bool get isEdit => originalNodeId != null;
// True if this is an edit of an existing node, false if it's a new node
bool get isEdit => operation == UploadOperation.modify;
// True if this is a deletion of an existing node
bool get isDeletion => operation == UploadOperation.delete;
// Get display name for the upload destination
String get uploadModeDisplayName {
@@ -41,11 +54,11 @@ class PendingUpload {
}
}
// Get combined tags from camera profile and operator profile
// Get combined tags from node profile and operator profile
Map<String, String> getCombinedTags() {
final tags = Map<String, String>.from(profile.tags);
// Add operator profile tags (they override camera profile tags if there are conflicts)
// Add operator profile tags (they override node profile tags if there are conflicts)
if (operatorProfile != null) {
tags.addAll(operatorProfile!.tags);
}
@@ -65,7 +78,9 @@ class PendingUpload {
'profile': profile.toJson(),
'operatorProfile': operatorProfile?.toJson(),
'uploadMode': uploadMode.index,
'operation': operation.index,
'originalNodeId': originalNodeId,
'submittedNodeId': submittedNodeId,
'attempts': attempts,
'error': error,
'completing': completing,
@@ -83,7 +98,11 @@ class PendingUpload {
uploadMode: j['uploadMode'] != null
? UploadMode.values[j['uploadMode']]
: UploadMode.production, // Default for legacy entries
operation: j['operation'] != null
? UploadOperation.values[j['operation']]
: (j['originalNodeId'] != null ? UploadOperation.modify : UploadOperation.create), // Legacy compatibility
originalNodeId: j['originalNodeId'],
submittedNodeId: j['submittedNodeId'],
attempts: j['attempts'] ?? 0,
error: j['error'] ?? false,
completing: j['completing'] ?? false, // Default to false for legacy entries

View File

@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import '../services/localization_service.dart';
class AboutScreen extends StatelessWidget {
const AboutScreen({super.key});
@override
Widget build(BuildContext context) {
final locService = LocalizationService.instance;
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) => Scaffold(
appBar: AppBar(
title: Text(locService.t('settings.aboutThisApp')),
),
body: FutureBuilder<String>(
future: DefaultAssetBundle.of(context).loadString('assets/info.txt'),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (snapshot.hasError) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Error loading info: ${snapshot.error}',
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Text(
snapshot.data ?? 'No info available.',
style: Theme.of(context).textTheme.bodyLarge,
),
);
},
),
),
);
}
}

View File

@@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
import 'settings/sections/max_nodes_section.dart';
import 'settings/sections/proximity_alerts_section.dart';
import 'settings/sections/tile_provider_section.dart';
import '../services/localization_service.dart';
class AdvancedSettingsScreen extends StatelessWidget {
const AdvancedSettingsScreen({super.key});
@override
Widget build(BuildContext context) {
final locService = LocalizationService.instance;
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) => Scaffold(
appBar: AppBar(
title: Text(locService.t('settings.advancedSettings')),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: const [
MaxNodesSection(),
Divider(),
ProximityAlertsSection(),
Divider(),
TileProviderSection(),
],
),
),
);
}
}

View File

@@ -11,9 +11,11 @@ import '../services/localization_service.dart';
import '../widgets/add_node_sheet.dart';
import '../widgets/edit_node_sheet.dart';
import '../widgets/node_tag_sheet.dart';
import '../widgets/camera_provider_with_cache.dart';
import '../widgets/download_area_dialog.dart';
import '../widgets/measured_sheet.dart';
import '../models/osm_node.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@@ -28,9 +30,16 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
late final AnimatedMapController _mapController;
bool _editSheetShown = false;
// Track sheet heights for map padding
// Track sheet heights for map positioning
double _addSheetHeight = 0.0;
double _editSheetHeight = 0.0;
double _tagSheetHeight = 0.0;
// Flag to prevent map bounce when transitioning from tag sheet to edit sheet
bool _transitioningToEdit = false;
// Track selected node for highlighting
int? _selectedNodeId;
@override
void initState() {
@@ -110,24 +119,97 @@ 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);
// Set transition flag to prevent map bounce
_transitioningToEdit = true;
// Close any existing tag sheet first
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), () {
if (!mounted) return;
final controller = _scaffoldKey.currentState!.showBottomSheet(
(ctx) => MeasuredSheet(
onHeightChanged: (height) {
setState(() {
_editSheetHeight = height;
// Clear transition flag and reset tag sheet height once edit sheet starts sizing
if (height > 0 && _transitioningToEdit) {
_transitioningToEdit = false;
_tagSheetHeight = 0.0; // Now safe to reset
_selectedNodeId = null; // Clear selection when moving to edit
}
});
},
child: EditNodeSheet(session: session),
),
);
// Reset height when sheet is dismissed
controller.closed.then((_) {
setState(() {
_editSheetHeight = 0.0;
_transitioningToEdit = false;
});
});
});
}
void openNodeTagSheet(OsmNode node) {
setState(() {
_selectedNodeId = node.id; // Track selected node for highlighting
});
// Start smooth centering animation simultaneously with sheet opening
// Use the same duration as SheetAwareMap (300ms) for coordinated animation
try {
_mapController.animateTo(
dest: node.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(node.coord, _mapController.mapController.camera.zoom);
} catch (_) {
// Controller really not ready, skip centering
}
}
final controller = _scaffoldKey.currentState!.showBottomSheet(
(ctx) => MeasuredSheet(
onHeightChanged: (height) {
setState(() {
_editSheetHeight = height;
_tagSheetHeight = height;
});
},
child: EditNodeSheet(session: session),
child: NodeTagSheet(
node: node,
onEditPressed: () {
final appState = context.read<AppState>();
appState.startEditSession(node);
// This will trigger _openEditNodeSheet via the existing auto-show logic
},
),
),
);
// Reset height when sheet is dismissed
// Reset height and selection when sheet is dismissed (unless transitioning to edit)
controller.closed.then((_) {
setState(() {
_editSheetHeight = 0.0;
});
if (!_transitioningToEdit) {
setState(() {
_tagSheetHeight = 0.0;
_selectedNodeId = null; // Clear selection
});
}
// If transitioning to edit, keep the height until edit sheet takes over
});
}
@@ -143,9 +225,12 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
_editSheetShown = false;
}
// Calculate bottom padding for map (90% of active sheet height)
final activeSheetHeight = _addSheetHeight > 0 ? _addSheetHeight : _editSheetHeight;
final mapBottomPadding = activeSheetHeight * 0.9;
// Pass the active sheet height directly to the map
final activeSheetHeight = _addSheetHeight > 0
? _addSheetHeight
: (_editSheetHeight > 0
? _editSheetHeight
: _tagSheetHeight);
return MultiProvider(
providers: [
@@ -190,7 +275,9 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
key: _mapViewKey,
controller: _mapController,
followMeMode: appState.followMeMode,
bottomPadding: mapBottomPadding,
sheetHeight: activeSheetHeight,
selectedNodeId: _selectedNodeId,
onNodeTap: openNodeTagSheet,
onUserGesture: () {
if (appState.followMeMode != FollowMeMode.off) {
appState.setFollowMeMode(FollowMeMode.off);
@@ -201,25 +288,27 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
alignment: Alignment.bottomCenter,
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom + kBottomButtonBarMargin,
bottom: MediaQuery.of(context).padding.bottom + kBottomButtonBarOffset,
left: 8,
right: 8,
),
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),
)
],
),
margin: EdgeInsets.only(bottom: kBottomButtonBarMargin),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
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),
)
],
),
margin: EdgeInsets.only(bottom: kBottomButtonBarOffset),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
children: [
Expanded(
child: AnimatedBuilder(
@@ -256,6 +345,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
],
),
),
),
),
),
],

View File

@@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'settings/sections/language_section.dart';
import '../services/localization_service.dart';
class LanguageSettingsScreen extends StatelessWidget {
const LanguageSettingsScreen({super.key});
@override
Widget build(BuildContext context) {
final locService = LocalizationService.instance;
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) => Scaffold(
appBar: AppBar(
title: Text(locService.t('settings.language')),
),
body: const Padding(
padding: EdgeInsets.all(16),
child: LanguageSection(),
),
),
);
}
}

View File

@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'settings/sections/offline_mode_section.dart';
import 'settings/sections/offline_areas_section.dart';
import '../services/localization_service.dart';
class OfflineSettingsScreen extends StatelessWidget {
const OfflineSettingsScreen({super.key});
@override
Widget build(BuildContext context) {
final locService = LocalizationService.instance;
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) => Scaffold(
appBar: AppBar(
title: Text(locService.t('settings.offlineSettings')),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: const [
OfflineModeSection(),
Divider(),
OfflineAreasSection(),
],
),
),
);
}
}

View File

@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'settings/sections/node_profiles_section.dart';
import 'settings/sections/operator_profiles_section.dart';
import '../services/localization_service.dart';
class ProfilesSettingsScreen extends StatelessWidget {
const ProfilesSettingsScreen({super.key});
@override
Widget build(BuildContext context) {
final locService = LocalizationService.instance;
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) => Scaffold(
appBar: AppBar(
title: Text(locService.t('settings.profiles')),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: const [
NodeProfilesSection(),
Divider(),
OperatorProfilesSection(),
],
),
),
);
}
}

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import '../../services/localization_service.dart';
import '../../../services/localization_service.dart';
class AboutSection extends StatelessWidget {
const AboutSection({super.key});

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../app_state.dart';
import '../../services/localization_service.dart';
import '../../../app_state.dart';
import '../../../services/localization_service.dart';
class AuthSection extends StatelessWidget {
const AuthSection({super.key});

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../services/localization_service.dart';
import '../../../services/localization_service.dart';
class LanguageSection extends StatefulWidget {
const LanguageSection({super.key});
@@ -57,15 +57,6 @@ class _LanguageSectionState extends State<LanguageSection> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
locService.t('settings.language'),
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
),
// System Default option
RadioListTile<String?>(
title: Text(locService.t('settings.systemDefault')),
@@ -73,8 +64,18 @@ class _LanguageSectionState extends State<LanguageSection> {
groupValue: _selectedLanguage,
onChanged: _setLanguage,
),
// Dynamic language options
...locService.availableLanguages.map((langCode) =>
// English always appears second (if available)
if (locService.availableLanguages.contains('en'))
RadioListTile<String>(
title: Text(_languageNames['en'] ?? 'English'),
value: 'en',
groupValue: _selectedLanguage,
onChanged: _setLanguage,
),
// Other language options (excluding English since it's already shown)
...locService.availableLanguages
.where((langCode) => langCode != 'en')
.map((langCode) =>
RadioListTile<String>(
title: Text(_languageNames[langCode] ?? langCode.toUpperCase()),
value: langCode,

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../app_state.dart';
import '../../services/localization_service.dart';
import '../../../app_state.dart';
import '../../../services/localization_service.dart';
class MaxNodesSection extends StatefulWidget {
const MaxNodesSection({super.key});
@@ -39,13 +39,17 @@ class _MaxNodesSectionState extends State<MaxNodesSection> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('settings.maxNodes'),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
ListTile(
leading: const Icon(Icons.filter_alt),
title: Text(locService.t('settings.maxNodes')),
title: Text(locService.t('settings.maxNodesSubtitle')),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(locService.t('settings.maxNodesSubtitle')),
if (showWarning)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),

View File

@@ -1,13 +1,13 @@
import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';
import 'package:provider/provider.dart';
import '../../app_state.dart';
import '../../models/node_profile.dart';
import '../../services/localization_service.dart';
import '../profile_editor.dart';
import '../../../app_state.dart';
import '../../../models/node_profile.dart';
import '../../../services/localization_service.dart';
import '../../profile_editor.dart';
class ProfileListSection extends StatelessWidget {
const ProfileListSection({super.key});
class NodeProfilesSection extends StatelessWidget {
const NodeProfilesSection({super.key});
@override
Widget build(BuildContext context) {
@@ -22,7 +22,10 @@ class ProfileListSection extends StatelessWidget {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(locService.t('profiles.nodeProfiles'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
Text(
locService.t('profiles.nodeProfiles'),
style: Theme.of(context).textTheme.titleMedium,
),
TextButton.icon(
onPressed: () => Navigator.push(
context,

View File

@@ -0,0 +1,280 @@
import 'package:flutter/material.dart';
import '../../../services/offline_area_service.dart';
import '../../../services/offline_areas/offline_area_models.dart';
import '../../../services/localization_service.dart';
class OfflineAreasSection extends StatefulWidget {
const OfflineAreasSection({super.key});
@override
State<OfflineAreasSection> createState() => _OfflineAreasSectionState();
}
class _OfflineAreasSectionState extends State<OfflineAreasSection> {
OfflineAreaService get service => OfflineAreaService();
@override
void initState() {
super.initState();
Future.doWhile(() async {
await Future.delayed(const Duration(seconds: 1));
if (!mounted) return false;
setState(() {});
return true;
});
}
void _showRefreshDialog(OfflineArea area) {
showDialog(
context: context,
builder: (context) => _RefreshAreaDialog(
area: area,
onRefresh: (refreshTiles, refreshNodes) {
try {
// ignore: unawaited_futures
service.refreshArea(
id: area.id,
refreshTiles: refreshTiles,
refreshNodes: refreshNodes,
onProgress: (progress) => setState(() {}),
onComplete: (status) => setState(() {}),
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(LocalizationService.instance.t('offlineAreas.refreshStarted')),
),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(LocalizationService.instance.t('offlineAreas.refreshFailed', params: [e.toString()])),
),
);
}
},
),
);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
final areas = service.offlineAreas;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('offlineAreas.title'),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
if (areas.isEmpty)
ListTile(
leading: const Icon(Icons.download_for_offline),
title: Text(locService.t('offlineAreas.noAreasTitle')),
subtitle: Text(locService.t('offlineAreas.noAreasSubtitle')),
)
else
...areas.map((area) {
String diskStr = area.sizeBytes > 0
? area.sizeBytes > 1024 * 1024
? "${(area.sizeBytes / (1024 * 1024)).toStringAsFixed(2)} ${locService.t('offlineAreas.megabytes')}"
: "${(area.sizeBytes / 1024).toStringAsFixed(1)} ${locService.t('offlineAreas.kilobytes')}"
: '--';
String subtitle = '${locService.t('offlineAreas.provider')}: ${area.tileProviderDisplay}\n' +
'${locService.t('offlineAreas.maxZoom')}: Z${area.maxZoom}' + '\n' +
'${locService.t('offlineAreas.latitude')}: ${area.bounds.southWest.latitude.toStringAsFixed(3)}, ${area.bounds.southWest.longitude.toStringAsFixed(3)}\n' +
'${locService.t('offlineAreas.latitude')}: ${area.bounds.northEast.latitude.toStringAsFixed(3)}, ${area.bounds.northEast.longitude.toStringAsFixed(3)}';
if (area.status == OfflineAreaStatus.downloading) {
subtitle += '\n${locService.t('offlineAreas.tiles')}: ${area.tilesDownloaded} / ${area.tilesTotal}';
} else {
subtitle += '\n${locService.t('offlineAreas.tiles')}: ${area.tilesTotal}';
}
subtitle += '\n${locService.t('offlineAreas.size')}: $diskStr';
subtitle += '\n${locService.t('offlineAreas.nodes')}: ${area.nodes.length}';
return Card(
child: ListTile(
leading: Icon(area.status == OfflineAreaStatus.complete
? Icons.cloud_done
: area.status == OfflineAreaStatus.error
? Icons.error
: Icons.download_for_offline),
title: Row(
children: [
Expanded(
child: Text(area.name.isNotEmpty
? area.name
: locService.t('offlineAreas.areaIdFallback', params: [area.id.substring(0, 6)])),
),
IconButton(
icon: const Icon(Icons.edit, size: 20),
tooltip: locService.t('offlineAreas.renameArea'),
onPressed: () async {
String? newName = await showDialog<String>(
context: context,
builder: (ctx) {
final ctrl = TextEditingController(text: area.name);
return AlertDialog(
title: Text(locService.t('offlineAreas.renameAreaDialogTitle')),
content: TextField(
controller: ctrl,
maxLength: 40,
decoration: InputDecoration(labelText: locService.t('offlineAreas.areaNameLabel')),
autofocus: true,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text(locService.t('actions.cancel')),
),
ElevatedButton(
onPressed: () {
Navigator.pop(ctx, ctrl.text.trim());
},
child: Text(locService.t('offlineAreas.renameButton')),
),
],
);
},
);
if (newName != null && newName.trim().isNotEmpty) {
setState(() {
area.name = newName.trim();
service.saveAreasToDisk();
});
}
},
),
if (area.status != OfflineAreaStatus.downloading) ...[
IconButton(
icon: const Icon(Icons.refresh, color: Colors.blue),
tooltip: locService.t('offlineAreas.refreshArea'),
onPressed: () => _showRefreshDialog(area),
),
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
tooltip: locService.t('offlineAreas.deleteOfflineArea'),
onPressed: () async {
service.deleteArea(area.id);
setState(() {});
},
),
],
],
),
subtitle: Text(subtitle),
isThreeLine: true,
trailing: area.status == OfflineAreaStatus.downloading
? Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 64,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
LinearProgressIndicator(value: area.progress),
Text(
locService.t('offlineAreas.progress', params: [(area.progress * 100).toStringAsFixed(0)]),
style: const TextStyle(fontSize: 12),
)
],
),
),
IconButton(
icon: const Icon(Icons.cancel, color: Colors.orange),
tooltip: locService.t('offlineAreas.cancelDownload'),
onPressed: () {
service.cancelDownload(area.id);
setState(() {});
},
)
],
)
: null,
onLongPress: area.status == OfflineAreaStatus.downloading
? () {
service.cancelDownload(area.id);
setState(() {});
}
: null,
),
);
}).toList(),
],
);
},
);
}
}
class _RefreshAreaDialog extends StatefulWidget {
final OfflineArea area;
final Function(bool refreshTiles, bool refreshNodes) onRefresh;
const _RefreshAreaDialog({
required this.area,
required this.onRefresh,
});
@override
State<_RefreshAreaDialog> createState() => _RefreshAreaDialogState();
}
class _RefreshAreaDialogState extends State<_RefreshAreaDialog> {
bool _refreshTiles = true;
bool _refreshNodes = true;
@override
Widget build(BuildContext context) {
final locService = LocalizationService.instance;
return AlertDialog(
title: Text(locService.t('offlineAreas.refreshAreaDialogTitle')),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(locService.t('offlineAreas.refreshAreaDialogSubtitle')),
const SizedBox(height: 16),
CheckboxListTile(
title: Text(locService.t('offlineAreas.refreshTiles')),
subtitle: Text(locService.t('offlineAreas.refreshTilesSubtitle')),
value: _refreshTiles,
onChanged: (value) => setState(() => _refreshTiles = value ?? true),
dense: true,
),
CheckboxListTile(
title: Text(locService.t('offlineAreas.refreshNodes')),
subtitle: Text(locService.t('offlineAreas.refreshNodesSubtitle')),
value: _refreshNodes,
onChanged: (value) => setState(() => _refreshNodes = value ?? true),
dense: true,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(locService.t('actions.cancel')),
),
ElevatedButton(
onPressed: (_refreshTiles || _refreshNodes)
? () {
Navigator.of(context).pop();
widget.onRefresh(_refreshTiles, _refreshNodes);
}
: null,
child: Text(locService.t('offlineAreas.startRefresh')),
),
],
);
}
}

View File

@@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../app_state.dart';
import '../../services/offline_area_service.dart';
import '../../services/localization_service.dart';
import '../../../app_state.dart';
import '../../../services/offline_area_service.dart';
import '../../../services/localization_service.dart';
class OfflineModeSection extends StatelessWidget {
const OfflineModeSection({super.key});
@@ -61,14 +61,23 @@ class OfflineModeSection extends StatelessWidget {
final locService = LocalizationService.instance;
final appState = context.watch<AppState>();
return ListTile(
leading: const Icon(Icons.wifi_off),
title: Text(locService.t('settings.offlineMode')),
subtitle: Text(locService.t('settings.offlineModeSubtitle')),
trailing: Switch(
value: appState.offlineMode,
onChanged: (value) => _handleOfflineModeChange(context, appState, value),
),
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('settings.offlineMode'),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
ListTile(
leading: const Icon(Icons.wifi_off),
title: Text(locService.t('settings.offlineModeSubtitle')),
trailing: Switch(
value: appState.offlineMode,
onChanged: (value) => _handleOfflineModeChange(context, appState, value),
),
),
],
);
},
);

View File

@@ -1,13 +1,13 @@
import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';
import 'package:provider/provider.dart';
import '../../app_state.dart';
import '../../models/operator_profile.dart';
import '../../services/localization_service.dart';
import '../operator_profile_editor.dart';
import '../../../app_state.dart';
import '../../../models/operator_profile.dart';
import '../../../services/localization_service.dart';
import '../../operator_profile_editor.dart';
class OperatorProfileListSection extends StatelessWidget {
const OperatorProfileListSection({super.key});
class OperatorProfilesSection extends StatelessWidget {
const OperatorProfilesSection({super.key});
@override
Widget build(BuildContext context) {
@@ -22,7 +22,10 @@ class OperatorProfileListSection extends StatelessWidget {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(locService.t('operatorProfiles.title'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
Text(
locService.t('operatorProfiles.title'),
style: Theme.of(context).textTheme.titleMedium,
),
TextButton.icon(
onPressed: () => Navigator.push(
context,

View File

@@ -0,0 +1,221 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../../app_state.dart';
import '../../../services/localization_service.dart';
import '../../../services/proximity_alert_service.dart';
import '../../../dev_config.dart';
/// Settings section for proximity alerts configuration
/// Follows brutalist principles: simple, explicit UI that matches existing patterns
class ProximityAlertsSection extends StatefulWidget {
const ProximityAlertsSection({super.key});
@override
State<ProximityAlertsSection> createState() => _ProximityAlertsSectionState();
}
class _ProximityAlertsSectionState extends State<ProximityAlertsSection> {
late final TextEditingController _distanceController;
bool _notificationsEnabled = false;
bool _checkingPermissions = false;
@override
void initState() {
super.initState();
final appState = context.read<AppState>();
_distanceController = TextEditingController(
text: appState.proximityAlertDistance.toString(),
);
_checkNotificationPermissions();
}
Future<void> _checkNotificationPermissions() async {
setState(() {
_checkingPermissions = true;
});
final enabled = await ProximityAlertService().areNotificationsEnabled();
if (mounted) {
setState(() {
_notificationsEnabled = enabled;
_checkingPermissions = false;
});
}
}
Future<void> _requestNotificationPermissions() async {
setState(() {
_checkingPermissions = true;
});
final enabled = await ProximityAlertService().requestNotificationPermissions();
if (mounted) {
setState(() {
_notificationsEnabled = enabled;
_checkingPermissions = false;
});
}
}
@override
void dispose() {
_distanceController.dispose();
super.dispose();
}
void _updateDistance(AppState appState) {
final text = _distanceController.text.trim();
final distance = int.tryParse(text);
if (distance != null) {
appState.setProximityAlertDistance(distance);
} else {
// Reset to current value if invalid
_distanceController.text = appState.proximityAlertDistance.toString();
}
}
@override
Widget build(BuildContext context) {
return Consumer<AppState>(
builder: (context, appState, child) {
final locService = LocalizationService.instance;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('settings.proximityAlerts'),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
// Enable/disable toggle
SwitchListTile(
title: Text(locService.t('proximityAlerts.getNotified')),
subtitle: Text(
'${locService.t('proximityAlerts.batteryUsage')}\n'
'${_notificationsEnabled ? locService.t('proximityAlerts.notificationsEnabled') : locService.t('proximityAlerts.notificationsDisabled')}',
style: TextStyle(fontSize: 12),
),
value: appState.proximityAlertsEnabled,
onChanged: (enabled) {
appState.setProximityAlertsEnabled(enabled);
if (enabled && !_notificationsEnabled) {
// Automatically try to request permissions when enabling
_requestNotificationPermissions();
}
},
contentPadding: EdgeInsets.zero,
),
// Notification permissions section (only show when proximity alerts are enabled)
if (appState.proximityAlertsEnabled && !_notificationsEnabled && !_checkingPermissions) ...[
const SizedBox(height: 8),
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: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.notifications_off, color: Colors.orange, size: 20),
const SizedBox(width: 8),
Text(
locService.t('proximityAlerts.permissionRequired'),
style: TextStyle(fontWeight: FontWeight.w600),
),
],
),
const SizedBox(height: 8),
Text(
locService.t('proximityAlerts.permissionExplanation'),
style: TextStyle(fontSize: 12),
),
const SizedBox(height: 8),
ElevatedButton.icon(
onPressed: _requestNotificationPermissions,
icon: const Icon(Icons.settings, size: 16),
label: Text(locService.t('proximityAlerts.enableNotifications')),
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 32),
textStyle: const TextStyle(fontSize: 12),
),
),
],
),
),
],
// Loading indicator
if (_checkingPermissions) ...[
const SizedBox(height: 8),
Row(
children: [
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: 8),
Text(locService.t('proximityAlerts.checkingPermissions'), style: TextStyle(fontSize: 12)),
],
),
],
// Distance setting (only show when enabled)
if (appState.proximityAlertsEnabled) ...[
const SizedBox(height: 12),
Row(
children: [
Text(locService.t('proximityAlerts.alertDistance')),
SizedBox(
width: 80,
child: TextField(
controller: _distanceController,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
decoration: const InputDecoration(
isDense: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 8,
vertical: 8,
),
border: OutlineInputBorder(),
),
onSubmitted: (_) => _updateDistance(appState),
onEditingComplete: () => _updateDistance(appState),
),
),
const SizedBox(width: 8),
Text(locService.t('proximityAlerts.meters')),
],
),
const SizedBox(height: 8),
Text(
locService.t('proximityAlerts.rangeInfo', params: [
kProximityAlertMinDistance.toString(),
kProximityAlertMaxDistance.toString(),
kProximityAlertDefaultDistance.toString(),
]),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.6),
),
),
],
],
);
},
);
}
}

View File

@@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../app_state.dart';
import '../../services/localization_service.dart';
import '../../state/settings_state.dart';
import '../../../app_state.dart';
import '../../../services/localization_service.dart';
import '../../../state/settings_state.dart';
class QueueSection extends StatelessWidget {
const QueueSection({super.key});

View File

@@ -0,0 +1,180 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../app_state.dart';
import '../../../models/tile_provider.dart';
import '../../../services/localization_service.dart';
import '../../tile_provider_editor_screen.dart';
class TileProviderSection extends StatelessWidget {
const TileProviderSection({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 providers = appState.tileProviders;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
locService.t('mapTiles.title'),
style: Theme.of(context).textTheme.titleMedium,
),
TextButton.icon(
onPressed: () => _addProvider(context),
icon: const Icon(Icons.add),
label: Text(locService.t('tileProviders.addProvider')),
),
],
),
const SizedBox(height: 8),
if (providers.isEmpty)
Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(locService.t('tileProviders.noProvidersConfigured')),
),
)
else
...providers.map((provider) => _buildProviderTile(context, provider, appState)).toList(),
],
);
},
);
}
Widget _buildProviderTile(BuildContext context, TileProvider provider, AppState appState) {
final locService = LocalizationService.instance;
final isSelected = appState.selectedTileProvider?.id == provider.id;
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
title: Text(
provider.name,
style: TextStyle(
fontWeight: isSelected ? FontWeight.bold : null,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(locService.t('tileProviders.tileTypesCount', params: [provider.tileTypes.length.toString()])),
if (provider.apiKey?.isNotEmpty == true)
Text(
locService.t('tileProviders.apiKeyConfigured'),
style: const TextStyle(
fontStyle: FontStyle.italic,
fontSize: 12,
),
),
if (!provider.isUsable)
Text(
locService.t('tileProviders.needsApiKey'),
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
),
],
),
leading: CircleAvatar(
backgroundColor: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.surfaceVariant,
child: Icon(
Icons.map,
color: isSelected
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
trailing: appState.tileProviders.length > 1
? PopupMenuButton<String>(
onSelected: (action) {
switch (action) {
case 'edit':
_editProvider(context, provider);
break;
case 'delete':
_deleteProvider(context, provider);
break;
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'edit',
child: Row(
children: [
const Icon(Icons.edit),
const SizedBox(width: 8),
Text(locService.t('actions.edit')),
],
),
),
PopupMenuItem(
value: 'delete',
child: Row(
children: [
const Icon(Icons.delete),
const SizedBox(width: 8),
Text(locService.t('tileProviders.deleteProvider')),
],
),
),
],
)
: const Icon(Icons.lock, size: 16), // Can't delete last provider
onTap: () => _editProvider(context, provider),
),
);
}
void _addProvider(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const TileProviderEditorScreen(),
),
);
}
void _editProvider(BuildContext context, TileProvider provider) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => TileProviderEditorScreen(provider: provider),
),
);
}
void _deleteProvider(BuildContext context, TileProvider provider) {
final locService = LocalizationService.instance;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(locService.t('tileProviders.deleteProvider')),
content: Text(locService.t('tileProviders.deleteProviderConfirm', params: [provider.name])),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(locService.t('actions.cancel')),
),
TextButton(
onPressed: () {
context.read<AppState>().deleteTileProvider(provider.id);
Navigator.of(context).pop();
},
child: Text(locService.t('tileProviders.deleteProvider')),
),
],
),
);
}
}

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../app_state.dart';
import '../../services/localization_service.dart';
import '../../../app_state.dart';
import '../../../services/localization_service.dart';
class UploadModeSection extends StatelessWidget {
const UploadModeSection({super.key});
@@ -53,19 +53,9 @@ class UploadModeSection extends StatelessWidget {
style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7))
);
case UploadMode.sandbox:
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('uploadMode.sandboxDescription'),
style: const TextStyle(fontSize: 12, color: Colors.orange),
),
const SizedBox(height: 2),
Text(
locService.t('uploadMode.sandboxNote'),
style: const TextStyle(fontSize: 11, color: Colors.redAccent),
),
],
return Text(
locService.t('uploadMode.sandboxDescription'),
style: const TextStyle(fontSize: 12, color: Colors.orange),
);
case UploadMode.simulate:
default:

View File

@@ -1,53 +1,97 @@
import 'package:flutter/material.dart';
import 'settings_screen_sections/auth_section.dart';
import 'settings_screen_sections/upload_mode_section.dart';
import 'settings_screen_sections/profile_list_section.dart';
import 'settings_screen_sections/operator_profile_list_section.dart';
import 'settings_screen_sections/queue_section.dart';
import 'settings_screen_sections/offline_areas_section.dart';
import 'settings_screen_sections/offline_mode_section.dart';
import 'settings_screen_sections/about_section.dart';
import 'settings_screen_sections/max_nodes_section.dart';
import 'settings_screen_sections/tile_provider_section.dart';
import 'settings_screen_sections/language_section.dart';
import 'settings/sections/auth_section.dart';
import 'settings/sections/upload_mode_section.dart';
import 'settings/sections/queue_section.dart';
import '../services/localization_service.dart';
import '../dev_config.dart';
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context) {
final locService = LocalizationService.instance;
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) => Scaffold(
appBar: AppBar(title: Text(LocalizationService.instance.t('settings.title'))),
appBar: AppBar(title: Text(locService.t('settings.title'))),
body: ListView(
padding: const EdgeInsets.all(16),
children: const [
UploadModeSection(),
Divider(),
AuthSection(),
Divider(),
QueueSection(),
Divider(),
ProfileListSection(),
Divider(),
OperatorProfileListSection(),
Divider(),
MaxNodesSection(),
Divider(),
TileProviderSection(),
Divider(),
OfflineModeSection(),
Divider(),
OfflineAreasSection(),
Divider(),
LanguageSection(),
Divider(),
AboutSection(),
children: [
// Only show upload mode section in development builds
if (kEnableDevelopmentModes) ...[
const UploadModeSection(),
const Divider(),
],
const AuthSection(),
const Divider(),
const QueueSection(),
const Divider(),
// Navigation to sub-pages
_buildNavigationTile(
context,
icon: Icons.account_tree,
title: locService.t('settings.profiles'),
subtitle: locService.t('settings.profilesSubtitle'),
onTap: () => Navigator.pushNamed(context, '/settings/profiles'),
),
const Divider(),
_buildNavigationTile(
context,
icon: Icons.cloud_off,
title: locService.t('settings.offlineSettings'),
subtitle: locService.t('settings.offlineSettingsSubtitle'),
onTap: () => Navigator.pushNamed(context, '/settings/offline'),
),
const Divider(),
_buildNavigationTile(
context,
icon: Icons.tune,
title: locService.t('settings.advancedSettings'),
subtitle: locService.t('settings.advancedSettingsSubtitle'),
onTap: () => Navigator.pushNamed(context, '/settings/advanced'),
),
const Divider(),
_buildNavigationTile(
context,
icon: Icons.language,
title: locService.t('settings.language'),
subtitle: locService.t('settings.languageSubtitle'),
onTap: () => Navigator.pushNamed(context, '/settings/language'),
),
const Divider(),
_buildNavigationTile(
context,
icon: Icons.info_outline,
title: locService.t('settings.aboutInfo'),
subtitle: locService.t('settings.aboutSubtitle'),
onTap: () => Navigator.pushNamed(context, '/settings/about'),
),
],
),
),
);
}
Widget _buildNavigationTile(
BuildContext context, {
required IconData icon,
required String title,
required String subtitle,
required VoidCallback onTap,
}) {
return ListTile(
leading: Icon(icon),
title: Text(title),
subtitle: Text(subtitle),
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
onTap: onTap,
);
}
}

View File

@@ -1,185 +0,0 @@
import 'package:flutter/material.dart';
import '../../services/offline_area_service.dart';
import '../../services/offline_areas/offline_area_models.dart';
class OfflineAreasSection extends StatefulWidget {
const OfflineAreasSection({super.key});
@override
State<OfflineAreasSection> createState() => _OfflineAreasSectionState();
}
class _OfflineAreasSectionState extends State<OfflineAreasSection> {
OfflineAreaService get service => OfflineAreaService();
@override
void initState() {
super.initState();
Future.doWhile(() async {
await Future.delayed(const Duration(seconds: 1));
if (!mounted) return false;
setState(() {});
return true;
});
}
@override
Widget build(BuildContext context) {
final areas = service.offlineAreas;
if (areas.isEmpty) {
return const ListTile(
leading: Icon(Icons.download_for_offline),
title: Text('No offline areas'),
subtitle: Text('Download a map area for offline use.'),
);
}
return Column(
children: areas.map((area) {
String diskStr = area.sizeBytes > 0
? area.sizeBytes > 1024 * 1024
? "${(area.sizeBytes / (1024 * 1024)).toStringAsFixed(2)} MB"
: "${(area.sizeBytes / 1024).toStringAsFixed(1)} KB"
: '--';
String subtitle =
'Provider: ${area.tileProviderDisplay}\n' +
'Z${area.minZoom}-${area.maxZoom}\n' +
'Lat: ${area.bounds.southWest.latitude.toStringAsFixed(3)}, ${area.bounds.southWest.longitude.toStringAsFixed(3)}\n' +
'Lat: ${area.bounds.northEast.latitude.toStringAsFixed(3)}, ${area.bounds.northEast.longitude.toStringAsFixed(3)}';
if (area.status == OfflineAreaStatus.downloading) {
subtitle += '\nTiles: ${area.tilesDownloaded} / ${area.tilesTotal}';
} else {
subtitle += '\nTiles: ${area.tilesTotal}';
}
subtitle += '\nSize: $diskStr';
if (!area.isPermanent) {
subtitle += '\nCameras: ${area.cameras.length}';
}
return Card(
child: ListTile(
leading: Icon(area.status == OfflineAreaStatus.complete
? Icons.cloud_done
: area.status == OfflineAreaStatus.error
? Icons.error
: Icons.download_for_offline),
title: Row(
children: [
Expanded(
child: Text(area.name.isNotEmpty
? area.name
: 'Area ${area.id.substring(0, 6)}...'),
),
if (!area.isPermanent)
IconButton(
icon: const Icon(Icons.edit, size: 20),
tooltip: 'Rename area',
onPressed: () async {
String? newName = await showDialog<String>(
context: context,
builder: (ctx) {
final ctrl = TextEditingController(text: area.name);
return AlertDialog(
title: const Text('Rename Offline Area'),
content: TextField(
controller: ctrl,
maxLength: 40,
decoration: const InputDecoration(labelText: 'Area Name'),
autofocus: true,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () {
Navigator.pop(ctx, ctrl.text.trim());
},
child: const Text('Rename'),
),
],
);
},
);
if (newName != null && newName.trim().isNotEmpty) {
setState(() {
area.name = newName.trim();
service.saveAreasToDisk();
});
}
},
),
if (area.isPermanent && area.status != OfflineAreaStatus.downloading)
IconButton(
icon: const Icon(Icons.refresh, color: Colors.blue),
tooltip: 'Refresh/re-download world tiles',
onPressed: () async {
await service.downloadArea(
id: area.id,
bounds: area.bounds,
minZoom: area.minZoom,
maxZoom: area.maxZoom,
directory: area.directory,
name: area.name,
onProgress: (progress) {},
onComplete: (status) {},
tileProviderId: area.tileProviderId,
tileProviderName: area.tileProviderName,
tileTypeId: area.tileTypeId,
tileTypeName: area.tileTypeName,
);
setState(() {});
},
)
else if (!area.isPermanent && area.status != OfflineAreaStatus.downloading)
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
tooltip: 'Delete offline area',
onPressed: () async {
service.deleteArea(area.id);
setState(() {});
},
),
],
),
subtitle: Text(subtitle),
isThreeLine: true,
trailing: area.status == OfflineAreaStatus.downloading
? Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 64,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
LinearProgressIndicator(value: area.progress),
Text(
'${(area.progress * 100).toStringAsFixed(0)}%',
style: const TextStyle(fontSize: 12),
)
],
),
),
IconButton(
icon: const Icon(Icons.cancel, color: Colors.orange),
tooltip: 'Cancel download',
onPressed: () {
service.cancelDownload(area.id);
setState(() {});
},
)
],
)
: null,
onLongPress: area.status == OfflineAreaStatus.downloading
? () {
service.cancelDownload(area.id);
setState(() {});
}
: null,
),
);
}).toList(),
);
}
}

View File

@@ -1,46 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../app_state.dart';
import '../../models/tile_provider.dart';
import '../../services/localization_service.dart';
import '../tile_provider_management_screen.dart';
class TileProviderSection extends StatelessWidget {
const TileProviderSection({super.key});
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('mapTiles.title'),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const TileProviderManagementScreen(),
),
);
},
icon: const Icon(Icons.settings),
label: Text(locService.t('mapTiles.manageProviders')),
),
),
],
);
},
);
}
}

View File

@@ -7,6 +7,7 @@ import 'package:collection/collection.dart';
import '../app_state.dart';
import '../models/tile_provider.dart';
import '../services/localization_service.dart';
import '../dev_config.dart';
class TileProviderEditorScreen extends StatefulWidget {
final TileProvider? provider; // null for adding new provider
@@ -378,11 +379,11 @@ class _TileTypeDialogState extends State<_TileTypeDialog> {
});
try {
// Use a sample tile (zoom 10, somewhere in the world)
// Use a sample tile from configured preview location
final url = _urlController.text
.replaceAll('{z}', '10')
.replaceAll('{x}', '512')
.replaceAll('{y}', '384');
.replaceAll('{z}', kPreviewTileZoom.toString())
.replaceAll('{x}', kPreviewTileX.toString())
.replaceAll('{y}', kPreviewTileY.toString());
final response = await http.get(Uri.parse(url));

View File

@@ -1,168 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../app_state.dart';
import '../models/tile_provider.dart';
import '../services/localization_service.dart';
import 'tile_provider_editor_screen.dart';
class TileProviderManagementScreen extends StatelessWidget {
const TileProviderManagementScreen({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 providers = appState.tileProviders;
return Scaffold(
appBar: AppBar(
title: Text(locService.t('tileProviders.title')),
actions: [
IconButton(
icon: const Icon(Icons.add),
onPressed: () => _addProvider(context),
),
],
),
body: providers.isEmpty
? Center(
child: Text(locService.t('tileProviders.noProvidersConfigured')),
)
: ListView.builder(
itemCount: providers.length,
itemBuilder: (context, index) {
final provider = providers[index];
final isSelected = appState.selectedTileProvider?.id == provider.id;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: ListTile(
title: Text(
provider.name,
style: TextStyle(
fontWeight: isSelected ? FontWeight.bold : null,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(locService.t('tileProviders.tileTypesCount', params: [provider.tileTypes.length.toString()])),
if (provider.apiKey?.isNotEmpty == true)
Text(
locService.t('tileProviders.apiKeyConfigured'),
style: const TextStyle(
fontStyle: FontStyle.italic,
fontSize: 12,
),
),
if (!provider.isUsable)
Text(
locService.t('tileProviders.needsApiKey'),
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
),
],
),
leading: CircleAvatar(
backgroundColor: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.surfaceVariant,
child: Icon(
Icons.map,
color: isSelected
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
trailing: providers.length > 1
? PopupMenuButton<String>(
onSelected: (action) {
switch (action) {
case 'edit':
_editProvider(context, provider);
break;
case 'delete':
_deleteProvider(context, provider);
break;
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'edit',
child: Row(
children: [
const Icon(Icons.edit),
const SizedBox(width: 8),
Text(locService.t('actions.edit')),
],
),
),
PopupMenuItem(
value: 'delete',
child: Row(
children: [
const Icon(Icons.delete),
const SizedBox(width: 8),
Text(locService.t('tileProviders.deleteProvider')),
],
),
),
],
)
: const Icon(Icons.lock, size: 16), // Can't delete last provider
onTap: () => _editProvider(context, provider),
),
);
},
),
);
},
);
}
void _addProvider(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const TileProviderEditorScreen(),
),
);
}
void _editProvider(BuildContext context, TileProvider provider) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => TileProviderEditorScreen(provider: provider),
),
);
}
void _deleteProvider(BuildContext context, TileProvider provider) {
final locService = LocalizationService.instance;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(locService.t('tileProviders.deleteProvider')),
content: Text(locService.t('tileProviders.deleteProviderConfirm', params: [provider.name])),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(locService.t('actions.cancel')),
),
TextButton(
onPressed: () {
context.read<AppState>().deleteTileProvider(provider.id);
Navigator.of(context).pop();
},
child: Text(locService.t('tileProviders.deleteProvider')),
),
],
),
);
}
}

View File

@@ -106,6 +106,36 @@ class AuthService {
}
}
// Restore login state from stored token (for app startup)
Future<String?> restoreLogin() async {
if (_mode == UploadMode.simulate) {
final prefs = await SharedPreferences.getInstance();
final isLoggedIn = prefs.getBool('sim_user_logged_in') ?? false;
if (isLoggedIn) {
_displayName = 'Demo User';
return _displayName;
}
return null;
}
// Get stored token directly from SharedPreferences
final accessToken = await getAccessToken();
if (accessToken == null) {
return null;
}
try {
_displayName = await _fetchUsername(accessToken);
return _displayName;
} catch (e) {
print('AuthService: Error restoring login with stored token: $e');
log('Error restoring login with stored token: $e');
// Token might be expired or invalid, clear it
await logout();
return null;
}
}
Future<void> logout() async {
if (_mode == UploadMode.simulate) {
final prefs = await SharedPreferences.getInstance();

View File

@@ -1,56 +0,0 @@
import 'package:latlong2/latlong.dart';
import '../models/osm_camera_node.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
class CameraCache {
// Singleton instance
static final CameraCache instance = CameraCache._internal();
factory CameraCache() => instance;
CameraCache._internal();
final Map<int, OsmCameraNode> _nodes = {};
/// Add or update a batch of camera nodes in the cache.
void addOrUpdate(List<OsmCameraNode> nodes) {
for (var node in nodes) {
final existing = _nodes[node.id];
if (existing != null) {
// Preserve any tags starting with underscore when updating existing nodes
final mergedTags = Map<String, String>.from(node.tags);
for (final entry in existing.tags.entries) {
if (entry.key.startsWith('_')) {
mergedTags[entry.key] = entry.value;
}
}
_nodes[node.id] = OsmCameraNode(
id: node.id,
coord: node.coord,
tags: mergedTags,
);
} else {
_nodes[node.id] = node;
}
}
}
/// Query for all cached cameras currently within the given LatLngBounds.
List<OsmCameraNode> queryByBounds(LatLngBounds bounds) {
return _nodes.values
.where((node) => _inBounds(node.coord, bounds))
.toList();
}
/// Retrieve all cached cameras.
List<OsmCameraNode> getAll() => _nodes.values.toList();
/// Optionally clear the cache (rarely needed)
void clear() => _nodes.clear();
/// Utility: point-in-bounds for coordinates
bool _inBounds(LatLng coord, LatLngBounds bounds) {
return coord.latitude >= bounds.southWest.latitude &&
coord.latitude <= bounds.northEast.latitude &&
coord.longitude >= bounds.southWest.longitude &&
coord.longitude <= bounds.northEast.longitude;
}
}

View File

@@ -24,9 +24,44 @@ class LocalizationService extends ChangeNotifier {
}
Future<void> _discoverAvailableLanguages() async {
// For now, we'll hardcode the languages we support
// In the future, this could scan the assets directory
_availableLanguages = ['en', 'es', 'fr', 'de'];
_availableLanguages = [];
try {
// Get the asset manifest to find all localization files
final manifestContent = await rootBundle.loadString('AssetManifest.json');
final Map<String, dynamic> manifestMap = json.decode(manifestContent);
// Find all .json files in lib/localizations/
final localizationFiles = manifestMap.keys
.where((String key) => key.startsWith('lib/localizations/') && key.endsWith('.json'))
.toList();
for (final filePath in localizationFiles) {
// Extract language code from filename (e.g., 'lib/localizations/pt.json' -> 'pt')
final fileName = filePath.split('/').last;
final languageCode = fileName.substring(0, fileName.length - 5); // Remove '.json'
try {
// Try to load and parse the file to ensure it's valid
final jsonString = await rootBundle.loadString(filePath);
final parsedJson = json.decode(jsonString);
// Basic validation - ensure it has the expected structure
if (parsedJson is Map && parsedJson.containsKey('language')) {
_availableLanguages.add(languageCode);
debugPrint('Found localization: $languageCode');
}
} catch (e) {
debugPrint('Failed to load localization file $filePath: $e');
}
}
} catch (e) {
debugPrint('Failed to read AssetManifest.json: $e');
// If manifest reading fails, we'll have an empty list
// The system will handle this gracefully by falling back to 'en' in _loadSavedLanguage
}
debugPrint('Available languages: $_availableLanguages');
}
Future<void> _loadSavedLanguage() async {
@@ -99,10 +134,10 @@ class LocalizationService extends ChangeNotifier {
String result = current is String ? current : key;
// Replace parameters if provided
// Replace parameters if provided - replace first occurrence only for each parameter
if (params != null) {
for (int i = 0; i < params.length; i++) {
result = result.replaceAll('{}', params[i]);
result = result.replaceFirst('{}', params[i]);
}
}

View File

@@ -3,12 +3,14 @@ import 'package:flutter_map/flutter_map.dart';
import 'package:flutter/foundation.dart';
import '../models/node_profile.dart';
import '../models/osm_camera_node.dart';
import '../models/osm_node.dart';
import '../app_state.dart';
import 'map_data_submodules/nodes_from_overpass.dart';
import 'map_data_submodules/nodes_from_osm_api.dart';
import 'map_data_submodules/tiles_from_remote.dart';
import 'map_data_submodules/nodes_from_local.dart';
import 'map_data_submodules/tiles_from_local.dart';
import 'network_status.dart';
enum MapSource { local, remote, auto } // For future use
@@ -33,12 +35,13 @@ class MapDataProvider {
/// Fetch surveillance nodes from OSM/Overpass or local storage.
/// Remote is default. If source is MapSource.auto, remote is tried first unless offline.
Future<List<OsmCameraNode>> getNodes({
Future<List<OsmNode>> getNodes({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
UploadMode uploadMode = UploadMode.production,
MapSource source = MapSource.auto,
}) async {
try {
final offline = AppState.instance.offlineMode;
// Explicit remote request: error if offline, else always remote
@@ -46,7 +49,7 @@ class MapDataProvider {
if (offline) {
throw OfflineModeException("Cannot fetch remote nodes in offline mode.");
}
return fetchOverpassNodes(
return _fetchRemoteNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
@@ -62,51 +65,97 @@ class MapDataProvider {
);
}
// AUTO: default = remote first, fallback to local only if offline
// AUTO: In offline mode, behavior depends on upload mode
if (offline) {
return fetchLocalNodes(
bounds: bounds,
profiles: profiles,
maxNodes: AppState.instance.maxCameras,
);
} else {
// Try remote, fallback to local ONLY if remote throws (optional, could be removed for stricter behavior)
try {
return await fetchOverpassNodes(
if (uploadMode == UploadMode.sandbox) {
// Offline + Sandbox = no nodes (local cache is production data)
debugPrint('[MapDataProvider] Offline + Sandbox mode: returning no nodes (local cache is production data)');
return <OsmNode>[];
} else {
// Offline + Production = use local cache
return fetchLocalNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: AppState.instance.maxCameras,
maxNodes: AppState.instance.maxCameras,
);
} catch (e) {
debugPrint('[MapDataProvider] Remote node fetch failed, error: $e. Falling back to local.');
return fetchLocalNodes(
}
} else if (uploadMode == UploadMode.sandbox) {
// Sandbox mode: Only fetch from sandbox API, ignore local production nodes
debugPrint('[MapDataProvider] Sandbox mode: fetching only from sandbox API, ignoring local cache');
return _fetchRemoteNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: AppState.instance.maxCameras,
);
} else {
// Production mode: fetch both remote and local, then merge with deduplication
final List<Future<List<OsmNode>>> futures = [];
// Always try to get local nodes (fast, cached)
futures.add(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
}));
// 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;
}
// Add remote nodes, overwriting any local duplicates
for (final node in remoteNodes) {
mergedNodes[node.id] = node;
}
// 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();
}
}
/// Bulk/paged node fetch for offline downloads (handling paging, dedup, and Overpass retries)
/// Only use for offline area download, not for map browsing! Ignores maxCameras config.
Future<List<OsmCameraNode>> getAllNodesForDownload({
Future<List<OsmNode>> getAllNodesForDownload({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
UploadMode uploadMode = UploadMode.production,
int pageSize = 500,
int maxResults = 0, // 0 = no limit for offline downloads
int maxTries = 3,
}) async {
final offline = AppState.instance.offlineMode;
if (offline) {
throw OfflineModeException("Cannot fetch remote nodes for offline area download in offline mode.");
}
return fetchOverpassNodes(
return _fetchRemoteNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: pageSize,
maxResults: maxResults, // Pass 0 for unlimited
);
}
@@ -163,4 +212,58 @@ class MapDataProvider {
void clearTileQueue() {
clearRemoteTileQueue();
}
/// Fetch remote nodes with Overpass first, OSM API fallback
Future<List<OsmNode>> _fetchRemoteNodes({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
UploadMode uploadMode = UploadMode.production,
required int maxResults,
}) async {
// For sandbox mode, skip Overpass and go directly to OSM API
// (Overpass doesn't have sandbox data)
if (uploadMode == UploadMode.sandbox) {
debugPrint('[MapDataProvider] Sandbox mode detected, using OSM API directly');
return fetchOsmApiNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: maxResults,
);
}
// For production mode, try Overpass first, then fallback to OSM API
try {
final nodes = await fetchOverpassNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: maxResults,
);
// If Overpass returns nodes, we're good
if (nodes.isNotEmpty) {
return nodes;
}
// If Overpass returns empty (could be no data or could be an issue),
// try OSM API as well to be thorough
debugPrint('[MapDataProvider] Overpass returned no nodes, trying OSM API fallback');
return fetchOsmApiNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: maxResults,
);
} catch (e) {
debugPrint('[MapDataProvider] Overpass failed ($e), trying OSM API fallback');
return fetchOsmApiNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: maxResults,
);
}
}
}

View File

@@ -1,20 +1,21 @@
import 'dart:io';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
import '../../models/osm_camera_node.dart';
import '../../models/osm_node.dart';
import '../../models/node_profile.dart';
import '../offline_area_service.dart';
import '../offline_areas/offline_area_models.dart';
/// Fetch surveillance nodes from all offline areas intersecting the bounds/profile list.
Future<List<OsmCameraNode>> fetchLocalNodes({
Future<List<OsmNode>> fetchLocalNodes({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
int? maxNodes,
}) async {
final areas = OfflineAreaService().offlineAreas;
final Map<int, OsmCameraNode> deduped = {};
final Map<int, OsmNode> deduped = {};
for (final area in areas) {
if (area.status != OfflineAreaStatus.complete) continue;
@@ -37,16 +38,32 @@ Future<List<OsmCameraNode>> fetchLocalNodes({
}
// Try in-memory first, else load from disk
Future<List<OsmCameraNode>> _loadAreaNodes(OfflineArea area) async {
if (area.cameras.isNotEmpty) {
return area.cameras;
Future<List<OsmNode>> _loadAreaNodes(OfflineArea area) async {
if (area.nodes.isNotEmpty) {
return area.nodes;
}
final file = File('${area.directory}/cameras.json');
if (await file.exists()) {
final str = await file.readAsString();
final jsonList = jsonDecode(str) as List;
return jsonList.map((e) => OsmCameraNode.fromJson(e)).toList();
// Try new nodes.json first, fall back to legacy cameras.json for backward compatibility
final nodeFile = File('${area.directory}/nodes.json');
final legacyCameraFile = File('${area.directory}/cameras.json');
File? fileToLoad;
if (await nodeFile.exists()) {
fileToLoad = nodeFile;
} else if (await legacyCameraFile.exists()) {
fileToLoad = legacyCameraFile;
}
if (fileToLoad != null) {
try {
final str = await fileToLoad.readAsString();
final jsonList = jsonDecode(str) as List;
return jsonList.map((e) => OsmNode.fromJson(e)).toList();
} catch (e) {
debugPrint('[_loadAreaNodes] Error loading nodes from ${fileToLoad.path}: $e');
}
}
return [];
}
@@ -57,14 +74,14 @@ bool _pointInBounds(LatLng pt, LatLngBounds bounds) {
pt.longitude <= bounds.northEast.longitude;
}
bool _matchesAnyProfile(OsmCameraNode node, List<NodeProfile> profiles) {
bool _matchesAnyProfile(OsmNode node, List<NodeProfile> profiles) {
for (final prof in profiles) {
if (_nodeMatchesProfile(node, prof)) return true;
}
return false;
}
bool _nodeMatchesProfile(OsmCameraNode node, NodeProfile profile) {
bool _nodeMatchesProfile(OsmNode node, NodeProfile profile) {
for (final e in profile.tags.entries) {
if (node.tags[e.key] != e.value) return false; // All profile tags must match
}

View File

@@ -0,0 +1,129 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:xml/xml.dart';
import '../../models/node_profile.dart';
import '../../models/osm_node.dart';
import '../../app_state.dart';
import '../network_status.dart';
/// Fetches surveillance nodes from the direct OSM API using bbox query.
/// This is a fallback for when Overpass is not available (e.g., sandbox mode).
Future<List<OsmNode>> fetchOsmApiNodes({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
UploadMode uploadMode = UploadMode.production,
required int maxResults,
}) async {
if (profiles.isEmpty) return [];
// Choose API endpoint based on upload mode
final String apiHost = uploadMode == UploadMode.sandbox
? 'api06.dev.openstreetmap.org'
: 'api.openstreetmap.org';
// Build the map query URL - fetches all data in bounding box
final left = bounds.southWest.longitude;
final bottom = bounds.southWest.latitude;
final right = bounds.northEast.longitude;
final top = bounds.northEast.latitude;
final url = 'https://$apiHost/api/0.6/map?bbox=$left,$bottom,$right,$top';
try {
debugPrint('[fetchOsmApiNodes] Querying OSM API for nodes in bbox...');
debugPrint('[fetchOsmApiNodes] URL: $url');
final response = await http.get(Uri.parse(url));
if (response.statusCode != 200) {
debugPrint('[fetchOsmApiNodes] OSM API error: ${response.statusCode} - ${response.body}');
NetworkStatus.instance.reportOverpassIssue(); // Reuse same status tracking
return [];
}
// 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;
}
}
if (nodes.isNotEmpty) {
debugPrint('[fetchOsmApiNodes] Retrieved ${nodes.length} matching surveillance nodes');
}
NetworkStatus.instance.reportOverpassSuccess(); // Reuse same status tracking
return nodes;
} catch (e) {
debugPrint('[fetchOsmApiNodes] Exception: $e');
// Report network issues for connection errors
if (e.toString().contains('Connection refused') ||
e.toString().contains('Connection timed out') ||
e.toString().contains('Connection reset')) {
NetworkStatus.instance.reportOverpassIssue();
}
return [];
}
}
/// 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) {
if (_nodeMatchesProfile(nodeTags, profile)) {
return true;
}
}
return false;
}
/// Check if a node's tags match a specific profile
bool _nodeMatchesProfile(Map<String, String> nodeTags, NodeProfile profile) {
// All profile tags must be present in the node for it to match
for (final entry in profile.tags.entries) {
if (nodeTags[entry.key] != entry.value) {
return false;
}
}
return true;
}

View File

@@ -5,12 +5,13 @@ import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart';
import '../../models/node_profile.dart';
import '../../models/osm_camera_node.dart';
import '../../models/osm_node.dart';
import '../../models/pending_upload.dart';
import '../../app_state.dart';
import '../network_status.dart';
/// Fetches surveillance nodes from the Overpass OSM API for the given bounds and profiles.
Future<List<OsmCameraNode>> fetchOverpassNodes({
Future<List<OsmNode>> fetchOverpassNodes({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
UploadMode uploadMode = UploadMode.production,
@@ -47,14 +48,19 @@ Future<List<OsmCameraNode>> fetchOverpassNodes({
NetworkStatus.instance.reportOverpassSuccess();
return elements.whereType<Map<String, dynamic>>().map((element) {
return OsmCameraNode(
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();
// Clean up any pending uploads that now appear in Overpass results
_cleanupCompletedUploads(nodes);
return nodes;
} catch (e) {
debugPrint('[fetchOverpassNodes] Exception: $e');
@@ -82,11 +88,50 @@ String _buildOverpassQuery(LatLngBounds bounds, List<NodeProfile> profiles, int
return 'node$tagFilters(${bounds.southWest.latitude},${bounds.southWest.longitude},${bounds.northEast.latitude},${bounds.northEast.longitude});';
}).join('\n ');
// Use unlimited output if maxResults is 0
final outputClause = maxResults > 0 ? 'out body $maxResults;' : 'out body;';
return '''
[out:json][timeout:25];
(
$nodeClauses
);
out body $maxResults;
$outputClause
''';
}
/// Clean up pending uploads that now appear in Overpass results
void _cleanupCompletedUploads(List<OsmNode> overpassNodes) {
try {
final appState = AppState.instance;
final pendingUploads = appState.pendingUploads;
if (pendingUploads.isEmpty) return;
final overpassNodeIds = overpassNodes.map((n) => n.id).toSet();
// Find pending uploads whose submitted node IDs now appear in Overpass results
final uploadsToRemove = <PendingUpload>[];
for (final upload in pendingUploads) {
if (upload.submittedNodeId != null &&
overpassNodeIds.contains(upload.submittedNodeId!)) {
uploadsToRemove.add(upload);
debugPrint('[OverpassCleanup] Found submitted node ${upload.submittedNodeId} in Overpass results, removing from pending queue');
}
}
// Remove the completed uploads from the queue
for (final upload in uploadsToRemove) {
appState.removeFromQueue(upload);
}
if (uploadsToRemove.isNotEmpty) {
debugPrint('[OverpassCleanup] Cleaned up ${uploadsToRemove.length} completed uploads');
}
} catch (e) {
debugPrint('[OverpassCleanup] Error during cleanup: $e');
// Don't let cleanup errors break the main functionality
}
}

View File

@@ -6,6 +6,9 @@ import '../app_state.dart';
enum NetworkIssueType { osmTiles, overpassApi, both }
enum NetworkStatusType { waiting, issues, timedOut, noData, ready, success }
/// 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._();
NetworkStatus._();
@@ -23,6 +26,13 @@ class NetworkStatus extends ChangeNotifier {
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;
// Getters
bool get hasAnyIssues => _osmTilesHaveIssues || _overpassHasIssues;
bool get osmTilesHaveIssues => _osmTilesHaveIssues;
@@ -32,7 +42,22 @@ class NetworkStatus extends ChangeNotifier {
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;
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
if (hasAnyIssues) return NetworkStatusType.issues;
if (_isWaitingForData) return NetworkStatusType.waiting;
if (_isTimedOut) return NetworkStatusType.timedOut;
@@ -206,12 +231,91 @@ 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() {
_osmRecoveryTimer?.cancel();
_overpassRecoveryTimer?.cancel();
_waitingTimer?.cancel();
_noDataResetTimer?.cancel();
_tileTimeoutTimer?.cancel();
_nodeTimeoutTimer?.cancel();
_successDisplayTimer?.cancel();
super.dispose();
}
}

View File

@@ -0,0 +1,110 @@
import 'package:latlong2/latlong.dart';
import '../models/osm_node.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
class NodeCache {
// Singleton instance
static final NodeCache instance = NodeCache._internal();
factory NodeCache() => instance;
NodeCache._internal();
final Map<int, OsmNode> _nodes = {};
/// Add or update a batch of nodes in the cache.
void addOrUpdate(List<OsmNode> nodes) {
for (var node in nodes) {
final existing = _nodes[node.id];
if (existing != null) {
// Preserve any tags starting with underscore when updating existing nodes
final mergedTags = Map<String, String>.from(node.tags);
for (final entry in existing.tags.entries) {
if (entry.key.startsWith('_')) {
mergedTags[entry.key] = entry.value;
}
}
_nodes[node.id] = OsmNode(
id: node.id,
coord: node.coord,
tags: mergedTags,
);
} else {
_nodes[node.id] = node;
}
}
}
/// Query for all cached nodes currently within the given LatLngBounds.
List<OsmNode> queryByBounds(LatLngBounds bounds) {
return _nodes.values
.where((node) => _inBounds(node.coord, bounds))
.toList();
}
/// Retrieve all cached nodes.
List<OsmNode> getAll() => _nodes.values.toList();
/// Optionally clear the cache (rarely needed)
void clear() => _nodes.clear();
/// Remove the _pending_edit marker from a specific node
void removePendingEditMarker(int nodeId) {
final node = _nodes[nodeId];
if (node != null && node.tags.containsKey('_pending_edit')) {
final cleanTags = Map<String, String>.from(node.tags);
cleanTags.remove('_pending_edit');
_nodes[nodeId] = OsmNode(
id: node.id,
coord: node.coord,
tags: cleanTags,
);
}
}
/// Remove a node by ID from the cache (used for successful deletions)
void removeNodeById(int nodeId) {
if (_nodes.remove(nodeId) != null) {
print('[NodeCache] Removed node $nodeId from cache (successful deletion)');
}
}
/// Remove temporary nodes (negative IDs) with _pending_upload marker at the given coordinate
/// This is used when a real node ID is assigned to clean up temp placeholders
void removeTempNodesByCoordinate(LatLng coord, {double tolerance = 0.00001}) {
final nodesToRemove = <int>[];
for (final entry in _nodes.entries) {
final nodeId = entry.key;
final node = entry.value;
// Only consider temp nodes (negative IDs) with pending upload marker
if (nodeId < 0 &&
node.tags.containsKey('_pending_upload') &&
_coordsMatch(node.coord, coord, tolerance)) {
nodesToRemove.add(nodeId);
}
}
for (final nodeId in nodesToRemove) {
_nodes.remove(nodeId);
}
if (nodesToRemove.isNotEmpty) {
print('[NodeCache] Removed ${nodesToRemove.length} temp nodes at coordinate ${coord.latitude}, ${coord.longitude}');
}
}
/// Check if two coordinates match within tolerance
bool _coordsMatch(LatLng coord1, LatLng coord2, double tolerance) {
return (coord1.latitude - coord2.latitude).abs() < tolerance &&
(coord1.longitude - coord2.longitude).abs() < tolerance;
}
/// Utility: point-in-bounds for coordinates
bool _inBounds(LatLng coord, LatLngBounds bounds) {
return coord.latitude >= bounds.southWest.latitude &&
coord.latitude <= bounds.northEast.latitude &&
coord.longitude >= bounds.southWest.longitude &&
coord.longitude <= bounds.northEast.longitude;
}
}

View File

@@ -7,8 +7,8 @@ import 'package:path_provider/path_provider.dart';
import 'offline_areas/offline_area_models.dart';
import 'offline_areas/offline_tile_utils.dart';
import 'offline_areas/offline_area_downloader.dart';
import 'offline_areas/world_area_manager.dart';
import '../models/osm_camera_node.dart';
import '../models/osm_node.dart';
import '../app_state.dart';
import 'map_data_provider.dart';
import 'package:deflockapp/dev_config.dart';
@@ -59,8 +59,7 @@ class OfflineAreaService {
if (_initialized) return;
await _loadAreasFromDisk();
await WorldAreaManager.ensureWorldArea(_areas, getOfflineAreaDir, downloadArea);
await saveAreasToDisk(); // Save any world area updates
await _cleanupLegacyWorldAreas();
_initialized = true;
}
@@ -262,9 +261,6 @@ class OfflineAreaService {
}
_areas.remove(area);
await saveAreasToDisk();
if (area.isPermanent) {
await WorldAreaManager.ensureWorldArea(_areas, getOfflineAreaDir, downloadArea);
}
}
void deleteArea(String id) async {
@@ -276,6 +272,140 @@ class OfflineAreaService {
_areas.remove(area);
await saveAreasToDisk();
}
/// Refresh/update an existing offline area - tiles, nodes, or both
Future<void> refreshArea({
required String id,
required bool refreshTiles,
required bool refreshNodes,
void Function(double progress)? onProgress,
void Function(OfflineAreaStatus status)? onComplete,
}) async {
final area = _areas.firstWhere((a) => a.id == id, orElse: () => throw 'Area not found');
if (area.status == OfflineAreaStatus.downloading) {
throw 'Area is already downloading';
}
// Set area to downloading state
area.status = OfflineAreaStatus.downloading;
area.progress = 0.0;
// Only reset tile count if we're actually refreshing tiles
if (refreshTiles) {
area.tilesDownloaded = 0;
}
await saveAreasToDisk();
try {
bool success = true;
if (refreshTiles && refreshNodes) {
// Refresh both - use the full download process
success = await OfflineAreaDownloader.downloadArea(
area: area,
bounds: area.bounds,
minZoom: area.minZoom,
maxZoom: area.maxZoom,
directory: area.directory,
onProgress: onProgress,
saveAreasToDisk: saveAreasToDisk,
getAreaSizeBytes: getAreaSizeBytes,
);
} else if (refreshTiles) {
// Refresh tiles only
success = await _refreshTilesOnly(area, onProgress);
} else if (refreshNodes) {
// Refresh nodes only
success = await _refreshNodesOnly(area, onProgress);
} else {
// Neither option selected - shouldn't happen but handle gracefully
success = true;
area.progress = 1.0;
}
await getAreaSizeBytes(area);
if (success) {
area.status = OfflineAreaStatus.complete;
area.progress = 1.0;
debugPrint('Area $id: refresh completed successfully.');
} else {
area.status = OfflineAreaStatus.error;
debugPrint('Area $id: refresh failed after maximum retry attempts.');
}
await saveAreasToDisk();
onComplete?.call(area.status);
} catch (e) {
area.status = OfflineAreaStatus.error;
await saveAreasToDisk();
onComplete?.call(area.status);
debugPrint('Area $id: refresh failed with exception: $e');
}
}
/// Refresh only the tiles for an area
Future<bool> _refreshTilesOnly(OfflineArea area, void Function(double progress)? onProgress) async {
final allTiles = computeTileList(area.bounds, area.minZoom, area.maxZoom);
area.tilesTotal = allTiles.length;
return await OfflineAreaDownloader.downloadTilesWithRetry(
area: area,
allTiles: allTiles,
directory: area.directory,
onProgress: onProgress,
saveAreasToDisk: saveAreasToDisk,
getAreaSizeBytes: getAreaSizeBytes,
);
}
/// Refresh only the nodes for an area
Future<bool> _refreshNodesOnly(OfflineArea area, void Function(double progress)? onProgress) async {
try {
// Use the same logic as in the downloader for consistency
final nodeZoom = (area.minZoom + 1).clamp(8, 16);
final expandedNodeBounds = OfflineAreaDownloader.calculateNodeBounds(area.bounds, nodeZoom);
final nodes = await MapDataProvider().getAllNodesForDownload(
bounds: expandedNodeBounds,
profiles: AppState.instance.profiles,
);
area.nodes = nodes;
await OfflineAreaDownloader.saveNodes(nodes, area.directory);
// Set progress to complete for nodes-only refresh
onProgress?.call(1.0);
area.progress = 1.0;
debugPrint('Area ${area.id}: Refreshed ${nodes.length} nodes');
return true;
} catch (e) {
debugPrint('Area ${area.id}: Failed to refresh nodes: $e');
return false;
}
}
/// Remove any legacy world areas from previous versions
Future<void> _cleanupLegacyWorldAreas() async {
final worldAreas = _areas.where((area) => area.isPermanent || area.id == 'world').toList();
if (worldAreas.isNotEmpty) {
debugPrint('OfflineAreaService: Cleaning up ${worldAreas.length} legacy world area(s)');
for (final area in worldAreas) {
final dir = Directory(area.directory);
if (await dir.exists()) {
await dir.delete(recursive: true);
debugPrint('OfflineAreaService: Deleted world area directory: ${area.directory}');
}
_areas.remove(area);
}
await saveAreasToDisk();
debugPrint('OfflineAreaService: Legacy world area cleanup complete');
}
}
}

View File

@@ -6,11 +6,10 @@ import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
import '../../app_state.dart';
import '../../models/osm_camera_node.dart';
import '../../models/osm_node.dart';
import '../map_data_provider.dart';
import 'offline_area_models.dart';
import 'offline_tile_utils.dart';
import 'package:deflockapp/dev_config.dart';
/// Handles the actual downloading process for offline areas
class OfflineAreaDownloader {
@@ -27,16 +26,11 @@ class OfflineAreaDownloader {
required Future<void> Function() saveAreasToDisk,
required Future<void> Function(OfflineArea) getAreaSizeBytes,
}) async {
Set<List<int>> allTiles;
if (area.isPermanent) {
allTiles = computeTileList(globalWorldBounds(), kWorldMinZoom, kWorldMaxZoom);
} else {
allTiles = computeTileList(bounds, minZoom, maxZoom);
}
Set<List<int>> allTiles = computeTileList(bounds, minZoom, maxZoom);
area.tilesTotal = allTiles.length;
// Download tiles with retry logic
final success = await _downloadTilesWithRetry(
final success = await downloadTilesWithRetry(
area: area,
allTiles: allTiles,
directory: directory,
@@ -45,23 +39,19 @@ class OfflineAreaDownloader {
getAreaSizeBytes: getAreaSizeBytes,
);
// Download cameras for non-permanent areas
if (!area.isPermanent) {
await _downloadCameras(
area: area,
bounds: bounds,
minZoom: minZoom,
directory: directory,
);
} else {
area.cameras = [];
}
// Download nodes for all areas
await _downloadNodes(
area: area,
bounds: bounds,
minZoom: minZoom,
directory: directory,
);
return success;
}
/// Download tiles with retry logic
static Future<bool> _downloadTilesWithRetry({
static Future<bool> downloadTilesWithRetry({
required OfflineArea area,
required Set<List<int>> allTiles,
required String directory,
@@ -138,26 +128,29 @@ class OfflineAreaDownloader {
return missingTiles;
}
/// Download cameras for the area with expanded bounds
static Future<void> _downloadCameras({
/// Download nodes for the area with modest expansion (one zoom level lower)
static Future<void> _downloadNodes({
required OfflineArea area,
required LatLngBounds bounds,
required int minZoom,
required String directory,
}) async {
// Calculate expanded camera bounds that cover the entire tile area at minimum zoom
final cameraBounds = _calculateCameraBounds(bounds, minZoom);
final cameras = await MapDataProvider().getAllNodesForDownload(
bounds: cameraBounds,
// Modest expansion: use tiles at minZoom + 1 instead of minZoom
// This gives a reasonable buffer without capturing entire states
final nodeZoom = (minZoom + 1).clamp(8, 16); // Reasonable bounds for node fetching
final expandedNodeBounds = calculateNodeBounds(bounds, nodeZoom);
final nodes = await MapDataProvider().getAllNodesForDownload(
bounds: expandedNodeBounds,
profiles: AppState.instance.profiles, // Use ALL profiles, not just enabled ones
);
area.cameras = cameras;
await OfflineAreaDownloader.saveCameras(cameras, directory);
debugPrint('Area ${area.id}: Downloaded ${cameras.length} cameras from expanded bounds (all profiles)');
area.nodes = nodes;
await OfflineAreaDownloader.saveNodes(nodes, directory);
debugPrint('Area ${area.id}: Downloaded ${nodes.length} nodes from modestly expanded bounds (all profiles)');
}
/// Calculate expanded bounds that cover the entire tile area at minimum zoom
static LatLngBounds _calculateCameraBounds(LatLngBounds visibleBounds, int minZoom) {
static LatLngBounds calculateNodeBounds(LatLngBounds visibleBounds, int minZoom) {
final tiles = computeTileList(visibleBounds, minZoom, minZoom);
if (tiles.isEmpty) return visibleBounds;
@@ -188,9 +181,9 @@ class OfflineAreaDownloader {
await file.writeAsBytes(bytes);
}
/// Save cameras to disk as JSON
static Future<void> saveCameras(List<OsmCameraNode> cams, String dir) async {
final file = File('$dir/cameras.json');
await file.writeAsString(jsonEncode(cams.map((c) => c.toJson()).toList()));
/// Save nodes to disk as JSON
static Future<void> saveNodes(List<OsmNode> nodes, String dir) async {
final file = File('$dir/nodes.json');
await file.writeAsString(jsonEncode(nodes.map((n) => n.toJson()).toList()));
}
}

View File

@@ -1,6 +1,6 @@
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
import '../../models/osm_camera_node.dart';
import '../../models/osm_node.dart';
/// Status of an offline area
enum OfflineAreaStatus { downloading, complete, error, cancelled }
@@ -17,7 +17,7 @@ class OfflineArea {
double progress; // 0.0 - 1.0
int tilesDownloaded;
int tilesTotal;
List<OsmCameraNode> cameras;
List<OsmNode> nodes;
int sizeBytes; // Disk size in bytes
final bool isPermanent; // Not user-deletable if true
@@ -38,7 +38,7 @@ class OfflineArea {
this.progress = 0,
this.tilesDownloaded = 0,
this.tilesTotal = 0,
this.cameras = const [],
this.nodes = const [],
this.sizeBytes = 0,
this.isPermanent = false,
this.tileProviderId,
@@ -61,7 +61,7 @@ class OfflineArea {
'progress': progress,
'tilesDownloaded': tilesDownloaded,
'tilesTotal': tilesTotal,
'cameras': cameras.map((c) => c.toJson()).toList(),
'nodes': nodes.map((n) => n.toJson()).toList(),
'sizeBytes': sizeBytes,
'isPermanent': isPermanent,
'tileProviderId': tileProviderId,
@@ -87,8 +87,8 @@ class OfflineArea {
progress: (json['progress'] ?? 0).toDouble(),
tilesDownloaded: json['tilesDownloaded'] ?? 0,
tilesTotal: json['tilesTotal'] ?? 0,
cameras: (json['cameras'] as List? ?? [])
.map((e) => OsmCameraNode.fromJson(e)).toList(),
nodes: (json['nodes'] as List? ?? json['cameras'] as List? ?? [])
.map((e) => OsmNode.fromJson(e)).toList(),
sizeBytes: json['sizeBytes'] ?? 0,
isPermanent: json['isPermanent'] ?? false,
tileProviderId: json['tileProviderId'],

View File

@@ -1,153 +0,0 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
import 'package:path_provider/path_provider.dart';
import 'offline_area_models.dart';
import 'offline_tile_utils.dart';
import 'package:deflockapp/dev_config.dart';
/// Manages the world area (permanent offline area for base map)
class WorldAreaManager {
static const String _worldAreaId = 'world';
static const String _worldAreaName = 'World Base Map';
/// Ensure world area exists and check if download is needed
static Future<OfflineArea> ensureWorldArea(
List<OfflineArea> areas,
Future<Directory> Function() getOfflineAreaDir,
Future<void> Function({
required String id,
required LatLngBounds bounds,
required int minZoom,
required int maxZoom,
required String directory,
String? name,
String? tileProviderId,
String? tileProviderName,
String? tileTypeId,
String? tileTypeName,
}) downloadArea,
) async {
// Find existing world area
OfflineArea? world;
for (final area in areas) {
if (area.isPermanent) {
world = area;
break;
}
}
// Create world area if it doesn't exist, or update existing area without provider info
if (world == null) {
final appDocDir = await getOfflineAreaDir();
final dir = "${appDocDir.path}/$_worldAreaId";
world = OfflineArea(
id: _worldAreaId,
name: _worldAreaName,
bounds: globalWorldBounds(),
minZoom: kWorldMinZoom,
maxZoom: kWorldMaxZoom,
directory: dir,
status: OfflineAreaStatus.downloading,
isPermanent: true,
// World area always uses OpenStreetMap
tileProviderId: 'openstreetmap',
tileProviderName: 'OpenStreetMap',
tileTypeId: 'osm_street',
tileTypeName: 'Street Map',
);
areas.insert(0, world);
} else if (world.tileProviderId == null || world.tileTypeId == null) {
// Update existing world area that lacks provider metadata
final updatedWorld = OfflineArea(
id: world.id,
name: world.name,
bounds: world.bounds,
minZoom: world.minZoom,
maxZoom: world.maxZoom,
directory: world.directory,
status: world.status,
progress: world.progress,
tilesDownloaded: world.tilesDownloaded,
tilesTotal: world.tilesTotal,
cameras: world.cameras,
sizeBytes: world.sizeBytes,
isPermanent: world.isPermanent,
// Add missing provider metadata
tileProviderId: 'openstreetmap',
tileProviderName: 'OpenStreetMap',
tileTypeId: 'osm_street',
tileTypeName: 'Street Map',
);
final index = areas.indexOf(world);
areas[index] = updatedWorld;
world = updatedWorld;
}
// Check world area status and start download if needed
await _checkAndStartWorldDownload(world, downloadArea);
return world;
}
/// Check world area download status and start if needed
static Future<void> _checkAndStartWorldDownload(
OfflineArea world,
Future<void> Function({
required String id,
required LatLngBounds bounds,
required int minZoom,
required int maxZoom,
required String directory,
String? name,
String? tileProviderId,
String? tileProviderName,
String? tileTypeId,
String? tileTypeName,
}) downloadArea,
) async {
if (world.status == OfflineAreaStatus.complete) return;
// Count existing tiles
final expectedTiles = computeTileList(
globalWorldBounds(),
kWorldMinZoom,
kWorldMaxZoom,
);
int filesFound = 0;
for (final tile in expectedTiles) {
final file = File('${world.directory}/tiles/${tile[0]}/${tile[1]}/${tile[2]}.png');
if (file.existsSync()) {
filesFound++;
}
}
// Update world area stats
world.tilesTotal = expectedTiles.length;
world.tilesDownloaded = filesFound;
world.progress = (world.tilesTotal == 0) ? 0.0 : (filesFound / world.tilesTotal);
if (filesFound == world.tilesTotal) {
world.status = OfflineAreaStatus.complete;
debugPrint('WorldAreaManager: World area download already complete.');
} else {
world.status = OfflineAreaStatus.downloading;
debugPrint('WorldAreaManager: Starting world area download. ${world.tilesDownloaded}/${world.tilesTotal} tiles found.');
// Start download (fire and forget) - use OSM for world areas
downloadArea(
id: world.id,
bounds: world.bounds,
minZoom: world.minZoom,
maxZoom: world.maxZoom,
directory: world.directory,
name: world.name,
tileProviderId: 'openstreetmap',
tileProviderName: 'OpenStreetMap',
tileTypeId: 'osm_street',
tileTypeName: 'Street Map',
);
}
}
}

View File

@@ -0,0 +1,257 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:geolocator/geolocator.dart';
import 'package:latlong2/latlong.dart';
import '../models/osm_node.dart';
import '../models/node_profile.dart';
import '../dev_config.dart';
/// Simple data class for tracking recent proximity alerts to prevent spam
class RecentAlert {
final int nodeId;
final DateTime alertTime;
RecentAlert({required this.nodeId, required this.alertTime});
}
/// Service for handling proximity alerts when approaching surveillance nodes
/// Follows brutalist principles: simple, explicit, easy to understand
class ProximityAlertService {
static final ProximityAlertService _instance = ProximityAlertService._internal();
factory ProximityAlertService() => _instance;
ProximityAlertService._internal();
FlutterLocalNotificationsPlugin? _notifications;
bool _isInitialized = false;
// Simple in-memory tracking of recent alerts to prevent spam
final List<RecentAlert> _recentAlerts = [];
static const Duration _alertCooldown = kProximityAlertCooldown;
// Callback for showing in-app visual alerts
VoidCallback? _onVisualAlert;
/// Initialize the notification plugin and request permissions
Future<void> initialize({VoidCallback? onVisualAlert}) async {
_onVisualAlert = onVisualAlert;
_notifications = FlutterLocalNotificationsPlugin();
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
const iosSettings = DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
);
const initSettings = InitializationSettings(
android: androidSettings,
iOS: iosSettings,
);
try {
final initialized = await _notifications!.initialize(initSettings);
_isInitialized = initialized ?? false;
// Request notification permissions (especially important for Android 13+)
if (_isInitialized) {
await _requestNotificationPermissions();
}
debugPrint('[ProximityAlertService] Initialized: $_isInitialized');
} catch (e) {
debugPrint('[ProximityAlertService] Failed to initialize: $e');
_isInitialized = false;
}
}
/// Request notification permissions on both platforms
Future<void> _requestNotificationPermissions() async {
if (_notifications == null) return;
try {
// Request permissions - this will show the permission dialog on Android 13+
final result = await _notifications!
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
?.requestNotificationsPermission();
debugPrint('[ProximityAlertService] Android notification permission result: $result');
// Also request for iOS (though this was already done in initialization)
await _notifications!
.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>()
?.requestPermissions(
alert: true,
badge: true,
sound: true,
);
} catch (e) {
debugPrint('[ProximityAlertService] Failed to request permissions: $e');
}
}
/// Check proximity to nodes and trigger alerts if needed
/// This should be called on GPS position updates
Future<void> checkProximity({
required LatLng userLocation,
required List<OsmNode> nodes,
required List<NodeProfile> enabledProfiles,
required int alertDistance,
}) async {
if (!_isInitialized || nodes.isEmpty) return;
// Clean up old alerts (anything older than cooldown period)
final cutoffTime = DateTime.now().subtract(_alertCooldown);
_recentAlerts.removeWhere((alert) => alert.alertTime.isBefore(cutoffTime));
// Check each node for proximity
for (final node in nodes) {
// Skip if we recently alerted for this node
if (_recentAlerts.any((alert) => alert.nodeId == node.id)) {
continue;
}
// Calculate distance using Geolocator's distanceBetween
final distance = Geolocator.distanceBetween(
userLocation.latitude,
userLocation.longitude,
node.coord.latitude,
node.coord.longitude,
);
// Check if within alert distance
if (distance <= alertDistance) {
// Determine node type for alert message
final nodeType = _getNodeTypeDescription(node, enabledProfiles);
// Trigger both push notification and visual alert
await _showNotification(node, nodeType, distance.round());
_showVisualAlert();
// Track this alert to prevent spam
_recentAlerts.add(RecentAlert(
nodeId: node.id,
alertTime: DateTime.now(),
));
debugPrint('[ProximityAlertService] Alert triggered for node ${node.id} ($nodeType) at ${distance.round()}m');
}
}
}
/// Show push notification for proximity alert
Future<void> _showNotification(OsmNode node, String nodeType, int distance) async {
if (!_isInitialized || _notifications == null) return;
const androidDetails = AndroidNotificationDetails(
'proximity_alerts',
'Proximity Alerts',
channelDescription: 'Notifications when approaching surveillance devices',
importance: Importance.high,
priority: Priority.high,
enableVibration: true,
playSound: true,
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: false,
presentSound: true,
);
const notificationDetails = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
final title = 'Surveillance Device Nearby';
final body = '$nodeType detected ${distance}m ahead';
try {
await _notifications!.show(
node.id, // Use node ID as notification ID
title,
body,
notificationDetails,
);
} catch (e) {
debugPrint('[ProximityAlertService] Failed to show notification: $e');
}
}
/// Trigger visual alert in the app UI
void _showVisualAlert() {
_onVisualAlert?.call();
}
/// Get a user-friendly description of the node type
String _getNodeTypeDescription(OsmNode node, List<NodeProfile> enabledProfiles) {
final tags = node.tags;
// Check for specific surveillance types
if (tags.containsKey('man_made') && tags['man_made'] == 'surveillance') {
final surveillanceType = tags['surveillance:type'] ?? 'surveillance device';
if (surveillanceType == 'camera') return 'Camera';
if (surveillanceType == 'ALPR') return 'License plate reader';
return 'Surveillance device';
}
// Check for emergency devices
if (tags.containsKey('emergency') && tags['emergency'] == 'siren') {
return 'Emergency siren';
}
// Fall back to checking enabled profiles to see what type this might be
for (final profile in enabledProfiles) {
bool matches = true;
for (final entry in profile.tags.entries) {
if (node.tags[entry.key] != entry.value) {
matches = false;
break;
}
}
if (matches) {
return profile.name;
}
}
return 'Surveillance device';
}
/// Get count of recent alerts (for debugging/testing)
int get recentAlertCount => _recentAlerts.length;
/// Clear recent alerts (for testing)
void clearRecentAlerts() {
_recentAlerts.clear();
}
/// Check if notification permissions are granted
Future<bool> areNotificationsEnabled() async {
if (!_isInitialized || _notifications == null) return false;
try {
// Check Android permissions
final androidImpl = _notifications!
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
if (androidImpl != null) {
final result = await androidImpl.areNotificationsEnabled();
return result ?? false;
}
// For iOS, assume enabled if we got this far (permissions were requested during init)
return true;
} catch (e) {
debugPrint('[ProximityAlertService] Failed to check notification permissions: $e');
return false;
}
}
/// Request permissions again (can be called from settings)
Future<bool> requestNotificationPermissions() async {
await _requestNotificationPermissions();
return await areNotificationsEnabled();
}
}

View File

@@ -10,6 +10,9 @@ import 'network_status.dart';
class SimpleTileHttpClient extends http.BaseClient {
final http.Client _inner = http.Client();
final MapDataProvider _mapDataProvider = MapDataProvider();
// Tile completion tracking (brutalist approach)
int _pendingTileRequests = 0;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
@@ -48,14 +51,14 @@ class SimpleTileHttpClient extends http.BaseClient {
}
Future<http.StreamedResponse> _handleTileRequest(int z, int x, int y) async {
// Increment pending counter (brutalist completion detection)
_pendingTileRequests++;
try {
// Always go through MapDataProvider - it handles offline/online routing
// MapDataProvider will get current provider from AppState
final tileBytes = await _mapDataProvider.getTile(z: z, x: x, y: y, source: MapSource.auto);
// Show success status briefly
NetworkStatus.instance.setSuccess();
// Serve tile with proper cache headers
return http.StreamedResponse(
Stream.value(tileBytes),
@@ -71,15 +74,18 @@ class SimpleTileHttpClient extends http.BaseClient {
} catch (e) {
debugPrint('[SimpleTileService] Could not get tile $z/$x/$y: $e');
// 404 means no tiles available - show "no data" status briefly
NetworkStatus.instance.setNoData();
// Return 404 and let flutter_map handle it gracefully
return http.StreamedResponse(
Stream.value(<int>[]),
404,
reasonPhrase: 'Tile unavailable: $e',
);
} finally {
// Decrement pending counter and report completion when all done
_pendingTileRequests--;
if (_pendingTileRequests == 0) {
NetworkStatus.instance.reportTileComplete();
}
}
}

View File

@@ -0,0 +1,78 @@
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../models/tile_provider.dart';
import '../state/settings_state.dart';
import '../dev_config.dart';
/// Service for fetching missing tile preview images
class TilePreviewService {
static const Duration _timeout = Duration(seconds: 10);
/// Attempt to fetch missing preview tiles for tile types that don't already have preview data
/// Fails silently - no error handling or user notification on failure
static Future<void> fetchMissingPreviews(SettingsState settingsState) async {
try {
bool anyUpdates = false;
for (final provider in settingsState.tileProviders) {
final updatedTileTypes = <TileType>[];
bool providerNeedsUpdate = false;
for (final tileType in provider.tileTypes) {
// Only fetch if preview tile is missing
if (tileType.previewTile == null) {
// Skip if tile type requires API key but provider doesn't have one
if (tileType.requiresApiKey && (provider.apiKey == null || provider.apiKey!.isEmpty)) {
updatedTileTypes.add(tileType);
continue;
}
final previewData = await _fetchPreviewForTileType(tileType, provider.apiKey);
if (previewData != null) {
// Create updated tile type with preview data
final updatedTileType = tileType.copyWith(previewTile: previewData);
updatedTileTypes.add(updatedTileType);
providerNeedsUpdate = true;
} else {
updatedTileTypes.add(tileType);
}
} else {
updatedTileTypes.add(tileType);
}
}
if (providerNeedsUpdate) {
final updatedProvider = provider.copyWith(tileTypes: updatedTileTypes);
await settingsState.addOrUpdateTileProvider(updatedProvider);
anyUpdates = true;
}
}
if (anyUpdates) {
debugPrint('TilePreviewService: Updated providers with new preview tiles');
}
} catch (e) {
// Fail silently as requested
debugPrint('TilePreviewService: Error during preview fetching: $e');
}
}
static Future<Uint8List?> _fetchPreviewForTileType(TileType tileType, String? apiKey) async {
try {
final url = tileType.getTileUrl(kPreviewTileZoom, kPreviewTileX, kPreviewTileY, apiKey: apiKey);
final response = await http.get(Uri.parse(url)).timeout(_timeout);
if (response.statusCode == 200 && response.bodyBytes.isNotEmpty) {
debugPrint('TilePreviewService: Fetched preview for ${tileType.name}');
return response.bodyBytes;
}
} catch (e) {
// Fail silently - just log for debugging
debugPrint('TilePreviewService: Failed to fetch preview for ${tileType.name}: $e');
}
return null;
}
}

View File

@@ -9,7 +9,7 @@ class Uploader {
Uploader(this.accessToken, this.onSuccess, {this.uploadMode = UploadMode.production});
final String accessToken;
final void Function() onSuccess;
final void Function(int nodeId) onSuccess;
final UploadMode uploadMode;
Future<bool> upload(PendingUpload p) async {
@@ -17,7 +17,18 @@ class Uploader {
print('Uploader: Starting upload for node at ${p.coord.latitude}, ${p.coord.longitude}');
// 1. open changeset
final action = p.isEdit ? 'Update' : 'Add';
String action;
switch (p.operation) {
case UploadOperation.create:
action = 'Add';
break;
case UploadOperation.modify:
action = 'Update';
break;
case UploadOperation.delete:
action = 'Delete';
break;
}
final csXml = '''
<osm>
<changeset>
@@ -35,63 +46,100 @@ class Uploader {
final csId = csResp.body.trim();
print('Uploader: Created changeset ID: $csId');
// 2. create or update node
final mergedTags = p.getCombinedTags();
final tagsXml = mergedTags.entries.map((e) =>
'<tag k="${e.key}" v="${e.value}"/>').join('\n ');
// 2. create, update, or delete node
final http.Response nodeResp;
final String nodeId;
if (p.isEdit) {
// First, fetch the current node to get its version
print('Uploader: Fetching current node ${p.originalNodeId} to get version...');
final currentNodeResp = await _get('/api/0.6/node/${p.originalNodeId}');
print('Uploader: Current node response: ${currentNodeResp.statusCode}');
if (currentNodeResp.statusCode != 200) {
print('Uploader: Failed to fetch current node');
return false;
}
// Parse version from the response XML
final currentNodeXml = currentNodeResp.body;
final versionMatch = RegExp(r'version="(\d+)"').firstMatch(currentNodeXml);
if (versionMatch == null) {
print('Uploader: Could not parse version from current node XML');
return false;
}
final currentVersion = versionMatch.group(1)!;
print('Uploader: Current node version: $currentVersion');
// Update existing node with version
final nodeXml = '''
<osm>
<node changeset="$csId" id="${p.originalNodeId}" version="$currentVersion" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
$tagsXml
</node>
</osm>''';
print('Uploader: Updating node ${p.originalNodeId}...');
nodeResp = await _put('/api/0.6/node/${p.originalNodeId}', nodeXml);
nodeId = p.originalNodeId.toString();
} else {
// Create new node
final nodeXml = '''
switch (p.operation) {
case UploadOperation.create:
// Create new node
final mergedTags = p.getCombinedTags();
final tagsXml = mergedTags.entries.map((e) =>
'<tag k="${e.key}" v="${e.value}"/>').join('\n ');
final nodeXml = '''
<osm>
<node changeset="$csId" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
$tagsXml
</node>
</osm>''';
print('Uploader: Creating new node...');
nodeResp = await _put('/api/0.6/node/create', nodeXml);
nodeId = nodeResp.body.trim();
print('Uploader: Creating new node...');
nodeResp = await _put('/api/0.6/node/create', nodeXml);
nodeId = nodeResp.body.trim();
break;
case UploadOperation.modify:
// First, fetch the current node to get its version
print('Uploader: Fetching current node ${p.originalNodeId} to get version...');
final currentNodeResp = await _get('/api/0.6/node/${p.originalNodeId}');
print('Uploader: Current node response: ${currentNodeResp.statusCode}');
if (currentNodeResp.statusCode != 200) {
print('Uploader: Failed to fetch current node');
return false;
}
// Parse version from the response XML
final currentNodeXml = currentNodeResp.body;
final versionMatch = RegExp(r'version="(\d+)"').firstMatch(currentNodeXml);
if (versionMatch == null) {
print('Uploader: Could not parse version from current node XML');
return false;
}
final currentVersion = versionMatch.group(1)!;
print('Uploader: Current node version: $currentVersion');
// Update existing node with version
final mergedTags = p.getCombinedTags();
final tagsXml = mergedTags.entries.map((e) =>
'<tag k="${e.key}" v="${e.value}"/>').join('\n ');
final nodeXml = '''
<osm>
<node changeset="$csId" id="${p.originalNodeId}" version="$currentVersion" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
$tagsXml
</node>
</osm>''';
print('Uploader: Updating node ${p.originalNodeId}...');
nodeResp = await _put('/api/0.6/node/${p.originalNodeId}', nodeXml);
nodeId = p.originalNodeId.toString();
break;
case UploadOperation.delete:
// First, fetch the current node to get its version and coordinates
print('Uploader: Fetching current node ${p.originalNodeId} for deletion...');
final currentNodeResp = await _get('/api/0.6/node/${p.originalNodeId}');
print('Uploader: Current node response: ${currentNodeResp.statusCode}');
if (currentNodeResp.statusCode != 200) {
print('Uploader: Failed to fetch current node');
return false;
}
// Parse version and tags from the response XML
final currentNodeXml = currentNodeResp.body;
final versionMatch = RegExp(r'version="(\d+)"').firstMatch(currentNodeXml);
if (versionMatch == null) {
print('Uploader: Could not parse version from current node XML');
return false;
}
final currentVersion = versionMatch.group(1)!;
print('Uploader: Current node version: $currentVersion');
// Delete node - OSM requires current tags and coordinates
final nodeXml = '''
<osm>
<node changeset="$csId" id="${p.originalNodeId}" version="$currentVersion" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
</node>
</osm>''';
print('Uploader: Deleting node ${p.originalNodeId}...');
nodeResp = await _delete('/api/0.6/node/${p.originalNodeId}', nodeXml);
nodeId = p.originalNodeId.toString();
break;
}
print('Uploader: Node response: ${nodeResp.statusCode} - ${nodeResp.body}');
if (nodeResp.statusCode != 200) {
print('Uploader: Failed to ${p.isEdit ? "update" : "create"} node');
print('Uploader: Failed to ${p.operation.name} node');
return false;
}
print('Uploader: ${p.isEdit ? "Updated" : "Created"} node ID: $nodeId');
print('Uploader: ${p.operation.name.capitalize()} node ID: $nodeId');
// 3. close changeset
print('Uploader: Closing changeset...');
@@ -99,7 +147,8 @@ class Uploader {
print('Uploader: Close response: ${closeResp.statusCode}');
print('Uploader: Upload successful!');
onSuccess();
final nodeIdInt = int.parse(nodeId);
onSuccess(nodeIdInt);
return true;
} catch (e) {
print('Uploader: Upload failed with error: $e');
@@ -134,9 +183,21 @@ class Uploader {
body: body,
);
Future<http.Response> _delete(String path, String body) => http.delete(
Uri.https(_host, path),
headers: _headers,
body: body,
);
Map<String, String> get _headers => {
'Authorization': 'Bearer $accessToken',
'Content-Type': 'text/xml',
};
}
extension StringExtension on String {
String capitalize() {
return "${this[0].toUpperCase()}${substring(1)}";
}
}

View File

@@ -18,7 +18,7 @@ class AuthState extends ChangeNotifier {
try {
if (await _auth.isLoggedIn()) {
_username = await _auth.login();
_username = await _auth.restoreLogin();
}
} catch (e) {
print("AuthState: Error during auth initialization: $e");
@@ -44,7 +44,7 @@ class AuthState extends ChangeNotifier {
Future<void> refreshAuthState() async {
try {
if (await _auth.isLoggedIn()) {
_username = await _auth.login();
_username = await _auth.restoreLogin();
} else {
_username = null;
}
@@ -83,7 +83,7 @@ class AuthState extends ChangeNotifier {
if (await _auth.isLoggedIn()) {
final isValid = await validateToken();
if (isValid) {
_username = await _auth.login();
_username = await _auth.restoreLogin();
} else {
await logout(); // This clears _username also.
}

View File

@@ -3,7 +3,7 @@ import 'package:latlong2/latlong.dart';
import '../models/node_profile.dart';
import '../models/operator_profile.dart';
import '../models/osm_camera_node.dart';
import '../models/osm_node.dart';
// ------------------ AddNodeSession ------------------
class AddNodeSession {
@@ -23,7 +23,7 @@ class EditNodeSession {
required this.target,
});
final OsmCameraNode originalNode; // The original node being edited
final OsmNode originalNode; // The original node being edited
NodeProfile profile;
OperatorProfile? operatorProfile;
double directionDegrees;
@@ -48,7 +48,7 @@ class SessionState extends ChangeNotifier {
notifyListeners();
}
void startEditSession(OsmCameraNode node, List<NodeProfile> enabledProfiles) {
void startEditSession(OsmNode node, List<NodeProfile> enabledProfiles) {
final submittableProfiles = enabledProfiles.where((p) => p.isSubmittable).toList();
// Try to find a matching profile based on the node's tags

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