diff --git a/lib/main.dart b/lib/main.dart index 424aa04..da425e6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -23,6 +23,9 @@ import 'services/voice_player_service.dart'; import 'services/notification_service.dart'; import 'services/locale_preferences.dart'; import 'services/mesh_map_nodes_service.dart'; +import 'services/profile_manager.dart'; +import 'services/profile_workspace_coordinator.dart'; +import 'services/profiles_feature_service.dart'; import 'services/update_checker_service.dart'; import 'services/wizard_preferences.dart'; import 'screens/discovery_screen.dart'; @@ -58,6 +61,14 @@ class _MeshCoreSarAppState extends State { } Future _initializeApp() async { + final prefs = await SharedPreferences.getInstance(); + final profilesEnabled = await ProfilesFeatureService.isEnabled(); + final activeProfileId = + prefs.getString(ProfileManager.activeProfileIdKey) ?? 'default'; + await ProfileStorageScope.bootstrap( + profilesEnabled: profilesEnabled, + activeProfileId: activeProfileId, + ); await _loadThemePreference(); await _loadLocalePreference(); @@ -271,6 +282,13 @@ class _MeshCoreSarAppState extends State { ), ChangeNotifierProvider(create: (_) => ChannelsProvider()), ChangeNotifierProvider(create: (_) => SensorsProvider()), + ChangeNotifierProvider( + create: (_) { + final manager = ProfileManager(); + manager.initialize(); + return manager; + }, + ), // Voice provider (packet reassembly + playback) ChangeNotifierProvider( @@ -323,6 +341,36 @@ class _MeshCoreSarAppState extends State { imageProvider: context.read(), ), ), + ProxyProvider6< + ProfileManager, + AppProvider, + ConnectionProvider, + ContactsProvider, + MessagesProvider, + MapProvider, + ProfileWorkspaceCoordinator + >( + update: + ( + context, + profileManager, + appProvider, + connectionProvider, + contactsProvider, + messagesProvider, + mapProvider, + previous, + ) => ProfileWorkspaceCoordinator( + profileManager: profileManager, + connectionProvider: connectionProvider, + contactsProvider: contactsProvider, + messagesProvider: messagesProvider, + mapProvider: mapProvider, + drawingProvider: context.read(), + channelsProvider: context.read(), + appProvider: appProvider, + ), + ), ], child: _buildMaterialApp(), ); diff --git a/lib/models/config_profile.dart b/lib/models/config_profile.dart new file mode 100644 index 0000000..ea581f2 --- /dev/null +++ b/lib/models/config_profile.dart @@ -0,0 +1,528 @@ +import 'package:flutter/foundation.dart'; +import 'channel.dart'; + +@immutable +class DeviceConfigProfileSection { + final int? frequencyKhz; + final int? bandwidth; + final int? spreadingFactor; + final int? codingRate; + final bool? repeatEnabled; + final int? txPower; + final int? telemetryModes; + final int? advertLocationPolicy; + final int? multiAcks; + final bool? manualAddContacts; + final bool? autoAddUsers; + final bool? autoAddRepeaters; + final bool? autoAddRoomServers; + final bool? autoAddSensors; + final bool? autoAddOverwriteOldest; + final double? publicLatitude; + final double? publicLongitude; + + const DeviceConfigProfileSection({ + this.frequencyKhz, + this.bandwidth, + this.spreadingFactor, + this.codingRate, + this.repeatEnabled, + this.txPower, + this.telemetryModes, + this.advertLocationPolicy, + this.multiAcks, + this.manualAddContacts, + this.autoAddUsers, + this.autoAddRepeaters, + this.autoAddRoomServers, + this.autoAddSensors, + this.autoAddOverwriteOldest, + this.publicLatitude, + this.publicLongitude, + }); + + bool get isEmpty => + frequencyKhz == null && + bandwidth == null && + spreadingFactor == null && + codingRate == null && + repeatEnabled == null && + txPower == null && + telemetryModes == null && + advertLocationPolicy == null && + multiAcks == null && + manualAddContacts == null && + autoAddUsers == null && + autoAddRepeaters == null && + autoAddRoomServers == null && + autoAddSensors == null && + autoAddOverwriteOldest == null && + publicLatitude == null && + publicLongitude == null; + + Map toJson() => { + 'frequencyKhz': frequencyKhz, + 'bandwidth': bandwidth, + 'spreadingFactor': spreadingFactor, + 'codingRate': codingRate, + 'repeatEnabled': repeatEnabled, + 'txPower': txPower, + 'telemetryModes': telemetryModes, + 'advertLocationPolicy': advertLocationPolicy, + 'multiAcks': multiAcks, + 'manualAddContacts': manualAddContacts, + 'autoAddUsers': autoAddUsers, + 'autoAddRepeaters': autoAddRepeaters, + 'autoAddRoomServers': autoAddRoomServers, + 'autoAddSensors': autoAddSensors, + 'autoAddOverwriteOldest': autoAddOverwriteOldest, + 'publicLatitude': publicLatitude, + 'publicLongitude': publicLongitude, + }; + + factory DeviceConfigProfileSection.fromJson(Map json) { + return DeviceConfigProfileSection( + frequencyKhz: json['frequencyKhz'] as int?, + bandwidth: json['bandwidth'] as int?, + spreadingFactor: json['spreadingFactor'] as int?, + codingRate: json['codingRate'] as int?, + repeatEnabled: json['repeatEnabled'] as bool?, + txPower: json['txPower'] as int?, + telemetryModes: json['telemetryModes'] as int?, + advertLocationPolicy: json['advertLocationPolicy'] as int?, + multiAcks: json['multiAcks'] as int?, + manualAddContacts: json['manualAddContacts'] as bool?, + autoAddUsers: json['autoAddUsers'] as bool?, + autoAddRepeaters: json['autoAddRepeaters'] as bool?, + autoAddRoomServers: json['autoAddRoomServers'] as bool?, + autoAddSensors: json['autoAddSensors'] as bool?, + autoAddOverwriteOldest: json['autoAddOverwriteOldest'] as bool?, + publicLatitude: (json['publicLatitude'] as num?)?.toDouble(), + publicLongitude: (json['publicLongitude'] as num?)?.toDouble(), + ); + } +} + +@immutable +class AppSettingsProfileSection { + final bool? mapEnabled; + final bool? contactsEnabled; + final bool? sensorsEnabled; + final bool? voiceSilenceTrimmingEnabled; + final bool? voiceBandPassFilterEnabled; + final bool? voiceCompressorEnabled; + final bool? voiceLimiterEnabled; + final bool? voiceAutoGainEnabled; + final bool? voiceEchoCancellationEnabled; + final bool? voiceNoiseSuppressionEnabled; + final double? messageFontScale; + final bool? autoRouteRotationEnabled; + final bool? clearPathOnMaxRetry; + final bool? nearestRelayFallbackEnabled; + final int? voiceBitrate; + final int? routeHashSize; + final int? imageMaxSize; + final int? imageCompression; + final bool? imageGrayscale; + final bool? imageUltraMode; + final bool? showRxTxIndicators; + final bool? fastLocationUpdatesEnabled; + final double? fastLocationMovementThresholdMeters; + final int? fastLocationActiveCadenceSeconds; + + const AppSettingsProfileSection({ + this.mapEnabled, + this.contactsEnabled, + this.sensorsEnabled, + this.voiceSilenceTrimmingEnabled, + this.voiceBandPassFilterEnabled, + this.voiceCompressorEnabled, + this.voiceLimiterEnabled, + this.voiceAutoGainEnabled, + this.voiceEchoCancellationEnabled, + this.voiceNoiseSuppressionEnabled, + this.messageFontScale, + this.autoRouteRotationEnabled, + this.clearPathOnMaxRetry, + this.nearestRelayFallbackEnabled, + this.voiceBitrate, + this.routeHashSize, + this.imageMaxSize, + this.imageCompression, + this.imageGrayscale, + this.imageUltraMode, + this.showRxTxIndicators, + this.fastLocationUpdatesEnabled, + this.fastLocationMovementThresholdMeters, + this.fastLocationActiveCadenceSeconds, + }); + + bool get isEmpty => + mapEnabled == null && + contactsEnabled == null && + sensorsEnabled == null && + voiceSilenceTrimmingEnabled == null && + voiceBandPassFilterEnabled == null && + voiceCompressorEnabled == null && + voiceLimiterEnabled == null && + voiceAutoGainEnabled == null && + voiceEchoCancellationEnabled == null && + voiceNoiseSuppressionEnabled == null && + messageFontScale == null && + autoRouteRotationEnabled == null && + clearPathOnMaxRetry == null && + nearestRelayFallbackEnabled == null && + voiceBitrate == null && + routeHashSize == null && + imageMaxSize == null && + imageCompression == null && + imageGrayscale == null && + imageUltraMode == null && + showRxTxIndicators == null && + fastLocationUpdatesEnabled == null && + fastLocationMovementThresholdMeters == null && + fastLocationActiveCadenceSeconds == null; + + Map toJson() => { + 'mapEnabled': mapEnabled, + 'contactsEnabled': contactsEnabled, + 'sensorsEnabled': sensorsEnabled, + 'voiceSilenceTrimmingEnabled': voiceSilenceTrimmingEnabled, + 'voiceBandPassFilterEnabled': voiceBandPassFilterEnabled, + 'voiceCompressorEnabled': voiceCompressorEnabled, + 'voiceLimiterEnabled': voiceLimiterEnabled, + 'voiceAutoGainEnabled': voiceAutoGainEnabled, + 'voiceEchoCancellationEnabled': voiceEchoCancellationEnabled, + 'voiceNoiseSuppressionEnabled': voiceNoiseSuppressionEnabled, + 'messageFontScale': messageFontScale, + 'autoRouteRotationEnabled': autoRouteRotationEnabled, + 'clearPathOnMaxRetry': clearPathOnMaxRetry, + 'nearestRelayFallbackEnabled': nearestRelayFallbackEnabled, + 'voiceBitrate': voiceBitrate, + 'routeHashSize': routeHashSize, + 'imageMaxSize': imageMaxSize, + 'imageCompression': imageCompression, + 'imageGrayscale': imageGrayscale, + 'imageUltraMode': imageUltraMode, + 'showRxTxIndicators': showRxTxIndicators, + 'fastLocationUpdatesEnabled': fastLocationUpdatesEnabled, + 'fastLocationMovementThresholdMeters': fastLocationMovementThresholdMeters, + 'fastLocationActiveCadenceSeconds': fastLocationActiveCadenceSeconds, + }; + + factory AppSettingsProfileSection.fromJson(Map json) { + return AppSettingsProfileSection( + mapEnabled: json['mapEnabled'] as bool?, + contactsEnabled: json['contactsEnabled'] as bool?, + sensorsEnabled: json['sensorsEnabled'] as bool?, + voiceSilenceTrimmingEnabled: json['voiceSilenceTrimmingEnabled'] as bool?, + voiceBandPassFilterEnabled: json['voiceBandPassFilterEnabled'] as bool?, + voiceCompressorEnabled: json['voiceCompressorEnabled'] as bool?, + voiceLimiterEnabled: json['voiceLimiterEnabled'] as bool?, + voiceAutoGainEnabled: json['voiceAutoGainEnabled'] as bool?, + voiceEchoCancellationEnabled: + json['voiceEchoCancellationEnabled'] as bool?, + voiceNoiseSuppressionEnabled: + json['voiceNoiseSuppressionEnabled'] as bool?, + messageFontScale: (json['messageFontScale'] as num?)?.toDouble(), + autoRouteRotationEnabled: json['autoRouteRotationEnabled'] as bool?, + clearPathOnMaxRetry: json['clearPathOnMaxRetry'] as bool?, + nearestRelayFallbackEnabled: json['nearestRelayFallbackEnabled'] as bool?, + voiceBitrate: json['voiceBitrate'] as int?, + routeHashSize: json['routeHashSize'] as int?, + imageMaxSize: json['imageMaxSize'] as int?, + imageCompression: json['imageCompression'] as int?, + imageGrayscale: json['imageGrayscale'] as bool?, + imageUltraMode: json['imageUltraMode'] as bool?, + showRxTxIndicators: json['showRxTxIndicators'] as bool?, + fastLocationUpdatesEnabled: json['fastLocationUpdatesEnabled'] as bool?, + fastLocationMovementThresholdMeters: + (json['fastLocationMovementThresholdMeters'] as num?)?.toDouble(), + fastLocationActiveCadenceSeconds: + json['fastLocationActiveCadenceSeconds'] as int?, + ); + } +} + +@immutable +class MapWorkspaceProfileSection { + final Map? mapPrefs; + final List> drawings; + final Map? currentTrail; + final List> trailHistory; + final Map? importedTrail; + final bool? isTrailVisible; + final bool? showCadastralOverlay; + final bool? showForestRoadsOverlay; + final bool? showHikingTrailsOverlay; + final bool? showMainRoadsOverlay; + final bool? showHouseNumbersOverlay; + final bool? showFireHazardZonesOverlay; + final bool? showHistoricalFiresOverlay; + final bool? showFirebreaksOverlay; + final bool? showKrasFireZonesOverlay; + final bool? showPlaceNamesOverlay; + final bool? showMunicipalityBordersOverlay; + final bool? showAllContactTrails; + final bool? hideRepeatersOnMap; + + const MapWorkspaceProfileSection({ + this.mapPrefs, + this.drawings = const [], + this.currentTrail, + this.trailHistory = const [], + this.importedTrail, + this.isTrailVisible, + this.showCadastralOverlay, + this.showForestRoadsOverlay, + this.showHikingTrailsOverlay, + this.showMainRoadsOverlay, + this.showHouseNumbersOverlay, + this.showFireHazardZonesOverlay, + this.showHistoricalFiresOverlay, + this.showFirebreaksOverlay, + this.showKrasFireZonesOverlay, + this.showPlaceNamesOverlay, + this.showMunicipalityBordersOverlay, + this.showAllContactTrails, + this.hideRepeatersOnMap, + }); + + bool get isEmpty => + mapPrefs == null && + drawings.isEmpty && + currentTrail == null && + trailHistory.isEmpty && + importedTrail == null && + isTrailVisible == null && + showCadastralOverlay == null && + showForestRoadsOverlay == null && + showHikingTrailsOverlay == null && + showMainRoadsOverlay == null && + showHouseNumbersOverlay == null && + showFireHazardZonesOverlay == null && + showHistoricalFiresOverlay == null && + showFirebreaksOverlay == null && + showKrasFireZonesOverlay == null && + showPlaceNamesOverlay == null && + showMunicipalityBordersOverlay == null && + showAllContactTrails == null && + hideRepeatersOnMap == null; + + Map toJson() => { + 'mapPrefs': mapPrefs, + 'drawings': drawings, + 'currentTrail': currentTrail, + 'trailHistory': trailHistory, + 'importedTrail': importedTrail, + 'isTrailVisible': isTrailVisible, + 'showCadastralOverlay': showCadastralOverlay, + 'showForestRoadsOverlay': showForestRoadsOverlay, + 'showHikingTrailsOverlay': showHikingTrailsOverlay, + 'showMainRoadsOverlay': showMainRoadsOverlay, + 'showHouseNumbersOverlay': showHouseNumbersOverlay, + 'showFireHazardZonesOverlay': showFireHazardZonesOverlay, + 'showHistoricalFiresOverlay': showHistoricalFiresOverlay, + 'showFirebreaksOverlay': showFirebreaksOverlay, + 'showKrasFireZonesOverlay': showKrasFireZonesOverlay, + 'showPlaceNamesOverlay': showPlaceNamesOverlay, + 'showMunicipalityBordersOverlay': showMunicipalityBordersOverlay, + 'showAllContactTrails': showAllContactTrails, + 'hideRepeatersOnMap': hideRepeatersOnMap, + }; + + factory MapWorkspaceProfileSection.fromJson(Map json) { + List> decodeList(String key) { + final raw = json[key] as List? ?? const []; + return raw.whereType>().toList(); + } + + return MapWorkspaceProfileSection( + mapPrefs: json['mapPrefs'] as Map?, + drawings: decodeList('drawings'), + currentTrail: json['currentTrail'] as Map?, + trailHistory: decodeList('trailHistory'), + importedTrail: json['importedTrail'] as Map?, + isTrailVisible: json['isTrailVisible'] as bool?, + showCadastralOverlay: json['showCadastralOverlay'] as bool?, + showForestRoadsOverlay: json['showForestRoadsOverlay'] as bool?, + showHikingTrailsOverlay: json['showHikingTrailsOverlay'] as bool?, + showMainRoadsOverlay: json['showMainRoadsOverlay'] as bool?, + showHouseNumbersOverlay: json['showHouseNumbersOverlay'] as bool?, + showFireHazardZonesOverlay: json['showFireHazardZonesOverlay'] as bool?, + showHistoricalFiresOverlay: json['showHistoricalFiresOverlay'] as bool?, + showFirebreaksOverlay: json['showFirebreaksOverlay'] as bool?, + showKrasFireZonesOverlay: json['showKrasFireZonesOverlay'] as bool?, + showPlaceNamesOverlay: json['showPlaceNamesOverlay'] as bool?, + showMunicipalityBordersOverlay: + json['showMunicipalityBordersOverlay'] as bool?, + showAllContactTrails: json['showAllContactTrails'] as bool?, + hideRepeatersOnMap: json['hideRepeatersOnMap'] as bool?, + ); + } +} + +@immutable +class ConfigProfileSections { + final DeviceConfigProfileSection? deviceConfig; + final AppSettingsProfileSection? appSettings; + final MapWorkspaceProfileSection? mapWorkspace; + final List channels; + + const ConfigProfileSections({ + this.deviceConfig, + this.appSettings, + this.mapWorkspace, + this.channels = const [], + }); + + bool get isEmpty => + (deviceConfig == null || deviceConfig!.isEmpty) && + (appSettings == null || appSettings!.isEmpty) && + (mapWorkspace == null || mapWorkspace!.isEmpty) && + channels.isEmpty; + + Map toJson() => { + 'deviceConfig': deviceConfig?.toJson(), + 'appSettings': appSettings?.toJson(), + 'mapWorkspace': mapWorkspace?.toJson(), + 'channels': channels.map((channel) => channel.toJson()).toList(), + }; + + factory ConfigProfileSections.fromJson(Map json) { + final rawChannels = json['channels'] as List? ?? const []; + return ConfigProfileSections( + deviceConfig: json['deviceConfig'] is Map + ? DeviceConfigProfileSection.fromJson( + json['deviceConfig'] as Map, + ) + : null, + appSettings: json['appSettings'] is Map + ? AppSettingsProfileSection.fromJson( + json['appSettings'] as Map, + ) + : null, + mapWorkspace: json['mapWorkspace'] is Map + ? MapWorkspaceProfileSection.fromJson( + json['mapWorkspace'] as Map, + ) + : null, + channels: rawChannels + .whereType>() + .map(Channel.fromJson) + .toList(), + ); + } +} + +@immutable +class ConfigProfile { + static const String defaultProfileId = 'default'; + + final String id; + final String name; + final DateTime createdAt; + final DateTime updatedAt; + final String? notes; + final ConfigProfileSections sections; + + const ConfigProfile({ + required this.id, + required this.name, + required this.createdAt, + required this.updatedAt, + required this.sections, + this.notes, + }); + + bool get isDefault => id == defaultProfileId; + + factory ConfigProfile.defaultProfile() { + final now = DateTime.now(); + return ConfigProfile( + id: defaultProfileId, + name: 'Default', + createdAt: now, + updatedAt: now, + sections: const ConfigProfileSections(), + ); + } + + ConfigProfile copyWith({ + String? id, + String? name, + DateTime? createdAt, + DateTime? updatedAt, + String? notes, + ConfigProfileSections? sections, + }) { + return ConfigProfile( + id: id ?? this.id, + name: name ?? this.name, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + notes: notes ?? this.notes, + sections: sections ?? this.sections, + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + 'createdAt': createdAt.toIso8601String(), + 'updatedAt': updatedAt.toIso8601String(), + 'notes': notes, + 'sections': sections.toJson(), + }; + + factory ConfigProfile.fromJson(Map json) { + return ConfigProfile( + id: json['id'] as String, + name: json['name'] as String, + createdAt: DateTime.parse(json['createdAt'] as String), + updatedAt: DateTime.parse(json['updatedAt'] as String), + notes: json['notes'] as String?, + sections: ConfigProfileSections.fromJson( + json['sections'] as Map? ?? const {}, + ), + ); + } +} + +@immutable +class ProfileDiff { + final List changedSections; + + const ProfileDiff({required this.changedSections}); +} + +@immutable +class ProfileTransferRecord { + final String profileId; + final String direction; + final DateTime timestamp; + final String detail; + + const ProfileTransferRecord({ + required this.profileId, + required this.direction, + required this.timestamp, + required this.detail, + }); + + Map toJson() => { + 'profileId': profileId, + 'direction': direction, + 'timestamp': timestamp.toIso8601String(), + 'detail': detail, + }; + + factory ProfileTransferRecord.fromJson(Map json) { + return ProfileTransferRecord( + profileId: json['profileId'] as String, + direction: json['direction'] as String, + timestamp: DateTime.parse(json['timestamp'] as String), + detail: json['detail'] as String, + ); + } +} diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 03efc9f..a759530 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -18,6 +18,7 @@ import '../services/messaging_route_preferences.dart'; import '../services/nearest_router_selector.dart'; import '../services/packet_capture_storage_service.dart'; import '../services/path_history_service.dart'; +import '../services/profiles_feature_service.dart'; import '../services/route_hash_preferences.dart'; import '../services/notification_service.dart'; import '../models/contact.dart'; @@ -253,6 +254,10 @@ class AppProvider with ChangeNotifier { _isInitialized = true; } + String _scopedKey(String baseKey) { + return ProfileStorageScope.scopedKey(baseKey); + } + void _startPacketCapturePersistence() { _packetCaptureFlushTimer?.cancel(); _packetCaptureFlushTimer = Timer.periodic(const Duration(seconds: 2), (_) { @@ -516,7 +521,7 @@ class AppProvider with ChangeNotifier { Future _loadMapEnabled() async { try { final prefs = await SharedPreferences.getInstance(); - _isMapEnabled = prefs.getBool('map_enabled') ?? true; + _isMapEnabled = prefs.getBool(_scopedKey('map_enabled')) ?? true; notifyListeners(); } catch (e) { debugPrint('Error loading map enabled setting: $e'); @@ -528,7 +533,7 @@ class AppProvider with ChangeNotifier { try { _isMapEnabled = enabled; final prefs = await SharedPreferences.getInstance(); - await prefs.setBool('map_enabled', enabled); + await prefs.setBool(_scopedKey('map_enabled'), enabled); notifyListeners(); } catch (e) { debugPrint('Error saving map enabled setting: $e'); @@ -539,7 +544,8 @@ class AppProvider with ChangeNotifier { Future _loadContactsEnabled() async { try { final prefs = await SharedPreferences.getInstance(); - _isContactsEnabled = prefs.getBool('contacts_enabled') ?? true; + _isContactsEnabled = + prefs.getBool(_scopedKey('contacts_enabled')) ?? true; notifyListeners(); } catch (e) { debugPrint('Error loading contacts enabled setting: $e'); @@ -551,7 +557,7 @@ class AppProvider with ChangeNotifier { try { _isContactsEnabled = enabled; final prefs = await SharedPreferences.getInstance(); - await prefs.setBool('contacts_enabled', enabled); + await prefs.setBool(_scopedKey('contacts_enabled'), enabled); notifyListeners(); } catch (e) { debugPrint('Error saving contacts enabled setting: $e'); @@ -562,7 +568,7 @@ class AppProvider with ChangeNotifier { Future _loadSensorsEnabled() async { try { final prefs = await SharedPreferences.getInstance(); - _isSensorsEnabled = prefs.getBool('sensors_enabled') ?? true; + _isSensorsEnabled = prefs.getBool(_scopedKey('sensors_enabled')) ?? true; notifyListeners(); } catch (e) { debugPrint('Error loading sensors enabled setting: $e'); @@ -574,7 +580,7 @@ class AppProvider with ChangeNotifier { try { _isSensorsEnabled = enabled; final prefs = await SharedPreferences.getInstance(); - await prefs.setBool('sensors_enabled', enabled); + await prefs.setBool(_scopedKey('sensors_enabled'), enabled); notifyListeners(); } catch (e) { debugPrint('Error saving sensors enabled setting: $e'); @@ -586,7 +592,7 @@ class AppProvider with ChangeNotifier { try { final prefs = await SharedPreferences.getInstance(); _isVoiceSilenceTrimmingEnabled = - prefs.getBool('voice_silence_trimming_enabled') ?? true; + prefs.getBool(_scopedKey('voice_silence_trimming_enabled')) ?? true; notifyListeners(); } catch (e) { debugPrint('Error loading voice silence trimming setting: $e'); @@ -598,7 +604,10 @@ class AppProvider with ChangeNotifier { try { _isVoiceSilenceTrimmingEnabled = enabled; final prefs = await SharedPreferences.getInstance(); - await prefs.setBool('voice_silence_trimming_enabled', enabled); + await prefs.setBool( + _scopedKey('voice_silence_trimming_enabled'), + enabled, + ); notifyListeners(); } catch (e) { debugPrint('Error saving voice silence trimming setting: $e'); @@ -610,7 +619,7 @@ class AppProvider with ChangeNotifier { try { final prefs = await SharedPreferences.getInstance(); _isVoiceBandPassFilterEnabled = - prefs.getBool('voice_band_pass_filter_enabled') ?? true; + prefs.getBool(_scopedKey('voice_band_pass_filter_enabled')) ?? true; notifyListeners(); } catch (e) { debugPrint('Error loading voice band-pass filter setting: $e'); @@ -622,7 +631,10 @@ class AppProvider with ChangeNotifier { try { _isVoiceBandPassFilterEnabled = enabled; final prefs = await SharedPreferences.getInstance(); - await prefs.setBool('voice_band_pass_filter_enabled', enabled); + await prefs.setBool( + _scopedKey('voice_band_pass_filter_enabled'), + enabled, + ); notifyListeners(); } catch (e) { debugPrint('Error saving voice band-pass filter setting: $e'); @@ -634,7 +646,7 @@ class AppProvider with ChangeNotifier { try { final prefs = await SharedPreferences.getInstance(); _isVoiceCompressorEnabled = - prefs.getBool('voice_compressor_enabled') ?? true; + prefs.getBool(_scopedKey('voice_compressor_enabled')) ?? true; notifyListeners(); } catch (e) { debugPrint('Error loading voice compressor setting: $e'); @@ -646,7 +658,7 @@ class AppProvider with ChangeNotifier { try { _isVoiceCompressorEnabled = enabled; final prefs = await SharedPreferences.getInstance(); - await prefs.setBool('voice_compressor_enabled', enabled); + await prefs.setBool(_scopedKey('voice_compressor_enabled'), enabled); notifyListeners(); } catch (e) { debugPrint('Error saving voice compressor setting: $e'); @@ -657,7 +669,8 @@ class AppProvider with ChangeNotifier { Future _loadVoiceLimiterEnabled() async { try { final prefs = await SharedPreferences.getInstance(); - _isVoiceLimiterEnabled = prefs.getBool('voice_limiter_enabled') ?? true; + _isVoiceLimiterEnabled = + prefs.getBool(_scopedKey('voice_limiter_enabled')) ?? true; notifyListeners(); } catch (e) { debugPrint('Error loading voice limiter setting: $e'); @@ -669,7 +682,7 @@ class AppProvider with ChangeNotifier { try { _isVoiceLimiterEnabled = enabled; final prefs = await SharedPreferences.getInstance(); - await prefs.setBool('voice_limiter_enabled', enabled); + await prefs.setBool(_scopedKey('voice_limiter_enabled'), enabled); notifyListeners(); } catch (e) { debugPrint('Error saving voice limiter setting: $e'); @@ -680,7 +693,7 @@ class AppProvider with ChangeNotifier { try { final prefs = await SharedPreferences.getInstance(); _isVoiceAutoGainEnabled = - prefs.getBool('voice_auto_gain_enabled') ?? false; + prefs.getBool(_scopedKey('voice_auto_gain_enabled')) ?? false; notifyListeners(); } catch (e) { debugPrint('Error loading voice auto gain setting: $e'); @@ -691,7 +704,7 @@ class AppProvider with ChangeNotifier { try { _isVoiceAutoGainEnabled = enabled; final prefs = await SharedPreferences.getInstance(); - await prefs.setBool('voice_auto_gain_enabled', enabled); + await prefs.setBool(_scopedKey('voice_auto_gain_enabled'), enabled); notifyListeners(); } catch (e) { debugPrint('Error saving voice auto gain setting: $e'); @@ -702,7 +715,7 @@ class AppProvider with ChangeNotifier { try { final prefs = await SharedPreferences.getInstance(); _isVoiceEchoCancellationEnabled = - prefs.getBool('voice_echo_cancellation_enabled') ?? false; + prefs.getBool(_scopedKey('voice_echo_cancellation_enabled')) ?? false; notifyListeners(); } catch (e) { debugPrint('Error loading voice echo cancellation setting: $e'); @@ -713,7 +726,10 @@ class AppProvider with ChangeNotifier { try { _isVoiceEchoCancellationEnabled = enabled; final prefs = await SharedPreferences.getInstance(); - await prefs.setBool('voice_echo_cancellation_enabled', enabled); + await prefs.setBool( + _scopedKey('voice_echo_cancellation_enabled'), + enabled, + ); notifyListeners(); } catch (e) { debugPrint('Error saving voice echo cancellation setting: $e'); @@ -724,7 +740,7 @@ class AppProvider with ChangeNotifier { try { final prefs = await SharedPreferences.getInstance(); _isVoiceNoiseSuppressionEnabled = - prefs.getBool('voice_noise_suppression_enabled') ?? false; + prefs.getBool(_scopedKey('voice_noise_suppression_enabled')) ?? false; notifyListeners(); } catch (e) { debugPrint('Error loading voice noise suppression setting: $e'); @@ -735,7 +751,10 @@ class AppProvider with ChangeNotifier { try { _isVoiceNoiseSuppressionEnabled = enabled; final prefs = await SharedPreferences.getInstance(); - await prefs.setBool('voice_noise_suppression_enabled', enabled); + await prefs.setBool( + _scopedKey('voice_noise_suppression_enabled'), + enabled, + ); notifyListeners(); } catch (e) { debugPrint('Error saving voice noise suppression setting: $e'); @@ -745,10 +764,11 @@ class AppProvider with ChangeNotifier { Future _loadMessageFontScale() async { try { final prefs = await SharedPreferences.getInstance(); - _messageFontScale = (prefs.getDouble('message_font_scale') ?? 1.0).clamp( - 0.85, - 1.4, - ); + _messageFontScale = + (prefs.getDouble(_scopedKey('message_font_scale')) ?? 1.0).clamp( + 0.85, + 1.4, + ); notifyListeners(); } catch (e) { debugPrint('Error loading message font scale setting: $e'); @@ -759,7 +779,10 @@ class AppProvider with ChangeNotifier { try { _messageFontScale = scale.clamp(0.85, 1.4); final prefs = await SharedPreferences.getInstance(); - await prefs.setDouble('message_font_scale', _messageFontScale); + await prefs.setDouble( + _scopedKey('message_font_scale'), + _messageFontScale, + ); notifyListeners(); } catch (e) { debugPrint('Error saving message font scale setting: $e'); @@ -2600,6 +2623,24 @@ class AppProvider with ChangeNotifier { locationTrackingService.setFastLocationActiveUse(isActive); } + Future reloadProfileScopedSettings() async { + await Future.wait([ + _loadMapEnabled(), + _loadContactsEnabled(), + _loadSensorsEnabled(), + _loadVoiceSilenceTrimmingEnabled(), + _loadVoiceBandPassFilterEnabled(), + _loadVoiceCompressorEnabled(), + _loadVoiceLimiterEnabled(), + _loadVoiceAutoGainEnabled(), + _loadVoiceEchoCancellationEnabled(), + _loadVoiceNoiseSuppressionEnabled(), + _loadMessageFontScale(), + _loadMessagingRouteSettings(), + locationTrackingService.loadSettings(), + ]); + } + Future _sendFastLocationUpdate( dynamic position, { required String reason, diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index cb02ee2..9c58219 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -1325,6 +1325,31 @@ class ConnectionProvider with ChangeNotifier { } } + Future setChannelSlot({ + required int channelIdx, + required String channelName, + required Uint8List secret, + }) async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + await _activeService.setChannel( + channelIdx: channelIdx, + channelName: channelName, + secret: secret, + ); + await _activeService.getChannel(channelIdx); + } catch (e) { + _error = 'Failed to set channel: $e'; + notifyListeners(); + rethrow; + } + } + /// Generate secret for hash channel using SHA256 /// Same algorithm as Channel model for consistency /// Python equivalent: hashlib.sha256(channel_name.encode()).digest()[0:16] diff --git a/lib/providers/contacts_provider.dart b/lib/providers/contacts_provider.dart index 7e3835b..06d44ec 100644 --- a/lib/providers/contacts_provider.dart +++ b/lib/providers/contacts_provider.dart @@ -134,6 +134,7 @@ class ContactsProvider with ChangeNotifier { bool _persistRequested = false; bool _isPersistingPendingAdverts = false; bool _persistPendingAdvertsRequested = false; + String? _storageNamespace; // Add default public channel on initialization ContactsProvider() { @@ -141,19 +142,74 @@ class ContactsProvider with ChangeNotifier { } bool get isInitialized => _isInitialized; + String? get storageNamespace => _storageNamespace; /// Initialize and load persisted contacts at app startup /// This loads contacts without filtering, allowing offline viewing Future initializeEarly() async { if (_isInitialized) return; + await _loadFromStorage(); + } + + Future reloadFromStorage({ + String? namespace, + Uint8List? devicePublicKey, + }) async { + _storageNamespace = namespace; + await _loadFromStorage(force: true, devicePublicKey: devicePublicKey); + } + + Future persistNow() async { + await _storageService.saveContacts( + _contactsForStorage(), + namespace: _storageNamespace, + ); + await _storageService.saveContactGroups( + _savedContactGroups, + namespace: _storageNamespace, + ); + await _storageService.savePendingAdverts( + _pendingAdverts.values.map(_pendingAdvertToJson).toList(), + namespace: _storageNamespace, + ); + } + + Future cloneCurrentStorageTo(String? namespace) async { + await _storageService.saveContacts( + _contactsForStorage(), + namespace: namespace, + ); + await _storageService.saveContactGroups( + _savedContactGroups, + namespace: namespace, + ); + await _storageService.savePendingAdverts( + _pendingAdverts.values.map(_pendingAdvertToJson).toList(), + namespace: namespace, + ); + } + + Future _loadFromStorage({ + bool force = false, + Uint8List? devicePublicKey, + }) async { + if (_isInitialized && !force) return; + try { + _resetInMemoryState(); debugPrint( 'đŸ“Ļ [ContactsProvider] Early loading persisted contacts (no filtering)...', ); - final storedContacts = await _storageService.loadContacts(); - final storedGroups = await _storageService.loadContactGroups(); - final storedPendingAdverts = await _storageService.loadPendingAdverts(); + final storedContacts = await _storageService.loadContacts( + namespace: _storageNamespace, + ); + final storedGroups = await _storageService.loadContactGroups( + namespace: _storageNamespace, + ); + final storedPendingAdverts = await _storageService.loadPendingAdverts( + namespace: _storageNamespace, + ); // Add stored contacts (excluding any with all-zeros public key) const publicChannelKey = @@ -170,7 +226,10 @@ class ContactsProvider with ChangeNotifier { _savedContactGroups ..clear() ..addAll(storedGroups); - _restorePendingAdverts(storedPendingAdverts); + _restorePendingAdverts( + storedPendingAdverts, + devicePublicKey: devicePublicKey, + ); debugPrint( '✅ [ContactsProvider] Early loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups', ); @@ -197,45 +256,9 @@ class ContactsProvider with ChangeNotifier { return; } - try { - debugPrint('đŸ“Ļ [ContactsProvider] Loading persisted contacts...'); - final storedContacts = await _storageService.loadContacts( - excludePublicKey: devicePublicKey, - ); - final storedGroups = await _storageService.loadContactGroups(); - final storedPendingAdverts = await _storageService.loadPendingAdverts(); - - // Add stored contacts (excluding any with all-zeros public key) - const publicChannelKey = - '0000000000000000000000000000000000000000000000000000000000000000'; - for (final contact in storedContacts) { - // Skip any contacts with all-zeros public key (shouldn't happen, but safety check) - if (contact.publicKeyHex == publicChannelKey) { - continue; - } - _contacts[contact.publicKeyHex] = contact; - } - - _isInitialized = true; - _savedContactGroups - ..clear() - ..addAll(storedGroups); - _restorePendingAdverts( - storedPendingAdverts, - devicePublicKey: devicePublicKey, - ); - debugPrint( - '✅ [ContactsProvider] Loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups', - ); - - // Ensure public channel exists after loading - _ensurePublicChannelExists(); - - notifyListeners(); - } catch (e) { - debugPrint('❌ [ContactsProvider] Error initializing: $e'); - _isInitialized = true; // Mark as initialized even on error - _ensurePublicChannelExists(); + await _loadFromStorage(force: true, devicePublicKey: devicePublicKey); + if (devicePublicKey != null) { + _removeSelfContact(devicePublicKey); } } @@ -287,7 +310,10 @@ class ContactsProvider with ChangeNotifier { try { while (_persistRequested) { _persistRequested = false; - await _storageService.saveContacts(_contactsForStorage()); + await _storageService.saveContacts( + _contactsForStorage(), + namespace: _storageNamespace, + ); } } catch (e) { debugPrint('❌ [ContactsProvider] Error persisting contacts: $e'); @@ -305,6 +331,7 @@ class ContactsProvider with ChangeNotifier { _persistPendingAdvertsRequested = false; await _storageService.savePendingAdverts( _pendingAdverts.values.map(_pendingAdvertToJson).toList(), + namespace: _storageNamespace, ); } } catch (e) { @@ -436,7 +463,10 @@ class ContactsProvider with ChangeNotifier { Future _persistSavedGroups() async { try { - await _storageService.saveContactGroups(_savedContactGroups); + await _storageService.saveContactGroups( + _savedContactGroups, + namespace: _storageNamespace, + ); } catch (e) { debugPrint('❌ [ContactsProvider] Error persisting contact groups: $e'); } @@ -1442,11 +1472,7 @@ class ContactsProvider with ChangeNotifier { /// Get storage statistics Future> getStorageStats() async { - return await _storageService.getStorageStats(); - } - - Future persistNow() async { - await _storageService.saveContacts(_contactsForStorage()); + return await _storageService.getStorageStats(namespace: _storageNamespace); } Future clearPendingAdverts() async { @@ -1454,7 +1480,7 @@ class ContactsProvider with ChangeNotifier { return; } _pendingAdverts.clear(); - await _storageService.clearPendingAdverts(); + await _storageService.clearPendingAdverts(namespace: _storageNamespace); notifyListeners(); } @@ -1517,6 +1543,12 @@ class ContactsProvider with ChangeNotifier { } } + void _resetInMemoryState() { + _contacts.clear(); + _savedContactGroups.clear(); + _pendingAdverts.clear(); + } + Map _pendingAdvertToJson(PendingAdvert advert) { return { 'publicKey': base64Encode(advert.publicKey), diff --git a/lib/providers/drawing_provider.dart b/lib/providers/drawing_provider.dart index 4a40917..300c5ce 100644 --- a/lib/providers/drawing_provider.dart +++ b/lib/providers/drawing_provider.dart @@ -5,6 +5,7 @@ import 'package:latlong2/latlong.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/map_drawing.dart'; import '../models/map_coordinate_space.dart'; +import '../services/profiles_feature_service.dart'; import '../utils/drawing_message_parser.dart'; /// Drawing mode state @@ -85,6 +86,12 @@ class DrawingProvider with ChangeNotifier { _isInitialized = true; } + Future reloadProfileScopedState() async { + await _loadPreferences(); + await _loadDrawings(); + notifyListeners(); + } + void setMapContext({ required MapCoordinateSpace coordinateSpace, String? mapId, @@ -140,15 +147,19 @@ class DrawingProvider with ChangeNotifier { Future _loadPreferences() async { final prefs = await SharedPreferences.getInstance(); - _showReceivedDrawings = prefs.getBool(_showReceivedDrawingsKey) ?? true; - _showSarMarkers = prefs.getBool(_showSarMarkersKey) ?? true; + _showReceivedDrawings = + prefs.getBool(_scopedKey(_showReceivedDrawingsKey)) ?? true; + _showSarMarkers = prefs.getBool(_scopedKey(_showSarMarkersKey)) ?? true; notifyListeners(); } Future _savePreferences() async { final prefs = await SharedPreferences.getInstance(); - await prefs.setBool(_showReceivedDrawingsKey, _showReceivedDrawings); - await prefs.setBool(_showSarMarkersKey, _showSarMarkers); + await prefs.setBool( + _scopedKey(_showReceivedDrawingsKey), + _showReceivedDrawings, + ); + await prefs.setBool(_scopedKey(_showSarMarkersKey), _showSarMarkers); } /// Start drawing a line @@ -365,7 +376,7 @@ class DrawingProvider with ChangeNotifier { final prefs = await SharedPreferences.getInstance(); final jsonList = _drawings.map((d) => d.toJson()).toList(); final jsonString = jsonEncode(jsonList); - await prefs.setString(_storageKey, jsonString); + await prefs.setString(_scopedKey(_storageKey), jsonString); } catch (e) { debugPrint('Error saving drawings: $e'); } @@ -375,8 +386,11 @@ class DrawingProvider with ChangeNotifier { Future _loadDrawings() async { try { final prefs = await SharedPreferences.getInstance(); - final jsonString = prefs.getString(_storageKey); - if (jsonString == null) return; + final jsonString = prefs.getString(_scopedKey(_storageKey)); + if (jsonString == null || jsonString.isEmpty) { + _drawings.clear(); + return; + } final jsonList = jsonDecode(jsonString) as List; _drawings.clear(); @@ -475,6 +489,24 @@ class DrawingProvider with ChangeNotifier { } } + List> exportDrawingsJson() { + return _drawings.map((drawing) => drawing.toJson()).toList(); + } + + Future replaceDrawingsFromJson( + List> jsonList, + ) async { + _drawings + ..clear() + ..addAll(jsonList.map(MapDrawing.fromJson).whereType()); + await _saveDrawings(); + notifyListeners(); + } + + String _scopedKey(String baseKey) { + return ProfileStorageScope.scopedKey(baseKey); + } + /// Get all unshared drawings (local drawings not yet sent) List getUnsharedDrawings() { return drawings.where((d) => !d.isShared && !d.isReceived).toList(); diff --git a/lib/providers/map_provider.dart b/lib/providers/map_provider.dart index e6c15ab..91b3ffa 100644 --- a/lib/providers/map_provider.dart +++ b/lib/providers/map_provider.dart @@ -16,6 +16,7 @@ import '../models/location_trail.dart'; import '../models/map_coordinate_space.dart'; import '../models/map_drawing.dart'; import '../models/sar_marker.dart'; +import '../services/profiles_feature_service.dart'; import '../utils/custom_map_id.dart'; class MapProvider with ChangeNotifier { @@ -105,6 +106,10 @@ class MapProvider with ChangeNotifier { LatLngBounds? get customMapBounds => _customMapConfig?.bounds; + Future reloadProfileScopedState() async { + await _loadInitialState(); + } + bool matchesActiveCustomMap(String? mapId) { return hasCustomMap && normalizeCustomMapId(_customMapConfig!.mapId) == @@ -522,27 +527,29 @@ class MapProvider with ChangeNotifier { Future loadOverlayState() async { final prefs = await SharedPreferences.getInstance(); _showCadastralOverlay = - prefs.getBool('map_show_cadastral_overlay') ?? false; + prefs.getBool(_scopedKey('map_show_cadastral_overlay')) ?? false; _showForestRoadsOverlay = - prefs.getBool('map_show_forest_roads_overlay') ?? false; + prefs.getBool(_scopedKey('map_show_forest_roads_overlay')) ?? false; _showHikingTrailsOverlay = - prefs.getBool('map_show_hiking_trails_overlay') ?? false; + prefs.getBool(_scopedKey('map_show_hiking_trails_overlay')) ?? false; _showMainRoadsOverlay = - prefs.getBool('map_show_main_roads_overlay') ?? false; + prefs.getBool(_scopedKey('map_show_main_roads_overlay')) ?? false; _showHouseNumbersOverlay = - prefs.getBool('map_show_house_numbers_overlay') ?? false; + prefs.getBool(_scopedKey('map_show_house_numbers_overlay')) ?? false; _showFireHazardZonesOverlay = - prefs.getBool('map_show_fire_hazard_zones_overlay') ?? false; + prefs.getBool(_scopedKey('map_show_fire_hazard_zones_overlay')) ?? + false; _showHistoricalFiresOverlay = - prefs.getBool('map_show_historical_fires_overlay') ?? false; + prefs.getBool(_scopedKey('map_show_historical_fires_overlay')) ?? false; _showFirebreaksOverlay = - prefs.getBool('map_show_firebreaks_overlay') ?? false; + prefs.getBool(_scopedKey('map_show_firebreaks_overlay')) ?? false; _showKrasFireZonesOverlay = - prefs.getBool('map_show_kras_fire_zones_overlay') ?? false; + prefs.getBool(_scopedKey('map_show_kras_fire_zones_overlay')) ?? false; _showPlaceNamesOverlay = - prefs.getBool('map_show_place_names_overlay') ?? false; + prefs.getBool(_scopedKey('map_show_place_names_overlay')) ?? false; _showMunicipalityBordersOverlay = - prefs.getBool('map_show_municipality_borders_overlay') ?? false; + prefs.getBool(_scopedKey('map_show_municipality_borders_overlay')) ?? + false; notifyListeners(); } @@ -557,36 +564,48 @@ class MapProvider with ChangeNotifier { Future _saveOverlayState() async { final prefs = await SharedPreferences.getInstance(); - await prefs.setBool('map_show_cadastral_overlay', _showCadastralOverlay); await prefs.setBool( - 'map_show_forest_roads_overlay', + _scopedKey('map_show_cadastral_overlay'), + _showCadastralOverlay, + ); + await prefs.setBool( + _scopedKey('map_show_forest_roads_overlay'), _showForestRoadsOverlay, ); await prefs.setBool( - 'map_show_hiking_trails_overlay', + _scopedKey('map_show_hiking_trails_overlay'), _showHikingTrailsOverlay, ); - await prefs.setBool('map_show_main_roads_overlay', _showMainRoadsOverlay); await prefs.setBool( - 'map_show_house_numbers_overlay', + _scopedKey('map_show_main_roads_overlay'), + _showMainRoadsOverlay, + ); + await prefs.setBool( + _scopedKey('map_show_house_numbers_overlay'), _showHouseNumbersOverlay, ); await prefs.setBool( - 'map_show_fire_hazard_zones_overlay', + _scopedKey('map_show_fire_hazard_zones_overlay'), _showFireHazardZonesOverlay, ); await prefs.setBool( - 'map_show_historical_fires_overlay', + _scopedKey('map_show_historical_fires_overlay'), _showHistoricalFiresOverlay, ); - await prefs.setBool('map_show_firebreaks_overlay', _showFirebreaksOverlay); await prefs.setBool( - 'map_show_kras_fire_zones_overlay', + _scopedKey('map_show_firebreaks_overlay'), + _showFirebreaksOverlay, + ); + await prefs.setBool( + _scopedKey('map_show_kras_fire_zones_overlay'), _showKrasFireZonesOverlay, ); - await prefs.setBool('map_show_place_names_overlay', _showPlaceNamesOverlay); await prefs.setBool( - 'map_show_municipality_borders_overlay', + _scopedKey('map_show_place_names_overlay'), + _showPlaceNamesOverlay, + ); + await prefs.setBool( + _scopedKey('map_show_municipality_borders_overlay'), _showMunicipalityBordersOverlay, ); } @@ -600,13 +619,16 @@ class MapProvider with ChangeNotifier { Future loadTrailSettings() async { final prefs = await SharedPreferences.getInstance(); _showAllContactTrails = - prefs.getBool('map_show_all_contact_trails') ?? true; + prefs.getBool(_scopedKey('map_show_all_contact_trails')) ?? true; notifyListeners(); } Future _saveTrailSettings() async { final prefs = await SharedPreferences.getInstance(); - await prefs.setBool('map_show_all_contact_trails', _showAllContactTrails); + await prefs.setBool( + _scopedKey('map_show_all_contact_trails'), + _showAllContactTrails, + ); } Future setHideRepeatersOnMap(bool hide) async { @@ -614,12 +636,13 @@ class MapProvider with ChangeNotifier { _hideRepeatersOnMap = hide; notifyListeners(); final prefs = await SharedPreferences.getInstance(); - await prefs.setBool('map_hide_repeaters', _hideRepeatersOnMap); + await prefs.setBool(_scopedKey('map_hide_repeaters'), _hideRepeatersOnMap); } Future loadRepeaterVisibilitySettings() async { final prefs = await SharedPreferences.getInstance(); - _hideRepeatersOnMap = prefs.getBool('map_hide_repeaters') ?? false; + _hideRepeatersOnMap = + prefs.getBool(_scopedKey('map_hide_repeaters')) ?? false; notifyListeners(); } @@ -661,7 +684,7 @@ class MapProvider with ChangeNotifier { Future _loadCustomMapState() async { final prefs = await SharedPreferences.getInstance(); - final configJson = prefs.getString(_customMapConfigKey); + final configJson = prefs.getString(_scopedKey(_customMapConfigKey)); if (configJson != null && configJson.isNotEmpty) { final decoded = jsonDecode(configJson); if (decoded is Map) { @@ -672,23 +695,26 @@ class MapProvider with ChangeNotifier { _customMapConfig = null; } } + } else { + _customMapConfig = null; } _isUsingCustomMap = - (prefs.getBool(_customMapModeKey) ?? false) && _customMapConfig != null; + (prefs.getBool(_scopedKey(_customMapModeKey)) ?? false) && + _customMapConfig != null; notifyListeners(); } Future _saveCustomMapState() async { final prefs = await SharedPreferences.getInstance(); if (_customMapConfig == null) { - await prefs.remove(_customMapConfigKey); + await prefs.remove(_scopedKey(_customMapConfigKey)); } else { await prefs.setString( - _customMapConfigKey, + _scopedKey(_customMapConfigKey), jsonEncode(_customMapConfig!.toJson()), ); } - await prefs.setBool(_customMapModeKey, _isUsingCustomMap); + await prefs.setBool(_scopedKey(_customMapModeKey), _isUsingCustomMap); } Future<(int, int)> _decodeImageSize(Uint8List bytes) async { @@ -711,4 +737,67 @@ class MapProvider with ChangeNotifier { await file.delete(); } } + + Map exportWorkspaceJson() { + return { + 'currentTrail': _currentTrail?.toJson(), + 'trailHistory': _trailHistory.map((trail) => trail.toJson()).toList(), + 'importedTrail': _importedTrail?.toJson(), + 'isTrailVisible': _isTrailVisible, + 'showCadastralOverlay': _showCadastralOverlay, + 'showForestRoadsOverlay': _showForestRoadsOverlay, + 'showHikingTrailsOverlay': _showHikingTrailsOverlay, + 'showMainRoadsOverlay': _showMainRoadsOverlay, + 'showHouseNumbersOverlay': _showHouseNumbersOverlay, + 'showFireHazardZonesOverlay': _showFireHazardZonesOverlay, + 'showHistoricalFiresOverlay': _showHistoricalFiresOverlay, + 'showFirebreaksOverlay': _showFirebreaksOverlay, + 'showKrasFireZonesOverlay': _showKrasFireZonesOverlay, + 'showPlaceNamesOverlay': _showPlaceNamesOverlay, + 'showMunicipalityBordersOverlay': _showMunicipalityBordersOverlay, + 'showAllContactTrails': _showAllContactTrails, + 'hideRepeatersOnMap': _hideRepeatersOnMap, + }; + } + + void applyWorkspaceJson(Map json) { + _currentTrail = json['currentTrail'] is Map + ? LocationTrail.fromJson(json['currentTrail'] as Map) + : null; + _trailHistory + ..clear() + ..addAll( + (json['trailHistory'] as List? ?? const []) + .whereType>() + .map(LocationTrail.fromJson), + ); + _importedTrail = json['importedTrail'] is Map + ? LocationTrail.fromJson(json['importedTrail'] as Map) + : null; + _isTrailVisible = json['isTrailVisible'] as bool? ?? true; + _showCadastralOverlay = json['showCadastralOverlay'] as bool? ?? false; + _showForestRoadsOverlay = json['showForestRoadsOverlay'] as bool? ?? false; + _showHikingTrailsOverlay = + json['showHikingTrailsOverlay'] as bool? ?? false; + _showMainRoadsOverlay = json['showMainRoadsOverlay'] as bool? ?? false; + _showHouseNumbersOverlay = + json['showHouseNumbersOverlay'] as bool? ?? false; + _showFireHazardZonesOverlay = + json['showFireHazardZonesOverlay'] as bool? ?? false; + _showHistoricalFiresOverlay = + json['showHistoricalFiresOverlay'] as bool? ?? false; + _showFirebreaksOverlay = json['showFirebreaksOverlay'] as bool? ?? false; + _showKrasFireZonesOverlay = + json['showKrasFireZonesOverlay'] as bool? ?? false; + _showPlaceNamesOverlay = json['showPlaceNamesOverlay'] as bool? ?? false; + _showMunicipalityBordersOverlay = + json['showMunicipalityBordersOverlay'] as bool? ?? false; + _showAllContactTrails = json['showAllContactTrails'] as bool? ?? true; + _hideRepeatersOnMap = json['hideRepeatersOnMap'] as bool? ?? false; + notifyListeners(); + } + + String _scopedKey(String baseKey) { + return ProfileStorageScope.scopedKey(baseKey); + } } diff --git a/lib/providers/messages_provider.dart b/lib/providers/messages_provider.dart index ad6e6ea..e7a86ef 100644 --- a/lib/providers/messages_provider.dart +++ b/lib/providers/messages_provider.dart @@ -37,6 +37,7 @@ class MessagesProvider with ChangeNotifier { final Map _messageReceptionDetails = {}; final Map _messageTransferDetails = {}; final Map _messageRouteMetadata = {}; + String? _storageNamespace; // Track pending sent messages by expected ACK/TAG final Map _pendingSentMessages = {}; @@ -149,6 +150,7 @@ class MessagesProvider with ChangeNotifier { sarMarkers.where((m) => m.type == SarMarkerType.object).toList(); bool get isInitialized => _isInitialized; + String? get storageNamespace => _storageNamespace; String? get targetMessageId => _targetMessageId; @@ -311,19 +313,64 @@ class MessagesProvider with ChangeNotifier { Future initialize() async { if (_isInitialized) return; + await _loadFromStorage(); + } + + Future reloadFromStorage({String? namespace}) async { + _storageNamespace = namespace; + await _loadFromStorage(force: true); + } + + Future persistNow() async { + await _storageService.saveMessages( + _messages, + messageContactLocations: _messageContactLocations, + messageReceptionDetails: _messageReceptionDetails, + messageTransferDetails: _messageTransferDetails, + messageRouteMetadata: _messageRouteMetadata, + namespace: _storageNamespace, + ); + await _storageService.saveRemovedSarMarkerIds( + _removedSarMarkerIds, + namespace: _storageNamespace, + ); + } + + Future cloneCurrentStorageTo(String? namespace) async { + await _storageService.saveMessages( + _messages, + messageContactLocations: _messageContactLocations, + messageReceptionDetails: _messageReceptionDetails, + messageTransferDetails: _messageTransferDetails, + messageRouteMetadata: _messageRouteMetadata, + namespace: namespace, + ); + await _storageService.saveRemovedSarMarkerIds( + _removedSarMarkerIds, + namespace: namespace, + ); + } + + Future _loadFromStorage({bool force = false}) async { + if (_isInitialized && !force) return; + try { + _cancelAllTimers(); + _resetInMemoryState(); debugPrint('đŸ“Ļ [MessagesProvider] Loading persisted messages...'); - final storedMessages = await _storageService.loadMessages(); + final storedMessages = await _storageService.loadMessages( + namespace: _storageNamespace, + ); final storedContactLocations = await _storageService - .loadMessageContactLocations(); + .loadMessageContactLocations(namespace: _storageNamespace); final storedReceptionDetails = await _storageService - .loadMessageReceptionDetails(); + .loadMessageReceptionDetails(namespace: _storageNamespace); final storedTransferDetails = await _storageService - .loadMessageTransferDetails(); + .loadMessageTransferDetails(namespace: _storageNamespace); final storedRouteMetadata = await _storageService - .loadMessageRouteMetadata(); + .loadMessageRouteMetadata(namespace: _storageNamespace); final storedRemovedSarMarkerIds = await _storageService - .loadRemovedSarMarkerIds(); + .loadRemovedSarMarkerIds(namespace: _storageNamespace); _messageContactLocations ..clear() ..addAll(storedContactLocations); @@ -1051,6 +1098,7 @@ class MessagesProvider with ChangeNotifier { messageReceptionDetails: _messageReceptionDetails, messageTransferDetails: _messageTransferDetails, messageRouteMetadata: _messageRouteMetadata, + namespace: _storageNamespace, ); } } catch (e) { @@ -1199,7 +1247,10 @@ class MessagesProvider with ChangeNotifier { Future removeSarMarker(String id) async { _sarMarkers.remove(id); _removedSarMarkerIds.add(id); - await _storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds); + await _storageService.saveRemovedSarMarkerIds( + _removedSarMarkerIds, + namespace: _storageNamespace, + ); notifyListeners(); } @@ -1324,7 +1375,12 @@ class MessagesProvider with ChangeNotifier { debugPrint('đŸ—‘ī¸ [MessagesProvider] Message $messageId deleted'); _persistMessages(); - unawaited(_storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds)); + unawaited( + _storageService.saveRemovedSarMarkerIds( + _removedSarMarkerIds, + namespace: _storageNamespace, + ), + ); notifyListeners(); } } @@ -1348,23 +1404,15 @@ class MessagesProvider with ChangeNotifier { /// Clear all messages void clearMessages() { _cancelAllTimers(); - _messages.clear(); - _sarMarkers.clear(); - _removedSarMarkerIds.clear(); - _messageContactLocations.clear(); - _messageReceptionDetails.clear(); - _messageTransferDetails.clear(); - _messageRouteMetadata.clear(); - _pendingSentMessages.clear(); - _messageContactMap.clear(); - _groupedMessageMapping.clear(); - _ackTagToRecipients.clear(); - _messageAckHistory.clear(); - _ackHistoryLookup.clear(); - _completedAckHistory.clear(); + _resetInMemoryState(); _retryManager.clearAll(); _persistMessages(); - unawaited(_storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds)); + unawaited( + _storageService.saveRemovedSarMarkerIds( + _removedSarMarkerIds, + namespace: _storageNamespace, + ), + ); notifyListeners(); } @@ -1372,13 +1420,29 @@ class MessagesProvider with ChangeNotifier { Future clearSarMarkers() async { _removedSarMarkerIds.addAll(_sarMarkers.keys); _sarMarkers.clear(); - await _storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds); + await _storageService.saveRemovedSarMarkerIds( + _removedSarMarkerIds, + namespace: _storageNamespace, + ); notifyListeners(); } /// Clear all data void clearAll() { _cancelAllTimers(); + _resetInMemoryState(); + _retryManager.clearAll(); + _persistMessages(); + unawaited( + _storageService.saveRemovedSarMarkerIds( + _removedSarMarkerIds, + namespace: _storageNamespace, + ), + ); + notifyListeners(); + } + + void _resetInMemoryState() { _messages.clear(); _sarMarkers.clear(); _removedSarMarkerIds.clear(); @@ -1393,10 +1457,6 @@ class MessagesProvider with ChangeNotifier { _messageAckHistory.clear(); _ackHistoryLookup.clear(); _completedAckHistory.clear(); - _retryManager.clearAll(); - _persistMessages(); - unawaited(_storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds)); - notifyListeners(); } int transferCountForSession({ diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index b37736d..dbd67ec 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -20,6 +20,7 @@ import 'settings_screen.dart'; import 'device_config_screen.dart'; import 'packet_log_screen.dart'; import 'live_traffic_screen.dart'; +import 'profiles_screen.dart'; import 'spectrum_scan_screen.dart'; import '../utils/toast_logger.dart'; import '../l10n/app_localizations.dart'; @@ -28,6 +29,8 @@ import '../widgets/connection_dialog.dart'; import '../utils/battery_display_helper.dart'; import '../services/developer_mode_service.dart'; import '../services/mesh_map_nodes_service.dart'; +import '../services/profile_manager.dart'; +import '../services/profiles_feature_service.dart'; enum _HomeTab { messages, contacts, sensors, map } @@ -237,7 +240,11 @@ class _HomeScreenState extends State final prefs = await SharedPreferences.getInstance(); if (mounted) { setState(() { - _showRxTxIndicators = prefs.getBool('show_rx_tx_indicators') ?? true; + _showRxTxIndicators = + prefs.getBool( + ProfileStorageScope.scopedKey('show_rx_tx_indicators'), + ) ?? + true; }); } } @@ -626,6 +633,9 @@ class _HomeScreenState extends State icon: const Icon(Icons.more_vert), itemBuilder: (context) { final items = >[]; + final profilesEnabled = context + .read() + .profilesEnabled; if (_isDeveloperModeEnabled) { items.add( @@ -776,6 +786,31 @@ class _HomeScreenState extends State ), ); + if (profilesEnabled) { + items.add( + PopupMenuItem( + child: const Row( + children: [ + Icon(Icons.layers_outlined), + SizedBox(width: 8), + Text('Profiles'), + ], + ), + onTap: () { + final navigator = Navigator.of(context); + Future.delayed(Duration.zero, () { + if (!mounted) return; + navigator.push( + MaterialPageRoute( + builder: (context) => const ProfilesScreen(), + ), + ); + }); + }, + ), + ); + } + return items; }, ), diff --git a/lib/screens/map_tab.dart b/lib/screens/map_tab.dart index fa7da15..9a3d876 100644 --- a/lib/screens/map_tab.dart +++ b/lib/screens/map_tab.dart @@ -28,6 +28,7 @@ import '../services/location_tracking_service.dart'; import '../services/map_marker_service.dart'; import '../services/message_destination_preferences.dart'; import '../services/trail_color_service.dart'; +import '../services/profiles_feature_service.dart'; import '../widgets/map_debug_info.dart'; import '../widgets/map/compass_widget.dart'; import '../widgets/map/detailed_compass_dialog.dart'; @@ -244,25 +245,49 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { final prefs = await SharedPreferences.getInstance(); if (mounted) { // Load last map position if available - final lastLat = prefs.getDouble('map_last_latitude'); - final lastLon = prefs.getDouble('map_last_longitude'); - final lastZoom = prefs.getDouble('map_last_zoom'); + final lastLat = prefs.getDouble( + ProfileStorageScope.scopedKey('map_last_latitude'), + ); + final lastLon = prefs.getDouble( + ProfileStorageScope.scopedKey('map_last_longitude'), + ); + final lastZoom = prefs.getDouble( + ProfileStorageScope.scopedKey('map_last_zoom'), + ); // Load last map layer - final lastLayerType = prefs.getInt('map_last_layer_type'); + final lastLayerType = prefs.getInt( + ProfileStorageScope.scopedKey('map_last_layer_type'), + ); setState(() { _rotateMarkerWithHeading = - prefs.getBool('map_rotate_with_heading') ?? false; - _showMapDebugInfo = prefs.getBool('map_show_debug_info') ?? false; - _isFullscreen = prefs.getBool('map_fullscreen') ?? false; + prefs.getBool( + ProfileStorageScope.scopedKey('map_rotate_with_heading'), + ) ?? + false; + _showMapDebugInfo = + prefs.getBool( + ProfileStorageScope.scopedKey('map_show_debug_info'), + ) ?? + false; + _isFullscreen = + prefs.getBool(ProfileStorageScope.scopedKey('map_fullscreen')) ?? + false; // Notify parent about initial fullscreen state WidgetsBinding.instance.addPostFrameCallback((_) { widget.onFullscreenChanged?.call(_isFullscreen); }); - _gpsUpdateDistance = prefs.getDouble('map_gps_update_distance') ?? 3.0; + _gpsUpdateDistance = + prefs.getDouble( + ProfileStorageScope.scopedKey('map_gps_update_distance'), + ) ?? + 3.0; _backgroundTrackingEnabled = - prefs.getBool('background_tracking_enabled') ?? false; + prefs.getBool( + ProfileStorageScope.scopedKey('background_tracking_enabled'), + ) ?? + false; // Store saved position for use in build if (lastLat != null && lastLon != null && lastZoom != null) { @@ -298,18 +323,33 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { Future _saveSettings() async { final prefs = await SharedPreferences.getInstance(); - await prefs.setBool('map_rotate_with_heading', _rotateMarkerWithHeading); - await prefs.setBool('map_show_debug_info', _showMapDebugInfo); - await prefs.setBool('map_fullscreen', _isFullscreen); - await prefs.setDouble('map_gps_update_distance', _gpsUpdateDistance); await prefs.setBool( - 'background_tracking_enabled', + ProfileStorageScope.scopedKey('map_rotate_with_heading'), + _rotateMarkerWithHeading, + ); + await prefs.setBool( + ProfileStorageScope.scopedKey('map_show_debug_info'), + _showMapDebugInfo, + ); + await prefs.setBool( + ProfileStorageScope.scopedKey('map_fullscreen'), + _isFullscreen, + ); + await prefs.setDouble( + ProfileStorageScope.scopedKey('map_gps_update_distance'), + _gpsUpdateDistance, + ); + await prefs.setBool( + ProfileStorageScope.scopedKey('background_tracking_enabled'), _backgroundTrackingEnabled, ); // Save layer type. - await prefs.setInt('map_last_layer_type', _currentLayer.type.index); - await prefs.remove('map_last_layer_name'); + await prefs.setInt( + ProfileStorageScope.scopedKey('map_last_layer_type'), + _currentLayer.type.index, + ); + await prefs.remove(ProfileStorageScope.scopedKey('map_last_layer_name')); } Future _saveMapPosition() async { @@ -318,9 +358,18 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { try { final prefs = await SharedPreferences.getInstance(); final camera = _mapController.camera; - await prefs.setDouble('map_last_latitude', camera.center.latitude); - await prefs.setDouble('map_last_longitude', camera.center.longitude); - await prefs.setDouble('map_last_zoom', camera.zoom); + await prefs.setDouble( + ProfileStorageScope.scopedKey('map_last_latitude'), + camera.center.latitude, + ); + await prefs.setDouble( + ProfileStorageScope.scopedKey('map_last_longitude'), + camera.center.longitude, + ); + await prefs.setDouble( + ProfileStorageScope.scopedKey('map_last_zoom'), + camera.zoom, + ); } catch (e) { debugPrint('Error saving map position: $e'); } diff --git a/lib/screens/profiles_screen.dart b/lib/screens/profiles_screen.dart new file mode 100644 index 0000000..bcc628b --- /dev/null +++ b/lib/screens/profiles_screen.dart @@ -0,0 +1,258 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../models/config_profile.dart'; +import '../services/profile_manager.dart'; +import '../services/profile_workspace_coordinator.dart'; + +class ProfilesScreen extends StatelessWidget { + const ProfilesScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, profileManager, child) { + final profiles = profileManager.visibleProfiles; + return Scaffold( + appBar: AppBar( + title: const Text('Profiles'), + actions: [ + IconButton( + onPressed: () async { + await context + .read() + .importProfileFromFile(); + }, + icon: const Icon(Icons.file_open), + tooltip: 'Import profile', + ), + ], + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => _createProfile(context), + icon: const Icon(Icons.add), + label: const Text('New Profile'), + ), + body: profiles.isEmpty + ? const Center( + child: Text('Enable profiles to start managing them.'), + ) + : ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: profiles.length, + itemBuilder: (context, index) { + final profile = profiles[index]; + final isActive = + profileManager.activeProfileId == profile.id; + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + profile.name, + style: Theme.of( + context, + ).textTheme.titleMedium, + ), + ), + if (isActive) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(999), + ), + child: const Text('Active'), + ), + ], + ), + const SizedBox(height: 8), + Text(_summary(profile)), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + OutlinedButton( + onPressed: () async { + await context + .read() + .openProfile(profile.id); + }, + child: const Text('Open'), + ), + FilledButton( + onPressed: () async { + await context + .read() + .applyProfile(profile.id); + }, + child: const Text('Apply'), + ), + OutlinedButton( + onPressed: () async { + final resolved = + profile.id == + ConfigProfile.defaultProfileId + ? await context + .read< + ProfileWorkspaceCoordinator + >() + .snapshotCurrentProfile( + id: profile.id, + name: profile.name, + ) + : profile; + if (!context.mounted) return; + await context + .read() + .exportProfile(resolved); + }, + child: const Text('Share'), + ), + PopupMenuButton( + onSelected: (value) async { + switch (value) { + case 'duplicate': + await context + .read() + .duplicateProfile(profile); + break; + case 'rename': + await _renameProfile(context, profile); + break; + case 'delete': + await context + .read() + .deleteProfile(profile); + break; + } + }, + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'duplicate', + child: Text('Duplicate'), + ), + if (!profile.isDefault) + const PopupMenuItem( + value: 'rename', + child: Text('Rename'), + ), + if (!profile.isDefault) + const PopupMenuItem( + value: 'delete', + child: Text('Delete'), + ), + ], + ), + ], + ), + ], + ), + ), + ); + }, + ), + ); + }, + ); + } + + String _summary(ConfigProfile profile) { + if (profile.isDefault) { + return 'Current app state and history.'; + } + + final sections = []; + if (profile.sections.deviceConfig?.isEmpty == false) { + sections.add('Device'); + } + if (profile.sections.channels.isNotEmpty) { + sections.add('${profile.sections.channels.length} channels'); + } + if (profile.sections.appSettings?.isEmpty == false) { + sections.add('App settings'); + } + if (profile.sections.mapWorkspace?.isEmpty == false) { + sections.add('Map workspace'); + } + return sections.isEmpty ? 'Empty profile' : sections.join(' | '); + } + + Future _createProfile(BuildContext context) async { + final controller = TextEditingController(); + final name = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Create Profile'), + content: TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration(labelText: 'Profile name'), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(controller.text.trim()), + child: const Text('Create'), + ), + ], + ), + ); + if (name == null || name.isEmpty || !context.mounted) { + return; + } + await context.read().createProfileFromCurrent( + name: name, + ); + } + + Future _renameProfile( + BuildContext context, + ConfigProfile profile, + ) async { + final controller = TextEditingController(text: profile.name); + final name = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Rename Profile'), + content: TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration(labelText: 'Profile name'), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(controller.text.trim()), + child: const Text('Save'), + ), + ], + ), + ); + if (name == null || name.isEmpty || !context.mounted) { + return; + } + await context.read().renameProfile( + profile, + name, + ); + } +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index cbb3fd2..ca0f821 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -16,6 +16,7 @@ import '../providers/app_provider.dart'; import '../providers/connection_provider.dart'; import '../providers/drawing_provider.dart'; import '../providers/map_provider.dart'; +import '../models/config_profile.dart'; import '../services/location_tracking_service.dart'; import '../services/locale_preferences.dart'; import '../services/mesh_map_nodes_service.dart'; @@ -26,6 +27,9 @@ import '../services/route_hash_preferences.dart'; import '../services/image_codec_service.dart'; import '../services/developer_mode_service.dart'; import '../services/notification_service.dart'; +import '../services/profile_manager.dart'; +import '../services/profile_workspace_coordinator.dart'; +import '../services/profiles_feature_service.dart'; import '../utils/sample_data_generator.dart'; import '../utils/image_message_parser.dart'; import '../utils/voice_message_parser.dart'; @@ -33,6 +37,7 @@ import '../theme/app_theme.dart'; import '../l10n/app_localizations.dart'; import '../widgets/update_dialog.dart'; import 'sar_template_management_screen.dart'; +import 'profiles_screen.dart'; import 'welcome_wizard_screen.dart'; class SettingsScreen extends StatefulWidget { @@ -83,6 +88,7 @@ class _SettingsScreenState extends State { bool _updateNotificationsEnabled = true; bool _muteForegroundNotifications = true; bool _isDeveloperModeEnabled = false; + bool _profilesEnabled = false; DateTime? _onlineTraceCacheUpdatedAt; bool _isClearingOnlineTraceCache = false; int _versionTapCount = 0; @@ -102,6 +108,7 @@ class _SettingsScreenState extends State { _loadImagePreferences(); _loadFastLocationSettings(); _loadDeveloperMode(); + _loadProfilesEnabled(); _loadOnlineTraceCacheStatus(); _loadMapPreferences(); _loadNotificationPreferences(); @@ -129,7 +136,11 @@ class _SettingsScreenState extends State { final prefs = await SharedPreferences.getInstance(); if (mounted) { setState(() { - _showRxTxIndicators = prefs.getBool('show_rx_tx_indicators') ?? true; + _showRxTxIndicators = + prefs.getBool( + ProfileStorageScope.scopedKey('show_rx_tx_indicators'), + ) ?? + true; }); } } @@ -142,6 +153,14 @@ class _SettingsScreenState extends State { }); } + Future _loadProfilesEnabled() async { + final isEnabled = await ProfilesFeatureService.isEnabled(); + if (!mounted) return; + setState(() { + _profilesEnabled = isEnabled; + }); + } + Future _loadNotificationPreferences() async { final service = NotificationService(); await service.initialize(); @@ -199,22 +218,33 @@ class _SettingsScreenState extends State { Future _saveRxTxPreference(bool value) async { final prefs = await SharedPreferences.getInstance(); - await prefs.setBool('show_rx_tx_indicators', value); + await prefs.setBool( + ProfileStorageScope.scopedKey('show_rx_tx_indicators'), + value, + ); } Future _loadMapPreferences() async { final prefs = await SharedPreferences.getInstance(); if (!mounted) return; setState(() { - _rotateMapWithHeading = prefs.getBool('map_rotate_with_heading') ?? false; - _showMapDebugInfo = prefs.getBool('map_show_debug_info') ?? false; - _openMapInFullscreen = prefs.getBool('map_fullscreen') ?? false; + _rotateMapWithHeading = + prefs.getBool( + ProfileStorageScope.scopedKey('map_rotate_with_heading'), + ) ?? + false; + _showMapDebugInfo = + prefs.getBool(ProfileStorageScope.scopedKey('map_show_debug_info')) ?? + false; + _openMapInFullscreen = + prefs.getBool(ProfileStorageScope.scopedKey('map_fullscreen')) ?? + false; }); } Future _saveMapPreference(String key, bool value) async { final prefs = await SharedPreferences.getInstance(); - await prefs.setBool(key, value); + await prefs.setBool(ProfileStorageScope.scopedKey(key), value); } Future _loadVoicePreferences() async { @@ -487,7 +517,10 @@ class _SettingsScreenState extends State { // Load settings and restore tracking state final prefs = await SharedPreferences.getInstance(); final wasTracking = - prefs.getBool('background_tracking_enabled') ?? false; + prefs.getBool( + ProfileStorageScope.scopedKey('background_tracking_enabled'), + ) ?? + false; if (wasTracking) { await _startBackgroundTracking(); @@ -1518,6 +1551,46 @@ class _SettingsScreenState extends State { builder: (context, connectionProvider, child) => _buildImageModePreviewCard(connectionProvider), ), + _buildSectionHeader('Profiles'), + _buildSettingsCard([ + SwitchListTile( + secondary: const Icon(Icons.layers_outlined), + title: const Text('Enable Profiles'), + subtitle: const Text( + 'Show profile management UI while keeping the hidden Default profile as the current workspace.', + ), + value: _profilesEnabled, + onChanged: (value) async { + await context + .read() + .setProfilesEnabled(value); + if (!mounted) return; + setState(() { + _profilesEnabled = value; + }); + }, + ), + if (_profilesEnabled) + ListTile( + leading: const Icon(Icons.folder_copy_outlined), + title: const Text('Manage profiles'), + subtitle: Text( + context.watch().activeProfileId == + ConfigProfile.defaultProfileId + ? 'Default is active' + : 'Custom profile active', + ), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const ProfilesScreen(), + ), + ); + }, + ), + ]), _buildSectionHeader('Templates & Help'), _buildSettingsCard([ ListTile( diff --git a/lib/services/app_config_snapshot_service.dart b/lib/services/app_config_snapshot_service.dart new file mode 100644 index 0000000..fa02a91 --- /dev/null +++ b/lib/services/app_config_snapshot_service.dart @@ -0,0 +1,156 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +import '../models/config_profile.dart'; +import '../providers/app_provider.dart'; +import 'image_preferences.dart'; +import 'profiles_feature_service.dart'; +import 'route_hash_preferences.dart'; +import 'voice_bitrate_preferences.dart'; + +class AppConfigSnapshotService { + Future capture(AppProvider appProvider) async { + final prefs = await SharedPreferences.getInstance(); + final locationTracking = appProvider.locationTrackingService; + return AppSettingsProfileSection( + mapEnabled: appProvider.isMapEnabled, + contactsEnabled: appProvider.isContactsEnabled, + sensorsEnabled: appProvider.isSensorsEnabled, + voiceSilenceTrimmingEnabled: appProvider.isVoiceSilenceTrimmingEnabled, + voiceBandPassFilterEnabled: appProvider.isVoiceBandPassFilterEnabled, + voiceCompressorEnabled: appProvider.isVoiceCompressorEnabled, + voiceLimiterEnabled: appProvider.isVoiceLimiterEnabled, + voiceAutoGainEnabled: appProvider.isVoiceAutoGainEnabled, + voiceEchoCancellationEnabled: appProvider.isVoiceEchoCancellationEnabled, + voiceNoiseSuppressionEnabled: appProvider.isVoiceNoiseSuppressionEnabled, + messageFontScale: appProvider.messageFontScale, + autoRouteRotationEnabled: appProvider.autoRouteRotationEnabled, + clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry, + nearestRelayFallbackEnabled: appProvider.nearestRelayFallbackEnabled, + voiceBitrate: await VoiceBitratePreferences.getBitrate(), + routeHashSize: await RouteHashPreferences.getHashSize(), + imageMaxSize: await ImagePreferences.getMaxSize(), + imageCompression: await ImagePreferences.getCompression(), + imageGrayscale: await ImagePreferences.getGrayscale(), + imageUltraMode: await ImagePreferences.getUltraMode(), + showRxTxIndicators: + prefs.getBool( + ProfileStorageScope.scopedKey('show_rx_tx_indicators'), + ) ?? + true, + fastLocationUpdatesEnabled: locationTracking.fastLocationUpdatesEnabled, + fastLocationMovementThresholdMeters: + locationTracking.fastLocationMovementThresholdMeters, + fastLocationActiveCadenceSeconds: + locationTracking.fastLocationActiveCadenceSeconds, + ); + } + + Future apply( + AppSettingsProfileSection? section, + AppProvider appProvider, + ) async { + if (section == null || section.isEmpty) { + return; + } + + if (section.mapEnabled != null) { + await appProvider.toggleMapEnabled(section.mapEnabled!); + } + if (section.contactsEnabled != null) { + await appProvider.toggleContactsEnabled(section.contactsEnabled!); + } + if (section.sensorsEnabled != null) { + await appProvider.toggleSensorsEnabled(section.sensorsEnabled!); + } + if (section.voiceSilenceTrimmingEnabled != null) { + await appProvider.toggleVoiceSilenceTrimmingEnabled( + section.voiceSilenceTrimmingEnabled!, + ); + } + if (section.voiceBandPassFilterEnabled != null) { + await appProvider.toggleVoiceBandPassFilterEnabled( + section.voiceBandPassFilterEnabled!, + ); + } + if (section.voiceCompressorEnabled != null) { + await appProvider.toggleVoiceCompressorEnabled( + section.voiceCompressorEnabled!, + ); + } + if (section.voiceLimiterEnabled != null) { + await appProvider.toggleVoiceLimiterEnabled(section.voiceLimiterEnabled!); + } + if (section.voiceAutoGainEnabled != null) { + await appProvider.toggleVoiceAutoGainEnabled( + section.voiceAutoGainEnabled!, + ); + } + if (section.voiceEchoCancellationEnabled != null) { + await appProvider.toggleVoiceEchoCancellationEnabled( + section.voiceEchoCancellationEnabled!, + ); + } + if (section.voiceNoiseSuppressionEnabled != null) { + await appProvider.toggleVoiceNoiseSuppressionEnabled( + section.voiceNoiseSuppressionEnabled!, + ); + } + if (section.messageFontScale != null) { + await appProvider.setMessageFontScale(section.messageFontScale!); + } + if (section.autoRouteRotationEnabled != null) { + await appProvider.toggleAutoRouteRotationEnabled( + section.autoRouteRotationEnabled!, + ); + } + if (section.clearPathOnMaxRetry != null) { + await appProvider.toggleClearPathOnMaxRetry(section.clearPathOnMaxRetry!); + } + if (section.nearestRelayFallbackEnabled != null) { + await appProvider.toggleNearestRelayFallbackEnabled( + section.nearestRelayFallbackEnabled!, + ); + } + if (section.voiceBitrate != null) { + await VoiceBitratePreferences.setBitrate(section.voiceBitrate!); + } + if (section.routeHashSize != null) { + await RouteHashPreferences.setHashSize(section.routeHashSize!); + } + if (section.imageMaxSize != null) { + await ImagePreferences.setMaxSize(section.imageMaxSize!); + } + if (section.imageCompression != null) { + await ImagePreferences.setCompression(section.imageCompression!); + } + if (section.imageGrayscale != null) { + await ImagePreferences.setGrayscale(section.imageGrayscale!); + } + if (section.imageUltraMode != null) { + await ImagePreferences.setUltraMode(section.imageUltraMode!); + } + if (section.showRxTxIndicators != null) { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool( + ProfileStorageScope.scopedKey('show_rx_tx_indicators'), + section.showRxTxIndicators!, + ); + } + + final locationTracking = appProvider.locationTrackingService; + if (section.fastLocationUpdatesEnabled != null) { + locationTracking.fastLocationUpdatesEnabled = + section.fastLocationUpdatesEnabled!; + } + if (section.fastLocationMovementThresholdMeters != null) { + locationTracking.fastLocationMovementThresholdMeters = + section.fastLocationMovementThresholdMeters!; + } + if (section.fastLocationActiveCadenceSeconds != null) { + locationTracking.fastLocationActiveCadenceSeconds = + section.fastLocationActiveCadenceSeconds!; + } + await locationTracking.saveSettings(); + await appProvider.reloadProfileScopedSettings(); + } +} diff --git a/lib/services/background_location_service.dart b/lib/services/background_location_service.dart index 04ced60..0dbbcc5 100644 --- a/lib/services/background_location_service.dart +++ b/lib/services/background_location_service.dart @@ -4,6 +4,7 @@ import 'package:flutter/widgets.dart'; import 'package:geolocator/geolocator.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:meshcore_client/meshcore_client.dart'; +import 'profiles_feature_service.dart'; /// Background location tracking service for SAR operations /// Tracks user location and sends periodic updates via MeshCore BLE @@ -15,6 +16,11 @@ class BackgroundLocationService { static const String _prefKeyLastLon = 'background_last_lon'; MeshCoreBleService? _bleService; + + String _scopedKey(String baseKey) { + return ProfileStorageScope.scopedKey(baseKey); + } + bool _isInitialized = false; StreamSubscription? _positionSubscription; @@ -71,8 +77,8 @@ class BackgroundLocationService { // Save settings final prefs = await SharedPreferences.getInstance(); - await prefs.setBool(_prefKeyEnabled, true); - await prefs.setDouble(_prefKeyDistance, distanceThreshold); + await prefs.setBool(_scopedKey(_prefKeyEnabled), true); + await prefs.setDouble(_scopedKey(_prefKeyDistance), distanceThreshold); // Start listening to position updates Position? lastPosition; @@ -110,8 +116,14 @@ class BackgroundLocationService { lastPosition = position; // Save to preferences - await prefs.setDouble(_prefKeyLastLat, position.latitude); - await prefs.setDouble(_prefKeyLastLon, position.longitude); + await prefs.setDouble( + _scopedKey(_prefKeyLastLat), + position.latitude, + ); + await prefs.setDouble( + _scopedKey(_prefKeyLastLon), + position.longitude, + ); // Update device's advertised location if (_bleService != null && _bleService!.isConnected) { @@ -161,7 +173,7 @@ class BackgroundLocationService { _positionSubscription = null; final prefs = await SharedPreferences.getInstance(); - await prefs.setBool(_prefKeyEnabled, false); + await prefs.setBool(_scopedKey(_prefKeyEnabled), false); debugPrint('✅ [BackgroundLocation] Tracking stopped'); } @@ -169,13 +181,13 @@ class BackgroundLocationService { /// Note: This will restart tracking with the new threshold Future updateDistanceThreshold(double distance) async { final prefs = await SharedPreferences.getInstance(); - await prefs.setDouble(_prefKeyDistance, distance); + await prefs.setDouble(_scopedKey(_prefKeyDistance), distance); debugPrint( '📏 [BackgroundLocation] Distance threshold updated to ${distance}m', ); // Restart tracking if currently enabled - final isEnabled = prefs.getBool(_prefKeyEnabled) ?? false; + final isEnabled = prefs.getBool(_scopedKey(_prefKeyEnabled)) ?? false; if (isEnabled && _bleService != null) { await stopTracking(); await startTracking(distanceThreshold: distance); diff --git a/lib/services/contact_storage_service.dart b/lib/services/contact_storage_service.dart index 2589c4a..98f8e70 100644 --- a/lib/services/contact_storage_service.dart +++ b/lib/services/contact_storage_service.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/contact.dart'; import '../models/contact_group.dart'; +import 'profiles_feature_service.dart'; import '../utils/key_comparison.dart'; import 'package:latlong2/latlong.dart'; @@ -14,8 +15,12 @@ class ContactStorageService { static const int _maxStoredContacts = 500; // Store up to 500 contacts static const int _maxStoredPendingAdverts = 500; + String _key(String baseKey, {String? namespace}) { + return ProfileStorageScope.scopedKey(baseKey, namespace: namespace); + } + /// Save contacts to persistent storage - Future saveContacts(List contacts) async { + Future saveContacts(List contacts, {String? namespace}) async { try { final prefs = await SharedPreferences.getInstance(); @@ -30,7 +35,10 @@ class ContactStorageService { : jsonList; final jsonString = jsonEncode(limitedList); - await prefs.setString(_contactsKey, jsonString); + await prefs.setString( + _key(_contactsKey, namespace: namespace), + jsonString, + ); debugPrint( '✅ [ContactStorage] Saved ${limitedList.length} contacts to storage', @@ -42,10 +50,15 @@ class ContactStorageService { /// Load contacts from persistent storage /// [excludePublicKey] - optional public key to exclude (e.g., device's own key) - Future> loadContacts({Uint8List? excludePublicKey}) async { + Future> loadContacts({ + Uint8List? excludePublicKey, + String? namespace, + }) async { try { final prefs = await SharedPreferences.getInstance(); - final jsonString = prefs.getString(_contactsKey); + final jsonString = prefs.getString( + _key(_contactsKey, namespace: namespace), + ); if (jsonString == null || jsonString.isEmpty) { debugPrint('â„šī¸ [ContactStorage] No stored contacts found'); @@ -84,23 +97,29 @@ class ContactStorageService { } /// Clear all stored contacts - Future clearContacts() async { + Future clearContacts({String? namespace}) async { try { final prefs = await SharedPreferences.getInstance(); - await prefs.remove(_contactsKey); + await prefs.remove(_key(_contactsKey, namespace: namespace)); debugPrint('✅ [ContactStorage] Cleared all stored contacts'); } catch (e) { debugPrint('❌ [ContactStorage] Error clearing contacts: $e'); } } - Future saveContactGroups(List groups) async { + Future saveContactGroups( + List groups, { + String? namespace, + }) async { try { final prefs = await SharedPreferences.getInstance(); final jsonString = jsonEncode( groups.map((group) => _contactGroupToJson(group)).toList(), ); - await prefs.setString(_contactGroupsKey, jsonString); + await prefs.setString( + _key(_contactGroupsKey, namespace: namespace), + jsonString, + ); debugPrint( '✅ [ContactStorage] Saved ${groups.length} contact groups to storage', ); @@ -109,10 +128,12 @@ class ContactStorageService { } } - Future> loadContactGroups() async { + Future> loadContactGroups({String? namespace}) async { try { final prefs = await SharedPreferences.getInstance(); - final jsonString = prefs.getString(_contactGroupsKey); + final jsonString = prefs.getString( + _key(_contactGroupsKey, namespace: namespace), + ); if (jsonString == null || jsonString.isEmpty) { return []; } @@ -128,13 +149,29 @@ class ContactStorageService { } } - Future savePendingAdverts(List> adverts) async { + Future clearContactGroups({String? namespace}) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_key(_contactGroupsKey, namespace: namespace)); + debugPrint('✅ [ContactStorage] Cleared all contact groups'); + } catch (e) { + debugPrint('❌ [ContactStorage] Error clearing contact groups: $e'); + } + } + + Future savePendingAdverts( + List> adverts, { + String? namespace, + }) async { try { final prefs = await SharedPreferences.getInstance(); final limitedList = adverts.length > _maxStoredPendingAdverts ? adverts.sublist(adverts.length - _maxStoredPendingAdverts) : adverts; - await prefs.setString(_pendingAdvertsKey, jsonEncode(limitedList)); + await prefs.setString( + _key(_pendingAdvertsKey, namespace: namespace), + jsonEncode(limitedList), + ); debugPrint( '✅ [ContactStorage] Saved ${limitedList.length} pending adverts to storage', ); @@ -143,10 +180,14 @@ class ContactStorageService { } } - Future>> loadPendingAdverts() async { + Future>> loadPendingAdverts({ + String? namespace, + }) async { try { final prefs = await SharedPreferences.getInstance(); - final jsonString = prefs.getString(_pendingAdvertsKey); + final jsonString = prefs.getString( + _key(_pendingAdvertsKey, namespace: namespace), + ); if (jsonString == null || jsonString.isEmpty) { return const []; } @@ -159,10 +200,10 @@ class ContactStorageService { } } - Future clearPendingAdverts() async { + Future clearPendingAdverts({String? namespace}) async { try { final prefs = await SharedPreferences.getInstance(); - await prefs.remove(_pendingAdvertsKey); + await prefs.remove(_key(_pendingAdvertsKey, namespace: namespace)); debugPrint('✅ [ContactStorage] Cleared all stored pending adverts'); } catch (e) { debugPrint('❌ [ContactStorage] Error clearing pending adverts: $e'); @@ -170,10 +211,12 @@ class ContactStorageService { } /// Get storage statistics - Future> getStorageStats() async { + Future> getStorageStats({String? namespace}) async { try { final prefs = await SharedPreferences.getInstance(); - final jsonString = prefs.getString(_contactsKey); + final jsonString = prefs.getString( + _key(_contactsKey, namespace: namespace), + ); if (jsonString == null || jsonString.isEmpty) { return {'contactCount': 0, 'storageSizeBytes': 0, 'storageSizeKB': 0}; diff --git a/lib/services/device_config_applicator.dart b/lib/services/device_config_applicator.dart new file mode 100644 index 0000000..c79dfec --- /dev/null +++ b/lib/services/device_config_applicator.dart @@ -0,0 +1,140 @@ +import 'dart:typed_data'; + +import '../models/channel.dart'; +import '../models/config_profile.dart'; +import '../providers/channels_provider.dart'; +import '../providers/connection_provider.dart'; + +class DeviceConfigApplicator { + ConfigProfileSections capture({ + required ConnectionProvider connectionProvider, + required ChannelsProvider channelsProvider, + }) { + final deviceInfo = connectionProvider.deviceInfo; + return ConfigProfileSections( + deviceConfig: DeviceConfigProfileSection( + frequencyKhz: deviceInfo.radioFreq, + bandwidth: deviceInfo.radioBw, + spreadingFactor: deviceInfo.radioSf, + codingRate: deviceInfo.radioCr, + repeatEnabled: deviceInfo.clientRepeat, + txPower: deviceInfo.txPower, + telemetryModes: deviceInfo.telemetryModes, + advertLocationPolicy: deviceInfo.advertLocPolicy, + multiAcks: deviceInfo.multiAcks, + manualAddContacts: deviceInfo.manualAddContacts, + autoAddUsers: deviceInfo.autoAddUsers, + autoAddRepeaters: deviceInfo.autoAddRepeaters, + autoAddRoomServers: deviceInfo.autoAddRoomServers, + autoAddSensors: deviceInfo.autoAddSensors, + autoAddOverwriteOldest: deviceInfo.autoAddOverwriteOldest, + publicLatitude: _decodeAdvertCoordinate(deviceInfo.advLat), + publicLongitude: _decodeAdvertCoordinate(deviceInfo.advLon), + ), + channels: channelsProvider.channels, + ); + } + + Future apply( + ConfigProfile profile, { + required ConnectionProvider connectionProvider, + required ChannelsProvider channelsProvider, + }) async { + if (!connectionProvider.deviceInfo.isConnected) { + return; + } + + final deviceConfig = profile.sections.deviceConfig; + if (deviceConfig != null && !deviceConfig.isEmpty) { + if (deviceConfig.txPower != null) { + await connectionProvider.setTxPower(deviceConfig.txPower!); + } + if (deviceConfig.telemetryModes != null || + deviceConfig.advertLocationPolicy != null || + deviceConfig.manualAddContacts != null || + deviceConfig.multiAcks != null) { + await connectionProvider.setOtherParams( + manualAddContacts: (deviceConfig.manualAddContacts ?? false) ? 1 : 0, + telemetryModes: deviceConfig.telemetryModes ?? 0, + advertLocationPolicy: deviceConfig.advertLocationPolicy ?? 0, + multiAcks: deviceConfig.multiAcks ?? 0, + ); + } + if (deviceConfig.autoAddUsers != null && + deviceConfig.autoAddRepeaters != null && + deviceConfig.autoAddRoomServers != null && + deviceConfig.autoAddSensors != null && + deviceConfig.autoAddOverwriteOldest != null) { + await connectionProvider.setAutoaddConfig( + autoAddUsers: deviceConfig.autoAddUsers!, + autoAddRepeaters: deviceConfig.autoAddRepeaters!, + autoAddRoomServers: deviceConfig.autoAddRoomServers!, + autoAddSensors: deviceConfig.autoAddSensors!, + overwriteOldest: deviceConfig.autoAddOverwriteOldest!, + ); + } + if (deviceConfig.publicLatitude != null && + deviceConfig.publicLongitude != null) { + await connectionProvider.setAdvertLatLon( + latitude: deviceConfig.publicLatitude!, + longitude: deviceConfig.publicLongitude!, + ); + } + if (deviceConfig.frequencyKhz != null && + deviceConfig.bandwidth != null && + deviceConfig.spreadingFactor != null && + deviceConfig.codingRate != null) { + await connectionProvider.setRadioParams( + frequency: deviceConfig.frequencyKhz!, + bandwidth: deviceConfig.bandwidth!, + spreadingFactor: deviceConfig.spreadingFactor!, + codingRate: deviceConfig.codingRate!, + repeat: deviceConfig.repeatEnabled, + ); + } + } + + await _applyChannels( + channels: profile.sections.channels, + connectionProvider: connectionProvider, + channelsProvider: channelsProvider, + ); + await connectionProvider.refreshDeviceInfo(); + } + + Future _applyChannels({ + required List channels, + required ConnectionProvider connectionProvider, + required ChannelsProvider channelsProvider, + }) async { + if (channels.isEmpty) { + return; + } + + final desired = {for (final channel in channels) channel.index: channel}; + + for (final channel in channels) { + await connectionProvider.setChannelSlot( + channelIdx: channel.index, + channelName: channel.name, + secret: Uint8List.fromList(channel.secret), + ); + channelsProvider.addOrUpdateChannelObject(channel); + } + + for (final existing in channelsProvider.channels) { + if (existing.index == 0 || desired.containsKey(existing.index)) { + continue; + } + await connectionProvider.deleteChannel(existing.index); + channelsProvider.removeChannel(existing.index); + } + } + + double? _decodeAdvertCoordinate(int? value) { + if (value == null || value == 0) { + return null; + } + return value / 1e6; + } +} diff --git a/lib/services/image_preferences.dart b/lib/services/image_preferences.dart index f04cb2e..c09d943 100644 --- a/lib/services/image_preferences.dart +++ b/lib/services/image_preferences.dart @@ -1,4 +1,5 @@ import 'package:shared_preferences/shared_preferences.dart'; +import 'profiles_feature_service.dart'; /// Stores user-selected image compression settings. class ImagePreferences { @@ -17,44 +18,53 @@ class ImagePreferences { static Future getMaxSize() async { final prefs = await SharedPreferences.getInstance(); - final value = prefs.getInt(_maxSizeKey) ?? defaultMaxSize; + final value = + prefs.getInt(ProfileStorageScope.scopedKey(_maxSizeKey)) ?? + defaultMaxSize; return supportedSizes.contains(value) ? value : defaultMaxSize; } static Future setMaxSize(int size) async { final prefs = await SharedPreferences.getInstance(); - await prefs.setInt(_maxSizeKey, size); + await prefs.setInt(ProfileStorageScope.scopedKey(_maxSizeKey), size); } static Future getCompression() async { final prefs = await SharedPreferences.getInstance(); - final value = prefs.getInt(_qualityKey) ?? defaultQuality; + final value = + prefs.getInt(ProfileStorageScope.scopedKey(_qualityKey)) ?? + defaultQuality; return value.clamp(10, 90); } static Future setCompression(int compression) async { final prefs = await SharedPreferences.getInstance(); - await prefs.setInt(_qualityKey, compression.clamp(10, 90)); + await prefs.setInt( + ProfileStorageScope.scopedKey(_qualityKey), + compression.clamp(10, 90), + ); } static Future getGrayscale() async { final prefs = await SharedPreferences.getInstance(); - return prefs.getBool(_grayscaleKey) ?? defaultGrayscale; + return prefs.getBool(ProfileStorageScope.scopedKey(_grayscaleKey)) ?? + defaultGrayscale; } static Future setGrayscale(bool value) async { final prefs = await SharedPreferences.getInstance(); - await prefs.setBool(_grayscaleKey, value); + await prefs.setBool(ProfileStorageScope.scopedKey(_grayscaleKey), value); } static Future getUltraMode() async { final prefs = await SharedPreferences.getInstance(); - return prefs.getBool(_ultraModeKey) ?? defaultUltraMode; + return prefs.getBool(ProfileStorageScope.scopedKey(_ultraModeKey)) ?? + defaultUltraMode; } static Future setUltraMode(bool value) async { final prefs = await SharedPreferences.getInstance(); - await prefs.setBool(_ultraModeKey, value); + await prefs.setBool(ProfileStorageScope.scopedKey(_ultraModeKey), value); } static int effectiveMaxSize( diff --git a/lib/services/location_tracking_service.dart b/lib/services/location_tracking_service.dart index 1b4a940..dbdb4b4 100644 --- a/lib/services/location_tracking_service.dart +++ b/lib/services/location_tracking_service.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:geolocator/geolocator.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:meshcore_client/meshcore_client.dart'; +import 'profiles_feature_service.dart'; /// Centralized location tracking service for MeshCore SAR /// @@ -49,6 +50,10 @@ class LocationTrackingService { static const String _prefKeyFastActiveCadence = 'fast_location_active_cadence_seconds'; + String _scopedKey(String baseKey) { + return ProfileStorageScope.scopedKey(baseKey); + } + // ============================================================================ // Configuration Properties // ============================================================================ @@ -399,7 +404,7 @@ class LocationTrackingService { // Save disabled state final prefs = await SharedPreferences.getInstance(); - await prefs.setBool(_prefKeyEnabled, false); + await prefs.setBool(_scopedKey(_prefKeyEnabled), false); debugPrint('✅ [LocationTracking] Tracking stopped'); } @@ -477,8 +482,8 @@ class LocationTrackingService { // Save to preferences final prefs = await SharedPreferences.getInstance(); - await prefs.setDouble(_prefKeyLastLat, position.latitude); - await prefs.setDouble(_prefKeyLastLon, position.longitude); + await prefs.setDouble(_scopedKey(_prefKeyLastLat), position.latitude); + await prefs.setDouble(_scopedKey(_prefKeyLastLon), position.longitude); debugPrint('✅ [LocationTracking] Initial position set without broadcast'); debugPrint(' Next broadcast allowed in ${minTimeIntervalSeconds}s'); @@ -646,17 +651,24 @@ class LocationTrackingService { Future loadSettings() async { final prefs = await SharedPreferences.getInstance(); - minDistanceMeters = prefs.getDouble(_prefKeyMinDistance) ?? 5.0; - maxDistanceMeters = prefs.getDouble(_prefKeyMaxDistance) ?? 100.0; - minTimeIntervalSeconds = prefs.getInt(_prefKeyMinTimeInterval) ?? 30; - gpsUpdateDistance = prefs.getDouble(_prefKeyGpsUpdateDistance) ?? 10.0; + minDistanceMeters = prefs.getDouble(_scopedKey(_prefKeyMinDistance)) ?? 5.0; + maxDistanceMeters = + prefs.getDouble(_scopedKey(_prefKeyMaxDistance)) ?? 100.0; + minTimeIntervalSeconds = + prefs.getInt(_scopedKey(_prefKeyMinTimeInterval)) ?? 30; + gpsUpdateDistance = + prefs.getDouble(_scopedKey(_prefKeyGpsUpdateDistance)) ?? 10.0; fastLocationUpdatesEnabled = - prefs.getBool(_prefKeyFastLocationEnabled) ?? false; + prefs.getBool(_scopedKey(_prefKeyFastLocationEnabled)) ?? false; fastLocationMovementThresholdMeters = - (prefs.getDouble(_prefKeyFastMovementThreshold) ?? gpsUpdateDistance) + (prefs.getDouble(_scopedKey(_prefKeyFastMovementThreshold)) ?? + gpsUpdateDistance) .clamp(1.0, 1000.0); fastLocationActiveCadenceSeconds = - (prefs.getInt(_prefKeyFastActiveCadence) ?? 10).clamp(5, 60); + (prefs.getInt(_scopedKey(_prefKeyFastActiveCadence)) ?? 10).clamp( + 5, + 60, + ); debugPrint('✅ [LocationTracking] Settings loaded'); debugPrint(' Min distance: ${minDistanceMeters}m'); @@ -674,21 +686,27 @@ class LocationTrackingService { Future saveSettings() async { final prefs = await SharedPreferences.getInstance(); - await prefs.setDouble(_prefKeyMinDistance, minDistanceMeters); - await prefs.setDouble(_prefKeyMaxDistance, maxDistanceMeters); - await prefs.setInt(_prefKeyMinTimeInterval, minTimeIntervalSeconds); - await prefs.setDouble(_prefKeyGpsUpdateDistance, gpsUpdateDistance); - await prefs.setBool(_prefKeyEnabled, isTracking); + await prefs.setDouble(_scopedKey(_prefKeyMinDistance), minDistanceMeters); + await prefs.setDouble(_scopedKey(_prefKeyMaxDistance), maxDistanceMeters); + await prefs.setInt( + _scopedKey(_prefKeyMinTimeInterval), + minTimeIntervalSeconds, + ); + await prefs.setDouble( + _scopedKey(_prefKeyGpsUpdateDistance), + gpsUpdateDistance, + ); + await prefs.setBool(_scopedKey(_prefKeyEnabled), isTracking); await prefs.setBool( - _prefKeyFastLocationEnabled, + _scopedKey(_prefKeyFastLocationEnabled), fastLocationUpdatesEnabled, ); await prefs.setDouble( - _prefKeyFastMovementThreshold, + _scopedKey(_prefKeyFastMovementThreshold), fastLocationMovementThresholdMeters, ); await prefs.setInt( - _prefKeyFastActiveCadence, + _scopedKey(_prefKeyFastActiveCadence), fastLocationActiveCadenceSeconds, ); diff --git a/lib/services/map_workspace_snapshot_service.dart b/lib/services/map_workspace_snapshot_service.dart new file mode 100644 index 0000000..f65347c --- /dev/null +++ b/lib/services/map_workspace_snapshot_service.dart @@ -0,0 +1,100 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +import '../models/config_profile.dart'; +import '../providers/drawing_provider.dart'; +import '../providers/map_provider.dart'; +import 'profiles_feature_service.dart'; + +class MapWorkspaceSnapshotService { + Future capture({ + required MapProvider mapProvider, + required DrawingProvider drawingProvider, + }) async { + final prefs = await SharedPreferences.getInstance(); + return MapWorkspaceProfileSection( + mapPrefs: { + 'map_rotate_with_heading': + prefs.getBool( + ProfileStorageScope.scopedKey('map_rotate_with_heading'), + ) ?? + false, + 'map_show_debug_info': + prefs.getBool( + ProfileStorageScope.scopedKey('map_show_debug_info'), + ) ?? + false, + 'map_fullscreen': + prefs.getBool(ProfileStorageScope.scopedKey('map_fullscreen')) ?? + false, + 'map_last_latitude': prefs.getDouble( + ProfileStorageScope.scopedKey('map_last_latitude'), + ), + 'map_last_longitude': prefs.getDouble( + ProfileStorageScope.scopedKey('map_last_longitude'), + ), + 'map_last_zoom': prefs.getDouble( + ProfileStorageScope.scopedKey('map_last_zoom'), + ), + 'map_last_layer_type': prefs.getInt( + ProfileStorageScope.scopedKey('map_last_layer_type'), + ), + 'map_gps_update_distance': prefs.getDouble( + ProfileStorageScope.scopedKey('map_gps_update_distance'), + ), + 'background_tracking_enabled': prefs.getBool( + ProfileStorageScope.scopedKey('background_tracking_enabled'), + ), + }, + drawings: drawingProvider.exportDrawingsJson(), + currentTrail: mapProvider.currentTrail?.toJson(), + trailHistory: mapProvider.trailHistory + .map((trail) => trail.toJson()) + .toList(), + importedTrail: mapProvider.importedTrail?.toJson(), + isTrailVisible: mapProvider.isTrailVisible, + showCadastralOverlay: mapProvider.showCadastralOverlay, + showForestRoadsOverlay: mapProvider.showForestRoadsOverlay, + showHikingTrailsOverlay: mapProvider.showHikingTrailsOverlay, + showMainRoadsOverlay: mapProvider.showMainRoadsOverlay, + showHouseNumbersOverlay: mapProvider.showHouseNumbersOverlay, + showFireHazardZonesOverlay: mapProvider.showFireHazardZonesOverlay, + showHistoricalFiresOverlay: mapProvider.showHistoricalFiresOverlay, + showFirebreaksOverlay: mapProvider.showFirebreaksOverlay, + showKrasFireZonesOverlay: mapProvider.showKrasFireZonesOverlay, + showPlaceNamesOverlay: mapProvider.showPlaceNamesOverlay, + showMunicipalityBordersOverlay: + mapProvider.showMunicipalityBordersOverlay, + showAllContactTrails: mapProvider.showAllContactTrails, + hideRepeatersOnMap: mapProvider.hideRepeatersOnMap, + ); + } + + Future apply( + MapWorkspaceProfileSection? section, { + required MapProvider mapProvider, + required DrawingProvider drawingProvider, + }) async { + if (section == null || section.isEmpty) { + return; + } + + final prefs = await SharedPreferences.getInstance(); + final mapPrefs = section.mapPrefs ?? const {}; + for (final entry in mapPrefs.entries) { + final key = ProfileStorageScope.scopedKey(entry.key); + final value = entry.value; + if (value == null) { + await prefs.remove(key); + } else if (value is bool) { + await prefs.setBool(key, value); + } else if (value is int) { + await prefs.setInt(key, value); + } else if (value is double) { + await prefs.setDouble(key, value); + } + } + + mapProvider.applyWorkspaceJson(section.toJson()); + await drawingProvider.replaceDrawingsFromJson(section.drawings); + } +} diff --git a/lib/services/message_storage_service.dart b/lib/services/message_storage_service.dart index f104ed0..b4c06e8 100644 --- a/lib/services/message_storage_service.dart +++ b/lib/services/message_storage_service.dart @@ -6,6 +6,7 @@ import '../models/message_contact_location.dart'; import '../models/message_reception_details.dart'; import '../models/message_transfer_details.dart'; import '../models/message_route_metadata.dart'; +import 'profiles_feature_service.dart'; import 'package:latlong2/latlong.dart'; /// Service for persisting messages to local storage @@ -24,6 +25,10 @@ class MessageStorageService { static const String _legacyPathBytesKey = 'storedPathBytes'; static const int _maxStoredMessages = 1000; // Store up to 1000 messages + String _key(String baseKey, {String? namespace}) { + return ProfileStorageScope.scopedKey(baseKey, namespace: namespace); + } + /// Save messages to persistent storage Future saveMessages( List messages, { @@ -31,6 +36,7 @@ class MessageStorageService { Map messageReceptionDetails = const {}, Map messageTransferDetails = const {}, Map messageRouteMetadata = const {}, + String? namespace, }) async { try { final prefs = await SharedPreferences.getInstance(); @@ -52,7 +58,10 @@ class MessageStorageService { : jsonList; final jsonString = jsonEncode(limitedList); - await prefs.setString(_messagesKey, jsonString); + await prefs.setString( + _key(_messagesKey, namespace: namespace), + jsonString, + ); final retainedMessageIds = limitedList .map((entry) => entry['id'] as String) .toSet(); @@ -81,19 +90,19 @@ class MessageStorageService { } } await prefs.setString( - _messageContactLocationsKey, + _key(_messageContactLocationsKey, namespace: namespace), jsonEncode(locationJson), ); await prefs.setString( - _messageReceptionDetailsKey, + _key(_messageReceptionDetailsKey, namespace: namespace), jsonEncode(receptionJson), ); await prefs.setString( - _messageTransferDetailsKey, + _key(_messageTransferDetailsKey, namespace: namespace), jsonEncode(transferJson), ); await prefs.setString( - _messageRouteMetadataKey, + _key(_messageRouteMetadataKey, namespace: namespace), jsonEncode(routeMetadataJson), ); @@ -105,11 +114,14 @@ class MessageStorageService { } } - Future> - loadMessageContactLocations() async { + Future> loadMessageContactLocations({ + String? namespace, + }) async { try { final prefs = await SharedPreferences.getInstance(); - final jsonString = prefs.getString(_messageContactLocationsKey); + final jsonString = prefs.getString( + _key(_messageContactLocationsKey, namespace: namespace), + ); if (jsonString == null || jsonString.isEmpty) { return const {}; } @@ -135,11 +147,14 @@ class MessageStorageService { } } - Future> - loadMessageReceptionDetails() async { + Future> loadMessageReceptionDetails({ + String? namespace, + }) async { try { final prefs = await SharedPreferences.getInstance(); - final jsonString = prefs.getString(_messageReceptionDetailsKey); + final jsonString = prefs.getString( + _key(_messageReceptionDetailsKey, namespace: namespace), + ); final result = {}; if (jsonString != null && jsonString.isNotEmpty) { final decoded = jsonDecode(jsonString); @@ -155,12 +170,16 @@ class MessageStorageService { } } - final embeddedReceptionDetails = await _loadEmbeddedReceptionDetails(); + final embeddedReceptionDetails = await _loadEmbeddedReceptionDetails( + namespace: namespace, + ); embeddedReceptionDetails.forEach((messageId, snapshot) { result.putIfAbsent(messageId, () => snapshot); }); - final fallbackPathBytes = await _loadLegacyPathBytesFromMessages(); + final fallbackPathBytes = await _loadLegacyPathBytesFromMessages( + namespace: namespace, + ); fallbackPathBytes.forEach((messageId, pathBytes) { result.putIfAbsent( messageId, @@ -177,11 +196,14 @@ class MessageStorageService { } } - Future> - loadMessageTransferDetails() async { + Future> loadMessageTransferDetails({ + String? namespace, + }) async { try { final prefs = await SharedPreferences.getInstance(); - final jsonString = prefs.getString(_messageTransferDetailsKey); + final jsonString = prefs.getString( + _key(_messageTransferDetailsKey, namespace: namespace), + ); if (jsonString == null || jsonString.isEmpty) { return const {}; } @@ -207,10 +229,14 @@ class MessageStorageService { } } - Future> loadMessageRouteMetadata() async { + Future> loadMessageRouteMetadata({ + String? namespace, + }) async { try { final prefs = await SharedPreferences.getInstance(); - final jsonString = prefs.getString(_messageRouteMetadataKey); + final jsonString = prefs.getString( + _key(_messageRouteMetadataKey, namespace: namespace), + ); if (jsonString == null || jsonString.isEmpty) { return const {}; } @@ -234,10 +260,12 @@ class MessageStorageService { } /// Load messages from persistent storage - Future> loadMessages() async { + Future> loadMessages({String? namespace}) async { try { final prefs = await SharedPreferences.getInstance(); - final jsonString = prefs.getString(_messagesKey); + final jsonString = prefs.getString( + _key(_messagesKey, namespace: namespace), + ); if (jsonString == null || jsonString.isEmpty) { debugPrint('â„šī¸ [MessageStorage] No stored messages found'); @@ -262,25 +290,35 @@ class MessageStorageService { } /// Clear all stored messages - Future clearMessages() async { + Future clearMessages({String? namespace}) async { try { final prefs = await SharedPreferences.getInstance(); - await prefs.remove(_messagesKey); - await prefs.remove(_messageContactLocationsKey); - await prefs.remove(_messageReceptionDetailsKey); - await prefs.remove(_messageTransferDetailsKey); - await prefs.remove(_messageRouteMetadataKey); - await prefs.remove(_removedSarMarkerIdsKey); + await prefs.remove(_key(_messagesKey, namespace: namespace)); + await prefs.remove( + _key(_messageContactLocationsKey, namespace: namespace), + ); + await prefs.remove( + _key(_messageReceptionDetailsKey, namespace: namespace), + ); + await prefs.remove( + _key(_messageTransferDetailsKey, namespace: namespace), + ); + await prefs.remove(_key(_messageRouteMetadataKey, namespace: namespace)); + await prefs.remove(_key(_removedSarMarkerIdsKey, namespace: namespace)); debugPrint('✅ [MessageStorage] Cleared all stored messages'); } catch (e) { debugPrint('❌ [MessageStorage] Error clearing messages: $e'); } } - Future> loadRemovedSarMarkerIds() async { + Future> loadRemovedSarMarkerIds({String? namespace}) async { try { final prefs = await SharedPreferences.getInstance(); - final ids = prefs.getStringList(_removedSarMarkerIdsKey) ?? const []; + final ids = + prefs.getStringList( + _key(_removedSarMarkerIdsKey, namespace: namespace), + ) ?? + const []; return ids.toSet(); } catch (e) { debugPrint('❌ [MessageStorage] Error loading removed SAR marker IDs: $e'); @@ -288,10 +326,16 @@ class MessageStorageService { } } - Future saveRemovedSarMarkerIds(Set ids) async { + Future saveRemovedSarMarkerIds( + Set ids, { + String? namespace, + }) async { try { final prefs = await SharedPreferences.getInstance(); - await prefs.setStringList(_removedSarMarkerIdsKey, ids.toList()..sort()); + await prefs.setStringList( + _key(_removedSarMarkerIdsKey, namespace: namespace), + ids.toList()..sort(), + ); debugPrint( '✅ [MessageStorage] Saved ${ids.length} removed SAR marker IDs', ); @@ -301,10 +345,12 @@ class MessageStorageService { } /// Get storage statistics - Future> getStorageStats() async { + Future> getStorageStats({String? namespace}) async { try { final prefs = await SharedPreferences.getInstance(); - final jsonString = prefs.getString(_messagesKey); + final jsonString = prefs.getString( + _key(_messagesKey, namespace: namespace), + ); if (jsonString == null || jsonString.isEmpty) { return {'messageCount': 0, 'storageSizeBytes': 0, 'storageSizeKB': 0}; @@ -401,10 +447,13 @@ class MessageStorageService { }; } - Future> - _loadEmbeddedReceptionDetails() async { + Future> _loadEmbeddedReceptionDetails({ + String? namespace, + }) async { final prefs = await SharedPreferences.getInstance(); - final jsonString = prefs.getString(_messagesKey); + final jsonString = prefs.getString( + _key(_messagesKey, namespace: namespace), + ); if (jsonString == null || jsonString.isEmpty) { return const {}; } @@ -427,9 +476,13 @@ class MessageStorageService { return result; } - Future>> _loadLegacyPathBytesFromMessages() async { + Future>> _loadLegacyPathBytesFromMessages({ + String? namespace, + }) async { final prefs = await SharedPreferences.getInstance(); - final jsonString = prefs.getString(_messagesKey); + final jsonString = prefs.getString( + _key(_messagesKey, namespace: namespace), + ); if (jsonString == null || jsonString.isEmpty) { return const {}; } diff --git a/lib/services/messaging_route_preferences.dart b/lib/services/messaging_route_preferences.dart index 7d00085..1b0216e 100644 --- a/lib/services/messaging_route_preferences.dart +++ b/lib/services/messaging_route_preferences.dart @@ -1,4 +1,5 @@ import 'package:shared_preferences/shared_preferences.dart'; +import 'profiles_feature_service.dart'; class MessagingRoutePreferences { static const bool defaultAutoRouteRotationEnabled = false; @@ -14,33 +15,49 @@ class MessagingRoutePreferences { static Future getAutoRouteRotationEnabled() async { final prefs = await SharedPreferences.getInstance(); - return prefs.getBool(_autoRouteRotationKey) ?? + return prefs.getBool( + ProfileStorageScope.scopedKey(_autoRouteRotationKey), + ) ?? defaultAutoRouteRotationEnabled; } static Future setAutoRouteRotationEnabled(bool enabled) async { final prefs = await SharedPreferences.getInstance(); - await prefs.setBool(_autoRouteRotationKey, enabled); + await prefs.setBool( + ProfileStorageScope.scopedKey(_autoRouteRotationKey), + enabled, + ); } static Future getClearPathOnMaxRetry() async { final prefs = await SharedPreferences.getInstance(); - return prefs.getBool(_clearPathOnMaxRetryKey) ?? defaultClearPathOnMaxRetry; + return prefs.getBool( + ProfileStorageScope.scopedKey(_clearPathOnMaxRetryKey), + ) ?? + defaultClearPathOnMaxRetry; } static Future setClearPathOnMaxRetry(bool enabled) async { final prefs = await SharedPreferences.getInstance(); - await prefs.setBool(_clearPathOnMaxRetryKey, enabled); + await prefs.setBool( + ProfileStorageScope.scopedKey(_clearPathOnMaxRetryKey), + enabled, + ); } static Future getNearestRelayFallbackEnabled() async { final prefs = await SharedPreferences.getInstance(); - return prefs.getBool(_nearestRelayFallbackKey) ?? + return prefs.getBool( + ProfileStorageScope.scopedKey(_nearestRelayFallbackKey), + ) ?? defaultNearestRelayFallbackEnabled; } static Future setNearestRelayFallbackEnabled(bool enabled) async { final prefs = await SharedPreferences.getInstance(); - await prefs.setBool(_nearestRelayFallbackKey, enabled); + await prefs.setBool( + ProfileStorageScope.scopedKey(_nearestRelayFallbackKey), + enabled, + ); } } diff --git a/lib/services/profile_manager.dart b/lib/services/profile_manager.dart new file mode 100644 index 0000000..9ea505e --- /dev/null +++ b/lib/services/profile_manager.dart @@ -0,0 +1,141 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../models/config_profile.dart'; +import 'profiles_feature_service.dart'; + +class ProfileManager with ChangeNotifier { + static const String _profilesKey = 'profiles_library'; + static const String activeProfileIdKey = 'profiles_active_profile_id'; + static const String _transferHistoryKey = 'profiles_transfer_history'; + + final List _customProfiles = []; + final List _transferHistory = + []; + bool _isInitialized = false; + bool _profilesEnabled = false; + String _activeProfileId = ConfigProfile.defaultProfileId; + + bool get isInitialized => _isInitialized; + bool get profilesEnabled => _profilesEnabled; + String get activeProfileId => _activeProfileId; + List get customProfiles => List.unmodifiable(_customProfiles); + List get transferHistory => + List.unmodifiable(_transferHistory); + + List get visibleProfiles => [ + if (_profilesEnabled) ConfigProfile.defaultProfile(), + ..._customProfiles, + ]; + + Future initialize() async { + if (_isInitialized) return; + final prefs = await SharedPreferences.getInstance(); + _profilesEnabled = await ProfilesFeatureService.isEnabled(); + _activeProfileId = + prefs.getString(activeProfileIdKey) ?? ConfigProfile.defaultProfileId; + + final profilesJson = prefs.getString(_profilesKey); + if (profilesJson != null && profilesJson.isNotEmpty) { + final decoded = jsonDecode(profilesJson) as List; + _customProfiles + ..clear() + ..addAll( + decoded.whereType>().map(ConfigProfile.fromJson), + ); + } + + final historyJson = prefs.getString(_transferHistoryKey); + if (historyJson != null && historyJson.isNotEmpty) { + final decoded = jsonDecode(historyJson) as List; + _transferHistory + ..clear() + ..addAll( + decoded.whereType>().map( + ProfileTransferRecord.fromJson, + ), + ); + } + + _isInitialized = true; + ProfileStorageScope.setScope( + profilesEnabled: _profilesEnabled, + activeProfileId: _activeProfileId, + ); + notifyListeners(); + } + + ConfigProfile? getProfile(String id) { + if (id == ConfigProfile.defaultProfileId) { + return ConfigProfile.defaultProfile(); + } + for (final profile in _customProfiles) { + if (profile.id == id) { + return profile; + } + } + return null; + } + + Future setProfilesEnabled(bool enabled) async { + _profilesEnabled = enabled; + await ProfilesFeatureService.setEnabled(enabled); + ProfileStorageScope.setScope( + profilesEnabled: _profilesEnabled, + activeProfileId: _activeProfileId, + ); + notifyListeners(); + } + + Future setActiveProfileId(String id) async { + _activeProfileId = id; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(activeProfileIdKey, id); + ProfileStorageScope.setScope( + profilesEnabled: _profilesEnabled, + activeProfileId: _activeProfileId, + ); + notifyListeners(); + } + + Future upsertProfile(ConfigProfile profile) async { + final index = _customProfiles.indexWhere((item) => item.id == profile.id); + if (index == -1) { + _customProfiles.add(profile); + } else { + _customProfiles[index] = profile; + } + _customProfiles.sort((a, b) => a.name.compareTo(b.name)); + await _persistProfiles(); + notifyListeners(); + } + + Future deleteProfile(String id) async { + _customProfiles.removeWhere((profile) => profile.id == id); + await _persistProfiles(); + notifyListeners(); + } + + Future recordTransfer(ProfileTransferRecord record) async { + _transferHistory.insert(0, record); + if (_transferHistory.length > 100) { + _transferHistory.removeRange(100, _transferHistory.length); + } + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + _transferHistoryKey, + jsonEncode(_transferHistory.map((item) => item.toJson()).toList()), + ); + notifyListeners(); + } + + Future _persistProfiles() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + _profilesKey, + jsonEncode(_customProfiles.map((profile) => profile.toJson()).toList()), + ); + } +} diff --git a/lib/services/profile_transport.dart b/lib/services/profile_transport.dart new file mode 100644 index 0000000..64e43f6 --- /dev/null +++ b/lib/services/profile_transport.dart @@ -0,0 +1,10 @@ +abstract class ProfileTransport { + String get label; +} + +class FileProfileTransport implements ProfileTransport { + const FileProfileTransport(); + + @override + String get label => 'File'; +} diff --git a/lib/services/profile_workspace_coordinator.dart b/lib/services/profile_workspace_coordinator.dart new file mode 100644 index 0000000..4a64a36 --- /dev/null +++ b/lib/services/profile_workspace_coordinator.dart @@ -0,0 +1,363 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; + +import '../models/config_profile.dart'; +import '../providers/app_provider.dart'; +import '../providers/channels_provider.dart'; +import '../providers/connection_provider.dart'; +import '../providers/contacts_provider.dart'; +import '../providers/drawing_provider.dart'; +import '../providers/map_provider.dart'; +import '../providers/messages_provider.dart'; +import 'app_config_snapshot_service.dart'; +import 'contact_storage_service.dart'; +import 'device_config_applicator.dart'; +import 'message_storage_service.dart'; +import 'profile_manager.dart'; +import 'profiles_feature_service.dart'; +import 'map_workspace_snapshot_service.dart'; + +class ProfileWorkspaceCoordinator { + ProfileWorkspaceCoordinator({ + required this.profileManager, + required this.connectionProvider, + required this.contactsProvider, + required this.messagesProvider, + required this.mapProvider, + required this.drawingProvider, + required this.channelsProvider, + required this.appProvider, + AppConfigSnapshotService? appConfigSnapshotService, + MapWorkspaceSnapshotService? mapWorkspaceSnapshotService, + DeviceConfigApplicator? deviceConfigApplicator, + MessageStorageService? messageStorageService, + ContactStorageService? contactStorageService, + }) : _appConfigSnapshotService = + appConfigSnapshotService ?? AppConfigSnapshotService(), + _mapWorkspaceSnapshotService = + mapWorkspaceSnapshotService ?? MapWorkspaceSnapshotService(), + _deviceConfigApplicator = + deviceConfigApplicator ?? DeviceConfigApplicator(), + _messageStorageService = + messageStorageService ?? MessageStorageService(), + _contactStorageService = + contactStorageService ?? ContactStorageService(); + + final ProfileManager profileManager; + final ConnectionProvider connectionProvider; + final ContactsProvider contactsProvider; + final MessagesProvider messagesProvider; + final MapProvider mapProvider; + final DrawingProvider drawingProvider; + final ChannelsProvider channelsProvider; + final AppProvider appProvider; + final AppConfigSnapshotService _appConfigSnapshotService; + final MapWorkspaceSnapshotService _mapWorkspaceSnapshotService; + final DeviceConfigApplicator _deviceConfigApplicator; + final MessageStorageService _messageStorageService; + final ContactStorageService _contactStorageService; + + Future setProfilesEnabled(bool enabled) async { + final wasEnabled = profileManager.profilesEnabled; + if (wasEnabled && !enabled) { + await _saveActiveCustomProfileSnapshot(); + } else { + await _persistCurrentState(); + } + await profileManager.setProfilesEnabled(enabled); + ProfileStorageScope.setScope( + profilesEnabled: enabled, + activeProfileId: enabled ? profileManager.activeProfileId : 'default', + ); + if (enabled) { + if (wasEnabled) { + await openProfile(profileManager.activeProfileId); + } else { + await _switchRuntimeScope(profileManager.activeProfileId); + final profile = await resolveProfile(profileManager.activeProfileId); + await _appConfigSnapshotService.apply( + profile.sections.appSettings, + appProvider, + ); + await _mapWorkspaceSnapshotService.apply( + profile.sections.mapWorkspace, + mapProvider: mapProvider, + drawingProvider: drawingProvider, + ); + } + } else { + await _switchRuntimeScope('default'); + } + } + + Future snapshotCurrentProfile({ + required String id, + required String name, + String? notes, + }) async { + final deviceSections = _deviceConfigApplicator.capture( + connectionProvider: connectionProvider, + channelsProvider: channelsProvider, + ); + final appSettings = await _appConfigSnapshotService.capture(appProvider); + final mapWorkspace = await _mapWorkspaceSnapshotService.capture( + mapProvider: mapProvider, + drawingProvider: drawingProvider, + ); + final now = DateTime.now(); + return ConfigProfile( + id: id, + name: name, + createdAt: now, + updatedAt: now, + notes: notes, + sections: ConfigProfileSections( + deviceConfig: deviceSections.deviceConfig, + channels: deviceSections.channels, + appSettings: appSettings, + mapWorkspace: mapWorkspace, + ), + ); + } + + Future createProfileFromCurrent({ + required String name, + String? notes, + }) async { + final profileId = 'profile_${DateTime.now().millisecondsSinceEpoch}'; + final profile = await snapshotCurrentProfile( + id: profileId, + name: name, + notes: notes, + ); + await messagesProvider.cloneCurrentStorageTo(profileId); + await contactsProvider.cloneCurrentStorageTo(profileId); + await profileManager.upsertProfile(profile); + return profile; + } + + Future duplicateProfile(ConfigProfile source) async { + final duplicate = await snapshotCurrentProfile( + id: 'profile_${DateTime.now().millisecondsSinceEpoch}', + name: '${source.name} Copy', + notes: source.notes, + ); + final sourceNamespace = source.id == ConfigProfile.defaultProfileId + ? null + : source.id; + await _copyStorageNamespace(sourceNamespace, duplicate.id); + await profileManager.upsertProfile( + duplicate.copyWith( + sections: source.id == profileManager.activeProfileId + ? duplicate.sections + : source.sections, + ), + ); + return duplicate; + } + + Future renameProfile(ConfigProfile profile, String name) async { + await profileManager.upsertProfile( + profile.copyWith(name: name, updatedAt: DateTime.now()), + ); + } + + Future deleteProfile(ConfigProfile profile) async { + if (profile.isDefault) return; + if (profile.id == profileManager.activeProfileId) { + await openProfile(ConfigProfile.defaultProfileId); + } + await _messageStorageService.clearMessages(namespace: profile.id); + await _contactStorageService.clearContacts(namespace: profile.id); + await _contactStorageService.clearContactGroups(namespace: profile.id); + await _contactStorageService.clearPendingAdverts(namespace: profile.id); + await profileManager.deleteProfile(profile.id); + } + + Future openProfile(String profileId) async { + await _saveActiveCustomProfileSnapshot(); + await profileManager.setActiveProfileId(profileId); + await _switchRuntimeScope(profileId); + + final profile = await resolveProfile(profileId); + await _appConfigSnapshotService.apply( + profile.sections.appSettings, + appProvider, + ); + await _mapWorkspaceSnapshotService.apply( + profile.sections.mapWorkspace, + mapProvider: mapProvider, + drawingProvider: drawingProvider, + ); + } + + Future applyProfile(String profileId) async { + await openProfile(profileId); + final profile = await resolveProfile(profileId); + await _deviceConfigApplicator.apply( + profile, + connectionProvider: connectionProvider, + channelsProvider: channelsProvider, + ); + } + + Future resolveProfile(String profileId) async { + if (profileId == ConfigProfile.defaultProfileId) { + return snapshotCurrentProfile(id: profileId, name: 'Default'); + } + return profileManager.getProfile(profileId)!; + } + + Future exportProfile(ConfigProfile profile) async { + final resolved = profile.id == ConfigProfile.defaultProfileId + ? await snapshotCurrentProfile(id: profile.id, name: profile.name) + : profile; + final tempDir = await getTemporaryDirectory(); + final file = File( + '${tempDir.path}/${resolved.name.replaceAll(' ', '_').toLowerCase()}_${resolved.id}.meshcore_profile.json', + ); + await file.writeAsString( + const JsonEncoder.withIndent(' ').convert(resolved.toJson()), + ); + await SharePlus.instance.share(ShareParams(files: [XFile(file.path)])); + await profileManager.recordTransfer( + ProfileTransferRecord( + profileId: resolved.id, + direction: 'export', + timestamp: DateTime.now(), + detail: 'Shared as file', + ), + ); + } + + Future importProfileFromFile() async { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['json'], + withData: true, + ); + if (result == null || result.files.isEmpty) { + return null; + } + final file = result.files.first; + if (file.bytes == null) return null; + + final decoded = jsonDecode(utf8.decode(file.bytes!)); + if (decoded is! Map) { + return null; + } + + final imported = ConfigProfile.fromJson(decoded).copyWith( + id: 'profile_${DateTime.now().millisecondsSinceEpoch}', + updatedAt: DateTime.now(), + ); + await profileManager.upsertProfile(imported); + await profileManager.recordTransfer( + ProfileTransferRecord( + profileId: imported.id, + direction: 'import', + timestamp: DateTime.now(), + detail: file.name, + ), + ); + return imported; + } + + Future _switchRuntimeScope(String profileId) async { + final runtimeProfilesEnabled = profileManager.profilesEnabled; + ProfileStorageScope.setScope( + profilesEnabled: runtimeProfilesEnabled, + activeProfileId: profileId, + ); + await messagesProvider.reloadFromStorage( + namespace: ProfileStorageScope.effectiveNamespace, + ); + await contactsProvider.reloadFromStorage( + namespace: ProfileStorageScope.effectiveNamespace, + devicePublicKey: connectionProvider.deviceInfo.publicKey, + ); + await drawingProvider.reloadProfileScopedState(); + await mapProvider.reloadProfileScopedState(); + await appProvider.reloadProfileScopedSettings(); + } + + Future _persistCurrentState() async { + await messagesProvider.persistNow(); + await contactsProvider.persistNow(); + } + + Future _saveActiveCustomProfileSnapshot() async { + if (profileManager.activeProfileId == ConfigProfile.defaultProfileId) { + await _persistCurrentState(); + return; + } + final current = profileManager.getProfile(profileManager.activeProfileId); + if (current == null) return; + final snapshot = await snapshotCurrentProfile( + id: current.id, + name: current.name, + notes: current.notes, + ); + await profileManager.upsertProfile(snapshot); + await _persistCurrentState(); + } + + Future _copyStorageNamespace( + String? sourceNamespace, + String targetNamespace, + ) async { + final messages = await _messageStorageService.loadMessages( + namespace: sourceNamespace, + ); + final contactLocations = await _messageStorageService + .loadMessageContactLocations(namespace: sourceNamespace); + final receptionDetails = await _messageStorageService + .loadMessageReceptionDetails(namespace: sourceNamespace); + final transferDetails = await _messageStorageService + .loadMessageTransferDetails(namespace: sourceNamespace); + final routeMetadata = await _messageStorageService.loadMessageRouteMetadata( + namespace: sourceNamespace, + ); + final removedIds = await _messageStorageService.loadRemovedSarMarkerIds( + namespace: sourceNamespace, + ); + await _messageStorageService.saveMessages( + messages, + messageContactLocations: contactLocations, + messageReceptionDetails: receptionDetails, + messageTransferDetails: transferDetails, + messageRouteMetadata: routeMetadata, + namespace: targetNamespace, + ); + await _messageStorageService.saveRemovedSarMarkerIds( + removedIds, + namespace: targetNamespace, + ); + + final contacts = await _contactStorageService.loadContacts( + namespace: sourceNamespace, + ); + final groups = await _contactStorageService.loadContactGroups( + namespace: sourceNamespace, + ); + final pending = await _contactStorageService.loadPendingAdverts( + namespace: sourceNamespace, + ); + await _contactStorageService.saveContacts( + contacts, + namespace: targetNamespace, + ); + await _contactStorageService.saveContactGroups( + groups, + namespace: targetNamespace, + ); + await _contactStorageService.savePendingAdverts( + pending, + namespace: targetNamespace, + ); + } +} diff --git a/lib/services/profiles_feature_service.dart b/lib/services/profiles_feature_service.dart new file mode 100644 index 0000000..231cc8b --- /dev/null +++ b/lib/services/profiles_feature_service.dart @@ -0,0 +1,54 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +class ProfilesFeatureService { + static const String enabledKey = 'profiles_enabled'; + + static Future isEnabled() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(enabledKey) ?? false; + } + + static Future setEnabled(bool enabled) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(enabledKey, enabled); + } +} + +class ProfileStorageScope { + static bool _profilesEnabled = false; + static String _activeProfileId = 'default'; + + static Future bootstrap({ + required bool profilesEnabled, + required String activeProfileId, + }) async { + _profilesEnabled = profilesEnabled; + _activeProfileId = activeProfileId; + } + + static void setScope({ + required bool profilesEnabled, + required String activeProfileId, + }) { + _profilesEnabled = profilesEnabled; + _activeProfileId = activeProfileId; + } + + static bool get profilesEnabled => _profilesEnabled; + static String get activeProfileId => _activeProfileId; + + static String? get effectiveNamespace { + if (!_profilesEnabled || _activeProfileId == 'default') { + return null; + } + return _activeProfileId; + } + + static String scopedKey(String baseKey, {String? namespace}) { + final scope = namespace ?? effectiveNamespace; + if (scope == null || scope.isEmpty) { + return baseKey; + } + return 'profile.$scope.$baseKey'; + } +} diff --git a/lib/services/route_hash_preferences.dart b/lib/services/route_hash_preferences.dart index ab5674a..0c47bb2 100644 --- a/lib/services/route_hash_preferences.dart +++ b/lib/services/route_hash_preferences.dart @@ -1,4 +1,5 @@ import 'package:shared_preferences/shared_preferences.dart'; +import 'profiles_feature_service.dart'; class RouteHashPreferences { static const String _hashSizeKey = 'route_hash_size'; @@ -6,13 +7,18 @@ class RouteHashPreferences { static Future getHashSize() async { final prefs = await SharedPreferences.getInstance(); - final value = prefs.getInt(_hashSizeKey) ?? defaultHashSize; + final value = + prefs.getInt(ProfileStorageScope.scopedKey(_hashSizeKey)) ?? + defaultHashSize; return _normalize(value); } static Future setHashSize(int value) async { final prefs = await SharedPreferences.getInstance(); - await prefs.setInt(_hashSizeKey, _normalize(value)); + await prefs.setInt( + ProfileStorageScope.scopedKey(_hashSizeKey), + _normalize(value), + ); } static int normalizeSync(int value) => _normalize(value); diff --git a/lib/services/voice_bitrate_preferences.dart b/lib/services/voice_bitrate_preferences.dart index 225de5c..694fe63 100644 --- a/lib/services/voice_bitrate_preferences.dart +++ b/lib/services/voice_bitrate_preferences.dart @@ -1,21 +1,32 @@ import 'package:shared_preferences/shared_preferences.dart'; +import 'profiles_feature_service.dart'; import '../utils/voice_message_parser.dart'; /// Stores user-selected voice bitrate and maps it to supported codec modes. class VoiceBitratePreferences { static const String _bitrateKey = 'voice_bitrate'; static const int defaultBitrate = 1300; - static const List supportedBitrates = [700, 1200, 1300, 1400, 1600, 2400, 3200]; + static const List supportedBitrates = [ + 700, + 1200, + 1300, + 1400, + 1600, + 2400, + 3200, + ]; static Future getBitrate() async { final prefs = await SharedPreferences.getInstance(); - final value = prefs.getInt(_bitrateKey) ?? defaultBitrate; + final value = + prefs.getInt(ProfileStorageScope.scopedKey(_bitrateKey)) ?? + defaultBitrate; return supportedBitrates.contains(value) ? value : defaultBitrate; } static Future setBitrate(int bitrate) async { final prefs = await SharedPreferences.getInstance(); - await prefs.setInt(_bitrateKey, bitrate); + await prefs.setInt(ProfileStorageScope.scopedKey(_bitrateKey), bitrate); } static VoicePacketMode toVoiceMode(int bitrate) { diff --git a/test/services/contact_storage_service_test.dart b/test/services/contact_storage_service_test.dart new file mode 100644 index 0000000..ebfe29e --- /dev/null +++ b/test/services/contact_storage_service_test.dart @@ -0,0 +1,56 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/services/contact_storage_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Contact createContact({required Uint8List key, required String name}) { + return Contact( + publicKey: key, + type: ContactType.chat, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: name, + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: (46.0569 * 1e6).round(), + advLon: (14.5058 * 1e6).round(), + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + } + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('keeps default and custom profile contact storage isolated', () async { + final storage = ContactStorageService(); + final defaultContact = createContact( + key: Uint8List.fromList(List.filled(32, 1)), + name: 'Default Contact', + ); + final customContact = createContact( + key: Uint8List.fromList(List.filled(32, 2)), + name: 'Custom Contact', + ); + + await storage.saveContacts([defaultContact]); + await storage.saveContacts([customContact], namespace: 'alpha'); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('stored_contacts'), isNotNull); + expect(prefs.getString('profile.alpha.stored_contacts'), isNotNull); + + final defaultContacts = await storage.loadContacts(); + final customContacts = await storage.loadContacts(namespace: 'alpha'); + + expect(defaultContacts.single.advName, defaultContact.advName); + expect(customContacts.single.advName, customContact.advName); + expect(defaultContacts.single.publicKey, defaultContact.publicKey); + expect(customContacts.single.publicKey, customContact.publicKey); + }); +} diff --git a/test/services/message_storage_service_test.dart b/test/services/message_storage_service_test.dart index 80eb917..0844e52 100644 --- a/test/services/message_storage_service_test.dart +++ b/test/services/message_storage_service_test.dart @@ -112,4 +112,47 @@ void main() { expect(restored, equals({'sar-1', 'sar-2'})); }); + + test('keeps default and custom profile message storage isolated', () async { + final storage = MessageStorageService(); + final defaultMessage = Message( + id: 'default-msg', + messageType: MessageType.channel, + senderPublicKeyPrefix: Uint8List.fromList([1, 1, 1, 1, 1, 1]), + channelIdx: 0, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: 1700001000, + text: 'Default profile', + receivedAt: DateTime.fromMillisecondsSinceEpoch(1700001000500), + isRead: true, + ); + final customMessage = Message( + id: 'custom-msg', + messageType: MessageType.channel, + senderPublicKeyPrefix: Uint8List.fromList([2, 2, 2, 2, 2, 2]), + channelIdx: 1, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: 1700002000, + text: 'Custom profile', + receivedAt: DateTime.fromMillisecondsSinceEpoch(1700002000500), + isRead: false, + ); + + await storage.saveMessages([defaultMessage]); + await storage.saveMessages([customMessage], namespace: 'alpha'); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('stored_messages'), isNotNull); + expect(prefs.getString('profile.alpha.stored_messages'), isNotNull); + + final defaultMessages = await storage.loadMessages(); + final customMessages = await storage.loadMessages(namespace: 'alpha'); + + expect(defaultMessages.single.id, defaultMessage.id); + expect(defaultMessages.single.text, defaultMessage.text); + expect(customMessages.single.id, customMessage.id); + expect(customMessages.single.text, customMessage.text); + }); } diff --git a/test/services/profile_manager_test.dart b/test/services/profile_manager_test.dart new file mode 100644 index 0000000..618da94 --- /dev/null +++ b/test/services/profile_manager_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/models/config_profile.dart'; +import 'package:meshcore_sar_app/services/profile_manager.dart'; +import 'package:meshcore_sar_app/services/profiles_feature_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + ProfileStorageScope.setScope( + profilesEnabled: false, + activeProfileId: ConfigProfile.defaultProfileId, + ); + }); + + group('ProfileManager', () { + test( + 'hides the built-in default profile until profiles are enabled', + () async { + final manager = ProfileManager(); + + await manager.initialize(); + + expect(manager.activeProfileId, ConfigProfile.defaultProfileId); + expect(manager.visibleProfiles, isEmpty); + expect( + manager.getProfile(ConfigProfile.defaultProfileId)?.isDefault, + isTrue, + ); + expect(ProfileStorageScope.profilesEnabled, isFalse); + expect(ProfileStorageScope.effectiveNamespace, isNull); + + await manager.setProfilesEnabled(true); + + expect(manager.visibleProfiles, hasLength(1)); + expect( + manager.visibleProfiles.single.id, + ConfigProfile.defaultProfileId, + ); + expect(manager.visibleProfiles.single.name, 'Default'); + expect(ProfileStorageScope.profilesEnabled, isTrue); + expect(ProfileStorageScope.effectiveNamespace, isNull); + }, + ); + + test( + 'persists custom profiles and restores scoped active profile state', + () async { + final manager = ProfileManager(); + await manager.initialize(); + await manager.setProfilesEnabled(true); + + final profile = ConfigProfile( + id: 'profile-alpha', + name: 'Alpha', + createdAt: DateTime.parse('2026-03-16T12:00:00Z'), + updatedAt: DateTime.parse('2026-03-16T12:00:00Z'), + sections: const ConfigProfileSections(), + ); + + await manager.upsertProfile(profile); + await manager.setActiveProfileId(profile.id); + + final reloaded = ProfileManager(); + await reloaded.initialize(); + + expect(reloaded.profilesEnabled, isTrue); + expect(reloaded.activeProfileId, profile.id); + expect(reloaded.visibleProfiles.map((item) => item.id), [ + ConfigProfile.defaultProfileId, + profile.id, + ]); + expect(reloaded.getProfile(profile.id)?.name, profile.name); + expect(ProfileStorageScope.effectiveNamespace, profile.id); + + await reloaded.setProfilesEnabled(false); + + expect(ProfileStorageScope.profilesEnabled, isFalse); + expect(ProfileStorageScope.effectiveNamespace, isNull); + expect(reloaded.activeProfileId, profile.id); + }, + ); + }); +}