@@ -223,7 +223,9 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier
|
|||||||
await storage.setActiveProfileId(profile.id);
|
await storage.setActiveProfileId(profile.id);
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
await storage.markProfileUsed(profile.id, 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();
|
safeNotifyListeners();
|
||||||
appLogger.i('ActiveProfileProvider: activated ${profile.displayName} (${profile.id})');
|
appLogger.i('ActiveProfileProvider: activated ${profile.displayName} (${profile.id})');
|
||||||
if (profile.isLocal) {
|
if (profile.isLocal) {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ List<Profile> mergeLocalWithPlexHome({
|
|||||||
required Map<String, Connection> connectionsById,
|
required Map<String, Connection> connectionsById,
|
||||||
StorageService? storage,
|
StorageService? storage,
|
||||||
}) {
|
}) {
|
||||||
final out = <Profile>[...locals];
|
final out = <Profile>[for (final local in locals) _withLatestStoredLastUsed(local, storage)];
|
||||||
for (final entry in plexHomeByConnectionId.entries) {
|
for (final entry in plexHomeByConnectionId.entries) {
|
||||||
final connectionId = entry.key;
|
final connectionId = entry.key;
|
||||||
if (!connectionsById.containsKey(connectionId)) continue;
|
if (!connectionsById.containsKey(connectionId)) continue;
|
||||||
@@ -29,5 +29,33 @@ List<Profile> mergeLocalWithPlexHome({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return sortProfilesByLastUsed(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Profile> sortProfilesByLastUsed(List<Profile> 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,25 +58,33 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> with MountedS
|
|||||||
bool _focusRequested = false;
|
bool _focusRequested = false;
|
||||||
bool _switching = false;
|
bool _switching = false;
|
||||||
Stream<ProfilesView>? _viewStream;
|
Stream<ProfilesView>? _viewStream;
|
||||||
|
StorageService? _viewStreamStorage;
|
||||||
StorageService? _storage;
|
StorageService? _storage;
|
||||||
|
Future<StorageService>? _storageFuture;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.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<ProfileRegistry>(),
|
profiles: context.read<ProfileRegistry>(),
|
||||||
profileConnections: context.read<ProfileConnectionRegistry>(),
|
profileConnections: context.read<ProfileConnectionRegistry>(),
|
||||||
connections: context.read<ConnectionRegistry>(),
|
connections: context.read<ConnectionRegistry>(),
|
||||||
plexHome: context.read<PlexHomeService>(),
|
plexHome: context.read<PlexHomeService>(),
|
||||||
storage: _storage,
|
storage: _storage,
|
||||||
);
|
);
|
||||||
if (_storage == null) {
|
|
||||||
unawaited(
|
|
||||||
StorageService.getInstance().then((s) {
|
|
||||||
setStateIfMounted(() => _storage = s);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -92,6 +100,7 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> with MountedS
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
_ensureViewStream();
|
||||||
return PopScope(
|
return PopScope(
|
||||||
canPop: !widget.requireSelection || _allowPop,
|
canPop: !widget.requireSelection || _allowPop,
|
||||||
onPopInvokedWithResult: (didPop, _) {
|
onPopInvokedWithResult: (didPop, _) {
|
||||||
|
|||||||
@@ -151,6 +151,20 @@ void main() {
|
|||||||
expect(provider.activeId, 'p2');
|
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 {
|
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 registry.upsert(Profile.local(id: 'p1', displayName: 'Owner', createdAt: DateTime(2026, 1, 1)));
|
||||||
await provider.initialize();
|
await provider.initialize();
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -84,6 +84,53 @@ void main() {
|
|||||||
await tester.pumpWidget(const SizedBox.shrink());
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
await tester.pump();
|
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<ProfileRegistry>.value(value: profiles),
|
||||||
|
Provider<ProfileConnectionRegistry>.value(value: profileConnections),
|
||||||
|
Provider<ConnectionRegistry>.value(value: connections),
|
||||||
|
Provider<PlexHomeService>.value(value: plexHome),
|
||||||
|
ChangeNotifierProvider<ActiveProfileProvider>.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 {
|
class _FakeProfileRegistry extends ProfileRegistry {
|
||||||
|
|||||||
Reference in New Issue
Block a user