diff --git a/mobile/lib/pages/search/search.page.dart b/mobile/lib/pages/search/search.page.dart index dbd32ac94b..df56655de8 100644 --- a/mobile/lib/pages/search/search.page.dart +++ b/mobile/lib/pages/search/search.page.dart @@ -90,14 +90,11 @@ class SearchPage extends HookConsumerWidget { } loadMoreSearchResult() async { - isSearching.value = true; final hasResult = await ref.watch(paginatedSearchProvider.notifier).search(filter.value); if (!hasResult) { context.showSnackBar(searchInfoSnackBar('search_no_more_result'.tr())); } - - isSearching.value = false; } searchPrefilter() { diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index 0ce3f20641..e376ae2690 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -9,7 +9,6 @@ import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/person.model.dart'; import 'package:immich_mobile/domain/models/tag.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; -import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; @@ -116,15 +115,17 @@ class DriftSearchPage extends HookConsumerWidget { search() => searchFilter(filter.value); + final isLoadingMore = useState(false); + loadMoreSearchResult() async { - isSearching.value = true; + if (isLoadingMore.value) return; + isLoadingMore.value = true; final hasResult = await ref.watch(paginatedSearchProvider.notifier).search(filter.value); + isLoadingMore.value = false; if (!hasResult) { context.showSnackBar(searchInfoSnackBar('search_no_more_result'.t(context: context))); } - - isSearching.value = false; } searchPreFilter() { @@ -745,7 +746,7 @@ class DriftSearchPage extends HookConsumerWidget { if (isSearching.value) const SliverFillRemaining(hasScrollBody: false, child: Center(child: CircularProgressIndicator())) else - _SearchResultGrid(onScrollEnd: loadMoreSearchResult), + _SearchResultGrid(onScrollEnd: loadMoreSearchResult, isLoadingMore: isLoadingMore.value), ], ), ); @@ -754,28 +755,30 @@ class DriftSearchPage extends HookConsumerWidget { class _SearchResultGrid extends ConsumerWidget { final VoidCallback onScrollEnd; + final bool isLoadingMore; - const _SearchResultGrid({required this.onScrollEnd}); + const _SearchResultGrid({required this.onScrollEnd, this.isLoadingMore = false}); @override Widget build(BuildContext context, WidgetRef ref) { - final assets = ref.watch(paginatedSearchProvider.select((s) => s.assets)); + final hasAssets = ref.watch(paginatedSearchProvider.select((s) => s.assets.isNotEmpty)); - if (assets.isEmpty) { + if (!hasAssets) { return const _SearchEmptyContent(); } - return NotificationListener( + return NotificationListener( onNotification: (notification) { final isBottomSheetNotification = notification.context?.findAncestorWidgetOfExactType() != null; final metrics = notification.metrics; final isVerticalScroll = metrics.axis == Axis.vertical; + final remaining = metrics.maxScrollExtent - metrics.pixels; - if (metrics.pixels >= metrics.maxScrollExtent && isVerticalScroll && !isBottomSheetNotification) { + if (remaining < metrics.viewportDimension && isVerticalScroll && !isBottomSheetNotification) { onScrollEnd(); - ref.read(paginatedSearchProvider.notifier).setScrollOffset(metrics.maxScrollExtent); + ref.read(paginatedSearchProvider.notifier).setScrollOffset(metrics.pixels); } return true; @@ -784,18 +787,29 @@ class _SearchResultGrid extends ConsumerWidget { child: ProviderScope( overrides: [ timelineServiceProvider.overrideWith((ref) { - final timelineService = ref.watch(timelineFactoryProvider).fromAssets(assets, TimelineOrigin.search); - ref.onDispose(timelineService.dispose); - return timelineService; + return ref.watch(paginatedSearchTimelineProvider); }), ], child: Timeline( - key: ValueKey(assets.length), groupBy: GroupAssetsBy.none, appBar: null, bottomSheet: const GeneralBottomSheet(minChildSize: 0.20), snapToMonth: false, initialScrollOffset: ref.read(paginatedSearchProvider.select((s) => s.scrollOffset)), + bottomSliverWidget: isLoadingMore + ? const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 32), + child: Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 3), + ), + ), + ), + ) + : null, ), ), ), diff --git a/mobile/lib/presentation/pages/search/paginated_search.provider.dart b/mobile/lib/presentation/pages/search/paginated_search.provider.dart index e37aa7e0af..a577f45787 100644 --- a/mobile/lib/presentation/pages/search/paginated_search.provider.dart +++ b/mobile/lib/presentation/pages/search/paginated_search.provider.dart @@ -1,6 +1,11 @@ +import 'dart:async'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/search_result.model.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/services/search.service.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/providers/infrastructure/search.provider.dart'; @@ -58,3 +63,49 @@ class PaginatedSearchNotifier extends StateNotifier { state = const SearchResult(assets: [], nextPage: 1, scrollOffset: 0.0); } } + +/// A [TimelineService] for search results that stays alive across pagination. +/// Instead of recreating the service on each page load, it uses a long-lived +/// bucket stream that emits new buckets whenever the search assets change. +final paginatedSearchTimelineProvider = Provider.autoDispose((ref) { + final controller = StreamController>.broadcast(); + + List generateBuckets(int count) { + if (count == 0) return []; + final buckets = List.filled( + (count / kTimelineNoneSegmentSize).ceil(), + const Bucket(assetCount: kTimelineNoneSegmentSize), + ); + if (count % kTimelineNoneSegmentSize != 0) { + buckets[buckets.length - 1] = Bucket(assetCount: count % kTimelineNoneSegmentSize); + } + return buckets; + } + + // Emit new buckets whenever asset count changes + ref.listen(paginatedSearchProvider.select((s) => s.assets.length), (_, count) { + controller.add(generateBuckets(count)); + }); + + // Each subscriber gets the current bucket state then follows updates + Stream> bucketSource() async* { + yield generateBuckets(ref.read(paginatedSearchProvider).assets.length); + yield* controller.stream; + } + + final service = TimelineService(( + bucketSource: bucketSource, + assetSource: (offset, count) { + final assets = ref.read(paginatedSearchProvider).assets; + return Future.value(assets.skip(offset).take(count).toList(growable: false)); + }, + origin: TimelineOrigin.search, + )); + + ref.onDispose(() { + controller.close(); + service.dispose(); + }); + + return service; +}); diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index 4d72a9b0a5..d652761c04 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -34,6 +34,7 @@ class Timeline extends StatelessWidget { super.key, this.topSliverWidget, this.topSliverWidgetHeight, + this.bottomSliverWidget, this.showStorageIndicator = false, this.withStack = false, this.appBar = const ImmichSliverAppBar(floating: true, pinned: false, snap: false), @@ -48,6 +49,7 @@ class Timeline extends StatelessWidget { final Widget? topSliverWidget; final double? topSliverWidgetHeight; + final Widget? bottomSliverWidget; final bool showStorageIndicator; final Widget? appBar; final Widget? bottomSheet; @@ -82,6 +84,7 @@ class Timeline extends StatelessWidget { child: _SliverTimeline( topSliverWidget: topSliverWidget, topSliverWidgetHeight: topSliverWidgetHeight, + bottomSliverWidget: bottomSliverWidget, appBar: appBar, bottomSheet: bottomSheet, withScrubber: withScrubber, @@ -111,6 +114,7 @@ class _SliverTimeline extends ConsumerStatefulWidget { const _SliverTimeline({ this.topSliverWidget, this.topSliverWidgetHeight, + this.bottomSliverWidget, this.appBar, this.bottomSheet, this.withScrubber = true, @@ -122,6 +126,7 @@ class _SliverTimeline extends ConsumerStatefulWidget { final Widget? topSliverWidget; final double? topSliverWidgetHeight; + final Widget? bottomSliverWidget; final Widget? appBar; final Widget? bottomSheet; final bool withScrubber; @@ -408,6 +413,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { addRepaintBoundaries: false, ), ), + if (widget.bottomSliverWidget != null) widget.bottomSliverWidget!, SliverPadding(padding: EdgeInsets.only(bottom: bottomPadding)), ], ); diff --git a/mobile/lib/widgets/asset_grid/multiselect_grid.dart b/mobile/lib/widgets/asset_grid/multiselect_grid.dart index c0d8a6bea2..430db770e0 100644 --- a/mobile/lib/widgets/asset_grid/multiselect_grid.dart +++ b/mobile/lib/widgets/asset_grid/multiselect_grid.dart @@ -26,6 +26,7 @@ import 'package:immich_mobile/services/stack.service.dart'; import 'package:immich_mobile/utils/immich_loading_overlay.dart'; import 'package:immich_mobile/utils/selection_handlers.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; +import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/widgets/asset_grid/control_bottom_app_bar.dart'; import 'package:immich_mobile/widgets/asset_grid/immich_asset_grid.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -405,10 +406,10 @@ class MultiselectGrid extends HookConsumerWidget { bottom: false, child: Stack( children: [ - ref - .watch(renderListProvider) - .when( - data: (data) => data.isEmpty && (buildLoadingIndicator != null || topWidget == null) + ref.watch(renderListProvider).widgetWhen( + onLoading: buildLoadingIndicator ?? buildDefaultLoadingIndicator, + onError: (error, _) => Center(child: Text(error.toString())), + onData: (data) => data.isEmpty && (buildLoadingIndicator != null || topWidget == null) ? (buildLoadingIndicator ?? buildEmptyIndicator)() : ImmichAssetGrid( renderList: data, @@ -419,8 +420,6 @@ class MultiselectGrid extends HookConsumerWidget { showStack: stackEnabled, showDragScrollLabel: dragScrollLabelEnabled, ), - error: (error, _) => Center(child: Text(error.toString())), - loading: buildLoadingIndicator ?? buildDefaultLoadingIndicator, ), if (selectionEnabledHook.value) ControlBottomAppBar(