diff --git a/mobile/lib/models/search/search_result.model.dart b/mobile/lib/models/search/search_result.model.dart new file mode 100644 index 0000000000000..f51353ad613dd --- /dev/null +++ b/mobile/lib/models/search/search_result.model.dart @@ -0,0 +1,37 @@ +import 'package:collection/collection.dart'; + +import 'package:immich_mobile/entities/asset.entity.dart'; + +class SearchResult { + final List assets; + final int? nextPage; + + SearchResult({ + required this.assets, + this.nextPage, + }); + + SearchResult copyWith({ + List? assets, + int? nextPage, + }) { + return SearchResult( + assets: assets ?? this.assets, + nextPage: nextPage ?? this.nextPage, + ); + } + + @override + String toString() => 'SearchResult(assets: $assets, nextPage: $nextPage)'; + + @override + bool operator ==(covariant SearchResult other) { + if (identical(this, other)) return true; + final listEquals = const DeepCollectionEquality().equals; + + return listEquals(other.assets, assets) && other.nextPage == nextPage; + } + + @override + int get hashCode => assets.hashCode ^ nextPage.hashCode; +} diff --git a/mobile/lib/pages/search/search.page.dart b/mobile/lib/pages/search/search.page.dart index 60e61da4cc5d5..83220cff15c58 100644 --- a/mobile/lib/pages/search/search.page.dart +++ b/mobile/lib/pages/search/search.page.dart @@ -58,23 +58,22 @@ class SearchPage extends HookConsumerWidget { final mediaTypeCurrentFilterWidget = useState(null); final displayOptionCurrentFilterWidget = useState(null); - final currentPage = useState(1); - final searchProvider = ref.watch(paginatedSearchProvider); - final searchResultCount = useState(0); + final isSearching = useState(false); search() async { if (prefilter == null && filter.value == previousFilter.value) return; + isSearching.value = true; ref.watch(paginatedSearchProvider.notifier).clear(); - - currentPage.value = 1; - - final searchResult = await ref - .watch(paginatedSearchProvider.notifier) - .getNextPage(filter.value, currentPage.value); - + await ref.watch(paginatedSearchProvider.notifier).search(filter.value); previousFilter.value = filter.value; - searchResultCount.value = searchResult.length; + isSearching.value = false; + } + + loadMoreSearchResult() async { + isSearching.value = true; + await ref.watch(paginatedSearchProvider.notifier).search(filter.value); + isSearching.value = false; } searchPrefilter() { @@ -97,20 +96,16 @@ class SearchPage extends HookConsumerWidget { useEffect( () { + Future.microtask( + () => ref.invalidate(paginatedSearchProvider), + ); searchPrefilter(); + return null; }, [], ); - loadMoreSearchResult() async { - currentPage.value += 1; - final searchResult = await ref - .watch(paginatedSearchProvider.notifier) - .getNextPage(filter.value, currentPage.value); - searchResultCount.value = searchResult.length; - } - showPeoplePicker() { handleOnSelect(Set value) { filter.value = filter.value.copyWith( @@ -465,41 +460,6 @@ class SearchPage extends HookConsumerWidget { search(); } - buildSearchResult() { - return switch (searchProvider) { - AsyncData() => Expanded( - child: Padding( - padding: const EdgeInsets.only(top: 8.0), - child: NotificationListener( - onNotification: (notification) { - final metrics = notification.metrics; - final shouldLoadMore = searchResultCount.value > 75; - if (metrics.pixels >= metrics.maxScrollExtent && - shouldLoadMore) { - loadMoreSearchResult(); - } - return true; - }, - child: MultiselectGrid( - renderListProvider: paginatedSearchRenderListProvider, - archiveEnabled: true, - deleteEnabled: true, - editEnabled: true, - favoriteEnabled: true, - stackEnabled: false, - emptyIndicator: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: SearchEmptyContent(), - ), - ), - ), - ), - ), - AsyncError(:final error) => Text('Error: $error'), - _ => const Expanded(child: Center(child: CircularProgressIndicator())), - }; - } - return Scaffold( resizeToAvoidBottomInset: true, appBar: AppBar( @@ -635,13 +595,67 @@ class SearchPage extends HookConsumerWidget { ), ), ), - buildSearchResult(), + SearchResultGrid( + onScrollEnd: loadMoreSearchResult, + isSearching: isSearching.value, + ), ], ), ); } } +class SearchResultGrid extends StatelessWidget { + final VoidCallback onScrollEnd; + final bool isSearching; + + const SearchResultGrid({ + super.key, + required this.onScrollEnd, + this.isSearching = false, + }); + + @override + Widget build(BuildContext context) { + return Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: NotificationListener( + onNotification: (notification) { + final isBottomSheetNotification = notification.context + ?.findAncestorWidgetOfExactType< + DraggableScrollableSheet>() != + null; + + final metrics = notification.metrics; + final isVerticalScroll = metrics.axis == Axis.vertical; + + if (metrics.pixels >= metrics.maxScrollExtent && + isVerticalScroll && + !isBottomSheetNotification) { + onScrollEnd(); + } + + return true; + }, + child: MultiselectGrid( + renderListProvider: paginatedSearchRenderListProvider, + archiveEnabled: true, + deleteEnabled: true, + editEnabled: true, + favoriteEnabled: true, + stackEnabled: false, + emptyIndicator: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: !isSearching ? SearchEmptyContent() : SizedBox.shrink(), + ), + ), + ), + ), + ); + } +} + class SearchEmptyContent extends StatelessWidget { const SearchEmptyContent({super.key}); diff --git a/mobile/lib/providers/search/paginated_search.provider.dart b/mobile/lib/providers/search/paginated_search.provider.dart index abf711f0ad691..270f1148e8fb6 100644 --- a/mobile/lib/providers/search/paginated_search.provider.dart +++ b/mobile/lib/providers/search/paginated_search.provider.dart @@ -1,46 +1,39 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/models/search/search_result.model.dart'; import 'package:immich_mobile/providers/asset_viewer/render_list.provider.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/services/search.service.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'paginated_search.provider.g.dart'; -@riverpod -class PaginatedSearch extends _$PaginatedSearch { - Future?> _search(SearchFilter filter, int page) async { - final service = ref.read(searchServiceProvider); - final result = await service.search(filter, page); - - return result; - } +final paginatedSearchProvider = + StateNotifierProvider( + (ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)), +); - @override - Future> build() async { - return []; - } +class PaginatedSearchNotifier extends StateNotifier { + final SearchService _searchService; - Future> getNextPage(SearchFilter filter, int nextPage) async { - state = const AsyncValue.loading(); + PaginatedSearchNotifier(this._searchService) + : super(SearchResult(assets: [], nextPage: 1)); - final newState = await AsyncValue.guard(() async { - final assets = await _search(filter, nextPage); + search(SearchFilter filter) async { + if (state.nextPage == null) return; - if (assets != null) { - return [...?state.value, ...assets]; - } - }); + final result = await _searchService.search(filter, state.nextPage!); - state = newState.valueOrNull == null - ? const AsyncValue.data([]) - : AsyncValue.data(newState.value!); + if (result == null) return; - return newState.valueOrNull ?? []; + state = SearchResult( + assets: [...state.assets, ...result.assets], + nextPage: result.nextPage, + ); } clear() { - state = const AsyncValue.data([]); + state = SearchResult(assets: [], nextPage: 1); } } @@ -48,15 +41,11 @@ class PaginatedSearch extends _$PaginatedSearch { AsyncValue paginatedSearchRenderList( PaginatedSearchRenderListRef ref, ) { - final assets = ref.watch(paginatedSearchProvider).value; + final result = ref.watch(paginatedSearchProvider); - if (assets != null) { - return ref.watch( - renderListProviderWithGrouping( - (assets, GroupAssetsBy.none), - ), - ); - } else { - return const AsyncValue.loading(); - } + return ref.watch( + renderListProviderWithGrouping( + (result.assets, GroupAssetsBy.none), + ), + ); } diff --git a/mobile/lib/providers/search/paginated_search.provider.g.dart b/mobile/lib/providers/search/paginated_search.provider.g.dart index 3357be7776450..cdf8cdd741a8d 100644 --- a/mobile/lib/providers/search/paginated_search.provider.g.dart +++ b/mobile/lib/providers/search/paginated_search.provider.g.dart @@ -7,7 +7,7 @@ part of 'paginated_search.provider.dart'; // ************************************************************************** String _$paginatedSearchRenderListHash() => - r'c2cc2381ee6ea8f8e08d6d4c1289bbf0c6b9647e'; + r'4585c832106b16b6d294055f47bbbe83e0802846'; /// See also [paginatedSearchRenderList]. @ProviderFor(paginatedSearchRenderList) @@ -24,21 +24,5 @@ final paginatedSearchRenderListProvider = typedef PaginatedSearchRenderListRef = AutoDisposeProviderRef>; -String _$paginatedSearchHash() => r'8312f358261368cf2b5572b839fdd8f8fbe9a62e'; - -/// See also [PaginatedSearch]. -@ProviderFor(PaginatedSearch) -final paginatedSearchProvider = - AutoDisposeAsyncNotifierProvider>.internal( - PaginatedSearch.new, - name: r'paginatedSearchProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$paginatedSearchHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$PaginatedSearch = AutoDisposeAsyncNotifier>; // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/mobile/lib/services/search.service.dart b/mobile/lib/services/search.service.dart index 336fe450108d3..3dd0106c09b47 100644 --- a/mobile/lib/services/search.service.dart +++ b/mobile/lib/services/search.service.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/string_extensions.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/models/search/search_result.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; @@ -44,7 +46,7 @@ class SearchService { } } - Future?> search(SearchFilter filter, int page) async { + Future search(SearchFilter filter, int page) async { try { SearchResponseDto? response; AssetTypeEnum? type; @@ -103,8 +105,12 @@ class SearchService { return null; } - return _assetRepository - .getAllByRemoteId(response.assets.items.map((e) => e.id)); + return SearchResult( + assets: await _assetRepository.getAllByRemoteId( + response.assets.items.map((e) => e.id), + ), + nextPage: response.assets.nextPage?.toInt(), + ); } catch (error, stackTrace) { _log.severe("Failed to search for assets", error, stackTrace); }