diff --git a/ios/fastlane/report.xml b/ios/fastlane/report.xml index 3938aeb..68c15a4 100644 --- a/ios/fastlane/report.xml +++ b/ios/fastlane/report.xml @@ -5,22 +5,22 @@ - + - + - + - + diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 0db6845..7a3f2ef 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -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 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 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 super.initState(); WidgetsBinding.instance.addObserver(this); _appProvider = context.read(); + _connectionProvider = context.read(); + _connectionProvider.addListener(_handleConnectionProviderChanged); _isMapEnabled = _appProvider.isMapEnabled; _isContactsEnabled = _appProvider.isContactsEnabled; _isSensorsEnabled = _appProvider.isSensorsEnabled; @@ -108,6 +116,10 @@ class _HomeScreenState extends State _showPermissionDialog(); }); } + + WidgetsBinding.instance.addPostFrameCallback((_) { + _handleConnectionProviderChanged(); + }); } void _initTabController() { @@ -223,6 +235,36 @@ class _HomeScreenState extends State ); } + 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() + .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 }); } - @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 ); } - 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 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 ); }, child: _buildCompactActivityIndicator( - rxActive: provider.rxActivity, - txActive: provider.txActivity, - ), + rxActive: provider.rxActivity, + txActive: provider.txActivity, + ), ) else const SizedBox(width: 24), diff --git a/lib/services/profile_device_key_resolver.dart b/lib/services/profile_device_key_resolver.dart new file mode 100644 index 0000000..472fe8b --- /dev/null +++ b/lib/services/profile_device_key_resolver.dart @@ -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'; + } +} diff --git a/lib/services/profile_manager.dart b/lib/services/profile_manager.dart index 9ea505e..fdecd49 100644 --- a/lib/services/profile_manager.dart +++ b/lib/services/profile_manager.dart @@ -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 _customProfiles = []; final List _transferHistory = []; + final Map _deviceProfileDefaults = {}; 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) { + _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; @@ -90,9 +106,30 @@ class ProfileManager with ChangeNotifier { } Future setActiveProfileId(String id) async { + await setActiveProfileIdForDevice(id); + } + + String profileIdForDevice(String? deviceKey) { + if (deviceKey == null || deviceKey.isEmpty) { + return _activeProfileId; + } + return _deviceProfileDefaults[deviceKey] ?? ConfigProfile.defaultProfileId; + } + + Future 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, diff --git a/lib/services/profile_workspace_coordinator.dart b/lib/services/profile_workspace_coordinator.dart index 3396d20..413bb0e 100644 --- a/lib/services/profile_workspace_coordinator.dart +++ b/lib/services/profile_workspace_coordinator.dart @@ -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 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 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 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 _switchRuntimeScope(String profileId) async { final runtimeProfilesEnabled = profileManager.profilesEnabled; ProfileStorageScope.setScope( diff --git a/test/services/profile_manager_test.dart b/test/services/profile_manager_test.dart index 618da94..e34ad29 100644 --- a/test/services/profile_manager_test.dart +++ b/test/services/profile_manager_test.dart @@ -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, + ); + }); }); } diff --git a/test/services/profile_workspace_coordinator_test.dart b/test/services/profile_workspace_coordinator_test.dart index 17af36e..701eb0e 100644 --- a/test/services/profile_workspace_coordinator_test.dart +++ b/test/services/profile_workspace_coordinator_test.dart @@ -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 disconnect() async {