mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 08:20:36 +00:00
feat: Save profile defaults per device
This commit is contained in:
@@ -5,22 +5,22 @@
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.00024">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.00103">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.554978">
|
||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="1.372199">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: build_app" time="100.17557">
|
||||
<testcase classname="fastlane.lanes" name="2: build_app" time="121.394124">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="264.49426">
|
||||
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="365.414242">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -29,7 +31,9 @@ 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_device_key_resolver.dart';
|
||||
import '../services/profile_manager.dart';
|
||||
import '../services/profile_workspace_coordinator.dart';
|
||||
import '../services/profiles_feature_service.dart';
|
||||
|
||||
enum _HomeTab { messages, contacts, sensors, map }
|
||||
@@ -60,6 +64,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
with TickerProviderStateMixin, WidgetsBindingObserver {
|
||||
late TabController _tabController;
|
||||
late final AppProvider _appProvider;
|
||||
late final ConnectionProvider _connectionProvider;
|
||||
int _currentIndex = 0;
|
||||
bool _isMapFullscreen = false;
|
||||
bool _showRxTxIndicators = true;
|
||||
@@ -68,6 +73,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
bool _isContactsEnabled = true;
|
||||
bool _isSensorsEnabled = false;
|
||||
AppLifecycleState _lifecycleState = AppLifecycleState.resumed;
|
||||
String? _lastProfileDeviceKey;
|
||||
|
||||
List<_HomeTab> get _enabledTabs {
|
||||
return [
|
||||
@@ -91,6 +97,8 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_appProvider = context.read<AppProvider>();
|
||||
_connectionProvider = context.read<ConnectionProvider>();
|
||||
_connectionProvider.addListener(_handleConnectionProviderChanged);
|
||||
_isMapEnabled = _appProvider.isMapEnabled;
|
||||
_isContactsEnabled = _appProvider.isContactsEnabled;
|
||||
_isSensorsEnabled = _appProvider.isSensorsEnabled;
|
||||
@@ -108,6 +116,10 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
_showPermissionDialog();
|
||||
});
|
||||
}
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_handleConnectionProviderChanged();
|
||||
});
|
||||
}
|
||||
|
||||
void _initTabController() {
|
||||
@@ -223,6 +235,36 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
);
|
||||
}
|
||||
|
||||
void _handleConnectionProviderChanged() {
|
||||
final deviceKey = ProfileDeviceKeyResolver.resolve(
|
||||
deviceInfo: _connectionProvider.deviceInfo,
|
||||
connectionMode: _connectionProvider.connectionMode,
|
||||
);
|
||||
if (_lastProfileDeviceKey == deviceKey) {
|
||||
return;
|
||||
}
|
||||
_lastProfileDeviceKey = deviceKey;
|
||||
if (deviceKey == null || !mounted) {
|
||||
return;
|
||||
}
|
||||
unawaited(
|
||||
context
|
||||
.read<ProfileWorkspaceCoordinator>()
|
||||
.syncActiveProfileForCurrentDevice(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_connectionProvider.removeListener(_handleConnectionProviderChanged);
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_appProvider.setFastLocationUiActive(false);
|
||||
_appProvider.removeListener(_handleAppProviderChanged);
|
||||
_tabController.removeListener(_onTabChanged);
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _openLiveTraffic(ConnectionProvider provider) {
|
||||
openLiveTrafficScreen(context, provider);
|
||||
}
|
||||
@@ -257,16 +299,6 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_appProvider.setFastLocationUiActive(false);
|
||||
_appProvider.removeListener(_handleAppProviderChanged);
|
||||
_tabController.removeListener(_onTabChanged);
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _showPermissionDialog() {
|
||||
if (!mounted) return;
|
||||
|
||||
@@ -402,10 +434,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMiniSignalBars({
|
||||
required int activeBars,
|
||||
required Color color,
|
||||
}) {
|
||||
Widget _buildMiniSignalBars({required int activeBars, required Color color}) {
|
||||
final inactive = Colors.grey.withValues(alpha: 0.3);
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -1033,9 +1062,10 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
deviceInfo.signalRssi != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
_buildMiniSignalBars(
|
||||
activeBars: BatteryDisplayHelper.getSignalBars(
|
||||
deviceInfo.signalRssi!,
|
||||
),
|
||||
activeBars:
|
||||
BatteryDisplayHelper.getSignalBars(
|
||||
deviceInfo.signalRssi!,
|
||||
),
|
||||
color: signalColor,
|
||||
),
|
||||
],
|
||||
@@ -1162,9 +1192,9 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
);
|
||||
},
|
||||
child: _buildCompactActivityIndicator(
|
||||
rxActive: provider.rxActivity,
|
||||
txActive: provider.txActivity,
|
||||
),
|
||||
rxActive: provider.rxActivity,
|
||||
txActive: provider.txActivity,
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 24),
|
||||
|
||||
29
lib/services/profile_device_key_resolver.dart
Normal file
29
lib/services/profile_device_key_resolver.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
import '../models/device_info.dart';
|
||||
|
||||
class ProfileDeviceKeyResolver {
|
||||
static String? resolve({
|
||||
required DeviceInfo deviceInfo,
|
||||
required ConnectionMode connectionMode,
|
||||
}) {
|
||||
final publicKey = deviceInfo.publicKey;
|
||||
if (publicKey != null && publicKey.isNotEmpty) {
|
||||
final hex = publicKey
|
||||
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
if (hex.isNotEmpty) {
|
||||
return 'pk:$hex';
|
||||
}
|
||||
}
|
||||
|
||||
final deviceId = deviceInfo.deviceId?.trim();
|
||||
if (deviceId == null || deviceId.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (connectionMode == ConnectionMode.usb && deviceId == 'usb') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'id:$deviceId';
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,14 @@ 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 _deviceProfileDefaultsKey =
|
||||
'profiles_device_active_profile_ids';
|
||||
static const String _transferHistoryKey = 'profiles_transfer_history';
|
||||
|
||||
final List<ConfigProfile> _customProfiles = <ConfigProfile>[];
|
||||
final List<ProfileTransferRecord> _transferHistory =
|
||||
<ProfileTransferRecord>[];
|
||||
final Map<String, String> _deviceProfileDefaults = <String, String>{};
|
||||
bool _isInitialized = false;
|
||||
bool _profilesEnabled = false;
|
||||
String _activeProfileId = ConfigProfile.defaultProfileId;
|
||||
@@ -37,6 +40,19 @@ class ProfileManager with ChangeNotifier {
|
||||
_activeProfileId =
|
||||
prefs.getString(activeProfileIdKey) ?? ConfigProfile.defaultProfileId;
|
||||
|
||||
final deviceDefaultsJson = prefs.getString(_deviceProfileDefaultsKey);
|
||||
if (deviceDefaultsJson != null && deviceDefaultsJson.isNotEmpty) {
|
||||
final decoded = jsonDecode(deviceDefaultsJson);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
_deviceProfileDefaults
|
||||
..clear()
|
||||
..addAll(
|
||||
decoded.map((key, value) => MapEntry(key, value?.toString() ?? ''))
|
||||
..removeWhere((key, value) => value.isEmpty),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final profilesJson = prefs.getString(_profilesKey);
|
||||
if (profilesJson != null && profilesJson.isNotEmpty) {
|
||||
final decoded = jsonDecode(profilesJson) as List<dynamic>;
|
||||
@@ -90,9 +106,30 @@ class ProfileManager with ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> setActiveProfileId(String id) async {
|
||||
await setActiveProfileIdForDevice(id);
|
||||
}
|
||||
|
||||
String profileIdForDevice(String? deviceKey) {
|
||||
if (deviceKey == null || deviceKey.isEmpty) {
|
||||
return _activeProfileId;
|
||||
}
|
||||
return _deviceProfileDefaults[deviceKey] ?? ConfigProfile.defaultProfileId;
|
||||
}
|
||||
|
||||
Future<void> setActiveProfileIdForDevice(
|
||||
String id, {
|
||||
String? deviceKey,
|
||||
}) async {
|
||||
_activeProfileId = id;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(activeProfileIdKey, id);
|
||||
if (deviceKey != null && deviceKey.isNotEmpty) {
|
||||
_deviceProfileDefaults[deviceKey] = id;
|
||||
await prefs.setString(
|
||||
_deviceProfileDefaultsKey,
|
||||
jsonEncode(_deviceProfileDefaults),
|
||||
);
|
||||
}
|
||||
ProfileStorageScope.setScope(
|
||||
profilesEnabled: _profilesEnabled,
|
||||
activeProfileId: _activeProfileId,
|
||||
|
||||
@@ -19,6 +19,7 @@ import 'contact_storage_service.dart';
|
||||
import 'device_config_applicator.dart';
|
||||
import 'message_storage_service.dart';
|
||||
import 'profile_manager.dart';
|
||||
import 'profile_device_key_resolver.dart';
|
||||
import 'profiles_feature_service.dart';
|
||||
import 'map_workspace_snapshot_service.dart';
|
||||
|
||||
@@ -63,6 +64,7 @@ class ProfileWorkspaceCoordinator {
|
||||
final DeviceConfigApplicator _deviceConfigApplicator;
|
||||
final MessageStorageService _messageStorageService;
|
||||
final ContactStorageService _contactStorageService;
|
||||
bool _isSyncingDeviceProfile = false;
|
||||
|
||||
Future<void> setProfilesEnabled(bool enabled) async {
|
||||
final wasEnabled = profileManager.profilesEnabled;
|
||||
@@ -77,6 +79,14 @@ class ProfileWorkspaceCoordinator {
|
||||
activeProfileId: enabled ? profileManager.activeProfileId : 'default',
|
||||
);
|
||||
if (enabled) {
|
||||
final deviceKey = _currentDeviceProfileKey;
|
||||
final targetProfileId = profileManager.profileIdForDevice(deviceKey);
|
||||
if (targetProfileId != profileManager.activeProfileId) {
|
||||
await profileManager.setActiveProfileIdForDevice(
|
||||
targetProfileId,
|
||||
deviceKey: deviceKey,
|
||||
);
|
||||
}
|
||||
if (wasEnabled) {
|
||||
await openProfile(profileManager.activeProfileId);
|
||||
} else {
|
||||
@@ -181,9 +191,13 @@ class ProfileWorkspaceCoordinator {
|
||||
}
|
||||
|
||||
Future<void> openProfile(String profileId) async {
|
||||
final deviceKey = _currentDeviceProfileKey;
|
||||
await _saveActiveCustomProfileSnapshot();
|
||||
await connectionProvider.disconnect();
|
||||
await profileManager.setActiveProfileId(profileId);
|
||||
await profileManager.setActiveProfileIdForDevice(
|
||||
profileId,
|
||||
deviceKey: deviceKey,
|
||||
);
|
||||
await _switchRuntimeScope(profileId);
|
||||
|
||||
final profile = await resolveProfile(profileId);
|
||||
@@ -271,6 +285,49 @@ class ProfileWorkspaceCoordinator {
|
||||
return imported;
|
||||
}
|
||||
|
||||
Future<void> syncActiveProfileForCurrentDevice() async {
|
||||
if (!profileManager.profilesEnabled || _isSyncingDeviceProfile) {
|
||||
return;
|
||||
}
|
||||
final deviceKey = _currentDeviceProfileKey;
|
||||
if (deviceKey == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final targetProfileId = profileManager.profileIdForDevice(deviceKey);
|
||||
if (targetProfileId == profileManager.activeProfileId) {
|
||||
return;
|
||||
}
|
||||
|
||||
_isSyncingDeviceProfile = true;
|
||||
try {
|
||||
await _persistCurrentState();
|
||||
await profileManager.setActiveProfileIdForDevice(
|
||||
targetProfileId,
|
||||
deviceKey: deviceKey,
|
||||
);
|
||||
await _switchRuntimeScope(targetProfileId);
|
||||
|
||||
final profile = await resolveProfile(targetProfileId);
|
||||
await _appConfigSnapshotService.apply(
|
||||
profile.sections.appSettings,
|
||||
appProvider,
|
||||
);
|
||||
await _mapWorkspaceSnapshotService.apply(
|
||||
profile.sections.mapWorkspace,
|
||||
mapProvider: mapProvider,
|
||||
drawingProvider: drawingProvider,
|
||||
);
|
||||
} finally {
|
||||
_isSyncingDeviceProfile = false;
|
||||
}
|
||||
}
|
||||
|
||||
String? get _currentDeviceProfileKey => ProfileDeviceKeyResolver.resolve(
|
||||
deviceInfo: connectionProvider.deviceInfo,
|
||||
connectionMode: connectionProvider.connectionMode,
|
||||
);
|
||||
|
||||
Future<void> _switchRuntimeScope(String profileId) async {
|
||||
final runtimeProfilesEnabled = profileManager.profilesEnabled;
|
||||
ProfileStorageScope.setScope(
|
||||
|
||||
@@ -82,5 +82,42 @@ void main() {
|
||||
expect(reloaded.activeProfileId, profile.id);
|
||||
},
|
||||
);
|
||||
|
||||
test('stores per-device default profiles independently', () async {
|
||||
final manager = ProfileManager();
|
||||
await manager.initialize();
|
||||
await manager.setProfilesEnabled(true);
|
||||
|
||||
final profile = ConfigProfile(
|
||||
id: 'profile-alpha',
|
||||
name: 'Alpha',
|
||||
createdAt: DateTime.parse('2026-03-16T12:00:00Z'),
|
||||
updatedAt: DateTime.parse('2026-03-16T12:00:00Z'),
|
||||
sections: const ConfigProfileSections(),
|
||||
);
|
||||
|
||||
await manager.upsertProfile(profile);
|
||||
await manager.setActiveProfileIdForDevice(
|
||||
profile.id,
|
||||
deviceKey: 'pk:device-a',
|
||||
);
|
||||
await manager.setActiveProfileIdForDevice(
|
||||
ConfigProfile.defaultProfileId,
|
||||
deviceKey: 'pk:device-b',
|
||||
);
|
||||
|
||||
final reloaded = ProfileManager();
|
||||
await reloaded.initialize();
|
||||
|
||||
expect(reloaded.profileIdForDevice('pk:device-a'), profile.id);
|
||||
expect(
|
||||
reloaded.profileIdForDevice('pk:device-b'),
|
||||
ConfigProfile.defaultProfileId,
|
||||
);
|
||||
expect(
|
||||
reloaded.profileIdForDevice('pk:device-c'),
|
||||
ConfigProfile.defaultProfileId,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -184,6 +184,39 @@ void main() {
|
||||
expect(manager.activeProfileId, target.id);
|
||||
},
|
||||
);
|
||||
|
||||
test('syncActiveProfileForCurrentDevice uses per-device default', () async {
|
||||
final manager = ProfileManager();
|
||||
await manager.initialize();
|
||||
await manager.setProfilesEnabled(true);
|
||||
|
||||
final alpha = ConfigProfile(
|
||||
id: 'profile-alpha',
|
||||
name: 'Alpha',
|
||||
createdAt: DateTime.parse('2026-03-16T12:00:00Z'),
|
||||
updatedAt: DateTime.parse('2026-03-16T12:00:00Z'),
|
||||
sections: const ConfigProfileSections(),
|
||||
);
|
||||
await manager.upsertProfile(alpha);
|
||||
await manager.setActiveProfileIdForDevice(
|
||||
alpha.id,
|
||||
deviceKey: 'pk:01020304',
|
||||
);
|
||||
await manager.setActiveProfileId(ConfigProfile.defaultProfileId);
|
||||
|
||||
final connectionProvider = _FakeConnectionProvider(
|
||||
deviceInfo: DeviceInfo(publicKey: Uint8List.fromList([1, 2, 3, 4])),
|
||||
);
|
||||
final coordinator = _buildCoordinator(
|
||||
profileManager: manager,
|
||||
connectionProvider: connectionProvider,
|
||||
);
|
||||
|
||||
await coordinator.syncActiveProfileForCurrentDevice();
|
||||
|
||||
expect(manager.activeProfileId, alpha.id);
|
||||
expect(connectionProvider.disconnectCallCount, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -265,10 +298,18 @@ class _FakeDeviceConfigApplicator extends DeviceConfigApplicator {
|
||||
}
|
||||
|
||||
class _FakeConnectionProvider implements ConnectionProvider {
|
||||
_FakeConnectionProvider({
|
||||
DeviceInfo? deviceInfo,
|
||||
this.connectionMode = ConnectionMode.ble,
|
||||
}) : deviceInfo = deviceInfo ?? DeviceInfo();
|
||||
|
||||
int disconnectCallCount = 0;
|
||||
|
||||
@override
|
||||
DeviceInfo get deviceInfo => DeviceInfo();
|
||||
final DeviceInfo deviceInfo;
|
||||
|
||||
@override
|
||||
final ConnectionMode connectionMode;
|
||||
|
||||
@override
|
||||
Future<void> disconnect() async {
|
||||
|
||||
Reference in New Issue
Block a user