fix(mobile): search results

This commit is contained in:
Thomas Way
2026-02-24 21:32:10 +00:00
parent dd97395f3a
commit 532567ab2a
5 changed files with 91 additions and 24 deletions

View File

@@ -90,14 +90,11 @@ class SearchPage extends HookConsumerWidget {
} }
loadMoreSearchResult() async { loadMoreSearchResult() async {
isSearching.value = true;
final hasResult = await ref.watch(paginatedSearchProvider.notifier).search(filter.value); final hasResult = await ref.watch(paginatedSearchProvider.notifier).search(filter.value);
if (!hasResult) { if (!hasResult) {
context.showSnackBar(searchInfoSnackBar('search_no_more_result'.tr())); context.showSnackBar(searchInfoSnackBar('search_no_more_result'.tr()));
} }
isSearching.value = false;
} }
searchPrefilter() { searchPrefilter() {

View File

@@ -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/person.model.dart';
import 'package:immich_mobile/domain/models/tag.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/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/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
@@ -116,15 +115,17 @@ class DriftSearchPage extends HookConsumerWidget {
search() => searchFilter(filter.value); search() => searchFilter(filter.value);
final isLoadingMore = useState(false);
loadMoreSearchResult() async { loadMoreSearchResult() async {
isSearching.value = true; if (isLoadingMore.value) return;
isLoadingMore.value = true;
final hasResult = await ref.watch(paginatedSearchProvider.notifier).search(filter.value); final hasResult = await ref.watch(paginatedSearchProvider.notifier).search(filter.value);
isLoadingMore.value = false;
if (!hasResult) { if (!hasResult) {
context.showSnackBar(searchInfoSnackBar('search_no_more_result'.t(context: context))); context.showSnackBar(searchInfoSnackBar('search_no_more_result'.t(context: context)));
} }
isSearching.value = false;
} }
searchPreFilter() { searchPreFilter() {
@@ -745,7 +746,7 @@ class DriftSearchPage extends HookConsumerWidget {
if (isSearching.value) if (isSearching.value)
const SliverFillRemaining(hasScrollBody: false, child: Center(child: CircularProgressIndicator())) const SliverFillRemaining(hasScrollBody: false, child: Center(child: CircularProgressIndicator()))
else else
_SearchResultGrid(onScrollEnd: loadMoreSearchResult), _SearchResultGrid(onScrollEnd: loadMoreSearchResult, isLoadingMore: isLoadingMore.value),
], ],
), ),
); );
@@ -754,28 +755,30 @@ class DriftSearchPage extends HookConsumerWidget {
class _SearchResultGrid extends ConsumerWidget { class _SearchResultGrid extends ConsumerWidget {
final VoidCallback onScrollEnd; final VoidCallback onScrollEnd;
final bool isLoadingMore;
const _SearchResultGrid({required this.onScrollEnd}); const _SearchResultGrid({required this.onScrollEnd, this.isLoadingMore = false});
@override @override
Widget build(BuildContext context, WidgetRef ref) { 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 const _SearchEmptyContent();
} }
return NotificationListener<ScrollEndNotification>( return NotificationListener<ScrollUpdateNotification>(
onNotification: (notification) { onNotification: (notification) {
final isBottomSheetNotification = final isBottomSheetNotification =
notification.context?.findAncestorWidgetOfExactType<DraggableScrollableSheet>() != null; notification.context?.findAncestorWidgetOfExactType<DraggableScrollableSheet>() != null;
final metrics = notification.metrics; final metrics = notification.metrics;
final isVerticalScroll = metrics.axis == Axis.vertical; 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(); onScrollEnd();
ref.read(paginatedSearchProvider.notifier).setScrollOffset(metrics.maxScrollExtent); ref.read(paginatedSearchProvider.notifier).setScrollOffset(metrics.pixels);
} }
return true; return true;
@@ -784,18 +787,29 @@ class _SearchResultGrid extends ConsumerWidget {
child: ProviderScope( child: ProviderScope(
overrides: [ overrides: [
timelineServiceProvider.overrideWith((ref) { timelineServiceProvider.overrideWith((ref) {
final timelineService = ref.watch(timelineFactoryProvider).fromAssets(assets, TimelineOrigin.search); return ref.watch(paginatedSearchTimelineProvider);
ref.onDispose(timelineService.dispose);
return timelineService;
}), }),
], ],
child: Timeline( child: Timeline(
key: ValueKey(assets.length),
groupBy: GroupAssetsBy.none, groupBy: GroupAssetsBy.none,
appBar: null, appBar: null,
bottomSheet: const GeneralBottomSheet(minChildSize: 0.20), bottomSheet: const GeneralBottomSheet(minChildSize: 0.20),
snapToMonth: false, snapToMonth: false,
initialScrollOffset: ref.read(paginatedSearchProvider.select((s) => s.scrollOffset)), 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,
), ),
), ),
), ),

View File

@@ -1,6 +1,11 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart'; 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/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/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/models/search/search_filter.model.dart';
import 'package:immich_mobile/providers/infrastructure/search.provider.dart'; import 'package:immich_mobile/providers/infrastructure/search.provider.dart';
@@ -58,3 +63,49 @@ class PaginatedSearchNotifier extends StateNotifier<SearchResult> {
state = const SearchResult(assets: [], nextPage: 1, scrollOffset: 0.0); 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<TimelineService>((ref) {
final controller = StreamController<List<Bucket>>.broadcast();
List<Bucket> 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<List<Bucket>> 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;
});

View File

@@ -34,6 +34,7 @@ class Timeline extends StatelessWidget {
super.key, super.key,
this.topSliverWidget, this.topSliverWidget,
this.topSliverWidgetHeight, this.topSliverWidgetHeight,
this.bottomSliverWidget,
this.showStorageIndicator = false, this.showStorageIndicator = false,
this.withStack = false, this.withStack = false,
this.appBar = const ImmichSliverAppBar(floating: true, pinned: false, snap: false), this.appBar = const ImmichSliverAppBar(floating: true, pinned: false, snap: false),
@@ -48,6 +49,7 @@ class Timeline extends StatelessWidget {
final Widget? topSliverWidget; final Widget? topSliverWidget;
final double? topSliverWidgetHeight; final double? topSliverWidgetHeight;
final Widget? bottomSliverWidget;
final bool showStorageIndicator; final bool showStorageIndicator;
final Widget? appBar; final Widget? appBar;
final Widget? bottomSheet; final Widget? bottomSheet;
@@ -82,6 +84,7 @@ class Timeline extends StatelessWidget {
child: _SliverTimeline( child: _SliverTimeline(
topSliverWidget: topSliverWidget, topSliverWidget: topSliverWidget,
topSliverWidgetHeight: topSliverWidgetHeight, topSliverWidgetHeight: topSliverWidgetHeight,
bottomSliverWidget: bottomSliverWidget,
appBar: appBar, appBar: appBar,
bottomSheet: bottomSheet, bottomSheet: bottomSheet,
withScrubber: withScrubber, withScrubber: withScrubber,
@@ -111,6 +114,7 @@ class _SliverTimeline extends ConsumerStatefulWidget {
const _SliverTimeline({ const _SliverTimeline({
this.topSliverWidget, this.topSliverWidget,
this.topSliverWidgetHeight, this.topSliverWidgetHeight,
this.bottomSliverWidget,
this.appBar, this.appBar,
this.bottomSheet, this.bottomSheet,
this.withScrubber = true, this.withScrubber = true,
@@ -122,6 +126,7 @@ class _SliverTimeline extends ConsumerStatefulWidget {
final Widget? topSliverWidget; final Widget? topSliverWidget;
final double? topSliverWidgetHeight; final double? topSliverWidgetHeight;
final Widget? bottomSliverWidget;
final Widget? appBar; final Widget? appBar;
final Widget? bottomSheet; final Widget? bottomSheet;
final bool withScrubber; final bool withScrubber;
@@ -408,6 +413,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
addRepaintBoundaries: false, addRepaintBoundaries: false,
), ),
), ),
if (widget.bottomSliverWidget != null) widget.bottomSliverWidget!,
SliverPadding(padding: EdgeInsets.only(bottom: bottomPadding)), SliverPadding(padding: EdgeInsets.only(bottom: bottomPadding)),
], ],
); );

View File

@@ -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/immich_loading_overlay.dart';
import 'package:immich_mobile/utils/selection_handlers.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/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/control_bottom_app_bar.dart';
import 'package:immich_mobile/widgets/asset_grid/immich_asset_grid.dart'; import 'package:immich_mobile/widgets/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -405,10 +406,10 @@ class MultiselectGrid extends HookConsumerWidget {
bottom: false, bottom: false,
child: Stack( child: Stack(
children: [ children: [
ref ref.watch(renderListProvider).widgetWhen(
.watch(renderListProvider) onLoading: buildLoadingIndicator ?? buildDefaultLoadingIndicator,
.when( onError: (error, _) => Center(child: Text(error.toString())),
data: (data) => data.isEmpty && (buildLoadingIndicator != null || topWidget == null) onData: (data) => data.isEmpty && (buildLoadingIndicator != null || topWidget == null)
? (buildLoadingIndicator ?? buildEmptyIndicator)() ? (buildLoadingIndicator ?? buildEmptyIndicator)()
: ImmichAssetGrid( : ImmichAssetGrid(
renderList: data, renderList: data,
@@ -419,8 +420,6 @@ class MultiselectGrid extends HookConsumerWidget {
showStack: stackEnabled, showStack: stackEnabled,
showDragScrollLabel: dragScrollLabelEnabled, showDragScrollLabel: dragScrollLabelEnabled,
), ),
error: (error, _) => Center(child: Text(error.toString())),
loading: buildLoadingIndicator ?? buildDefaultLoadingIndicator,
), ),
if (selectionEnabledHook.value) if (selectionEnabledHook.value)
ControlBottomAppBar( ControlBottomAppBar(