diff --git a/analysis_options.yaml b/analysis_options.yaml
index 0d290213..bf8d4218 100644
--- a/analysis_options.yaml
+++ b/analysis_options.yaml
@@ -7,6 +7,15 @@
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
+analyzer:
+ exclude:
+ - build/**
+ - android/**
+ - ios/**
+ - web/**
+ - windows/**
+ - macos/**
+ - linux/**
include: package:flutter_lints/flutter.yaml
linter:
diff --git a/assets/changelog.json b/assets/changelog.json
index 3bf0123b..97c59897 100644
--- a/assets/changelog.json
+++ b/assets/changelog.json
@@ -1,6 +1,7 @@
{
"2.11.1": {
"content": [
+ "• Fix incorrect Flock Raven tags (surveillance/manufacturer) on existing saved entries",
"• Fix FOV-only edits not registering as a change",
"• Notify on changeset comments and show a popup bubble on launch"
]
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index cf4234a8..4fb418fb 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -1,8 +1,8 @@
-
-
- FlutterDeepLinkingEnabled
-
- CADisableMinimumFrameDurationOnPhone
-
-
- CFBundleDevelopmentRegion
- $(DEVELOPMENT_LANGUAGE)
- CFBundleDisplayName
- DeFlock
- CFBundleExecutable
- $(EXECUTABLE_NAME)
- CFBundleIdentifier
- $(PRODUCT_BUNDLE_IDENTIFIER)
- CFBundleInfoDictionaryVersion
- 6.0
- CFBundleName
- deflockapp
- CFBundlePackageType
- APPL
- CFBundleShortVersionString
- $(FLUTTER_BUILD_NAME)
- CFBundleSignature
- ????
- CFBundleURLTypes
-
-
- CFBundleTypeRole
- None
- CFBundleURLSchemes
-
- deflockapp
-
-
-
- CFBundleVersion
- $(FLUTTER_BUILD_NUMBER)
- LSApplicationQueriesSchemes
-
- https
-
- LSRequiresIPhoneOS
-
- NSLocationAlwaysAndWhenInUseUsageDescription
- This app optionally uses your location to center the map on your current position and provide proximity alerts for nearby surveillance devices. These features are entirely optional.
- NSLocationAlwaysUsageDescription
- This app optionally uses your location in the background so proximity alerts can warn you about nearby surveillance devices while the app is not on screen. This feature is entirely optional.
- NSLocationWhenInUseUsageDescription
- This app optionally uses your location to show nearby cameras by centering the map on your location.
- UIApplicationSceneManifest
-
- UIApplicationSupportsMultipleScenes
+ FlutterDeepLinkingEnabled
- UISceneConfigurations
+ CADisableMinimumFrameDurationOnPhone
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ DeFlock
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ deflockapp
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ $(FLUTTER_BUILD_NAME)
+ CFBundleSignature
+ ????
+ CFBundleURLTypes
+
+
+ CFBundleTypeRole
+ None
+ CFBundleURLSchemes
+
+ deflockapp
+
+
+
+ CFBundleVersion
+ $(FLUTTER_BUILD_NUMBER)
+ LSApplicationQueriesSchemes
+
+ https
+
+ LSRequiresIPhoneOS
+
+ NSLocationAlwaysAndWhenInUseUsageDescription
+ This app optionally uses your location to center the map on your current position and provide proximity alerts for nearby surveillance devices. These features are entirely optional.
+ NSLocationAlwaysUsageDescription
+ This app optionally uses your location in the background so proximity alerts can warn you about nearby surveillance devices while the app is not on screen. This feature is entirely optional.
+ NSLocationWhenInUseUsageDescription
+ This app optionally uses your location to show nearby cameras by centering the map on your location.
+ UIApplicationSceneManifest
- UIWindowSceneSessionRoleApplication
-
-
- UISceneClassName
- UIWindowScene
- UISceneConfigurationName
- flutter
- UISceneDelegateClassName
- FlutterSceneDelegate
- UISceneStoryboardFile
- Main
-
-
+ UIApplicationSupportsMultipleScenes
+
+ UISceneConfigurations
+
+ UIWindowSceneSessionRoleApplication
+
+
+ UISceneClassName
+ UIWindowScene
+ UISceneConfigurationName
+ flutter
+ UISceneDelegateClassName
+ FlutterSceneDelegate
+ UISceneStoryboardFile
+ Main
+
+
+
-
- UIApplicationSupportsIndirectInputEvents
-
-
- UIBackgroundModes
-
- location
-
- UILaunchStoryboardName
- LaunchScreen
- UIMainStoryboardFile
- Main
- UIStatusBarHidden
-
- UISupportedInterfaceOrientations
-
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
-
- UISupportedInterfaceOrientations~ipad
-
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
-
-
+ UIBackgroundModes
+
+ location
+
+ UILaunchStoryboardName
+ LaunchScreen
+ UIMainStoryboardFile
+ Main
+ UIStatusBarHidden
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+
diff --git a/lib/app_state.dart b/lib/app_state.dart
index 90fbb4f8..4b056cd0 100644
--- a/lib/app_state.dart
+++ b/lib/app_state.dart
@@ -868,6 +868,11 @@ class AppState extends ChangeNotifier {
await _uploadQueueState.reloadQueue();
}
+ /// Fix queued Flock Raven entries still using the old tag scheme (for migration purposes)
+ Future migrateFlockRavenQueueTags() async {
+ return _uploadQueueState.migrateFlockRavenQueueTags();
+ }
+
// ---------- Suspected Location Methods ----------
Future setSuspectedLocationsEnabled(bool enabled) async {
await _suspectedLocationState.setEnabled(enabled);
diff --git a/lib/migrations.dart b/lib/migrations.dart
index 171e4eb4..c8ef1264 100644
--- a/lib/migrations.dart
+++ b/lib/migrations.dart
@@ -215,6 +215,50 @@ class OneTimeMigrations {
}
}
+ /// Fix Flock Raven tags that were using the wrong surveillance scheme (v2.11.1)
+ /// Old (incorrect): 'surveillance': 'public', 'brand': 'Flock Safety', 'brand:wikidata': 'Q108485435'
+ /// New (correct): 'surveillance': 'outdoor', 'manufacturer': 'Flock Safety', 'manufacturer:wikidata': 'Q108485435'
+ static Future migrate_2_11_1(AppState appState) async {
+ try {
+ // Fix the persisted profile catalog (used when creating new nodes going forward)
+ final profiles = await ProfileService().load();
+ bool profilesChanged = false;
+
+ final updatedProfiles = profiles.map((profile) {
+ final tags = profile.tags;
+ if (tags['surveillance'] == 'public' &&
+ tags['brand'] == 'Flock Safety' &&
+ tags['surveillance:type'] == 'gunshot_detector') {
+ debugPrint('[Migration] 2.11.1: Fixing Flock Raven tags on profile: ${profile.id}');
+ final newTags = Map.from(tags);
+ final wikidata = newTags.remove('brand:wikidata');
+ newTags.remove('brand');
+ newTags['surveillance'] = 'outdoor';
+ newTags['manufacturer'] = 'Flock Safety';
+ newTags['manufacturer:wikidata'] = wikidata ?? 'Q108485435';
+ profilesChanged = true;
+ return profile.copyWith(tags: newTags);
+ }
+ return profile;
+ }).toList();
+
+ if (profilesChanged) {
+ await ProfileService().save(updatedProfiles);
+ await appState.reloadProfiles();
+ }
+
+ // Fix any not-yet-uploaded queue entries that snapshotted the old tags
+ final queueChanged = await appState.migrateFlockRavenQueueTags();
+
+ debugPrint('[Migration] 2.11.1 completed: fixed Flock Raven tags '
+ '(profiles changed=$profilesChanged, queue changed=$queueChanged)');
+ } catch (e, stackTrace) {
+ debugPrint('[Migration] 2.11.1 ERROR: Failed to fix Flock Raven tags: $e');
+ debugPrint('[Migration] 2.11.1 ERROR: Stack trace: $stackTrace');
+ // Don't rethrow - non-critical, worst case stale tags persist until manually fixed
+ }
+ }
+
/// Get the migration function for a specific version
static Future Function(AppState)? getMigrationForVersion(String version) {
switch (version) {
@@ -234,6 +278,8 @@ class OneTimeMigrations {
return migrate_2_10_0;
case '2.10.5':
return migrate_2_10_5;
+ case '2.11.1':
+ return migrate_2_11_1;
default:
return null;
}
diff --git a/lib/models/node_profile.dart b/lib/models/node_profile.dart
index cc8612ae..90b8a860 100644
--- a/lib/models/node_profile.dart
+++ b/lib/models/node_profile.dart
@@ -195,10 +195,10 @@ class NodeProfile {
name: 'Flock Raven',
tags: const {
'man_made': 'surveillance',
- 'surveillance': 'public',
+ 'surveillance': 'outdoor',
'surveillance:type': 'gunshot_detector',
- 'brand': 'Flock Safety',
- 'brand:wikidata': 'Q108485435',
+ 'manufacturer': 'Flock Safety',
+ 'manufacturer:wikidata': 'Q108485435',
},
builtin: true,
requiresDirection: false,
diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart
index 4e5e7f26..b85f1c1d 100644
--- a/lib/screens/home_screen.dart
+++ b/lib/screens/home_screen.dart
@@ -238,7 +238,7 @@ class _HomeScreenState extends State with TickerProviderStateMixin {
// Complete the version change workflow (updates last seen version)
await ChangelogService().completeVersionChange();
-
+
} catch (e) {
// Silently handle errors to avoid breaking the app launch
debugPrint('[HomeScreen] Error checking for popup: $e');
@@ -355,6 +355,14 @@ class _HomeScreenState extends State with TickerProviderStateMixin {
onSelectedNodeChanged: (id) => setState(() => _selectedNodeId = id),
);
+ // Deleting the node rebuilds this sheet mid-close (into a "deleted"
+ // placeholder of a different height) before the close animation settles.
+ // That late MeasuredSheet measurement can arrive after `closed` has
+ // already reset the height below, leaving it stuck non-zero forever
+ // (and the location button permanently greyed out). Ignore any height
+ // updates once the sheet has actually closed.
+ bool sheetClosed = false;
+
final controller = _scaffoldKey.currentState!.showBottomSheet(
(ctx) => Padding(
padding: EdgeInsets.only(
@@ -362,6 +370,7 @@ class _HomeScreenState extends State with TickerProviderStateMixin {
),
child: MeasuredSheet(
onHeightChanged: (height) {
+ if (sheetClosed) return;
_sheetCoordinator.updateTagSheetHeight(
height + MediaQuery.of(context).padding.bottom,
() => setState(() {}),
@@ -396,6 +405,7 @@ class _HomeScreenState extends State with TickerProviderStateMixin {
// Reset height and selection when sheet is dismissed (unless transitioning to edit)
controller.closed.then((_) {
+ sheetClosed = true;
if (!_sheetCoordinator.transitioningToEdit) {
_sheetCoordinator.resetTagSheetHeight(() => setState(() {}));
setState(() => _selectedNodeId = null);
diff --git a/lib/services/changelog_service.dart b/lib/services/changelog_service.dart
index 0797daf0..f00db56d 100644
--- a/lib/services/changelog_service.dart
+++ b/lib/services/changelog_service.dart
@@ -244,7 +244,11 @@ class ChangelogService {
if (needsMigration(lastSeenVersion, currentVersion, '2.10.5')) {
versionsNeedingMigration.add('2.10.5');
}
-
+
+ if (needsMigration(lastSeenVersion, currentVersion, '2.11.1')) {
+ versionsNeedingMigration.add('2.11.1');
+ }
+
// Future versions can be added here
// if (needsMigration(lastSeenVersion, currentVersion, '2.0.0')) {
diff --git a/lib/state/upload_queue_state.dart b/lib/state/upload_queue_state.dart
index 15557f4c..2e6e359c 100644
--- a/lib/state/upload_queue_state.dart
+++ b/lib/state/upload_queue_state.dart
@@ -790,6 +790,66 @@ class UploadQueueState extends ChangeNotifier {
notifyListeners();
}
+ /// Fix queued (not-yet-uploaded) Flock Raven entries that still carry the old
+ /// tag scheme ('surveillance': 'public', 'brand': 'Flock Safety') by
+ /// replacing them with the corrected scheme ('surveillance': 'outdoor',
+ /// 'manufacturer': 'Flock Safety'). Returns true if any entries were changed.
+ Future migrateFlockRavenQueueTags() async {
+ bool changed = false;
+
+ for (var i = 0; i < _queue.length; i++) {
+ final upload = _queue[i];
+ final profile = upload.profile;
+ if (profile == null) continue;
+
+ final tags = profile.tags;
+ if (tags['surveillance'] == 'public' &&
+ tags['brand'] == 'Flock Safety' &&
+ tags['surveillance:type'] == 'gunshot_detector') {
+ final newTags = Map.from(tags);
+ final wikidata = newTags.remove('brand:wikidata');
+ newTags.remove('brand');
+ newTags['surveillance'] = 'outdoor';
+ newTags['manufacturer'] = 'Flock Safety';
+ newTags['manufacturer:wikidata'] = wikidata ?? 'Q108485435';
+
+ _queue[i] = PendingUpload(
+ coord: upload.coord,
+ direction: upload.direction,
+ profile: profile.copyWith(tags: newTags),
+ operatorProfile: upload.operatorProfile,
+ refinedTags: upload.refinedTags,
+ additionalExistingTags: upload.additionalExistingTags,
+ changesetComment: upload.changesetComment,
+ uploadMode: upload.uploadMode,
+ operation: upload.operation,
+ originalNodeId: upload.originalNodeId,
+ submittedNodeId: upload.submittedNodeId,
+ tempNodeId: upload.tempNodeId,
+ attempts: upload.attempts,
+ error: upload.error,
+ errorMessage: upload.errorMessage,
+ completing: upload.completing,
+ uploadState: upload.uploadState,
+ changesetId: upload.changesetId,
+ nodeOperationCompletedAt: upload.nodeOperationCompletedAt,
+ changesetCloseAttempts: upload.changesetCloseAttempts,
+ lastChangesetCloseAttemptAt: upload.lastChangesetCloseAttemptAt,
+ nodeSubmissionAttempts: upload.nodeSubmissionAttempts,
+ lastNodeSubmissionAttemptAt: upload.lastNodeSubmissionAttemptAt,
+ );
+ changed = true;
+ }
+ }
+
+ if (changed) {
+ await _saveQueue();
+ notifyListeners();
+ }
+
+ return changed;
+ }
+
// Public method to manually trigger cache repopulation (useful for debugging or after cache clears)
void repopulateCacheFromQueue() {
_repopulateCacheFromQueue();
diff --git a/pubspec.lock b/pubspec.lock
index e5cd22b2..c75e8e76 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -564,10 +564,10 @@ packages:
dependency: transitive
description:
name: matcher
- sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
+ sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
url: "https://pub.dev"
source: hosted
- version: "0.12.19"
+ version: "0.12.20"
material_color_utilities:
dependency: transitive
description:
@@ -580,10 +580,10 @@ packages:
dependency: transitive
description:
name: meta
- sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
+ sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
url: "https://pub.dev"
source: hosted
- version: "1.18.0"
+ version: "1.19.0"
mgrs_dart:
dependency: transitive
description:
@@ -945,10 +945,10 @@ packages:
dependency: transitive
description:
name: test_api
- sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
+ sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
url: "https://pub.dev"
source: hosted
- version: "0.7.11"
+ version: "0.7.12"
timezone:
dependency: transitive
description:
@@ -1081,10 +1081,10 @@ packages:
dependency: transitive
description:
name: vector_math
- sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
+ sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
url: "https://pub.dev"
source: hosted
- version: "2.2.0"
+ version: "2.4.2"
vm_service:
dependency: transitive
description:
diff --git a/test/migrations_test.dart b/test/migrations_test.dart
new file mode 100644
index 00000000..0127b2d7
--- /dev/null
+++ b/test/migrations_test.dart
@@ -0,0 +1,143 @@
+import 'dart:convert';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:latlong2/latlong.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+import 'package:deflockapp/app_state.dart';
+import 'package:deflockapp/migrations.dart';
+import 'package:deflockapp/models/node_profile.dart';
+import 'package:deflockapp/models/pending_upload.dart';
+import 'package:deflockapp/services/profile_service.dart';
+import 'package:deflockapp/state/upload_queue_state.dart';
+
+class MockAppState extends Mock implements AppState {}
+
+NodeProfile staleFlockRavenProfile() => NodeProfile(
+ id: 'builtin-flock-raven',
+ name: 'Flock Raven',
+ tags: const {
+ 'man_made': 'surveillance',
+ 'surveillance': 'public',
+ 'surveillance:type': 'gunshot_detector',
+ 'brand': 'Flock Safety',
+ 'brand:wikidata': 'Q108485435',
+ },
+ builtin: true,
+ requiresDirection: false,
+ );
+
+void main() {
+ late MockAppState mockAppState;
+
+ setUp(() {
+ SharedPreferences.setMockInitialValues({});
+ mockAppState = MockAppState();
+ AppState.instance = mockAppState;
+ when(() => mockAppState.offlineMode).thenReturn(false);
+ when(() => mockAppState.offlineFeaturesEnabled).thenReturn(false);
+ when(() => mockAppState.reloadProfiles()).thenAnswer((_) async {});
+ when(() => mockAppState.migrateFlockRavenQueueTags()).thenAnswer((_) async => false);
+ });
+
+ group('OneTimeMigrations.migrate_2_11_1 (profile catalog)', () {
+ test('fixes stale Flock Raven profile tags in storage', () async {
+ await ProfileService().save([staleFlockRavenProfile()]);
+
+ await OneTimeMigrations.migrate_2_11_1(mockAppState);
+
+ final saved = await ProfileService().load();
+ final fixed = saved.firstWhere((p) => p.id == 'builtin-flock-raven');
+
+ expect(fixed.tags['surveillance'], 'outdoor');
+ expect(fixed.tags['manufacturer'], 'Flock Safety');
+ expect(fixed.tags['manufacturer:wikidata'], 'Q108485435');
+ expect(fixed.tags.containsKey('brand'), isFalse);
+ expect(fixed.tags.containsKey('brand:wikidata'), isFalse);
+
+ verify(() => mockAppState.reloadProfiles()).called(1);
+ verify(() => mockAppState.migrateFlockRavenQueueTags()).called(1);
+ });
+
+ test('leaves an already-correct profile untouched', () async {
+ final correctProfile = staleFlockRavenProfile().copyWith(tags: const {
+ 'man_made': 'surveillance',
+ 'surveillance': 'outdoor',
+ 'surveillance:type': 'gunshot_detector',
+ 'manufacturer': 'Flock Safety',
+ 'manufacturer:wikidata': 'Q108485435',
+ });
+ await ProfileService().save([correctProfile]);
+
+ await OneTimeMigrations.migrate_2_11_1(mockAppState);
+
+ verifyNever(() => mockAppState.reloadProfiles());
+ });
+
+ test('does not touch the unrelated ALPR "Flock" profile', () async {
+ final alprFlock = NodeProfile(
+ id: 'builtin-flock',
+ name: 'Flock',
+ tags: const {
+ 'man_made': 'surveillance',
+ 'surveillance': 'public',
+ 'surveillance:type': 'ALPR',
+ 'manufacturer': 'Flock Safety',
+ 'manufacturer:wikidata': 'Q108485435',
+ },
+ builtin: true,
+ );
+ await ProfileService().save([alprFlock]);
+
+ await OneTimeMigrations.migrate_2_11_1(mockAppState);
+
+ final saved = await ProfileService().load();
+ expect(saved.single.tags, alprFlock.tags);
+ verifyNever(() => mockAppState.reloadProfiles());
+ });
+ });
+
+ group('UploadQueueState.migrateFlockRavenQueueTags', () {
+ test('fixes a queued (not-yet-uploaded) Flock Raven entry', () async {
+ final pending = PendingUpload(
+ coord: const LatLng(1, 2),
+ direction: 0,
+ profile: staleFlockRavenProfile(),
+ changesetComment: 'Add Flock Raven surveillance node',
+ uploadMode: UploadMode.simulate,
+ operation: UploadOperation.create,
+ );
+
+ SharedPreferences.setMockInitialValues({
+ 'queue': jsonEncode([pending.toJson()]),
+ });
+
+ final state = UploadQueueState();
+ await state.init();
+
+ final changed = await state.migrateFlockRavenQueueTags();
+ expect(changed, isTrue);
+
+ final fixedTags = state.pendingUploads.single.profile!.tags;
+ expect(fixedTags['surveillance'], 'outdoor');
+ expect(fixedTags['manufacturer'], 'Flock Safety');
+ expect(fixedTags.containsKey('brand'), isFalse);
+
+ // Persisted change should survive a reload.
+ final reloaded = UploadQueueState();
+ await reloaded.init();
+ expect(reloaded.pendingUploads.single.profile!.tags['surveillance'], 'outdoor');
+ });
+
+ test('leaves a queue with no Flock Raven entries unchanged', () async {
+ SharedPreferences.setMockInitialValues({'queue': jsonEncode([])});
+
+ final state = UploadQueueState();
+ await state.init();
+
+ final changed = await state.migrateFlockRavenQueueTags();
+ expect(changed, isFalse);
+ });
+ });
+}