diff --git a/mobile/lib/domain/models/search_result.model.dart b/mobile/lib/domain/models/search_result.model.dart index 947bc6192fba9..21134b73d8626 100644 --- a/mobile/lib/domain/models/search_result.model.dart +++ b/mobile/lib/domain/models/search_result.model.dart @@ -3,30 +3,21 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; class SearchResult { final List assets; - final double scrollOffset; final int? nextPage; - const SearchResult({required this.assets, this.scrollOffset = 0.0, this.nextPage}); - - SearchResult copyWith({List? assets, int? nextPage, double? scrollOffset}) { - return SearchResult( - assets: assets ?? this.assets, - nextPage: nextPage ?? this.nextPage, - scrollOffset: scrollOffset ?? this.scrollOffset, - ); - } + const SearchResult({required this.assets, this.nextPage}); @override - String toString() => 'SearchResult(assets: ${assets.length}, nextPage: $nextPage, scrollOffset: $scrollOffset)'; + String toString() => 'SearchResult(assets: ${assets.length}, 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 && other.scrollOffset == scrollOffset; + return listEquals(other.assets, assets) && other.nextPage == nextPage; } @override - int get hashCode => assets.hashCode ^ nextPage.hashCode ^ scrollOffset.hashCode; + int get hashCode => assets.hashCode ^ nextPage.hashCode; } diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index 39aeb867a39bb..b33940eacd39a 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -78,6 +78,9 @@ class TimelineFactory { TimelineService fromAssets(List assets, TimelineOrigin type) => TimelineService(_timelineRepository.fromAssets(assets, type)); + TimelineService fromAssetStream(List Function() getAssets, Stream assetCount, TimelineOrigin type) => + TimelineService(_timelineRepository.fromAssetStream(getAssets, assetCount, type)); + TimelineService fromAssetsWithBuckets(List assets, TimelineOrigin type) => TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type)); @@ -112,7 +115,7 @@ class TimelineService { if (totalAssets == 0) { _bufferOffset = 0; - _buffer.clear(); + _buffer = []; } else { final int offset; final int count; diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index 4b4a13a4f917b..74af6dc3f0199 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -276,6 +276,19 @@ class DriftTimelineRepository extends DriftDatabaseRepository { origin: origin, ); + TimelineQuery fromAssetStream(List Function() getAssets, Stream assetCount, TimelineOrigin origin) => + ( + bucketSource: () async* { + yield _generateBuckets(getAssets().length); + yield* assetCount.map(_generateBuckets); + }, + assetSource: (offset, count) { + final assets = getAssets(); + return Future.value(assets.skip(offset).take(count).toList(growable: false)); + }, + origin: origin, + ); + TimelineQuery fromAssetsWithBuckets(List assets, TimelineOrigin origin) { // Sort assets by date descending and group by day final sorted = List.from(assets)..sort((a, b) => b.createdAt.compareTo(a.createdAt)); diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index 0ce3f20641ccd..701a6ff74a1c5 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -80,51 +80,28 @@ class DriftSearchPage extends HookConsumerWidget { final ratingCurrentFilterWidget = useState(null); final displayOptionCurrentFilterWidget = useState(null); - final isSearching = useState(false); - final userPreferences = ref.watch(userMetadataPreferencesProvider); - SnackBar searchInfoSnackBar(String message) { - return SnackBar( - content: Text(message, style: context.textTheme.labelLarge), - showCloseIcon: true, - behavior: SnackBarBehavior.fixed, - closeIconColor: context.colorScheme.onSurface, - ); - } - - searchFilter(SearchFilter filter) async { - if (filter.isEmpty) { - return; - } - + searchFilter(SearchFilter filter) { if (preFilter == null && filter == previousFilter.value) { return; } - isSearching.value = true; - ref.watch(paginatedSearchProvider.notifier).clear(); - final hasResult = await ref.watch(paginatedSearchProvider.notifier).search(filter); + ref.read(paginatedSearchProvider.notifier).clear(); - if (!hasResult) { - context.showSnackBar(searchInfoSnackBar('search_no_result'.t(context: context))); + if (filter.isEmpty) { + previousFilter.value = null; + return; } + unawaited(ref.read(paginatedSearchProvider.notifier).search(filter)); previousFilter.value = filter; - isSearching.value = false; } search() => searchFilter(filter.value); - loadMoreSearchResult() async { - isSearching.value = true; - final hasResult = await ref.watch(paginatedSearchProvider.notifier).search(filter.value); - - if (!hasResult) { - context.showSnackBar(searchInfoSnackBar('search_no_more_result'.t(context: context))); - } - - isSearching.value = false; + loadMoreSearchResults() { + unawaited(ref.read(paginatedSearchProvider.notifier).search(filter.value)); } searchPreFilter() { @@ -742,10 +719,10 @@ class DriftSearchPage extends HookConsumerWidget { ), ), ), - if (isSearching.value) - const SliverFillRemaining(hasScrollBody: false, child: Center(child: CircularProgressIndicator())) + if (filter.value.isEmpty) + const _SearchSuggestions() else - _SearchResultGrid(onScrollEnd: loadMoreSearchResult), + _SearchResultGrid(onScrollEnd: loadMoreSearchResults), ], ), ); @@ -757,45 +734,85 @@ class _SearchResultGrid extends ConsumerWidget { const _SearchResultGrid({required this.onScrollEnd}); - @override - Widget build(BuildContext context, WidgetRef ref) { - final assets = ref.watch(paginatedSearchProvider.select((s) => s.assets)); + bool _onScrollUpdateNotification(ScrollNotification notification) { + final metrics = notification.metrics; - if (assets.isEmpty) { - return const _SearchEmptyContent(); + if (metrics.axis != Axis.vertical) return false; + + final isBottomSheet = notification.context?.findAncestorWidgetOfExactType() != null; + final remaining = metrics.maxScrollExtent - metrics.pixels; + + if (remaining < metrics.viewportDimension && !isBottomSheet) { + onScrollEnd(); } - return NotificationListener( - onNotification: (notification) { - final isBottomSheetNotification = - notification.context?.findAncestorWidgetOfExactType() != null; + return false; + } + + Widget? _bottomWidget(BuildContext context, WidgetRef ref) { + final isLoading = ref.watch(paginatedSearchProvider.select((s) => s.isLoading)); + + if (isLoading) { + return const SliverFillRemaining( + hasScrollBody: false, + child: Padding( + padding: EdgeInsets.all(32), + child: Center(child: CircularProgressIndicator()), + ), + ); + } - final metrics = notification.metrics; - final isVerticalScroll = metrics.axis == Axis.vertical; + final hasMore = ref.watch(paginatedSearchProvider.select((s) => s.nextPage != null)); - if (metrics.pixels >= metrics.maxScrollExtent && isVerticalScroll && !isBottomSheetNotification) { - onScrollEnd(); - ref.read(paginatedSearchProvider.notifier).setScrollOffset(metrics.maxScrollExtent); - } + if (hasMore) return null; - return true; - }, + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 32), + child: Center( + child: Text( + 'search_no_more_result'.t(context: context), + style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceVariant), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final hasAssets = ref.watch(paginatedSearchProvider.select((s) => s.assets.isNotEmpty)); + final isLoading = ref.watch(paginatedSearchProvider.select((s) => s.isLoading)); + + if (!hasAssets && !isLoading) { + return const _SearchNoResults(); + } + + return NotificationListener( + onNotification: _onScrollUpdateNotification, child: SliverFillRemaining( child: ProviderScope( overrides: [ timelineServiceProvider.overrideWith((ref) { - final timelineService = ref.watch(timelineFactoryProvider).fromAssets(assets, TimelineOrigin.search); - ref.onDispose(timelineService.dispose); - return timelineService; + final notifier = ref.read(paginatedSearchProvider.notifier); + final service = ref + .watch(timelineFactoryProvider) + .fromAssetStream( + () => ref.read(paginatedSearchProvider).assets, + notifier.assetCount, + TimelineOrigin.search, + ); + ref.onDispose(service.dispose); + return service; }), ], 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)), + loadingWidget: const SizedBox.shrink(), + bottomSliverWidget: _bottomWidget(context, ref), ), ), ), @@ -803,8 +820,35 @@ class _SearchResultGrid extends ConsumerWidget { } } -class _SearchEmptyContent extends StatelessWidget { - const _SearchEmptyContent(); +class _SearchNoResults extends StatelessWidget { + const _SearchNoResults(); + + @override + Widget build(BuildContext context) { + return SliverFillRemaining( + hasScrollBody: false, + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.all(48), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.search_off_rounded, size: 72, color: context.colorScheme.onSurfaceVariant), + const SizedBox(height: 24), + Text( + 'search_no_result'.t(context: context), + textAlign: TextAlign.center, + style: context.textTheme.bodyLarge?.copyWith(color: context.colorScheme.onSurfaceVariant), + ), + ], + ), + ), + ); + } +} + +class _SearchSuggestions extends StatelessWidget { + const _SearchSuggestions(); @override Widget build(BuildContext context) { diff --git a/mobile/lib/presentation/pages/search/paginated_search.provider.dart b/mobile/lib/presentation/pages/search/paginated_search.provider.dart index e37aa7e0afa2c..f65ca6b909689 100644 --- a/mobile/lib/presentation/pages/search/paginated_search.provider.dart +++ b/mobile/lib/presentation/pages/search/paginated_search.provider.dart @@ -1,5 +1,7 @@ +import 'dart:async'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/search_result.model.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/services/search.service.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/providers/infrastructure/search.provider.dart'; @@ -21,40 +23,52 @@ class SearchFilterProvider extends Notifier { } } -final paginatedSearchProvider = StateNotifierProvider( +class SearchState { + final List assets; + final int? nextPage; + final bool isLoading; + + const SearchState({this.assets = const [], this.nextPage = 1, this.isLoading = false}); +} + +final paginatedSearchProvider = StateNotifierProvider( (ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)), ); -class PaginatedSearchNotifier extends StateNotifier { +class PaginatedSearchNotifier extends StateNotifier { final SearchService _searchService; + final _assetCountController = StreamController.broadcast(); - PaginatedSearchNotifier(this._searchService) : super(const SearchResult(assets: [], nextPage: 1)); + PaginatedSearchNotifier(this._searchService) : super(const SearchState()); - Future search(SearchFilter filter) async { - if (state.nextPage == null) { - return false; - } + Stream get assetCount => _assetCountController.stream; + + Future search(SearchFilter filter) async { + if (state.nextPage == null || state.isLoading) return; + + state = SearchState(assets: state.assets, nextPage: state.nextPage, isLoading: true); final result = await _searchService.search(filter, state.nextPage!); if (result == null) { - return false; + state = SearchState(assets: state.assets, nextPage: state.nextPage); + return; } - state = SearchResult( - assets: [...state.assets, ...result.assets], - nextPage: result.nextPage, - scrollOffset: state.scrollOffset, - ); + final assets = [...state.assets, ...result.assets]; + state = SearchState(assets: assets, nextPage: result.nextPage); - return true; + _assetCountController.add(assets.length); } - void setScrollOffset(double offset) { - state = state.copyWith(scrollOffset: offset); + void clear() { + state = const SearchState(); + _assetCountController.add(0); } - clear() { - state = const SearchResult(assets: [], nextPage: 1, scrollOffset: 0.0); + @override + void dispose() { + _assetCountController.close(); + super.dispose(); } } diff --git a/mobile/lib/presentation/widgets/timeline/constants.dart b/mobile/lib/presentation/widgets/timeline/constants.dart index 3b4269925cf88..84892c79f75c8 100644 --- a/mobile/lib/presentation/widgets/timeline/constants.dart +++ b/mobile/lib/presentation/widgets/timeline/constants.dart @@ -5,6 +5,7 @@ const Size kTimelineFixedTileExtent = Size.square(256); const double kTimelineSpacing = 2.0; const int kTimelineColumnCount = 3; +const double kScrubberThumbHeight = 48.0; const Duration kTimelineScrubberFadeInDuration = Duration(milliseconds: 300); const Duration kTimelineScrubberFadeOutDuration = Duration(milliseconds: 800); diff --git a/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart b/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart index d31048fbb512e..f0dfef571cff9 100644 --- a/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart @@ -530,12 +530,14 @@ class _CircularThumb extends StatelessWidget { elevation: 4.0, color: backgroundColor, borderRadius: const BorderRadius.only( - topLeft: Radius.circular(48.0), - bottomLeft: Radius.circular(48.0), + topLeft: Radius.circular(kScrubberThumbHeight), + bottomLeft: Radius.circular(kScrubberThumbHeight), topRight: Radius.circular(4.0), bottomRight: Radius.circular(4.0), ), - child: Container(constraints: BoxConstraints.tight(const Size(48.0 * 0.6, 48.0))), + child: Container( + constraints: BoxConstraints.tight(const Size(kScrubberThumbHeight * 0.6, kScrubberThumbHeight)), + ), ), ); } diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index 4d72a9b0a5b9f..8d494a8452841 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -17,6 +17,7 @@ import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'; @@ -34,6 +35,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), @@ -41,13 +43,14 @@ class Timeline extends StatelessWidget { this.groupBy, this.withScrubber = true, this.snapToMonth = true, - this.initialScrollOffset, this.readOnly = false, this.persistentBottomBar = false, + this.loadingWidget, }); final Widget? topSliverWidget; final double? topSliverWidgetHeight; + final Widget? bottomSliverWidget; final bool showStorageIndicator; final Widget? appBar; final Widget? bottomSheet; @@ -55,9 +58,9 @@ class Timeline extends StatelessWidget { final GroupAssetsBy? groupBy; final bool withScrubber; final bool snapToMonth; - final double? initialScrollOffset; final bool readOnly; final bool persistentBottomBar; + final Widget? loadingWidget; @override Widget build(BuildContext context) { @@ -82,13 +85,14 @@ class Timeline extends StatelessWidget { child: _SliverTimeline( topSliverWidget: topSliverWidget, topSliverWidgetHeight: topSliverWidgetHeight, + bottomSliverWidget: bottomSliverWidget, appBar: appBar, bottomSheet: bottomSheet, withScrubber: withScrubber, persistentBottomBar: persistentBottomBar, snapToMonth: snapToMonth, - initialScrollOffset: initialScrollOffset, maxWidth: constraints.maxWidth, + loadingWidget: loadingWidget, ), ), ), @@ -111,24 +115,26 @@ class _SliverTimeline extends ConsumerStatefulWidget { const _SliverTimeline({ this.topSliverWidget, this.topSliverWidgetHeight, + this.bottomSliverWidget, this.appBar, this.bottomSheet, this.withScrubber = true, this.persistentBottomBar = false, this.snapToMonth = true, - this.initialScrollOffset, this.maxWidth, + this.loadingWidget, }); final Widget? topSliverWidget; final double? topSliverWidgetHeight; + final Widget? bottomSliverWidget; final Widget? appBar; final Widget? bottomSheet; final bool withScrubber; final bool persistentBottomBar; final bool snapToMonth; - final double? initialScrollOffset; final double? maxWidth; + final Widget? loadingWidget; @override ConsumerState createState() => _SliverTimelineState(); @@ -152,10 +158,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { @override void initState() { super.initState(); - _scrollController = ScrollController( - initialScrollOffset: widget.initialScrollOffset ?? 0.0, - onAttach: _restoreAssetPosition, - ); + _scrollController = ScrollController(onAttach: _restoreAssetPosition); _eventSubscription = EventStream.shared.listen(_onEvent); final currentTilesPerRow = ref.read(settingsProvider).get(Setting.tilesPerRow); @@ -373,6 +376,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { } }, child: asyncSegments.widgetWhen( + onLoading: widget.loadingWidget != null ? () => widget.loadingWidget! : null, onData: (segments) { final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1; final double appBarExpandedHeight = widget.appBar != null && widget.appBar is MesmerizingSliverAppBar @@ -380,12 +384,9 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { : 0; final topPadding = context.padding.top + (widget.appBar == null ? 0 : kToolbarHeight) + 10; - const scrubberBottomPadding = 100.0; const bottomSheetOpenModifier = 120.0; - final bottomPadding = - context.padding.bottom + - (widget.appBar == null ? 0 : scrubberBottomPadding) + - (isMultiSelectEnabled ? bottomSheetOpenModifier : 0); + final contentBottomPadding = context.padding.bottom + (isMultiSelectEnabled ? bottomSheetOpenModifier : 0); + final scrubberBottomPadding = contentBottomPadding + kScrubberThumbHeight; final grid = CustomScrollView( primary: true, @@ -408,7 +409,8 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { addRepaintBoundaries: false, ), ), - SliverPadding(padding: EdgeInsets.only(bottom: bottomPadding)), + if (widget.bottomSliverWidget != null) widget.bottomSliverWidget!, + SliverPadding(padding: EdgeInsets.only(bottom: contentBottomPadding)), ], ); @@ -419,7 +421,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { layoutSegments: segments, timelineHeight: maxHeight, topPadding: topPadding, - bottomPadding: bottomPadding, + bottomPadding: scrubberBottomPadding, monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight, hasAppBar: widget.appBar != null, child: grid,