Compare commits

..

15 Commits

Author SHA1 Message Date
stopflock
c7f4164f12 update changelog 2025-10-22 18:47:56 -05:00
stopflock
27c404687a Fix north-up UX 2025-10-22 18:46:48 -05:00
stopflock
c8e2727f69 bump ver, get rid of buggy north-lock 2025-10-22 17:36:34 -05:00
stopflock
728ec08ab0 bump ver 2025-10-22 15:53:34 -05:00
stopflock
23a056bfe5 Validate localizations on build 2025-10-22 15:52:50 -05:00
stopflock
76d0ece314 always show welcome checkbox to not show again 2025-10-22 15:51:25 -05:00
stopflock
cd2ab00042 North-up compass and rotation lock 2025-10-22 15:27:28 -05:00
stopflock
ee3906df80 Update dev notes and changelog 2025-10-22 12:09:00 -05:00
stopflock
ca68bd6059 fix network indicator, simplify overpass fetching 2025-10-22 11:56:01 -05:00
stopflock
aea4ac1102 update readme 2025-10-21 15:51:59 -05:00
stopflock
62cf70e36e fix swiping away add/edit sheets 2025-10-21 15:33:51 -05:00
stopflock
de0bd7f275 too much, sorry 2025-10-21 15:11:50 -05:00
stopflock
2ccd01c691 Pre-fetch larger areas to reduce calls to overpass; split large requests up 2025-10-21 10:22:04 -05:00
stopflock
d696e1dfb6 Add welcome popup, changelog system, and account deletion button 2025-10-20 19:27:55 -05:00
stopflock
07fe869eec Update readme/dev notes 2025-10-16 21:47:58 -05:00
38 changed files with 2075 additions and 242 deletions

View File

@@ -51,6 +51,9 @@ jobs:
- name: Install dependencies
run: flutter pub get
- name: Validate localizations
run: dart run scripts/validate_localizations.dart
- name: Generate icons and splash screens
run: |
dart run flutter_launcher_icons
@@ -100,6 +103,9 @@ jobs:
- name: Install dependencies
run: flutter pub get
- name: Validate localizations
run: dart run scripts/validate_localizations.dart
- name: Generate icons and splash screens
run: |
dart run flutter_launcher_icons
@@ -142,6 +148,9 @@ jobs:
- name: Install dependencies
run: flutter pub get
- name: Validate localizations
run: dart run scripts/validate_localizations.dart
- name: Generate icons and splash screens
run: |
dart run flutter_launcher_icons

View File

@@ -33,7 +33,10 @@ AppState (main coordinator)
├── ProfileState (node profiles & toggles)
├── SessionState (add/edit sessions)
├── SettingsState (preferences & tile providers)
── UploadQueueState (pending operations)
── UploadQueueState (pending operations)
├── SuspectedLocationState (permit data & display)
├── NavigationState (routing & search)
└── SearchState (location search results)
```
**Why this approach:**
@@ -62,25 +65,75 @@ External APIs (OSM, Overpass, Tile providers)
---
## Changelog & First Launch System
The app includes a comprehensive system for welcoming new users and notifying existing users of updates.
### Components
- **ChangelogService**: Manages version tracking and changelog loading
- **WelcomeDialog**: First launch popup with privacy information and quick links
- **ChangelogDialog**: Update notification popup for version changes
- **ReleaseNotesScreen**: Settings page for viewing all changelog history
### Content Management
Changelog content is stored in `assets/changelog.json`:
```json
{
"1.2.4": {
"content": "• New feature description\n• Bug fixes\n• Other improvements"
},
"1.2.3": {
"content": "" // Empty string = skip popup for this version
}
}
```
### Developer Workflow
1. **For each release**: Add entry to `changelog.json` with version from `pubspec.yaml`
2. **Content required**: Every version must have an entry (can be empty string to skip)
3. **Localization**: Welcome dialog supports i18n, changelog content is English-only
4. **Testing**: Clear app data to test first launch, change version to test updates
### User Experience Flow
- **First Launch**: Welcome popup with "don't show again" option
- **Version Updates**: Changelog popup (only if content exists, no "don't show again")
- **Settings Access**: Complete changelog history available in Settings > About > Release Notes
### Privacy Integration
The welcome popup explains that the app:
- Runs entirely locally on device
- Uses OpenStreetMap API for data storage only
- DeFlock collects no user data
- DeFlock is not responsible for OSM account management
---
## Core Components
### 1. MapDataProvider
### 1. MapDataProvider & Smart Area Caching
**Purpose**: Unified interface for fetching map tiles and surveillance nodes
**Purpose**: Unified interface for fetching map tiles and surveillance nodes with intelligent area caching
**Design decisions:**
- **Pluggable sources**: Local (cached) vs Remote (live API)
- **Offline-first**: Always try local first, graceful degradation
- **Single fetch strategy**: Uses PrefetchAreaService for smart 3x area caching instead of dual immediate/background fetching
- **Spatial + temporal refresh**: Fetches larger areas (3x visible bounds) and refreshes stale data (>60s old)
- **Offline-first**: Always try local cache first, graceful degradation
- **Mode-aware**: Different behavior for production vs sandbox
- **Failure handling**: Never crash the UI, always provide fallbacks
**Key methods:**
- `getNodes()`: Smart fetching with local/remote merging
- `getTile()`: Tile fetching with caching
- `getNodes()`: Returns cache immediately, triggers pre-fetch if needed (spatial or temporal)
- `getTile()`: Tile fetching with enhanced retry strategy (6 attempts, 1-8s delays)
- `_fetchRemoteNodes()`: Handles Overpass → OSM API fallback
**Why unified interface:**
The app needs to seamlessly switch between multiple data sources (local cache, Overpass API, OSM API, offline areas) based on network status, upload mode, and zoom level. A single interface prevents the UI from needing to know about these complexities.
**Smart caching flow:**
1. Check if current view within cached area AND data <60s old
2. If not: trigger pre-fetch of 3x larger area, show loading state
3. Return cache immediately for responsive UI
4. When pre-fetch completes: update cache, refresh UI, report success
**Why this approach:**
Reduces API load by 3-4x while ensuring data freshness. User sees instant responses from cache while background fetching keeps data current. Eliminates complex dual-path logic in favor of simple spatial/temporal triggers.
### 2. Node Operations (Create/Edit/Delete)
@@ -140,7 +193,7 @@ Users expect instant response to their actions. By immediately updating the cach
**Why underscore prefix:**
These are internal app tags, not OSM tags. The underscore prefix makes this explicit and prevents accidental upload to OSM.
### 5. Multi-API Data Sources
### 5. Enhanced Overpass Integration & Error Handling
**Production mode:** Overpass API → OSM API fallback
**Sandbox mode:** OSM API only (Overpass doesn't have sandbox data)
@@ -149,8 +202,19 @@ These are internal app tags, not OSM tags. The underscore prefix makes this expl
- **Production (Overpass)**: Zoom ≥ 10 (established limit)
- **Sandbox (OSM API)**: Zoom ≥ 13 (stricter due to bbox limits)
**Why different zoom limits:**
The OSM API returns ALL data types (nodes, ways, relations) in a bounding box and has stricter size limits. Overpass is more efficient for large areas. The zoom restrictions prevent API errors and excessive data transfer.
**Smart error handling & splitting:**
- **50k node limit**: Automatically splits query into 4 quadrants, recursively up to 3 levels deep
- **Timeout errors**: Also triggers splitting (dense areas with many profiles)
- **Rate limiting**: Extended backoff (30s), no splitting (would make it worse)
- **Surgical detection**: Only splits on actual limit errors, not network issues
**Query optimization:**
- **Pre-fetch limit**: 4x user's display limit (e.g., 1000 nodes for 250 display limit)
- **User-initiated detection**: Only reports loading status for user-facing operations
- **Background operations**: Pre-fetch runs silently, doesn't trigger loading states
**Why this approach:**
Dense urban areas (SF, NYC) with many profiles enabled can easily exceed both 50k node limits and 25s timeouts. Splitting reduces query complexity while surgical error detection avoids unnecessary API load from network issues.
### 6. Offline vs Online Mode Behavior
@@ -165,6 +229,80 @@ Sandbox + Offline → No nodes (cache is production data)
**Why sandbox + offline = no nodes:**
Local cache contains production data. Showing production nodes in sandbox mode would be confusing and could lead to users trying to edit production nodes with sandbox credentials.
### 7. Proximity Alerts & Background Monitoring
**Design approach:**
- **Simple cooldown system**: In-memory tracking to prevent notification spam
- **Dual alert types**: Push notifications (background) and visual banners (foreground)
- **Configurable distance**: 25-200 meter alert radius
- **Battery awareness**: Users explicitly opt into background location monitoring
**Implementation notes:**
- Uses Flutter Local Notifications for cross-platform background alerts
- Simple RecentAlert tracking prevents duplicate notifications
- Visual callback system for in-app alerts when app is active
### 8. Compass Indicator & North Lock
**Purpose**: Visual compass showing map orientation with optional north-lock functionality
**Design decisions:**
- **Separate from follow mode**: North lock is independent of GPS following behavior
- **Smart rotation detection**: Distinguishes intentional rotation (>5°) from zoom gestures
- **Visual feedback**: Clear skeumorphic compass design with red north indicator
- **Mode awareness**: Disabled during follow+rotate mode (incompatible)
**Key behaviors:**
- **North indicator**: Red arrow always points toward true north regardless of map rotation
- **Tap to toggle**: Enable/disable north lock with visual animation to north
- **Auto-disable**: North lock turns off when switching to follow+rotate mode
- **Gesture intelligence**: Only disables on significant rotation changes, ignores zoom artifacts
**Visual states:**
- **Normal**: White background, grey border, red north arrow
- **North locked**: White background, blue border, bright red north arrow
- **Disabled**: Grey background, muted colors (during follow+rotate mode)
**Why separate from follow mode:**
Users often want to follow their location while keeping the map oriented north. Previous "north up" follow mode was confusing because it didn't actually keep north up. This separation provides clear, predictable behavior.
### 9. Suspected Locations
**Data pipeline:**
- **CSV ingestion**: Downloads utility permit data from alprwatch.org
- **GeoJSON processing**: Handles Point, Polygon, and MultiPolygon geometries
- **Proximity filtering**: Hides suspected locations near confirmed devices
- **Regional availability**: Currently select locations, expanding regularly
**Why utility permits:**
Utility companies often must file permits when installing surveillance infrastructure. This creates a paper trail that can indicate potential surveillance sites before devices are confirmed through direct observation.
### 10. Upload Mode Simplification
**Release vs Debug builds:**
- **Release builds**: Production OSM only (simplified UX)
- **Debug builds**: Full sandbox/simulate options available
Most users should contribute to production; testing modes add complexity
**Implementation:**
```dart
// Upload mode selection disabled in release builds
bool get showUploadModeSelector => kDebugMode;
```
### 11. Navigation & Routing (Implemented, Awaiting Integration)
**Current state:**
- **Search functionality**: Fully implemented and active
- **Basic routing**: Complete but disabled pending API integration
- **Avoidance routing**: Awaiting alprwatch.org/directions API
- **Offline routing**: Requires vector map tiles
**Architecture:**
- NavigationState manages routing computation and turn-by-turn instructions
- RoutingService handles API communication and route calculation
- SearchService provides location lookup and geocoding
---
## Key Design Decisions & Rationales
@@ -227,6 +365,22 @@ Local cache contains production data. Showing production nodes in sandbox mode w
- **Battery life**: Excessive network requests drain battery
- **Clear feedback**: Users understand why nodes aren't showing
### 6. Why Separate Compass Indicator from Follow Mode?
**Alternative**: Combined "follow with north up" mode
**Why separate controls:**
- **Clear user mental model**: "Follow me" vs "lock to north" are distinct concepts
- **Flexible combinations**: Users can follow without north lock, or vice versa
- **Avoid mode conflicts**: Follow+rotate is incompatible with north lock
- **Reduced confusion**: Previous "north up" mode didn't actually keep north up
**Design benefits:**
- **Brutalist approach**: Two simple, independent features instead of complex mode combinations
- **Visual feedback**: Compass shows exact map orientation regardless of follow state
- **Smart gesture detection**: Differentiates intentional rotation from zoom artifacts
- **Predictable behavior**: Each control does exactly what it says
---
## Development Guidelines

View File

@@ -24,7 +24,7 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
### Map & Navigation
- **Multi-source tiles**: Switch between OpenStreetMap, USGS imagery, Esri imagery, Mapbox, OpenTopoMap, and any custom providers
- **Offline-first design**: Download a region for complete offline operation
- **Smooth UX**: Intuitive controls, follow-me mode with GPS rotation, and gesture-friendly interactions
- **Smooth UX**: Intuitive controls, follow-me mode with GPS rotation, compass indicator with north-lock, and gesture-friendly interactions
- **Device visualization**: Color-coded markers showing real devices (blue), pending uploads (purple), pending edits (grey), devices being edited (orange), and pending deletions (red)
### Device Management
@@ -33,6 +33,11 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
- **Direction visualization**: Interactive field-of-view cones showing camera viewing angles
- **Bulk operations**: Tag multiple devices efficiently with profile-based workflow
### Surveillance Intelligence
- **Suspected locations**: Display potential surveillance sites from utility permit data (select locations, more added regularly)
- **Proximity alerts**: Get notified when approaching mapped surveillance devices, with configurable distance and background notifications
- **Location search**: Find addresses and points of interest to aid in mapping missions
### Professional Upload & Sync
- **OpenStreetMap integration**: Direct upload with full OAuth2 authentication
- **Upload modes**: Production OSM, testing sandbox, or simulate-only mode
@@ -49,7 +54,7 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
## Quick Start
1. **Install** the app on iOS or Android
1. **Install** the app on iOS or Android - a welcome popup will guide you through key information
2. **Enable location** permissions
3. **Log into OpenStreetMap**: Choose upload mode and get OAuth2 credentials
4. **Add your first device**: Tap the "New Node" button, position the pin, set direction, select a profile, and tap submit
@@ -57,6 +62,8 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
**New to OpenStreetMap?** Visit [deflock.me](https://deflock.me) for complete setup instructions and community guidelines.
**App Updates**: The app will automatically show you what's new when you update. You can always view release notes in Settings > About.
---
## For Developers
@@ -80,25 +87,27 @@ cp lib/keys.dart.example lib/keys.dart
### Needed Bugfixes
- Are offline areas preferred for fast loading even when online? Check working.
- Ease up on overpass by pre-caching a larger area. Maybe we could grab the full latest database just like for suspected locations?
- Stop failing to fetch tiles; keep retrying after 3. Remove kTileFetchInitialDelayMs, kTileFetchJitter1Ms, etc from dev_config. Fix network indicator - only done when fetch queue is empty!
- Fix network indicator - only done when fetch queue is empty!
### Current Development
- Import/Export map providers
- Swap in alprwatch.org/directions avoidance routing API
- Help button with links to email, discord, and website
- Clean cache when nodes have disappeared / been deleted by others / queue item was deleted
- Improve offline area node refresh live display
- Add default operator profiles (Lowes etc)
- Add Rekor, generic PTZ profiles
### Future Features & Wishlist
- Update offline area nodes while browsing?
- Suspected locations toggle (alprwatch.com/flock/utilities)
- Offline navigation
- Offline navigation (pending vector map tiles)
- Suspected locations expansion to more regions
### Maybes
- Yellow ring for devices missing specific tag details?
- "Cache accumulating" offline area?
- "Offline areas" as tile provider?
- Maybe we could grab the full latest database for each profile just like for suspected locations? (Instead of overpass)
- Optional custom icons for camera profiles?
- Upgrade device marker design? (considering nullplate's svg)
- Custom device providers and OSM/Overpass alternatives?

20
assets/changelog.json Normal file
View File

@@ -0,0 +1,20 @@
{
"1.2.7": {
"content": "• NEW: Compass indicator shows map orientation; tap to spin north-up\n• Smart area caching: Loads 3x larger areas and refreshes data every 60 seconds for much faster browsing\n• Enhanced tile loading: Increased retry attempts with faster delays - tiles load much more reliably\n• Better network status: Simplified loading indicator logic\n• Instant node display: Surveillance devices now appear immediately when data finishes loading\n• Node limit alerts: Get notified when some nodes are not drawn"
},
"1.2.4": {
"content": "• New welcome popup for first-time users with essential privacy information\n• Automatic changelog display when app updates (like this one!)\n• Added Release Notes viewer in Settings > About\n• Enhanced user onboarding and transparency about data handling\n• Improved documentation for contributors"
},
"1.2.3": {
"content": "• Enhanced map performance and stability\n• Improved offline sync reliability\n• Added better error handling for uploads\n• Various bug fixes and improvements"
},
"1.2.2": {
"content": "• New surveillance device profiles added\n• Improved tile loading performance\n• Fixed issue with GPS accuracy\n• Updated translations"
},
"1.2.1": {
"content": ""
},
"1.2.0": {
"content": "• Major UI improvements\n• Added proximity alerts\n• Enhanced offline capabilities\n• New suspected locations feature"
}
}

View File

@@ -80,6 +80,11 @@ fi
# Build the dart-define arguments
DART_DEFINE_ARGS="--dart-define=OSM_PROD_CLIENTID=$OSM_PROD_CLIENTID --dart-define=OSM_SANDBOX_CLIENTID=$OSM_SANDBOX_CLIENTID"
# Validate localizations before building
echo "Validating localizations..."
dart run scripts/validate_localizations.dart || exit 1
echo
appver=$(grep "version:" pubspec.yaml | head -1 | cut -d ':' -f 2 | tr -d ' ' | cut -d '+' -f 1)
echo
echo "Building app version ${appver}..."

View File

@@ -12,6 +12,7 @@ import 'models/search_result.dart';
import 'services/offline_area_service.dart';
import 'services/node_cache.dart';
import 'services/tile_preview_service.dart';
import 'services/changelog_service.dart';
import 'widgets/camera_provider_with_cache.dart';
import 'state/auth_state.dart';
import 'state/navigation_state.dart';
@@ -129,6 +130,7 @@ 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;
bool get networkStatusIndicatorEnabled => _settingsState.networkStatusIndicatorEnabled;
@@ -160,6 +162,9 @@ class AppState extends ChangeNotifier {
// Initialize all state modules
await _settingsState.init();
// Initialize changelog service
await ChangelogService().init();
// Attempt to fetch missing tile type preview tiles (fails silently)
_fetchMissingTilePreviews();
@@ -405,7 +410,7 @@ class AppState extends ChangeNotifier {
Future<void> setFollowMeMode(FollowMeMode mode) async {
await _settingsState.setFollowMeMode(mode);
}
/// Set proximity alerts enabled/disabled
Future<void> setProximityAlertsEnabled(bool enabled) async {
await _settingsState.setProximityAlertsEnabled(enabled);

View File

@@ -64,6 +64,14 @@ const int kOsmApiMinZoomLevel = 13; // Minimum zoom for OSM API bbox queries (sa
const Duration kMarkerTapTimeout = Duration(milliseconds: 250);
const Duration kDebounceCameraRefresh = Duration(milliseconds: 500);
// Pre-fetch area configuration
const double kPreFetchAreaExpansionMultiplier = 3.0; // Expand visible bounds by this factor for pre-fetching
const int kPreFetchZoomLevel = 10; // Always pre-fetch at this zoom level for consistent area sizes
const int kMaxPreFetchSplitDepth = 3; // Maximum recursive splits when hitting Overpass node limit
// Data refresh configuration
const int kDataRefreshIntervalSeconds = 60; // Refresh cached data after this many seconds
// Follow-me mode smooth transitions
const Duration kFollowMeAnimationDuration = Duration(milliseconds: 600);
const double kMinSpeedForRotationMps = 1.0; // Minimum speed (m/s) to apply rotation
@@ -74,14 +82,12 @@ const int kProximityAlertMinDistance = 50; // meters
const int kProximityAlertMaxDistance = 1000; // meters
const Duration kProximityAlertCooldown = Duration(minutes: 10); // Cooldown between alerts for same node
// Tile/OSM fetch retry parameters (for tunable backoff)
const int kTileFetchMaxAttempts = 3;
const int kTileFetchInitialDelayMs = 4000;
const int kTileFetchJitter1Ms = 1000;
const int kTileFetchSecondDelayMs = 15000;
const int kTileFetchJitter2Ms = 4000;
const int kTileFetchThirdDelayMs = 60000;
const int kTileFetchJitter3Ms = 5000;
// Tile fetch retry parameters (configurable backoff system)
const int kTileFetchMaxAttempts = 16; // Number of retry attempts before giving up
const int kTileFetchInitialDelayMs = 500; // Base delay for first retry (1 second)
const double kTileFetchBackoffMultiplier = 1.5; // Multiply delay by this each attempt
const int kTileFetchMaxDelayMs = 10000; // Cap delays at this value (8 seconds max)
const int kTileFetchRandomJitterMs = 250; // Random fuzz to add (0 to 500ms)
// User download max zoom span (user can download up to kMaxUserDownloadZoomSpan zooms above min)
const int kMaxUserDownloadZoomSpan = 7;

View File

@@ -19,8 +19,8 @@
"clear": "Löschen"
},
"followMe": {
"off": "Verfolgung aktivieren (Norden oben)",
"northUp": "Verfolgung aktivieren (Rotation)",
"off": "Verfolgung aktivieren",
"follow": "Verfolgung aktivieren (Rotation)",
"rotating": "Verfolgung deaktivieren"
},
"settings": {
@@ -124,7 +124,12 @@
"testConnection": "Verbindung Testen",
"testConnectionSubtitle": "OSM-Anmeldedaten überprüfen",
"connectionOK": "Verbindung OK - Anmeldedaten sind gültig",
"connectionFailed": "Verbindung fehlgeschlagen - bitte erneut anmelden"
"connectionFailed": "Verbindung fehlgeschlagen - bitte erneut anmelden",
"deleteAccount": "OSM-Konto Löschen",
"deleteAccountSubtitle": "Ihr OpenStreetMap-Konto verwalten",
"deleteAccountExplanation": "Um Ihr OpenStreetMap-Konto zu löschen, müssen Sie die OpenStreetMap-Website besuchen. Dies entfernt dauerhaft Ihr OSM-Konto und alle zugehörigen Daten.",
"deleteAccountWarning": "Warnung: Diese Aktion kann nicht rückgängig gemacht werden und löscht Ihr OSM-Konto dauerhaft.",
"goToOSM": "Zu OpenStreetMap gehen"
},
"queue": {
"pendingUploads": "Ausstehende Uploads: {}",
@@ -306,6 +311,16 @@
"initiative": "Teil der breiteren DeFlock-Initiative zur Förderung von Überwachungstransparenz.",
"footer": "Besuchen Sie: deflock.me\nGebaut mit Flutter • Open Source"
},
"welcome": {
"title": "Willkommen bei DeFlock",
"description": "DeFlock wurde auf der Idee gegründet, dass öffentliche Überwachungsinstrumente transparent sein sollten. In dieser mobilen App, wie auch auf der Website, können Sie die Standorte von ALPRs und anderer Überwachungsinfrastruktur in Ihrer Umgebung und weltweit einsehen.",
"mission": "Dieses Projekt ist jedoch nicht automatisiert; es braucht uns alle, um dieses Projekt zu verbessern. Bei der Kartenansicht können Sie auf \"Neuer Knoten\" tippen, um eine bisher unbekannte Installation hinzuzufügen. Mit Ihrer Hilfe können wir unser Ziel erreichen: mehr Transparenz und öffentliches Bewusstsein für Überwachungsinfrastruktur.",
"privacy": "Datenschutzhinweis: Diese App läuft vollständig lokal auf Ihrem Gerät und nutzt die OpenStreetMap-API von Drittanbietern nur für Datenspeicherung und Übermittlungen. DeFlock sammelt oder speichert keinerlei Nutzerdaten und ist nicht für die Kontoverwaltung verantwortlich.",
"tileNote": "HINWEIS: Die kostenlosen Kartenkacheln von OpenStreetMap können sehr langsam laden. Alternative Kartenanbieter können unter Einstellungen > Erweitert konfiguriert werden.",
"moreInfo": "Weitere Links finden Sie unter Einstellungen > Über.",
"dontShowAgain": "Diese Willkommensnachricht nicht mehr anzeigen",
"getStarted": "Los geht's mit DeFlocking!"
},
"navigation": {
"searchLocation": "Ort suchen",
"searchPlaceholder": "Orte oder Koordinaten suchen...",

View File

@@ -12,6 +12,16 @@
"initiative": "Part of the broader DeFlock initiative to promote surveillance transparency.",
"footer": "Visit: deflock.me\nBuilt with Flutter • Open Source"
},
"welcome": {
"title": "Welcome to DeFlock",
"description": "DeFlock was founded on the idea that public surveillance tools should be transparent. Within this mobile app, as on the website, you will be able to view the location of ALPRs and other surveillance infrastructure in your local area and abroad.",
"mission": "However, this project isn't automated; it takes all of us to make this project better. When viewing the map, you can tap \"New Node\" to add a previously unknown installation. With your help, we can achieve our goal of increased transparency and public awareness of surveillance infrastructure.",
"privacy": "Privacy Note: This app runs entirely locally on your device and uses the third-party OpenStreetMap API for data storage and submissions. DeFlock does not collect or store any user data of any kind, and is not responsible for account management.",
"tileNote": "NOTE: The free map tiles from OpenStreetMap can be very slow to load. Alternate tile providers can be configured in Settings > Advanced.",
"moreInfo": "You can find more links under Settings > About.",
"dontShowAgain": "Don't show this welcome message again",
"getStarted": "Let's Get DeFlocking!"
},
"actions": {
"tagNode": "New Node",
"download": "Download",
@@ -26,8 +36,8 @@
"clear": "Clear"
},
"followMe": {
"off": "Enable follow-me (north up)",
"northUp": "Enable follow-me (rotating)",
"off": "Enable follow-me",
"follow": "Enable follow-me (rotating)",
"rotating": "Disable follow-me"
},
"settings": {
@@ -131,7 +141,12 @@
"testConnection": "Test Connection",
"testConnectionSubtitle": "Verify OSM credentials are working",
"connectionOK": "Connection OK - credentials are valid",
"connectionFailed": "Connection failed - please re-login"
"connectionFailed": "Connection failed - please re-login",
"deleteAccount": "Delete OSM Account",
"deleteAccountSubtitle": "Manage your OpenStreetMap account",
"deleteAccountExplanation": "To delete your OpenStreetMap account, you'll need to visit the OpenStreetMap website. This will permanently remove your OSM account and all associated data.",
"deleteAccountWarning": "Warning: This action cannot be undone and will permanently delete your OSM account.",
"goToOSM": "Go to OpenStreetMap"
},
"queue": {
"pendingUploads": "Pending uploads: {}",

View File

@@ -12,6 +12,16 @@
"initiative": "Parte de la iniciativa más amplia DeFlock para promover la transparencia en vigilancia.",
"footer": "Visita: deflock.me\nConstruido con Flutter • Código Abierto"
},
"welcome": {
"title": "Bienvenido a DeFlock",
"description": "DeFlock fue fundado sobre la idea de que las herramientas de vigilancia pública deben ser transparentes. Dentro de esta aplicación móvil, como en el sitio web, podrás ver la ubicación de ALPRs y otra infraestructura de vigilancia en tu área local y en el extranjero.",
"mission": "Sin embargo, este proyecto no es automatizado; todos nosotros somos necesarios para mejorarlo. Al ver el mapa, puedes tocar \"Nuevo Nodo\" para agregar una instalación previamente desconocida. Con tu ayuda, podemos lograr nuestro objetivo de mayor transparencia y conciencia pública sobre la infraestructura de vigilancia.",
"privacy": "Nota de Privacidad: Esta aplicación funciona completamente de forma local en tu dispositivo y utiliza la API de terceros de OpenStreetMap solo para almacenamiento y envío de datos. DeFlock no recopila ni almacena ningún tipo de datos de usuario, y no es responsable de la gestión de cuentas.",
"tileNote": "NOTA: Los mosaicos gratuitos de mapa de OpenStreetMap pueden tardar mucho en cargar. Se pueden configurar proveedores alternativos de mosaicos en Configuración > Avanzado.",
"moreInfo": "Puedes encontrar más enlaces en Configuración > Acerca de.",
"dontShowAgain": "No mostrar este mensaje de bienvenida otra vez",
"getStarted": "¡Comencemos con DeFlock!"
},
"actions": {
"tagNode": "Nuevo Nodo",
"download": "Descargar",
@@ -26,8 +36,8 @@
"clear": "Limpiar"
},
"followMe": {
"off": "Activar seguimiento (norte arriba)",
"northUp": "Activar seguimiento (rotación)",
"off": "Activar seguimiento",
"follow": "Activar seguimiento (rotación)",
"rotating": "Desactivar seguimiento"
},
"settings": {
@@ -131,7 +141,12 @@
"testConnection": "Probar Conexión",
"testConnectionSubtitle": "Verificar que las credenciales de OSM funcionen",
"connectionOK": "Conexión OK - las credenciales son válidas",
"connectionFailed": "Conexión falló - por favor, inicie sesión nuevamente"
"connectionFailed": "Conexión falló - por favor, inicie sesión nuevamente",
"deleteAccount": "Eliminar Cuenta OSM",
"deleteAccountSubtitle": "Gestiona tu cuenta de OpenStreetMap",
"deleteAccountExplanation": "Para eliminar tu cuenta de OpenStreetMap, necesitarás visitar el sitio web de OpenStreetMap. Esto eliminará permanentemente tu cuenta OSM y todos los datos asociados.",
"deleteAccountWarning": "Advertencia: Esta acción no se puede deshacer y eliminará permanentemente tu cuenta OSM.",
"goToOSM": "Ir a OpenStreetMap"
},
"queue": {
"pendingUploads": "Subidas pendientes: {}",

View File

@@ -12,6 +12,16 @@
"initiative": "Partie de l'initiative plus large DeFlock pour promouvoir la transparence de la surveillance.",
"footer": "Visitez : deflock.me\nConstruit avec Flutter • Source Ouverte"
},
"welcome": {
"title": "Bienvenue dans DeFlock",
"description": "DeFlock a été fondé sur l'idée que les outils de surveillance publique devraient être transparents. Dans cette application mobile, comme sur le site web, vous pourrez voir l'emplacement des ALPRs et autres infrastructures de surveillance dans votre région et à l'étranger.",
"mission": "Cependant, ce projet n'est pas automatisé ; il nous faut tous pour améliorer ce projet. En visualisant la carte, vous pouvez appuyer sur \"Nouveau Nœud\" pour ajouter une installation précédemment inconnue. Avec votre aide, nous pouvons atteindre notre objectif d'augmenter la transparence et la sensibilisation du public à l'infrastructure de surveillance.",
"privacy": "Note de Confidentialité : Cette application fonctionne entièrement localement sur votre appareil et utilise l'API tierce OpenStreetMap uniquement pour le stockage et la soumission de données. DeFlock ne collecte ni ne stocke aucune donnée utilisateur de quelque nature que ce soit, et n'est pas responsable de la gestion des comptes.",
"tileNote": "NOTE : Les tuiles de carte gratuites d'OpenStreetMap peuvent être très lentes à charger. Des fournisseurs de tuiles alternatifs peuvent être configurés dans Paramètres > Avancé.",
"moreInfo": "Vous pouvez trouver plus de liens sous Paramètres > À propos.",
"dontShowAgain": "Ne plus afficher ce message de bienvenue",
"getStarted": "Commençons le DeFlock !"
},
"actions": {
"tagNode": "Nouveau Nœud",
"download": "Télécharger",
@@ -26,8 +36,8 @@
"clear": "Effacer"
},
"followMe": {
"off": "Activer le suivi (nord en haut)",
"northUp": "Activer le suivi (rotation)",
"off": "Activer le suivi",
"follow": "Activer le suivi (rotation)",
"rotating": "Désactiver le suivi"
},
"settings": {
@@ -131,7 +141,12 @@
"testConnection": "Tester Connexion",
"testConnectionSubtitle": "Vérifier que les identifiants OSM fonctionnent",
"connectionOK": "Connexion OK - les identifiants sont valides",
"connectionFailed": "Connexion échouée - veuillez vous reconnecter"
"connectionFailed": "Connexion échouée - veuillez vous reconnecter",
"deleteAccount": "Supprimer Compte OSM",
"deleteAccountSubtitle": "Gérez votre compte OpenStreetMap",
"deleteAccountExplanation": "Pour supprimer votre compte OpenStreetMap, vous devrez visiter le site web OpenStreetMap. Cela supprimera définitivement votre compte OSM et toutes les données associées.",
"deleteAccountWarning": "Attention : Cette action ne peut pas être annulée et supprimera définitivement votre compte OSM.",
"goToOSM": "Aller à OpenStreetMap"
},
"queue": {
"pendingUploads": "Téléchargements en attente: {}",

View File

@@ -12,6 +12,16 @@
"initiative": "Parte della più ampia iniziativa DeFlock per promuovere la trasparenza della sorveglianza.",
"footer": "Visita: deflock.me\nCostruito con Flutter • Open Source"
},
"welcome": {
"title": "Benvenuto in DeFlock",
"description": "DeFlock è stato fondato sull'idea che gli strumenti di sorveglianza pubblica dovrebbero essere trasparenti. All'interno di questa app mobile, come sul sito web, sarai in grado di visualizzare la posizione di ALPR e altre infrastrutture di sorveglianza nella tua zona locale e all'estero.",
"mission": "Tuttavia, questo progetto non è automatizzato; servono tutti noi per migliorare questo progetto. Durante la visualizzazione della mappa, puoi toccare \"Nuovo Nodo\" per aggiungere un'installazione precedentemente sconosciuta. Con il tuo aiuto, possiamo raggiungere il nostro obiettivo di maggiore trasparenza e consapevolezza pubblica dell'infrastruttura di sorveglianza.",
"privacy": "Nota sulla Privacy: Questa app funziona interamente localmente sul tuo dispositivo e utilizza l'API di terze parti OpenStreetMap solo per l'archiviazione e l'invio dei dati. DeFlock non raccoglie né memorizza alcun tipo di dati utente e non è responsabile della gestione degli account.",
"tileNote": "NOTA: Le tessere mappa gratuite di OpenStreetMap possono essere molto lente a caricare. Fornitori di tessere alternativi possono essere configurati in Impostazioni > Avanzate.",
"moreInfo": "Puoi trovare altri collegamenti in Impostazioni > Informazioni.",
"dontShowAgain": "Non mostrare più questo messaggio di benvenuto",
"getStarted": "Iniziamo con DeFlock!"
},
"actions": {
"tagNode": "Nuovo Nodo",
"download": "Scarica",
@@ -26,8 +36,8 @@
"clear": "Pulisci"
},
"followMe": {
"off": "Attiva seguimi (nord in alto)",
"northUp": "Attiva seguimi (rotazione)",
"off": "Attiva seguimi",
"follow": "Attiva seguimi (rotazione)",
"rotating": "Disattiva seguimi"
},
"settings": {
@@ -131,7 +141,12 @@
"testConnection": "Testa Connessione",
"testConnectionSubtitle": "Verifica che le credenziali OSM funzionino",
"connectionOK": "Connessione OK - le credenziali sono valide",
"connectionFailed": "Connessione fallita - per favore accedi di nuovo"
"connectionFailed": "Connessione fallita - per favore accedi di nuovo",
"deleteAccount": "Elimina Account OSM",
"deleteAccountSubtitle": "Gestisci il tuo account OpenStreetMap",
"deleteAccountExplanation": "Per eliminare il tuo account OpenStreetMap, dovrai visitare il sito web di OpenStreetMap. Questo rimuoverà permanentemente il tuo account OSM e tutti i dati associati.",
"deleteAccountWarning": "Attenzione: Questa azione non può essere annullata e eliminerà permanentemente il tuo account OSM.",
"goToOSM": "Vai a OpenStreetMap"
},
"queue": {
"pendingUploads": "Upload in sospeso: {}",

View File

@@ -12,6 +12,16 @@
"initiative": "Parte da iniciativa mais ampla DeFlock para promover transparência na vigilância.",
"footer": "Visite: deflock.me\nConstruído com Flutter • Código Aberto"
},
"welcome": {
"title": "Bem-vindo ao DeFlock",
"description": "DeFlock foi fundado na ideia de que ferramentas de vigilância pública devem ser transparentes. Dentro deste aplicativo móvel, como no site, você poderá ver a localização de ALPRs e outras infraestruturas de vigilância em sua área local e no exterior.",
"mission": "No entanto, este projeto não é automatizado; precisamos de todos nós para tornar este projeto melhor. Ao visualizar o mapa, você pode tocar em \"Novo Nó\" para adicionar uma instalação anteriormente desconhecida. Com sua ajuda, podemos alcançar nosso objetivo de maior transparência e conscientização pública sobre infraestrutura de vigilância.",
"privacy": "Nota de Privacidade: Este aplicativo funciona inteiramente localmente em seu dispositivo e usa a API de terceiros OpenStreetMap apenas para armazenamento e envio de dados. DeFlock não coleta nem armazena qualquer tipo de dados do usuário e não é responsável pelo gerenciamento de contas.",
"tileNote": "NOTA: Os tiles gratuitos de mapa do OpenStreetMap podem ser muito lentos para carregar. Provedores alternativos de tiles podem ser configurados em Configurações > Avançado.",
"moreInfo": "Você pode encontrar mais links em Configurações > Sobre.",
"dontShowAgain": "Não mostrar esta mensagem de boas-vindas novamente",
"getStarted": "Vamos começar com o DeFlock!"
},
"actions": {
"tagNode": "Novo Nó",
"download": "Baixar",
@@ -26,8 +36,8 @@
"clear": "Limpar"
},
"followMe": {
"off": "Ativar seguir-me (norte para cima)",
"northUp": "Ativar seguir-me (rotação)",
"off": "Ativar seguir-me",
"follow": "Ativar seguir-me (rotação)",
"rotating": "Desativar seguir-me"
},
"settings": {
@@ -131,7 +141,12 @@
"testConnection": "Testar Conexão",
"testConnectionSubtitle": "Verificar se as credenciais OSM estão funcionando",
"connectionOK": "Conexão OK - credenciais são válidas",
"connectionFailed": "Conexão falhou - por favor, faça login novamente"
"connectionFailed": "Conexão falhou - por favor, faça login novamente",
"deleteAccount": "Excluir Conta OSM",
"deleteAccountSubtitle": "Gerencie sua conta OpenStreetMap",
"deleteAccountExplanation": "Para excluir sua conta OpenStreetMap, você precisará visitar o site do OpenStreetMap. Isso removerá permanentemente sua conta OSM e todos os dados associados.",
"deleteAccountWarning": "Aviso: Esta ação não pode ser desfeita e excluirá permanentemente sua conta OSM.",
"goToOSM": "Ir para OpenStreetMap"
},
"queue": {
"pendingUploads": "Uploads pendentes: {}",

View File

@@ -12,6 +12,16 @@
"initiative": "DeFlock 更广泛倡议的一部分,旨在促进监控透明化。",
"footer": "访问deflock.me\n使用 Flutter 构建 • 开源"
},
"welcome": {
"title": "欢迎使用 DeFlock",
"description": "DeFlock 的创立基于公共监控工具应该透明的理念。在这个移动应用程序中,就像在网站上一样,您将能够查看您当地和国外的车牌识别系统和其他监控基础设施的位置。",
"mission": "然而,这个项目不是自动化的;需要我们所有人来改善这个项目。在查看地图时,您可以点击\"新建节点\"来添加一个之前未知的装置。在您的帮助下,我们可以实现增强监控基础设施透明度和公众意识的目标。",
"privacy": "隐私说明:此应用程序完全在您的设备上本地运行,仅使用第三方 OpenStreetMap API 进行数据存储和提交。DeFlock 不收集或存储任何类型的用户数据,也不负责账户管理。",
"tileNote": "注意:来自 OpenStreetMap 的免费地图图块可能加载很慢。可以在设置 > 高级中配置替代图块提供商。",
"moreInfo": "您可以在设置 > 关于中找到更多链接。",
"dontShowAgain": "不再显示此欢迎消息",
"getStarted": "开始使用 DeFlock"
},
"actions": {
"tagNode": "新建节点",
"download": "下载",
@@ -26,8 +36,8 @@
"clear": "清空"
},
"followMe": {
"off": "启用跟随模式(北向上)",
"northUp": "启用跟随模式(旋转)",
"off": "启用跟随模式",
"follow": "启用跟随模式(旋转)",
"rotating": "禁用跟随模式"
},
"settings": {
@@ -131,7 +141,12 @@
"testConnection": "测试连接",
"testConnectionSubtitle": "验证 OSM 凭据是否有效",
"connectionOK": "连接正常 - 凭据有效",
"connectionFailed": "连接失败 - 请重新登录"
"connectionFailed": "连接失败 - 请重新登录",
"deleteAccount": "删除 OSM 账户",
"deleteAccountSubtitle": "管理您的 OpenStreetMap 账户",
"deleteAccountExplanation": "要删除您的 OpenStreetMap 账户,您需要访问 OpenStreetMap 网站。这将永久删除您的 OSM 账户和所有相关数据。",
"deleteAccountWarning": "警告:此操作无法撤销,将永久删除您的 OSM 账户。",
"goToOSM": "前往 OpenStreetMap"
},
"queue": {
"pendingUploads": "待上传:{}",

View File

@@ -10,6 +10,7 @@ import 'screens/offline_settings_screen.dart';
import 'screens/advanced_settings_screen.dart';
import 'screens/language_settings_screen.dart';
import 'screens/about_screen.dart';
import 'screens/release_notes_screen.dart';
import 'services/localization_service.dart';
import 'services/version_service.dart';
@@ -74,6 +75,7 @@ class DeFlockApp extends StatelessWidget {
'/settings/advanced': (context) => const AdvancedSettingsScreen(),
'/settings/language': (context) => const LanguageSettingsScreen(),
'/settings/about': (context) => const AboutScreen(),
'/settings/release-notes': (context) => const ReleaseNotesScreen(),
},
initialRoute: '/',
);

View File

@@ -68,7 +68,18 @@ class AboutScreen extends StatelessWidget {
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
const SizedBox(height: 24),
// Release Notes button
Center(
child: OutlinedButton.icon(
onPressed: () {
Navigator.pushNamed(context, '/settings/release-notes');
},
icon: const Icon(Icons.article_outlined),
label: const Text('View Release Notes'),
),
),
const SizedBox(height: 24),
_buildHelpLinks(context),
],
),
@@ -91,12 +102,76 @@ class AboutScreen extends StatelessWidget {
_buildLinkText(context, 'Source Code', 'https://github.com/FoggedLens/deflock-app'),
const SizedBox(height: 8),
_buildLinkText(context, 'Contact', 'https://deflock.me/contact'),
const SizedBox(height: 8),
_buildLinkText(context, 'Donate', 'https://deflock.me/donate'),
const SizedBox(height: 24),
// Divider for account management section
Divider(
color: Theme.of(context).dividerColor.withOpacity(0.3),
),
const SizedBox(height: 16),
// Account deletion link (less prominent)
_buildAccountDeletionLink(context),
],
);
}
Widget _buildAccountDeletionLink(BuildContext context) {
final locService = LocalizationService.instance;
return GestureDetector(
onTap: () => _showDeleteAccountDialog(context, locService),
child: Text(
locService.t('auth.deleteAccount'),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.error.withOpacity(0.7),
decoration: TextDecoration.underline,
),
textAlign: TextAlign.center,
),
);
}
void _showDeleteAccountDialog(BuildContext context, LocalizationService locService) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(locService.t('auth.deleteAccount')),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(locService.t('auth.deleteAccountExplanation')),
const SizedBox(height: 12),
Text(
locService.t('auth.deleteAccountWarning'),
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.error,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(locService.t('actions.cancel')),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
_launchUrl('https://www.openstreetmap.org/account/deletion', context);
},
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
child: Text(locService.t('auth.goToOSM')),
),
],
),
);
}
Widget _buildLinkText(BuildContext context, String text, String url) {
return GestureDetector(
onTap: () => _launchUrl(url, context),

View File

@@ -19,9 +19,12 @@ import '../widgets/measured_sheet.dart';
import '../widgets/navigation_sheet.dart';
import '../widgets/search_bar.dart';
import '../widgets/suspected_location_sheet.dart';
import '../widgets/welcome_dialog.dart';
import '../widgets/changelog_dialog.dart';
import '../models/osm_node.dart';
import '../models/suspected_location.dart';
import '../models/search_result.dart';
import '../services/changelog_service.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@@ -48,6 +51,9 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
// Track selected node for highlighting
int? _selectedNodeId;
// Track popup display to avoid showing multiple times
bool _hasCheckedForPopup = false;
@override
void initState() {
@@ -66,8 +72,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
switch (mode) {
case FollowMeMode.off:
return locService.t('followMe.off');
case FollowMeMode.northUp:
return locService.t('followMe.northUp');
case FollowMeMode.follow:
return locService.t('followMe.follow');
case FollowMeMode.rotating:
return locService.t('followMe.rotating');
}
@@ -77,7 +83,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
switch (mode) {
case FollowMeMode.off:
return Icons.gps_off;
case FollowMeMode.northUp:
case FollowMeMode.follow:
return Icons.gps_fixed;
case FollowMeMode.rotating:
return Icons.navigation;
@@ -87,8 +93,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
FollowMeMode _getNextFollowMeMode(FollowMeMode mode) {
switch (mode) {
case FollowMeMode.off:
return FollowMeMode.northUp;
case FollowMeMode.northUp:
return FollowMeMode.follow;
case FollowMeMode.follow:
return FollowMeMode.rotating;
case FollowMeMode.rotating:
return FollowMeMode.off;
@@ -124,6 +130,13 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
setState(() {
_addSheetHeight = 0.0;
});
// Handle dismissal by canceling session if still active
final appState = context.read<AppState>();
if (appState.session != null) {
debugPrint('[HomeScreen] AddNodeSheet dismissed - canceling session');
appState.cancelSession();
}
});
}
@@ -174,6 +187,13 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
_editSheetHeight = 0.0;
_transitioningToEdit = false;
});
// Handle dismissal by canceling edit session if still active
final appState = context.read<AppState>();
if (appState.editSession != null) {
debugPrint('[HomeScreen] EditNodeSheet dismissed - canceling edit session');
appState.cancelEditSession();
}
});
});
}
@@ -219,6 +239,52 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
});
}
// Check for and display welcome/changelog popup
Future<void> _checkForPopup() async {
if (!mounted) return;
try {
final popupType = await ChangelogService().getPopupType();
if (!mounted) return; // Check again after async operation
switch (popupType) {
case PopupType.welcome:
await showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const WelcomeDialog(),
);
break;
case PopupType.changelog:
final changelogContent = ChangelogService().getChangelogForCurrentVersion();
if (changelogContent != null) {
await showDialog(
context: context,
barrierDismissible: false,
builder: (context) => ChangelogDialog(changelogContent: changelogContent),
);
}
break;
case PopupType.none:
// No popup needed, but still update version tracking for future launches
await ChangelogService().updateLastSeenVersion();
break;
}
} catch (e) {
// Silently handle errors to avoid breaking the app launch
debugPrint('[HomeScreen] Error checking for popup: $e');
// Still update version tracking in case of error
try {
await ChangelogService().updateLastSeenVersion();
} catch (e2) {
debugPrint('[HomeScreen] Error updating version: $e2');
}
}
}
void _onStartRoute() {
final appState = context.read<AppState>();
@@ -230,7 +296,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
userLocation = _mapViewKey.currentState?.getUserLocation();
if (userLocation != null && appState.shouldAutoEnableFollowMe(userLocation)) {
debugPrint('[HomeScreen] Auto-enabling follow-me mode - user within 1km of start');
appState.setFollowMeMode(FollowMeMode.northUp);
appState.setFollowMeMode(FollowMeMode.follow);
enableFollowMe = true;
}
} catch (e) {
@@ -526,6 +592,12 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
}
}
// Check for welcome/changelog popup after app is fully initialized
if (appState.isInitialized && !_hasCheckedForPopup) {
_hasCheckedForPopup = true;
WidgetsBinding.instance.addPostFrameCallback((_) => _checkForPopup());
}
// Pass the active sheet height directly to the map
final activeSheetHeight = _addSheetHeight > 0
? _addSheetHeight

View File

@@ -0,0 +1,191 @@
import 'package:flutter/material.dart';
import '../services/changelog_service.dart';
import '../services/version_service.dart';
class ReleaseNotesScreen extends StatefulWidget {
const ReleaseNotesScreen({super.key});
@override
State<ReleaseNotesScreen> createState() => _ReleaseNotesScreenState();
}
class _ReleaseNotesScreenState extends State<ReleaseNotesScreen> {
Map<String, String>? _changelogs;
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadChangelogs();
}
Future<void> _loadChangelogs() async {
try {
// Ensure changelog service is initialized
if (!ChangelogService().isInitialized) {
await ChangelogService().init();
}
final changelogs = ChangelogService().getAllChangelogs();
if (mounted) {
setState(() {
_changelogs = changelogs;
_isLoading = false;
});
}
} catch (e) {
debugPrint('[ReleaseNotesScreen] Error loading changelogs: $e');
if (mounted) {
setState(() {
_changelogs = {};
_isLoading = false;
});
}
}
}
List<String> _sortVersions(List<String> versions) {
// Simple version sorting - splits by '.' and compares numerically
versions.sort((a, b) {
final aParts = a.split('.').map(int.tryParse).where((v) => v != null).cast<int>().toList();
final bParts = b.split('.').map(int.tryParse).where((v) => v != null).cast<int>().toList();
// Compare version parts (reverse order for newest first)
for (int i = 0; i < aParts.length && i < bParts.length; i++) {
final comparison = bParts[i].compareTo(aParts[i]); // Reverse for desc order
if (comparison != 0) return comparison;
}
// If one version has more parts, the longer one is newer
return bParts.length.compareTo(aParts.length);
});
return versions;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Release Notes'),
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _changelogs == null || _changelogs!.isEmpty
? const Center(
child: Padding(
padding: EdgeInsets.all(24.0),
child: Text(
'No release notes available.',
style: TextStyle(fontSize: 16),
textAlign: TextAlign.center,
),
),
)
: ListView(
padding: const EdgeInsets.all(16),
children: [
// Current version indicator
Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).colorScheme.primary.withOpacity(0.3),
),
),
child: Row(
children: [
Icon(
Icons.info_outline,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'Current Version: ${VersionService().version}',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
),
// Changelog entries
..._buildChangelogEntries(),
],
),
);
}
List<Widget> _buildChangelogEntries() {
if (_changelogs == null || _changelogs!.isEmpty) return [];
final sortedVersions = _sortVersions(_changelogs!.keys.toList());
final currentVersion = VersionService().version;
return sortedVersions.map((version) {
final content = _changelogs![version]!;
final isCurrentVersion = version == currentVersion;
return Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
border: Border.all(
color: isCurrentVersion
? Theme.of(context).colorScheme.primary.withOpacity(0.3)
: Theme.of(context).dividerColor.withOpacity(0.3),
),
borderRadius: BorderRadius.circular(8),
),
child: ExpansionTile(
title: Row(
children: [
Text(
'Version $version',
style: TextStyle(
fontWeight: FontWeight.bold,
color: isCurrentVersion
? Theme.of(context).colorScheme.primary
: null,
),
),
if (isCurrentVersion) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'CURRENT',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onPrimary,
),
),
),
],
],
),
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
child: Text(
content,
style: const TextStyle(height: 1.4),
),
),
],
),
);
}).toList();
}
}

View File

@@ -0,0 +1,154 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'version_service.dart';
/// Service for managing changelog data and first launch detection
class ChangelogService {
static final ChangelogService _instance = ChangelogService._internal();
factory ChangelogService() => _instance;
ChangelogService._internal();
static const String _lastSeenVersionKey = 'last_seen_version';
static const String _hasSeenWelcomeKey = 'has_seen_welcome';
Map<String, dynamic>? _changelogData;
bool _initialized = false;
/// Initialize the service by loading changelog data
Future<void> init() async {
if (_initialized) return;
try {
final String jsonString = await rootBundle.loadString('assets/changelog.json');
_changelogData = json.decode(jsonString);
_initialized = true;
debugPrint('[ChangelogService] Loaded changelog with ${_changelogData?.keys.length ?? 0} versions');
} catch (e) {
debugPrint('[ChangelogService] Failed to load changelog: $e');
_changelogData = {};
_initialized = true; // Mark as initialized even on failure to prevent repeated attempts
}
}
/// Check if this is the first app launch ever
Future<bool> isFirstLaunch() async {
final prefs = await SharedPreferences.getInstance();
return !prefs.containsKey(_lastSeenVersionKey);
}
/// Check if user has seen the welcome popup (separate from version tracking)
Future<bool> hasSeenWelcome() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_hasSeenWelcomeKey) ?? false;
}
/// Mark that user has seen the welcome popup
Future<void> markWelcomeSeen() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_hasSeenWelcomeKey, true);
}
/// Check if app version has changed since last launch
Future<bool> hasVersionChanged() async {
final prefs = await SharedPreferences.getInstance();
final lastSeenVersion = prefs.getString(_lastSeenVersionKey);
final currentVersion = VersionService().version;
return lastSeenVersion != currentVersion;
}
/// Update the stored version to current version
Future<void> updateLastSeenVersion() async {
final prefs = await SharedPreferences.getInstance();
final currentVersion = VersionService().version;
await prefs.setString(_lastSeenVersionKey, currentVersion);
debugPrint('[ChangelogService] Updated last seen version to: $currentVersion');
}
/// Get changelog content for the current version
String? getChangelogForCurrentVersion() {
if (!_initialized || _changelogData == null) {
debugPrint('[ChangelogService] Not initialized or no changelog data');
return null;
}
final currentVersion = VersionService().version;
final versionData = _changelogData![currentVersion] as Map<String, dynamic>?;
if (versionData == null) {
debugPrint('[ChangelogService] No changelog entry found for version: $currentVersion');
return null;
}
final content = versionData['content'] as String?;
return (content?.isEmpty == true) ? null : content;
}
/// Get changelog content for a specific version
String? getChangelogForVersion(String version) {
if (!_initialized || _changelogData == null) return null;
final versionData = _changelogData![version] as Map<String, dynamic>?;
if (versionData == null) return null;
final content = versionData['content'] as String?;
return (content?.isEmpty == true) ? null : content;
}
/// Get all changelog entries (for settings page)
Map<String, String> getAllChangelogs() {
if (!_initialized || _changelogData == null) return {};
final Map<String, String> result = {};
for (final entry in _changelogData!.entries) {
final version = entry.key;
final versionData = entry.value as Map<String, dynamic>?;
final content = versionData?['content'] as String?;
// Only include versions with non-empty content
if (content != null && content.isNotEmpty) {
result[version] = content;
}
}
return result;
}
/// Determine what popup (if any) should be shown
Future<PopupType> getPopupType() async {
// Ensure services are initialized
if (!_initialized) await init();
final isFirstLaunch = await this.isFirstLaunch();
final hasSeenWelcome = await this.hasSeenWelcome();
final hasVersionChanged = await this.hasVersionChanged();
// First launch and haven't seen welcome
if (isFirstLaunch || !hasSeenWelcome) {
return PopupType.welcome;
}
// Version changed and there's changelog content
if (hasVersionChanged) {
final changelogContent = getChangelogForCurrentVersion();
if (changelogContent != null) {
return PopupType.changelog;
}
}
return PopupType.none;
}
/// Check if the service is properly initialized
bool get isInitialized => _initialized;
}
/// Types of popups that can be shown
enum PopupType {
none,
welcome,
changelog,
}

View File

@@ -11,6 +11,7 @@ import 'map_data_submodules/tiles_from_remote.dart';
import 'map_data_submodules/nodes_from_local.dart';
import 'map_data_submodules/tiles_from_local.dart';
import 'network_status.dart';
import 'prefetch_area_service.dart';
enum MapSource { local, remote, auto } // For future use
@@ -41,7 +42,6 @@ class MapDataProvider {
UploadMode uploadMode = UploadMode.production,
MapSource source = MapSource.auto,
}) async {
try {
final offline = AppState.instance.offlineMode;
// Explicit remote request: error if offline, else always remote
@@ -89,52 +89,40 @@ class MapDataProvider {
maxResults: AppState.instance.maxCameras,
);
} else {
// Production mode: fetch both remote and local, then merge with deduplication
final List<Future<List<OsmNode>>> futures = [];
// Production mode: use pre-fetch service for efficient area loading
final preFetchService = PrefetchAreaService();
// Always try to get local nodes (fast, cached)
futures.add(fetchLocalNodes(
// Always get local nodes first (fast, from cache)
final localNodes = await fetchLocalNodes(
bounds: bounds,
profiles: profiles,
maxNodes: AppState.instance.maxCameras,
));
);
// Always try to get remote nodes (slower, fresh data)
futures.add(_fetchRemoteNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: AppState.instance.maxCameras,
).catchError((e) {
debugPrint('[MapDataProvider] Remote node fetch failed, error: $e. Continuing with local only.');
return <OsmNode>[]; // Return empty list on remote failure
}));
// Check if we need to trigger a new pre-fetch (spatial or temporal)
final needsFetch = !preFetchService.isWithinPreFetchedArea(bounds, profiles, uploadMode) ||
preFetchService.isDataStale();
// Wait for both, then merge with deduplication by node ID
final results = await Future.wait(futures);
final localNodes = results[0];
final remoteNodes = results[1];
// Merge with deduplication - prefer remote data over local for same node ID
final Map<int, OsmNode> mergedNodes = {};
// Add local nodes first
for (final node in localNodes) {
mergedNodes[node.id] = node;
if (needsFetch) {
// Outside area OR data stale - start pre-fetch with loading state
debugPrint('[MapDataProvider] Starting pre-fetch with loading state');
NetworkStatus.instance.setWaiting();
preFetchService.requestPreFetchIfNeeded(
viewBounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
);
} else {
debugPrint('[MapDataProvider] Using existing fresh pre-fetched area cache');
}
// Add remote nodes, overwriting any local duplicates
for (final node in remoteNodes) {
mergedNodes[node.id] = node;
// Apply rendering limit and warn if nodes are being excluded
final maxNodes = AppState.instance.maxCameras;
if (localNodes.length > maxNodes) {
NetworkStatus.instance.reportNodeLimitReached(localNodes.length, maxNodes);
}
// Apply maxCameras limit to the merged result
final finalNodes = mergedNodes.values.take(AppState.instance.maxCameras).toList();
return finalNodes;
}
} finally {
// Always report node completion, regardless of success or failure
NetworkStatus.instance.reportNodeComplete();
return localNodes.take(maxNodes).toList();
}
}
@@ -212,6 +200,11 @@ class MapDataProvider {
void clearTileQueue() {
clearRemoteTileQueue();
}
/// Clear only tile requests that are no longer visible in the current bounds
void clearTileQueueSelective(LatLngBounds currentBounds) {
clearRemoteTileQueueSelective(currentBounds);
}
/// Fetch remote nodes with Overpass first, OSM API fallback
Future<List<OsmNode>> _fetchRemoteNodes({

View File

@@ -8,17 +8,101 @@ import '../../models/node_profile.dart';
import '../../models/osm_node.dart';
import '../../models/pending_upload.dart';
import '../../app_state.dart';
import '../../dev_config.dart';
import '../network_status.dart';
import '../overpass_node_limit_exception.dart';
/// Fetches surveillance nodes from the Overpass OSM API for the given bounds and profiles.
/// If the query fails due to too many nodes, automatically splits the area and retries.
Future<List<OsmNode>> fetchOverpassNodes({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
UploadMode uploadMode = UploadMode.production,
required int maxResults,
}) async {
// Check if this is a user-initiated fetch (indicated by loading state)
final wasUserInitiated = NetworkStatus.instance.currentStatus == NetworkStatusType.waiting;
return _fetchOverpassNodesWithSplitting(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: maxResults,
splitDepth: 0,
wasUserInitiated: wasUserInitiated,
);
}
/// Internal method that handles splitting when node limit is exceeded.
Future<List<OsmNode>> _fetchOverpassNodesWithSplitting({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
UploadMode uploadMode = UploadMode.production,
required int maxResults,
required int splitDepth,
required bool wasUserInitiated,
}) async {
if (profiles.isEmpty) return [];
const int maxSplitDepth = kMaxPreFetchSplitDepth; // Maximum times we'll split (4^3 = 64 max sub-areas)
try {
return await _fetchSingleOverpassQuery(
bounds: bounds,
profiles: profiles,
maxResults: maxResults,
);
} on OverpassRateLimitException catch (e) {
// Rate limits should NOT be split - just fail with extended backoff
debugPrint('[fetchOverpassNodes] Rate limited - using extended backoff, not splitting');
// Report error if user was waiting
if (wasUserInitiated) {
NetworkStatus.instance.setNetworkError();
}
// Wait longer for rate limits before giving up entirely
await Future.delayed(const Duration(seconds: 30));
return []; // Return empty rather than rethrowing
} on OverpassNodeLimitException {
// If we've hit max split depth, give up to avoid infinite recursion
if (splitDepth >= maxSplitDepth) {
debugPrint('[fetchOverpassNodes] Max split depth reached, giving up on area: $bounds');
// Report timeout if this was user-initiated (can't split further)
if (wasUserInitiated) {
NetworkStatus.instance.setTimeoutError();
}
return [];
}
// Split the bounds into 4 quadrants and try each separately
debugPrint('[fetchOverpassNodes] Splitting area into quadrants (depth: $splitDepth)');
final quadrants = _splitBounds(bounds);
final List<OsmNode> allNodes = [];
for (final quadrant in quadrants) {
final nodes = await _fetchOverpassNodesWithSplitting(
bounds: quadrant,
profiles: profiles,
uploadMode: uploadMode,
maxResults: 0, // No limit on individual quadrants to avoid double-limiting
splitDepth: splitDepth + 1,
wasUserInitiated: wasUserInitiated,
);
allNodes.addAll(nodes);
}
debugPrint('[fetchOverpassNodes] Collected ${allNodes.length} nodes from ${quadrants.length} quadrants');
return allNodes;
}
}
/// Perform a single Overpass query without splitting logic.
Future<List<OsmNode>> _fetchSingleOverpassQuery({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
required int maxResults,
}) async {
const String overpassEndpoint = 'https://overpass-api.de/api/interpreter';
// Build the Overpass query
@@ -34,7 +118,34 @@ Future<List<OsmNode>> fetchOverpassNodes({
);
if (response.statusCode != 200) {
debugPrint('[fetchOverpassNodes] Overpass API error: ${response.body}');
final errorBody = response.body;
debugPrint('[fetchOverpassNodes] Overpass API error: $errorBody');
// Check if it's specifically the 50k node limit error (HTTP 400)
// Exact message: "You requested too many nodes (limit is 50000)"
if (errorBody.contains('too many nodes') &&
errorBody.contains('50000')) {
debugPrint('[fetchOverpassNodes] Detected 50k node limit error, will attempt splitting');
throw OverpassNodeLimitException('Query exceeded node limit', serverResponse: errorBody);
}
// Check for timeout errors that indicate query complexity (should split)
// Common timeout messages from Overpass
if (errorBody.contains('timeout') ||
errorBody.contains('runtime limit exceeded') ||
errorBody.contains('Query timed out')) {
debugPrint('[fetchOverpassNodes] Detected timeout error, will attempt splitting to reduce complexity');
throw OverpassNodeLimitException('Query timed out', serverResponse: errorBody);
}
// Check for rate limiting (should NOT split - needs longer backoff)
if (errorBody.contains('rate limited') ||
errorBody.contains('too many requests') ||
response.statusCode == 429) {
debugPrint('[fetchOverpassNodes] Rate limited by Overpass API - needs extended backoff');
throw OverpassRateLimitException('Rate limited by server', serverResponse: errorBody);
}
NetworkStatus.instance.reportOverpassIssue();
return [];
}
@@ -62,6 +173,9 @@ Future<List<OsmNode>> fetchOverpassNodes({
return nodes;
} catch (e) {
// Re-throw OverpassNodeLimitException so splitting logic can catch it
if (e is OverpassNodeLimitException) rethrow;
debugPrint('[fetchOverpassNodes] Exception: $e');
// Report network issues for connection errors
@@ -100,6 +214,35 @@ $outputClause
''';
}
/// Split a LatLngBounds into 4 quadrants (NW, NE, SW, SE).
List<LatLngBounds> _splitBounds(LatLngBounds bounds) {
final centerLat = (bounds.north + bounds.south) / 2;
final centerLng = (bounds.east + bounds.west) / 2;
return [
// Southwest quadrant (bottom-left)
LatLngBounds(
LatLng(bounds.south, bounds.west),
LatLng(centerLat, centerLng),
),
// Southeast quadrant (bottom-right)
LatLngBounds(
LatLng(bounds.south, centerLng),
LatLng(centerLat, bounds.east),
),
// Northwest quadrant (top-left)
LatLngBounds(
LatLng(centerLat, bounds.west),
LatLng(bounds.north, centerLng),
),
// Northeast quadrant (top-right)
LatLngBounds(
LatLng(centerLat, centerLng),
LatLng(bounds.north, bounds.east),
),
];
}
/// Clean up pending uploads that now appear in Overpass results
void _cleanupCompletedUploads(List<OsmNode> overpassNodes) {
try {

View File

@@ -3,6 +3,8 @@ import 'dart:io';
import 'dart:async';
import 'package:http/http.dart' as http;
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:deflockapp/dev_config.dart';
import '../network_status.dart';
@@ -18,6 +20,77 @@ void clearRemoteTileQueue() {
}
}
/// Clear only tile requests that are no longer visible in the given bounds
void clearRemoteTileQueueSelective(LatLngBounds currentBounds) {
final clearedCount = _tileFetchSemaphore.clearStaleRequests((z, x, y) {
// Return true if tile should be cleared (i.e., is NOT visible)
return !_isTileVisible(z, x, y, currentBounds);
});
if (clearedCount > 0) {
debugPrint('[RemoteTiles] Selectively cleared $clearedCount non-visible tile requests');
}
}
/// Calculate retry delay using configurable backoff strategy.
/// Uses: initialDelay * (multiplier ^ (attempt - 1)) + randomJitter, capped at maxDelay
int _calculateRetryDelay(int attempt, Random random) {
// Calculate exponential backoff: initialDelay * (multiplier ^ (attempt - 1))
final baseDelay = (kTileFetchInitialDelayMs *
pow(kTileFetchBackoffMultiplier, attempt - 1)).round();
// Add random jitter to avoid thundering herd
final jitter = random.nextInt(kTileFetchRandomJitterMs + 1);
// Apply max delay cap
return (baseDelay + jitter).clamp(0, kTileFetchMaxDelayMs);
}
/// Convert tile coordinates to lat/lng bounds for spatial filtering
class _TileBounds {
final double north, south, east, west;
_TileBounds({required this.north, required this.south, required this.east, required this.west});
}
/// Calculate the lat/lng bounds for a given tile
_TileBounds _tileToBounds(int z, int x, int y) {
final n = pow(2, z);
final lon1 = (x / n) * 360.0 - 180.0;
final lon2 = ((x + 1) / n) * 360.0 - 180.0;
final lat1 = _yToLatitude(y, z);
final lat2 = _yToLatitude(y + 1, z);
return _TileBounds(
north: max(lat1, lat2),
south: min(lat1, lat2),
east: max(lon1, lon2),
west: min(lon1, lon2),
);
}
/// Convert tile Y coordinate to latitude
double _yToLatitude(int y, int z) {
final n = pow(2, z);
final latRad = atan(_sinh(pi * (1 - 2 * y / n)));
return latRad * 180.0 / pi;
}
/// Hyperbolic sine function: sinh(x) = (e^x - e^(-x)) / 2
double _sinh(double x) {
return (exp(x) - exp(-x)) / 2;
}
/// Check if a tile intersects with the current view bounds
bool _isTileVisible(int z, int x, int y, LatLngBounds viewBounds) {
final tileBounds = _tileToBounds(z, x, y);
// Check if tile bounds intersect with view bounds
return !(tileBounds.east < viewBounds.west ||
tileBounds.west > viewBounds.east ||
tileBounds.north < viewBounds.south ||
tileBounds.south > viewBounds.north);
}
/// Fetches a tile from any remote provider, with in-memory retries/backoff, and global concurrency limit.
@@ -31,16 +104,10 @@ Future<List<int>> fetchRemoteTile({
const int maxAttempts = kTileFetchMaxAttempts;
int attempt = 0;
final random = Random();
final delays = [
kTileFetchInitialDelayMs + random.nextInt(kTileFetchJitter1Ms),
kTileFetchSecondDelayMs + random.nextInt(kTileFetchJitter2Ms),
kTileFetchThirdDelayMs + random.nextInt(kTileFetchJitter3Ms),
];
final hostInfo = Uri.parse(url).host; // For logging
while (true) {
await _tileFetchSemaphore.acquire();
await _tileFetchSemaphore.acquire(z: z, x: x, y: y);
try {
// Only log on first attempt or errors
if (attempt == 1) {
@@ -71,7 +138,7 @@ Future<List<int>> fetchRemoteTile({
rethrow;
}
final delay = delays[attempt - 1].clamp(0, 60000);
final delay = _calculateRetryDelay(attempt, random);
if (attempt == 1) {
debugPrint("[fetchRemoteTile] Attempt $attempt for $z/$x/$y from $hostInfo failed: $e. Retrying in ${delay}ms.");
}
@@ -97,28 +164,42 @@ Future<List<int>> fetchOSMTile({
);
}
/// Simple counting semaphore, suitable for single-thread Flutter concurrency
/// Enhanced tile request entry that tracks coordinates for spatial filtering
class _TileRequest {
final int z, x, y;
final VoidCallback callback;
_TileRequest({required this.z, required this.x, required this.y, required this.callback});
}
/// Spatially-aware counting semaphore for tile requests
class _SimpleSemaphore {
final int _max;
int _current = 0;
final List<VoidCallback> _queue = [];
final List<_TileRequest> _queue = [];
_SimpleSemaphore(this._max);
Future<void> acquire() async {
Future<void> acquire({int? z, int? x, int? y}) async {
if (_current < _max) {
_current++;
return;
} else {
final c = Completer<void>();
_queue.add(() => c.complete());
final request = _TileRequest(
z: z ?? -1,
x: x ?? -1,
y: y ?? -1,
callback: () => c.complete(),
);
_queue.add(request);
await c.future;
}
}
void release() {
if (_queue.isNotEmpty) {
final callback = _queue.removeAt(0);
callback();
final request = _queue.removeAt(0);
request.callback();
} else {
_current--;
}
@@ -130,4 +211,17 @@ class _SimpleSemaphore {
_queue.clear();
return clearedCount;
}
/// Clear only tiles that don't pass the visibility filter
int clearStaleRequests(bool Function(int z, int x, int y) isStale) {
final initialCount = _queue.length;
_queue.removeWhere((request) => isStale(request.z, request.x, request.y));
final clearedCount = initialCount - _queue.length;
if (clearedCount > 0) {
debugPrint('[SimpleSemaphore] Cleared $clearedCount stale tile requests, kept ${_queue.length}');
}
return clearedCount;
}
}

View File

@@ -4,10 +4,9 @@ import 'dart:async';
import '../app_state.dart';
enum NetworkIssueType { osmTiles, overpassApi, both }
enum NetworkStatusType { waiting, issues, timedOut, noData, ready, success }
enum NetworkStatusType { waiting, issues, timedOut, noData, ready, success, nodeLimitReached }
/// Simple loading state for dual-source async operations (brutalist approach)
enum LoadingState { ready, waiting, success, timeout }
class NetworkStatus extends ChangeNotifier {
static final NetworkStatus instance = NetworkStatus._();
@@ -25,13 +24,8 @@ class NetworkStatus extends ChangeNotifier {
Timer? _waitingTimer;
Timer? _noDataResetTimer;
Timer? _successResetTimer;
// New dual-source loading state (brutalist approach)
LoadingState _tileLoadingState = LoadingState.ready;
LoadingState _nodeLoadingState = LoadingState.ready;
Timer? _tileTimeoutTimer;
Timer? _nodeTimeoutTimer;
Timer? _successDisplayTimer;
bool _nodeLimitReached = false;
Timer? _nodeLimitResetTimer;
// Getters
bool get hasAnyIssues => _osmTilesHaveIssues || _overpassHasIssues;
@@ -41,28 +35,16 @@ class NetworkStatus extends ChangeNotifier {
bool get isTimedOut => _isTimedOut;
bool get hasNoData => _hasNoData;
bool get hasSuccess => _hasSuccess;
// New dual-source getters (brutalist approach)
LoadingState get tileLoadingState => _tileLoadingState;
LoadingState get nodeLoadingState => _nodeLoadingState;
/// Derive overall loading status from dual sources
bool get isDualSourceLoading => _tileLoadingState == LoadingState.waiting || _nodeLoadingState == LoadingState.waiting;
bool get isDualSourceTimeout => _tileLoadingState == LoadingState.timeout || _nodeLoadingState == LoadingState.timeout;
bool get isDualSourceSuccess => _tileLoadingState == LoadingState.success && _nodeLoadingState == LoadingState.success;
bool get nodeLimitReached => _nodeLimitReached;
NetworkStatusType get currentStatus {
// Check new dual-source states first
if (isDualSourceTimeout) return NetworkStatusType.timedOut;
if (isDualSourceLoading) return NetworkStatusType.waiting;
if (isDualSourceSuccess) return NetworkStatusType.success;
// Fall back to legacy states for compatibility
// Simple single-path status logic
if (hasAnyIssues) return NetworkStatusType.issues;
if (_isWaitingForData) return NetworkStatusType.waiting;
if (_isTimedOut) return NetworkStatusType.timedOut;
if (_hasNoData) return NetworkStatusType.noData;
if (_hasSuccess) return NetworkStatusType.success;
if (_nodeLimitReached) return NetworkStatusType.nodeLimitReached;
return NetworkStatusType.ready;
}
@@ -194,18 +176,72 @@ class NetworkStatus extends ChangeNotifier {
/// Clear waiting/timeout/no-data status (legacy method for compatibility)
void clearWaiting() {
if (_isWaitingForData || _isTimedOut || _hasNoData || _hasSuccess) {
if (_isWaitingForData || _isTimedOut || _hasNoData || _hasSuccess || _nodeLimitReached) {
_isWaitingForData = false;
_isTimedOut = false;
_hasNoData = false;
_hasSuccess = false;
_nodeLimitReached = false;
_recentOfflineMisses = 0;
_waitingTimer?.cancel();
_noDataResetTimer?.cancel();
_successResetTimer?.cancel();
_nodeLimitResetTimer?.cancel();
notifyListeners();
}
}
/// Set timeout error state
void setTimeoutError() {
_isWaitingForData = false;
_isTimedOut = true;
_hasNoData = false;
_hasSuccess = false;
_waitingTimer?.cancel();
_noDataResetTimer?.cancel();
_successResetTimer?.cancel();
notifyListeners();
debugPrint('[NetworkStatus] Request timed out');
// Auto-clear timeout after 5 seconds
Timer(const Duration(seconds: 5), () {
if (_isTimedOut) {
_isTimedOut = false;
notifyListeners();
}
});
}
/// Set network error state (rate limits, connection issues, etc.)
void setNetworkError() {
_isWaitingForData = false;
_isTimedOut = false;
_hasNoData = false;
_hasSuccess = false;
_waitingTimer?.cancel();
_noDataResetTimer?.cancel();
_successResetTimer?.cancel();
// Use existing issue reporting system
reportOverpassIssue();
debugPrint('[NetworkStatus] Network error occurred');
}
/// Show notification that node display limit was reached
void reportNodeLimitReached(int totalNodes, int maxNodes) {
_nodeLimitReached = true;
notifyListeners();
debugPrint('[NetworkStatus] Node display limit reached: $totalNodes found, showing $maxNodes');
// Auto-clear after 8 seconds
_nodeLimitResetTimer?.cancel();
_nodeLimitResetTimer = Timer(const Duration(seconds: 8), () {
if (_nodeLimitReached) {
_nodeLimitReached = false;
notifyListeners();
}
});
}
@@ -231,81 +267,7 @@ class NetworkStatus extends ChangeNotifier {
});
}
// New dual-source loading methods (brutalist approach)
/// Start waiting for both tiles and nodes
void setDualSourceWaiting() {
_tileLoadingState = LoadingState.waiting;
_nodeLoadingState = LoadingState.waiting;
// Set timeout timers for both
_tileTimeoutTimer?.cancel();
_tileTimeoutTimer = Timer(const Duration(seconds: 8), () {
if (_tileLoadingState == LoadingState.waiting) {
_tileLoadingState = LoadingState.timeout;
debugPrint('[NetworkStatus] Tile loading timed out');
notifyListeners();
}
});
_nodeTimeoutTimer?.cancel();
_nodeTimeoutTimer = Timer(const Duration(seconds: 8), () {
if (_nodeLoadingState == LoadingState.waiting) {
_nodeLoadingState = LoadingState.timeout;
debugPrint('[NetworkStatus] Node loading timed out');
notifyListeners();
}
});
notifyListeners();
}
/// Report tile loading completion
void reportTileComplete() {
if (_tileLoadingState == LoadingState.waiting) {
_tileLoadingState = LoadingState.success;
_tileTimeoutTimer?.cancel();
_checkDualSourceComplete();
}
}
/// Report node loading completion
void reportNodeComplete() {
if (_nodeLoadingState == LoadingState.waiting) {
_nodeLoadingState = LoadingState.success;
_nodeTimeoutTimer?.cancel();
_checkDualSourceComplete();
}
}
/// Check if both sources are complete and show success briefly
void _checkDualSourceComplete() {
if (_tileLoadingState == LoadingState.success && _nodeLoadingState == LoadingState.success) {
debugPrint('[NetworkStatus] Both tiles and nodes loaded successfully');
notifyListeners();
// Auto-reset to ready after showing success briefly
_successDisplayTimer?.cancel();
_successDisplayTimer = Timer(const Duration(seconds: 2), () {
_tileLoadingState = LoadingState.ready;
_nodeLoadingState = LoadingState.ready;
notifyListeners();
});
} else {
// Just notify if one completed but not both yet
notifyListeners();
}
}
/// Reset dual-source state to ready
void resetDualSourceState() {
_tileLoadingState = LoadingState.ready;
_nodeLoadingState = LoadingState.ready;
_tileTimeoutTimer?.cancel();
_nodeTimeoutTimer?.cancel();
_successDisplayTimer?.cancel();
notifyListeners();
}
@override
void dispose() {
@@ -313,9 +275,8 @@ class NetworkStatus extends ChangeNotifier {
_overpassRecoveryTimer?.cancel();
_waitingTimer?.cancel();
_noDataResetTimer?.cancel();
_tileTimeoutTimer?.cancel();
_nodeTimeoutTimer?.cancel();
_successDisplayTimer?.cancel();
_successResetTimer?.cancel();
_nodeLimitResetTimer?.cancel();
super.dispose();
}
}

View File

@@ -0,0 +1,23 @@
/// Exception thrown when Overpass API returns an error indicating too many nodes were requested.
/// This typically happens when querying large areas that would return more than 50k nodes.
class OverpassNodeLimitException implements Exception {
final String message;
final String? serverResponse;
OverpassNodeLimitException(this.message, {this.serverResponse});
@override
String toString() => 'OverpassNodeLimitException: $message';
}
/// Exception thrown when Overpass API rate limits the request.
/// Should trigger longer backoff delays, not area splitting.
class OverpassRateLimitException implements Exception {
final String message;
final String? serverResponse;
OverpassRateLimitException(this.message, {this.serverResponse});
@override
String toString() => 'OverpassRateLimitException: $message';
}

View File

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

View File

@@ -1,5 +1,7 @@
import 'package:http/http.dart' as http;
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart';
import '../app_state.dart';
import 'map_data_provider.dart';
@@ -84,7 +86,10 @@ class SimpleTileHttpClient extends http.BaseClient {
// Decrement pending counter and report completion when all done
_pendingTileRequests--;
if (_pendingTileRequests == 0) {
NetworkStatus.instance.reportTileComplete();
// Only report tile completion if we were in loading state (user-initiated)
if (NetworkStatus.instance.currentStatus == NetworkStatusType.waiting) {
NetworkStatus.instance.setSuccess();
}
}
}
}
@@ -93,6 +98,11 @@ class SimpleTileHttpClient extends http.BaseClient {
void clearTileQueue() {
_mapDataProvider.clearTileQueue();
}
/// Clear only tile requests that are no longer visible in the current bounds
void clearStaleRequests(LatLngBounds currentBounds) {
_mapDataProvider.clearTileQueueSelective(currentBounds);
}
/// Format date for HTTP headers (RFC 7231)
String _httpDateFormat(DateTime date) {

View File

@@ -12,8 +12,8 @@ enum UploadMode { production, sandbox, simulate }
// Enum for follow-me mode (moved from HomeScreen to centralized state)
enum FollowMeMode {
off, // No following
northUp, // Follow position, keep north up
rotating, // Follow position and rotation
follow, // Follow position, preserve current rotation
rotating, // Follow position and rotation based on heading
}
class SettingsState extends ChangeNotifier {
@@ -32,7 +32,7 @@ class SettingsState extends ChangeNotifier {
bool _offlineMode = false;
int _maxCameras = 250;
UploadMode _uploadMode = kEnableDevelopmentModes ? UploadMode.simulate : UploadMode.production;
FollowMeMode _followMeMode = FollowMeMode.northUp;
FollowMeMode _followMeMode = FollowMeMode.follow;
bool _proximityAlertsEnabled = false;
int _proximityAlertDistance = kProximityAlertDefaultDistance;
bool _networkStatusIndicatorEnabled = false;
@@ -291,12 +291,13 @@ class SettingsState extends ChangeNotifier {
Future<void> setFollowMeMode(FollowMeMode mode) async {
if (_followMeMode != mode) {
_followMeMode = mode;
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_followMeModePrefsKey, mode.index);
notifyListeners();
}
}
/// Set proximity alerts enabled/disabled
Future<void> setProximityAlertsEnabled(bool enabled) async {
if (_proximityAlertsEnabled != enabled) {

View File

@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import '../services/changelog_service.dart';
import '../services/version_service.dart';
class ChangelogDialog extends StatelessWidget {
final String changelogContent;
const ChangelogDialog({
super.key,
required this.changelogContent,
});
void _onClose(BuildContext context) async {
// Update version tracking when closing changelog dialog
await ChangelogService().updateLastSeenVersion();
if (context.mounted) {
Navigator.of(context).pop();
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('What\'s New in v${VersionService().version}'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
changelogContent,
style: const TextStyle(fontSize: 14, height: 1.4),
),
const SizedBox(height: 16),
Text(
'Thank you for keeping DeFlock up to date!',
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.primary,
fontStyle: FontStyle.italic,
),
),
],
),
),
actions: [
TextButton(
onPressed: () => _onClose(context),
child: const Text('Continue'),
),
],
);
}
}

View File

@@ -0,0 +1,164 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map_animations/flutter_map_animations.dart';
import 'package:provider/provider.dart';
import '../app_state.dart';
/// A compass indicator widget that shows the current map rotation and allows tapping to animate to north.
/// The compass appears in the top-right corner of the map and is disabled (non-interactive) when in follow+rotate mode.
class CompassIndicator extends StatefulWidget {
final AnimatedMapController mapController;
const CompassIndicator({
super.key,
required this.mapController,
});
@override
State<CompassIndicator> createState() => _CompassIndicatorState();
}
class _CompassIndicatorState extends State<CompassIndicator> {
Timer? _animationTimer;
@override
void dispose() {
_animationTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
// Get current map rotation in degrees
double rotationDegrees = 0.0;
try {
rotationDegrees = widget.mapController.mapController.camera.rotation;
} catch (_) {
// Map controller not ready yet
}
// Convert degrees to radians for Transform.rotate (flutter_map uses degrees)
final rotationRadians = rotationDegrees * (pi / 180);
// Check if we're in follow+rotate mode (compass should be disabled)
final isDisabled = appState.followMeMode == FollowMeMode.rotating;
return Positioned(
top: (appState.uploadMode == UploadMode.sandbox || appState.uploadMode == UploadMode.simulate) ? 60 : 18,
right: 16,
child: GestureDetector(
onTap: isDisabled ? null : () {
// Animate to north-up orientation
try {
// Cancel any existing animation timer
_animationTimer?.cancel();
// Start animation
widget.mapController.animateTo(
dest: widget.mapController.mapController.camera.center,
zoom: widget.mapController.mapController.camera.zoom,
rotation: 0.0,
duration: const Duration(milliseconds: 500),
curve: Curves.easeOut,
);
// Start timer to force compass updates during animation
// Update every 16ms (~60fps) for smooth visual rotation
_animationTimer = Timer.periodic(const Duration(milliseconds: 16), (timer) {
if (mounted) {
setState(() {});
}
});
// Stop the timer after animation completes (with small buffer)
Timer(const Duration(milliseconds: 550), () {
_animationTimer?.cancel();
_animationTimer = null;
if (mounted) {
setState(() {}); // Final update to ensure correct end state
}
});
} catch (_) {
// Controller not ready, ignore
}
},
child: Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: isDisabled
? Colors.grey.withOpacity(0.8)
: Colors.white.withOpacity(0.95),
shape: BoxShape.circle,
border: Border.all(
color: isDisabled
? Colors.grey.shade400
: Colors.grey.shade300,
width: 2.0,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.25),
blurRadius: 6,
offset: const Offset(0, 3),
),
],
),
child: Stack(
children: [
// Compass face with cardinal directions
Center(
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isDisabled
? Colors.grey.shade200
: Colors.grey.shade50,
),
),
),
// North indicator that rotates with map rotation
Transform.rotate(
angle: rotationRadians, // Rotate same direction as map rotation to counter-act it
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// North arrow (red triangle pointing up)
Container(
margin: const EdgeInsets.only(top: 6),
child: Icon(
Icons.keyboard_arrow_up,
size: 20,
color: isDisabled
? Colors.grey.shade600
: Colors.red.shade600,
),
),
// Small 'N' label
Text(
'N',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: isDisabled
? Colors.grey.shade600
: Colors.red.shade600,
),
),
],
),
),
),
],
),
),
),
);
}
}

View File

@@ -5,6 +5,7 @@ import 'package:latlong2/latlong.dart';
import '../../models/node_profile.dart';
import '../../app_state.dart' show UploadMode;
import '../../services/prefetch_area_service.dart';
import '../camera_provider_with_cache.dart';
import '../../dev_config.dart';
@@ -43,6 +44,8 @@ class CameraRefreshController {
WidgetsBinding.instance.addPostFrameCallback((_) {
// Clear camera cache to ensure fresh data for new profile combination
_cameraProvider.clearCache();
// Clear pre-fetch area since profiles changed
PrefetchAreaService().clearPreFetchedArea();
// Force display refresh first (for immediate UI update)
_cameraProvider.refreshDisplay();
// Notify that profiles changed (triggers camera refresh)

View File

@@ -46,6 +46,7 @@ class GpsController {
required FollowMeMode newMode,
required FollowMeMode oldMode,
required AnimatedMapController controller,
VoidCallback? onMapMovedProgrammatically,
}) {
debugPrint('[GpsController] Follow-me mode changed: $oldMode$newMode');
@@ -55,13 +56,14 @@ class GpsController {
_currentLatLng != null) {
try {
if (newMode == FollowMeMode.northUp) {
if (newMode == FollowMeMode.follow) {
controller.animateTo(
dest: _currentLatLng!,
zoom: controller.mapController.camera.zoom,
duration: kFollowMeAnimationDuration,
curve: Curves.easeOut,
);
onMapMovedProgrammatically?.call();
} else if (newMode == FollowMeMode.rotating) {
// When switching to rotating mode, reset to north-up first
controller.animateTo(
@@ -71,6 +73,7 @@ class GpsController {
duration: kFollowMeAnimationDuration,
curve: Curves.easeOut,
);
onMapMovedProgrammatically?.call();
}
} catch (e) {
debugPrint('[GpsController] MapController not ready for follow-me change: $e');
@@ -89,6 +92,9 @@ class GpsController {
int proximityAlertDistance = 200,
List<OsmNode> nearbyNodes = const [],
List<NodeProfile> enabledProfiles = const [],
// Optional callback when map is moved programmatically
VoidCallback? onMapMovedProgrammatically,
}) {
final latLng = LatLng(position.latitude, position.longitude);
_currentLatLng = latLng;
@@ -111,14 +117,18 @@ class GpsController {
debugPrint('[GpsController] GPS position update: ${latLng.latitude}, ${latLng.longitude}, follow-me: $followMeMode');
WidgetsBinding.instance.addPostFrameCallback((_) {
try {
if (followMeMode == FollowMeMode.northUp) {
if (followMeMode == FollowMeMode.follow) {
// Follow position only, keep current rotation
controller.animateTo(
dest: latLng,
zoom: controller.mapController.camera.zoom,
rotation: controller.mapController.camera.rotation,
duration: kFollowMeAnimationDuration,
curve: Curves.easeOut,
);
// Notify that we moved the map programmatically (for node refresh)
onMapMovedProgrammatically?.call();
} else if (followMeMode == FollowMeMode.rotating) {
// Follow position and rotation based on heading
final heading = position.heading;
@@ -135,6 +145,9 @@ class GpsController {
duration: kFollowMeAnimationDuration,
curve: Curves.easeOut,
);
// Notify that we moved the map programmatically (for node refresh)
onMapMovedProgrammatically?.call();
}
} catch (e) {
debugPrint('[GpsController] MapController not ready for position animation: $e');
@@ -153,6 +166,8 @@ class GpsController {
required int Function() getProximityAlertDistance,
required List<OsmNode> Function() getNearbyNodes,
required List<NodeProfile> Function() getEnabledProfiles,
VoidCallback? onMapMovedProgrammatically,
}) async {
final perm = await Geolocator.requestPermission();
if (perm == LocationPermission.denied ||
@@ -168,7 +183,6 @@ class GpsController {
final proximityAlertDistance = getProximityAlertDistance();
final nearbyNodes = getNearbyNodes();
final enabledProfiles = getEnabledProfiles();
processPositionUpdate(
position: position,
followMeMode: currentFollowMeMode,
@@ -178,6 +192,7 @@ class GpsController {
proximityAlertDistance: proximityAlertDistance,
nearbyNodes: nearbyNodes,
enabledProfiles: enabledProfiles,
onMapMovedProgrammatically: onMapMovedProgrammatically,
);
});
}

View File

@@ -1,22 +1,23 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map_animations/flutter_map_animations.dart';
import 'package:provider/provider.dart';
import '../../app_state.dart';
import '../../dev_config.dart';
import '../../services/localization_service.dart';
import '../camera_icon.dart';
import '../compass_indicator.dart';
import 'layer_selector_button.dart';
/// Widget that renders all map overlay UI elements
class MapOverlays extends StatelessWidget {
final MapController mapController;
final AnimatedMapController mapController;
final UploadMode uploadMode;
final AddNodeSession? session;
final EditNodeSession? editSession;
final String? attribution; // Attribution for current tile provider
final VoidCallback? onSearchPressed; // Callback for search button
const MapOverlays({
super.key,
required this.mapController,
@@ -82,6 +83,11 @@ class MapOverlays extends StatelessWidget {
),
),
// Compass indicator (top-right, below mode indicator)
CompassIndicator(
mapController: mapController,
),
// Zoom indicator, positioned relative to button bar
Positioned(
left: 10,
@@ -96,7 +102,7 @@ class MapOverlays extends StatelessWidget {
builder: (context) {
double zoom = 15.0; // fallback
try {
zoom = mapController.camera.zoom;
zoom = mapController.mapController.camera.zoom;
} catch (_) {
// Map controller not ready yet
}
@@ -173,8 +179,8 @@ class MapOverlays extends StatelessWidget {
heroTag: "zoom_in",
onPressed: () {
try {
final zoom = mapController.camera.zoom;
mapController.move(mapController.camera.center, zoom + 1);
final zoom = mapController.mapController.camera.zoom;
mapController.mapController.move(mapController.mapController.camera.center, zoom + 1);
} catch (_) {
// Map controller not ready yet
}
@@ -188,8 +194,8 @@ class MapOverlays extends StatelessWidget {
heroTag: "zoom_out",
onPressed: () {
try {
final zoom = mapController.camera.zoom;
mapController.move(mapController.camera.center, zoom - 1);
final zoom = mapController.mapController.camera.zoom;
mapController.mapController.move(mapController.mapController.camera.center, zoom - 1);
} catch (_) {
// Map controller not ready yet
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import '../../models/tile_provider.dart' as models;
import '../../services/simple_tile_service.dart';
@@ -64,6 +65,11 @@ class TileLayerManager {
void clearTileQueueImmediate() {
_tileHttpClient.clearTileQueue();
}
/// Clear only tiles that are no longer visible in the current bounds
void clearStaleRequests({required LatLngBounds currentBounds}) {
_tileHttpClient.clearStaleRequests(currentBounds);
}
/// Build tile layer widget with current provider and type.
/// Uses fake domain that SimpleTileHttpClient can parse for cache separation.

View File

@@ -7,6 +7,7 @@ import 'package:provider/provider.dart';
import '../app_state.dart';
import '../services/offline_area_service.dart';
import '../services/network_status.dart';
import '../services/prefetch_area_service.dart';
import '../models/osm_node.dart';
import '../models/node_profile.dart';
import '../models/suspected_location.dart';
@@ -70,8 +71,15 @@ class MapViewState extends State<MapView> {
// Track zoom to clear queue on zoom changes
double? _lastZoom;
// Track map center to clear queue on significant panning
LatLng? _lastCenter;
// State for proximity alert banner
bool _showProximityBanner = false;
@override
void initState() {
@@ -174,6 +182,11 @@ class MapViewState extends State<MapView> {
}
return [];
},
onMapMovedProgrammatically: () {
// Refresh nodes when GPS controller moves the map
_refreshNodesFromProvider();
},
);
// Fetch initial cameras
@@ -194,6 +207,7 @@ class MapViewState extends State<MapView> {
_cameraController.dispose();
_tileManager.dispose();
_gpsController.dispose();
PrefetchAreaService().dispose();
super.dispose();
}
@@ -230,6 +244,22 @@ class MapViewState extends State<MapView> {
}
}
/// Check if the map has moved significantly enough to cancel stale tile requests.
/// Uses a simple distance threshold - roughly equivalent to 1/4 screen width at zoom 15.
bool _mapMovedSignificantly(LatLng? newCenter, LatLng? oldCenter) {
if (newCenter == null || oldCenter == null) return false;
// Calculate approximate distance in meters (rough calculation for performance)
final latDiff = (newCenter.latitude - oldCenter.latitude).abs();
final lngDiff = (newCenter.longitude - oldCenter.longitude).abs();
// Threshold: ~500 meters (roughly 1/4 screen at zoom 15)
// This prevents excessive cancellations on small movements while catching real pans
const double significantMovementThreshold = 0.005; // degrees (~500m at equator)
return latDiff > significantMovementThreshold || lngDiff > significantMovementThreshold;
}
/// Show zoom warning if user is below minimum zoom level
void _showZoomWarningIfNeeded(BuildContext context, double currentZoom, int minZoom) {
// Only show warning once per zoom level to avoid spam
@@ -278,6 +308,9 @@ class MapViewState extends State<MapView> {
newMode: widget.followMeMode,
oldMode: oldWidget.followMeMode,
controller: _controller,
onMapMovedProgrammatically: () {
_refreshNodesFromProvider();
},
);
}
}
@@ -523,7 +556,11 @@ class MapViewState extends State<MapView> {
maxZoom: (appState.selectedTileType?.maxZoom ?? 18).toDouble(),
onPositionChanged: (pos, gesture) {
setState(() {}); // Instant UI update for zoom, etc.
if (gesture) widget.onUserGesture();
if (gesture) {
widget.onUserGesture();
}
if (session != null) {
appState.updateSession(target: pos.center);
@@ -537,20 +574,29 @@ class MapViewState extends State<MapView> {
appState.updateProvisionalPinLocation(pos.center);
}
// Start dual-source waiting when map moves (user is expecting new tiles AND nodes)
NetworkStatus.instance.setDualSourceWaiting();
// Only clear tile queue on significant ZOOM changes (not panning)
// Clear tile queue on tile level changes OR significant panning
final currentZoom = pos.zoom;
final zoomChanged = _lastZoom != null && (currentZoom - _lastZoom!).abs() > 0.5;
final currentCenter = pos.center;
final currentTileLevel = currentZoom.round();
final lastTileLevel = _lastZoom?.round();
final tileLevelChanged = lastTileLevel != null && currentTileLevel != lastTileLevel;
final centerMoved = _mapMovedSignificantly(currentCenter, _lastCenter);
if (zoomChanged) {
if (tileLevelChanged || centerMoved) {
_tileDebounce(() {
// Clear stale tile requests on zoom change (quietly)
_tileManager.clearTileQueueImmediate();
// Use selective clearing to only cancel tiles that are no longer visible
try {
final currentBounds = _controller.mapController.camera.visibleBounds;
_tileManager.clearStaleRequests(currentBounds: currentBounds);
} catch (e) {
// Fallback to clearing all if bounds calculation fails
debugPrint('[MapView] Could not get current bounds for selective clearing: $e');
_tileManager.clearTileQueueImmediate();
}
});
}
_lastZoom = currentZoom;
_lastCenter = currentCenter;
// Save map position (debounced to avoid excessive writes)
_mapPositionDebounce(() {
@@ -562,9 +608,7 @@ class MapViewState extends State<MapView> {
if (pos.zoom >= minZoom) {
_cameraDebounce(_refreshNodesFromProvider);
} else {
// Skip nodes at low zoom - report immediate completion (brutalist approach)
NetworkStatus.instance.reportNodeComplete();
// Skip nodes at low zoom - no loading state needed
// Show zoom warning if needed
_showZoomWarningIfNeeded(context, pos.zoom, minZoom);
}
@@ -594,7 +638,7 @@ class MapViewState extends State<MapView> {
// All map overlays (mode indicator, zoom, attribution, add pin)
MapOverlays(
mapController: _controller.mapController,
mapController: _controller,
uploadMode: appState.uploadMode,
session: session,
editSession: editSession,

View File

@@ -40,6 +40,12 @@ class NetworkStatusIndicator extends StatelessWidget {
color = Colors.green;
break;
case NetworkStatusType.nodeLimitReached:
message = 'Showing limit - increase in settings';
icon = Icons.visibility_off;
color = Colors.amber;
break;
case NetworkStatusType.issues:
switch (networkStatus.currentIssueType) {
case NetworkIssueType.osmTiles:

View File

@@ -0,0 +1,150 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../services/changelog_service.dart';
import '../services/localization_service.dart';
class WelcomeDialog extends StatefulWidget {
const WelcomeDialog({super.key});
@override
State<WelcomeDialog> createState() => _WelcomeDialogState();
}
class _WelcomeDialogState extends State<WelcomeDialog> {
bool _dontShowAgain = false;
Future<void> _launchUrl(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
void _onClose() async {
if (_dontShowAgain) {
await ChangelogService().markWelcomeSeen();
}
// Always update version tracking when closing welcome dialog
await ChangelogService().updateLastSeenVersion();
if (mounted) {
Navigator.of(context).pop();
}
}
@override
Widget build(BuildContext context) {
final locService = LocalizationService.instance;
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) => AlertDialog(
title: Text(locService.t('welcome.title')),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Scrollable content
Flexible(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('welcome.description'),
style: const TextStyle(fontSize: 14),
),
const SizedBox(height: 12),
Text(
locService.t('welcome.mission'),
style: const TextStyle(fontSize: 14),
),
const SizedBox(height: 12),
Text(
locService.t('welcome.privacy'),
style: const TextStyle(fontSize: 13, fontStyle: FontStyle.italic),
),
const SizedBox(height: 12),
Text(
locService.t('welcome.tileNote'),
style: const TextStyle(fontSize: 13, color: Colors.orange),
),
const SizedBox(height: 16),
Text(
locService.t('welcome.moreInfo'),
style: const TextStyle(fontSize: 13),
),
const SizedBox(height: 16),
// Quick links row
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildLinkButton('Website', 'https://deflock.me'),
_buildLinkButton('GitHub', 'https://github.com/FoggedLens/deflock-app'),
_buildLinkButton('Discord', 'https://discord.gg/aV7v4R3sKT'),
],
),
],
),
),
),
const SizedBox(height: 16),
// Always visible checkbox at the bottom
Row(
children: [
Checkbox(
value: _dontShowAgain,
onChanged: (value) {
setState(() {
_dontShowAgain = value ?? false;
});
},
),
Expanded(
child: Text(
locService.t('welcome.dontShowAgain'),
style: const TextStyle(fontSize: 13),
),
),
],
),
],
),
actions: [
TextButton(
onPressed: _onClose,
child: Text(locService.t('welcome.getStarted')),
),
],
),
);
}
Widget _buildLinkButton(String text, String url) {
return Flexible(
child: GestureDetector(
onTap: () => _launchUrl(url),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: Theme.of(context).colorScheme.primary.withOpacity(0.3),
),
),
child: Text(
text,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.primary,
),
textAlign: TextAlign.center,
),
),
),
);
}
}

View File

@@ -1,7 +1,7 @@
name: deflockapp
description: Map public surveillance infrastructure with OpenStreetMap
publish_to: "none"
version: 1.2.3+4 # The thing after the + is the version code, incremented with each release
version: 1.2.7+6 # The thing after the + is the version code, incremented with each release
environment:
sdk: ">=3.5.0 <4.0.0" # oauth2_client 4.x needs Dart 3.5+
@@ -45,6 +45,7 @@ flutter:
- assets/android_app_icon.png
- assets/transparent_1x1.png
- assets/deflock-logo.svg
- assets/changelog.json
- lib/localizations/
flutter_launcher_icons:

View File

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