From bf6211776f260a6fbcfd4e5827db520edd43d860 Mon Sep 17 00:00:00 2001 From: Brandon Wees Date: Thu, 4 Sep 2025 09:08:17 -0500 Subject: [PATCH] fix: retain filter and sort options when pulling to refresh (#21452) * fix: retain filter and sort options when pulling to refresh * chore: use classes to manage state * chore: format * chore: refactor to keep local state of filter/sorted albums instead of a global filteredAlbums * fix: keep sort when page is navigated away and returned * chore: lint * chore: format why is autoformat not working * fix: default sort direction state * fix: search clears sorting we have to cache our sorted albums since sorting is very computationally expensive and cannot be run on every keystroke. For searches, instead of pulling from the list of albums, we now pull from the cached sorted list and then filter which is then shown to the user --- .../widgets/album/album_selector.widget.dart | 96 +++++++++++++++---- .../infrastructure/remote_album.provider.dart | 59 +++++------- mobile/lib/utils/album_filter.utils.dart | 25 +++++ 3 files changed, 127 insertions(+), 53 deletions(-) create mode 100644 mobile/lib/utils/album_filter.utils.dart diff --git a/mobile/lib/presentation/widgets/album/album_selector.widget.dart b/mobile/lib/presentation/widgets/album/album_selector.widget.dart index e49f2b6804..ce98728089 100644 --- a/mobile/lib/presentation/widgets/album/album_selector.widget.dart +++ b/mobile/lib/presentation/widgets/album/album_selector.widget.dart @@ -19,6 +19,7 @@ import 'package:immich_mobile/providers/infrastructure/current_album.provider.da import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/utils/album_filter.utils.dart'; import 'package:immich_mobile/widgets/common/confirm_dialog.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/search_field.dart'; @@ -39,8 +40,12 @@ class AlbumSelector extends ConsumerStatefulWidget { class _AlbumSelectorState extends ConsumerState { bool isGrid = false; final searchController = TextEditingController(); - QuickFilterMode filterMode = QuickFilterMode.all; final searchFocusNode = FocusNode(); + List sortedAlbums = []; + List shownAlbums = []; + + AlbumFilter filter = AlbumFilter(query: "", mode: QuickFilterMode.all); + AlbumSort sort = AlbumSort(mode: RemoteAlbumSortMode.lastModified, isReverse: true); @override void initState() { @@ -52,7 +57,7 @@ class _AlbumSelectorState extends ConsumerState { }); searchController.addListener(() { - onSearch(searchController.text, filterMode); + onSearch(searchController.text, filter.mode); }); searchFocusNode.addListener(() { @@ -62,9 +67,11 @@ class _AlbumSelectorState extends ConsumerState { }); } - void onSearch(String searchTerm, QuickFilterMode sortMode) { + void onSearch(String searchTerm, QuickFilterMode filterMode) { final userId = ref.watch(currentUserProvider)?.id; - ref.read(remoteAlbumProvider.notifier).searchAlbums(searchTerm, userId, sortMode); + filter = filter.copyWith(query: searchTerm, userId: userId, mode: filterMode); + + filterAlbums(); } Future onRefresh() async { @@ -77,17 +84,60 @@ class _AlbumSelectorState extends ConsumerState { }); } - void changeFilter(QuickFilterMode sortMode) { + void changeFilter(QuickFilterMode mode) { setState(() { - filterMode = sortMode; + filter = filter.copyWith(mode: mode); }); + + filterAlbums(); + } + + Future changeSort(AlbumSort sort) async { + setState(() { + this.sort = sort; + }); + + await sortAlbums(); } void clearSearch() { setState(() { - filterMode = QuickFilterMode.all; + filter = filter.copyWith(mode: QuickFilterMode.all, query: null); searchController.clear(); - ref.read(remoteAlbumProvider.notifier).clearSearch(); + }); + + filterAlbums(); + } + + Future sortAlbums() async { + final sorted = await ref + .read(remoteAlbumProvider.notifier) + .sortAlbums(ref.read(remoteAlbumProvider).albums, sort.mode, isReverse: sort.isReverse); + + setState(() { + sortedAlbums = sorted; + }); + + // we need to re-filter the albums after sorting + // so shownAlbums gets updated + filterAlbums(); + } + + Future filterAlbums() async { + if (filter.query == null) { + setState(() { + shownAlbums = sortedAlbums; + }); + + return; + } + + final filteredAlbums = ref + .read(remoteAlbumProvider.notifier) + .searchAlbums(sortedAlbums, filter.query!, filter.userId, filter.mode); + + setState(() { + shownAlbums = filteredAlbums; }); } @@ -100,36 +150,41 @@ class _AlbumSelectorState extends ConsumerState { @override Widget build(BuildContext context) { - final albums = ref.watch(remoteAlbumProvider.select((s) => s.filteredAlbums)); - final userId = ref.watch(currentUserProvider)?.id; + // refilter and sort when albums change + ref.listen(remoteAlbumProvider.select((state) => state.albums), (_, _) async { + await sortAlbums(); + }); + return MultiSliver( children: [ _SearchBar( searchController: searchController, searchFocusNode: searchFocusNode, onSearch: onSearch, - filterMode: filterMode, + filterMode: filter.mode, onClearSearch: clearSearch, ), _QuickFilterButtonRow( - filterMode: filterMode, + filterMode: filter.mode, onChangeFilter: changeFilter, onSearch: onSearch, searchController: searchController, ), - _QuickSortAndViewMode(isGrid: isGrid, onToggleViewMode: toggleViewMode), + _QuickSortAndViewMode(isGrid: isGrid, onToggleViewMode: toggleViewMode, onSortChanged: changeSort), isGrid - ? _AlbumGrid(albums: albums, userId: userId, onAlbumSelected: widget.onAlbumSelected) - : _AlbumList(albums: albums, userId: userId, onAlbumSelected: widget.onAlbumSelected), + ? _AlbumGrid(albums: shownAlbums, userId: userId, onAlbumSelected: widget.onAlbumSelected) + : _AlbumList(albums: shownAlbums, userId: userId, onAlbumSelected: widget.onAlbumSelected), ], ); } } class _SortButton extends ConsumerStatefulWidget { - const _SortButton(); + const _SortButton(this.onSortChanged); + + final Future Function(AlbumSort) onSortChanged; @override ConsumerState<_SortButton> createState() => _SortButtonState(); @@ -148,15 +203,15 @@ class _SortButtonState extends ConsumerState<_SortButton> { albumSortIsReverse = !albumSortIsReverse; isSorting = true; }); - await ref.read(remoteAlbumProvider.notifier).sortFilteredAlbums(sortMode, isReverse: albumSortIsReverse); } else { setState(() { albumSortOption = sortMode; isSorting = true; }); - await ref.read(remoteAlbumProvider.notifier).sortFilteredAlbums(sortMode, isReverse: albumSortIsReverse); } + await widget.onSortChanged.call(AlbumSort(mode: albumSortOption, isReverse: albumSortIsReverse)); + setState(() { isSorting = false; }); @@ -394,10 +449,11 @@ class _QuickFilterButton extends StatelessWidget { } class _QuickSortAndViewMode extends StatelessWidget { - const _QuickSortAndViewMode({required this.isGrid, required this.onToggleViewMode}); + const _QuickSortAndViewMode({required this.isGrid, required this.onToggleViewMode, required this.onSortChanged}); final bool isGrid; final VoidCallback onToggleViewMode; + final Future Function(AlbumSort) onSortChanged; @override Widget build(BuildContext context) { @@ -407,7 +463,7 @@ class _QuickSortAndViewMode extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const _SortButton(), + _SortButton(onSortChanged), IconButton( icon: Icon(isGrid ? Icons.view_list_outlined : Icons.grid_view_outlined, size: 24), onPressed: onToggleViewMode, diff --git a/mobile/lib/providers/infrastructure/remote_album.provider.dart b/mobile/lib/providers/infrastructure/remote_album.provider.dart index a48a1c30e4..38ba52dc56 100644 --- a/mobile/lib/providers/infrastructure/remote_album.provider.dart +++ b/mobile/lib/providers/infrastructure/remote_album.provider.dart @@ -12,43 +12,42 @@ import 'album.provider.dart'; class RemoteAlbumState { final List albums; - final List filteredAlbums; - const RemoteAlbumState({required this.albums, List? filteredAlbums}) - : filteredAlbums = filteredAlbums ?? albums; + const RemoteAlbumState({required this.albums}); - RemoteAlbumState copyWith({List? albums, List? filteredAlbums}) { - return RemoteAlbumState(albums: albums ?? this.albums, filteredAlbums: filteredAlbums ?? this.filteredAlbums); + RemoteAlbumState copyWith({List? albums}) { + return RemoteAlbumState(albums: albums ?? this.albums); } @override - String toString() => 'RemoteAlbumState(albums: ${albums.length}, filteredAlbums: ${filteredAlbums.length})'; + String toString() => 'RemoteAlbumState(albums: ${albums.length})'; @override bool operator ==(covariant RemoteAlbumState other) { if (identical(this, other)) return true; final listEquals = const DeepCollectionEquality().equals; - return listEquals(other.albums, albums) && listEquals(other.filteredAlbums, filteredAlbums); + return listEquals(other.albums, albums); } @override - int get hashCode => albums.hashCode ^ filteredAlbums.hashCode; + int get hashCode => albums.hashCode; } class RemoteAlbumNotifier extends Notifier { late RemoteAlbumService _remoteAlbumService; final _logger = Logger('RemoteAlbumNotifier'); + @override RemoteAlbumState build() { _remoteAlbumService = ref.read(remoteAlbumServiceProvider); - return const RemoteAlbumState(albums: [], filteredAlbums: []); + return const RemoteAlbumState(albums: []); } Future> _getAll() async { try { final albums = await _remoteAlbumService.getAll(); - state = state.copyWith(albums: albums, filteredAlbums: albums); + state = state.copyWith(albums: albums); return albums; } catch (error, stack) { _logger.severe('Failed to fetch albums', error, stack); @@ -60,19 +59,21 @@ class RemoteAlbumNotifier extends Notifier { await _getAll(); } - void searchAlbums(String query, String? userId, [QuickFilterMode filterMode = QuickFilterMode.all]) { - final filtered = _remoteAlbumService.searchAlbums(state.albums, query, userId, filterMode); - - state = state.copyWith(filteredAlbums: filtered); + List searchAlbums( + List albums, + String query, + String? userId, [ + QuickFilterMode filterMode = QuickFilterMode.all, + ]) { + return _remoteAlbumService.searchAlbums(albums, query, userId, filterMode); } - void clearSearch() { - state = state.copyWith(filteredAlbums: state.albums); - } - - Future sortFilteredAlbums(RemoteAlbumSortMode sortMode, {bool isReverse = false}) async { - final sortedAlbums = await _remoteAlbumService.sortAlbums(state.filteredAlbums, sortMode, isReverse: isReverse); - state = state.copyWith(filteredAlbums: sortedAlbums); + Future> sortAlbums( + List albums, + RemoteAlbumSortMode sortMode, { + bool isReverse = false, + }) async { + return await _remoteAlbumService.sortAlbums(albums, sortMode, isReverse: isReverse); } Future createAlbum({ @@ -83,7 +84,7 @@ class RemoteAlbumNotifier extends Notifier { try { final album = await _remoteAlbumService.createAlbum(title: title, description: description, assetIds: assetIds); - state = state.copyWith(albums: [...state.albums, album], filteredAlbums: [...state.filteredAlbums, album]); + state = state.copyWith(albums: [...state.albums, album]); return album; } catch (error, stack) { @@ -114,11 +115,7 @@ class RemoteAlbumNotifier extends Notifier { return album.id == albumId ? updatedAlbum : album; }).toList(); - final updatedFilteredAlbums = state.filteredAlbums.map((album) { - return album.id == albumId ? updatedAlbum : album; - }).toList(); - - state = state.copyWith(albums: updatedAlbums, filteredAlbums: updatedFilteredAlbums); + state = state.copyWith(albums: updatedAlbums); return updatedAlbum; } catch (error, stack) { @@ -139,9 +136,7 @@ class RemoteAlbumNotifier extends Notifier { await _remoteAlbumService.deleteAlbum(albumId); final updatedAlbums = state.albums.where((album) => album.id != albumId).toList(); - final updatedFilteredAlbums = state.filteredAlbums.where((album) => album.id != albumId).toList(); - - state = state.copyWith(albums: updatedAlbums, filteredAlbums: updatedFilteredAlbums); + state = state.copyWith(albums: updatedAlbums); } Future> getAssets(String albumId) { @@ -164,9 +159,7 @@ class RemoteAlbumNotifier extends Notifier { await _remoteAlbumService.removeUser(albumId, userId: userId); final updatedAlbums = state.albums.where((album) => album.id != albumId).toList(); - final updatedFilteredAlbums = state.filteredAlbums.where((album) => album.id != albumId).toList(); - - state = state.copyWith(albums: updatedAlbums, filteredAlbums: updatedFilteredAlbums); + state = state.copyWith(albums: updatedAlbums); } Future setActivityStatus(String albumId, bool enabled) { diff --git a/mobile/lib/utils/album_filter.utils.dart b/mobile/lib/utils/album_filter.utils.dart new file mode 100644 index 0000000000..02142b1571 --- /dev/null +++ b/mobile/lib/utils/album_filter.utils.dart @@ -0,0 +1,25 @@ +import 'package:immich_mobile/domain/services/remote_album.service.dart'; +import 'package:immich_mobile/models/albums/album_search.model.dart'; + +class AlbumFilter { + String? userId; + String? query; + QuickFilterMode mode; + + AlbumFilter({required this.mode, this.userId, this.query}); + + AlbumFilter copyWith({String? userId, String? query, QuickFilterMode? mode}) { + return AlbumFilter(userId: userId ?? this.userId, query: query ?? this.query, mode: mode ?? this.mode); + } +} + +class AlbumSort { + RemoteAlbumSortMode mode; + bool isReverse; + + AlbumSort({required this.mode, this.isReverse = false}); + + AlbumSort copyWith({RemoteAlbumSortMode? mode, bool? isReverse}) { + return AlbumSort(mode: mode ?? this.mode, isReverse: isReverse ?? this.isReverse); + } +}