Enable profiles switch UI

This commit is contained in:
Janez T
2026-03-16 11:25:51 +01:00
parent fedd7b9a2b
commit 420690d683
30 changed files with 2858 additions and 269 deletions

View File

@@ -23,6 +23,9 @@ import 'services/voice_player_service.dart';
import 'services/notification_service.dart'; import 'services/notification_service.dart';
import 'services/locale_preferences.dart'; import 'services/locale_preferences.dart';
import 'services/mesh_map_nodes_service.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/update_checker_service.dart';
import 'services/wizard_preferences.dart'; import 'services/wizard_preferences.dart';
import 'screens/discovery_screen.dart'; import 'screens/discovery_screen.dart';
@@ -58,6 +61,14 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
} }
Future<void> _initializeApp() async { Future<void> _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 _loadThemePreference();
await _loadLocalePreference(); await _loadLocalePreference();
@@ -271,6 +282,13 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
), ),
ChangeNotifierProvider(create: (_) => ChannelsProvider()), ChangeNotifierProvider(create: (_) => ChannelsProvider()),
ChangeNotifierProvider(create: (_) => SensorsProvider()), ChangeNotifierProvider(create: (_) => SensorsProvider()),
ChangeNotifierProvider(
create: (_) {
final manager = ProfileManager();
manager.initialize();
return manager;
},
),
// Voice provider (packet reassembly + playback) // Voice provider (packet reassembly + playback)
ChangeNotifierProvider( ChangeNotifierProvider(
@@ -323,6 +341,36 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
imageProvider: context.read<ip.ImageProvider>(), imageProvider: context.read<ip.ImageProvider>(),
), ),
), ),
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<DrawingProvider>(),
channelsProvider: context.read<ChannelsProvider>(),
appProvider: appProvider,
),
),
], ],
child: _buildMaterialApp(), child: _buildMaterialApp(),
); );

View File

@@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic>? mapPrefs;
final List<Map<String, dynamic>> drawings;
final Map<String, dynamic>? currentTrail;
final List<Map<String, dynamic>> trailHistory;
final Map<String, dynamic>? 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<String, dynamic> 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<String, dynamic> json) {
List<Map<String, dynamic>> decodeList(String key) {
final raw = json[key] as List<dynamic>? ?? const [];
return raw.whereType<Map<String, dynamic>>().toList();
}
return MapWorkspaceProfileSection(
mapPrefs: json['mapPrefs'] as Map<String, dynamic>?,
drawings: decodeList('drawings'),
currentTrail: json['currentTrail'] as Map<String, dynamic>?,
trailHistory: decodeList('trailHistory'),
importedTrail: json['importedTrail'] as Map<String, dynamic>?,
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<Channel> 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<String, dynamic> toJson() => {
'deviceConfig': deviceConfig?.toJson(),
'appSettings': appSettings?.toJson(),
'mapWorkspace': mapWorkspace?.toJson(),
'channels': channels.map((channel) => channel.toJson()).toList(),
};
factory ConfigProfileSections.fromJson(Map<String, dynamic> json) {
final rawChannels = json['channels'] as List<dynamic>? ?? const [];
return ConfigProfileSections(
deviceConfig: json['deviceConfig'] is Map<String, dynamic>
? DeviceConfigProfileSection.fromJson(
json['deviceConfig'] as Map<String, dynamic>,
)
: null,
appSettings: json['appSettings'] is Map<String, dynamic>
? AppSettingsProfileSection.fromJson(
json['appSettings'] as Map<String, dynamic>,
)
: null,
mapWorkspace: json['mapWorkspace'] is Map<String, dynamic>
? MapWorkspaceProfileSection.fromJson(
json['mapWorkspace'] as Map<String, dynamic>,
)
: null,
channels: rawChannels
.whereType<Map<String, dynamic>>()
.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<String, dynamic> toJson() => {
'id': id,
'name': name,
'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(),
'notes': notes,
'sections': sections.toJson(),
};
factory ConfigProfile.fromJson(Map<String, dynamic> 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<String, dynamic>? ?? const {},
),
);
}
}
@immutable
class ProfileDiff {
final List<String> 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<String, dynamic> toJson() => {
'profileId': profileId,
'direction': direction,
'timestamp': timestamp.toIso8601String(),
'detail': detail,
};
factory ProfileTransferRecord.fromJson(Map<String, dynamic> 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,
);
}
}

View File

@@ -18,6 +18,7 @@ import '../services/messaging_route_preferences.dart';
import '../services/nearest_router_selector.dart'; import '../services/nearest_router_selector.dart';
import '../services/packet_capture_storage_service.dart'; import '../services/packet_capture_storage_service.dart';
import '../services/path_history_service.dart'; import '../services/path_history_service.dart';
import '../services/profiles_feature_service.dart';
import '../services/route_hash_preferences.dart'; import '../services/route_hash_preferences.dart';
import '../services/notification_service.dart'; import '../services/notification_service.dart';
import '../models/contact.dart'; import '../models/contact.dart';
@@ -253,6 +254,10 @@ class AppProvider with ChangeNotifier {
_isInitialized = true; _isInitialized = true;
} }
String _scopedKey(String baseKey) {
return ProfileStorageScope.scopedKey(baseKey);
}
void _startPacketCapturePersistence() { void _startPacketCapturePersistence() {
_packetCaptureFlushTimer?.cancel(); _packetCaptureFlushTimer?.cancel();
_packetCaptureFlushTimer = Timer.periodic(const Duration(seconds: 2), (_) { _packetCaptureFlushTimer = Timer.periodic(const Duration(seconds: 2), (_) {
@@ -516,7 +521,7 @@ class AppProvider with ChangeNotifier {
Future<void> _loadMapEnabled() async { Future<void> _loadMapEnabled() async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
_isMapEnabled = prefs.getBool('map_enabled') ?? true; _isMapEnabled = prefs.getBool(_scopedKey('map_enabled')) ?? true;
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
debugPrint('Error loading map enabled setting: $e'); debugPrint('Error loading map enabled setting: $e');
@@ -528,7 +533,7 @@ class AppProvider with ChangeNotifier {
try { try {
_isMapEnabled = enabled; _isMapEnabled = enabled;
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setBool('map_enabled', enabled); await prefs.setBool(_scopedKey('map_enabled'), enabled);
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
debugPrint('Error saving map enabled setting: $e'); debugPrint('Error saving map enabled setting: $e');
@@ -539,7 +544,8 @@ class AppProvider with ChangeNotifier {
Future<void> _loadContactsEnabled() async { Future<void> _loadContactsEnabled() async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
_isContactsEnabled = prefs.getBool('contacts_enabled') ?? true; _isContactsEnabled =
prefs.getBool(_scopedKey('contacts_enabled')) ?? true;
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
debugPrint('Error loading contacts enabled setting: $e'); debugPrint('Error loading contacts enabled setting: $e');
@@ -551,7 +557,7 @@ class AppProvider with ChangeNotifier {
try { try {
_isContactsEnabled = enabled; _isContactsEnabled = enabled;
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setBool('contacts_enabled', enabled); await prefs.setBool(_scopedKey('contacts_enabled'), enabled);
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
debugPrint('Error saving contacts enabled setting: $e'); debugPrint('Error saving contacts enabled setting: $e');
@@ -562,7 +568,7 @@ class AppProvider with ChangeNotifier {
Future<void> _loadSensorsEnabled() async { Future<void> _loadSensorsEnabled() async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
_isSensorsEnabled = prefs.getBool('sensors_enabled') ?? true; _isSensorsEnabled = prefs.getBool(_scopedKey('sensors_enabled')) ?? true;
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
debugPrint('Error loading sensors enabled setting: $e'); debugPrint('Error loading sensors enabled setting: $e');
@@ -574,7 +580,7 @@ class AppProvider with ChangeNotifier {
try { try {
_isSensorsEnabled = enabled; _isSensorsEnabled = enabled;
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setBool('sensors_enabled', enabled); await prefs.setBool(_scopedKey('sensors_enabled'), enabled);
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
debugPrint('Error saving sensors enabled setting: $e'); debugPrint('Error saving sensors enabled setting: $e');
@@ -586,7 +592,7 @@ class AppProvider with ChangeNotifier {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
_isVoiceSilenceTrimmingEnabled = _isVoiceSilenceTrimmingEnabled =
prefs.getBool('voice_silence_trimming_enabled') ?? true; prefs.getBool(_scopedKey('voice_silence_trimming_enabled')) ?? true;
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
debugPrint('Error loading voice silence trimming setting: $e'); debugPrint('Error loading voice silence trimming setting: $e');
@@ -598,7 +604,10 @@ class AppProvider with ChangeNotifier {
try { try {
_isVoiceSilenceTrimmingEnabled = enabled; _isVoiceSilenceTrimmingEnabled = enabled;
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setBool('voice_silence_trimming_enabled', enabled); await prefs.setBool(
_scopedKey('voice_silence_trimming_enabled'),
enabled,
);
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
debugPrint('Error saving voice silence trimming setting: $e'); debugPrint('Error saving voice silence trimming setting: $e');
@@ -610,7 +619,7 @@ class AppProvider with ChangeNotifier {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
_isVoiceBandPassFilterEnabled = _isVoiceBandPassFilterEnabled =
prefs.getBool('voice_band_pass_filter_enabled') ?? true; prefs.getBool(_scopedKey('voice_band_pass_filter_enabled')) ?? true;
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
debugPrint('Error loading voice band-pass filter setting: $e'); debugPrint('Error loading voice band-pass filter setting: $e');
@@ -622,7 +631,10 @@ class AppProvider with ChangeNotifier {
try { try {
_isVoiceBandPassFilterEnabled = enabled; _isVoiceBandPassFilterEnabled = enabled;
final prefs = await SharedPreferences.getInstance(); 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(); notifyListeners();
} catch (e) { } catch (e) {
debugPrint('Error saving voice band-pass filter setting: $e'); debugPrint('Error saving voice band-pass filter setting: $e');
@@ -634,7 +646,7 @@ class AppProvider with ChangeNotifier {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
_isVoiceCompressorEnabled = _isVoiceCompressorEnabled =
prefs.getBool('voice_compressor_enabled') ?? true; prefs.getBool(_scopedKey('voice_compressor_enabled')) ?? true;
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
debugPrint('Error loading voice compressor setting: $e'); debugPrint('Error loading voice compressor setting: $e');
@@ -646,7 +658,7 @@ class AppProvider with ChangeNotifier {
try { try {
_isVoiceCompressorEnabled = enabled; _isVoiceCompressorEnabled = enabled;
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setBool('voice_compressor_enabled', enabled); await prefs.setBool(_scopedKey('voice_compressor_enabled'), enabled);
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
debugPrint('Error saving voice compressor setting: $e'); debugPrint('Error saving voice compressor setting: $e');
@@ -657,7 +669,8 @@ class AppProvider with ChangeNotifier {
Future<void> _loadVoiceLimiterEnabled() async { Future<void> _loadVoiceLimiterEnabled() async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
_isVoiceLimiterEnabled = prefs.getBool('voice_limiter_enabled') ?? true; _isVoiceLimiterEnabled =
prefs.getBool(_scopedKey('voice_limiter_enabled')) ?? true;
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
debugPrint('Error loading voice limiter setting: $e'); debugPrint('Error loading voice limiter setting: $e');
@@ -669,7 +682,7 @@ class AppProvider with ChangeNotifier {
try { try {
_isVoiceLimiterEnabled = enabled; _isVoiceLimiterEnabled = enabled;
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setBool('voice_limiter_enabled', enabled); await prefs.setBool(_scopedKey('voice_limiter_enabled'), enabled);
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
debugPrint('Error saving voice limiter setting: $e'); debugPrint('Error saving voice limiter setting: $e');
@@ -680,7 +693,7 @@ class AppProvider with ChangeNotifier {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
_isVoiceAutoGainEnabled = _isVoiceAutoGainEnabled =
prefs.getBool('voice_auto_gain_enabled') ?? false; prefs.getBool(_scopedKey('voice_auto_gain_enabled')) ?? false;
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
debugPrint('Error loading voice auto gain setting: $e'); debugPrint('Error loading voice auto gain setting: $e');
@@ -691,7 +704,7 @@ class AppProvider with ChangeNotifier {
try { try {
_isVoiceAutoGainEnabled = enabled; _isVoiceAutoGainEnabled = enabled;
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setBool('voice_auto_gain_enabled', enabled); await prefs.setBool(_scopedKey('voice_auto_gain_enabled'), enabled);
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
debugPrint('Error saving voice auto gain setting: $e'); debugPrint('Error saving voice auto gain setting: $e');
@@ -702,7 +715,7 @@ class AppProvider with ChangeNotifier {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
_isVoiceEchoCancellationEnabled = _isVoiceEchoCancellationEnabled =
prefs.getBool('voice_echo_cancellation_enabled') ?? false; prefs.getBool(_scopedKey('voice_echo_cancellation_enabled')) ?? false;
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
debugPrint('Error loading voice echo cancellation setting: $e'); debugPrint('Error loading voice echo cancellation setting: $e');
@@ -713,7 +726,10 @@ class AppProvider with ChangeNotifier {
try { try {
_isVoiceEchoCancellationEnabled = enabled; _isVoiceEchoCancellationEnabled = enabled;
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setBool('voice_echo_cancellation_enabled', enabled); await prefs.setBool(
_scopedKey('voice_echo_cancellation_enabled'),
enabled,
);
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
debugPrint('Error saving voice echo cancellation setting: $e'); debugPrint('Error saving voice echo cancellation setting: $e');
@@ -724,7 +740,7 @@ class AppProvider with ChangeNotifier {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
_isVoiceNoiseSuppressionEnabled = _isVoiceNoiseSuppressionEnabled =
prefs.getBool('voice_noise_suppression_enabled') ?? false; prefs.getBool(_scopedKey('voice_noise_suppression_enabled')) ?? false;
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
debugPrint('Error loading voice noise suppression setting: $e'); debugPrint('Error loading voice noise suppression setting: $e');
@@ -735,7 +751,10 @@ class AppProvider with ChangeNotifier {
try { try {
_isVoiceNoiseSuppressionEnabled = enabled; _isVoiceNoiseSuppressionEnabled = enabled;
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setBool('voice_noise_suppression_enabled', enabled); await prefs.setBool(
_scopedKey('voice_noise_suppression_enabled'),
enabled,
);
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
debugPrint('Error saving voice noise suppression setting: $e'); debugPrint('Error saving voice noise suppression setting: $e');
@@ -745,10 +764,11 @@ class AppProvider with ChangeNotifier {
Future<void> _loadMessageFontScale() async { Future<void> _loadMessageFontScale() async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
_messageFontScale = (prefs.getDouble('message_font_scale') ?? 1.0).clamp( _messageFontScale =
0.85, (prefs.getDouble(_scopedKey('message_font_scale')) ?? 1.0).clamp(
1.4, 0.85,
); 1.4,
);
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
debugPrint('Error loading message font scale setting: $e'); debugPrint('Error loading message font scale setting: $e');
@@ -759,7 +779,10 @@ class AppProvider with ChangeNotifier {
try { try {
_messageFontScale = scale.clamp(0.85, 1.4); _messageFontScale = scale.clamp(0.85, 1.4);
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setDouble('message_font_scale', _messageFontScale); await prefs.setDouble(
_scopedKey('message_font_scale'),
_messageFontScale,
);
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
debugPrint('Error saving message font scale setting: $e'); debugPrint('Error saving message font scale setting: $e');
@@ -2600,6 +2623,24 @@ class AppProvider with ChangeNotifier {
locationTrackingService.setFastLocationActiveUse(isActive); locationTrackingService.setFastLocationActiveUse(isActive);
} }
Future<void> reloadProfileScopedSettings() async {
await Future.wait([
_loadMapEnabled(),
_loadContactsEnabled(),
_loadSensorsEnabled(),
_loadVoiceSilenceTrimmingEnabled(),
_loadVoiceBandPassFilterEnabled(),
_loadVoiceCompressorEnabled(),
_loadVoiceLimiterEnabled(),
_loadVoiceAutoGainEnabled(),
_loadVoiceEchoCancellationEnabled(),
_loadVoiceNoiseSuppressionEnabled(),
_loadMessageFontScale(),
_loadMessagingRouteSettings(),
locationTrackingService.loadSettings(),
]);
}
Future<void> _sendFastLocationUpdate( Future<void> _sendFastLocationUpdate(
dynamic position, { dynamic position, {
required String reason, required String reason,

View File

@@ -1325,6 +1325,31 @@ class ConnectionProvider with ChangeNotifier {
} }
} }
Future<void> 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 /// Generate secret for hash channel using SHA256
/// Same algorithm as Channel model for consistency /// Same algorithm as Channel model for consistency
/// Python equivalent: hashlib.sha256(channel_name.encode()).digest()[0:16] /// Python equivalent: hashlib.sha256(channel_name.encode()).digest()[0:16]

View File

@@ -134,6 +134,7 @@ class ContactsProvider with ChangeNotifier {
bool _persistRequested = false; bool _persistRequested = false;
bool _isPersistingPendingAdverts = false; bool _isPersistingPendingAdverts = false;
bool _persistPendingAdvertsRequested = false; bool _persistPendingAdvertsRequested = false;
String? _storageNamespace;
// Add default public channel on initialization // Add default public channel on initialization
ContactsProvider() { ContactsProvider() {
@@ -141,19 +142,74 @@ class ContactsProvider with ChangeNotifier {
} }
bool get isInitialized => _isInitialized; bool get isInitialized => _isInitialized;
String? get storageNamespace => _storageNamespace;
/// Initialize and load persisted contacts at app startup /// Initialize and load persisted contacts at app startup
/// This loads contacts without filtering, allowing offline viewing /// This loads contacts without filtering, allowing offline viewing
Future<void> initializeEarly() async { Future<void> initializeEarly() async {
if (_isInitialized) return; if (_isInitialized) return;
await _loadFromStorage();
}
Future<void> reloadFromStorage({
String? namespace,
Uint8List? devicePublicKey,
}) async {
_storageNamespace = namespace;
await _loadFromStorage(force: true, devicePublicKey: devicePublicKey);
}
Future<void> 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<void> 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<void> _loadFromStorage({
bool force = false,
Uint8List? devicePublicKey,
}) async {
if (_isInitialized && !force) return;
try { try {
_resetInMemoryState();
debugPrint( debugPrint(
'📦 [ContactsProvider] Early loading persisted contacts (no filtering)...', '📦 [ContactsProvider] Early loading persisted contacts (no filtering)...',
); );
final storedContacts = await _storageService.loadContacts(); final storedContacts = await _storageService.loadContacts(
final storedGroups = await _storageService.loadContactGroups(); namespace: _storageNamespace,
final storedPendingAdverts = await _storageService.loadPendingAdverts(); );
final storedGroups = await _storageService.loadContactGroups(
namespace: _storageNamespace,
);
final storedPendingAdverts = await _storageService.loadPendingAdverts(
namespace: _storageNamespace,
);
// Add stored contacts (excluding any with all-zeros public key) // Add stored contacts (excluding any with all-zeros public key)
const publicChannelKey = const publicChannelKey =
@@ -170,7 +226,10 @@ class ContactsProvider with ChangeNotifier {
_savedContactGroups _savedContactGroups
..clear() ..clear()
..addAll(storedGroups); ..addAll(storedGroups);
_restorePendingAdverts(storedPendingAdverts); _restorePendingAdverts(
storedPendingAdverts,
devicePublicKey: devicePublicKey,
);
debugPrint( debugPrint(
'✅ [ContactsProvider] Early loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups', '✅ [ContactsProvider] Early loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups',
); );
@@ -197,45 +256,9 @@ class ContactsProvider with ChangeNotifier {
return; return;
} }
try { await _loadFromStorage(force: true, devicePublicKey: devicePublicKey);
debugPrint('📦 [ContactsProvider] Loading persisted contacts...'); if (devicePublicKey != null) {
final storedContacts = await _storageService.loadContacts( _removeSelfContact(devicePublicKey);
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();
} }
} }
@@ -287,7 +310,10 @@ class ContactsProvider with ChangeNotifier {
try { try {
while (_persistRequested) { while (_persistRequested) {
_persistRequested = false; _persistRequested = false;
await _storageService.saveContacts(_contactsForStorage()); await _storageService.saveContacts(
_contactsForStorage(),
namespace: _storageNamespace,
);
} }
} catch (e) { } catch (e) {
debugPrint('❌ [ContactsProvider] Error persisting contacts: $e'); debugPrint('❌ [ContactsProvider] Error persisting contacts: $e');
@@ -305,6 +331,7 @@ class ContactsProvider with ChangeNotifier {
_persistPendingAdvertsRequested = false; _persistPendingAdvertsRequested = false;
await _storageService.savePendingAdverts( await _storageService.savePendingAdverts(
_pendingAdverts.values.map(_pendingAdvertToJson).toList(), _pendingAdverts.values.map(_pendingAdvertToJson).toList(),
namespace: _storageNamespace,
); );
} }
} catch (e) { } catch (e) {
@@ -436,7 +463,10 @@ class ContactsProvider with ChangeNotifier {
Future<void> _persistSavedGroups() async { Future<void> _persistSavedGroups() async {
try { try {
await _storageService.saveContactGroups(_savedContactGroups); await _storageService.saveContactGroups(
_savedContactGroups,
namespace: _storageNamespace,
);
} catch (e) { } catch (e) {
debugPrint('❌ [ContactsProvider] Error persisting contact groups: $e'); debugPrint('❌ [ContactsProvider] Error persisting contact groups: $e');
} }
@@ -1442,11 +1472,7 @@ class ContactsProvider with ChangeNotifier {
/// Get storage statistics /// Get storage statistics
Future<Map<String, dynamic>> getStorageStats() async { Future<Map<String, dynamic>> getStorageStats() async {
return await _storageService.getStorageStats(); return await _storageService.getStorageStats(namespace: _storageNamespace);
}
Future<void> persistNow() async {
await _storageService.saveContacts(_contactsForStorage());
} }
Future<void> clearPendingAdverts() async { Future<void> clearPendingAdverts() async {
@@ -1454,7 +1480,7 @@ class ContactsProvider with ChangeNotifier {
return; return;
} }
_pendingAdverts.clear(); _pendingAdverts.clear();
await _storageService.clearPendingAdverts(); await _storageService.clearPendingAdverts(namespace: _storageNamespace);
notifyListeners(); notifyListeners();
} }
@@ -1517,6 +1543,12 @@ class ContactsProvider with ChangeNotifier {
} }
} }
void _resetInMemoryState() {
_contacts.clear();
_savedContactGroups.clear();
_pendingAdverts.clear();
}
Map<String, dynamic> _pendingAdvertToJson(PendingAdvert advert) { Map<String, dynamic> _pendingAdvertToJson(PendingAdvert advert) {
return { return {
'publicKey': base64Encode(advert.publicKey), 'publicKey': base64Encode(advert.publicKey),

View File

@@ -5,6 +5,7 @@ import 'package:latlong2/latlong.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/map_drawing.dart'; import '../models/map_drawing.dart';
import '../models/map_coordinate_space.dart'; import '../models/map_coordinate_space.dart';
import '../services/profiles_feature_service.dart';
import '../utils/drawing_message_parser.dart'; import '../utils/drawing_message_parser.dart';
/// Drawing mode state /// Drawing mode state
@@ -85,6 +86,12 @@ class DrawingProvider with ChangeNotifier {
_isInitialized = true; _isInitialized = true;
} }
Future<void> reloadProfileScopedState() async {
await _loadPreferences();
await _loadDrawings();
notifyListeners();
}
void setMapContext({ void setMapContext({
required MapCoordinateSpace coordinateSpace, required MapCoordinateSpace coordinateSpace,
String? mapId, String? mapId,
@@ -140,15 +147,19 @@ class DrawingProvider with ChangeNotifier {
Future<void> _loadPreferences() async { Future<void> _loadPreferences() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
_showReceivedDrawings = prefs.getBool(_showReceivedDrawingsKey) ?? true; _showReceivedDrawings =
_showSarMarkers = prefs.getBool(_showSarMarkersKey) ?? true; prefs.getBool(_scopedKey(_showReceivedDrawingsKey)) ?? true;
_showSarMarkers = prefs.getBool(_scopedKey(_showSarMarkersKey)) ?? true;
notifyListeners(); notifyListeners();
} }
Future<void> _savePreferences() async { Future<void> _savePreferences() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_showReceivedDrawingsKey, _showReceivedDrawings); await prefs.setBool(
await prefs.setBool(_showSarMarkersKey, _showSarMarkers); _scopedKey(_showReceivedDrawingsKey),
_showReceivedDrawings,
);
await prefs.setBool(_scopedKey(_showSarMarkersKey), _showSarMarkers);
} }
/// Start drawing a line /// Start drawing a line
@@ -365,7 +376,7 @@ class DrawingProvider with ChangeNotifier {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final jsonList = _drawings.map((d) => d.toJson()).toList(); final jsonList = _drawings.map((d) => d.toJson()).toList();
final jsonString = jsonEncode(jsonList); final jsonString = jsonEncode(jsonList);
await prefs.setString(_storageKey, jsonString); await prefs.setString(_scopedKey(_storageKey), jsonString);
} catch (e) { } catch (e) {
debugPrint('Error saving drawings: $e'); debugPrint('Error saving drawings: $e');
} }
@@ -375,8 +386,11 @@ class DrawingProvider with ChangeNotifier {
Future<void> _loadDrawings() async { Future<void> _loadDrawings() async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_storageKey); final jsonString = prefs.getString(_scopedKey(_storageKey));
if (jsonString == null) return; if (jsonString == null || jsonString.isEmpty) {
_drawings.clear();
return;
}
final jsonList = jsonDecode(jsonString) as List<dynamic>; final jsonList = jsonDecode(jsonString) as List<dynamic>;
_drawings.clear(); _drawings.clear();
@@ -475,6 +489,24 @@ class DrawingProvider with ChangeNotifier {
} }
} }
List<Map<String, dynamic>> exportDrawingsJson() {
return _drawings.map((drawing) => drawing.toJson()).toList();
}
Future<void> replaceDrawingsFromJson(
List<Map<String, dynamic>> jsonList,
) async {
_drawings
..clear()
..addAll(jsonList.map(MapDrawing.fromJson).whereType<MapDrawing>());
await _saveDrawings();
notifyListeners();
}
String _scopedKey(String baseKey) {
return ProfileStorageScope.scopedKey(baseKey);
}
/// Get all unshared drawings (local drawings not yet sent) /// Get all unshared drawings (local drawings not yet sent)
List<MapDrawing> getUnsharedDrawings() { List<MapDrawing> getUnsharedDrawings() {
return drawings.where((d) => !d.isShared && !d.isReceived).toList(); return drawings.where((d) => !d.isShared && !d.isReceived).toList();

View File

@@ -16,6 +16,7 @@ import '../models/location_trail.dart';
import '../models/map_coordinate_space.dart'; import '../models/map_coordinate_space.dart';
import '../models/map_drawing.dart'; import '../models/map_drawing.dart';
import '../models/sar_marker.dart'; import '../models/sar_marker.dart';
import '../services/profiles_feature_service.dart';
import '../utils/custom_map_id.dart'; import '../utils/custom_map_id.dart';
class MapProvider with ChangeNotifier { class MapProvider with ChangeNotifier {
@@ -105,6 +106,10 @@ class MapProvider with ChangeNotifier {
LatLngBounds? get customMapBounds => _customMapConfig?.bounds; LatLngBounds? get customMapBounds => _customMapConfig?.bounds;
Future<void> reloadProfileScopedState() async {
await _loadInitialState();
}
bool matchesActiveCustomMap(String? mapId) { bool matchesActiveCustomMap(String? mapId) {
return hasCustomMap && return hasCustomMap &&
normalizeCustomMapId(_customMapConfig!.mapId) == normalizeCustomMapId(_customMapConfig!.mapId) ==
@@ -522,27 +527,29 @@ class MapProvider with ChangeNotifier {
Future<void> loadOverlayState() async { Future<void> loadOverlayState() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
_showCadastralOverlay = _showCadastralOverlay =
prefs.getBool('map_show_cadastral_overlay') ?? false; prefs.getBool(_scopedKey('map_show_cadastral_overlay')) ?? false;
_showForestRoadsOverlay = _showForestRoadsOverlay =
prefs.getBool('map_show_forest_roads_overlay') ?? false; prefs.getBool(_scopedKey('map_show_forest_roads_overlay')) ?? false;
_showHikingTrailsOverlay = _showHikingTrailsOverlay =
prefs.getBool('map_show_hiking_trails_overlay') ?? false; prefs.getBool(_scopedKey('map_show_hiking_trails_overlay')) ?? false;
_showMainRoadsOverlay = _showMainRoadsOverlay =
prefs.getBool('map_show_main_roads_overlay') ?? false; prefs.getBool(_scopedKey('map_show_main_roads_overlay')) ?? false;
_showHouseNumbersOverlay = _showHouseNumbersOverlay =
prefs.getBool('map_show_house_numbers_overlay') ?? false; prefs.getBool(_scopedKey('map_show_house_numbers_overlay')) ?? false;
_showFireHazardZonesOverlay = _showFireHazardZonesOverlay =
prefs.getBool('map_show_fire_hazard_zones_overlay') ?? false; prefs.getBool(_scopedKey('map_show_fire_hazard_zones_overlay')) ??
false;
_showHistoricalFiresOverlay = _showHistoricalFiresOverlay =
prefs.getBool('map_show_historical_fires_overlay') ?? false; prefs.getBool(_scopedKey('map_show_historical_fires_overlay')) ?? false;
_showFirebreaksOverlay = _showFirebreaksOverlay =
prefs.getBool('map_show_firebreaks_overlay') ?? false; prefs.getBool(_scopedKey('map_show_firebreaks_overlay')) ?? false;
_showKrasFireZonesOverlay = _showKrasFireZonesOverlay =
prefs.getBool('map_show_kras_fire_zones_overlay') ?? false; prefs.getBool(_scopedKey('map_show_kras_fire_zones_overlay')) ?? false;
_showPlaceNamesOverlay = _showPlaceNamesOverlay =
prefs.getBool('map_show_place_names_overlay') ?? false; prefs.getBool(_scopedKey('map_show_place_names_overlay')) ?? false;
_showMunicipalityBordersOverlay = _showMunicipalityBordersOverlay =
prefs.getBool('map_show_municipality_borders_overlay') ?? false; prefs.getBool(_scopedKey('map_show_municipality_borders_overlay')) ??
false;
notifyListeners(); notifyListeners();
} }
@@ -557,36 +564,48 @@ class MapProvider with ChangeNotifier {
Future<void> _saveOverlayState() async { Future<void> _saveOverlayState() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setBool('map_show_cadastral_overlay', _showCadastralOverlay);
await prefs.setBool( await prefs.setBool(
'map_show_forest_roads_overlay', _scopedKey('map_show_cadastral_overlay'),
_showCadastralOverlay,
);
await prefs.setBool(
_scopedKey('map_show_forest_roads_overlay'),
_showForestRoadsOverlay, _showForestRoadsOverlay,
); );
await prefs.setBool( await prefs.setBool(
'map_show_hiking_trails_overlay', _scopedKey('map_show_hiking_trails_overlay'),
_showHikingTrailsOverlay, _showHikingTrailsOverlay,
); );
await prefs.setBool('map_show_main_roads_overlay', _showMainRoadsOverlay);
await prefs.setBool( 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, _showHouseNumbersOverlay,
); );
await prefs.setBool( await prefs.setBool(
'map_show_fire_hazard_zones_overlay', _scopedKey('map_show_fire_hazard_zones_overlay'),
_showFireHazardZonesOverlay, _showFireHazardZonesOverlay,
); );
await prefs.setBool( await prefs.setBool(
'map_show_historical_fires_overlay', _scopedKey('map_show_historical_fires_overlay'),
_showHistoricalFiresOverlay, _showHistoricalFiresOverlay,
); );
await prefs.setBool('map_show_firebreaks_overlay', _showFirebreaksOverlay);
await prefs.setBool( 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, _showKrasFireZonesOverlay,
); );
await prefs.setBool('map_show_place_names_overlay', _showPlaceNamesOverlay);
await prefs.setBool( 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, _showMunicipalityBordersOverlay,
); );
} }
@@ -600,13 +619,16 @@ class MapProvider with ChangeNotifier {
Future<void> loadTrailSettings() async { Future<void> loadTrailSettings() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
_showAllContactTrails = _showAllContactTrails =
prefs.getBool('map_show_all_contact_trails') ?? true; prefs.getBool(_scopedKey('map_show_all_contact_trails')) ?? true;
notifyListeners(); notifyListeners();
} }
Future<void> _saveTrailSettings() async { Future<void> _saveTrailSettings() async {
final prefs = await SharedPreferences.getInstance(); 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<void> setHideRepeatersOnMap(bool hide) async { Future<void> setHideRepeatersOnMap(bool hide) async {
@@ -614,12 +636,13 @@ class MapProvider with ChangeNotifier {
_hideRepeatersOnMap = hide; _hideRepeatersOnMap = hide;
notifyListeners(); notifyListeners();
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setBool('map_hide_repeaters', _hideRepeatersOnMap); await prefs.setBool(_scopedKey('map_hide_repeaters'), _hideRepeatersOnMap);
} }
Future<void> loadRepeaterVisibilitySettings() async { Future<void> loadRepeaterVisibilitySettings() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
_hideRepeatersOnMap = prefs.getBool('map_hide_repeaters') ?? false; _hideRepeatersOnMap =
prefs.getBool(_scopedKey('map_hide_repeaters')) ?? false;
notifyListeners(); notifyListeners();
} }
@@ -661,7 +684,7 @@ class MapProvider with ChangeNotifier {
Future<void> _loadCustomMapState() async { Future<void> _loadCustomMapState() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final configJson = prefs.getString(_customMapConfigKey); final configJson = prefs.getString(_scopedKey(_customMapConfigKey));
if (configJson != null && configJson.isNotEmpty) { if (configJson != null && configJson.isNotEmpty) {
final decoded = jsonDecode(configJson); final decoded = jsonDecode(configJson);
if (decoded is Map<String, dynamic>) { if (decoded is Map<String, dynamic>) {
@@ -672,23 +695,26 @@ class MapProvider with ChangeNotifier {
_customMapConfig = null; _customMapConfig = null;
} }
} }
} else {
_customMapConfig = null;
} }
_isUsingCustomMap = _isUsingCustomMap =
(prefs.getBool(_customMapModeKey) ?? false) && _customMapConfig != null; (prefs.getBool(_scopedKey(_customMapModeKey)) ?? false) &&
_customMapConfig != null;
notifyListeners(); notifyListeners();
} }
Future<void> _saveCustomMapState() async { Future<void> _saveCustomMapState() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
if (_customMapConfig == null) { if (_customMapConfig == null) {
await prefs.remove(_customMapConfigKey); await prefs.remove(_scopedKey(_customMapConfigKey));
} else { } else {
await prefs.setString( await prefs.setString(
_customMapConfigKey, _scopedKey(_customMapConfigKey),
jsonEncode(_customMapConfig!.toJson()), jsonEncode(_customMapConfig!.toJson()),
); );
} }
await prefs.setBool(_customMapModeKey, _isUsingCustomMap); await prefs.setBool(_scopedKey(_customMapModeKey), _isUsingCustomMap);
} }
Future<(int, int)> _decodeImageSize(Uint8List bytes) async { Future<(int, int)> _decodeImageSize(Uint8List bytes) async {
@@ -711,4 +737,67 @@ class MapProvider with ChangeNotifier {
await file.delete(); await file.delete();
} }
} }
Map<String, dynamic> 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<String, dynamic> json) {
_currentTrail = json['currentTrail'] is Map<String, dynamic>
? LocationTrail.fromJson(json['currentTrail'] as Map<String, dynamic>)
: null;
_trailHistory
..clear()
..addAll(
(json['trailHistory'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(LocationTrail.fromJson),
);
_importedTrail = json['importedTrail'] is Map<String, dynamic>
? LocationTrail.fromJson(json['importedTrail'] as Map<String, dynamic>)
: 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);
}
} }

View File

@@ -37,6 +37,7 @@ class MessagesProvider with ChangeNotifier {
final Map<String, MessageReceptionDetails> _messageReceptionDetails = {}; final Map<String, MessageReceptionDetails> _messageReceptionDetails = {};
final Map<String, MessageTransferDetails> _messageTransferDetails = {}; final Map<String, MessageTransferDetails> _messageTransferDetails = {};
final Map<String, MessageRouteMetadata> _messageRouteMetadata = {}; final Map<String, MessageRouteMetadata> _messageRouteMetadata = {};
String? _storageNamespace;
// Track pending sent messages by expected ACK/TAG // Track pending sent messages by expected ACK/TAG
final Map<int, Message> _pendingSentMessages = {}; final Map<int, Message> _pendingSentMessages = {};
@@ -149,6 +150,7 @@ class MessagesProvider with ChangeNotifier {
sarMarkers.where((m) => m.type == SarMarkerType.object).toList(); sarMarkers.where((m) => m.type == SarMarkerType.object).toList();
bool get isInitialized => _isInitialized; bool get isInitialized => _isInitialized;
String? get storageNamespace => _storageNamespace;
String? get targetMessageId => _targetMessageId; String? get targetMessageId => _targetMessageId;
@@ -311,19 +313,64 @@ class MessagesProvider with ChangeNotifier {
Future<void> initialize() async { Future<void> initialize() async {
if (_isInitialized) return; if (_isInitialized) return;
await _loadFromStorage();
}
Future<void> reloadFromStorage({String? namespace}) async {
_storageNamespace = namespace;
await _loadFromStorage(force: true);
}
Future<void> persistNow() async {
await _storageService.saveMessages(
_messages,
messageContactLocations: _messageContactLocations,
messageReceptionDetails: _messageReceptionDetails,
messageTransferDetails: _messageTransferDetails,
messageRouteMetadata: _messageRouteMetadata,
namespace: _storageNamespace,
);
await _storageService.saveRemovedSarMarkerIds(
_removedSarMarkerIds,
namespace: _storageNamespace,
);
}
Future<void> 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<void> _loadFromStorage({bool force = false}) async {
if (_isInitialized && !force) return;
try { try {
_cancelAllTimers();
_resetInMemoryState();
debugPrint('📦 [MessagesProvider] Loading persisted messages...'); debugPrint('📦 [MessagesProvider] Loading persisted messages...');
final storedMessages = await _storageService.loadMessages(); final storedMessages = await _storageService.loadMessages(
namespace: _storageNamespace,
);
final storedContactLocations = await _storageService final storedContactLocations = await _storageService
.loadMessageContactLocations(); .loadMessageContactLocations(namespace: _storageNamespace);
final storedReceptionDetails = await _storageService final storedReceptionDetails = await _storageService
.loadMessageReceptionDetails(); .loadMessageReceptionDetails(namespace: _storageNamespace);
final storedTransferDetails = await _storageService final storedTransferDetails = await _storageService
.loadMessageTransferDetails(); .loadMessageTransferDetails(namespace: _storageNamespace);
final storedRouteMetadata = await _storageService final storedRouteMetadata = await _storageService
.loadMessageRouteMetadata(); .loadMessageRouteMetadata(namespace: _storageNamespace);
final storedRemovedSarMarkerIds = await _storageService final storedRemovedSarMarkerIds = await _storageService
.loadRemovedSarMarkerIds(); .loadRemovedSarMarkerIds(namespace: _storageNamespace);
_messageContactLocations _messageContactLocations
..clear() ..clear()
..addAll(storedContactLocations); ..addAll(storedContactLocations);
@@ -1051,6 +1098,7 @@ class MessagesProvider with ChangeNotifier {
messageReceptionDetails: _messageReceptionDetails, messageReceptionDetails: _messageReceptionDetails,
messageTransferDetails: _messageTransferDetails, messageTransferDetails: _messageTransferDetails,
messageRouteMetadata: _messageRouteMetadata, messageRouteMetadata: _messageRouteMetadata,
namespace: _storageNamespace,
); );
} }
} catch (e) { } catch (e) {
@@ -1199,7 +1247,10 @@ class MessagesProvider with ChangeNotifier {
Future<void> removeSarMarker(String id) async { Future<void> removeSarMarker(String id) async {
_sarMarkers.remove(id); _sarMarkers.remove(id);
_removedSarMarkerIds.add(id); _removedSarMarkerIds.add(id);
await _storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds); await _storageService.saveRemovedSarMarkerIds(
_removedSarMarkerIds,
namespace: _storageNamespace,
);
notifyListeners(); notifyListeners();
} }
@@ -1324,7 +1375,12 @@ class MessagesProvider with ChangeNotifier {
debugPrint('🗑️ [MessagesProvider] Message $messageId deleted'); debugPrint('🗑️ [MessagesProvider] Message $messageId deleted');
_persistMessages(); _persistMessages();
unawaited(_storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds)); unawaited(
_storageService.saveRemovedSarMarkerIds(
_removedSarMarkerIds,
namespace: _storageNamespace,
),
);
notifyListeners(); notifyListeners();
} }
} }
@@ -1348,23 +1404,15 @@ class MessagesProvider with ChangeNotifier {
/// Clear all messages /// Clear all messages
void clearMessages() { void clearMessages() {
_cancelAllTimers(); _cancelAllTimers();
_messages.clear(); _resetInMemoryState();
_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();
_retryManager.clearAll(); _retryManager.clearAll();
_persistMessages(); _persistMessages();
unawaited(_storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds)); unawaited(
_storageService.saveRemovedSarMarkerIds(
_removedSarMarkerIds,
namespace: _storageNamespace,
),
);
notifyListeners(); notifyListeners();
} }
@@ -1372,13 +1420,29 @@ class MessagesProvider with ChangeNotifier {
Future<void> clearSarMarkers() async { Future<void> clearSarMarkers() async {
_removedSarMarkerIds.addAll(_sarMarkers.keys); _removedSarMarkerIds.addAll(_sarMarkers.keys);
_sarMarkers.clear(); _sarMarkers.clear();
await _storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds); await _storageService.saveRemovedSarMarkerIds(
_removedSarMarkerIds,
namespace: _storageNamespace,
);
notifyListeners(); notifyListeners();
} }
/// Clear all data /// Clear all data
void clearAll() { void clearAll() {
_cancelAllTimers(); _cancelAllTimers();
_resetInMemoryState();
_retryManager.clearAll();
_persistMessages();
unawaited(
_storageService.saveRemovedSarMarkerIds(
_removedSarMarkerIds,
namespace: _storageNamespace,
),
);
notifyListeners();
}
void _resetInMemoryState() {
_messages.clear(); _messages.clear();
_sarMarkers.clear(); _sarMarkers.clear();
_removedSarMarkerIds.clear(); _removedSarMarkerIds.clear();
@@ -1393,10 +1457,6 @@ class MessagesProvider with ChangeNotifier {
_messageAckHistory.clear(); _messageAckHistory.clear();
_ackHistoryLookup.clear(); _ackHistoryLookup.clear();
_completedAckHistory.clear(); _completedAckHistory.clear();
_retryManager.clearAll();
_persistMessages();
unawaited(_storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds));
notifyListeners();
} }
int transferCountForSession({ int transferCountForSession({

View File

@@ -20,6 +20,7 @@ import 'settings_screen.dart';
import 'device_config_screen.dart'; import 'device_config_screen.dart';
import 'packet_log_screen.dart'; import 'packet_log_screen.dart';
import 'live_traffic_screen.dart'; import 'live_traffic_screen.dart';
import 'profiles_screen.dart';
import 'spectrum_scan_screen.dart'; import 'spectrum_scan_screen.dart';
import '../utils/toast_logger.dart'; import '../utils/toast_logger.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
@@ -28,6 +29,8 @@ import '../widgets/connection_dialog.dart';
import '../utils/battery_display_helper.dart'; import '../utils/battery_display_helper.dart';
import '../services/developer_mode_service.dart'; import '../services/developer_mode_service.dart';
import '../services/mesh_map_nodes_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 } enum _HomeTab { messages, contacts, sensors, map }
@@ -237,7 +240,11 @@ class _HomeScreenState extends State<HomeScreen>
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
if (mounted) { if (mounted) {
setState(() { 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<HomeScreen>
icon: const Icon(Icons.more_vert), icon: const Icon(Icons.more_vert),
itemBuilder: (context) { itemBuilder: (context) {
final items = <PopupMenuEntry<void>>[]; final items = <PopupMenuEntry<void>>[];
final profilesEnabled = context
.read<ProfileManager>()
.profilesEnabled;
if (_isDeveloperModeEnabled) { if (_isDeveloperModeEnabled) {
items.add( items.add(
@@ -776,6 +786,31 @@ class _HomeScreenState extends State<HomeScreen>
), ),
); );
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; return items;
}, },
), ),

View File

@@ -28,6 +28,7 @@ import '../services/location_tracking_service.dart';
import '../services/map_marker_service.dart'; import '../services/map_marker_service.dart';
import '../services/message_destination_preferences.dart'; import '../services/message_destination_preferences.dart';
import '../services/trail_color_service.dart'; import '../services/trail_color_service.dart';
import '../services/profiles_feature_service.dart';
import '../widgets/map_debug_info.dart'; import '../widgets/map_debug_info.dart';
import '../widgets/map/compass_widget.dart'; import '../widgets/map/compass_widget.dart';
import '../widgets/map/detailed_compass_dialog.dart'; import '../widgets/map/detailed_compass_dialog.dart';
@@ -244,25 +245,49 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
if (mounted) { if (mounted) {
// Load last map position if available // Load last map position if available
final lastLat = prefs.getDouble('map_last_latitude'); final lastLat = prefs.getDouble(
final lastLon = prefs.getDouble('map_last_longitude'); ProfileStorageScope.scopedKey('map_last_latitude'),
final lastZoom = prefs.getDouble('map_last_zoom'); );
final lastLon = prefs.getDouble(
ProfileStorageScope.scopedKey('map_last_longitude'),
);
final lastZoom = prefs.getDouble(
ProfileStorageScope.scopedKey('map_last_zoom'),
);
// Load last map layer // Load last map layer
final lastLayerType = prefs.getInt('map_last_layer_type'); final lastLayerType = prefs.getInt(
ProfileStorageScope.scopedKey('map_last_layer_type'),
);
setState(() { setState(() {
_rotateMarkerWithHeading = _rotateMarkerWithHeading =
prefs.getBool('map_rotate_with_heading') ?? false; prefs.getBool(
_showMapDebugInfo = prefs.getBool('map_show_debug_info') ?? false; ProfileStorageScope.scopedKey('map_rotate_with_heading'),
_isFullscreen = prefs.getBool('map_fullscreen') ?? false; ) ??
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 // Notify parent about initial fullscreen state
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onFullscreenChanged?.call(_isFullscreen); 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 = _backgroundTrackingEnabled =
prefs.getBool('background_tracking_enabled') ?? false; prefs.getBool(
ProfileStorageScope.scopedKey('background_tracking_enabled'),
) ??
false;
// Store saved position for use in build // Store saved position for use in build
if (lastLat != null && lastLon != null && lastZoom != null) { if (lastLat != null && lastLon != null && lastZoom != null) {
@@ -298,18 +323,33 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
Future<void> _saveSettings() async { Future<void> _saveSettings() async {
final prefs = await SharedPreferences.getInstance(); 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( 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, _backgroundTrackingEnabled,
); );
// Save layer type. // Save layer type.
await prefs.setInt('map_last_layer_type', _currentLayer.type.index); await prefs.setInt(
await prefs.remove('map_last_layer_name'); ProfileStorageScope.scopedKey('map_last_layer_type'),
_currentLayer.type.index,
);
await prefs.remove(ProfileStorageScope.scopedKey('map_last_layer_name'));
} }
Future<void> _saveMapPosition() async { Future<void> _saveMapPosition() async {
@@ -318,9 +358,18 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final camera = _mapController.camera; final camera = _mapController.camera;
await prefs.setDouble('map_last_latitude', camera.center.latitude); await prefs.setDouble(
await prefs.setDouble('map_last_longitude', camera.center.longitude); ProfileStorageScope.scopedKey('map_last_latitude'),
await prefs.setDouble('map_last_zoom', camera.zoom); 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) { } catch (e) {
debugPrint('Error saving map position: $e'); debugPrint('Error saving map position: $e');
} }

View File

@@ -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<ProfileManager>(
builder: (context, profileManager, child) {
final profiles = profileManager.visibleProfiles;
return Scaffold(
appBar: AppBar(
title: const Text('Profiles'),
actions: [
IconButton(
onPressed: () async {
await context
.read<ProfileWorkspaceCoordinator>()
.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<ProfileWorkspaceCoordinator>()
.openProfile(profile.id);
},
child: const Text('Open'),
),
FilledButton(
onPressed: () async {
await context
.read<ProfileWorkspaceCoordinator>()
.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<ProfileWorkspaceCoordinator>()
.exportProfile(resolved);
},
child: const Text('Share'),
),
PopupMenuButton<String>(
onSelected: (value) async {
switch (value) {
case 'duplicate':
await context
.read<ProfileWorkspaceCoordinator>()
.duplicateProfile(profile);
break;
case 'rename':
await _renameProfile(context, profile);
break;
case 'delete':
await context
.read<ProfileWorkspaceCoordinator>()
.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 = <String>[];
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<void> _createProfile(BuildContext context) async {
final controller = TextEditingController();
final name = await showDialog<String>(
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<ProfileWorkspaceCoordinator>().createProfileFromCurrent(
name: name,
);
}
Future<void> _renameProfile(
BuildContext context,
ConfigProfile profile,
) async {
final controller = TextEditingController(text: profile.name);
final name = await showDialog<String>(
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<ProfileWorkspaceCoordinator>().renameProfile(
profile,
name,
);
}
}

View File

@@ -16,6 +16,7 @@ import '../providers/app_provider.dart';
import '../providers/connection_provider.dart'; import '../providers/connection_provider.dart';
import '../providers/drawing_provider.dart'; import '../providers/drawing_provider.dart';
import '../providers/map_provider.dart'; import '../providers/map_provider.dart';
import '../models/config_profile.dart';
import '../services/location_tracking_service.dart'; import '../services/location_tracking_service.dart';
import '../services/locale_preferences.dart'; import '../services/locale_preferences.dart';
import '../services/mesh_map_nodes_service.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/image_codec_service.dart';
import '../services/developer_mode_service.dart'; import '../services/developer_mode_service.dart';
import '../services/notification_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/sample_data_generator.dart';
import '../utils/image_message_parser.dart'; import '../utils/image_message_parser.dart';
import '../utils/voice_message_parser.dart'; import '../utils/voice_message_parser.dart';
@@ -33,6 +37,7 @@ import '../theme/app_theme.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../widgets/update_dialog.dart'; import '../widgets/update_dialog.dart';
import 'sar_template_management_screen.dart'; import 'sar_template_management_screen.dart';
import 'profiles_screen.dart';
import 'welcome_wizard_screen.dart'; import 'welcome_wizard_screen.dart';
class SettingsScreen extends StatefulWidget { class SettingsScreen extends StatefulWidget {
@@ -83,6 +88,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _updateNotificationsEnabled = true; bool _updateNotificationsEnabled = true;
bool _muteForegroundNotifications = true; bool _muteForegroundNotifications = true;
bool _isDeveloperModeEnabled = false; bool _isDeveloperModeEnabled = false;
bool _profilesEnabled = false;
DateTime? _onlineTraceCacheUpdatedAt; DateTime? _onlineTraceCacheUpdatedAt;
bool _isClearingOnlineTraceCache = false; bool _isClearingOnlineTraceCache = false;
int _versionTapCount = 0; int _versionTapCount = 0;
@@ -102,6 +108,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_loadImagePreferences(); _loadImagePreferences();
_loadFastLocationSettings(); _loadFastLocationSettings();
_loadDeveloperMode(); _loadDeveloperMode();
_loadProfilesEnabled();
_loadOnlineTraceCacheStatus(); _loadOnlineTraceCacheStatus();
_loadMapPreferences(); _loadMapPreferences();
_loadNotificationPreferences(); _loadNotificationPreferences();
@@ -129,7 +136,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
if (mounted) { if (mounted) {
setState(() { 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<SettingsScreen> {
}); });
} }
Future<void> _loadProfilesEnabled() async {
final isEnabled = await ProfilesFeatureService.isEnabled();
if (!mounted) return;
setState(() {
_profilesEnabled = isEnabled;
});
}
Future<void> _loadNotificationPreferences() async { Future<void> _loadNotificationPreferences() async {
final service = NotificationService(); final service = NotificationService();
await service.initialize(); await service.initialize();
@@ -199,22 +218,33 @@ class _SettingsScreenState extends State<SettingsScreen> {
Future<void> _saveRxTxPreference(bool value) async { Future<void> _saveRxTxPreference(bool value) async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setBool('show_rx_tx_indicators', value); await prefs.setBool(
ProfileStorageScope.scopedKey('show_rx_tx_indicators'),
value,
);
} }
Future<void> _loadMapPreferences() async { Future<void> _loadMapPreferences() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_rotateMapWithHeading = prefs.getBool('map_rotate_with_heading') ?? false; _rotateMapWithHeading =
_showMapDebugInfo = prefs.getBool('map_show_debug_info') ?? false; prefs.getBool(
_openMapInFullscreen = prefs.getBool('map_fullscreen') ?? false; 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<void> _saveMapPreference(String key, bool value) async { Future<void> _saveMapPreference(String key, bool value) async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setBool(key, value); await prefs.setBool(ProfileStorageScope.scopedKey(key), value);
} }
Future<void> _loadVoicePreferences() async { Future<void> _loadVoicePreferences() async {
@@ -487,7 +517,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Load settings and restore tracking state // Load settings and restore tracking state
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final wasTracking = final wasTracking =
prefs.getBool('background_tracking_enabled') ?? false; prefs.getBool(
ProfileStorageScope.scopedKey('background_tracking_enabled'),
) ??
false;
if (wasTracking) { if (wasTracking) {
await _startBackgroundTracking(); await _startBackgroundTracking();
@@ -1518,6 +1551,46 @@ class _SettingsScreenState extends State<SettingsScreen> {
builder: (context, connectionProvider, child) => builder: (context, connectionProvider, child) =>
_buildImageModePreviewCard(connectionProvider), _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<ProfileWorkspaceCoordinator>()
.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<ProfileManager>().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'), _buildSectionHeader('Templates & Help'),
_buildSettingsCard([ _buildSettingsCard([
ListTile( ListTile(

View File

@@ -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<AppSettingsProfileSection> 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<void> 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();
}
}

View File

@@ -4,6 +4,7 @@ import 'package:flutter/widgets.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:meshcore_client/meshcore_client.dart'; import 'package:meshcore_client/meshcore_client.dart';
import 'profiles_feature_service.dart';
/// Background location tracking service for SAR operations /// Background location tracking service for SAR operations
/// Tracks user location and sends periodic updates via MeshCore BLE /// Tracks user location and sends periodic updates via MeshCore BLE
@@ -15,6 +16,11 @@ class BackgroundLocationService {
static const String _prefKeyLastLon = 'background_last_lon'; static const String _prefKeyLastLon = 'background_last_lon';
MeshCoreBleService? _bleService; MeshCoreBleService? _bleService;
String _scopedKey(String baseKey) {
return ProfileStorageScope.scopedKey(baseKey);
}
bool _isInitialized = false; bool _isInitialized = false;
StreamSubscription<Position>? _positionSubscription; StreamSubscription<Position>? _positionSubscription;
@@ -71,8 +77,8 @@ class BackgroundLocationService {
// Save settings // Save settings
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefKeyEnabled, true); await prefs.setBool(_scopedKey(_prefKeyEnabled), true);
await prefs.setDouble(_prefKeyDistance, distanceThreshold); await prefs.setDouble(_scopedKey(_prefKeyDistance), distanceThreshold);
// Start listening to position updates // Start listening to position updates
Position? lastPosition; Position? lastPosition;
@@ -110,8 +116,14 @@ class BackgroundLocationService {
lastPosition = position; lastPosition = position;
// Save to preferences // Save to preferences
await prefs.setDouble(_prefKeyLastLat, position.latitude); await prefs.setDouble(
await prefs.setDouble(_prefKeyLastLon, position.longitude); _scopedKey(_prefKeyLastLat),
position.latitude,
);
await prefs.setDouble(
_scopedKey(_prefKeyLastLon),
position.longitude,
);
// Update device's advertised location // Update device's advertised location
if (_bleService != null && _bleService!.isConnected) { if (_bleService != null && _bleService!.isConnected) {
@@ -161,7 +173,7 @@ class BackgroundLocationService {
_positionSubscription = null; _positionSubscription = null;
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefKeyEnabled, false); await prefs.setBool(_scopedKey(_prefKeyEnabled), false);
debugPrint('✅ [BackgroundLocation] Tracking stopped'); debugPrint('✅ [BackgroundLocation] Tracking stopped');
} }
@@ -169,13 +181,13 @@ class BackgroundLocationService {
/// Note: This will restart tracking with the new threshold /// Note: This will restart tracking with the new threshold
Future<void> updateDistanceThreshold(double distance) async { Future<void> updateDistanceThreshold(double distance) async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setDouble(_prefKeyDistance, distance); await prefs.setDouble(_scopedKey(_prefKeyDistance), distance);
debugPrint( debugPrint(
'📏 [BackgroundLocation] Distance threshold updated to ${distance}m', '📏 [BackgroundLocation] Distance threshold updated to ${distance}m',
); );
// Restart tracking if currently enabled // Restart tracking if currently enabled
final isEnabled = prefs.getBool(_prefKeyEnabled) ?? false; final isEnabled = prefs.getBool(_scopedKey(_prefKeyEnabled)) ?? false;
if (isEnabled && _bleService != null) { if (isEnabled && _bleService != null) {
await stopTracking(); await stopTracking();
await startTracking(distanceThreshold: distance); await startTracking(distanceThreshold: distance);

View File

@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/contact_group.dart'; import '../models/contact_group.dart';
import 'profiles_feature_service.dart';
import '../utils/key_comparison.dart'; import '../utils/key_comparison.dart';
import 'package:latlong2/latlong.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 _maxStoredContacts = 500; // Store up to 500 contacts
static const int _maxStoredPendingAdverts = 500; static const int _maxStoredPendingAdverts = 500;
String _key(String baseKey, {String? namespace}) {
return ProfileStorageScope.scopedKey(baseKey, namespace: namespace);
}
/// Save contacts to persistent storage /// Save contacts to persistent storage
Future<void> saveContacts(List<Contact> contacts) async { Future<void> saveContacts(List<Contact> contacts, {String? namespace}) async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@@ -30,7 +35,10 @@ class ContactStorageService {
: jsonList; : jsonList;
final jsonString = jsonEncode(limitedList); final jsonString = jsonEncode(limitedList);
await prefs.setString(_contactsKey, jsonString); await prefs.setString(
_key(_contactsKey, namespace: namespace),
jsonString,
);
debugPrint( debugPrint(
'✅ [ContactStorage] Saved ${limitedList.length} contacts to storage', '✅ [ContactStorage] Saved ${limitedList.length} contacts to storage',
@@ -42,10 +50,15 @@ class ContactStorageService {
/// Load contacts from persistent storage /// Load contacts from persistent storage
/// [excludePublicKey] - optional public key to exclude (e.g., device's own key) /// [excludePublicKey] - optional public key to exclude (e.g., device's own key)
Future<List<Contact>> loadContacts({Uint8List? excludePublicKey}) async { Future<List<Contact>> loadContacts({
Uint8List? excludePublicKey,
String? namespace,
}) async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_contactsKey); final jsonString = prefs.getString(
_key(_contactsKey, namespace: namespace),
);
if (jsonString == null || jsonString.isEmpty) { if (jsonString == null || jsonString.isEmpty) {
debugPrint(' [ContactStorage] No stored contacts found'); debugPrint(' [ContactStorage] No stored contacts found');
@@ -84,23 +97,29 @@ class ContactStorageService {
} }
/// Clear all stored contacts /// Clear all stored contacts
Future<void> clearContacts() async { Future<void> clearContacts({String? namespace}) async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.remove(_contactsKey); await prefs.remove(_key(_contactsKey, namespace: namespace));
debugPrint('✅ [ContactStorage] Cleared all stored contacts'); debugPrint('✅ [ContactStorage] Cleared all stored contacts');
} catch (e) { } catch (e) {
debugPrint('❌ [ContactStorage] Error clearing contacts: $e'); debugPrint('❌ [ContactStorage] Error clearing contacts: $e');
} }
} }
Future<void> saveContactGroups(List<SavedContactGroup> groups) async { Future<void> saveContactGroups(
List<SavedContactGroup> groups, {
String? namespace,
}) async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final jsonString = jsonEncode( final jsonString = jsonEncode(
groups.map((group) => _contactGroupToJson(group)).toList(), groups.map((group) => _contactGroupToJson(group)).toList(),
); );
await prefs.setString(_contactGroupsKey, jsonString); await prefs.setString(
_key(_contactGroupsKey, namespace: namespace),
jsonString,
);
debugPrint( debugPrint(
'✅ [ContactStorage] Saved ${groups.length} contact groups to storage', '✅ [ContactStorage] Saved ${groups.length} contact groups to storage',
); );
@@ -109,10 +128,12 @@ class ContactStorageService {
} }
} }
Future<List<SavedContactGroup>> loadContactGroups() async { Future<List<SavedContactGroup>> loadContactGroups({String? namespace}) async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_contactGroupsKey); final jsonString = prefs.getString(
_key(_contactGroupsKey, namespace: namespace),
);
if (jsonString == null || jsonString.isEmpty) { if (jsonString == null || jsonString.isEmpty) {
return []; return [];
} }
@@ -128,13 +149,29 @@ class ContactStorageService {
} }
} }
Future<void> savePendingAdverts(List<Map<String, dynamic>> adverts) async { Future<void> 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<void> savePendingAdverts(
List<Map<String, dynamic>> adverts, {
String? namespace,
}) async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final limitedList = adverts.length > _maxStoredPendingAdverts final limitedList = adverts.length > _maxStoredPendingAdverts
? adverts.sublist(adverts.length - _maxStoredPendingAdverts) ? adverts.sublist(adverts.length - _maxStoredPendingAdverts)
: adverts; : adverts;
await prefs.setString(_pendingAdvertsKey, jsonEncode(limitedList)); await prefs.setString(
_key(_pendingAdvertsKey, namespace: namespace),
jsonEncode(limitedList),
);
debugPrint( debugPrint(
'✅ [ContactStorage] Saved ${limitedList.length} pending adverts to storage', '✅ [ContactStorage] Saved ${limitedList.length} pending adverts to storage',
); );
@@ -143,10 +180,14 @@ class ContactStorageService {
} }
} }
Future<List<Map<String, dynamic>>> loadPendingAdverts() async { Future<List<Map<String, dynamic>>> loadPendingAdverts({
String? namespace,
}) async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_pendingAdvertsKey); final jsonString = prefs.getString(
_key(_pendingAdvertsKey, namespace: namespace),
);
if (jsonString == null || jsonString.isEmpty) { if (jsonString == null || jsonString.isEmpty) {
return const []; return const [];
} }
@@ -159,10 +200,10 @@ class ContactStorageService {
} }
} }
Future<void> clearPendingAdverts() async { Future<void> clearPendingAdverts({String? namespace}) async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.remove(_pendingAdvertsKey); await prefs.remove(_key(_pendingAdvertsKey, namespace: namespace));
debugPrint('✅ [ContactStorage] Cleared all stored pending adverts'); debugPrint('✅ [ContactStorage] Cleared all stored pending adverts');
} catch (e) { } catch (e) {
debugPrint('❌ [ContactStorage] Error clearing pending adverts: $e'); debugPrint('❌ [ContactStorage] Error clearing pending adverts: $e');
@@ -170,10 +211,12 @@ class ContactStorageService {
} }
/// Get storage statistics /// Get storage statistics
Future<Map<String, dynamic>> getStorageStats() async { Future<Map<String, dynamic>> getStorageStats({String? namespace}) async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_contactsKey); final jsonString = prefs.getString(
_key(_contactsKey, namespace: namespace),
);
if (jsonString == null || jsonString.isEmpty) { if (jsonString == null || jsonString.isEmpty) {
return {'contactCount': 0, 'storageSizeBytes': 0, 'storageSizeKB': 0}; return {'contactCount': 0, 'storageSizeBytes': 0, 'storageSizeKB': 0};

View File

@@ -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<void> 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<void> _applyChannels({
required List<Channel> 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;
}
}

View File

@@ -1,4 +1,5 @@
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'profiles_feature_service.dart';
/// Stores user-selected image compression settings. /// Stores user-selected image compression settings.
class ImagePreferences { class ImagePreferences {
@@ -17,44 +18,53 @@ class ImagePreferences {
static Future<int> getMaxSize() async { static Future<int> getMaxSize() async {
final prefs = await SharedPreferences.getInstance(); 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; return supportedSizes.contains(value) ? value : defaultMaxSize;
} }
static Future<void> setMaxSize(int size) async { static Future<void> setMaxSize(int size) async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_maxSizeKey, size); await prefs.setInt(ProfileStorageScope.scopedKey(_maxSizeKey), size);
} }
static Future<int> getCompression() async { static Future<int> getCompression() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final value = prefs.getInt(_qualityKey) ?? defaultQuality; final value =
prefs.getInt(ProfileStorageScope.scopedKey(_qualityKey)) ??
defaultQuality;
return value.clamp(10, 90); return value.clamp(10, 90);
} }
static Future<void> setCompression(int compression) async { static Future<void> setCompression(int compression) async {
final prefs = await SharedPreferences.getInstance(); 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<bool> getGrayscale() async { static Future<bool> getGrayscale() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_grayscaleKey) ?? defaultGrayscale; return prefs.getBool(ProfileStorageScope.scopedKey(_grayscaleKey)) ??
defaultGrayscale;
} }
static Future<void> setGrayscale(bool value) async { static Future<void> setGrayscale(bool value) async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_grayscaleKey, value); await prefs.setBool(ProfileStorageScope.scopedKey(_grayscaleKey), value);
} }
static Future<bool> getUltraMode() async { static Future<bool> getUltraMode() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_ultraModeKey) ?? defaultUltraMode; return prefs.getBool(ProfileStorageScope.scopedKey(_ultraModeKey)) ??
defaultUltraMode;
} }
static Future<void> setUltraMode(bool value) async { static Future<void> setUltraMode(bool value) async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_ultraModeKey, value); await prefs.setBool(ProfileStorageScope.scopedKey(_ultraModeKey), value);
} }
static int effectiveMaxSize( static int effectiveMaxSize(

View File

@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:meshcore_client/meshcore_client.dart'; import 'package:meshcore_client/meshcore_client.dart';
import 'profiles_feature_service.dart';
/// Centralized location tracking service for MeshCore SAR /// Centralized location tracking service for MeshCore SAR
/// ///
@@ -49,6 +50,10 @@ class LocationTrackingService {
static const String _prefKeyFastActiveCadence = static const String _prefKeyFastActiveCadence =
'fast_location_active_cadence_seconds'; 'fast_location_active_cadence_seconds';
String _scopedKey(String baseKey) {
return ProfileStorageScope.scopedKey(baseKey);
}
// ============================================================================ // ============================================================================
// Configuration Properties // Configuration Properties
// ============================================================================ // ============================================================================
@@ -399,7 +404,7 @@ class LocationTrackingService {
// Save disabled state // Save disabled state
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefKeyEnabled, false); await prefs.setBool(_scopedKey(_prefKeyEnabled), false);
debugPrint('✅ [LocationTracking] Tracking stopped'); debugPrint('✅ [LocationTracking] Tracking stopped');
} }
@@ -477,8 +482,8 @@ class LocationTrackingService {
// Save to preferences // Save to preferences
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setDouble(_prefKeyLastLat, position.latitude); await prefs.setDouble(_scopedKey(_prefKeyLastLat), position.latitude);
await prefs.setDouble(_prefKeyLastLon, position.longitude); await prefs.setDouble(_scopedKey(_prefKeyLastLon), position.longitude);
debugPrint('✅ [LocationTracking] Initial position set without broadcast'); debugPrint('✅ [LocationTracking] Initial position set without broadcast');
debugPrint(' Next broadcast allowed in ${minTimeIntervalSeconds}s'); debugPrint(' Next broadcast allowed in ${minTimeIntervalSeconds}s');
@@ -646,17 +651,24 @@ class LocationTrackingService {
Future<void> loadSettings() async { Future<void> loadSettings() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
minDistanceMeters = prefs.getDouble(_prefKeyMinDistance) ?? 5.0; minDistanceMeters = prefs.getDouble(_scopedKey(_prefKeyMinDistance)) ?? 5.0;
maxDistanceMeters = prefs.getDouble(_prefKeyMaxDistance) ?? 100.0; maxDistanceMeters =
minTimeIntervalSeconds = prefs.getInt(_prefKeyMinTimeInterval) ?? 30; prefs.getDouble(_scopedKey(_prefKeyMaxDistance)) ?? 100.0;
gpsUpdateDistance = prefs.getDouble(_prefKeyGpsUpdateDistance) ?? 10.0; minTimeIntervalSeconds =
prefs.getInt(_scopedKey(_prefKeyMinTimeInterval)) ?? 30;
gpsUpdateDistance =
prefs.getDouble(_scopedKey(_prefKeyGpsUpdateDistance)) ?? 10.0;
fastLocationUpdatesEnabled = fastLocationUpdatesEnabled =
prefs.getBool(_prefKeyFastLocationEnabled) ?? false; prefs.getBool(_scopedKey(_prefKeyFastLocationEnabled)) ?? false;
fastLocationMovementThresholdMeters = fastLocationMovementThresholdMeters =
(prefs.getDouble(_prefKeyFastMovementThreshold) ?? gpsUpdateDistance) (prefs.getDouble(_scopedKey(_prefKeyFastMovementThreshold)) ??
gpsUpdateDistance)
.clamp(1.0, 1000.0); .clamp(1.0, 1000.0);
fastLocationActiveCadenceSeconds = fastLocationActiveCadenceSeconds =
(prefs.getInt(_prefKeyFastActiveCadence) ?? 10).clamp(5, 60); (prefs.getInt(_scopedKey(_prefKeyFastActiveCadence)) ?? 10).clamp(
5,
60,
);
debugPrint('✅ [LocationTracking] Settings loaded'); debugPrint('✅ [LocationTracking] Settings loaded');
debugPrint(' Min distance: ${minDistanceMeters}m'); debugPrint(' Min distance: ${minDistanceMeters}m');
@@ -674,21 +686,27 @@ class LocationTrackingService {
Future<void> saveSettings() async { Future<void> saveSettings() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setDouble(_prefKeyMinDistance, minDistanceMeters); await prefs.setDouble(_scopedKey(_prefKeyMinDistance), minDistanceMeters);
await prefs.setDouble(_prefKeyMaxDistance, maxDistanceMeters); await prefs.setDouble(_scopedKey(_prefKeyMaxDistance), maxDistanceMeters);
await prefs.setInt(_prefKeyMinTimeInterval, minTimeIntervalSeconds); await prefs.setInt(
await prefs.setDouble(_prefKeyGpsUpdateDistance, gpsUpdateDistance); _scopedKey(_prefKeyMinTimeInterval),
await prefs.setBool(_prefKeyEnabled, isTracking); minTimeIntervalSeconds,
);
await prefs.setDouble(
_scopedKey(_prefKeyGpsUpdateDistance),
gpsUpdateDistance,
);
await prefs.setBool(_scopedKey(_prefKeyEnabled), isTracking);
await prefs.setBool( await prefs.setBool(
_prefKeyFastLocationEnabled, _scopedKey(_prefKeyFastLocationEnabled),
fastLocationUpdatesEnabled, fastLocationUpdatesEnabled,
); );
await prefs.setDouble( await prefs.setDouble(
_prefKeyFastMovementThreshold, _scopedKey(_prefKeyFastMovementThreshold),
fastLocationMovementThresholdMeters, fastLocationMovementThresholdMeters,
); );
await prefs.setInt( await prefs.setInt(
_prefKeyFastActiveCadence, _scopedKey(_prefKeyFastActiveCadence),
fastLocationActiveCadenceSeconds, fastLocationActiveCadenceSeconds,
); );

View File

@@ -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<MapWorkspaceProfileSection> 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<void> 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 <String, dynamic>{};
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);
}
}

View File

@@ -6,6 +6,7 @@ import '../models/message_contact_location.dart';
import '../models/message_reception_details.dart'; import '../models/message_reception_details.dart';
import '../models/message_transfer_details.dart'; import '../models/message_transfer_details.dart';
import '../models/message_route_metadata.dart'; import '../models/message_route_metadata.dart';
import 'profiles_feature_service.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
/// Service for persisting messages to local storage /// Service for persisting messages to local storage
@@ -24,6 +25,10 @@ class MessageStorageService {
static const String _legacyPathBytesKey = 'storedPathBytes'; static const String _legacyPathBytesKey = 'storedPathBytes';
static const int _maxStoredMessages = 1000; // Store up to 1000 messages 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 /// Save messages to persistent storage
Future<void> saveMessages( Future<void> saveMessages(
List<Message> messages, { List<Message> messages, {
@@ -31,6 +36,7 @@ class MessageStorageService {
Map<String, MessageReceptionDetails> messageReceptionDetails = const {}, Map<String, MessageReceptionDetails> messageReceptionDetails = const {},
Map<String, MessageTransferDetails> messageTransferDetails = const {}, Map<String, MessageTransferDetails> messageTransferDetails = const {},
Map<String, MessageRouteMetadata> messageRouteMetadata = const {}, Map<String, MessageRouteMetadata> messageRouteMetadata = const {},
String? namespace,
}) async { }) async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@@ -52,7 +58,10 @@ class MessageStorageService {
: jsonList; : jsonList;
final jsonString = jsonEncode(limitedList); final jsonString = jsonEncode(limitedList);
await prefs.setString(_messagesKey, jsonString); await prefs.setString(
_key(_messagesKey, namespace: namespace),
jsonString,
);
final retainedMessageIds = limitedList final retainedMessageIds = limitedList
.map((entry) => entry['id'] as String) .map((entry) => entry['id'] as String)
.toSet(); .toSet();
@@ -81,19 +90,19 @@ class MessageStorageService {
} }
} }
await prefs.setString( await prefs.setString(
_messageContactLocationsKey, _key(_messageContactLocationsKey, namespace: namespace),
jsonEncode(locationJson), jsonEncode(locationJson),
); );
await prefs.setString( await prefs.setString(
_messageReceptionDetailsKey, _key(_messageReceptionDetailsKey, namespace: namespace),
jsonEncode(receptionJson), jsonEncode(receptionJson),
); );
await prefs.setString( await prefs.setString(
_messageTransferDetailsKey, _key(_messageTransferDetailsKey, namespace: namespace),
jsonEncode(transferJson), jsonEncode(transferJson),
); );
await prefs.setString( await prefs.setString(
_messageRouteMetadataKey, _key(_messageRouteMetadataKey, namespace: namespace),
jsonEncode(routeMetadataJson), jsonEncode(routeMetadataJson),
); );
@@ -105,11 +114,14 @@ class MessageStorageService {
} }
} }
Future<Map<String, MessageContactLocation>> Future<Map<String, MessageContactLocation>> loadMessageContactLocations({
loadMessageContactLocations() async { String? namespace,
}) async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messageContactLocationsKey); final jsonString = prefs.getString(
_key(_messageContactLocationsKey, namespace: namespace),
);
if (jsonString == null || jsonString.isEmpty) { if (jsonString == null || jsonString.isEmpty) {
return const {}; return const {};
} }
@@ -135,11 +147,14 @@ class MessageStorageService {
} }
} }
Future<Map<String, MessageReceptionDetails>> Future<Map<String, MessageReceptionDetails>> loadMessageReceptionDetails({
loadMessageReceptionDetails() async { String? namespace,
}) async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messageReceptionDetailsKey); final jsonString = prefs.getString(
_key(_messageReceptionDetailsKey, namespace: namespace),
);
final result = <String, MessageReceptionDetails>{}; final result = <String, MessageReceptionDetails>{};
if (jsonString != null && jsonString.isNotEmpty) { if (jsonString != null && jsonString.isNotEmpty) {
final decoded = jsonDecode(jsonString); final decoded = jsonDecode(jsonString);
@@ -155,12 +170,16 @@ class MessageStorageService {
} }
} }
final embeddedReceptionDetails = await _loadEmbeddedReceptionDetails(); final embeddedReceptionDetails = await _loadEmbeddedReceptionDetails(
namespace: namespace,
);
embeddedReceptionDetails.forEach((messageId, snapshot) { embeddedReceptionDetails.forEach((messageId, snapshot) {
result.putIfAbsent(messageId, () => snapshot); result.putIfAbsent(messageId, () => snapshot);
}); });
final fallbackPathBytes = await _loadLegacyPathBytesFromMessages(); final fallbackPathBytes = await _loadLegacyPathBytesFromMessages(
namespace: namespace,
);
fallbackPathBytes.forEach((messageId, pathBytes) { fallbackPathBytes.forEach((messageId, pathBytes) {
result.putIfAbsent( result.putIfAbsent(
messageId, messageId,
@@ -177,11 +196,14 @@ class MessageStorageService {
} }
} }
Future<Map<String, MessageTransferDetails>> Future<Map<String, MessageTransferDetails>> loadMessageTransferDetails({
loadMessageTransferDetails() async { String? namespace,
}) async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messageTransferDetailsKey); final jsonString = prefs.getString(
_key(_messageTransferDetailsKey, namespace: namespace),
);
if (jsonString == null || jsonString.isEmpty) { if (jsonString == null || jsonString.isEmpty) {
return const {}; return const {};
} }
@@ -207,10 +229,14 @@ class MessageStorageService {
} }
} }
Future<Map<String, MessageRouteMetadata>> loadMessageRouteMetadata() async { Future<Map<String, MessageRouteMetadata>> loadMessageRouteMetadata({
String? namespace,
}) async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messageRouteMetadataKey); final jsonString = prefs.getString(
_key(_messageRouteMetadataKey, namespace: namespace),
);
if (jsonString == null || jsonString.isEmpty) { if (jsonString == null || jsonString.isEmpty) {
return const {}; return const {};
} }
@@ -234,10 +260,12 @@ class MessageStorageService {
} }
/// Load messages from persistent storage /// Load messages from persistent storage
Future<List<Message>> loadMessages() async { Future<List<Message>> loadMessages({String? namespace}) async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messagesKey); final jsonString = prefs.getString(
_key(_messagesKey, namespace: namespace),
);
if (jsonString == null || jsonString.isEmpty) { if (jsonString == null || jsonString.isEmpty) {
debugPrint(' [MessageStorage] No stored messages found'); debugPrint(' [MessageStorage] No stored messages found');
@@ -262,25 +290,35 @@ class MessageStorageService {
} }
/// Clear all stored messages /// Clear all stored messages
Future<void> clearMessages() async { Future<void> clearMessages({String? namespace}) async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.remove(_messagesKey); await prefs.remove(_key(_messagesKey, namespace: namespace));
await prefs.remove(_messageContactLocationsKey); await prefs.remove(
await prefs.remove(_messageReceptionDetailsKey); _key(_messageContactLocationsKey, namespace: namespace),
await prefs.remove(_messageTransferDetailsKey); );
await prefs.remove(_messageRouteMetadataKey); await prefs.remove(
await prefs.remove(_removedSarMarkerIdsKey); _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'); debugPrint('✅ [MessageStorage] Cleared all stored messages');
} catch (e) { } catch (e) {
debugPrint('❌ [MessageStorage] Error clearing messages: $e'); debugPrint('❌ [MessageStorage] Error clearing messages: $e');
} }
} }
Future<Set<String>> loadRemovedSarMarkerIds() async { Future<Set<String>> loadRemovedSarMarkerIds({String? namespace}) async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final ids = prefs.getStringList(_removedSarMarkerIdsKey) ?? const []; final ids =
prefs.getStringList(
_key(_removedSarMarkerIdsKey, namespace: namespace),
) ??
const [];
return ids.toSet(); return ids.toSet();
} catch (e) { } catch (e) {
debugPrint('❌ [MessageStorage] Error loading removed SAR marker IDs: $e'); debugPrint('❌ [MessageStorage] Error loading removed SAR marker IDs: $e');
@@ -288,10 +326,16 @@ class MessageStorageService {
} }
} }
Future<void> saveRemovedSarMarkerIds(Set<String> ids) async { Future<void> saveRemovedSarMarkerIds(
Set<String> ids, {
String? namespace,
}) async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(_removedSarMarkerIdsKey, ids.toList()..sort()); await prefs.setStringList(
_key(_removedSarMarkerIdsKey, namespace: namespace),
ids.toList()..sort(),
);
debugPrint( debugPrint(
'✅ [MessageStorage] Saved ${ids.length} removed SAR marker IDs', '✅ [MessageStorage] Saved ${ids.length} removed SAR marker IDs',
); );
@@ -301,10 +345,12 @@ class MessageStorageService {
} }
/// Get storage statistics /// Get storage statistics
Future<Map<String, dynamic>> getStorageStats() async { Future<Map<String, dynamic>> getStorageStats({String? namespace}) async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messagesKey); final jsonString = prefs.getString(
_key(_messagesKey, namespace: namespace),
);
if (jsonString == null || jsonString.isEmpty) { if (jsonString == null || jsonString.isEmpty) {
return {'messageCount': 0, 'storageSizeBytes': 0, 'storageSizeKB': 0}; return {'messageCount': 0, 'storageSizeBytes': 0, 'storageSizeKB': 0};
@@ -401,10 +447,13 @@ class MessageStorageService {
}; };
} }
Future<Map<String, MessageReceptionDetails>> Future<Map<String, MessageReceptionDetails>> _loadEmbeddedReceptionDetails({
_loadEmbeddedReceptionDetails() async { String? namespace,
}) async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messagesKey); final jsonString = prefs.getString(
_key(_messagesKey, namespace: namespace),
);
if (jsonString == null || jsonString.isEmpty) { if (jsonString == null || jsonString.isEmpty) {
return const {}; return const {};
} }
@@ -427,9 +476,13 @@ class MessageStorageService {
return result; return result;
} }
Future<Map<String, List<int>>> _loadLegacyPathBytesFromMessages() async { Future<Map<String, List<int>>> _loadLegacyPathBytesFromMessages({
String? namespace,
}) async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messagesKey); final jsonString = prefs.getString(
_key(_messagesKey, namespace: namespace),
);
if (jsonString == null || jsonString.isEmpty) { if (jsonString == null || jsonString.isEmpty) {
return const {}; return const {};
} }

View File

@@ -1,4 +1,5 @@
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'profiles_feature_service.dart';
class MessagingRoutePreferences { class MessagingRoutePreferences {
static const bool defaultAutoRouteRotationEnabled = false; static const bool defaultAutoRouteRotationEnabled = false;
@@ -14,33 +15,49 @@ class MessagingRoutePreferences {
static Future<bool> getAutoRouteRotationEnabled() async { static Future<bool> getAutoRouteRotationEnabled() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_autoRouteRotationKey) ?? return prefs.getBool(
ProfileStorageScope.scopedKey(_autoRouteRotationKey),
) ??
defaultAutoRouteRotationEnabled; defaultAutoRouteRotationEnabled;
} }
static Future<void> setAutoRouteRotationEnabled(bool enabled) async { static Future<void> setAutoRouteRotationEnabled(bool enabled) async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_autoRouteRotationKey, enabled); await prefs.setBool(
ProfileStorageScope.scopedKey(_autoRouteRotationKey),
enabled,
);
} }
static Future<bool> getClearPathOnMaxRetry() async { static Future<bool> getClearPathOnMaxRetry() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_clearPathOnMaxRetryKey) ?? defaultClearPathOnMaxRetry; return prefs.getBool(
ProfileStorageScope.scopedKey(_clearPathOnMaxRetryKey),
) ??
defaultClearPathOnMaxRetry;
} }
static Future<void> setClearPathOnMaxRetry(bool enabled) async { static Future<void> setClearPathOnMaxRetry(bool enabled) async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_clearPathOnMaxRetryKey, enabled); await prefs.setBool(
ProfileStorageScope.scopedKey(_clearPathOnMaxRetryKey),
enabled,
);
} }
static Future<bool> getNearestRelayFallbackEnabled() async { static Future<bool> getNearestRelayFallbackEnabled() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_nearestRelayFallbackKey) ?? return prefs.getBool(
ProfileStorageScope.scopedKey(_nearestRelayFallbackKey),
) ??
defaultNearestRelayFallbackEnabled; defaultNearestRelayFallbackEnabled;
} }
static Future<void> setNearestRelayFallbackEnabled(bool enabled) async { static Future<void> setNearestRelayFallbackEnabled(bool enabled) async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_nearestRelayFallbackKey, enabled); await prefs.setBool(
ProfileStorageScope.scopedKey(_nearestRelayFallbackKey),
enabled,
);
} }
} }

View File

@@ -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<ConfigProfile> _customProfiles = <ConfigProfile>[];
final List<ProfileTransferRecord> _transferHistory =
<ProfileTransferRecord>[];
bool _isInitialized = false;
bool _profilesEnabled = false;
String _activeProfileId = ConfigProfile.defaultProfileId;
bool get isInitialized => _isInitialized;
bool get profilesEnabled => _profilesEnabled;
String get activeProfileId => _activeProfileId;
List<ConfigProfile> get customProfiles => List.unmodifiable(_customProfiles);
List<ProfileTransferRecord> get transferHistory =>
List.unmodifiable(_transferHistory);
List<ConfigProfile> get visibleProfiles => [
if (_profilesEnabled) ConfigProfile.defaultProfile(),
..._customProfiles,
];
Future<void> 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<dynamic>;
_customProfiles
..clear()
..addAll(
decoded.whereType<Map<String, dynamic>>().map(ConfigProfile.fromJson),
);
}
final historyJson = prefs.getString(_transferHistoryKey);
if (historyJson != null && historyJson.isNotEmpty) {
final decoded = jsonDecode(historyJson) as List<dynamic>;
_transferHistory
..clear()
..addAll(
decoded.whereType<Map<String, dynamic>>().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<void> setProfilesEnabled(bool enabled) async {
_profilesEnabled = enabled;
await ProfilesFeatureService.setEnabled(enabled);
ProfileStorageScope.setScope(
profilesEnabled: _profilesEnabled,
activeProfileId: _activeProfileId,
);
notifyListeners();
}
Future<void> setActiveProfileId(String id) async {
_activeProfileId = id;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(activeProfileIdKey, id);
ProfileStorageScope.setScope(
profilesEnabled: _profilesEnabled,
activeProfileId: _activeProfileId,
);
notifyListeners();
}
Future<void> 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<void> deleteProfile(String id) async {
_customProfiles.removeWhere((profile) => profile.id == id);
await _persistProfiles();
notifyListeners();
}
Future<void> 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<void> _persistProfiles() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
_profilesKey,
jsonEncode(_customProfiles.map((profile) => profile.toJson()).toList()),
);
}
}

View File

@@ -0,0 +1,10 @@
abstract class ProfileTransport {
String get label;
}
class FileProfileTransport implements ProfileTransport {
const FileProfileTransport();
@override
String get label => 'File';
}

View File

@@ -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<void> 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<ConfigProfile> 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<ConfigProfile> 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<ConfigProfile> 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<void> renameProfile(ConfigProfile profile, String name) async {
await profileManager.upsertProfile(
profile.copyWith(name: name, updatedAt: DateTime.now()),
);
}
Future<void> 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<void> 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<void> applyProfile(String profileId) async {
await openProfile(profileId);
final profile = await resolveProfile(profileId);
await _deviceConfigApplicator.apply(
profile,
connectionProvider: connectionProvider,
channelsProvider: channelsProvider,
);
}
Future<ConfigProfile> resolveProfile(String profileId) async {
if (profileId == ConfigProfile.defaultProfileId) {
return snapshotCurrentProfile(id: profileId, name: 'Default');
}
return profileManager.getProfile(profileId)!;
}
Future<void> 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<ConfigProfile?> 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<String, dynamic>) {
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<void> _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<void> _persistCurrentState() async {
await messagesProvider.persistNow();
await contactsProvider.persistNow();
}
Future<void> _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<void> _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,
);
}
}

View File

@@ -0,0 +1,54 @@
import 'package:shared_preferences/shared_preferences.dart';
class ProfilesFeatureService {
static const String enabledKey = 'profiles_enabled';
static Future<bool> isEnabled() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(enabledKey) ?? false;
}
static Future<void> 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<void> 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';
}
}

View File

@@ -1,4 +1,5 @@
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'profiles_feature_service.dart';
class RouteHashPreferences { class RouteHashPreferences {
static const String _hashSizeKey = 'route_hash_size'; static const String _hashSizeKey = 'route_hash_size';
@@ -6,13 +7,18 @@ class RouteHashPreferences {
static Future<int> getHashSize() async { static Future<int> getHashSize() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final value = prefs.getInt(_hashSizeKey) ?? defaultHashSize; final value =
prefs.getInt(ProfileStorageScope.scopedKey(_hashSizeKey)) ??
defaultHashSize;
return _normalize(value); return _normalize(value);
} }
static Future<void> setHashSize(int value) async { static Future<void> setHashSize(int value) async {
final prefs = await SharedPreferences.getInstance(); 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); static int normalizeSync(int value) => _normalize(value);

View File

@@ -1,21 +1,32 @@
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'profiles_feature_service.dart';
import '../utils/voice_message_parser.dart'; import '../utils/voice_message_parser.dart';
/// Stores user-selected voice bitrate and maps it to supported codec modes. /// Stores user-selected voice bitrate and maps it to supported codec modes.
class VoiceBitratePreferences { class VoiceBitratePreferences {
static const String _bitrateKey = 'voice_bitrate'; static const String _bitrateKey = 'voice_bitrate';
static const int defaultBitrate = 1300; static const int defaultBitrate = 1300;
static const List<int> supportedBitrates = [700, 1200, 1300, 1400, 1600, 2400, 3200]; static const List<int> supportedBitrates = [
700,
1200,
1300,
1400,
1600,
2400,
3200,
];
static Future<int> getBitrate() async { static Future<int> getBitrate() async {
final prefs = await SharedPreferences.getInstance(); 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; return supportedBitrates.contains(value) ? value : defaultBitrate;
} }
static Future<void> setBitrate(int bitrate) async { static Future<void> setBitrate(int bitrate) async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_bitrateKey, bitrate); await prefs.setInt(ProfileStorageScope.scopedKey(_bitrateKey), bitrate);
} }
static VoicePacketMode toVoiceMode(int bitrate) { static VoicePacketMode toVoiceMode(int bitrate) {

View File

@@ -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<int>.filled(32, 1)),
name: 'Default Contact',
);
final customContact = createContact(
key: Uint8List.fromList(List<int>.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);
});
}

View File

@@ -112,4 +112,47 @@ void main() {
expect(restored, equals({'sar-1', 'sar-2'})); 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);
});
} }

View File

@@ -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);
},
);
});
}