mirror of
https://github.com/immich-app/immich.git
synced 2025-07-31 15:08:44 -04:00
feat(mobile): drift search page (#19811)
* feat(mobile): drift search page * migrate to drift page --------- Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
parent
2046dcc5b4
commit
8491fe459d
@ -106,6 +106,7 @@ custom_lint:
|
|||||||
- lib/utils/{image_url_builder,openapi_patching}.dart # utils are fine
|
- lib/utils/{image_url_builder,openapi_patching}.dart # utils are fine
|
||||||
- test/modules/utils/openapi_patching_test.dart # filename is self-explanatory...
|
- test/modules/utils/openapi_patching_test.dart # filename is self-explanatory...
|
||||||
- lib/domain/services/sync_stream.service.dart # Making sure to comply with the type from database
|
- lib/domain/services/sync_stream.service.dart # Making sure to comply with the type from database
|
||||||
|
- lib/domain/services/search.service.dart
|
||||||
|
|
||||||
# refactor
|
# refactor
|
||||||
- lib/models/map/map_marker.model.dart
|
- lib/models/map/map_marker.model.dart
|
||||||
|
38
mobile/lib/domain/models/search_result.model.dart
Normal file
38
mobile/lib/domain/models/search_result.model.dart
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
|
||||||
|
class SearchResult {
|
||||||
|
final List<BaseAsset> assets;
|
||||||
|
final int? nextPage;
|
||||||
|
|
||||||
|
const SearchResult({
|
||||||
|
required this.assets,
|
||||||
|
this.nextPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
int get totalAssets => assets.length;
|
||||||
|
|
||||||
|
SearchResult copyWith({
|
||||||
|
List<BaseAsset>? 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;
|
||||||
|
}
|
92
mobile/lib/domain/services/search.service.dart
Normal file
92
mobile/lib/domain/services/search.service.dart
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/search_result.model.dart';
|
||||||
|
import 'package:immich_mobile/extensions/string_extensions.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/search_api.repository.dart';
|
||||||
|
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:openapi/api.dart' as api show AssetVisibility;
|
||||||
|
import 'package:openapi/api.dart' hide AssetVisibility;
|
||||||
|
|
||||||
|
class SearchService {
|
||||||
|
final _log = Logger("SearchService");
|
||||||
|
final SearchApiRepository _searchApiRepository;
|
||||||
|
|
||||||
|
SearchService(this._searchApiRepository);
|
||||||
|
|
||||||
|
Future<List<String>?> getSearchSuggestions(
|
||||||
|
SearchSuggestionType type, {
|
||||||
|
String? country,
|
||||||
|
String? state,
|
||||||
|
String? make,
|
||||||
|
String? model,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
return await _searchApiRepository.getSearchSuggestions(
|
||||||
|
type,
|
||||||
|
country: country,
|
||||||
|
state: state,
|
||||||
|
make: make,
|
||||||
|
model: model,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_log.warning("Failed to get search suggestions", e);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<SearchResult?> search(SearchFilter filter, int page) async {
|
||||||
|
try {
|
||||||
|
final response = await _searchApiRepository.search(filter, page);
|
||||||
|
|
||||||
|
if (response == null || response.assets.items.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SearchResult(
|
||||||
|
assets: response.assets.items.map((e) => e.toDto()).toList(),
|
||||||
|
nextPage: response.assets.nextPage?.toInt(),
|
||||||
|
);
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
_log.severe("Failed to search for assets", error, stackTrace);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension on AssetResponseDto {
|
||||||
|
RemoteAsset toDto() {
|
||||||
|
return RemoteAsset(
|
||||||
|
id: id,
|
||||||
|
name: originalFileName,
|
||||||
|
checksum: checksum,
|
||||||
|
createdAt: fileCreatedAt,
|
||||||
|
updatedAt: updatedAt,
|
||||||
|
ownerId: ownerId,
|
||||||
|
visibility: switch (visibility) {
|
||||||
|
api.AssetVisibility.timeline => AssetVisibility.timeline,
|
||||||
|
api.AssetVisibility.hidden => AssetVisibility.hidden,
|
||||||
|
api.AssetVisibility.archive => AssetVisibility.archive,
|
||||||
|
api.AssetVisibility.locked => AssetVisibility.locked,
|
||||||
|
_ => AssetVisibility.timeline,
|
||||||
|
},
|
||||||
|
durationInSeconds: duration.toDuration()?.inSeconds ?? 0,
|
||||||
|
height: exifInfo?.exifImageHeight?.toInt(),
|
||||||
|
width: exifInfo?.exifImageWidth?.toInt(),
|
||||||
|
isFavorite: isFavorite,
|
||||||
|
livePhotoVideoId: livePhotoVideoId,
|
||||||
|
thumbHash: thumbhash,
|
||||||
|
localId: null,
|
||||||
|
type: type.toAssetType(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension on AssetTypeEnum {
|
||||||
|
AssetType toAssetType() => switch (this) {
|
||||||
|
AssetTypeEnum.IMAGE => AssetType.image,
|
||||||
|
AssetTypeEnum.VIDEO => AssetType.video,
|
||||||
|
AssetTypeEnum.AUDIO => AssetType.audio,
|
||||||
|
AssetTypeEnum.OTHER => AssetType.other,
|
||||||
|
_ => throw Exception('Unknown AssetType value: $this'),
|
||||||
|
};
|
||||||
|
}
|
@ -65,6 +65,9 @@ class TimelineFactory {
|
|||||||
|
|
||||||
TimelineService place(String place) =>
|
TimelineService place(String place) =>
|
||||||
TimelineService(_timelineRepository.place(place, groupBy));
|
TimelineService(_timelineRepository.place(place, groupBy));
|
||||||
|
|
||||||
|
TimelineService fromAssets(List<BaseAsset> assets) =>
|
||||||
|
TimelineService(_timelineRepository.fromAssets(assets));
|
||||||
}
|
}
|
||||||
|
|
||||||
class TimelineService {
|
class TimelineService {
|
||||||
|
@ -0,0 +1,87 @@
|
|||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'
|
||||||
|
hide AssetVisibility;
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/api.repository.dart';
|
||||||
|
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
|
class SearchApiRepository extends ApiRepository {
|
||||||
|
final SearchApi _api;
|
||||||
|
const SearchApiRepository(this._api);
|
||||||
|
|
||||||
|
Future<SearchResponseDto?> search(SearchFilter filter, int page) {
|
||||||
|
AssetTypeEnum? type;
|
||||||
|
if (filter.mediaType.index == AssetType.image.index) {
|
||||||
|
type = AssetTypeEnum.IMAGE;
|
||||||
|
} else if (filter.mediaType.index == AssetType.video.index) {
|
||||||
|
type = AssetTypeEnum.VIDEO;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.context != null && filter.context!.isNotEmpty) {
|
||||||
|
return _api.searchSmart(
|
||||||
|
SmartSearchDto(
|
||||||
|
query: filter.context!,
|
||||||
|
language: filter.language,
|
||||||
|
country: filter.location.country,
|
||||||
|
state: filter.location.state,
|
||||||
|
city: filter.location.city,
|
||||||
|
make: filter.camera.make,
|
||||||
|
model: filter.camera.model,
|
||||||
|
takenAfter: filter.date.takenAfter,
|
||||||
|
takenBefore: filter.date.takenBefore,
|
||||||
|
visibility: filter.display.isArchive
|
||||||
|
? AssetVisibility.archive
|
||||||
|
: AssetVisibility.timeline,
|
||||||
|
isFavorite: filter.display.isFavorite ? true : null,
|
||||||
|
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
|
||||||
|
personIds: filter.people.map((e) => e.id).toList(),
|
||||||
|
type: type,
|
||||||
|
page: page,
|
||||||
|
size: 1000,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _api.searchAssets(
|
||||||
|
MetadataSearchDto(
|
||||||
|
originalFileName: filter.filename != null && filter.filename!.isNotEmpty
|
||||||
|
? filter.filename
|
||||||
|
: null,
|
||||||
|
country: filter.location.country,
|
||||||
|
description:
|
||||||
|
filter.description != null && filter.description!.isNotEmpty
|
||||||
|
? filter.description
|
||||||
|
: null,
|
||||||
|
state: filter.location.state,
|
||||||
|
city: filter.location.city,
|
||||||
|
make: filter.camera.make,
|
||||||
|
model: filter.camera.model,
|
||||||
|
takenAfter: filter.date.takenAfter,
|
||||||
|
takenBefore: filter.date.takenBefore,
|
||||||
|
visibility: filter.display.isArchive
|
||||||
|
? AssetVisibility.archive
|
||||||
|
: AssetVisibility.timeline,
|
||||||
|
isFavorite: filter.display.isFavorite ? true : null,
|
||||||
|
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
|
||||||
|
personIds: filter.people.map((e) => e.id).toList(),
|
||||||
|
type: type,
|
||||||
|
page: page,
|
||||||
|
size: 1000,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<String>?> getSearchSuggestions(
|
||||||
|
SearchSuggestionType type, {
|
||||||
|
String? country,
|
||||||
|
String? state,
|
||||||
|
String? make,
|
||||||
|
String? model,
|
||||||
|
}) =>
|
||||||
|
_api.getSearchSuggestions(
|
||||||
|
type,
|
||||||
|
country: country,
|
||||||
|
state: state,
|
||||||
|
make: make,
|
||||||
|
model: model,
|
||||||
|
);
|
||||||
|
}
|
@ -302,6 +302,12 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
|||||||
.get();
|
.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TimelineQuery fromAssets(List<BaseAsset> assets) => (
|
||||||
|
bucketSource: () => Stream.value(_generateBuckets(assets.length)),
|
||||||
|
assetSource: (offset, count) =>
|
||||||
|
Future.value(assets.skip(offset).take(count).toList()),
|
||||||
|
);
|
||||||
|
|
||||||
TimelineQuery remote(String ownerId, GroupAssetsBy groupBy) =>
|
TimelineQuery remote(String ownerId, GroupAssetsBy groupBy) =>
|
||||||
_remoteQueryBuilder(
|
_remoteQueryBuilder(
|
||||||
filter: (row) =>
|
filter: (row) =>
|
||||||
|
@ -133,7 +133,7 @@ class _TabShellPageState extends ConsumerState<TabShellPage> {
|
|||||||
return AutoTabsRouter(
|
return AutoTabsRouter(
|
||||||
routes: [
|
routes: [
|
||||||
const MainTimelineRoute(),
|
const MainTimelineRoute(),
|
||||||
SearchRoute(),
|
DriftSearchRoute(),
|
||||||
const DriftAlbumsRoute(),
|
const DriftAlbumsRoute(),
|
||||||
const DriftLibraryRoute(),
|
const DriftLibraryRoute(),
|
||||||
],
|
],
|
||||||
|
@ -22,16 +22,6 @@ final _features = [
|
|||||||
icon: Icons.timeline_rounded,
|
icon: Icons.timeline_rounded,
|
||||||
onTap: (ctx, _) => ctx.pushRoute(const TabShellRoute()),
|
onTap: (ctx, _) => ctx.pushRoute(const TabShellRoute()),
|
||||||
),
|
),
|
||||||
_Feature(
|
|
||||||
name: 'Video',
|
|
||||||
icon: Icons.video_collection_outlined,
|
|
||||||
onTap: (ctx, _) => ctx.pushRoute(const DriftVideoRoute()),
|
|
||||||
),
|
|
||||||
_Feature(
|
|
||||||
name: 'Recently Taken',
|
|
||||||
icon: Icons.schedule_outlined,
|
|
||||||
onTap: (ctx, _) => ctx.pushRoute(const DriftRecentlyTakenRoute()),
|
|
||||||
),
|
|
||||||
_Feature(
|
_Feature(
|
||||||
name: 'Selection Mode Timeline',
|
name: 'Selection Mode Timeline',
|
||||||
icon: Icons.developer_mode_rounded,
|
icon: Icons.developer_mode_rounded,
|
||||||
|
925
mobile/lib/presentation/pages/search/drift_search.page.dart
Normal file
925
mobile/lib/presentation/pages/search/drift_search.page.dart
Normal file
@ -0,0 +1,925 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/person.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||||
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
|
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
||||||
|
import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
|
||||||
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
import 'package:immich_mobile/widgets/common/search_field.dart';
|
||||||
|
import 'package:immich_mobile/widgets/search/search_filter/camera_picker.dart';
|
||||||
|
import 'package:immich_mobile/widgets/search/search_filter/display_option_picker.dart';
|
||||||
|
import 'package:immich_mobile/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart';
|
||||||
|
import 'package:immich_mobile/widgets/search/search_filter/location_picker.dart';
|
||||||
|
import 'package:immich_mobile/widgets/search/search_filter/media_type_picker.dart';
|
||||||
|
import 'package:immich_mobile/widgets/search/search_filter/people_picker.dart';
|
||||||
|
import 'package:immich_mobile/widgets/search/search_filter/search_filter_chip.dart';
|
||||||
|
import 'package:immich_mobile/widgets/search/search_filter/search_filter_utils.dart';
|
||||||
|
|
||||||
|
@RoutePage()
|
||||||
|
class DriftSearchPage extends HookConsumerWidget {
|
||||||
|
const DriftSearchPage({super.key, this.preFilter});
|
||||||
|
|
||||||
|
final SearchFilter? preFilter;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final textSearchType = useState<TextSearchType>(TextSearchType.context);
|
||||||
|
final searchHintText =
|
||||||
|
useState<String>('sunrise_on_the_beach'.t(context: context));
|
||||||
|
final textSearchController = useTextEditingController();
|
||||||
|
final filter = useState<SearchFilter>(
|
||||||
|
SearchFilter(
|
||||||
|
people: preFilter?.people ?? {},
|
||||||
|
location: preFilter?.location ?? SearchLocationFilter(),
|
||||||
|
camera: preFilter?.camera ?? SearchCameraFilter(),
|
||||||
|
date: preFilter?.date ?? SearchDateFilter(),
|
||||||
|
display: preFilter?.display ??
|
||||||
|
SearchDisplayFilters(
|
||||||
|
isNotInAlbum: false,
|
||||||
|
isArchive: false,
|
||||||
|
isFavorite: false,
|
||||||
|
),
|
||||||
|
mediaType: preFilter?.mediaType ?? AssetType.other,
|
||||||
|
language:
|
||||||
|
"${context.locale.languageCode}-${context.locale.countryCode}",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final previousFilter = useState<SearchFilter?>(null);
|
||||||
|
|
||||||
|
final peopleCurrentFilterWidget = useState<Widget?>(null);
|
||||||
|
final dateRangeCurrentFilterWidget = useState<Widget?>(null);
|
||||||
|
final cameraCurrentFilterWidget = useState<Widget?>(null);
|
||||||
|
final locationCurrentFilterWidget = useState<Widget?>(null);
|
||||||
|
final mediaTypeCurrentFilterWidget = useState<Widget?>(null);
|
||||||
|
final displayOptionCurrentFilterWidget = useState<Widget?>(null);
|
||||||
|
|
||||||
|
final isSearching = useState(false);
|
||||||
|
|
||||||
|
SnackBar searchInfoSnackBar(String message) {
|
||||||
|
return SnackBar(
|
||||||
|
content: Text(
|
||||||
|
message,
|
||||||
|
style: context.textTheme.labelLarge,
|
||||||
|
),
|
||||||
|
showCloseIcon: true,
|
||||||
|
behavior: SnackBarBehavior.fixed,
|
||||||
|
closeIconColor: context.colorScheme.onSurface,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
search() async {
|
||||||
|
if (filter.value.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preFilter == null && filter.value == previousFilter.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSearching.value = true;
|
||||||
|
ref.watch(paginatedSearchProvider.notifier).clear();
|
||||||
|
final hasResult = await ref
|
||||||
|
.watch(paginatedSearchProvider.notifier)
|
||||||
|
.search(filter.value);
|
||||||
|
|
||||||
|
if (!hasResult) {
|
||||||
|
context.showSnackBar(
|
||||||
|
searchInfoSnackBar('search_no_result'.t(context: context)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
previousFilter.value = filter.value;
|
||||||
|
isSearching.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchPreFilter() {
|
||||||
|
if (preFilter != null) {
|
||||||
|
Future.delayed(
|
||||||
|
Duration.zero,
|
||||||
|
() {
|
||||||
|
search();
|
||||||
|
|
||||||
|
if (preFilter!.location.city != null) {
|
||||||
|
locationCurrentFilterWidget.value = Text(
|
||||||
|
preFilter!.location.city!,
|
||||||
|
style: context.textTheme.labelLarge,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() {
|
||||||
|
Future.microtask(
|
||||||
|
() => ref.invalidate(paginatedSearchProvider),
|
||||||
|
);
|
||||||
|
searchPreFilter();
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
showPeoplePicker() {
|
||||||
|
handleOnSelect(Set<Person> value) {
|
||||||
|
filter.value = filter.value.copyWith(
|
||||||
|
people: value,
|
||||||
|
);
|
||||||
|
|
||||||
|
peopleCurrentFilterWidget.value = Text(
|
||||||
|
value
|
||||||
|
.map((e) => e.name != '' ? e.name : 'no_name'.t(context: context))
|
||||||
|
.join(', '),
|
||||||
|
style: context.textTheme.labelLarge,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClear() {
|
||||||
|
filter.value = filter.value.copyWith(
|
||||||
|
people: {},
|
||||||
|
);
|
||||||
|
|
||||||
|
peopleCurrentFilterWidget.value = null;
|
||||||
|
search();
|
||||||
|
}
|
||||||
|
|
||||||
|
showFilterBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
child: FractionallySizedBox(
|
||||||
|
heightFactor: 0.8,
|
||||||
|
child: FilterBottomSheetScaffold(
|
||||||
|
title: 'search_filter_people_title'.t(context: context),
|
||||||
|
expanded: true,
|
||||||
|
onSearch: search,
|
||||||
|
onClear: handleClear,
|
||||||
|
child: PeoplePicker(
|
||||||
|
onSelect: handleOnSelect,
|
||||||
|
filter: filter.value.people,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
showLocationPicker() {
|
||||||
|
handleOnSelect(Map<String, String?> value) {
|
||||||
|
filter.value = filter.value.copyWith(
|
||||||
|
location: SearchLocationFilter(
|
||||||
|
country: value['country'],
|
||||||
|
city: value['city'],
|
||||||
|
state: value['state'],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final locationText = <String>[];
|
||||||
|
if (value['country'] != null) {
|
||||||
|
locationText.add(value['country']!);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value['state'] != null) {
|
||||||
|
locationText.add(value['state']!);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value['city'] != null) {
|
||||||
|
locationText.add(value['city']!);
|
||||||
|
}
|
||||||
|
|
||||||
|
locationCurrentFilterWidget.value = Text(
|
||||||
|
locationText.join(', '),
|
||||||
|
style: context.textTheme.labelLarge,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClear() {
|
||||||
|
filter.value = filter.value.copyWith(
|
||||||
|
location: SearchLocationFilter(),
|
||||||
|
);
|
||||||
|
|
||||||
|
locationCurrentFilterWidget.value = null;
|
||||||
|
search();
|
||||||
|
}
|
||||||
|
|
||||||
|
showFilterBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
isDismissible: true,
|
||||||
|
child: FilterBottomSheetScaffold(
|
||||||
|
title: 'search_filter_location_title'.t(context: context),
|
||||||
|
onSearch: search,
|
||||||
|
onClear: handleClear,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||||
|
child: Container(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: context.viewInsets.bottom,
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||||
|
child: LocationPicker(
|
||||||
|
onSelected: handleOnSelect,
|
||||||
|
filter: filter.value.location,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
showCameraPicker() {
|
||||||
|
handleOnSelect(Map<String, String?> value) {
|
||||||
|
filter.value = filter.value.copyWith(
|
||||||
|
camera: SearchCameraFilter(
|
||||||
|
make: value['make'],
|
||||||
|
model: value['model'],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
cameraCurrentFilterWidget.value = Text(
|
||||||
|
'${value['make'] ?? ''} ${value['model'] ?? ''}',
|
||||||
|
style: context.textTheme.labelLarge,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClear() {
|
||||||
|
filter.value = filter.value.copyWith(
|
||||||
|
camera: SearchCameraFilter(),
|
||||||
|
);
|
||||||
|
|
||||||
|
cameraCurrentFilterWidget.value = null;
|
||||||
|
search();
|
||||||
|
}
|
||||||
|
|
||||||
|
showFilterBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
isDismissible: true,
|
||||||
|
child: FilterBottomSheetScaffold(
|
||||||
|
title: 'search_filter_camera_title'.t(context: context),
|
||||||
|
onSearch: search,
|
||||||
|
onClear: handleClear,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: CameraPicker(
|
||||||
|
onSelect: handleOnSelect,
|
||||||
|
filter: filter.value.camera,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
showDatePicker() async {
|
||||||
|
final firstDate = DateTime(1900);
|
||||||
|
final lastDate = DateTime.now();
|
||||||
|
|
||||||
|
final date = await showDateRangePicker(
|
||||||
|
context: context,
|
||||||
|
firstDate: firstDate,
|
||||||
|
lastDate: lastDate,
|
||||||
|
currentDate: DateTime.now(),
|
||||||
|
initialDateRange: DateTimeRange(
|
||||||
|
start: filter.value.date.takenAfter ?? lastDate,
|
||||||
|
end: filter.value.date.takenBefore ?? lastDate,
|
||||||
|
),
|
||||||
|
helpText: 'search_filter_date_title'.t(context: context),
|
||||||
|
cancelText: 'cancel'.t(context: context),
|
||||||
|
confirmText: 'select'.t(context: context),
|
||||||
|
saveText: 'save'.t(context: context),
|
||||||
|
errorFormatText: 'invalid_date_format'.t(context: context),
|
||||||
|
errorInvalidText: 'invalid_date'.t(context: context),
|
||||||
|
fieldStartHintText: 'start_date'.t(context: context),
|
||||||
|
fieldEndHintText: 'end_date'.t(context: context),
|
||||||
|
initialEntryMode: DatePickerEntryMode.calendar,
|
||||||
|
keyboardType: TextInputType.text,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (date == null) {
|
||||||
|
filter.value = filter.value.copyWith(
|
||||||
|
date: SearchDateFilter(),
|
||||||
|
);
|
||||||
|
|
||||||
|
dateRangeCurrentFilterWidget.value = null;
|
||||||
|
search();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
filter.value = filter.value.copyWith(
|
||||||
|
date: SearchDateFilter(
|
||||||
|
takenAfter: date.start,
|
||||||
|
takenBefore: date.end.add(
|
||||||
|
const Duration(
|
||||||
|
hours: 23,
|
||||||
|
minutes: 59,
|
||||||
|
seconds: 59,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// If date range is less than 24 hours, set the end date to the end of the day
|
||||||
|
if (date.end.difference(date.start).inHours < 24) {
|
||||||
|
dateRangeCurrentFilterWidget.value = Text(
|
||||||
|
DateFormat.yMMMd().format(date.start.toLocal()),
|
||||||
|
style: context.textTheme.labelLarge,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
dateRangeCurrentFilterWidget.value = Text(
|
||||||
|
'search_filter_date_interval'.t(
|
||||||
|
context: context,
|
||||||
|
args: {
|
||||||
|
"start": DateFormat.yMMMd().format(date.start.toLocal()),
|
||||||
|
"end": DateFormat.yMMMd().format(date.end.toLocal()),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
style: context.textTheme.labelLarge,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
search();
|
||||||
|
}
|
||||||
|
|
||||||
|
// MEDIA PICKER
|
||||||
|
showMediaTypePicker() {
|
||||||
|
handleOnSelected(AssetType assetType) {
|
||||||
|
filter.value = filter.value.copyWith(
|
||||||
|
mediaType: assetType,
|
||||||
|
);
|
||||||
|
|
||||||
|
mediaTypeCurrentFilterWidget.value = Text(
|
||||||
|
assetType == AssetType.image
|
||||||
|
? 'image'.t(context: context)
|
||||||
|
: assetType == AssetType.video
|
||||||
|
? 'video'.t(context: context)
|
||||||
|
: 'all'.t(context: context),
|
||||||
|
style: context.textTheme.labelLarge,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClear() {
|
||||||
|
filter.value = filter.value.copyWith(
|
||||||
|
mediaType: AssetType.other,
|
||||||
|
);
|
||||||
|
|
||||||
|
mediaTypeCurrentFilterWidget.value = null;
|
||||||
|
search();
|
||||||
|
}
|
||||||
|
|
||||||
|
showFilterBottomSheet(
|
||||||
|
context: context,
|
||||||
|
child: FilterBottomSheetScaffold(
|
||||||
|
title: 'search_filter_media_type_title'.t(context: context),
|
||||||
|
onSearch: search,
|
||||||
|
onClear: handleClear,
|
||||||
|
child: MediaTypePicker(
|
||||||
|
onSelect: handleOnSelected,
|
||||||
|
filter: filter.value.mediaType,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DISPLAY OPTION
|
||||||
|
showDisplayOptionPicker() {
|
||||||
|
handleOnSelect(Map<DisplayOption, bool> value) {
|
||||||
|
final filterText = <String>[];
|
||||||
|
value.forEach((key, value) {
|
||||||
|
switch (key) {
|
||||||
|
case DisplayOption.notInAlbum:
|
||||||
|
filter.value = filter.value.copyWith(
|
||||||
|
display: filter.value.display.copyWith(
|
||||||
|
isNotInAlbum: value,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (value) {
|
||||||
|
filterText.add(
|
||||||
|
'search_filter_display_option_not_in_album'
|
||||||
|
.t(context: context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case DisplayOption.archive:
|
||||||
|
filter.value = filter.value.copyWith(
|
||||||
|
display: filter.value.display.copyWith(
|
||||||
|
isArchive: value,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (value) {
|
||||||
|
filterText.add('archive'.t(context: context));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case DisplayOption.favorite:
|
||||||
|
filter.value = filter.value.copyWith(
|
||||||
|
display: filter.value.display.copyWith(
|
||||||
|
isFavorite: value,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (value) {
|
||||||
|
filterText.add('favorite'.t(context: context));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filterText.isEmpty) {
|
||||||
|
displayOptionCurrentFilterWidget.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
displayOptionCurrentFilterWidget.value = Text(
|
||||||
|
filterText.join(', '),
|
||||||
|
style: context.textTheme.labelLarge,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClear() {
|
||||||
|
filter.value = filter.value.copyWith(
|
||||||
|
display: SearchDisplayFilters(
|
||||||
|
isNotInAlbum: false,
|
||||||
|
isArchive: false,
|
||||||
|
isFavorite: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
displayOptionCurrentFilterWidget.value = null;
|
||||||
|
search();
|
||||||
|
}
|
||||||
|
|
||||||
|
showFilterBottomSheet(
|
||||||
|
context: context,
|
||||||
|
child: FilterBottomSheetScaffold(
|
||||||
|
title: 'display_options'.t(context: context),
|
||||||
|
onSearch: search,
|
||||||
|
onClear: handleClear,
|
||||||
|
child: DisplayOptionPicker(
|
||||||
|
onSelect: handleOnSelect,
|
||||||
|
filter: filter.value.display,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleTextSubmitted(String value) {
|
||||||
|
switch (textSearchType.value) {
|
||||||
|
case TextSearchType.context:
|
||||||
|
filter.value = filter.value.copyWith(
|
||||||
|
filename: '',
|
||||||
|
context: value,
|
||||||
|
description: '',
|
||||||
|
);
|
||||||
|
|
||||||
|
break;
|
||||||
|
case TextSearchType.filename:
|
||||||
|
filter.value = filter.value.copyWith(
|
||||||
|
filename: value,
|
||||||
|
context: '',
|
||||||
|
description: '',
|
||||||
|
);
|
||||||
|
|
||||||
|
break;
|
||||||
|
case TextSearchType.description:
|
||||||
|
filter.value = filter.value.copyWith(
|
||||||
|
filename: '',
|
||||||
|
context: '',
|
||||||
|
description: value,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
search();
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData getSearchPrefixIcon() => switch (textSearchType.value) {
|
||||||
|
TextSearchType.context => Icons.image_search_rounded,
|
||||||
|
TextSearchType.filename => Icons.abc_rounded,
|
||||||
|
TextSearchType.description => Icons.text_snippet_outlined,
|
||||||
|
};
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
resizeToAvoidBottomInset: false,
|
||||||
|
appBar: AppBar(
|
||||||
|
automaticallyImplyLeading: true,
|
||||||
|
actions: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 16.0),
|
||||||
|
child: MenuAnchor(
|
||||||
|
style: MenuStyle(
|
||||||
|
elevation: const WidgetStatePropertyAll(1),
|
||||||
|
shape: WidgetStateProperty.all(
|
||||||
|
const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.all(
|
||||||
|
Radius.circular(24),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
padding: const WidgetStatePropertyAll(
|
||||||
|
EdgeInsets.all(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
builder: (
|
||||||
|
BuildContext context,
|
||||||
|
MenuController controller,
|
||||||
|
Widget? child,
|
||||||
|
) {
|
||||||
|
return IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
if (controller.isOpen) {
|
||||||
|
controller.close();
|
||||||
|
} else {
|
||||||
|
controller.open();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.more_vert_rounded),
|
||||||
|
tooltip: 'Show text search menu',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
menuChildren: [
|
||||||
|
MenuItemButton(
|
||||||
|
child: ListTile(
|
||||||
|
leading: const Icon(Icons.image_search_rounded),
|
||||||
|
title: Text(
|
||||||
|
'search_by_context'.t(context: context),
|
||||||
|
style: context.textTheme.bodyLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: textSearchType.value == TextSearchType.context
|
||||||
|
? context.colorScheme.primary
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
selectedColor: context.colorScheme.primary,
|
||||||
|
selected: textSearchType.value == TextSearchType.context,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
textSearchType.value = TextSearchType.context;
|
||||||
|
searchHintText.value =
|
||||||
|
'sunrise_on_the_beach'.t(context: context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
MenuItemButton(
|
||||||
|
child: ListTile(
|
||||||
|
leading: const Icon(Icons.abc_rounded),
|
||||||
|
title: Text(
|
||||||
|
'search_filter_filename'.t(context: context),
|
||||||
|
style: context.textTheme.bodyLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: textSearchType.value == TextSearchType.filename
|
||||||
|
? context.colorScheme.primary
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
selectedColor: context.colorScheme.primary,
|
||||||
|
selected: textSearchType.value == TextSearchType.filename,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
textSearchType.value = TextSearchType.filename;
|
||||||
|
searchHintText.value =
|
||||||
|
'file_name_or_extension'.t(context: context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
MenuItemButton(
|
||||||
|
child: ListTile(
|
||||||
|
leading: const Icon(Icons.text_snippet_outlined),
|
||||||
|
title: Text(
|
||||||
|
'search_by_description'.t(context: context),
|
||||||
|
style: context.textTheme.bodyLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color:
|
||||||
|
textSearchType.value == TextSearchType.description
|
||||||
|
? context.colorScheme.primary
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
selectedColor: context.colorScheme.primary,
|
||||||
|
selected:
|
||||||
|
textSearchType.value == TextSearchType.description,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
textSearchType.value = TextSearchType.description;
|
||||||
|
searchHintText.value =
|
||||||
|
'search_by_description_example'.t(context: context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
title: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(
|
||||||
|
color: context.colorScheme.onSurface.withAlpha(0),
|
||||||
|
width: 0,
|
||||||
|
),
|
||||||
|
borderRadius: const BorderRadius.all(
|
||||||
|
Radius.circular(24),
|
||||||
|
),
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
context.colorScheme.primary.withValues(alpha: 0.075),
|
||||||
|
context.colorScheme.primary.withValues(alpha: 0.09),
|
||||||
|
context.colorScheme.primary.withValues(alpha: 0.075),
|
||||||
|
],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SearchField(
|
||||||
|
hintText: searchHintText.value,
|
||||||
|
key: const Key('search_text_field'),
|
||||||
|
controller: textSearchController,
|
||||||
|
contentPadding: preFilter != null
|
||||||
|
? const EdgeInsets.only(left: 24)
|
||||||
|
: const EdgeInsets.all(8),
|
||||||
|
prefixIcon: preFilter != null
|
||||||
|
? null
|
||||||
|
: Icon(
|
||||||
|
getSearchPrefixIcon(),
|
||||||
|
color: context.colorScheme.primary,
|
||||||
|
),
|
||||||
|
onSubmitted: handleTextSubmitted,
|
||||||
|
focusNode: ref.watch(searchInputFocusProvider),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.only(top: 12.0),
|
||||||
|
sliver: SliverToBoxAdapter(
|
||||||
|
child: SizedBox(
|
||||||
|
height: 50,
|
||||||
|
child: ListView(
|
||||||
|
key: const Key('search_filter_chip_list'),
|
||||||
|
shrinkWrap: true,
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
children: [
|
||||||
|
SearchFilterChip(
|
||||||
|
icon: Icons.people_alt_outlined,
|
||||||
|
onTap: showPeoplePicker,
|
||||||
|
label: 'people'.t(context: context),
|
||||||
|
currentFilter: peopleCurrentFilterWidget.value,
|
||||||
|
),
|
||||||
|
SearchFilterChip(
|
||||||
|
icon: Icons.location_on_outlined,
|
||||||
|
onTap: showLocationPicker,
|
||||||
|
label: 'search_filter_location'.t(context: context),
|
||||||
|
currentFilter: locationCurrentFilterWidget.value,
|
||||||
|
),
|
||||||
|
SearchFilterChip(
|
||||||
|
icon: Icons.camera_alt_outlined,
|
||||||
|
onTap: showCameraPicker,
|
||||||
|
label: 'camera'.t(context: context),
|
||||||
|
currentFilter: cameraCurrentFilterWidget.value,
|
||||||
|
),
|
||||||
|
SearchFilterChip(
|
||||||
|
icon: Icons.date_range_outlined,
|
||||||
|
onTap: showDatePicker,
|
||||||
|
label: 'search_filter_date'.t(context: context),
|
||||||
|
currentFilter: dateRangeCurrentFilterWidget.value,
|
||||||
|
),
|
||||||
|
SearchFilterChip(
|
||||||
|
key: const Key('media_type_chip'),
|
||||||
|
icon: Icons.video_collection_outlined,
|
||||||
|
onTap: showMediaTypePicker,
|
||||||
|
label: 'search_filter_media_type'.t(context: context),
|
||||||
|
currentFilter: mediaTypeCurrentFilterWidget.value,
|
||||||
|
),
|
||||||
|
SearchFilterChip(
|
||||||
|
icon: Icons.display_settings_outlined,
|
||||||
|
onTap: showDisplayOptionPicker,
|
||||||
|
label:
|
||||||
|
'search_filter_display_options'.t(context: context),
|
||||||
|
currentFilter: displayOptionCurrentFilterWidget.value,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isSearching.value)
|
||||||
|
const SliverFillRemaining(
|
||||||
|
hasScrollBody: false,
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
_SearchResultGrid(onScrollEnd: loadMoreSearchResult),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SearchResultGrid extends ConsumerWidget {
|
||||||
|
final VoidCallback onScrollEnd;
|
||||||
|
|
||||||
|
const _SearchResultGrid({required this.onScrollEnd});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final searchResult = ref.watch(paginatedSearchProvider);
|
||||||
|
|
||||||
|
if (searchResult.totalAssets == 0) {
|
||||||
|
return const _SearchEmptyContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
return 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: SliverFillRemaining(
|
||||||
|
child: ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
timelineServiceProvider.overrideWith(
|
||||||
|
(ref) {
|
||||||
|
final timelineService = ref
|
||||||
|
.watch(timelineFactoryProvider)
|
||||||
|
.fromAssets(searchResult.assets);
|
||||||
|
ref.onDispose(timelineService.dispose);
|
||||||
|
return timelineService;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: Timeline(
|
||||||
|
key: ValueKey(searchResult.totalAssets),
|
||||||
|
appBar: null,
|
||||||
|
groupBy: GroupAssetsBy.none,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SearchEmptyContent extends StatelessWidget {
|
||||||
|
const _SearchEmptyContent();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SliverToBoxAdapter(
|
||||||
|
child: ListView(
|
||||||
|
shrinkWrap: true,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 40),
|
||||||
|
Center(
|
||||||
|
child: Image.asset(
|
||||||
|
context.isDarkTheme
|
||||||
|
? 'assets/polaroid-dark.png'
|
||||||
|
: 'assets/polaroid-light.png',
|
||||||
|
height: 125,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Center(
|
||||||
|
child: Text(
|
||||||
|
'search_page_search_photos_videos'.t(context: context),
|
||||||
|
style: context.textTheme.labelLarge,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: _QuickLinkList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QuickLinkList extends StatelessWidget {
|
||||||
|
const _QuickLinkList();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: const BorderRadius.all(
|
||||||
|
Radius.circular(20),
|
||||||
|
),
|
||||||
|
border: Border.all(
|
||||||
|
color: context.colorScheme.outline.withAlpha(10),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
context.colorScheme.primary.withAlpha(10),
|
||||||
|
context.colorScheme.primary.withAlpha(15),
|
||||||
|
context.colorScheme.primary.withAlpha(20),
|
||||||
|
],
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: ListView(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
children: [
|
||||||
|
_QuickLink(
|
||||||
|
title: 'recently_taken'.t(context: context),
|
||||||
|
icon: Icons.schedule_outlined,
|
||||||
|
isTop: true,
|
||||||
|
onTap: () => context.pushRoute(const DriftRecentlyTakenRoute()),
|
||||||
|
),
|
||||||
|
_QuickLink(
|
||||||
|
title: 'videos'.t(context: context),
|
||||||
|
icon: Icons.play_circle_outline_rounded,
|
||||||
|
onTap: () => context.pushRoute(const DriftVideoRoute()),
|
||||||
|
),
|
||||||
|
_QuickLink(
|
||||||
|
title: 'favorites'.t(context: context),
|
||||||
|
icon: Icons.favorite_border_rounded,
|
||||||
|
isBottom: true,
|
||||||
|
onTap: () => context.pushRoute(const DriftFavoriteRoute()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QuickLink extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final IconData icon;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final bool isTop;
|
||||||
|
final bool isBottom;
|
||||||
|
|
||||||
|
const _QuickLink({
|
||||||
|
required this.title,
|
||||||
|
required this.icon,
|
||||||
|
required this.onTap,
|
||||||
|
this.isTop = false,
|
||||||
|
this.isBottom = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final borderRadius = BorderRadius.only(
|
||||||
|
topLeft: Radius.circular(isTop ? 20 : 0),
|
||||||
|
topRight: Radius.circular(isTop ? 20 : 0),
|
||||||
|
bottomLeft: Radius.circular(isBottom ? 20 : 0),
|
||||||
|
bottomRight: Radius.circular(isBottom ? 20 : 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: borderRadius,
|
||||||
|
),
|
||||||
|
leading: Icon(
|
||||||
|
icon,
|
||||||
|
size: 26,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
title,
|
||||||
|
style: context.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onTap: onTap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,40 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/search_result.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';
|
||||||
|
|
||||||
|
final paginatedSearchProvider =
|
||||||
|
StateNotifierProvider<PaginatedSearchNotifier, SearchResult>(
|
||||||
|
(ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)),
|
||||||
|
);
|
||||||
|
|
||||||
|
class PaginatedSearchNotifier extends StateNotifier<SearchResult> {
|
||||||
|
final SearchService _searchService;
|
||||||
|
|
||||||
|
PaginatedSearchNotifier(this._searchService)
|
||||||
|
: super(const SearchResult(assets: [], nextPage: 1));
|
||||||
|
|
||||||
|
Future<bool> search(SearchFilter filter) async {
|
||||||
|
if (state.nextPage == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await _searchService.search(filter, state.nextPage!);
|
||||||
|
|
||||||
|
if (result == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
state = SearchResult(
|
||||||
|
assets: [...state.assets, ...result.assets],
|
||||||
|
nextPage: result.nextPage,
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
state = const SearchResult(assets: [], nextPage: 1);
|
||||||
|
}
|
||||||
|
}
|
@ -15,6 +15,7 @@ class TimelineArgs {
|
|||||||
final double spacing;
|
final double spacing;
|
||||||
final int columnCount;
|
final int columnCount;
|
||||||
final bool showStorageIndicator;
|
final bool showStorageIndicator;
|
||||||
|
final GroupAssetsBy? groupBy;
|
||||||
|
|
||||||
const TimelineArgs({
|
const TimelineArgs({
|
||||||
required this.maxWidth,
|
required this.maxWidth,
|
||||||
@ -22,6 +23,7 @@ class TimelineArgs {
|
|||||||
this.spacing = kTimelineSpacing,
|
this.spacing = kTimelineSpacing,
|
||||||
this.columnCount = kTimelineColumnCount,
|
this.columnCount = kTimelineColumnCount,
|
||||||
this.showStorageIndicator = false,
|
this.showStorageIndicator = false,
|
||||||
|
this.groupBy,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -30,7 +32,8 @@ class TimelineArgs {
|
|||||||
maxWidth == other.maxWidth &&
|
maxWidth == other.maxWidth &&
|
||||||
maxHeight == other.maxHeight &&
|
maxHeight == other.maxHeight &&
|
||||||
columnCount == other.columnCount &&
|
columnCount == other.columnCount &&
|
||||||
showStorageIndicator == other.showStorageIndicator;
|
showStorageIndicator == other.showStorageIndicator &&
|
||||||
|
groupBy == other.groupBy;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -39,7 +42,8 @@ class TimelineArgs {
|
|||||||
maxHeight.hashCode ^
|
maxHeight.hashCode ^
|
||||||
spacing.hashCode ^
|
spacing.hashCode ^
|
||||||
columnCount.hashCode ^
|
columnCount.hashCode ^
|
||||||
showStorageIndicator.hashCode;
|
showStorageIndicator.hashCode ^
|
||||||
|
groupBy.hashCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
class TimelineState {
|
class TimelineState {
|
||||||
@ -97,7 +101,8 @@ final timelineSegmentProvider = StreamProvider.autoDispose<List<Segment>>(
|
|||||||
final availableTileWidth = args.maxWidth - (spacing * (columnCount - 1));
|
final availableTileWidth = args.maxWidth - (spacing * (columnCount - 1));
|
||||||
final tileExtent = math.max(0, availableTileWidth) / columnCount;
|
final tileExtent = math.max(0, availableTileWidth) / columnCount;
|
||||||
|
|
||||||
final groupBy = GroupAssetsBy
|
final groupBy = args.groupBy ??
|
||||||
|
GroupAssetsBy
|
||||||
.values[ref.watch(settingsProvider).get(Setting.groupAssetsBy)];
|
.values[ref.watch(settingsProvider).get(Setting.groupAssetsBy)];
|
||||||
|
|
||||||
final timelineService = ref.watch(timelineServiceProvider);
|
final timelineService = ref.watch(timelineServiceProvider);
|
||||||
|
@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/rendering.dart';
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
@ -27,8 +28,13 @@ class Timeline extends StatelessWidget {
|
|||||||
this.topSliverWidget,
|
this.topSliverWidget,
|
||||||
this.topSliverWidgetHeight,
|
this.topSliverWidgetHeight,
|
||||||
this.showStorageIndicator = false,
|
this.showStorageIndicator = false,
|
||||||
this.appBar,
|
this.appBar = const ImmichSliverAppBar(
|
||||||
|
floating: true,
|
||||||
|
pinned: false,
|
||||||
|
snap: false,
|
||||||
|
),
|
||||||
this.bottomSheet = const GeneralBottomSheet(),
|
this.bottomSheet = const GeneralBottomSheet(),
|
||||||
|
this.groupBy,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Widget? topSliverWidget;
|
final Widget? topSliverWidget;
|
||||||
@ -36,6 +42,8 @@ class Timeline extends StatelessWidget {
|
|||||||
final bool showStorageIndicator;
|
final bool showStorageIndicator;
|
||||||
final Widget? appBar;
|
final Widget? appBar;
|
||||||
final Widget? bottomSheet;
|
final Widget? bottomSheet;
|
||||||
|
final GroupAssetsBy? groupBy;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@ -50,6 +58,7 @@ class Timeline extends StatelessWidget {
|
|||||||
settingsProvider.select((s) => s.get(Setting.tilesPerRow)),
|
settingsProvider.select((s) => s.get(Setting.tilesPerRow)),
|
||||||
),
|
),
|
||||||
showStorageIndicator: showStorageIndicator,
|
showStorageIndicator: showStorageIndicator,
|
||||||
|
groupBy: groupBy,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -112,13 +121,17 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
|||||||
return asyncSegments.widgetWhen(
|
return asyncSegments.widgetWhen(
|
||||||
onData: (segments) {
|
onData: (segments) {
|
||||||
final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1;
|
final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1;
|
||||||
final statusBarHeight = context.padding.top;
|
|
||||||
final double appBarExpandedHeight =
|
final double appBarExpandedHeight =
|
||||||
widget.appBar != null && widget.appBar is MesmerizingSliverAppBar
|
widget.appBar != null && widget.appBar is MesmerizingSliverAppBar
|
||||||
? 200
|
? 200
|
||||||
: 0;
|
: 0;
|
||||||
final totalAppBarHeight = statusBarHeight + kToolbarHeight;
|
final topPadding = context.padding.top +
|
||||||
|
(widget.appBar == null ? 0 : kToolbarHeight) +
|
||||||
|
10;
|
||||||
|
|
||||||
const scrubberBottomPadding = 100.0;
|
const scrubberBottomPadding = 100.0;
|
||||||
|
final bottomPadding = context.padding.bottom +
|
||||||
|
(widget.appBar == null ? 0 : scrubberBottomPadding);
|
||||||
|
|
||||||
return PrimaryScrollController(
|
return PrimaryScrollController(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
@ -127,8 +140,8 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
|||||||
Scrubber(
|
Scrubber(
|
||||||
layoutSegments: segments,
|
layoutSegments: segments,
|
||||||
timelineHeight: maxHeight,
|
timelineHeight: maxHeight,
|
||||||
topPadding: totalAppBarHeight + 10,
|
topPadding: topPadding,
|
||||||
bottomPadding: context.padding.bottom + scrubberBottomPadding,
|
bottomPadding: bottomPadding,
|
||||||
monthSegmentSnappingOffset:
|
monthSegmentSnappingOffset:
|
||||||
widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
|
widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
@ -137,13 +150,8 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
|||||||
slivers: [
|
slivers: [
|
||||||
if (isSelectionMode)
|
if (isSelectionMode)
|
||||||
const SelectionSliverAppBar()
|
const SelectionSliverAppBar()
|
||||||
else
|
else if (widget.appBar != null)
|
||||||
widget.appBar ??
|
widget.appBar!,
|
||||||
const ImmichSliverAppBar(
|
|
||||||
floating: true,
|
|
||||||
pinned: false,
|
|
||||||
snap: false,
|
|
||||||
),
|
|
||||||
if (widget.topSliverWidget != null) widget.topSliverWidget!,
|
if (widget.topSliverWidget != null) widget.topSliverWidget!,
|
||||||
_SliverSegmentedList(
|
_SliverSegmentedList(
|
||||||
segments: segments,
|
segments: segments,
|
||||||
@ -188,6 +196,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
|||||||
child: _MultiSelectStatusButton(),
|
child: _MultiSelectStatusButton(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (widget.bottomSheet != null)
|
||||||
Consumer(
|
Consumer(
|
||||||
builder: (_, consumerRef, child) {
|
builder: (_, consumerRef, child) {
|
||||||
final isMultiSelectEnabled = consumerRef.watch(
|
final isMultiSelectEnabled = consumerRef.watch(
|
||||||
|
12
mobile/lib/providers/infrastructure/search.provider.dart
Normal file
12
mobile/lib/providers/infrastructure/search.provider.dart
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/search.service.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/search_api.repository.dart';
|
||||||
|
import 'package:immich_mobile/providers/api.provider.dart';
|
||||||
|
|
||||||
|
final searchApiRepositoryProvider = Provider(
|
||||||
|
(ref) => SearchApiRepository(ref.watch(apiServiceProvider).searchApi),
|
||||||
|
);
|
||||||
|
|
||||||
|
final searchServiceProvider = Provider(
|
||||||
|
(ref) => SearchService(ref.watch(searchApiRepositoryProvider)),
|
||||||
|
);
|
@ -91,6 +91,7 @@ import 'package:immich_mobile/presentation/pages/drift_remote_album.page.dart';
|
|||||||
import 'package:immich_mobile/presentation/pages/drift_trash.page.dart';
|
import 'package:immich_mobile/presentation/pages/drift_trash.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/drift_video.page.dart';
|
import 'package:immich_mobile/presentation/pages/drift_video.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/local_timeline.page.dart';
|
import 'package:immich_mobile/presentation/pages/local_timeline.page.dart';
|
||||||
|
import 'package:immich_mobile/presentation/pages/search/drift_search.page.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart';
|
||||||
import 'package:immich_mobile/providers/api.provider.dart';
|
import 'package:immich_mobile/providers/api.provider.dart';
|
||||||
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
||||||
@ -189,7 +190,7 @@ class AppRouter extends RootStackRouter {
|
|||||||
guards: [_authGuard, _duplicateGuard],
|
guards: [_authGuard, _duplicateGuard],
|
||||||
),
|
),
|
||||||
AutoRoute(
|
AutoRoute(
|
||||||
page: SearchRoute.page,
|
page: DriftSearchRoute.page,
|
||||||
guards: [_authGuard, _duplicateGuard],
|
guards: [_authGuard, _duplicateGuard],
|
||||||
maintainState: false,
|
maintainState: false,
|
||||||
),
|
),
|
||||||
|
@ -991,6 +991,45 @@ class DriftRecentlyTakenRoute extends PageRouteInfo<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// generated route for
|
||||||
|
/// [DriftSearchPage]
|
||||||
|
class DriftSearchRoute extends PageRouteInfo<DriftSearchRouteArgs> {
|
||||||
|
DriftSearchRoute({
|
||||||
|
Key? key,
|
||||||
|
SearchFilter? preFilter,
|
||||||
|
List<PageRouteInfo>? children,
|
||||||
|
}) : super(
|
||||||
|
DriftSearchRoute.name,
|
||||||
|
args: DriftSearchRouteArgs(key: key, preFilter: preFilter),
|
||||||
|
initialChildren: children,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const String name = 'DriftSearchRoute';
|
||||||
|
|
||||||
|
static PageInfo page = PageInfo(
|
||||||
|
name,
|
||||||
|
builder: (data) {
|
||||||
|
final args = data.argsAs<DriftSearchRouteArgs>(
|
||||||
|
orElse: () => const DriftSearchRouteArgs(),
|
||||||
|
);
|
||||||
|
return DriftSearchPage(key: args.key, preFilter: args.preFilter);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class DriftSearchRouteArgs {
|
||||||
|
const DriftSearchRouteArgs({this.key, this.preFilter});
|
||||||
|
|
||||||
|
final Key? key;
|
||||||
|
|
||||||
|
final SearchFilter? preFilter;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'DriftSearchRouteArgs{key: $key, preFilter: $preFilter}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [DriftTrashPage]
|
/// [DriftTrashPage]
|
||||||
class DriftTrashRoute extends PageRouteInfo<void> {
|
class DriftTrashRoute extends PageRouteInfo<void> {
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
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/extensions/string_extensions.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/search_api.repository.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/models/search/search_result.model.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/providers/infrastructure/search.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';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
@ -14,15 +15,21 @@ final searchServiceProvider = Provider(
|
|||||||
(ref) => SearchService(
|
(ref) => SearchService(
|
||||||
ref.watch(apiServiceProvider),
|
ref.watch(apiServiceProvider),
|
||||||
ref.watch(assetRepositoryProvider),
|
ref.watch(assetRepositoryProvider),
|
||||||
|
ref.watch(searchApiRepositoryProvider),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
class SearchService {
|
class SearchService {
|
||||||
final ApiService _apiService;
|
final ApiService _apiService;
|
||||||
final AssetRepository _assetRepository;
|
final AssetRepository _assetRepository;
|
||||||
|
final SearchApiRepository _searchApiRepository;
|
||||||
|
|
||||||
final _log = Logger("SearchService");
|
final _log = Logger("SearchService");
|
||||||
SearchService(this._apiService, this._assetRepository);
|
SearchService(
|
||||||
|
this._apiService,
|
||||||
|
this._assetRepository,
|
||||||
|
this._searchApiRepository,
|
||||||
|
);
|
||||||
|
|
||||||
Future<List<String>?> getSearchSuggestions(
|
Future<List<String>?> getSearchSuggestions(
|
||||||
SearchSuggestionType type, {
|
SearchSuggestionType type, {
|
||||||
@ -32,7 +39,7 @@ class SearchService {
|
|||||||
String? model,
|
String? model,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
return await _apiService.searchApi.getSearchSuggestions(
|
return await _searchApiRepository.getSearchSuggestions(
|
||||||
type,
|
type,
|
||||||
country: country,
|
country: country,
|
||||||
state: state,
|
state: state,
|
||||||
@ -47,76 +54,15 @@ class SearchService {
|
|||||||
|
|
||||||
Future<SearchResult?> search(SearchFilter filter, int page) async {
|
Future<SearchResult?> search(SearchFilter filter, int page) async {
|
||||||
try {
|
try {
|
||||||
SearchResponseDto? response;
|
final response = await _searchApiRepository.search(filter, page);
|
||||||
AssetTypeEnum? type;
|
|
||||||
if (filter.mediaType == AssetType.image) {
|
|
||||||
type = AssetTypeEnum.IMAGE;
|
|
||||||
} else if (filter.mediaType == AssetType.video) {
|
|
||||||
type = AssetTypeEnum.VIDEO;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.context != null && filter.context!.isNotEmpty) {
|
|
||||||
response = await _apiService.searchApi.searchSmart(
|
|
||||||
SmartSearchDto(
|
|
||||||
query: filter.context!,
|
|
||||||
language: filter.language,
|
|
||||||
country: filter.location.country,
|
|
||||||
state: filter.location.state,
|
|
||||||
city: filter.location.city,
|
|
||||||
make: filter.camera.make,
|
|
||||||
model: filter.camera.model,
|
|
||||||
takenAfter: filter.date.takenAfter,
|
|
||||||
takenBefore: filter.date.takenBefore,
|
|
||||||
visibility: filter.display.isArchive
|
|
||||||
? AssetVisibility.archive
|
|
||||||
: AssetVisibility.timeline,
|
|
||||||
isFavorite: filter.display.isFavorite ? true : null,
|
|
||||||
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
|
|
||||||
personIds: filter.people.map((e) => e.id).toList(),
|
|
||||||
type: type,
|
|
||||||
page: page,
|
|
||||||
size: 1000,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
response = await _apiService.searchApi.searchAssets(
|
|
||||||
MetadataSearchDto(
|
|
||||||
originalFileName:
|
|
||||||
filter.filename != null && filter.filename!.isNotEmpty
|
|
||||||
? filter.filename
|
|
||||||
: null,
|
|
||||||
country: filter.location.country,
|
|
||||||
description:
|
|
||||||
filter.description != null && filter.description!.isNotEmpty
|
|
||||||
? filter.description
|
|
||||||
: null,
|
|
||||||
state: filter.location.state,
|
|
||||||
city: filter.location.city,
|
|
||||||
make: filter.camera.make,
|
|
||||||
model: filter.camera.model,
|
|
||||||
takenAfter: filter.date.takenAfter,
|
|
||||||
takenBefore: filter.date.takenBefore,
|
|
||||||
visibility: filter.display.isArchive
|
|
||||||
? AssetVisibility.archive
|
|
||||||
: AssetVisibility.timeline,
|
|
||||||
isFavorite: filter.display.isFavorite ? true : null,
|
|
||||||
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
|
|
||||||
personIds: filter.people.map((e) => e.id).toList(),
|
|
||||||
type: type,
|
|
||||||
page: page,
|
|
||||||
size: 1000,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response == null || response.assets.items.isEmpty) {
|
if (response == null || response.assets.items.isEmpty) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return SearchResult(
|
return SearchResult(
|
||||||
assets: await _assetRepository.getAllByRemoteId(
|
assets: await _assetRepository
|
||||||
response.assets.items.map((e) => e.id),
|
.getAllByRemoteId(response.assets.items.map((e) => e.id)),
|
||||||
),
|
|
||||||
nextPage: response.assets.nextPage?.toInt(),
|
nextPage: response.assets.nextPage?.toInt(),
|
||||||
);
|
);
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/search.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||||
import 'package:immich_mobile/repositories/activity_api.repository.dart';
|
import 'package:immich_mobile/repositories/activity_api.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/album_api.repository.dart';
|
import 'package:immich_mobile/repositories/album_api.repository.dart';
|
||||||
@ -15,4 +16,5 @@ void invalidateAllApiRepositoryProviders(WidgetRef ref) {
|
|||||||
ref.invalidate(personApiRepositoryProvider);
|
ref.invalidate(personApiRepositoryProvider);
|
||||||
ref.invalidate(assetApiRepositoryProvider);
|
ref.invalidate(assetApiRepositoryProvider);
|
||||||
ref.invalidate(timelineRepositoryProvider);
|
ref.invalidate(timelineRepositoryProvider);
|
||||||
|
ref.invalidate(searchApiRepositoryProvider);
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user