Compare commits

...

20 Commits

Author SHA1 Message Date
stopflock
0957670a15 roadmap 2026-01-28 20:36:46 -06:00
stopflock
3fc3a72cde Fixes for 360-deg FOVs 2026-01-28 20:21:25 -06:00
stopflock
1d65d5ecca v2.4.1, adds profile import via deeplink, moves profile save button, fixes FOV clearing, disable direction slider while submitting with 360-fov profile 2026-01-28 18:13:49 -06:00
stopflock
1873d6e768 profile import from deeplinks 2026-01-28 15:20:25 -06:00
stopflock
4638a18887 roadmap 2026-01-28 15:20:25 -06:00
stopflock
6bfdfadd97 Merge pull request #29 from pbaehr/main
Update running instructions in DEVELOPER.md
2026-01-25 16:37:52 -06:00
Peter Baehr
72f3c9ee79 Update running instructions in DEVELOPER.md
Add script execution and client ID definition to run instructions
2026-01-21 16:29:03 -05:00
stopflock
05e2e4e7c6 roadmap 2026-01-14 12:23:28 -06:00
stopflock
2e679c9a7e roadmap 2026-01-13 15:06:55 -06:00
stopflock
3ef053126b fix changelog syntax issue - missing comma 2026-01-13 15:03:07 -06:00
stopflock
ae354c43a4 drop approx location support, restore follow me mode on sheet close 2025-12-24 15:29:32 -06:00
stopflock
34eac41a96 bump ver 2025-12-23 18:18:16 -06:00
stopflock
816dadfbd1 devibe changelog 2025-12-23 17:58:06 -06:00
stopflock
607ecbafaf Concurrent submissions 2025-12-23 17:56:16 -06:00
stopflock
8b44b3abf5 Better loading indicator 2025-12-23 16:17:06 -06:00
stopflock
a675cf185a roadmap 2025-12-23 13:47:05 -06:00
stopflock
26b479bf20 forgot to update roadmap 2025-12-23 12:04:30 -06:00
stopflock
ae795a7607 configurable overpass query timeout; increased to 45s 2025-12-23 12:03:53 -06:00
stopflock
a05e03567e shorten nav timeout to reasonable number 2025-12-23 11:39:25 -06:00
stopflock
da6887f7d3 update roadmap 2025-12-23 11:38:54 -06:00
25 changed files with 742 additions and 134 deletions

View File

@@ -202,15 +202,24 @@ Deletions don't need position dragging or tag editing - they just need confirmat
- Retries: Exponential backoff up to 59 minutes
- Failures: OSM auto-closes after 60 minutes, so we eventually give up
**Queue processing workflow:**
**Queue processing workflow (v2.3.0+ concurrent processing):**
1. User action (add/edit/delete) → `PendingUpload` created with `UploadState.pending`
2. Immediate visual feedback (cache updated with temp markers)
3. Background uploader processes queue when online:
3. Background uploader starts new uploads every 5 seconds (configurable via `kUploadQueueProcessingInterval`):
- **Concurrency limit**: Maximum 5 uploads processing simultaneously (`kMaxConcurrentUploads`)
- **Individual lifecycles**: Each upload processes through all three stages independently
- **Timer role**: Only used to start new pending uploads, not control stage progression
4. Each upload processes through stages without waiting for other uploads:
- **Pending** → Create changeset → **CreatingChangeset****Uploading**
- **Uploading** → Upload node → **ClosingChangeset**
- **ClosingChangeset** → Close changeset → **Complete**
4. Success → cache updated with real data, temp markers removed
5. Failures → appropriate retry logic based on which stage failed
5. Success → cache updated with real data, temp markers removed
6. Failures → appropriate retry logic based on which stage failed
**Performance improvement (v2.3.0):**
- **Before**: Sequential processing with 10-second delays between each stage of each upload
- **After**: Concurrent processing with uploads completing in 10-30 seconds regardless of queue size
- **User benefit**: 3-5x faster upload processing for users with good internet connections
**Why three explicit stages:**
The previous implementation conflated changeset creation + node operation as one step, making error handling unclear. The new approach:
@@ -800,7 +809,8 @@ cd ios && pod install
### Running
```bash
flutter pub get
flutter run
./gen_icons_splashes.sh
flutter run --dart-define=OSM_PROD_CLIENT_ID=[your OAuth2 client ID]
```
### Testing

View File

@@ -53,6 +53,12 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
- **Queue management**: Review, edit, retry, or cancel pending uploads
- **Changeset tracking**: Automatic grouping and commenting for organized contributions
### Profile Import & Sharing
- **Deep link support**: Import custom profiles via `deflockapp://profiles/add?p=<base64>` URLs
- **Website integration**: Generate profile import links from [deflock.me](https://deflock.me)
- **Pre-filled editor**: Imported profiles open in the profile editor for review and modification
- **Seamless workflow**: Edit imported profiles like any custom profile before saving
### Offline Operations
- **Smart area downloads**: Automatically calculate tile counts and storage requirements
- **Device caching**: Offline areas include surveillance device data for complete functionality without network
@@ -98,19 +104,19 @@ cp lib/keys.dart.example lib/keys.dart
## Roadmap
### Needed Bugfixes
- Add cancel button to submission guide
- When not logged in, submit button should take users to settings>account to log in.
- Ensure GPS/follow-me works after recent revamp (loses lock? have to move map for button state to update?)
- Add new tags to top of a profile so they're visible immediately
- Allow arbitrary entry on refine tags page
- Don't show NSI suggestions that aren't sufficiently popular (image=)
- Make submission guide scarier
- "More..." button in profiles dropdown -> identify page
- Node data fetching super slow; retries not working?
- Tile cache trimming? Does fluttermap handle?
- Filter NSI suggestions based on what has already been typed in
- NSI sometimes doesn't populate a dropdown, maybe always on the second tag added during an edit session?
- Clean cache when nodes have been deleted by others
- Are offline areas preferred for fast loading even when online? Check working.
### Current Development
- Add ability to downvote suspected locations which are old enough
- Turn by turn navigation or at least swipe nav sheet up to see a list
- Import/Export map providers, profiles (profiles from deflock identify page?)
- Import/Export map providers
### On Pause
- Offline navigation (pending vector map tiles)
@@ -118,8 +124,10 @@ cp lib/keys.dart.example lib/keys.dart
### Future Features & Wishlist
- Optional reason message when deleting
- Update offline area data while browsing?
- Save named locations to more easily navigate to home or work
### Maybes
- "Universal Links" for better handling of profile import when app not installed?
- Yellow ring for devices missing specific tag details
- Android Auto / CarPlay
- "Cache accumulating" offline area?

View File

@@ -35,6 +35,14 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<!-- Profile import deep links -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="deflockapp" android:host="profiles"/>
</intent-filter>
</activity>
<!-- flutter_web_auth_2 callback activity (V2 embedding) -->

View File

@@ -1,4 +1,43 @@
{
"2.4.3": {
"content": [
"• Fixed 360° FOV rendering - devices with full circle coverage now render as complete rings instead of having a wedge cut out or being a line",
"• Fixed 360° FOV submission - now correctly submits '0-360' to OpenStreetMap instead of incorrect '180-180' values, disables direction slider"
]
},
"2.4.1": {
"content": [
"• Save button moved to top-right corner of profile editor screens",
"• Fixed issue where FOV values could not be removed from profiles",
"• Direction slider is now disabled for profiles with 360° FOV"
]
},
"2.4.0": {
"content": [
"• Profile import from website links",
"• Visit deflock.me for profile links to auto-populate custom profiles"
]
},
"2.3.1": {
"content": [
"• Follow-me mode now automatically restores when add/edit/tag sheets are closed",
"• Follow-me button is greyed out while node sheets are open (add/edit/tag) since following doesn't make sense during node operations",
"• Drop support for approximate location since I can't get it to work reliably; apologies"
]
},
"2.3.0": {
"content": [
"• Concurrent upload queue processing",
"• Each submission is now much faster"
]
},
"2.2.1": {
"content": [
"• Fixed network status indicator timing out prematurely",
"• Improved GPS follow-me reliability - fixed sync issues that could cause tracking to stop working",
"• Network status now accurately shows 'taking a while' when requests split or backoff, and only shows 'timed out' for actual network failures"
]
},
"2.2.0": {
"content": [
"• Fixed follow-me sync issues where tracking would sometimes stop working after mode changes",
@@ -259,4 +298,4 @@
"• New suspected locations feature"
]
}
}
}

View File

@@ -18,6 +18,7 @@ import 'services/node_cache.dart';
import 'services/tile_preview_service.dart';
import 'services/changelog_service.dart';
import 'services/operator_profile_service.dart';
import 'services/deep_link_service.dart';
import 'widgets/node_provider_with_cache.dart';
import 'services/profile_service.dart';
import 'widgets/proximity_warning_dialog.dart';
@@ -244,6 +245,11 @@ class AppState extends ChangeNotifier {
_isInitialized = true;
// Check for initial deep link after a small delay to let navigation settle
Future.delayed(const Duration(milliseconds: 500), () {
DeepLinkService().checkInitialLink();
});
// Start periodic message checking
_startMessageCheckTimer();

View File

@@ -55,13 +55,18 @@ const String kClientName = 'DeFlock';
// Upload and changeset configuration
const Duration kUploadHttpTimeout = Duration(seconds: 30); // HTTP request timeout for uploads
const Duration kUploadQueueProcessingInterval = Duration(seconds: 5); // How often to check for new uploads to start
const int kMaxConcurrentUploads = 5; // Maximum number of uploads processing simultaneously
const Duration kChangesetCloseInitialRetryDelay = Duration(seconds: 10);
const Duration kChangesetCloseMaxRetryDelay = Duration(minutes: 5); // Cap at 5 minutes
const Duration kChangesetAutoCloseTimeout = Duration(minutes: 59); // Give up and trust OSM auto-close
const double kChangesetCloseBackoffMultiplier = 2.0;
// Navigation routing configuration
const Duration kNavigationRoutingTimeout = Duration(seconds: 120); // HTTP timeout for routing requests
const Duration kNavigationRoutingTimeout = Duration(seconds: 90); // HTTP timeout for routing requests
// Overpass API configuration
const Duration kOverpassQueryTimeout = Duration(seconds: 45); // Timeout for Overpass API queries (was 25s hardcoded)
// Suspected locations CSV URL
const String kSuspectedLocationsCsvUrl = 'https://alprwatch.org/suspected-locations/deflock-latest.csv';

View File

@@ -15,6 +15,7 @@ import 'screens/osm_account_screen.dart';
import 'screens/upload_queue_screen.dart';
import 'services/localization_service.dart';
import 'services/version_service.dart';
import 'services/deep_link_service.dart';
@@ -27,6 +28,10 @@ Future<void> main() async {
// Initialize localization service
await LocalizationService.instance.init();
// Initialize deep link service
await DeepLinkService().init();
DeepLinkService().setNavigatorKey(_navigatorKey);
runApp(
ChangeNotifierProvider(
create: (_) => AppState(),
@@ -68,6 +73,7 @@ class DeFlockApp extends StatelessWidget {
),
useMaterial3: true,
),
navigatorKey: _navigatorKey,
routes: {
'/': (context) => const HomeScreen(),
'/settings': (context) => const SettingsScreen(),
@@ -82,7 +88,11 @@ class DeFlockApp extends StatelessWidget {
'/settings/release-notes': (context) => const ReleaseNotesScreen(),
},
initialRoute: '/',
);
}
}
// Global navigator key for deep link navigation
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>();

View File

@@ -1,5 +1,8 @@
import 'package:uuid/uuid.dart';
/// Sentinel value for copyWith methods to distinguish between null and not provided
const Object _notProvided = Object();
/// A bundle of preset OSM tags that describe a particular surveillance node model/type.
class NodeProfile {
final String id;
@@ -217,7 +220,7 @@ class NodeProfile {
bool? requiresDirection,
bool? submittable,
bool? editable,
double? fov,
Object? fov = _notProvided,
}) =>
NodeProfile(
id: id ?? this.id,
@@ -227,7 +230,7 @@ class NodeProfile {
requiresDirection: requiresDirection ?? this.requiresDirection,
submittable: submittable ?? this.submittable,
editable: editable ?? this.editable,
fov: fov ?? this.fov,
fov: fov == _notProvided ? this.fov : fov as double?,
);
Map<String, dynamic> toJson() => {

View File

@@ -107,6 +107,11 @@ class OsmNode {
start = ((start % 360) + 360) % 360;
end = ((end % 360) + 360) % 360;
// Special case: if start equals end, this represents 360° FOV
if (start == end) {
return DirectionFov(start, 360.0);
}
double width, center;
if (start > end) {

View File

@@ -9,6 +9,7 @@ import '../../widgets/add_node_sheet.dart';
import '../../widgets/edit_node_sheet.dart';
import '../../widgets/navigation_sheet.dart';
import '../../widgets/measured_sheet.dart';
import '../../state/settings_state.dart' show FollowMeMode;
/// Coordinates all bottom sheet operations including opening, closing, height tracking,
/// and sheet-related validation logic.
@@ -25,6 +26,9 @@ class SheetCoordinator {
// Flag to prevent map bounce when transitioning from tag sheet to edit sheet
bool _transitioningToEdit = false;
// Follow-me state restoration
FollowMeMode? _followMeModeBeforeSheet;
// Getters for accessing heights
double get addSheetHeight => _addSheetHeight;
@@ -88,7 +92,8 @@ class SheetCoordinator {
return;
}
// Disable follow-me when adding a node so the map doesn't jump around
// Save current follow-me mode and disable it while sheet is open
_followMeModeBeforeSheet = appState.followMeMode;
appState.setFollowMeMode(FollowMeMode.off);
appState.startAddSession();
@@ -120,6 +125,9 @@ class SheetCoordinator {
debugPrint('[SheetCoordinator] AddNodeSheet dismissed - canceling session');
appState.cancelSession();
}
// Restore follow-me mode that was active before sheet opened
_restoreFollowMeMode(appState);
});
}
@@ -132,7 +140,8 @@ class SheetCoordinator {
}) {
final appState = context.read<AppState>();
// Disable follow-me when editing a node so the map doesn't jump around
// Save current follow-me mode and disable it while sheet is open
_followMeModeBeforeSheet = appState.followMeMode;
appState.setFollowMeMode(FollowMeMode.off);
final session = appState.editSession!; // should be non-null when this is called
@@ -185,6 +194,9 @@ class SheetCoordinator {
debugPrint('[SheetCoordinator] EditNodeSheet dismissed - canceling edit session');
appState.cancelEditSession();
}
// Restore follow-me mode that was active before sheet opened
_restoreFollowMeMode(appState);
});
}
@@ -250,4 +262,16 @@ class SheetCoordinator {
_tagSheetHeight = 0.0;
onStateChanged();
}
/// Restore the follow-me mode that was active before opening a node sheet
void _restoreFollowMeMode(AppState appState) {
if (_followMeModeBeforeSheet != null) {
debugPrint('[SheetCoordinator] Restoring follow-me mode: ${_followMeModeBeforeSheet}');
appState.setFollowMeMode(_followMeModeBeforeSheet!);
_followMeModeBeforeSheet = null; // Clear stored state
}
}
/// Check if any node editing/viewing sheet is currently open
bool get hasActiveNodeSheet => _addSheetHeight > 0 || _editSheetHeight > 0 || _tagSheetHeight > 0;
}

View File

@@ -433,7 +433,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
IconButton(
tooltip: _getFollowMeTooltip(appState.followMeMode),
icon: Icon(_getFollowMeIcon(appState.followMeMode)),
onPressed: _mapViewKey.currentState?.hasLocation == true
onPressed: (_mapViewKey.currentState?.hasLocation == true && !_sheetCoordinator.hasActiveNodeSheet)
? () {
final oldMode = appState.followMeMode;
final newMode = _getNextFollowMeMode(oldMode);
@@ -444,7 +444,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
_mapViewKey.currentState?.retryLocationInit();
}
}
: null, // Grey out when no location
: null, // Grey out when no location or when node sheet is open
),
AnimatedBuilder(
animation: LocalizationService.instance,

View File

@@ -55,6 +55,12 @@ class _OperatorProfileEditorState extends State<OperatorProfileEditor> {
return Scaffold(
appBar: AppBar(
title: Text(widget.profile.name.isEmpty ? locService.t('operatorProfileEditor.newOperatorProfile') : locService.t('operatorProfileEditor.editOperatorProfile')),
actions: [
TextButton(
onPressed: _save,
child: Text(locService.t('profileEditor.saveProfile')),
),
],
),
body: ListView(
padding: EdgeInsets.fromLTRB(
@@ -87,10 +93,6 @@ class _OperatorProfileEditorState extends State<OperatorProfileEditor> {
const SizedBox(height: 8),
..._buildTagRows(),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _save,
child: Text(locService.t('profileEditor.saveProfile')),
),
],
),
);

View File

@@ -69,6 +69,12 @@ class _ProfileEditorState extends State<ProfileEditor> {
title: Text(!widget.profile.editable
? locService.t('profileEditor.viewProfile')
: (widget.profile.name.isEmpty ? locService.t('profileEditor.newProfile') : locService.t('profileEditor.editProfile'))),
actions: widget.profile.editable ? [
TextButton(
onPressed: _save,
child: Text(locService.t('profileEditor.saveProfile')),
),
] : null,
),
body: ListView(
padding: EdgeInsets.fromLTRB(
@@ -135,11 +141,6 @@ class _ProfileEditorState extends State<ProfileEditor> {
const SizedBox(height: 8),
..._buildTagRows(),
const SizedBox(height: 24),
if (widget.profile.editable)
ElevatedButton(
onPressed: _save,
child: Text(locService.t('profileEditor.saveProfile')),
),
],
),
);

View File

@@ -0,0 +1,157 @@
import 'dart:async';
import 'package:app_links/app_links.dart';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import '../models/node_profile.dart';
import 'profile_import_service.dart';
import '../screens/profile_editor.dart';
class DeepLinkService {
static final DeepLinkService _instance = DeepLinkService._internal();
factory DeepLinkService() => _instance;
DeepLinkService._internal();
late AppLinks _appLinks;
StreamSubscription<Uri>? _linkSubscription;
/// Initialize deep link handling (sets up stream listener only)
Future<void> init() async {
_appLinks = AppLinks();
// Set up stream listener for links when app is already running
_linkSubscription = _appLinks.uriLinkStream.listen(
_processLink,
onError: (err) {
debugPrint('[DeepLinkService] Link stream error: $err');
},
);
}
/// Process a deep link
void _processLink(Uri uri) {
debugPrint('[DeepLinkService] Processing deep link: $uri');
// Only handle deflockapp scheme
if (uri.scheme != 'deflockapp') {
debugPrint('[DeepLinkService] Ignoring non-deflockapp scheme: ${uri.scheme}');
return;
}
// Route based on path
switch (uri.host) {
case 'profiles':
_handleProfilesLink(uri);
break;
case 'auth':
// OAuth links are handled by flutter_web_auth_2
debugPrint('[DeepLinkService] OAuth link handled by flutter_web_auth_2');
break;
default:
debugPrint('[DeepLinkService] Unknown deep link host: ${uri.host}');
}
}
/// Check for initial link after app is fully ready
Future<void> checkInitialLink() async {
debugPrint('[DeepLinkService] Checking for initial link...');
try {
final initialLink = await _appLinks.getInitialLink();
if (initialLink != null) {
debugPrint('[DeepLinkService] Found initial link: $initialLink');
_processLink(initialLink);
} else {
debugPrint('[DeepLinkService] No initial link found');
}
} catch (e) {
debugPrint('[DeepLinkService] Failed to get initial link: $e');
}
}
/// Handle profile-related deep links
void _handleProfilesLink(Uri uri) {
final segments = uri.pathSegments;
if (segments.isEmpty) {
debugPrint('[DeepLinkService] No path segments in profiles link');
return;
}
switch (segments[0]) {
case 'add':
_handleAddProfileLink(uri);
break;
default:
debugPrint('[DeepLinkService] Unknown profiles path: ${segments[0]}');
}
}
/// Handle profile add deep link: deflockapp://profiles/add?p=<base64>
void _handleAddProfileLink(Uri uri) {
final base64Data = uri.queryParameters['p'];
if (base64Data == null || base64Data.isEmpty) {
_showError('Invalid profile link: missing profile data');
return;
}
// Parse profile from base64
final profile = ProfileImportService.parseProfileFromBase64(base64Data);
if (profile == null) {
_showError('Invalid profile data');
return;
}
// Navigate to profile editor with the imported profile
_navigateToProfileEditor(profile);
}
/// Navigate to profile editor with pre-filled profile data
void _navigateToProfileEditor(NodeProfile profile) {
final context = _navigatorKey?.currentContext;
if (context == null) {
debugPrint('[DeepLinkService] No navigator context available');
return;
}
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ProfileEditor(profile: profile),
),
);
}
/// Show error message to user
void _showError(String message) {
final context = _navigatorKey?.currentContext;
if (context == null) {
debugPrint('[DeepLinkService] Error (no context): $message');
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.red,
),
);
}
/// Global navigator key for navigation
GlobalKey<NavigatorState>? _navigatorKey;
/// Set the global navigator key
void setNavigatorKey(GlobalKey<NavigatorState> navigatorKey) {
_navigatorKey = navigatorKey;
}
/// Clean up resources
void dispose() {
_linkSubscription?.cancel();
}
}

View File

@@ -78,6 +78,11 @@ Future<List<OsmNode>> _fetchOverpassNodesWithSplitting({
// Rate limits should NOT be split - just fail with extended backoff
debugPrint('[fetchOverpassNodes] Rate limited - using extended backoff, not splitting');
// Report slow progress when backing off
if (reportStatus) {
NetworkStatus.instance.reportSlowProgress();
}
// Wait longer for rate limits before giving up entirely
await Future.delayed(const Duration(seconds: 30));
return []; // Return empty rather than rethrowing - let caller handle error reporting
@@ -88,6 +93,11 @@ Future<List<OsmNode>> _fetchOverpassNodesWithSplitting({
return []; // Return empty - let caller handle error reporting
}
// Report slow progress when we start splitting (only at the top level)
if (reportStatus) {
NetworkStatus.instance.reportSlowProgress();
}
// Split the bounds into 4 quadrants and try each separately
debugPrint('[fetchOverpassNodes] Splitting area into quadrants (depth: $splitDepth)');
final quadrants = _splitBounds(bounds);
@@ -218,7 +228,7 @@ String _buildOverpassQuery(LatLngBounds bounds, List<NodeProfile> profiles, int
}).join('\n ');
return '''
[out:json][timeout:25];
[out:json][timeout:${kOverpassQueryTimeout.inSeconds}];
(
$nodeClauses
);

View File

@@ -19,7 +19,6 @@ class NetworkStatus extends ChangeNotifier {
bool _hasSuccess = false;
int _recentOfflineMisses = 0;
Timer? _overpassRecoveryTimer;
Timer? _waitingTimer;
Timer? _noDataResetTimer;
Timer? _successResetTimer;
// Getters
@@ -72,7 +71,25 @@ class NetworkStatus extends ChangeNotifier {
}
}
/// Set waiting status (show when loading tiles/cameras)
/// Report that requests are taking longer than usual (splitting, backoffs, etc.)
void reportSlowProgress() {
if (!_overpassHasIssues) {
_overpassHasIssues = true;
_isWaitingForData = false; // Transition from waiting to slow progress
notifyListeners();
debugPrint('[NetworkStatus] Surveillance data requests taking longer than usual');
}
// Reset recovery timer - we'll clear this when the operation actually completes
_overpassRecoveryTimer?.cancel();
_overpassRecoveryTimer = Timer(const Duration(minutes: 2), () {
_overpassHasIssues = false;
notifyListeners();
debugPrint('[NetworkStatus] Slow progress status cleared');
});
}
/// Set waiting status (show when loading surveillance data)
void setWaiting() {
// Clear any previous timeout/no-data state when starting new wait
_isTimedOut = false;
@@ -83,17 +100,7 @@ class NetworkStatus extends ChangeNotifier {
if (!_isWaitingForData) {
_isWaitingForData = true;
notifyListeners();
// Don't log routine waiting - only log if we stay waiting too long
}
// Set timeout for genuine network issues (not 404s)
_waitingTimer?.cancel();
_waitingTimer = Timer(const Duration(seconds: 8), () {
_isWaitingForData = false;
_isTimedOut = true;
debugPrint('[NetworkStatus] Request timed out - likely network issues');
notifyListeners();
});
}
/// Show success status briefly when data loads
@@ -103,7 +110,6 @@ class NetworkStatus extends ChangeNotifier {
_hasNoData = false;
_hasSuccess = true;
_recentOfflineMisses = 0;
_waitingTimer?.cancel();
_noDataResetTimer?.cancel();
notifyListeners();
@@ -123,7 +129,6 @@ class NetworkStatus extends ChangeNotifier {
_isTimedOut = false;
_hasSuccess = false;
_hasNoData = true;
_waitingTimer?.cancel();
_successResetTimer?.cancel();
notifyListeners();
@@ -145,7 +150,6 @@ class NetworkStatus extends ChangeNotifier {
_hasNoData = false;
_hasSuccess = false;
_recentOfflineMisses = 0;
_waitingTimer?.cancel();
_noDataResetTimer?.cancel();
_successResetTimer?.cancel();
notifyListeners();
@@ -158,7 +162,6 @@ class NetworkStatus extends ChangeNotifier {
_isTimedOut = true;
_hasNoData = false;
_hasSuccess = false;
_waitingTimer?.cancel();
_noDataResetTimer?.cancel();
_successResetTimer?.cancel();
notifyListeners();
@@ -179,7 +182,6 @@ class NetworkStatus extends ChangeNotifier {
_isTimedOut = false;
_hasNoData = false;
_hasSuccess = false;
_waitingTimer?.cancel();
_noDataResetTimer?.cancel();
_successResetTimer?.cancel();
@@ -200,7 +202,6 @@ class NetworkStatus extends ChangeNotifier {
_isWaitingForData = false;
_isTimedOut = false;
_hasNoData = true;
_waitingTimer?.cancel();
notifyListeners();
debugPrint('[NetworkStatus] No offline data available for this area');
}
@@ -217,7 +218,6 @@ class NetworkStatus extends ChangeNotifier {
@override
void dispose() {
_overpassRecoveryTimer?.cancel();
_waitingTimer?.cancel();
_noDataResetTimer?.cancel();
_successResetTimer?.cancel();
super.dispose();

View File

@@ -0,0 +1,120 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:uuid/uuid.dart';
import '../models/node_profile.dart';
class ProfileImportService {
// Maximum size for base64 encoded profile data (approx 50KB decoded)
static const int maxBase64Length = 70000;
/// Parse and validate a profile from a base64-encoded JSON string
/// Returns null if parsing/validation fails
static NodeProfile? parseProfileFromBase64(String base64Data) {
try {
// Basic size validation before expensive decode
if (base64Data.length > maxBase64Length) {
debugPrint('[ProfileImportService] Base64 data too large: ${base64Data.length} characters');
return null;
}
// Decode base64
final jsonBytes = base64Decode(base64Data);
final jsonString = utf8.decode(jsonBytes);
// Parse JSON
final jsonData = jsonDecode(jsonString) as Map<String, dynamic>;
// Validate and sanitize the profile data
final sanitizedProfile = _validateAndSanitizeProfile(jsonData);
return sanitizedProfile;
} catch (e) {
debugPrint('[ProfileImportService] Failed to parse profile from base64: $e');
return null;
}
}
/// Validate profile structure and sanitize all string values
static NodeProfile? _validateAndSanitizeProfile(Map<String, dynamic> data) {
try {
// Extract and sanitize required fields
final name = _sanitizeString(data['name']);
if (name == null || name.isEmpty) {
debugPrint('[ProfileImportService] Profile name is required');
return null;
}
// Extract and sanitize tags
final tagsData = data['tags'];
if (tagsData is! Map<String, dynamic>) {
debugPrint('[ProfileImportService] Profile tags must be a map');
return null;
}
final sanitizedTags = <String, String>{};
for (final entry in tagsData.entries) {
final key = _sanitizeString(entry.key);
final value = _sanitizeString(entry.value);
if (key != null && key.isNotEmpty) {
// Allow empty values for refinement purposes
sanitizedTags[key] = value ?? '';
}
}
if (sanitizedTags.isEmpty) {
debugPrint('[ProfileImportService] Profile must have at least one valid tag');
return null;
}
// Extract optional fields with defaults
final requiresDirection = data['requiresDirection'] ?? true;
final submittable = data['submittable'] ?? true;
// Parse FOV if provided
double? fov;
if (data['fov'] != null) {
if (data['fov'] is num) {
final fovValue = (data['fov'] as num).toDouble();
if (fovValue > 0 && fovValue <= 360) {
fov = fovValue;
}
}
}
return NodeProfile(
id: const Uuid().v4(), // Always generate new ID for imported profiles
name: name,
tags: sanitizedTags,
builtin: false, // Imported profiles are always custom
requiresDirection: requiresDirection is bool ? requiresDirection : true,
submittable: submittable is bool ? submittable : true,
editable: true, // Imported profiles are always editable
fov: fov,
);
} catch (e) {
debugPrint('[ProfileImportService] Failed to validate profile: $e');
return null;
}
}
/// Sanitize a string value by trimming and removing potentially harmful characters
static String? _sanitizeString(dynamic value) {
if (value == null) return null;
final str = value.toString().trim();
// Remove control characters and limit length
final sanitized = str.replaceAll(RegExp(r'[\x00-\x1F\x7F]'), '');
// Limit length to prevent abuse
const maxLength = 500;
if (sanitized.length > maxLength) {
return sanitized.substring(0, maxLength);
}
return sanitized;
}
}

View File

@@ -10,16 +10,19 @@ import '../models/node_profile.dart';
import '../services/node_cache.dart';
import '../services/uploader.dart';
import '../widgets/node_provider_with_cache.dart';
import '../dev_config.dart';
import 'settings_state.dart';
import 'session_state.dart';
class UploadQueueState extends ChangeNotifier {
final List<PendingUpload> _queue = [];
Timer? _uploadTimer;
int _activeUploadCount = 0;
// Getters
int get pendingCount => _queue.length;
List<PendingUpload> get pendingUploads => List.unmodifiable(_queue);
int get activeUploadCount => _activeUploadCount;
// Initialize by loading queue from storage and repopulate cache with pending nodes
Future<void> init() async {
@@ -321,19 +324,22 @@ class UploadQueueState extends ChangeNotifier {
// No uploads if queue is empty, offline mode is enabled, or queue processing is paused
if (_queue.isEmpty || offlineMode || pauseQueueProcessing) return;
_uploadTimer = Timer.periodic(const Duration(seconds: 10), (t) async {
_uploadTimer = Timer.periodic(kUploadQueueProcessingInterval, (t) async {
if (_queue.isEmpty || offlineMode || pauseQueueProcessing) {
_uploadTimer?.cancel();
return;
}
// Find next item to process based on state
final pendingItems = _queue.where((pu) => pu.uploadState == UploadState.pending).toList();
final creatingChangesetItems = _queue.where((pu) => pu.uploadState == UploadState.creatingChangeset).toList();
// Check if we can start more uploads (concurrency limit check)
if (_activeUploadCount >= kMaxConcurrentUploads) {
debugPrint('[UploadQueue] At concurrency limit ($_activeUploadCount/$kMaxConcurrentUploads), waiting for uploads to complete');
return;
}
// Process any expired items
final uploadingItems = _queue.where((pu) => pu.uploadState == UploadState.uploading).toList();
final closingItems = _queue.where((pu) => pu.uploadState == UploadState.closingChangeset).toList();
// Process any expired items
for (final uploadingItem in uploadingItems) {
if (uploadingItem.hasChangesetExpired) {
debugPrint('[UploadQueue] Changeset expired during node submission - marking as failed');
@@ -347,73 +353,109 @@ class UploadQueueState extends ChangeNotifier {
if (closingItem.hasChangesetExpired) {
debugPrint('[UploadQueue] Changeset expired during close - trusting OSM auto-close (node was submitted successfully)');
_markAsCompleting(closingItem, submittedNodeId: closingItem.submittedNodeId!);
// Continue processing loop - don't return here
}
}
// Find next pending item to start
final pendingItems = _queue.where((pu) => pu.uploadState == UploadState.pending).toList();
// Find next item to process (process in stage order)
PendingUpload? item;
if (pendingItems.isNotEmpty) {
item = pendingItems.first;
} else if (creatingChangesetItems.isNotEmpty) {
// Already in progress, skip
return;
} else if (uploadingItems.isNotEmpty) {
// Check if any uploading items are ready for retry
final readyToRetry = uploadingItems.where((ui) =>
!ui.hasChangesetExpired && ui.isReadyForNodeSubmissionRetry
).toList();
if (readyToRetry.isNotEmpty) {
item = readyToRetry.first;
}
} else {
// No active items, check if any changeset close items are ready for retry
final readyToRetry = closingItems.where((ci) =>
!ci.hasChangesetExpired && ci.isReadyForChangesetCloseRetry
).toList();
if (readyToRetry.isNotEmpty) {
item = readyToRetry.first;
}
}
if (item == null) {
// No items ready for processing - check if queue is effectively empty
if (pendingItems.isEmpty) {
// Check if queue is effectively empty
final hasActiveItems = _queue.any((pu) =>
pu.uploadState == UploadState.pending ||
pu.uploadState == UploadState.creatingChangeset ||
(pu.uploadState == UploadState.uploading && !pu.hasChangesetExpired) ||
(pu.uploadState == UploadState.closingChangeset && !pu.hasChangesetExpired)
pu.uploadState == UploadState.uploading ||
pu.uploadState == UploadState.closingChangeset
);
if (!hasActiveItems) {
debugPrint('[UploadQueue] No active items remaining, stopping uploader');
_uploadTimer?.cancel();
}
return; // Nothing to process right now
return;
}
// Retrieve access after every tick (accounts for re-login)
// Retrieve access token
final access = await getAccessToken();
if (access == null) return; // not logged in
debugPrint('[UploadQueue] Processing item in state: ${item.uploadState} with uploadMode: ${item.uploadMode}');
// Start processing the next pending upload
final item = pendingItems.first;
debugPrint('[UploadQueue] Starting new upload processing for item at ${item.coord} ($_activeUploadCount/$kMaxConcurrentUploads active)');
if (item.uploadState == UploadState.pending) {
await _processCreateChangeset(item, access);
} else if (item.uploadState == UploadState.creatingChangeset) {
// Already in progress, skip (shouldn't happen due to filtering above)
debugPrint('[UploadQueue] Changeset creation already in progress, skipping');
return;
} else if (item.uploadState == UploadState.uploading) {
await _processNodeOperation(item, access);
} else if (item.uploadState == UploadState.closingChangeset) {
await _processChangesetClose(item, access);
}
_activeUploadCount++;
_processIndividualUpload(item, access);
});
}
// Process an individual upload through all three stages
Future<void> _processIndividualUpload(PendingUpload item, String accessToken) async {
try {
debugPrint('[UploadQueue] Starting individual upload processing for ${item.operation.name} at ${item.coord}');
// Stage 1: Create changeset
await _processCreateChangeset(item, accessToken);
if (item.uploadState == UploadState.error) return;
// Stage 2: Node operation with retry logic
bool nodeOperationCompleted = false;
while (!nodeOperationCompleted && !item.hasChangesetExpired && item.uploadState != UploadState.error) {
await _processNodeOperation(item, accessToken);
if (item.uploadState == UploadState.closingChangeset) {
// Node operation succeeded
nodeOperationCompleted = true;
} else if (item.uploadState == UploadState.uploading && !item.isReadyForNodeSubmissionRetry) {
// Need to wait before retry
final delay = item.nextNodeSubmissionRetryDelay;
debugPrint('[UploadQueue] Waiting ${delay.inSeconds}s before node submission retry');
await Future.delayed(delay);
} else if (item.uploadState == UploadState.error) {
// Failed permanently
return;
}
}
if (!nodeOperationCompleted) return; // Failed or expired
// Stage 3: Close changeset with retry logic
bool changesetClosed = false;
while (!changesetClosed && !item.hasChangesetExpired && item.uploadState != UploadState.error) {
await _processChangesetClose(item, accessToken);
if (item.uploadState == UploadState.complete) {
// Changeset close succeeded
changesetClosed = true;
} else if (item.uploadState == UploadState.closingChangeset && !item.isReadyForChangesetCloseRetry) {
// Need to wait before retry
final delay = item.nextChangesetCloseRetryDelay;
debugPrint('[UploadQueue] Waiting ${delay.inSeconds}s before changeset close retry');
await Future.delayed(delay);
} else if (item.uploadState == UploadState.error) {
// Failed permanently
return;
}
}
if (!changesetClosed && item.hasChangesetExpired) {
// Trust OSM auto-close if we ran out of time
debugPrint('[UploadQueue] Upload completed but changeset close timed out - trusting OSM auto-close');
if (item.submittedNodeId != null) {
_markAsCompleting(item, submittedNodeId: item.submittedNodeId!);
}
}
} catch (e) {
debugPrint('[UploadQueue] Unexpected error in individual upload processing: $e');
item.setError('Unexpected error: $e');
_saveQueue();
notifyListeners();
} finally {
// Always decrement the active upload count
_activeUploadCount--;
debugPrint('[UploadQueue] Individual upload processing finished ($_activeUploadCount/$kMaxConcurrentUploads active)');
}
}
// Process changeset creation (step 1 of 3)
Future<void> _processCreateChangeset(PendingUpload item, String access) async {
item.markAsCreatingChangeset();
@@ -701,6 +743,11 @@ class UploadQueueState extends ChangeNotifier {
// Convert a center direction and FOV to range notation (e.g., 180° center with 90° FOV -> "135-225")
String _formatDirectionWithFov(double center, double fov) {
// Handle 360-degree FOV as special case
if (fov >= 360) {
return '0-360';
}
final halfFov = fov / 2;
final start = (center - halfFov + 360) % 360;
final end = (center + halfFov) % 360;

View File

@@ -157,6 +157,15 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
Widget _buildDirectionControls(BuildContext context, AppState appState, AddNodeSession session, LocalizationService locService) {
final requiresDirection = session.profile != null && session.profile!.requiresDirection;
final is360Fov = session.profile?.fov == 360;
final enableDirectionControls = requiresDirection && !is360Fov;
// Force direction to 0 when FOV is 360 (omnidirectional)
if (is360Fov && session.directionDegrees != 0) {
WidgetsBinding.instance.addPostFrameCallback((_) {
appState.updateSession(directionDeg: 0);
});
}
// Format direction display text with bold for current direction
String directionsText = '';
@@ -206,7 +215,7 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
divisions: 359,
value: session.directionDegrees,
label: session.directionDegrees.round().toString(),
onChanged: requiresDirection ? (v) => appState.updateSession(directionDeg: v) : null,
onChanged: enableDirectionControls ? (v) => appState.updateSession(directionDeg: v) : null,
),
),
// Direction control buttons - always show but grey out when direction not required
@@ -216,9 +225,9 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
icon: Icon(
Icons.remove,
size: 20,
color: requiresDirection ? null : Theme.of(context).disabledColor,
color: enableDirectionControls ? null : Theme.of(context).disabledColor,
),
onPressed: requiresDirection && session.directions.length > 1
onPressed: enableDirectionControls && session.directions.length > 1
? () => appState.removeDirection()
: null,
tooltip: requiresDirection ? 'Remove current direction' : 'Direction not required for this profile',
@@ -230,9 +239,9 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
icon: Icon(
Icons.add,
size: 20,
color: requiresDirection && session.directions.length < 8 ? null : Theme.of(context).disabledColor,
color: enableDirectionControls && session.directions.length < 8 ? null : Theme.of(context).disabledColor,
),
onPressed: requiresDirection && session.directions.length < 8 ? () => appState.addDirection() : null,
onPressed: enableDirectionControls && session.directions.length < 8 ? () => appState.addDirection() : null,
tooltip: requiresDirection
? (session.directions.length >= 8 ? 'Maximum 8 directions allowed' : 'Add new direction')
: 'Direction not required for this profile',
@@ -244,9 +253,9 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
icon: Icon(
Icons.repeat,
size: 20,
color: requiresDirection ? null : Theme.of(context).disabledColor,
color: enableDirectionControls ? null : Theme.of(context).disabledColor,
),
onPressed: requiresDirection && session.directions.length > 1
onPressed: enableDirectionControls && session.directions.length > 1
? () => appState.cycleDirection()
: null,
tooltip: requiresDirection ? 'Cycle through directions' : 'Direction not required for this profile',

View File

@@ -137,6 +137,15 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
Widget _buildDirectionControls(BuildContext context, AppState appState, EditNodeSession session, LocalizationService locService) {
final requiresDirection = session.profile != null && session.profile!.requiresDirection;
final is360Fov = session.profile?.fov == 360;
final enableDirectionControls = requiresDirection && !is360Fov;
// Force direction to 0 when FOV is 360 (omnidirectional)
if (is360Fov && session.directionDegrees != 0) {
WidgetsBinding.instance.addPostFrameCallback((_) {
appState.updateEditSession(directionDeg: 0);
});
}
// Format direction display text with bold for current direction
String directionsText = '';
@@ -186,7 +195,7 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
divisions: 359,
value: session.directionDegrees,
label: session.directionDegrees.round().toString(),
onChanged: requiresDirection ? (v) => appState.updateEditSession(directionDeg: v) : null,
onChanged: enableDirectionControls ? (v) => appState.updateEditSession(directionDeg: v) : null,
),
),
// Direction control buttons - always show but grey out when direction not required
@@ -196,9 +205,9 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
icon: Icon(
Icons.remove,
size: 20,
color: requiresDirection ? null : Theme.of(context).disabledColor,
color: enableDirectionControls ? null : Theme.of(context).disabledColor,
),
onPressed: requiresDirection && session.directions.length > 1
onPressed: enableDirectionControls && session.directions.length > 1
? () => appState.removeDirection()
: null,
tooltip: requiresDirection ? 'Remove current direction' : 'Direction not required for this profile',
@@ -210,9 +219,9 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
icon: Icon(
Icons.add,
size: 20,
color: requiresDirection && session.directions.length < 8 ? null : Theme.of(context).disabledColor,
color: enableDirectionControls && session.directions.length < 8 ? null : Theme.of(context).disabledColor,
),
onPressed: requiresDirection && session.directions.length < 8 ? () => appState.addDirection() : null,
onPressed: enableDirectionControls && session.directions.length < 8 ? () => appState.addDirection() : null,
tooltip: requiresDirection
? (session.directions.length >= 8 ? 'Maximum 8 directions allowed' : 'Add new direction')
: 'Direction not required for this profile',
@@ -224,9 +233,9 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
icon: Icon(
Icons.repeat,
size: 20,
color: requiresDirection ? null : Theme.of(context).disabledColor,
color: enableDirectionControls ? null : Theme.of(context).disabledColor,
),
onPressed: requiresDirection && session.directions.length > 1
onPressed: enableDirectionControls && session.directions.length > 1
? () => appState.cycleDirection()
: null,
tooltip: requiresDirection ? 'Cycle through directions' : 'Direction not required for this profile',

View File

@@ -170,7 +170,10 @@ class DirectionConesBuilder {
bool isActiveDirection = true,
}) {
// Handle full circle case (360-degree FOV)
if (halfAngleDeg >= 180) {
// Use 179.5 threshold to account for floating point precision
print("DEBUG: halfAngleDeg = $halfAngleDeg, bearing = $bearingDeg");
if (halfAngleDeg >= 179.5) {
print("DEBUG: Using full circle for 360° FOV");
return _buildFullCircle(
origin: origin,
zoom: zoom,
@@ -179,6 +182,7 @@ class DirectionConesBuilder {
isActiveDirection: isActiveDirection,
);
}
print("DEBUG: Using normal cone for FOV = ${halfAngleDeg * 2}°");
// Calculate pixel-based radii
final outerRadiusPx = kNodeIconDiameter + (kNodeIconDiameter * kDirectionConeBaseLength);
@@ -232,6 +236,7 @@ class DirectionConesBuilder {
}
/// Build a full circle for 360-degree FOV cases
/// Returns just the outer circle - we'll handle the donut effect differently
static Polygon _buildFullCircle({
required LatLng origin,
required double zoom,
@@ -239,17 +244,19 @@ class DirectionConesBuilder {
bool isSession = false,
bool isActiveDirection = true,
}) {
// Calculate pixel-based radii
print("DEBUG: Building full circle - isSession: $isSession, isActiveDirection: $isActiveDirection");
// Calculate pixel-based radii
final outerRadiusPx = kNodeIconDiameter + (kNodeIconDiameter * kDirectionConeBaseLength);
final innerRadiusPx = kNodeIconDiameter + (2 * getNodeRingThickness(context));
// Convert pixels to coordinate distances with zoom scaling
final pixelToCoordinate = 0.00001 * math.pow(2, 15 - zoom);
final outerRadius = outerRadiusPx * pixelToCoordinate;
final innerRadius = innerRadiusPx * pixelToCoordinate;
// Create full circle with many points for smooth rendering
const int circlePoints = 36;
print("DEBUG: Outer radius: $outerRadius, zoom: $zoom");
// Create simple filled circle - no donut complexity
const int circlePoints = 60;
final points = <LatLng>[];
LatLng project(double deg, double distance) {
@@ -260,17 +267,13 @@ class DirectionConesBuilder {
return LatLng(origin.latitude + dLat, origin.longitude + dLon);
}
// Add outer circle points
for (int i = 0; i < circlePoints; i++) {
final angle = i * 360.0 / circlePoints;
// Add outer circle points - simple complete circle
for (int i = 0; i <= circlePoints; i++) { // Note: <= to ensure closure
final angle = (i * 360.0 / circlePoints) % 360.0;
points.add(project(angle, outerRadius));
}
// Add inner circle points in reverse order to create donut
for (int i = circlePoints - 1; i >= 0; i--) {
final angle = i * 360.0 / circlePoints;
points.add(project(angle, innerRadius));
}
print("DEBUG: Created ${points.length} points for full circle");
// Adjust opacity based on direction state
double opacity = kDirectionConeOpacity;

View File

@@ -10,11 +10,11 @@ import '../../services/proximity_alert_service.dart';
import '../../models/osm_node.dart';
import '../../models/node_profile.dart';
/// Simple GPS controller that respects permissions and provides location updates.
/// Simple GPS controller that handles precise location permissions only.
/// Key principles:
/// - Respect "denied forever" - stop trying
/// - Retry "denied" - user might enable later
/// - Accept whatever accuracy is available once granted
/// - Retry "denied" - user might enable later
/// - Only works with precise location permissions
class GpsController {
StreamSubscription<Position>? _positionSub;
Timer? _retryTimer;

View File

@@ -9,6 +9,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.3"
app_links:
dependency: "direct main"
description:
name: app_links
sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8"
url: "https://pub.dev"
source: hosted
version: "6.4.1"
app_links_linux:
dependency: transitive
description:
name: app_links_linux
sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81
url: "https://pub.dev"
source: hosted
version: "1.0.3"
app_links_platform_interface:
dependency: transitive
description:
name: app_links_platform_interface
sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
app_links_web:
dependency: transitive
description:
name: app_links_web
sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555
url: "https://pub.dev"
source: hosted
version: "1.0.4"
archive:
dependency: transitive
description:
@@ -347,6 +379,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.2.5"
gtk:
dependency: transitive
description:
name: gtk
sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c
url: "https://pub.dev"
source: hosted
version: "2.1.0"
html:
dependency: transitive
description:

View File

@@ -1,7 +1,7 @@
name: deflockapp
description: Map public surveillance infrastructure with OpenStreetMap
publish_to: "none"
version: 2.2.0+36 # The thing after the + is the version code, incremented with each release
version: 2.4.3+41 # 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+
@@ -22,6 +22,7 @@ dependencies:
flutter_local_notifications: ^17.2.2
url_launcher: ^6.3.0
flutter_linkify: ^6.0.0
app_links: ^6.1.4
# Auth, storage, prefs
oauth2_client: ^4.2.0

View File

@@ -0,0 +1,91 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart';
import 'package:deflockapp/models/osm_node.dart';
void main() {
group('OsmNode Direction Parsing', () {
test('should parse 360-degree FOV from X-X notation', () {
final node = OsmNode(
id: 1,
coord: const LatLng(0, 0),
tags: {'direction': '180-180'},
);
final directionFovPairs = node.directionFovPairs;
expect(directionFovPairs, hasLength(1));
expect(directionFovPairs[0].centerDegrees, equals(180.0));
expect(directionFovPairs[0].fovDegrees, equals(360.0));
});
test('should parse 360-degree FOV from 0-0 notation', () {
final node = OsmNode(
id: 1,
coord: const LatLng(0, 0),
tags: {'direction': '0-0'},
);
final directionFovPairs = node.directionFovPairs;
expect(directionFovPairs, hasLength(1));
expect(directionFovPairs[0].centerDegrees, equals(0.0));
expect(directionFovPairs[0].fovDegrees, equals(360.0));
});
test('should parse 360-degree FOV from 270-270 notation', () {
final node = OsmNode(
id: 1,
coord: const LatLng(0, 0),
tags: {'direction': '270-270'},
);
final directionFovPairs = node.directionFovPairs;
expect(directionFovPairs, hasLength(1));
expect(directionFovPairs[0].centerDegrees, equals(270.0));
expect(directionFovPairs[0].fovDegrees, equals(360.0));
});
test('should parse normal range notation correctly', () {
final node = OsmNode(
id: 1,
coord: const LatLng(0, 0),
tags: {'direction': '90-270'},
);
final directionFovPairs = node.directionFovPairs;
expect(directionFovPairs, hasLength(1));
expect(directionFovPairs[0].centerDegrees, equals(180.0));
expect(directionFovPairs[0].fovDegrees, equals(180.0));
});
test('should parse wrapping range notation correctly', () {
final node = OsmNode(
id: 1,
coord: const LatLng(0, 0),
tags: {'direction': '270-90'},
);
final directionFovPairs = node.directionFovPairs;
expect(directionFovPairs, hasLength(1));
expect(directionFovPairs[0].centerDegrees, equals(0.0));
expect(directionFovPairs[0].fovDegrees, equals(180.0));
});
test('should parse single direction correctly', () {
final node = OsmNode(
id: 1,
coord: const LatLng(0, 0),
tags: {'direction': '90'},
);
final directionFovPairs = node.directionFovPairs;
expect(directionFovPairs, hasLength(1));
expect(directionFovPairs[0].centerDegrees, equals(90.0));
// Default FOV from dev_config (kDirectionConeHalfAngle * 2)
});
});
}