feat: Save profile defaults per device

This commit is contained in:
Janez T
2026-03-18 13:34:50 +01:00
parent 87cb890877
commit 29b75a3155
7 changed files with 257 additions and 26 deletions

View File

@@ -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>
<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>
<testcase classname="fastlane.lanes" name="2: build_app" time="100.17557"> <testcase classname="fastlane.lanes" name="2: build_app" time="121.394124">
</testcase> </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> </testcase>

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -29,7 +31,9 @@ import '../widgets/connection_dialog.dart';
import '../utils/battery_display_helper.dart'; import '../utils/battery_display_helper.dart';
import '../services/developer_mode_service.dart'; import '../services/developer_mode_service.dart';
import '../services/mesh_map_nodes_service.dart'; import '../services/mesh_map_nodes_service.dart';
import '../services/profile_device_key_resolver.dart';
import '../services/profile_manager.dart'; import '../services/profile_manager.dart';
import '../services/profile_workspace_coordinator.dart';
import '../services/profiles_feature_service.dart'; import '../services/profiles_feature_service.dart';
enum _HomeTab { messages, contacts, sensors, map } enum _HomeTab { messages, contacts, sensors, map }
@@ -60,6 +64,7 @@ class _HomeScreenState extends State<HomeScreen>
with TickerProviderStateMixin, WidgetsBindingObserver { with TickerProviderStateMixin, WidgetsBindingObserver {
late TabController _tabController; late TabController _tabController;
late final AppProvider _appProvider; late final AppProvider _appProvider;
late final ConnectionProvider _connectionProvider;
int _currentIndex = 0; int _currentIndex = 0;
bool _isMapFullscreen = false; bool _isMapFullscreen = false;
bool _showRxTxIndicators = true; bool _showRxTxIndicators = true;
@@ -68,6 +73,7 @@ class _HomeScreenState extends State<HomeScreen>
bool _isContactsEnabled = true; bool _isContactsEnabled = true;
bool _isSensorsEnabled = false; bool _isSensorsEnabled = false;
AppLifecycleState _lifecycleState = AppLifecycleState.resumed; AppLifecycleState _lifecycleState = AppLifecycleState.resumed;
String? _lastProfileDeviceKey;
List<_HomeTab> get _enabledTabs { List<_HomeTab> get _enabledTabs {
return [ return [
@@ -91,6 +97,8 @@ class _HomeScreenState extends State<HomeScreen>
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
_appProvider = context.read<AppProvider>(); _appProvider = context.read<AppProvider>();
_connectionProvider = context.read<ConnectionProvider>();
_connectionProvider.addListener(_handleConnectionProviderChanged);
_isMapEnabled = _appProvider.isMapEnabled; _isMapEnabled = _appProvider.isMapEnabled;
_isContactsEnabled = _appProvider.isContactsEnabled; _isContactsEnabled = _appProvider.isContactsEnabled;
_isSensorsEnabled = _appProvider.isSensorsEnabled; _isSensorsEnabled = _appProvider.isSensorsEnabled;
@@ -108,6 +116,10 @@ class _HomeScreenState extends State<HomeScreen>
_showPermissionDialog(); _showPermissionDialog();
}); });
} }
WidgetsBinding.instance.addPostFrameCallback((_) {
_handleConnectionProviderChanged();
});
} }
void _initTabController() { 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) { void _openLiveTraffic(ConnectionProvider provider) {
openLiveTrafficScreen(context, 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() { void _showPermissionDialog() {
if (!mounted) return; if (!mounted) return;
@@ -402,10 +434,7 @@ class _HomeScreenState extends State<HomeScreen>
); );
} }
Widget _buildMiniSignalBars({ Widget _buildMiniSignalBars({required int activeBars, required Color color}) {
required int activeBars,
required Color color,
}) {
final inactive = Colors.grey.withValues(alpha: 0.3); final inactive = Colors.grey.withValues(alpha: 0.3);
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -1033,7 +1062,8 @@ class _HomeScreenState extends State<HomeScreen>
deviceInfo.signalRssi != null) ...[ deviceInfo.signalRssi != null) ...[
const SizedBox(width: 4), const SizedBox(width: 4),
_buildMiniSignalBars( _buildMiniSignalBars(
activeBars: BatteryDisplayHelper.getSignalBars( activeBars:
BatteryDisplayHelper.getSignalBars(
deviceInfo.signalRssi!, deviceInfo.signalRssi!,
), ),
color: signalColor, color: signalColor,

View 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';
}
}

View File

@@ -9,11 +9,14 @@ import 'profiles_feature_service.dart';
class ProfileManager with ChangeNotifier { class ProfileManager with ChangeNotifier {
static const String _profilesKey = 'profiles_library'; static const String _profilesKey = 'profiles_library';
static const String activeProfileIdKey = 'profiles_active_profile_id'; static const String activeProfileIdKey = 'profiles_active_profile_id';
static const String _deviceProfileDefaultsKey =
'profiles_device_active_profile_ids';
static const String _transferHistoryKey = 'profiles_transfer_history'; static const String _transferHistoryKey = 'profiles_transfer_history';
final List<ConfigProfile> _customProfiles = <ConfigProfile>[]; final List<ConfigProfile> _customProfiles = <ConfigProfile>[];
final List<ProfileTransferRecord> _transferHistory = final List<ProfileTransferRecord> _transferHistory =
<ProfileTransferRecord>[]; <ProfileTransferRecord>[];
final Map<String, String> _deviceProfileDefaults = <String, String>{};
bool _isInitialized = false; bool _isInitialized = false;
bool _profilesEnabled = false; bool _profilesEnabled = false;
String _activeProfileId = ConfigProfile.defaultProfileId; String _activeProfileId = ConfigProfile.defaultProfileId;
@@ -37,6 +40,19 @@ class ProfileManager with ChangeNotifier {
_activeProfileId = _activeProfileId =
prefs.getString(activeProfileIdKey) ?? ConfigProfile.defaultProfileId; 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); final profilesJson = prefs.getString(_profilesKey);
if (profilesJson != null && profilesJson.isNotEmpty) { if (profilesJson != null && profilesJson.isNotEmpty) {
final decoded = jsonDecode(profilesJson) as List<dynamic>; final decoded = jsonDecode(profilesJson) as List<dynamic>;
@@ -90,9 +106,30 @@ class ProfileManager with ChangeNotifier {
} }
Future<void> setActiveProfileId(String id) async { 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; _activeProfileId = id;
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setString(activeProfileIdKey, id); await prefs.setString(activeProfileIdKey, id);
if (deviceKey != null && deviceKey.isNotEmpty) {
_deviceProfileDefaults[deviceKey] = id;
await prefs.setString(
_deviceProfileDefaultsKey,
jsonEncode(_deviceProfileDefaults),
);
}
ProfileStorageScope.setScope( ProfileStorageScope.setScope(
profilesEnabled: _profilesEnabled, profilesEnabled: _profilesEnabled,
activeProfileId: _activeProfileId, activeProfileId: _activeProfileId,

View File

@@ -19,6 +19,7 @@ import 'contact_storage_service.dart';
import 'device_config_applicator.dart'; import 'device_config_applicator.dart';
import 'message_storage_service.dart'; import 'message_storage_service.dart';
import 'profile_manager.dart'; import 'profile_manager.dart';
import 'profile_device_key_resolver.dart';
import 'profiles_feature_service.dart'; import 'profiles_feature_service.dart';
import 'map_workspace_snapshot_service.dart'; import 'map_workspace_snapshot_service.dart';
@@ -63,6 +64,7 @@ class ProfileWorkspaceCoordinator {
final DeviceConfigApplicator _deviceConfigApplicator; final DeviceConfigApplicator _deviceConfigApplicator;
final MessageStorageService _messageStorageService; final MessageStorageService _messageStorageService;
final ContactStorageService _contactStorageService; final ContactStorageService _contactStorageService;
bool _isSyncingDeviceProfile = false;
Future<void> setProfilesEnabled(bool enabled) async { Future<void> setProfilesEnabled(bool enabled) async {
final wasEnabled = profileManager.profilesEnabled; final wasEnabled = profileManager.profilesEnabled;
@@ -77,6 +79,14 @@ class ProfileWorkspaceCoordinator {
activeProfileId: enabled ? profileManager.activeProfileId : 'default', activeProfileId: enabled ? profileManager.activeProfileId : 'default',
); );
if (enabled) { if (enabled) {
final deviceKey = _currentDeviceProfileKey;
final targetProfileId = profileManager.profileIdForDevice(deviceKey);
if (targetProfileId != profileManager.activeProfileId) {
await profileManager.setActiveProfileIdForDevice(
targetProfileId,
deviceKey: deviceKey,
);
}
if (wasEnabled) { if (wasEnabled) {
await openProfile(profileManager.activeProfileId); await openProfile(profileManager.activeProfileId);
} else { } else {
@@ -181,9 +191,13 @@ class ProfileWorkspaceCoordinator {
} }
Future<void> openProfile(String profileId) async { Future<void> openProfile(String profileId) async {
final deviceKey = _currentDeviceProfileKey;
await _saveActiveCustomProfileSnapshot(); await _saveActiveCustomProfileSnapshot();
await connectionProvider.disconnect(); await connectionProvider.disconnect();
await profileManager.setActiveProfileId(profileId); await profileManager.setActiveProfileIdForDevice(
profileId,
deviceKey: deviceKey,
);
await _switchRuntimeScope(profileId); await _switchRuntimeScope(profileId);
final profile = await resolveProfile(profileId); final profile = await resolveProfile(profileId);
@@ -271,6 +285,49 @@ class ProfileWorkspaceCoordinator {
return imported; 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 { Future<void> _switchRuntimeScope(String profileId) async {
final runtimeProfilesEnabled = profileManager.profilesEnabled; final runtimeProfilesEnabled = profileManager.profilesEnabled;
ProfileStorageScope.setScope( ProfileStorageScope.setScope(

View File

@@ -82,5 +82,42 @@ void main() {
expect(reloaded.activeProfileId, profile.id); 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,
);
});
}); });
} }

View File

@@ -184,6 +184,39 @@ void main() {
expect(manager.activeProfileId, target.id); 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 { class _FakeConnectionProvider implements ConnectionProvider {
_FakeConnectionProvider({
DeviceInfo? deviceInfo,
this.connectionMode = ConnectionMode.ble,
}) : deviceInfo = deviceInfo ?? DeviceInfo();
int disconnectCallCount = 0; int disconnectCallCount = 0;
@override @override
DeviceInfo get deviceInfo => DeviceInfo(); final DeviceInfo deviceInfo;
@override
final ConnectionMode connectionMode;
@override @override
Future<void> disconnect() async { Future<void> disconnect() async {