Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 4 additions & 13 deletions mobile/lib/domain/models/search_result.model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,21 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';

class SearchResult {
final List<BaseAsset> assets;
final double scrollOffset;
final int? nextPage;

const SearchResult({required this.assets, this.scrollOffset = 0.0, this.nextPage});

SearchResult copyWith({List<BaseAsset>? 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;
}
5 changes: 4 additions & 1 deletion mobile/lib/domain/services/timeline.service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ class TimelineFactory {
TimelineService fromAssets(List<BaseAsset> assets, TimelineOrigin type) =>
TimelineService(_timelineRepository.fromAssets(assets, type));

TimelineService fromAssetStream(List<BaseAsset> Function() getAssets, Stream<int> assetCount, TimelineOrigin type) =>
TimelineService(_timelineRepository.fromAssetStream(getAssets, assetCount, type));

TimelineService fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin type) =>
TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type));

Expand Down Expand Up @@ -112,7 +115,7 @@ class TimelineService {

if (totalAssets == 0) {
_bufferOffset = 0;
_buffer.clear();
Comment thread
shenlong-tanwen marked this conversation as resolved.
_buffer = [];
} else {
final int offset;
final int count;
Expand Down
13 changes: 13 additions & 0 deletions mobile/lib/infrastructure/repositories/timeline.repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,19 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
origin: origin,
);

TimelineQuery fromAssetStream(List<BaseAsset> Function() getAssets, Stream<int> 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<BaseAsset> assets, TimelineOrigin origin) {
// Sort assets by date descending and group by day
final sorted = List<BaseAsset>.from(assets)..sort((a, b) => b.createdAt.compareTo(a.createdAt));
Expand Down
160 changes: 102 additions & 58 deletions mobile/lib/presentation/pages/search/drift_search.page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -80,51 +80,28 @@ class DriftSearchPage extends HookConsumerWidget {
final ratingCurrentFilterWidget = useState<Widget?>(null);
final displayOptionCurrentFilterWidget = useState<Widget?>(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() {
Expand Down Expand Up @@ -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),
],
),
);
Expand All @@ -757,54 +734,121 @@ 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<DraggableScrollableSheet>() != null;
final remaining = metrics.maxScrollExtent - metrics.pixels;

if (remaining < metrics.viewportDimension && !isBottomSheet) {
onScrollEnd();
}

return NotificationListener<ScrollEndNotification>(
onNotification: (notification) {
final isBottomSheetNotification =
notification.context?.findAncestorWidgetOfExactType<DraggableScrollableSheet>() != 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<ScrollUpdateNotification>(
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),
),
),
),
);
}
}

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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -21,40 +23,52 @@ class SearchFilterProvider extends Notifier<SearchFilter?> {
}
}

final paginatedSearchProvider = StateNotifierProvider<PaginatedSearchNotifier, SearchResult>(
class SearchState {
final List<BaseAsset> assets;
final int? nextPage;
final bool isLoading;

const SearchState({this.assets = const [], this.nextPage = 1, this.isLoading = false});
}

final paginatedSearchProvider = StateNotifierProvider<PaginatedSearchNotifier, SearchState>(
(ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)),
);

class PaginatedSearchNotifier extends StateNotifier<SearchResult> {
class PaginatedSearchNotifier extends StateNotifier<SearchState> {
final SearchService _searchService;
final _assetCountController = StreamController<int>.broadcast();

PaginatedSearchNotifier(this._searchService) : super(const SearchResult(assets: [], nextPage: 1));
PaginatedSearchNotifier(this._searchService) : super(const SearchState());

Future<bool> search(SearchFilter filter) async {
if (state.nextPage == null) {
return false;
}
Stream<int> get assetCount => _assetCountController.stream;

Future<void> 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);
Comment thread
shenlong-tanwen marked this conversation as resolved.
}

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();
}
}
1 change: 1 addition & 0 deletions mobile/lib/presentation/widgets/timeline/constants.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
),
),
);
}
Expand Down
Loading
Loading