From bd71f88452f983a36489d5509c2ace1c042a75e1 Mon Sep 17 00:00:00 2001 From: stopflock Date: Mon, 23 Mar 2026 12:58:50 -0500 Subject: [PATCH] operator profiles - import, reorderable --- assets/changelog.json | 7 ++ lib/app_state.dart | 4 + lib/screens/operator_profile_editor.dart | 9 +- .../sections/operator_profiles_section.dart | 95 ++++++++++------- lib/services/deep_link_service.dart | 44 +++++++- .../operator_profile_import_service.dart | 100 ++++++++++++++++++ lib/state/operator_profile_state.dart | 62 ++++++++++- pubspec.yaml | 2 +- 8 files changed, 279 insertions(+), 44 deletions(-) create mode 100644 lib/services/operator_profile_import_service.dart diff --git a/assets/changelog.json b/assets/changelog.json index 6100c2a..bb0b3c7 100644 --- a/assets/changelog.json +++ b/assets/changelog.json @@ -1,4 +1,11 @@ { + "2.10.1": { + "content": [ + "• Operator profiles are now reorderable - drag to reorder them in settings and on the refine tags page", + "• Added operator profile import via deep links - use ?op= URLs to import operator profiles", + "• Improved operator profile management UI consistency with device profiles" + ] + }, "2.10.0": { "content": [ "• Simplified profile field-of-view settings - profiles now use a simple '360 FOV' checkbox instead of custom degree values", diff --git a/lib/app_state.dart b/lib/app_state.dart index 2688946..a593c18 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -442,6 +442,10 @@ class AppState extends ChangeNotifier { _operatorProfileState.deleteProfile(p); } + void reorderOperatorProfiles(int oldIndex, int newIndex) { + _operatorProfileState.reorderProfiles(oldIndex, newIndex); + } + // ---------- Session Methods ---------- void startAddSession() { _sessionState.startAddSession(enabledProfiles); diff --git a/lib/screens/operator_profile_editor.dart b/lib/screens/operator_profile_editor.dart index b967d62..684d23d 100644 --- a/lib/screens/operator_profile_editor.dart +++ b/lib/screens/operator_profile_editor.dart @@ -155,8 +155,13 @@ class _OperatorProfileEditorState extends State { final tagMap = {}; for (final e in _tags) { - if (e.key.trim().isEmpty) continue; // Skip only if key is empty - tagMap[e.key.trim()] = e.value.trim(); // Allow empty values for refinement + final key = e.key.trim(); + final value = e.value.trim(); + + // Skip if key is empty OR value is empty (no empty values for operator profiles) + if (key.isEmpty || value.isEmpty) continue; + + tagMap[key] = value; } final newProfile = widget.profile.copyWith( diff --git a/lib/screens/settings/sections/operator_profiles_section.dart b/lib/screens/settings/sections/operator_profiles_section.dart index ee4e70b..de0dd8e 100644 --- a/lib/screens/settings/sections/operator_profiles_section.dart +++ b/lib/screens/settings/sections/operator_profiles_section.dart @@ -54,47 +54,64 @@ class OperatorProfilesSection extends StatelessWidget { ), ) else - ...appState.operatorProfiles.map( - (p) => ListTile( - title: Text(p.name), - subtitle: Text(locService.t('operatorProfiles.tagsCount', params: [p.tags.length.toString()])), - trailing: PopupMenuButton( - itemBuilder: (context) => [ - PopupMenuItem( - value: 'edit', - child: Row( - children: [ - const Icon(Icons.edit), - const SizedBox(width: 8), - Text(locService.t('actions.edit')), - ], - ), + ReorderableListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: appState.operatorProfiles.length, + onReorder: (oldIndex, newIndex) { + appState.reorderOperatorProfiles(oldIndex, newIndex); + }, + itemBuilder: (context, index) { + final p = appState.operatorProfiles[index]; + return ListTile( + key: ValueKey(p.id), + leading: ReorderableDragStartListener( + index: index, + child: const Icon( + Icons.drag_handle, + color: Colors.grey, ), - PopupMenuItem( - value: 'delete', - child: Row( - children: [ - const Icon(Icons.delete, color: Colors.red), - const SizedBox(width: 8), - Text(locService.t('operatorProfiles.deleteOperatorProfile'), style: const TextStyle(color: Colors.red)), - ], - ), - ), - ], - onSelected: (value) { - if (value == 'edit') { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => OperatorProfileEditor(profile: p), + ), + title: Text(p.name), + subtitle: Text(locService.t('operatorProfiles.tagsCount', params: [p.tags.length.toString()])), + trailing: PopupMenuButton( + itemBuilder: (context) => [ + PopupMenuItem( + value: 'edit', + child: Row( + children: [ + const Icon(Icons.edit), + const SizedBox(width: 8), + Text(locService.t('actions.edit')), + ], ), - ); - } else if (value == 'delete') { - _showDeleteProfileDialog(context, p); - } - }, - ), - ), + ), + PopupMenuItem( + value: 'delete', + child: Row( + children: [ + const Icon(Icons.delete, color: Colors.red), + const SizedBox(width: 8), + Text(locService.t('operatorProfiles.deleteOperatorProfile'), style: const TextStyle(color: Colors.red)), + ], + ), + ), + ], + onSelected: (value) { + if (value == 'edit') { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => OperatorProfileEditor(profile: p), + ), + ); + } else if (value == 'delete') { + _showDeleteProfileDialog(context, p); + } + }, + ), + ); + }, ), ], ); diff --git a/lib/services/deep_link_service.dart b/lib/services/deep_link_service.dart index cbf4f2e..0c178b0 100644 --- a/lib/services/deep_link_service.dart +++ b/lib/services/deep_link_service.dart @@ -3,8 +3,11 @@ import 'package:app_links/app_links.dart'; import 'package:flutter/material.dart'; import '../models/node_profile.dart'; +import '../models/operator_profile.dart'; import 'profile_import_service.dart'; +import 'operator_profile_import_service.dart'; import '../screens/profile_editor.dart'; +import '../screens/operator_profile_editor.dart'; class DeepLinkService { static final DeepLinkService _instance = DeepLinkService._internal(); @@ -86,8 +89,16 @@ class DeepLinkService { } } - /// Handle profile add deep link: `deflockapp://profiles/add?p=` + /// Handle profile add deep link: `deflockapp://profiles/add?p=` or `deflockapp://profiles/add?op=` void _handleAddProfileLink(Uri uri) { + // Check for operator profile parameter first + final operatorBase64Data = uri.queryParameters['op']; + if (operatorBase64Data != null && operatorBase64Data.isNotEmpty) { + _handleOperatorProfileImport(operatorBase64Data); + return; + } + + // Otherwise check for device profile parameter final base64Data = uri.queryParameters['p']; if (base64Data == null || base64Data.isEmpty) { @@ -107,6 +118,20 @@ class DeepLinkService { _navigateToProfileEditor(profile); } + /// Handle operator profile import from deep link + void _handleOperatorProfileImport(String base64Data) { + // Parse operator profile from base64 + final operatorProfile = OperatorProfileImportService.parseProfileFromBase64(base64Data); + + if (operatorProfile == null) { + _showError('Invalid operator profile data'); + return; + } + + // Navigate to operator profile editor with the imported profile + _navigateToOperatorProfileEditor(operatorProfile); + } + /// Navigate to profile editor with pre-filled profile data void _navigateToProfileEditor(NodeProfile profile) { final context = _navigatorKey?.currentContext; @@ -124,6 +149,23 @@ class DeepLinkService { ); } + /// Navigate to operator profile editor with pre-filled operator profile data + void _navigateToOperatorProfileEditor(OperatorProfile operatorProfile) { + final context = _navigatorKey?.currentContext; + + if (context == null) { + debugPrint('[DeepLinkService] No navigator context available'); + return; + } + + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => OperatorProfileEditor(profile: operatorProfile), + ), + ); + } + /// Show error message to user void _showError(String message) { final context = _navigatorKey?.currentContext; diff --git a/lib/services/operator_profile_import_service.dart b/lib/services/operator_profile_import_service.dart new file mode 100644 index 0000000..c2fa71e --- /dev/null +++ b/lib/services/operator_profile_import_service.dart @@ -0,0 +1,100 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:uuid/uuid.dart'; + +import '../models/operator_profile.dart'; + +class OperatorProfileImportService { + // Maximum size for base64 encoded profile data (approx 50KB decoded) + static const int maxBase64Length = 70000; + + /// Parse and validate an operator profile from a base64-encoded JSON string + /// Returns null if parsing/validation fails + static OperatorProfile? parseProfileFromBase64(String base64Data) { + try { + // Basic size validation before expensive decode + if (base64Data.length > maxBase64Length) { + debugPrint('[OperatorProfileImportService] 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; + + // Validate and sanitize the profile data + final sanitizedProfile = _validateAndSanitizeProfile(jsonData); + return sanitizedProfile; + + } catch (e) { + debugPrint('[OperatorProfileImportService] Failed to parse profile from base64: $e'); + return null; + } + } + + /// Validate operator profile structure and sanitize all string values + static OperatorProfile? _validateAndSanitizeProfile(Map data) { + try { + // Extract and sanitize required fields + final name = _sanitizeString(data['name']); + if (name == null || name.isEmpty) { + debugPrint('[OperatorProfileImportService] Operator profile name is required'); + return null; + } + + // Extract and sanitize tags + final tagsData = data['tags']; + if (tagsData is! Map) { + debugPrint('[OperatorProfileImportService] Operator profile tags must be a map'); + return null; + } + + final sanitizedTags = {}; + 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('[OperatorProfileImportService] Operator profile must have at least one valid tag'); + return null; + } + + return OperatorProfile( + id: const Uuid().v4(), // Always generate new ID for imported profiles + name: name, + tags: sanitizedTags, + ); + + } catch (e) { + debugPrint('[OperatorProfileImportService] Failed to validate operator 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; + } +} \ No newline at end of file diff --git a/lib/state/operator_profile_state.dart b/lib/state/operator_profile_state.dart index 9ff87a2..4ead392 100644 --- a/lib/state/operator_profile_state.dart +++ b/lib/state/operator_profile_state.dart @@ -1,12 +1,16 @@ import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../models/operator_profile.dart'; import '../services/operator_profile_service.dart'; class OperatorProfileState extends ChangeNotifier { + static const String _profileOrderPrefsKey = 'operator_profile_order'; + final List _profiles = []; + List _customOrder = []; // List of profile IDs in user's preferred order - List get profiles => List.unmodifiable(_profiles); + List get profiles => List.unmodifiable(_getOrderedProfiles()); Future init({bool addDefaults = false}) async { _profiles.addAll(await OperatorProfileService().load()); @@ -16,6 +20,10 @@ class OperatorProfileState extends ChangeNotifier { _profiles.addAll(OperatorProfile.getDefaults()); await OperatorProfileService().save(_profiles); } + + // Load custom order from prefs + final prefs = await SharedPreferences.getInstance(); + _customOrder = prefs.getStringList(_profileOrderPrefsKey) ?? []; } void addOrUpdateProfile(OperatorProfile p) { @@ -34,4 +42,56 @@ class OperatorProfileState extends ChangeNotifier { OperatorProfileService().save(_profiles); notifyListeners(); } + + // Reorder profiles (for drag-and-drop in settings) + void reorderProfiles(int oldIndex, int newIndex) { + final orderedProfiles = _getOrderedProfiles(); + + // Standard Flutter reordering logic + if (oldIndex < newIndex) { + newIndex -= 1; + } + final item = orderedProfiles.removeAt(oldIndex); + orderedProfiles.insert(newIndex, item); + + // Update custom order with new sequence + _customOrder = orderedProfiles.map((p) => p.id).toList(); + _saveCustomOrder(); + notifyListeners(); + } + + // Get profiles in custom order, with unordered profiles at the end + List _getOrderedProfiles() { + if (_customOrder.isEmpty) { + return List.from(_profiles); + } + + final ordered = []; + final profilesById = {for (final p in _profiles) p.id: p}; + + // Add profiles in custom order + for (final id in _customOrder) { + final profile = profilesById[id]; + if (profile != null) { + ordered.add(profile); + profilesById.remove(id); + } + } + + // Add any remaining profiles that weren't in the custom order + ordered.addAll(profilesById.values); + + return ordered; + } + + // Save custom order to disk + Future _saveCustomOrder() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList(_profileOrderPrefsKey, _customOrder); + } catch (e) { + // Fail gracefully in tests or if SharedPreferences isn't available + debugPrint('[OperatorProfileState] Failed to save custom order: $e'); + } + } } \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index bbc1e0e..ac3484d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: deflockapp description: Map public surveillance infrastructure with OpenStreetMap publish_to: "none" -version: 2.10.0+54 # The thing after the + is the version code, incremented with each release +version: 2.10.1+55 # The thing after the + is the version code, incremented with each release environment: sdk: ">=3.10.3 <4.0.0" # Resolved dependency floor (Dart 3.10.3 = Flutter 3.38+)