mirror of
https://github.com/immich-app/immich.git
synced 2026-03-03 02:57:01 +00:00
fix(mobile): search results
This commit is contained in:
@@ -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() {
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
|
|||||||
@@ -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)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user