mirror of
https://github.com/immich-app/immich.git
synced 2024-12-29 15:11:58 +00:00
fix(mobile): search page (#13833)
* fix(mobile): search page minor problems * fix: flashing between search * restore search size * remove print statement * linting
This commit is contained in:
parent
9d75c5b999
commit
318ab756cb
5 changed files with 140 additions and 94 deletions
37
mobile/lib/models/search/search_result.model.dart
Normal file
37
mobile/lib/models/search/search_result.model.dart
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
|
||||||
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
|
|
||||||
|
class SearchResult {
|
||||||
|
final List<Asset> assets;
|
||||||
|
final int? nextPage;
|
||||||
|
|
||||||
|
SearchResult({
|
||||||
|
required this.assets,
|
||||||
|
this.nextPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
SearchResult copyWith({
|
||||||
|
List<Asset>? assets,
|
||||||
|
int? nextPage,
|
||||||
|
}) {
|
||||||
|
return SearchResult(
|
||||||
|
assets: assets ?? this.assets,
|
||||||
|
nextPage: nextPage ?? this.nextPage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'SearchResult(assets: $assets, 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => assets.hashCode ^ nextPage.hashCode;
|
||||||
|
}
|
|
@ -58,23 +58,22 @@ class SearchPage extends HookConsumerWidget {
|
||||||
final mediaTypeCurrentFilterWidget = useState<Widget?>(null);
|
final mediaTypeCurrentFilterWidget = useState<Widget?>(null);
|
||||||
final displayOptionCurrentFilterWidget = useState<Widget?>(null);
|
final displayOptionCurrentFilterWidget = useState<Widget?>(null);
|
||||||
|
|
||||||
final currentPage = useState(1);
|
final isSearching = useState(false);
|
||||||
final searchProvider = ref.watch(paginatedSearchProvider);
|
|
||||||
final searchResultCount = useState(0);
|
|
||||||
|
|
||||||
search() async {
|
search() async {
|
||||||
if (prefilter == null && filter.value == previousFilter.value) return;
|
if (prefilter == null && filter.value == previousFilter.value) return;
|
||||||
|
|
||||||
|
isSearching.value = true;
|
||||||
ref.watch(paginatedSearchProvider.notifier).clear();
|
ref.watch(paginatedSearchProvider.notifier).clear();
|
||||||
|
await ref.watch(paginatedSearchProvider.notifier).search(filter.value);
|
||||||
currentPage.value = 1;
|
|
||||||
|
|
||||||
final searchResult = await ref
|
|
||||||
.watch(paginatedSearchProvider.notifier)
|
|
||||||
.getNextPage(filter.value, currentPage.value);
|
|
||||||
|
|
||||||
previousFilter.value = filter.value;
|
previousFilter.value = filter.value;
|
||||||
searchResultCount.value = searchResult.length;
|
isSearching.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadMoreSearchResult() async {
|
||||||
|
isSearching.value = true;
|
||||||
|
await ref.watch(paginatedSearchProvider.notifier).search(filter.value);
|
||||||
|
isSearching.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
searchPrefilter() {
|
searchPrefilter() {
|
||||||
|
@ -97,20 +96,16 @@ class SearchPage extends HookConsumerWidget {
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() {
|
() {
|
||||||
|
Future.microtask(
|
||||||
|
() => ref.invalidate(paginatedSearchProvider),
|
||||||
|
);
|
||||||
searchPrefilter();
|
searchPrefilter();
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
loadMoreSearchResult() async {
|
|
||||||
currentPage.value += 1;
|
|
||||||
final searchResult = await ref
|
|
||||||
.watch(paginatedSearchProvider.notifier)
|
|
||||||
.getNextPage(filter.value, currentPage.value);
|
|
||||||
searchResultCount.value = searchResult.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
showPeoplePicker() {
|
showPeoplePicker() {
|
||||||
handleOnSelect(Set<Person> value) {
|
handleOnSelect(Set<Person> value) {
|
||||||
filter.value = filter.value.copyWith(
|
filter.value = filter.value.copyWith(
|
||||||
|
@ -465,41 +460,6 @@ class SearchPage extends HookConsumerWidget {
|
||||||
search();
|
search();
|
||||||
}
|
}
|
||||||
|
|
||||||
buildSearchResult() {
|
|
||||||
return switch (searchProvider) {
|
|
||||||
AsyncData() => Expanded(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 8.0),
|
|
||||||
child: NotificationListener<ScrollEndNotification>(
|
|
||||||
onNotification: (notification) {
|
|
||||||
final metrics = notification.metrics;
|
|
||||||
final shouldLoadMore = searchResultCount.value > 75;
|
|
||||||
if (metrics.pixels >= metrics.maxScrollExtent &&
|
|
||||||
shouldLoadMore) {
|
|
||||||
loadMoreSearchResult();
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
child: MultiselectGrid(
|
|
||||||
renderListProvider: paginatedSearchRenderListProvider,
|
|
||||||
archiveEnabled: true,
|
|
||||||
deleteEnabled: true,
|
|
||||||
editEnabled: true,
|
|
||||||
favoriteEnabled: true,
|
|
||||||
stackEnabled: false,
|
|
||||||
emptyIndicator: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
|
||||||
child: SearchEmptyContent(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
AsyncError(:final error) => Text('Error: $error'),
|
|
||||||
_ => const Expanded(child: Center(child: CircularProgressIndicator())),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
resizeToAvoidBottomInset: true,
|
resizeToAvoidBottomInset: true,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
|
@ -635,13 +595,67 @@ class SearchPage extends HookConsumerWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
buildSearchResult(),
|
SearchResultGrid(
|
||||||
|
onScrollEnd: loadMoreSearchResult,
|
||||||
|
isSearching: isSearching.value,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class SearchResultGrid extends StatelessWidget {
|
||||||
|
final VoidCallback onScrollEnd;
|
||||||
|
final bool isSearching;
|
||||||
|
|
||||||
|
const SearchResultGrid({
|
||||||
|
super.key,
|
||||||
|
required this.onScrollEnd,
|
||||||
|
this.isSearching = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
|
child: NotificationListener<ScrollEndNotification>(
|
||||||
|
onNotification: (notification) {
|
||||||
|
final isBottomSheetNotification = notification.context
|
||||||
|
?.findAncestorWidgetOfExactType<
|
||||||
|
DraggableScrollableSheet>() !=
|
||||||
|
null;
|
||||||
|
|
||||||
|
final metrics = notification.metrics;
|
||||||
|
final isVerticalScroll = metrics.axis == Axis.vertical;
|
||||||
|
|
||||||
|
if (metrics.pixels >= metrics.maxScrollExtent &&
|
||||||
|
isVerticalScroll &&
|
||||||
|
!isBottomSheetNotification) {
|
||||||
|
onScrollEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
child: MultiselectGrid(
|
||||||
|
renderListProvider: paginatedSearchRenderListProvider,
|
||||||
|
archiveEnabled: true,
|
||||||
|
deleteEnabled: true,
|
||||||
|
editEnabled: true,
|
||||||
|
favoriteEnabled: true,
|
||||||
|
stackEnabled: false,
|
||||||
|
emptyIndicator: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||||
|
child: !isSearching ? SearchEmptyContent() : SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class SearchEmptyContent extends StatelessWidget {
|
class SearchEmptyContent extends StatelessWidget {
|
||||||
const SearchEmptyContent({super.key});
|
const SearchEmptyContent({super.key});
|
||||||
|
|
||||||
|
|
|
@ -1,46 +1,39 @@
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/models/search/search_result.model.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/render_list.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/render_list.provider.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/models/search/search_filter.model.dart';
|
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
||||||
import 'package:immich_mobile/services/search.service.dart';
|
import 'package:immich_mobile/services/search.service.dart';
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
part 'paginated_search.provider.g.dart';
|
part 'paginated_search.provider.g.dart';
|
||||||
|
|
||||||
@riverpod
|
final paginatedSearchProvider =
|
||||||
class PaginatedSearch extends _$PaginatedSearch {
|
StateNotifierProvider<PaginatedSearchNotifier, SearchResult>(
|
||||||
Future<List<Asset>?> _search(SearchFilter filter, int page) async {
|
(ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)),
|
||||||
final service = ref.read(searchServiceProvider);
|
);
|
||||||
final result = await service.search(filter, page);
|
|
||||||
|
|
||||||
return result;
|
class PaginatedSearchNotifier extends StateNotifier<SearchResult> {
|
||||||
}
|
final SearchService _searchService;
|
||||||
|
|
||||||
@override
|
PaginatedSearchNotifier(this._searchService)
|
||||||
Future<List<Asset>> build() async {
|
: super(SearchResult(assets: [], nextPage: 1));
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<Asset>> getNextPage(SearchFilter filter, int nextPage) async {
|
search(SearchFilter filter) async {
|
||||||
state = const AsyncValue.loading();
|
if (state.nextPage == null) return;
|
||||||
|
|
||||||
final newState = await AsyncValue.guard(() async {
|
final result = await _searchService.search(filter, state.nextPage!);
|
||||||
final assets = await _search(filter, nextPage);
|
|
||||||
|
|
||||||
if (assets != null) {
|
if (result == null) return;
|
||||||
return [...?state.value, ...assets];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
state = newState.valueOrNull == null
|
state = SearchResult(
|
||||||
? const AsyncValue.data([])
|
assets: [...state.assets, ...result.assets],
|
||||||
: AsyncValue.data(newState.value!);
|
nextPage: result.nextPage,
|
||||||
|
);
|
||||||
return newState.valueOrNull ?? [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
state = const AsyncValue.data([]);
|
state = SearchResult(assets: [], nextPage: 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -48,15 +41,11 @@ class PaginatedSearch extends _$PaginatedSearch {
|
||||||
AsyncValue<RenderList> paginatedSearchRenderList(
|
AsyncValue<RenderList> paginatedSearchRenderList(
|
||||||
PaginatedSearchRenderListRef ref,
|
PaginatedSearchRenderListRef ref,
|
||||||
) {
|
) {
|
||||||
final assets = ref.watch(paginatedSearchProvider).value;
|
final result = ref.watch(paginatedSearchProvider);
|
||||||
|
|
||||||
if (assets != null) {
|
return ref.watch(
|
||||||
return ref.watch(
|
renderListProviderWithGrouping(
|
||||||
renderListProviderWithGrouping(
|
(result.assets, GroupAssetsBy.none),
|
||||||
(assets, GroupAssetsBy.none),
|
),
|
||||||
),
|
);
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return const AsyncValue.loading();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
Binary file not shown.
|
@ -1,8 +1,10 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/extensions/string_extensions.dart';
|
||||||
import 'package:immich_mobile/interfaces/asset.interface.dart';
|
import 'package:immich_mobile/interfaces/asset.interface.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/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
|
import 'package:immich_mobile/models/search/search_result.model.dart';
|
||||||
import 'package:immich_mobile/providers/api.provider.dart';
|
import 'package:immich_mobile/providers/api.provider.dart';
|
||||||
import 'package:immich_mobile/repositories/asset.repository.dart';
|
import 'package:immich_mobile/repositories/asset.repository.dart';
|
||||||
import 'package:immich_mobile/services/api.service.dart';
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
|
@ -44,7 +46,7 @@ class SearchService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Asset>?> search(SearchFilter filter, int page) async {
|
Future<SearchResult?> search(SearchFilter filter, int page) async {
|
||||||
try {
|
try {
|
||||||
SearchResponseDto? response;
|
SearchResponseDto? response;
|
||||||
AssetTypeEnum? type;
|
AssetTypeEnum? type;
|
||||||
|
@ -103,8 +105,12 @@ class SearchService {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return _assetRepository
|
return SearchResult(
|
||||||
.getAllByRemoteId(response.assets.items.map((e) => e.id));
|
assets: await _assetRepository.getAllByRemoteId(
|
||||||
|
response.assets.items.map((e) => e.id),
|
||||||
|
),
|
||||||
|
nextPage: response.assets.nextPage?.toInt(),
|
||||||
|
);
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
_log.severe("Failed to search for assets", error, stackTrace);
|
_log.severe("Failed to search for assets", error, stackTrace);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue