From 5788d93e891a62fe426f2107ed0c93ec8caf21f1 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 27 May 2026 03:29:18 +0200 Subject: [PATCH] feat(profiles): sort by recent use close #1151 --- lib/profiles/active_profile_provider.dart | 4 +- lib/profiles/profile_merge.dart | 32 +++++- .../profile/profile_switch_screen.dart | 25 +++-- .../active_profile_provider_test.dart | 14 +++ test/profiles/profile_merge_test.dart | 106 ++++++++++++++++++ .../profile/profile_switch_screen_test.dart | 47 ++++++++ 6 files changed, 217 insertions(+), 11 deletions(-) create mode 100644 test/profiles/profile_merge_test.dart diff --git a/lib/profiles/active_profile_provider.dart b/lib/profiles/active_profile_provider.dart index d1e8866f..d751aaae 100644 --- a/lib/profiles/active_profile_provider.dart +++ b/lib/profiles/active_profile_provider.dart @@ -223,7 +223,9 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier await storage.setActiveProfileId(profile.id); final now = DateTime.now(); await storage.markProfileUsed(profile.id, now); - _active = profile.copyWith(lastUsedAt: now); + final activated = profile.copyWith(lastUsedAt: now); + _active = activated; + _profiles = sortProfilesByLastUsed([for (final p in _profiles) p.id == profile.id ? activated : p]); safeNotifyListeners(); appLogger.i('ActiveProfileProvider: activated ${profile.displayName} (${profile.id})'); if (profile.isLocal) { diff --git a/lib/profiles/profile_merge.dart b/lib/profiles/profile_merge.dart index 953457eb..fe88a83d 100644 --- a/lib/profiles/profile_merge.dart +++ b/lib/profiles/profile_merge.dart @@ -13,7 +13,7 @@ List mergeLocalWithPlexHome({ required Map connectionsById, StorageService? storage, }) { - final out = [...locals]; + final out = [for (final local in locals) _withLatestStoredLastUsed(local, storage)]; for (final entry in plexHomeByConnectionId.entries) { final connectionId = entry.key; if (!connectionsById.containsKey(connectionId)) continue; @@ -29,5 +29,33 @@ List mergeLocalWithPlexHome({ ); } } - return out; + return sortProfilesByLastUsed(out); +} + +List sortProfilesByLastUsed(List profiles) { + final indexed = profiles.indexed.toList(); + indexed.sort((a, b) { + final aLastUsed = a.$2.lastUsedAt; + final bLastUsed = b.$2.lastUsedAt; + if (aLastUsed == null && bLastUsed == null) return a.$1.compareTo(b.$1); + if (aLastUsed == null) return 1; + if (bLastUsed == null) return -1; + final byLastUsed = bLastUsed.compareTo(aLastUsed); + if (byLastUsed != 0) return byLastUsed; + return a.$1.compareTo(b.$1); + }); + return [for (final entry in indexed) entry.$2]; +} + +Profile _withLatestStoredLastUsed(Profile profile, StorageService? storage) { + final storedLastUsed = storage?.getProfileLastUsed(profile.id); + final currentLastUsed = profile.lastUsedAt; + final lastUsedAt = switch ((currentLastUsed, storedLastUsed)) { + (null, final stored?) => stored, + (final current?, null) => current, + (final current?, final stored?) => stored.isAfter(current) ? stored : current, + _ => null, + }; + if (lastUsedAt == currentLastUsed) return profile; + return profile.copyWith(lastUsedAt: lastUsedAt); } diff --git a/lib/screens/profile/profile_switch_screen.dart b/lib/screens/profile/profile_switch_screen.dart index cd63ed02..bce2dfd4 100644 --- a/lib/screens/profile/profile_switch_screen.dart +++ b/lib/screens/profile/profile_switch_screen.dart @@ -58,25 +58,33 @@ class _ProfileSwitchScreenState extends State with MountedS bool _focusRequested = false; bool _switching = false; Stream? _viewStream; + StorageService? _viewStreamStorage; StorageService? _storage; + Future? _storageFuture; @override void didChangeDependencies() { super.didChangeDependencies(); - _viewStream ??= watchProfilesView( + _ensureViewStream(); + if (_storage == null) { + unawaited( + (_storageFuture ??= StorageService.getInstance()).then((s) { + setStateIfMounted(() => _storage = s); + }), + ); + } + } + + void _ensureViewStream() { + if (_viewStream != null && identical(_viewStreamStorage, _storage)) return; + _viewStreamStorage = _storage; + _viewStream = watchProfilesView( profiles: context.read(), profileConnections: context.read(), connections: context.read(), plexHome: context.read(), storage: _storage, ); - if (_storage == null) { - unawaited( - StorageService.getInstance().then((s) { - setStateIfMounted(() => _storage = s); - }), - ); - } } @override @@ -92,6 +100,7 @@ class _ProfileSwitchScreenState extends State with MountedS @override Widget build(BuildContext context) { + _ensureViewStream(); return PopScope( canPop: !widget.requireSelection || _allowPop, onPopInvokedWithResult: (didPop, _) { diff --git a/test/profiles/active_profile_provider_test.dart b/test/profiles/active_profile_provider_test.dart index 328db2f5..c865aa9a 100644 --- a/test/profiles/active_profile_provider_test.dart +++ b/test/profiles/active_profile_provider_test.dart @@ -151,6 +151,20 @@ void main() { expect(provider.activeId, 'p2'); }); + test('activate moves the selected profile to the front by recent usage', () async { + await registry.upsert(Profile.local(id: 'p1', displayName: 'Owner', createdAt: DateTime(2026, 1, 1))); + await registry.upsert(Profile.local(id: 'p2', displayName: 'Kids', createdAt: DateTime(2026, 1, 2))); + await provider.initialize(); + expect(provider.profiles.map((p) => p.id).toList(), ['p1', 'p2']); + + final p2 = provider.profiles.firstWhere((p) => p.id == 'p2'); + final ok = await provider.activate(p2); + + expect(ok, isTrue); + expect(provider.profiles.map((p) => p.id).toList(), ['p2', 'p1']); + expect(storage.getProfileLastUsed('p2'), isNotNull); + }); + test('clearActiveProfile clears storage and in-memory active profile', () async { await registry.upsert(Profile.local(id: 'p1', displayName: 'Owner', createdAt: DateTime(2026, 1, 1))); await provider.initialize(); diff --git a/test/profiles/profile_merge_test.dart b/test/profiles/profile_merge_test.dart new file mode 100644 index 00000000..bd20307e --- /dev/null +++ b/test/profiles/profile_merge_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/connection/connection.dart'; +import 'package:plezy/models/plex/plex_home_user.dart'; +import 'package:plezy/profiles/profile.dart'; +import 'package:plezy/profiles/profile_merge.dart'; +import 'package:plezy/services/storage_service.dart'; + +import '../test_helpers/prefs.dart'; + +PlexHomeUser _homeUser(String uuid, String name) { + return PlexHomeUser( + id: 1, + uuid: uuid, + title: name, + thumb: '', + hasPassword: false, + restricted: false, + updatedAt: null, + admin: false, + guest: false, + protected: false, + ); +} + +PlexAccountConnection _account(String id) { + return PlexAccountConnection( + id: id, + accountToken: 'token-$id', + clientIdentifier: 'client-$id', + accountLabel: 'Plex', + createdAt: DateTime(2026, 1, 1), + ); +} + +void main() { + setUp(() { + resetSharedPreferencesForTest(); + }); + + group('mergeLocalWithPlexHome', () { + test('sorts local and Plex Home profiles by most recent usage', () async { + final storage = await StorageService.getInstance(); + final plexProfileId = plexHomeProfileId(accountConnectionId: 'plex-1', homeUserUuid: 'home-1'); + await storage.markProfileUsed('local-older', DateTime(2026, 1, 2)); + await storage.markProfileUsed(plexProfileId, DateTime(2026, 1, 3)); + + final profiles = mergeLocalWithPlexHome( + locals: [ + Profile.local(id: 'local-older', displayName: 'Older', createdAt: DateTime(2026, 1, 1)), + Profile.local(id: 'local-never', displayName: 'Never', createdAt: DateTime(2026, 1, 2)), + ], + plexHomeByConnectionId: { + 'plex-1': [_homeUser('home-1', 'Home')], + }, + connectionsById: {'plex-1': _account('plex-1')}, + storage: storage, + ); + + expect(profiles.map((p) => p.id).toList(), [plexProfileId, 'local-older', 'local-never']); + expect(profiles.first.lastUsedAt, DateTime(2026, 1, 3)); + }); + + test('keeps never-used profiles in fallback order', () { + final firstPlexId = plexHomeProfileId(accountConnectionId: 'plex-1', homeUserUuid: 'home-1'); + final secondPlexId = plexHomeProfileId(accountConnectionId: 'plex-1', homeUserUuid: 'home-2'); + + final profiles = mergeLocalWithPlexHome( + locals: [ + Profile.local(id: 'local-a', displayName: 'A', createdAt: DateTime(2026, 1, 1)), + Profile.local(id: 'local-b', displayName: 'B', createdAt: DateTime(2026, 1, 2)), + ], + plexHomeByConnectionId: { + 'plex-1': [_homeUser('home-1', 'Home 1'), _homeUser('home-2', 'Home 2')], + }, + connectionsById: {'plex-1': _account('plex-1')}, + ); + + expect(profiles.map((p) => p.id).toList(), ['local-a', 'local-b', firstPlexId, secondPlexId]); + }); + + test('uses the newest local timestamp from storage or the database row', () async { + final storage = await StorageService.getInstance(); + await storage.markProfileUsed('local-storage-newer', DateTime(2026, 1, 4)); + await storage.markProfileUsed('local-db-newer', DateTime(2026, 1, 2)); + + final profiles = mergeLocalWithPlexHome( + locals: [ + Profile.local( + id: 'local-db-newer', + displayName: 'DB newer', + createdAt: DateTime(2026, 1, 1), + lastUsedAt: DateTime(2026, 1, 3), + ), + Profile.local(id: 'local-storage-newer', displayName: 'Storage newer', createdAt: DateTime(2026, 1, 2)), + ], + plexHomeByConnectionId: const {}, + connectionsById: const {}, + storage: storage, + ); + + expect(profiles.map((p) => p.id).toList(), ['local-storage-newer', 'local-db-newer']); + expect(profiles.first.lastUsedAt, DateTime(2026, 1, 4)); + expect(profiles.last.lastUsedAt, DateTime(2026, 1, 3)); + }); + }); +} diff --git a/test/screens/profile/profile_switch_screen_test.dart b/test/screens/profile/profile_switch_screen_test.dart index 3eff23b6..a5f737f4 100644 --- a/test/screens/profile/profile_switch_screen_test.dart +++ b/test/screens/profile/profile_switch_screen_test.dart @@ -84,6 +84,53 @@ void main() { await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(); }); + + testWidgets('orders profiles by recent usage from storage', (tester) async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + final profiles = _FakeProfileRegistry(db, [ + Profile.local(id: 'local-owner', displayName: 'Owner', createdAt: DateTime(2026, 1, 1)), + Profile.local(id: 'local-kids', displayName: 'Kids', createdAt: DateTime(2026, 1, 2)), + ]); + final connections = _FakeConnectionRegistry(db); + final profileConnections = _FakeProfileConnectionRegistry(db); + final storage = await StorageService.getInstance(); + await storage.markProfileUsed('local-kids', DateTime(2026, 1, 3)); + final plexHome = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + storage: storage, + plexHomeUserFetcher: (_) async => const [], + ); + final activeProfile = ActiveProfileProvider( + registry: profiles, + plexHome: plexHome, + connections: connections, + storage: storage, + ); + addTearDown(() async { + activeProfile.dispose(); + await plexHome.dispose(); + await db.close(); + }); + + await tester.pumpWidget( + TranslationProvider( + child: MultiProvider( + providers: [ + Provider.value(value: profiles), + Provider.value(value: profileConnections), + Provider.value(value: connections), + Provider.value(value: plexHome), + ChangeNotifierProvider.value(value: activeProfile), + ], + child: const MaterialApp(home: ProfileSwitchScreen()), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('Kids')).dy, lessThan(tester.getTopLeft(find.text('Owner')).dy)); + }); } class _FakeProfileRegistry extends ProfileRegistry {