operator profiles - import, reorderable

This commit is contained in:
stopflock
2026-03-23 12:58:50 -05:00
parent 6e2fa3a04c
commit bd71f88452
8 changed files with 279 additions and 44 deletions
+7
View File
@@ -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=<base64 json> 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",
+4
View File
@@ -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);
+7 -2
View File
@@ -155,8 +155,13 @@ class _OperatorProfileEditorState extends State<OperatorProfileEditor> {
final tagMap = <String, String>{};
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(
@@ -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);
}
},
),
);
},
),
],
);
+43 -1
View File
@@ -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=<base64>`
/// Handle profile add deep link: `deflockapp://profiles/add?p=<base64>` or `deflockapp://profiles/add?op=<base64>`
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;
@@ -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<String, dynamic>;
// 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<String, dynamic> 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<String, dynamic>) {
debugPrint('[OperatorProfileImportService] Operator 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('[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;
}
}
+61 -1
View File
@@ -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<OperatorProfile> _profiles = [];
List<String> _customOrder = []; // List of profile IDs in user's preferred order
List<OperatorProfile> get profiles => List.unmodifiable(_profiles);
List<OperatorProfile> get profiles => List.unmodifiable(_getOrderedProfiles());
Future<void> 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<OperatorProfile> _getOrderedProfiles() {
if (_customOrder.isEmpty) {
return List.from(_profiles);
}
final ordered = <OperatorProfile>[];
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<void> _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');
}
}
}
+1 -1
View File
@@ -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+)