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

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

View File

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

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

View File

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

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

View File

@@ -1,4 +1,5 @@
import 'package:shared_preferences/shared_preferences.dart';
import 'profiles_feature_service.dart';
class MessagingRoutePreferences {
static const bool defaultAutoRouteRotationEnabled = false;
@@ -14,33 +15,49 @@ class MessagingRoutePreferences {
static Future<bool> getAutoRouteRotationEnabled() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_autoRouteRotationKey) ??
return prefs.getBool(
ProfileStorageScope.scopedKey(_autoRouteRotationKey),
) ??
defaultAutoRouteRotationEnabled;
}
static Future<void> setAutoRouteRotationEnabled(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_autoRouteRotationKey, enabled);
await prefs.setBool(
ProfileStorageScope.scopedKey(_autoRouteRotationKey),
enabled,
);
}
static Future<bool> getClearPathOnMaxRetry() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_clearPathOnMaxRetryKey) ?? defaultClearPathOnMaxRetry;
return prefs.getBool(
ProfileStorageScope.scopedKey(_clearPathOnMaxRetryKey),
) ??
defaultClearPathOnMaxRetry;
}
static Future<void> setClearPathOnMaxRetry(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_clearPathOnMaxRetryKey, enabled);
await prefs.setBool(
ProfileStorageScope.scopedKey(_clearPathOnMaxRetryKey),
enabled,
);
}
static Future<bool> getNearestRelayFallbackEnabled() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_nearestRelayFallbackKey) ??
return prefs.getBool(
ProfileStorageScope.scopedKey(_nearestRelayFallbackKey),
) ??
defaultNearestRelayFallbackEnabled;
}
static Future<void> setNearestRelayFallbackEnabled(bool enabled) async {
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 'profiles_feature_service.dart';
class RouteHashPreferences {
static const String _hashSizeKey = 'route_hash_size';
@@ -6,13 +7,18 @@ class RouteHashPreferences {
static Future<int> getHashSize() async {
final prefs = await SharedPreferences.getInstance();
final value = prefs.getInt(_hashSizeKey) ?? defaultHashSize;
final value =
prefs.getInt(ProfileStorageScope.scopedKey(_hashSizeKey)) ??
defaultHashSize;
return _normalize(value);
}
static Future<void> setHashSize(int value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_hashSizeKey, _normalize(value));
await prefs.setInt(
ProfileStorageScope.scopedKey(_hashSizeKey),
_normalize(value),
);
}
static int normalizeSync(int value) => _normalize(value);

View File

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