mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Enable profiles switch UI
This commit is contained in:
@@ -23,6 +23,9 @@ import 'services/voice_player_service.dart';
|
||||
import 'services/notification_service.dart';
|
||||
import 'services/locale_preferences.dart';
|
||||
import 'services/mesh_map_nodes_service.dart';
|
||||
import 'services/profile_manager.dart';
|
||||
import 'services/profile_workspace_coordinator.dart';
|
||||
import 'services/profiles_feature_service.dart';
|
||||
import 'services/update_checker_service.dart';
|
||||
import 'services/wizard_preferences.dart';
|
||||
import 'screens/discovery_screen.dart';
|
||||
@@ -58,6 +61,14 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
}
|
||||
|
||||
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 _loadLocalePreference();
|
||||
|
||||
@@ -271,6 +282,13 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
),
|
||||
ChangeNotifierProvider(create: (_) => ChannelsProvider()),
|
||||
ChangeNotifierProvider(create: (_) => SensorsProvider()),
|
||||
ChangeNotifierProvider(
|
||||
create: (_) {
|
||||
final manager = ProfileManager();
|
||||
manager.initialize();
|
||||
return manager;
|
||||
},
|
||||
),
|
||||
|
||||
// Voice provider (packet reassembly + playback)
|
||||
ChangeNotifierProvider(
|
||||
@@ -323,6 +341,36 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
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(),
|
||||
);
|
||||
|
||||
528
lib/models/config_profile.dart
Normal file
528
lib/models/config_profile.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import '../services/messaging_route_preferences.dart';
|
||||
import '../services/nearest_router_selector.dart';
|
||||
import '../services/packet_capture_storage_service.dart';
|
||||
import '../services/path_history_service.dart';
|
||||
import '../services/profiles_feature_service.dart';
|
||||
import '../services/route_hash_preferences.dart';
|
||||
import '../services/notification_service.dart';
|
||||
import '../models/contact.dart';
|
||||
@@ -253,6 +254,10 @@ class AppProvider with ChangeNotifier {
|
||||
_isInitialized = true;
|
||||
}
|
||||
|
||||
String _scopedKey(String baseKey) {
|
||||
return ProfileStorageScope.scopedKey(baseKey);
|
||||
}
|
||||
|
||||
void _startPacketCapturePersistence() {
|
||||
_packetCaptureFlushTimer?.cancel();
|
||||
_packetCaptureFlushTimer = Timer.periodic(const Duration(seconds: 2), (_) {
|
||||
@@ -516,7 +521,7 @@ class AppProvider with ChangeNotifier {
|
||||
Future<void> _loadMapEnabled() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_isMapEnabled = prefs.getBool('map_enabled') ?? true;
|
||||
_isMapEnabled = prefs.getBool(_scopedKey('map_enabled')) ?? true;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Error loading map enabled setting: $e');
|
||||
@@ -528,7 +533,7 @@ class AppProvider with ChangeNotifier {
|
||||
try {
|
||||
_isMapEnabled = enabled;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('map_enabled', enabled);
|
||||
await prefs.setBool(_scopedKey('map_enabled'), enabled);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Error saving map enabled setting: $e');
|
||||
@@ -539,7 +544,8 @@ class AppProvider with ChangeNotifier {
|
||||
Future<void> _loadContactsEnabled() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_isContactsEnabled = prefs.getBool('contacts_enabled') ?? true;
|
||||
_isContactsEnabled =
|
||||
prefs.getBool(_scopedKey('contacts_enabled')) ?? true;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Error loading contacts enabled setting: $e');
|
||||
@@ -551,7 +557,7 @@ class AppProvider with ChangeNotifier {
|
||||
try {
|
||||
_isContactsEnabled = enabled;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('contacts_enabled', enabled);
|
||||
await prefs.setBool(_scopedKey('contacts_enabled'), enabled);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Error saving contacts enabled setting: $e');
|
||||
@@ -562,7 +568,7 @@ class AppProvider with ChangeNotifier {
|
||||
Future<void> _loadSensorsEnabled() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_isSensorsEnabled = prefs.getBool('sensors_enabled') ?? true;
|
||||
_isSensorsEnabled = prefs.getBool(_scopedKey('sensors_enabled')) ?? true;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Error loading sensors enabled setting: $e');
|
||||
@@ -574,7 +580,7 @@ class AppProvider with ChangeNotifier {
|
||||
try {
|
||||
_isSensorsEnabled = enabled;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('sensors_enabled', enabled);
|
||||
await prefs.setBool(_scopedKey('sensors_enabled'), enabled);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Error saving sensors enabled setting: $e');
|
||||
@@ -586,7 +592,7 @@ class AppProvider with ChangeNotifier {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_isVoiceSilenceTrimmingEnabled =
|
||||
prefs.getBool('voice_silence_trimming_enabled') ?? true;
|
||||
prefs.getBool(_scopedKey('voice_silence_trimming_enabled')) ?? true;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Error loading voice silence trimming setting: $e');
|
||||
@@ -598,7 +604,10 @@ class AppProvider with ChangeNotifier {
|
||||
try {
|
||||
_isVoiceSilenceTrimmingEnabled = enabled;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('voice_silence_trimming_enabled', enabled);
|
||||
await prefs.setBool(
|
||||
_scopedKey('voice_silence_trimming_enabled'),
|
||||
enabled,
|
||||
);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Error saving voice silence trimming setting: $e');
|
||||
@@ -610,7 +619,7 @@ class AppProvider with ChangeNotifier {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_isVoiceBandPassFilterEnabled =
|
||||
prefs.getBool('voice_band_pass_filter_enabled') ?? true;
|
||||
prefs.getBool(_scopedKey('voice_band_pass_filter_enabled')) ?? true;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Error loading voice band-pass filter setting: $e');
|
||||
@@ -622,7 +631,10 @@ class AppProvider with ChangeNotifier {
|
||||
try {
|
||||
_isVoiceBandPassFilterEnabled = enabled;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('voice_band_pass_filter_enabled', enabled);
|
||||
await prefs.setBool(
|
||||
_scopedKey('voice_band_pass_filter_enabled'),
|
||||
enabled,
|
||||
);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Error saving voice band-pass filter setting: $e');
|
||||
@@ -634,7 +646,7 @@ class AppProvider with ChangeNotifier {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_isVoiceCompressorEnabled =
|
||||
prefs.getBool('voice_compressor_enabled') ?? true;
|
||||
prefs.getBool(_scopedKey('voice_compressor_enabled')) ?? true;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Error loading voice compressor setting: $e');
|
||||
@@ -646,7 +658,7 @@ class AppProvider with ChangeNotifier {
|
||||
try {
|
||||
_isVoiceCompressorEnabled = enabled;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('voice_compressor_enabled', enabled);
|
||||
await prefs.setBool(_scopedKey('voice_compressor_enabled'), enabled);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Error saving voice compressor setting: $e');
|
||||
@@ -657,7 +669,8 @@ class AppProvider with ChangeNotifier {
|
||||
Future<void> _loadVoiceLimiterEnabled() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_isVoiceLimiterEnabled = prefs.getBool('voice_limiter_enabled') ?? true;
|
||||
_isVoiceLimiterEnabled =
|
||||
prefs.getBool(_scopedKey('voice_limiter_enabled')) ?? true;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Error loading voice limiter setting: $e');
|
||||
@@ -669,7 +682,7 @@ class AppProvider with ChangeNotifier {
|
||||
try {
|
||||
_isVoiceLimiterEnabled = enabled;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('voice_limiter_enabled', enabled);
|
||||
await prefs.setBool(_scopedKey('voice_limiter_enabled'), enabled);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Error saving voice limiter setting: $e');
|
||||
@@ -680,7 +693,7 @@ class AppProvider with ChangeNotifier {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_isVoiceAutoGainEnabled =
|
||||
prefs.getBool('voice_auto_gain_enabled') ?? false;
|
||||
prefs.getBool(_scopedKey('voice_auto_gain_enabled')) ?? false;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Error loading voice auto gain setting: $e');
|
||||
@@ -691,7 +704,7 @@ class AppProvider with ChangeNotifier {
|
||||
try {
|
||||
_isVoiceAutoGainEnabled = enabled;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('voice_auto_gain_enabled', enabled);
|
||||
await prefs.setBool(_scopedKey('voice_auto_gain_enabled'), enabled);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Error saving voice auto gain setting: $e');
|
||||
@@ -702,7 +715,7 @@ class AppProvider with ChangeNotifier {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_isVoiceEchoCancellationEnabled =
|
||||
prefs.getBool('voice_echo_cancellation_enabled') ?? false;
|
||||
prefs.getBool(_scopedKey('voice_echo_cancellation_enabled')) ?? false;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Error loading voice echo cancellation setting: $e');
|
||||
@@ -713,7 +726,10 @@ class AppProvider with ChangeNotifier {
|
||||
try {
|
||||
_isVoiceEchoCancellationEnabled = enabled;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('voice_echo_cancellation_enabled', enabled);
|
||||
await prefs.setBool(
|
||||
_scopedKey('voice_echo_cancellation_enabled'),
|
||||
enabled,
|
||||
);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Error saving voice echo cancellation setting: $e');
|
||||
@@ -724,7 +740,7 @@ class AppProvider with ChangeNotifier {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_isVoiceNoiseSuppressionEnabled =
|
||||
prefs.getBool('voice_noise_suppression_enabled') ?? false;
|
||||
prefs.getBool(_scopedKey('voice_noise_suppression_enabled')) ?? false;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Error loading voice noise suppression setting: $e');
|
||||
@@ -735,7 +751,10 @@ class AppProvider with ChangeNotifier {
|
||||
try {
|
||||
_isVoiceNoiseSuppressionEnabled = enabled;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('voice_noise_suppression_enabled', enabled);
|
||||
await prefs.setBool(
|
||||
_scopedKey('voice_noise_suppression_enabled'),
|
||||
enabled,
|
||||
);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Error saving voice noise suppression setting: $e');
|
||||
@@ -745,10 +764,11 @@ class AppProvider with ChangeNotifier {
|
||||
Future<void> _loadMessageFontScale() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_messageFontScale = (prefs.getDouble('message_font_scale') ?? 1.0).clamp(
|
||||
0.85,
|
||||
1.4,
|
||||
);
|
||||
_messageFontScale =
|
||||
(prefs.getDouble(_scopedKey('message_font_scale')) ?? 1.0).clamp(
|
||||
0.85,
|
||||
1.4,
|
||||
);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Error loading message font scale setting: $e');
|
||||
@@ -759,7 +779,10 @@ class AppProvider with ChangeNotifier {
|
||||
try {
|
||||
_messageFontScale = scale.clamp(0.85, 1.4);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setDouble('message_font_scale', _messageFontScale);
|
||||
await prefs.setDouble(
|
||||
_scopedKey('message_font_scale'),
|
||||
_messageFontScale,
|
||||
);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Error saving message font scale setting: $e');
|
||||
@@ -2600,6 +2623,24 @@ class AppProvider with ChangeNotifier {
|
||||
locationTrackingService.setFastLocationActiveUse(isActive);
|
||||
}
|
||||
|
||||
Future<void> reloadProfileScopedSettings() async {
|
||||
await Future.wait([
|
||||
_loadMapEnabled(),
|
||||
_loadContactsEnabled(),
|
||||
_loadSensorsEnabled(),
|
||||
_loadVoiceSilenceTrimmingEnabled(),
|
||||
_loadVoiceBandPassFilterEnabled(),
|
||||
_loadVoiceCompressorEnabled(),
|
||||
_loadVoiceLimiterEnabled(),
|
||||
_loadVoiceAutoGainEnabled(),
|
||||
_loadVoiceEchoCancellationEnabled(),
|
||||
_loadVoiceNoiseSuppressionEnabled(),
|
||||
_loadMessageFontScale(),
|
||||
_loadMessagingRouteSettings(),
|
||||
locationTrackingService.loadSettings(),
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> _sendFastLocationUpdate(
|
||||
dynamic position, {
|
||||
required String reason,
|
||||
|
||||
@@ -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
|
||||
/// Same algorithm as Channel model for consistency
|
||||
/// Python equivalent: hashlib.sha256(channel_name.encode()).digest()[0:16]
|
||||
|
||||
@@ -134,6 +134,7 @@ class ContactsProvider with ChangeNotifier {
|
||||
bool _persistRequested = false;
|
||||
bool _isPersistingPendingAdverts = false;
|
||||
bool _persistPendingAdvertsRequested = false;
|
||||
String? _storageNamespace;
|
||||
|
||||
// Add default public channel on initialization
|
||||
ContactsProvider() {
|
||||
@@ -141,19 +142,74 @@ class ContactsProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
bool get isInitialized => _isInitialized;
|
||||
String? get storageNamespace => _storageNamespace;
|
||||
|
||||
/// Initialize and load persisted contacts at app startup
|
||||
/// This loads contacts without filtering, allowing offline viewing
|
||||
Future<void> initializeEarly() async {
|
||||
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 {
|
||||
_resetInMemoryState();
|
||||
debugPrint(
|
||||
'📦 [ContactsProvider] Early loading persisted contacts (no filtering)...',
|
||||
);
|
||||
final storedContacts = await _storageService.loadContacts();
|
||||
final storedGroups = await _storageService.loadContactGroups();
|
||||
final storedPendingAdverts = await _storageService.loadPendingAdverts();
|
||||
final storedContacts = await _storageService.loadContacts(
|
||||
namespace: _storageNamespace,
|
||||
);
|
||||
final storedGroups = await _storageService.loadContactGroups(
|
||||
namespace: _storageNamespace,
|
||||
);
|
||||
final storedPendingAdverts = await _storageService.loadPendingAdverts(
|
||||
namespace: _storageNamespace,
|
||||
);
|
||||
|
||||
// Add stored contacts (excluding any with all-zeros public key)
|
||||
const publicChannelKey =
|
||||
@@ -170,7 +226,10 @@ class ContactsProvider with ChangeNotifier {
|
||||
_savedContactGroups
|
||||
..clear()
|
||||
..addAll(storedGroups);
|
||||
_restorePendingAdverts(storedPendingAdverts);
|
||||
_restorePendingAdverts(
|
||||
storedPendingAdverts,
|
||||
devicePublicKey: devicePublicKey,
|
||||
);
|
||||
debugPrint(
|
||||
'✅ [ContactsProvider] Early loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups',
|
||||
);
|
||||
@@ -197,45 +256,9 @@ class ContactsProvider with ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
debugPrint('📦 [ContactsProvider] Loading persisted contacts...');
|
||||
final storedContacts = await _storageService.loadContacts(
|
||||
excludePublicKey: devicePublicKey,
|
||||
);
|
||||
final storedGroups = await _storageService.loadContactGroups();
|
||||
final storedPendingAdverts = await _storageService.loadPendingAdverts();
|
||||
|
||||
// Add stored contacts (excluding any with all-zeros public key)
|
||||
const publicChannelKey =
|
||||
'0000000000000000000000000000000000000000000000000000000000000000';
|
||||
for (final contact in storedContacts) {
|
||||
// Skip any contacts with all-zeros public key (shouldn't happen, but safety check)
|
||||
if (contact.publicKeyHex == publicChannelKey) {
|
||||
continue;
|
||||
}
|
||||
_contacts[contact.publicKeyHex] = contact;
|
||||
}
|
||||
|
||||
_isInitialized = true;
|
||||
_savedContactGroups
|
||||
..clear()
|
||||
..addAll(storedGroups);
|
||||
_restorePendingAdverts(
|
||||
storedPendingAdverts,
|
||||
devicePublicKey: devicePublicKey,
|
||||
);
|
||||
debugPrint(
|
||||
'✅ [ContactsProvider] Loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups',
|
||||
);
|
||||
|
||||
// Ensure public channel exists after loading
|
||||
_ensurePublicChannelExists();
|
||||
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactsProvider] Error initializing: $e');
|
||||
_isInitialized = true; // Mark as initialized even on error
|
||||
_ensurePublicChannelExists();
|
||||
await _loadFromStorage(force: true, devicePublicKey: devicePublicKey);
|
||||
if (devicePublicKey != null) {
|
||||
_removeSelfContact(devicePublicKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,7 +310,10 @@ class ContactsProvider with ChangeNotifier {
|
||||
try {
|
||||
while (_persistRequested) {
|
||||
_persistRequested = false;
|
||||
await _storageService.saveContacts(_contactsForStorage());
|
||||
await _storageService.saveContacts(
|
||||
_contactsForStorage(),
|
||||
namespace: _storageNamespace,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactsProvider] Error persisting contacts: $e');
|
||||
@@ -305,6 +331,7 @@ class ContactsProvider with ChangeNotifier {
|
||||
_persistPendingAdvertsRequested = false;
|
||||
await _storageService.savePendingAdverts(
|
||||
_pendingAdverts.values.map(_pendingAdvertToJson).toList(),
|
||||
namespace: _storageNamespace,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -436,7 +463,10 @@ class ContactsProvider with ChangeNotifier {
|
||||
|
||||
Future<void> _persistSavedGroups() async {
|
||||
try {
|
||||
await _storageService.saveContactGroups(_savedContactGroups);
|
||||
await _storageService.saveContactGroups(
|
||||
_savedContactGroups,
|
||||
namespace: _storageNamespace,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactsProvider] Error persisting contact groups: $e');
|
||||
}
|
||||
@@ -1442,11 +1472,7 @@ class ContactsProvider with ChangeNotifier {
|
||||
|
||||
/// Get storage statistics
|
||||
Future<Map<String, dynamic>> getStorageStats() async {
|
||||
return await _storageService.getStorageStats();
|
||||
}
|
||||
|
||||
Future<void> persistNow() async {
|
||||
await _storageService.saveContacts(_contactsForStorage());
|
||||
return await _storageService.getStorageStats(namespace: _storageNamespace);
|
||||
}
|
||||
|
||||
Future<void> clearPendingAdverts() async {
|
||||
@@ -1454,7 +1480,7 @@ class ContactsProvider with ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
_pendingAdverts.clear();
|
||||
await _storageService.clearPendingAdverts();
|
||||
await _storageService.clearPendingAdverts(namespace: _storageNamespace);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -1517,6 +1543,12 @@ class ContactsProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
void _resetInMemoryState() {
|
||||
_contacts.clear();
|
||||
_savedContactGroups.clear();
|
||||
_pendingAdverts.clear();
|
||||
}
|
||||
|
||||
Map<String, dynamic> _pendingAdvertToJson(PendingAdvert advert) {
|
||||
return {
|
||||
'publicKey': base64Encode(advert.publicKey),
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:latlong2/latlong.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/map_drawing.dart';
|
||||
import '../models/map_coordinate_space.dart';
|
||||
import '../services/profiles_feature_service.dart';
|
||||
import '../utils/drawing_message_parser.dart';
|
||||
|
||||
/// Drawing mode state
|
||||
@@ -85,6 +86,12 @@ class DrawingProvider with ChangeNotifier {
|
||||
_isInitialized = true;
|
||||
}
|
||||
|
||||
Future<void> reloadProfileScopedState() async {
|
||||
await _loadPreferences();
|
||||
await _loadDrawings();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setMapContext({
|
||||
required MapCoordinateSpace coordinateSpace,
|
||||
String? mapId,
|
||||
@@ -140,15 +147,19 @@ class DrawingProvider with ChangeNotifier {
|
||||
|
||||
Future<void> _loadPreferences() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_showReceivedDrawings = prefs.getBool(_showReceivedDrawingsKey) ?? true;
|
||||
_showSarMarkers = prefs.getBool(_showSarMarkersKey) ?? true;
|
||||
_showReceivedDrawings =
|
||||
prefs.getBool(_scopedKey(_showReceivedDrawingsKey)) ?? true;
|
||||
_showSarMarkers = prefs.getBool(_scopedKey(_showSarMarkersKey)) ?? true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _savePreferences() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_showReceivedDrawingsKey, _showReceivedDrawings);
|
||||
await prefs.setBool(_showSarMarkersKey, _showSarMarkers);
|
||||
await prefs.setBool(
|
||||
_scopedKey(_showReceivedDrawingsKey),
|
||||
_showReceivedDrawings,
|
||||
);
|
||||
await prefs.setBool(_scopedKey(_showSarMarkersKey), _showSarMarkers);
|
||||
}
|
||||
|
||||
/// Start drawing a line
|
||||
@@ -365,7 +376,7 @@ class DrawingProvider with ChangeNotifier {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonList = _drawings.map((d) => d.toJson()).toList();
|
||||
final jsonString = jsonEncode(jsonList);
|
||||
await prefs.setString(_storageKey, jsonString);
|
||||
await prefs.setString(_scopedKey(_storageKey), jsonString);
|
||||
} catch (e) {
|
||||
debugPrint('Error saving drawings: $e');
|
||||
}
|
||||
@@ -375,8 +386,11 @@ class DrawingProvider with ChangeNotifier {
|
||||
Future<void> _loadDrawings() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(_storageKey);
|
||||
if (jsonString == null) return;
|
||||
final jsonString = prefs.getString(_scopedKey(_storageKey));
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
_drawings.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||
_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)
|
||||
List<MapDrawing> getUnsharedDrawings() {
|
||||
return drawings.where((d) => !d.isShared && !d.isReceived).toList();
|
||||
|
||||
@@ -16,6 +16,7 @@ import '../models/location_trail.dart';
|
||||
import '../models/map_coordinate_space.dart';
|
||||
import '../models/map_drawing.dart';
|
||||
import '../models/sar_marker.dart';
|
||||
import '../services/profiles_feature_service.dart';
|
||||
import '../utils/custom_map_id.dart';
|
||||
|
||||
class MapProvider with ChangeNotifier {
|
||||
@@ -105,6 +106,10 @@ class MapProvider with ChangeNotifier {
|
||||
|
||||
LatLngBounds? get customMapBounds => _customMapConfig?.bounds;
|
||||
|
||||
Future<void> reloadProfileScopedState() async {
|
||||
await _loadInitialState();
|
||||
}
|
||||
|
||||
bool matchesActiveCustomMap(String? mapId) {
|
||||
return hasCustomMap &&
|
||||
normalizeCustomMapId(_customMapConfig!.mapId) ==
|
||||
@@ -522,27 +527,29 @@ class MapProvider with ChangeNotifier {
|
||||
Future<void> loadOverlayState() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_showCadastralOverlay =
|
||||
prefs.getBool('map_show_cadastral_overlay') ?? false;
|
||||
prefs.getBool(_scopedKey('map_show_cadastral_overlay')) ?? false;
|
||||
_showForestRoadsOverlay =
|
||||
prefs.getBool('map_show_forest_roads_overlay') ?? false;
|
||||
prefs.getBool(_scopedKey('map_show_forest_roads_overlay')) ?? false;
|
||||
_showHikingTrailsOverlay =
|
||||
prefs.getBool('map_show_hiking_trails_overlay') ?? false;
|
||||
prefs.getBool(_scopedKey('map_show_hiking_trails_overlay')) ?? false;
|
||||
_showMainRoadsOverlay =
|
||||
prefs.getBool('map_show_main_roads_overlay') ?? false;
|
||||
prefs.getBool(_scopedKey('map_show_main_roads_overlay')) ?? false;
|
||||
_showHouseNumbersOverlay =
|
||||
prefs.getBool('map_show_house_numbers_overlay') ?? false;
|
||||
prefs.getBool(_scopedKey('map_show_house_numbers_overlay')) ?? false;
|
||||
_showFireHazardZonesOverlay =
|
||||
prefs.getBool('map_show_fire_hazard_zones_overlay') ?? false;
|
||||
prefs.getBool(_scopedKey('map_show_fire_hazard_zones_overlay')) ??
|
||||
false;
|
||||
_showHistoricalFiresOverlay =
|
||||
prefs.getBool('map_show_historical_fires_overlay') ?? false;
|
||||
prefs.getBool(_scopedKey('map_show_historical_fires_overlay')) ?? false;
|
||||
_showFirebreaksOverlay =
|
||||
prefs.getBool('map_show_firebreaks_overlay') ?? false;
|
||||
prefs.getBool(_scopedKey('map_show_firebreaks_overlay')) ?? false;
|
||||
_showKrasFireZonesOverlay =
|
||||
prefs.getBool('map_show_kras_fire_zones_overlay') ?? false;
|
||||
prefs.getBool(_scopedKey('map_show_kras_fire_zones_overlay')) ?? false;
|
||||
_showPlaceNamesOverlay =
|
||||
prefs.getBool('map_show_place_names_overlay') ?? false;
|
||||
prefs.getBool(_scopedKey('map_show_place_names_overlay')) ?? false;
|
||||
_showMunicipalityBordersOverlay =
|
||||
prefs.getBool('map_show_municipality_borders_overlay') ?? false;
|
||||
prefs.getBool(_scopedKey('map_show_municipality_borders_overlay')) ??
|
||||
false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -557,36 +564,48 @@ class MapProvider with ChangeNotifier {
|
||||
|
||||
Future<void> _saveOverlayState() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('map_show_cadastral_overlay', _showCadastralOverlay);
|
||||
await prefs.setBool(
|
||||
'map_show_forest_roads_overlay',
|
||||
_scopedKey('map_show_cadastral_overlay'),
|
||||
_showCadastralOverlay,
|
||||
);
|
||||
await prefs.setBool(
|
||||
_scopedKey('map_show_forest_roads_overlay'),
|
||||
_showForestRoadsOverlay,
|
||||
);
|
||||
await prefs.setBool(
|
||||
'map_show_hiking_trails_overlay',
|
||||
_scopedKey('map_show_hiking_trails_overlay'),
|
||||
_showHikingTrailsOverlay,
|
||||
);
|
||||
await prefs.setBool('map_show_main_roads_overlay', _showMainRoadsOverlay);
|
||||
await prefs.setBool(
|
||||
'map_show_house_numbers_overlay',
|
||||
_scopedKey('map_show_main_roads_overlay'),
|
||||
_showMainRoadsOverlay,
|
||||
);
|
||||
await prefs.setBool(
|
||||
_scopedKey('map_show_house_numbers_overlay'),
|
||||
_showHouseNumbersOverlay,
|
||||
);
|
||||
await prefs.setBool(
|
||||
'map_show_fire_hazard_zones_overlay',
|
||||
_scopedKey('map_show_fire_hazard_zones_overlay'),
|
||||
_showFireHazardZonesOverlay,
|
||||
);
|
||||
await prefs.setBool(
|
||||
'map_show_historical_fires_overlay',
|
||||
_scopedKey('map_show_historical_fires_overlay'),
|
||||
_showHistoricalFiresOverlay,
|
||||
);
|
||||
await prefs.setBool('map_show_firebreaks_overlay', _showFirebreaksOverlay);
|
||||
await prefs.setBool(
|
||||
'map_show_kras_fire_zones_overlay',
|
||||
_scopedKey('map_show_firebreaks_overlay'),
|
||||
_showFirebreaksOverlay,
|
||||
);
|
||||
await prefs.setBool(
|
||||
_scopedKey('map_show_kras_fire_zones_overlay'),
|
||||
_showKrasFireZonesOverlay,
|
||||
);
|
||||
await prefs.setBool('map_show_place_names_overlay', _showPlaceNamesOverlay);
|
||||
await prefs.setBool(
|
||||
'map_show_municipality_borders_overlay',
|
||||
_scopedKey('map_show_place_names_overlay'),
|
||||
_showPlaceNamesOverlay,
|
||||
);
|
||||
await prefs.setBool(
|
||||
_scopedKey('map_show_municipality_borders_overlay'),
|
||||
_showMunicipalityBordersOverlay,
|
||||
);
|
||||
}
|
||||
@@ -600,13 +619,16 @@ class MapProvider with ChangeNotifier {
|
||||
Future<void> loadTrailSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_showAllContactTrails =
|
||||
prefs.getBool('map_show_all_contact_trails') ?? true;
|
||||
prefs.getBool(_scopedKey('map_show_all_contact_trails')) ?? true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _saveTrailSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('map_show_all_contact_trails', _showAllContactTrails);
|
||||
await prefs.setBool(
|
||||
_scopedKey('map_show_all_contact_trails'),
|
||||
_showAllContactTrails,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setHideRepeatersOnMap(bool hide) async {
|
||||
@@ -614,12 +636,13 @@ class MapProvider with ChangeNotifier {
|
||||
_hideRepeatersOnMap = hide;
|
||||
notifyListeners();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('map_hide_repeaters', _hideRepeatersOnMap);
|
||||
await prefs.setBool(_scopedKey('map_hide_repeaters'), _hideRepeatersOnMap);
|
||||
}
|
||||
|
||||
Future<void> loadRepeaterVisibilitySettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_hideRepeatersOnMap = prefs.getBool('map_hide_repeaters') ?? false;
|
||||
_hideRepeatersOnMap =
|
||||
prefs.getBool(_scopedKey('map_hide_repeaters')) ?? false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -661,7 +684,7 @@ class MapProvider with ChangeNotifier {
|
||||
|
||||
Future<void> _loadCustomMapState() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final configJson = prefs.getString(_customMapConfigKey);
|
||||
final configJson = prefs.getString(_scopedKey(_customMapConfigKey));
|
||||
if (configJson != null && configJson.isNotEmpty) {
|
||||
final decoded = jsonDecode(configJson);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
@@ -672,23 +695,26 @@ class MapProvider with ChangeNotifier {
|
||||
_customMapConfig = null;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_customMapConfig = null;
|
||||
}
|
||||
_isUsingCustomMap =
|
||||
(prefs.getBool(_customMapModeKey) ?? false) && _customMapConfig != null;
|
||||
(prefs.getBool(_scopedKey(_customMapModeKey)) ?? false) &&
|
||||
_customMapConfig != null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _saveCustomMapState() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (_customMapConfig == null) {
|
||||
await prefs.remove(_customMapConfigKey);
|
||||
await prefs.remove(_scopedKey(_customMapConfigKey));
|
||||
} else {
|
||||
await prefs.setString(
|
||||
_customMapConfigKey,
|
||||
_scopedKey(_customMapConfigKey),
|
||||
jsonEncode(_customMapConfig!.toJson()),
|
||||
);
|
||||
}
|
||||
await prefs.setBool(_customMapModeKey, _isUsingCustomMap);
|
||||
await prefs.setBool(_scopedKey(_customMapModeKey), _isUsingCustomMap);
|
||||
}
|
||||
|
||||
Future<(int, int)> _decodeImageSize(Uint8List bytes) async {
|
||||
@@ -711,4 +737,67 @@ class MapProvider with ChangeNotifier {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
|
||||
Map<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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ class MessagesProvider with ChangeNotifier {
|
||||
final Map<String, MessageReceptionDetails> _messageReceptionDetails = {};
|
||||
final Map<String, MessageTransferDetails> _messageTransferDetails = {};
|
||||
final Map<String, MessageRouteMetadata> _messageRouteMetadata = {};
|
||||
String? _storageNamespace;
|
||||
|
||||
// Track pending sent messages by expected ACK/TAG
|
||||
final Map<int, Message> _pendingSentMessages = {};
|
||||
@@ -149,6 +150,7 @@ class MessagesProvider with ChangeNotifier {
|
||||
sarMarkers.where((m) => m.type == SarMarkerType.object).toList();
|
||||
|
||||
bool get isInitialized => _isInitialized;
|
||||
String? get storageNamespace => _storageNamespace;
|
||||
|
||||
String? get targetMessageId => _targetMessageId;
|
||||
|
||||
@@ -311,19 +313,64 @@ class MessagesProvider with ChangeNotifier {
|
||||
Future<void> initialize() async {
|
||||
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 {
|
||||
_cancelAllTimers();
|
||||
_resetInMemoryState();
|
||||
debugPrint('📦 [MessagesProvider] Loading persisted messages...');
|
||||
final storedMessages = await _storageService.loadMessages();
|
||||
final storedMessages = await _storageService.loadMessages(
|
||||
namespace: _storageNamespace,
|
||||
);
|
||||
final storedContactLocations = await _storageService
|
||||
.loadMessageContactLocations();
|
||||
.loadMessageContactLocations(namespace: _storageNamespace);
|
||||
final storedReceptionDetails = await _storageService
|
||||
.loadMessageReceptionDetails();
|
||||
.loadMessageReceptionDetails(namespace: _storageNamespace);
|
||||
final storedTransferDetails = await _storageService
|
||||
.loadMessageTransferDetails();
|
||||
.loadMessageTransferDetails(namespace: _storageNamespace);
|
||||
final storedRouteMetadata = await _storageService
|
||||
.loadMessageRouteMetadata();
|
||||
.loadMessageRouteMetadata(namespace: _storageNamespace);
|
||||
final storedRemovedSarMarkerIds = await _storageService
|
||||
.loadRemovedSarMarkerIds();
|
||||
.loadRemovedSarMarkerIds(namespace: _storageNamespace);
|
||||
_messageContactLocations
|
||||
..clear()
|
||||
..addAll(storedContactLocations);
|
||||
@@ -1051,6 +1098,7 @@ class MessagesProvider with ChangeNotifier {
|
||||
messageReceptionDetails: _messageReceptionDetails,
|
||||
messageTransferDetails: _messageTransferDetails,
|
||||
messageRouteMetadata: _messageRouteMetadata,
|
||||
namespace: _storageNamespace,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -1199,7 +1247,10 @@ class MessagesProvider with ChangeNotifier {
|
||||
Future<void> removeSarMarker(String id) async {
|
||||
_sarMarkers.remove(id);
|
||||
_removedSarMarkerIds.add(id);
|
||||
await _storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds);
|
||||
await _storageService.saveRemovedSarMarkerIds(
|
||||
_removedSarMarkerIds,
|
||||
namespace: _storageNamespace,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -1324,7 +1375,12 @@ class MessagesProvider with ChangeNotifier {
|
||||
debugPrint('🗑️ [MessagesProvider] Message $messageId deleted');
|
||||
|
||||
_persistMessages();
|
||||
unawaited(_storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds));
|
||||
unawaited(
|
||||
_storageService.saveRemovedSarMarkerIds(
|
||||
_removedSarMarkerIds,
|
||||
namespace: _storageNamespace,
|
||||
),
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -1348,23 +1404,15 @@ class MessagesProvider with ChangeNotifier {
|
||||
/// Clear all messages
|
||||
void clearMessages() {
|
||||
_cancelAllTimers();
|
||||
_messages.clear();
|
||||
_sarMarkers.clear();
|
||||
_removedSarMarkerIds.clear();
|
||||
_messageContactLocations.clear();
|
||||
_messageReceptionDetails.clear();
|
||||
_messageTransferDetails.clear();
|
||||
_messageRouteMetadata.clear();
|
||||
_pendingSentMessages.clear();
|
||||
_messageContactMap.clear();
|
||||
_groupedMessageMapping.clear();
|
||||
_ackTagToRecipients.clear();
|
||||
_messageAckHistory.clear();
|
||||
_ackHistoryLookup.clear();
|
||||
_completedAckHistory.clear();
|
||||
_resetInMemoryState();
|
||||
_retryManager.clearAll();
|
||||
_persistMessages();
|
||||
unawaited(_storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds));
|
||||
unawaited(
|
||||
_storageService.saveRemovedSarMarkerIds(
|
||||
_removedSarMarkerIds,
|
||||
namespace: _storageNamespace,
|
||||
),
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -1372,13 +1420,29 @@ class MessagesProvider with ChangeNotifier {
|
||||
Future<void> clearSarMarkers() async {
|
||||
_removedSarMarkerIds.addAll(_sarMarkers.keys);
|
||||
_sarMarkers.clear();
|
||||
await _storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds);
|
||||
await _storageService.saveRemovedSarMarkerIds(
|
||||
_removedSarMarkerIds,
|
||||
namespace: _storageNamespace,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Clear all data
|
||||
void clearAll() {
|
||||
_cancelAllTimers();
|
||||
_resetInMemoryState();
|
||||
_retryManager.clearAll();
|
||||
_persistMessages();
|
||||
unawaited(
|
||||
_storageService.saveRemovedSarMarkerIds(
|
||||
_removedSarMarkerIds,
|
||||
namespace: _storageNamespace,
|
||||
),
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _resetInMemoryState() {
|
||||
_messages.clear();
|
||||
_sarMarkers.clear();
|
||||
_removedSarMarkerIds.clear();
|
||||
@@ -1393,10 +1457,6 @@ class MessagesProvider with ChangeNotifier {
|
||||
_messageAckHistory.clear();
|
||||
_ackHistoryLookup.clear();
|
||||
_completedAckHistory.clear();
|
||||
_retryManager.clearAll();
|
||||
_persistMessages();
|
||||
unawaited(_storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds));
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
int transferCountForSession({
|
||||
|
||||
@@ -20,6 +20,7 @@ import 'settings_screen.dart';
|
||||
import 'device_config_screen.dart';
|
||||
import 'packet_log_screen.dart';
|
||||
import 'live_traffic_screen.dart';
|
||||
import 'profiles_screen.dart';
|
||||
import 'spectrum_scan_screen.dart';
|
||||
import '../utils/toast_logger.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
@@ -28,6 +29,8 @@ import '../widgets/connection_dialog.dart';
|
||||
import '../utils/battery_display_helper.dart';
|
||||
import '../services/developer_mode_service.dart';
|
||||
import '../services/mesh_map_nodes_service.dart';
|
||||
import '../services/profile_manager.dart';
|
||||
import '../services/profiles_feature_service.dart';
|
||||
|
||||
enum _HomeTab { messages, contacts, sensors, map }
|
||||
|
||||
@@ -237,7 +240,11 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_showRxTxIndicators = prefs.getBool('show_rx_tx_indicators') ?? true;
|
||||
_showRxTxIndicators =
|
||||
prefs.getBool(
|
||||
ProfileStorageScope.scopedKey('show_rx_tx_indicators'),
|
||||
) ??
|
||||
true;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -626,6 +633,9 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
icon: const Icon(Icons.more_vert),
|
||||
itemBuilder: (context) {
|
||||
final items = <PopupMenuEntry<void>>[];
|
||||
final profilesEnabled = context
|
||||
.read<ProfileManager>()
|
||||
.profilesEnabled;
|
||||
|
||||
if (_isDeveloperModeEnabled) {
|
||||
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;
|
||||
},
|
||||
),
|
||||
|
||||
@@ -28,6 +28,7 @@ import '../services/location_tracking_service.dart';
|
||||
import '../services/map_marker_service.dart';
|
||||
import '../services/message_destination_preferences.dart';
|
||||
import '../services/trail_color_service.dart';
|
||||
import '../services/profiles_feature_service.dart';
|
||||
import '../widgets/map_debug_info.dart';
|
||||
import '../widgets/map/compass_widget.dart';
|
||||
import '../widgets/map/detailed_compass_dialog.dart';
|
||||
@@ -244,25 +245,49 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (mounted) {
|
||||
// Load last map position if available
|
||||
final lastLat = prefs.getDouble('map_last_latitude');
|
||||
final lastLon = prefs.getDouble('map_last_longitude');
|
||||
final lastZoom = prefs.getDouble('map_last_zoom');
|
||||
final lastLat = prefs.getDouble(
|
||||
ProfileStorageScope.scopedKey('map_last_latitude'),
|
||||
);
|
||||
final lastLon = prefs.getDouble(
|
||||
ProfileStorageScope.scopedKey('map_last_longitude'),
|
||||
);
|
||||
final lastZoom = prefs.getDouble(
|
||||
ProfileStorageScope.scopedKey('map_last_zoom'),
|
||||
);
|
||||
|
||||
// Load last map layer
|
||||
final lastLayerType = prefs.getInt('map_last_layer_type');
|
||||
final lastLayerType = prefs.getInt(
|
||||
ProfileStorageScope.scopedKey('map_last_layer_type'),
|
||||
);
|
||||
setState(() {
|
||||
_rotateMarkerWithHeading =
|
||||
prefs.getBool('map_rotate_with_heading') ?? false;
|
||||
_showMapDebugInfo = prefs.getBool('map_show_debug_info') ?? false;
|
||||
_isFullscreen = prefs.getBool('map_fullscreen') ?? false;
|
||||
prefs.getBool(
|
||||
ProfileStorageScope.scopedKey('map_rotate_with_heading'),
|
||||
) ??
|
||||
false;
|
||||
_showMapDebugInfo =
|
||||
prefs.getBool(
|
||||
ProfileStorageScope.scopedKey('map_show_debug_info'),
|
||||
) ??
|
||||
false;
|
||||
_isFullscreen =
|
||||
prefs.getBool(ProfileStorageScope.scopedKey('map_fullscreen')) ??
|
||||
false;
|
||||
|
||||
// Notify parent about initial fullscreen state
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
widget.onFullscreenChanged?.call(_isFullscreen);
|
||||
});
|
||||
_gpsUpdateDistance = prefs.getDouble('map_gps_update_distance') ?? 3.0;
|
||||
_gpsUpdateDistance =
|
||||
prefs.getDouble(
|
||||
ProfileStorageScope.scopedKey('map_gps_update_distance'),
|
||||
) ??
|
||||
3.0;
|
||||
_backgroundTrackingEnabled =
|
||||
prefs.getBool('background_tracking_enabled') ?? false;
|
||||
prefs.getBool(
|
||||
ProfileStorageScope.scopedKey('background_tracking_enabled'),
|
||||
) ??
|
||||
false;
|
||||
|
||||
// Store saved position for use in build
|
||||
if (lastLat != null && lastLon != null && lastZoom != null) {
|
||||
@@ -298,18 +323,33 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
||||
|
||||
Future<void> _saveSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('map_rotate_with_heading', _rotateMarkerWithHeading);
|
||||
await prefs.setBool('map_show_debug_info', _showMapDebugInfo);
|
||||
await prefs.setBool('map_fullscreen', _isFullscreen);
|
||||
await prefs.setDouble('map_gps_update_distance', _gpsUpdateDistance);
|
||||
await prefs.setBool(
|
||||
'background_tracking_enabled',
|
||||
ProfileStorageScope.scopedKey('map_rotate_with_heading'),
|
||||
_rotateMarkerWithHeading,
|
||||
);
|
||||
await prefs.setBool(
|
||||
ProfileStorageScope.scopedKey('map_show_debug_info'),
|
||||
_showMapDebugInfo,
|
||||
);
|
||||
await prefs.setBool(
|
||||
ProfileStorageScope.scopedKey('map_fullscreen'),
|
||||
_isFullscreen,
|
||||
);
|
||||
await prefs.setDouble(
|
||||
ProfileStorageScope.scopedKey('map_gps_update_distance'),
|
||||
_gpsUpdateDistance,
|
||||
);
|
||||
await prefs.setBool(
|
||||
ProfileStorageScope.scopedKey('background_tracking_enabled'),
|
||||
_backgroundTrackingEnabled,
|
||||
);
|
||||
|
||||
// Save layer type.
|
||||
await prefs.setInt('map_last_layer_type', _currentLayer.type.index);
|
||||
await prefs.remove('map_last_layer_name');
|
||||
await prefs.setInt(
|
||||
ProfileStorageScope.scopedKey('map_last_layer_type'),
|
||||
_currentLayer.type.index,
|
||||
);
|
||||
await prefs.remove(ProfileStorageScope.scopedKey('map_last_layer_name'));
|
||||
}
|
||||
|
||||
Future<void> _saveMapPosition() async {
|
||||
@@ -318,9 +358,18 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final camera = _mapController.camera;
|
||||
await prefs.setDouble('map_last_latitude', camera.center.latitude);
|
||||
await prefs.setDouble('map_last_longitude', camera.center.longitude);
|
||||
await prefs.setDouble('map_last_zoom', camera.zoom);
|
||||
await prefs.setDouble(
|
||||
ProfileStorageScope.scopedKey('map_last_latitude'),
|
||||
camera.center.latitude,
|
||||
);
|
||||
await prefs.setDouble(
|
||||
ProfileStorageScope.scopedKey('map_last_longitude'),
|
||||
camera.center.longitude,
|
||||
);
|
||||
await prefs.setDouble(
|
||||
ProfileStorageScope.scopedKey('map_last_zoom'),
|
||||
camera.zoom,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Error saving map position: $e');
|
||||
}
|
||||
|
||||
258
lib/screens/profiles_screen.dart
Normal file
258
lib/screens/profiles_screen.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import '../providers/app_provider.dart';
|
||||
import '../providers/connection_provider.dart';
|
||||
import '../providers/drawing_provider.dart';
|
||||
import '../providers/map_provider.dart';
|
||||
import '../models/config_profile.dart';
|
||||
import '../services/location_tracking_service.dart';
|
||||
import '../services/locale_preferences.dart';
|
||||
import '../services/mesh_map_nodes_service.dart';
|
||||
@@ -26,6 +27,9 @@ import '../services/route_hash_preferences.dart';
|
||||
import '../services/image_codec_service.dart';
|
||||
import '../services/developer_mode_service.dart';
|
||||
import '../services/notification_service.dart';
|
||||
import '../services/profile_manager.dart';
|
||||
import '../services/profile_workspace_coordinator.dart';
|
||||
import '../services/profiles_feature_service.dart';
|
||||
import '../utils/sample_data_generator.dart';
|
||||
import '../utils/image_message_parser.dart';
|
||||
import '../utils/voice_message_parser.dart';
|
||||
@@ -33,6 +37,7 @@ import '../theme/app_theme.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../widgets/update_dialog.dart';
|
||||
import 'sar_template_management_screen.dart';
|
||||
import 'profiles_screen.dart';
|
||||
import 'welcome_wizard_screen.dart';
|
||||
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
@@ -83,6 +88,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
bool _updateNotificationsEnabled = true;
|
||||
bool _muteForegroundNotifications = true;
|
||||
bool _isDeveloperModeEnabled = false;
|
||||
bool _profilesEnabled = false;
|
||||
DateTime? _onlineTraceCacheUpdatedAt;
|
||||
bool _isClearingOnlineTraceCache = false;
|
||||
int _versionTapCount = 0;
|
||||
@@ -102,6 +108,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_loadImagePreferences();
|
||||
_loadFastLocationSettings();
|
||||
_loadDeveloperMode();
|
||||
_loadProfilesEnabled();
|
||||
_loadOnlineTraceCacheStatus();
|
||||
_loadMapPreferences();
|
||||
_loadNotificationPreferences();
|
||||
@@ -129,7 +136,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_showRxTxIndicators = prefs.getBool('show_rx_tx_indicators') ?? true;
|
||||
_showRxTxIndicators =
|
||||
prefs.getBool(
|
||||
ProfileStorageScope.scopedKey('show_rx_tx_indicators'),
|
||||
) ??
|
||||
true;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -142,6 +153,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadProfilesEnabled() async {
|
||||
final isEnabled = await ProfilesFeatureService.isEnabled();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_profilesEnabled = isEnabled;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadNotificationPreferences() async {
|
||||
final service = NotificationService();
|
||||
await service.initialize();
|
||||
@@ -199,22 +218,33 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
Future<void> _saveRxTxPreference(bool value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('show_rx_tx_indicators', value);
|
||||
await prefs.setBool(
|
||||
ProfileStorageScope.scopedKey('show_rx_tx_indicators'),
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadMapPreferences() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_rotateMapWithHeading = prefs.getBool('map_rotate_with_heading') ?? false;
|
||||
_showMapDebugInfo = prefs.getBool('map_show_debug_info') ?? false;
|
||||
_openMapInFullscreen = prefs.getBool('map_fullscreen') ?? false;
|
||||
_rotateMapWithHeading =
|
||||
prefs.getBool(
|
||||
ProfileStorageScope.scopedKey('map_rotate_with_heading'),
|
||||
) ??
|
||||
false;
|
||||
_showMapDebugInfo =
|
||||
prefs.getBool(ProfileStorageScope.scopedKey('map_show_debug_info')) ??
|
||||
false;
|
||||
_openMapInFullscreen =
|
||||
prefs.getBool(ProfileStorageScope.scopedKey('map_fullscreen')) ??
|
||||
false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _saveMapPreference(String key, bool value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(key, value);
|
||||
await prefs.setBool(ProfileStorageScope.scopedKey(key), value);
|
||||
}
|
||||
|
||||
Future<void> _loadVoicePreferences() async {
|
||||
@@ -487,7 +517,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
// Load settings and restore tracking state
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final wasTracking =
|
||||
prefs.getBool('background_tracking_enabled') ?? false;
|
||||
prefs.getBool(
|
||||
ProfileStorageScope.scopedKey('background_tracking_enabled'),
|
||||
) ??
|
||||
false;
|
||||
|
||||
if (wasTracking) {
|
||||
await _startBackgroundTracking();
|
||||
@@ -1518,6 +1551,46 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
builder: (context, connectionProvider, child) =>
|
||||
_buildImageModePreviewCard(connectionProvider),
|
||||
),
|
||||
_buildSectionHeader('Profiles'),
|
||||
_buildSettingsCard([
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.layers_outlined),
|
||||
title: const Text('Enable Profiles'),
|
||||
subtitle: const Text(
|
||||
'Show profile management UI while keeping the hidden Default profile as the current workspace.',
|
||||
),
|
||||
value: _profilesEnabled,
|
||||
onChanged: (value) async {
|
||||
await context
|
||||
.read<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'),
|
||||
_buildSettingsCard([
|
||||
ListTile(
|
||||
|
||||
156
lib/services/app_config_snapshot_service.dart
Normal file
156
lib/services/app_config_snapshot_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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};
|
||||
|
||||
140
lib/services/device_config_applicator.dart
Normal file
140
lib/services/device_config_applicator.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
100
lib/services/map_workspace_snapshot_service.dart
Normal file
100
lib/services/map_workspace_snapshot_service.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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 {};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
141
lib/services/profile_manager.dart
Normal file
141
lib/services/profile_manager.dart
Normal 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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
10
lib/services/profile_transport.dart
Normal file
10
lib/services/profile_transport.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
abstract class ProfileTransport {
|
||||
String get label;
|
||||
}
|
||||
|
||||
class FileProfileTransport implements ProfileTransport {
|
||||
const FileProfileTransport();
|
||||
|
||||
@override
|
||||
String get label => 'File';
|
||||
}
|
||||
363
lib/services/profile_workspace_coordinator.dart
Normal file
363
lib/services/profile_workspace_coordinator.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
54
lib/services/profiles_feature_service.dart
Normal file
54
lib/services/profiles_feature_service.dart
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user