mirror of
https://github.com/immich-app/immich.git
synced 2025-06-02 21:24:28 -04:00
split up search page
This commit is contained in:
parent
c110c9b00e
commit
2c7e6071f5
@ -5,10 +5,11 @@ import 'package:immich_mobile/entities/asset.entity.dart';
|
|||||||
import 'package:immich_mobile/interfaces/person_api.interface.dart';
|
import 'package:immich_mobile/interfaces/person_api.interface.dart';
|
||||||
|
|
||||||
class SearchLocationFilter {
|
class SearchLocationFilter {
|
||||||
String? country;
|
final String? country;
|
||||||
String? state;
|
final String? state;
|
||||||
String? city;
|
final String? city;
|
||||||
SearchLocationFilter({
|
|
||||||
|
const SearchLocationFilter({
|
||||||
this.country,
|
this.country,
|
||||||
this.state,
|
this.state,
|
||||||
this.city,
|
this.city,
|
||||||
@ -65,9 +66,10 @@ class SearchLocationFilter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class SearchCameraFilter {
|
class SearchCameraFilter {
|
||||||
String? make;
|
final String? make;
|
||||||
String? model;
|
final String? model;
|
||||||
SearchCameraFilter({
|
|
||||||
|
const SearchCameraFilter({
|
||||||
this.make,
|
this.make,
|
||||||
this.model,
|
this.model,
|
||||||
});
|
});
|
||||||
@ -116,9 +118,10 @@ class SearchCameraFilter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class SearchDateFilter {
|
class SearchDateFilter {
|
||||||
DateTime? takenBefore;
|
final DateTime? takenBefore;
|
||||||
DateTime? takenAfter;
|
final DateTime? takenAfter;
|
||||||
SearchDateFilter({
|
|
||||||
|
const SearchDateFilter({
|
||||||
this.takenBefore,
|
this.takenBefore,
|
||||||
this.takenAfter,
|
this.takenAfter,
|
||||||
});
|
});
|
||||||
@ -172,10 +175,11 @@ class SearchDateFilter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class SearchDisplayFilters {
|
class SearchDisplayFilters {
|
||||||
bool isNotInAlbum = false;
|
final bool isNotInAlbum;
|
||||||
bool isArchive = false;
|
final bool isArchive;
|
||||||
bool isFavorite = false;
|
final bool isFavorite;
|
||||||
SearchDisplayFilters({
|
|
||||||
|
const SearchDisplayFilters({
|
||||||
required this.isNotInAlbum,
|
required this.isNotInAlbum,
|
||||||
required this.isArchive,
|
required this.isArchive,
|
||||||
required this.isFavorite,
|
required this.isFavorite,
|
||||||
@ -233,19 +237,19 @@ class SearchDisplayFilters {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class SearchFilter {
|
class SearchFilter {
|
||||||
String? context;
|
final String? context;
|
||||||
String? filename;
|
final String? filename;
|
||||||
String? description;
|
final String? description;
|
||||||
Set<Person> people;
|
final Set<Person> people;
|
||||||
SearchLocationFilter location;
|
final SearchLocationFilter location;
|
||||||
SearchCameraFilter camera;
|
final SearchCameraFilter camera;
|
||||||
SearchDateFilter date;
|
final SearchDateFilter date;
|
||||||
SearchDisplayFilters display;
|
final SearchDisplayFilters display;
|
||||||
|
|
||||||
// Enum
|
// Enum
|
||||||
AssetType mediaType;
|
final AssetType mediaType;
|
||||||
|
|
||||||
SearchFilter({
|
const SearchFilter({
|
||||||
this.context,
|
this.context,
|
||||||
this.filename,
|
this.filename,
|
||||||
this.description,
|
this.description,
|
||||||
|
@ -6,7 +6,7 @@ class SearchResult {
|
|||||||
final List<Asset> assets;
|
final List<Asset> assets;
|
||||||
final int? nextPage;
|
final int? nextPage;
|
||||||
|
|
||||||
SearchResult({
|
const SearchResult({
|
||||||
required this.assets,
|
required this.assets,
|
||||||
this.nextPage,
|
this.nextPage,
|
||||||
});
|
});
|
||||||
|
@ -6,520 +6,49 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||||
import 'package:immich_mobile/interfaces/person_api.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/pages/search/search_body.dart';
|
||||||
import 'package:immich_mobile/providers/search/paginated_search.provider.dart';
|
import 'package:immich_mobile/providers/search/paginated_search.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/search/search_filters.provider.dart';
|
||||||
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
|
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart';
|
import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.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()
|
@RoutePage()
|
||||||
class SearchPage extends HookConsumerWidget {
|
class SearchPage extends HookConsumerWidget {
|
||||||
const SearchPage({super.key, this.prefilter});
|
|
||||||
|
|
||||||
final SearchFilter? prefilter;
|
final SearchFilter? prefilter;
|
||||||
|
|
||||||
|
const SearchPage({super.key, this.prefilter});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final textSearchType = useState<TextSearchType>(TextSearchType.context);
|
final textSearchType = useState<TextSearchType>(TextSearchType.context);
|
||||||
final searchHintText = useState<String>('contextual_search'.tr());
|
final searchHintText = useState<String>('contextual_search'.tr());
|
||||||
final textSearchController = useTextEditingController();
|
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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
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'.tr()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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'.tr()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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'.tr()).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'.tr(),
|
|
||||||
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'.tr(),
|
|
||||||
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'.tr(),
|
|
||||||
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'.tr(),
|
|
||||||
cancelText: 'action_common_cancel'.tr(),
|
|
||||||
confirmText: 'action_common_select'.tr(),
|
|
||||||
saveText: 'action_common_save'.tr(),
|
|
||||||
errorFormatText: 'invalid_date_format'.tr(),
|
|
||||||
errorInvalidText: 'invalid_date'.tr(),
|
|
||||||
fieldStartHintText: 'start_date'.tr(),
|
|
||||||
fieldEndHintText: 'end_date'.tr(),
|
|
||||||
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'.tr(
|
|
||||||
namedArgs: {
|
|
||||||
"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
|
|
||||||
? 'search_filter_media_type_image'.tr()
|
|
||||||
: assetType == AssetType.video
|
|
||||||
? 'search_filter_media_type_video'.tr()
|
|
||||||
: 'search_filter_media_type_all'.tr(),
|
|
||||||
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'.tr(),
|
|
||||||
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'.tr());
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case DisplayOption.archive:
|
|
||||||
filter.value = filter.value.copyWith(
|
|
||||||
display: filter.value.display.copyWith(
|
|
||||||
isArchive: value,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (value) {
|
|
||||||
filterText.add('search_filter_display_option_archive'.tr());
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case DisplayOption.favorite:
|
|
||||||
filter.value = filter.value.copyWith(
|
|
||||||
display: filter.value.display.copyWith(
|
|
||||||
isFavorite: value,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (value) {
|
|
||||||
filterText.add('search_filter_display_option_favorite'.tr());
|
|
||||||
}
|
|
||||||
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: 'search_filter_display_options_title'.tr(),
|
|
||||||
onSearch: search,
|
|
||||||
onClear: handleClear,
|
|
||||||
child: DisplayOptionPicker(
|
|
||||||
onSelect: handleOnSelect,
|
|
||||||
filter: filter.value.display,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleTextSubmitted(String value) {
|
handleTextSubmitted(String value) {
|
||||||
switch (textSearchType.value) {
|
final filter = ref.read(searchFiltersProvider);
|
||||||
case TextSearchType.context:
|
ref.read(searchFiltersProvider.notifier).value =
|
||||||
filter.value = filter.value.copyWith(
|
switch (textSearchType.value) {
|
||||||
|
TextSearchType.context => filter.copyWith(
|
||||||
filename: '',
|
filename: '',
|
||||||
context: value,
|
context: value,
|
||||||
description: '',
|
description: '',
|
||||||
);
|
),
|
||||||
|
TextSearchType.filename => filter.copyWith(
|
||||||
break;
|
|
||||||
case TextSearchType.filename:
|
|
||||||
filter.value = filter.value.copyWith(
|
|
||||||
filename: value,
|
filename: value,
|
||||||
context: '',
|
context: '',
|
||||||
description: '',
|
description: '',
|
||||||
);
|
),
|
||||||
|
TextSearchType.description => filter.copyWith(
|
||||||
break;
|
|
||||||
case TextSearchType.description:
|
|
||||||
filter.value = filter.value.copyWith(
|
|
||||||
filename: '',
|
filename: '',
|
||||||
context: '',
|
context: '',
|
||||||
description: value,
|
description: value,
|
||||||
);
|
),
|
||||||
break;
|
};
|
||||||
}
|
ref.read(searchFiltersProvider.notifier).search();
|
||||||
|
|
||||||
search();
|
|
||||||
}
|
|
||||||
|
|
||||||
IconData getSearchPrefixIcon() {
|
|
||||||
switch (textSearchType.value) {
|
|
||||||
case TextSearchType.context:
|
|
||||||
return Icons.image_search_rounded;
|
|
||||||
case TextSearchType.filename:
|
|
||||||
return Icons.abc_rounded;
|
|
||||||
case TextSearchType.description:
|
|
||||||
return Icons.text_snippet_outlined;
|
|
||||||
default:
|
|
||||||
return Icons.search_rounded;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@ -530,16 +59,14 @@ class SearchPage extends HookConsumerWidget {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(right: 16.0),
|
padding: const EdgeInsets.only(right: 16.0),
|
||||||
child: MenuAnchor(
|
child: MenuAnchor(
|
||||||
style: MenuStyle(
|
style: const MenuStyle(
|
||||||
elevation: const WidgetStatePropertyAll(1),
|
elevation: WidgetStatePropertyAll(1),
|
||||||
shape: WidgetStateProperty.all(
|
shape: WidgetStatePropertyAll(
|
||||||
RoundedRectangleBorder(
|
RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.all(Radius.circular(24)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
padding: const WidgetStatePropertyAll(
|
padding: WidgetStatePropertyAll(EdgeInsets.all(4)),
|
||||||
EdgeInsets.all(4),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
builder: (
|
builder: (
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
@ -625,13 +152,13 @@ class SearchPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
title: Container(
|
title: DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: context.colorScheme.onSurface.withAlpha(0),
|
color: context.colorScheme.onSurface.withAlpha(0),
|
||||||
width: 0,
|
width: 0,
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: const BorderRadius.all(Radius.circular(24)),
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
colors: [
|
colors: [
|
||||||
context.colorScheme.primary.withOpacity(0.075),
|
context.colorScheme.primary.withOpacity(0.075),
|
||||||
@ -652,7 +179,7 @@ class SearchPage extends HookConsumerWidget {
|
|||||||
prefixIcon: prefilter != null
|
prefixIcon: prefilter != null
|
||||||
? null
|
? null
|
||||||
: Icon(
|
: Icon(
|
||||||
getSearchPrefixIcon(),
|
getSearchPrefixIcon(textSearchType.value),
|
||||||
color: context.colorScheme.primary,
|
color: context.colorScheme.primary,
|
||||||
),
|
),
|
||||||
hintText: searchHintText.value,
|
hintText: searchHintText.value,
|
||||||
@ -660,25 +187,20 @@ class SearchPage extends HookConsumerWidget {
|
|||||||
color: context.themeData.colorScheme.onSurfaceSecondary,
|
color: context.themeData.colorScheme.onSurfaceSecondary,
|
||||||
),
|
),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(25),
|
borderRadius: const BorderRadius.all(Radius.circular(25)),
|
||||||
borderSide: BorderSide(
|
borderSide: BorderSide(color: context.colorScheme.surfaceDim),
|
||||||
color: context.colorScheme.surfaceDim,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(25),
|
borderRadius: const BorderRadius.all(Radius.circular(25)),
|
||||||
borderSide: BorderSide(
|
borderSide:
|
||||||
color: context.colorScheme.surfaceContainer,
|
BorderSide(color: context.colorScheme.surfaceContainer),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
disabledBorder: OutlineInputBorder(
|
disabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(25),
|
borderRadius: const BorderRadius.all(Radius.circular(25)),
|
||||||
borderSide: BorderSide(
|
borderSide: BorderSide(color: context.colorScheme.surfaceDim),
|
||||||
color: context.colorScheme.surfaceDim,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(25),
|
borderRadius: const BorderRadius.all(Radius.circular(25)),
|
||||||
borderSide: BorderSide(
|
borderSide: BorderSide(
|
||||||
color: context.colorScheme.primary.withAlpha(100),
|
color: context.colorScheme.primary.withAlpha(100),
|
||||||
),
|
),
|
||||||
@ -690,72 +212,35 @@ class SearchPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
body: Column(
|
body: const SearchBody(),
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 12.0),
|
|
||||||
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_rounded,
|
|
||||||
onTap: showPeoplePicker,
|
|
||||||
label: 'search_filter_people'.tr(),
|
|
||||||
currentFilter: peopleCurrentFilterWidget.value,
|
|
||||||
),
|
|
||||||
SearchFilterChip(
|
|
||||||
icon: Icons.location_pin,
|
|
||||||
onTap: showLocationPicker,
|
|
||||||
label: 'search_filter_location'.tr(),
|
|
||||||
currentFilter: locationCurrentFilterWidget.value,
|
|
||||||
),
|
|
||||||
SearchFilterChip(
|
|
||||||
icon: Icons.camera_alt_rounded,
|
|
||||||
onTap: showCameraPicker,
|
|
||||||
label: 'search_filter_camera'.tr(),
|
|
||||||
currentFilter: cameraCurrentFilterWidget.value,
|
|
||||||
),
|
|
||||||
SearchFilterChip(
|
|
||||||
icon: Icons.date_range_rounded,
|
|
||||||
onTap: showDatePicker,
|
|
||||||
label: 'search_filter_date'.tr(),
|
|
||||||
currentFilter: dateRangeCurrentFilterWidget.value,
|
|
||||||
),
|
|
||||||
SearchFilterChip(
|
|
||||||
key: const Key('media_type_chip'),
|
|
||||||
icon: Icons.video_collection_outlined,
|
|
||||||
onTap: showMediaTypePicker,
|
|
||||||
label: 'search_filter_media_type'.tr(),
|
|
||||||
currentFilter: mediaTypeCurrentFilterWidget.value,
|
|
||||||
),
|
|
||||||
SearchFilterChip(
|
|
||||||
icon: Icons.display_settings_outlined,
|
|
||||||
onTap: showDisplayOptionPicker,
|
|
||||||
label: 'search_filter_display_options'.tr(),
|
|
||||||
currentFilter: displayOptionCurrentFilterWidget.value,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (isSearching.value)
|
|
||||||
const Expanded(
|
|
||||||
child: Center(child: CircularProgressIndicator.adaptive()),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
SearchResultGrid(
|
|
||||||
onScrollEnd: loadMoreSearchResult,
|
|
||||||
isSearching: isSearching.value,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SnackBar searchInfoSnackBar(
|
||||||
|
String message,
|
||||||
|
TextStyle? textStyle,
|
||||||
|
Color closeIconColor,
|
||||||
|
) {
|
||||||
|
return SnackBar(
|
||||||
|
content: Text(message, style: textStyle),
|
||||||
|
showCloseIcon: true,
|
||||||
|
behavior: SnackBarBehavior.fixed,
|
||||||
|
closeIconColor: closeIconColor,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData getSearchPrefixIcon(TextSearchType textSearchType) {
|
||||||
|
switch (textSearchType) {
|
||||||
|
case TextSearchType.context:
|
||||||
|
return Icons.image_search_rounded;
|
||||||
|
case TextSearchType.filename:
|
||||||
|
return Icons.abc_rounded;
|
||||||
|
case TextSearchType.description:
|
||||||
|
return Icons.text_snippet_outlined;
|
||||||
|
default:
|
||||||
|
return Icons.search_rounded;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class SearchResultGrid extends StatelessWidget {
|
class SearchResultGrid extends StatelessWidget {
|
||||||
@ -798,12 +283,15 @@ class SearchResultGrid extends StatelessWidget {
|
|||||||
editEnabled: true,
|
editEnabled: true,
|
||||||
favoriteEnabled: true,
|
favoriteEnabled: true,
|
||||||
stackEnabled: false,
|
stackEnabled: false,
|
||||||
emptyIndicator: Padding(
|
emptyIndicator: isSearching
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
? const Padding(
|
||||||
child: !isSearching
|
padding: EdgeInsets.symmetric(horizontal: 16.0),
|
||||||
? const SearchEmptyContent()
|
child: SizedBox.shrink(),
|
||||||
: const SizedBox.shrink(),
|
)
|
||||||
),
|
: const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 16.0),
|
||||||
|
child: SearchEmptyContent(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -822,14 +310,19 @@ class SearchEmptyContent extends StatelessWidget {
|
|||||||
shrinkWrap: false,
|
shrinkWrap: false,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 40),
|
const SizedBox(height: 40),
|
||||||
Center(
|
context.isDarkTheme
|
||||||
child: Image.asset(
|
? const Center(
|
||||||
context.isDarkTheme
|
child: Image(
|
||||||
? 'assets/polaroid-dark.png'
|
image: AssetImage('assets/polaroid-dark.png'),
|
||||||
: 'assets/polaroid-light.png',
|
height: 125,
|
||||||
height: 125,
|
),
|
||||||
),
|
)
|
||||||
),
|
: const Center(
|
||||||
|
child: Image(
|
||||||
|
image: AssetImage('assets/polaroid-light.png'),
|
||||||
|
height: 125,
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Center(
|
Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
@ -850,9 +343,9 @@ class QuickLinkList extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: context.colorScheme.outline.withAlpha(10),
|
color: context.colorScheme.outline.withAlpha(10),
|
||||||
width: 1,
|
width: 1,
|
||||||
@ -912,21 +405,34 @@ class QuickLink extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final borderRadius = BorderRadius.only(
|
final shape = switch ((isTop, isBottom)) {
|
||||||
topLeft: Radius.circular(isTop ? 20 : 0),
|
(true, false) => const RoundedRectangleBorder(
|
||||||
topRight: Radius.circular(isTop ? 20 : 0),
|
borderRadius: BorderRadius.only(
|
||||||
bottomLeft: Radius.circular(isBottom ? 20 : 0),
|
topLeft: Radius.circular(20),
|
||||||
bottomRight: Radius.circular(isBottom ? 20 : 0),
|
topRight: Radius.circular(20),
|
||||||
);
|
),
|
||||||
|
),
|
||||||
|
(false, true) => const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
bottomLeft: Radius.circular(20),
|
||||||
|
bottomRight: Radius.circular(20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(true, true) => const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
topLeft: Radius.circular(20),
|
||||||
|
topRight: Radius.circular(20),
|
||||||
|
bottomLeft: Radius.circular(20),
|
||||||
|
bottomRight: Radius.circular(20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(false, false) =>
|
||||||
|
const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
|
||||||
|
};
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
shape: RoundedRectangleBorder(
|
shape: shape,
|
||||||
borderRadius: borderRadius,
|
leading: Icon(icon, size: 26),
|
||||||
),
|
|
||||||
leading: Icon(
|
|
||||||
icon,
|
|
||||||
size: 26,
|
|
||||||
),
|
|
||||||
title: Text(
|
title: Text(
|
||||||
title,
|
title,
|
||||||
style: context.textTheme.titleSmall?.copyWith(
|
style: context.textTheme.titleSmall?.copyWith(
|
||||||
|
306
mobile/lib/pages/search/search_body.dart
Normal file
306
mobile/lib/pages/search/search_body.dart
Normal file
@ -0,0 +1,306 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/pages/search/show_camera_picker.dart';
|
||||||
|
import 'package:immich_mobile/pages/search/show_date_picker.dart';
|
||||||
|
import 'package:immich_mobile/pages/search/show_display_option_picker.dart';
|
||||||
|
import 'package:immich_mobile/pages/search/show_location_picker.dart';
|
||||||
|
import 'package:immich_mobile/pages/search/show_media_type_picker.dart';
|
||||||
|
import 'package:immich_mobile/pages/search/show_people_picker.dart';
|
||||||
|
import 'package:immich_mobile/providers/search/is_searching.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/search/paginated_search.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/search/search_filters.provider.dart';
|
||||||
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart';
|
||||||
|
|
||||||
|
class SearchBody extends HookConsumerWidget {
|
||||||
|
const SearchBody({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final isSearching = ref.watch(isSearchingProvider);
|
||||||
|
|
||||||
|
loadMoreSearchResult() async {
|
||||||
|
final filter = ref.read(searchFiltersProvider);
|
||||||
|
final hasResult =
|
||||||
|
await ref.read(paginatedSearchProvider.notifier).search(filter);
|
||||||
|
|
||||||
|
if (!hasResult) {
|
||||||
|
context.showSnackBar(
|
||||||
|
searchInfoSnackBar(
|
||||||
|
'search_no_more_result'.tr(),
|
||||||
|
context.textTheme.labelLarge,
|
||||||
|
context.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickers = Padding(
|
||||||
|
padding: EdgeInsets.only(top: 12.0),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 50,
|
||||||
|
child: ListView.custom(
|
||||||
|
key: Key('search_filter_chip_list'),
|
||||||
|
shrinkWrap: true,
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
childrenDelegate: SliverChildListDelegate.fixed(
|
||||||
|
[
|
||||||
|
ShowPeoplePicker(),
|
||||||
|
ShowLocationPicker(),
|
||||||
|
ShowCameraPicker(),
|
||||||
|
ShowDatePicker(),
|
||||||
|
ShowMediaTypePicker(),
|
||||||
|
ShowDisplayOptionsPicker(),
|
||||||
|
],
|
||||||
|
addAutomaticKeepAlives: true,
|
||||||
|
addRepaintBoundaries: true,
|
||||||
|
addSemanticIndexes: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: extend render list without discarding the existing result grid
|
||||||
|
return isSearching
|
||||||
|
? const Column(
|
||||||
|
children: [
|
||||||
|
pickers,
|
||||||
|
Expanded(
|
||||||
|
child: Center(child: CircularProgressIndicator.adaptive()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
children: [
|
||||||
|
pickers,
|
||||||
|
SearchResultGrid(onScrollEnd: loadMoreSearchResult),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
SnackBar searchInfoSnackBar(
|
||||||
|
String message,
|
||||||
|
TextStyle? textStyle,
|
||||||
|
Color closeIconColor,
|
||||||
|
) {
|
||||||
|
return SnackBar(
|
||||||
|
content: Text(message, style: textStyle),
|
||||||
|
showCloseIcon: true,
|
||||||
|
behavior: SnackBarBehavior.fixed,
|
||||||
|
closeIconColor: closeIconColor,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData getSearchPrefixIcon(TextSearchType textSearchType) {
|
||||||
|
switch (textSearchType) {
|
||||||
|
case TextSearchType.context:
|
||||||
|
return Icons.image_search_rounded;
|
||||||
|
case TextSearchType.filename:
|
||||||
|
return Icons.abc_rounded;
|
||||||
|
case TextSearchType.description:
|
||||||
|
return Icons.text_snippet_outlined;
|
||||||
|
default:
|
||||||
|
return Icons.search_rounded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SearchResultGrid extends StatelessWidget {
|
||||||
|
final VoidCallback onScrollEnd;
|
||||||
|
|
||||||
|
const SearchResultGrid({
|
||||||
|
super.key,
|
||||||
|
required this.onScrollEnd,
|
||||||
|
});
|
||||||
|
|
||||||
|
@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: const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 16.0),
|
||||||
|
child: SearchEmptyContent(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SearchEmptyContent extends StatelessWidget {
|
||||||
|
const SearchEmptyContent({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return NotificationListener<ScrollNotification>(
|
||||||
|
onNotification: (_) => true,
|
||||||
|
child: ListView(
|
||||||
|
shrinkWrap: false,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 40),
|
||||||
|
context.isDarkTheme
|
||||||
|
? const Center(
|
||||||
|
child: Image(
|
||||||
|
image: AssetImage('assets/polaroid-dark.png'),
|
||||||
|
height: 125,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Center(
|
||||||
|
child: Image(
|
||||||
|
image: AssetImage('assets/polaroid-light.png'),
|
||||||
|
height: 125,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Center(
|
||||||
|
child: Text(
|
||||||
|
'search_page_search_photos_videos'.tr(),
|
||||||
|
style: context.textTheme.labelLarge,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
const QuickLinkList(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class QuickLinkList extends StatelessWidget {
|
||||||
|
const QuickLinkList({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return DecoratedBox(
|
||||||
|
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_added'.tr(),
|
||||||
|
icon: const Icon(Icons.schedule_outlined, size: 26),
|
||||||
|
isTop: true,
|
||||||
|
onTap: () => context.pushRoute(const RecentlyAddedRoute()),
|
||||||
|
),
|
||||||
|
QuickLink(
|
||||||
|
title: 'videos'.tr(),
|
||||||
|
icon: const Icon(Icons.play_circle_outline_rounded, size: 26),
|
||||||
|
onTap: () => context.pushRoute(const AllVideosRoute()),
|
||||||
|
),
|
||||||
|
QuickLink(
|
||||||
|
title: 'favorites'.tr(),
|
||||||
|
icon: const Icon(Icons.favorite_border_rounded, size: 26),
|
||||||
|
isBottom: true,
|
||||||
|
onTap: () => context.pushRoute(const FavoritesRoute()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class QuickLink extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final Icon icon;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final bool isTop;
|
||||||
|
final bool isBottom;
|
||||||
|
|
||||||
|
const QuickLink({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.icon,
|
||||||
|
required this.onTap,
|
||||||
|
this.isTop = false,
|
||||||
|
this.isBottom = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final shape = switch ((isTop, isBottom)) {
|
||||||
|
(true, false) => const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
topLeft: Radius.circular(20),
|
||||||
|
topRight: Radius.circular(20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(false, true) => const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
bottomLeft: Radius.circular(20),
|
||||||
|
bottomRight: Radius.circular(20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(true, true) => const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
topLeft: Radius.circular(20),
|
||||||
|
topRight: Radius.circular(20),
|
||||||
|
bottomLeft: Radius.circular(20),
|
||||||
|
bottomRight: Radius.circular(20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(false, false) =>
|
||||||
|
const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
|
||||||
|
};
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
shape: shape,
|
||||||
|
leading: icon,
|
||||||
|
title: Text(
|
||||||
|
title,
|
||||||
|
style:
|
||||||
|
context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
onTap: onTap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
64
mobile/lib/pages/search/show_camera_picker.dart
Normal file
64
mobile/lib/pages/search/show_camera_picker.dart
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
||||||
|
import 'package:immich_mobile/providers/search/is_searching.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/search/search_filters.provider.dart';
|
||||||
|
import 'package:immich_mobile/widgets/search/search_filter/camera_picker.dart';
|
||||||
|
import 'package:immich_mobile/widgets/search/search_filter/filter_bottom_sheet_scaffold.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';
|
||||||
|
|
||||||
|
class ShowCameraPicker extends ConsumerWidget {
|
||||||
|
const ShowCameraPicker({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final filter = ref.watch(searchFiltersProvider);
|
||||||
|
|
||||||
|
showCameraPicker() {
|
||||||
|
handleOnSelect(Map<String, String?> value) {
|
||||||
|
ref.read(searchFiltersProvider.notifier).camera = SearchCameraFilter(
|
||||||
|
make: value['make'],
|
||||||
|
model: value['model'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClear() {
|
||||||
|
ref.read(searchFiltersProvider.notifier).value = filter.copyWith(
|
||||||
|
camera: const SearchCameraFilter(),
|
||||||
|
);
|
||||||
|
ref.read(searchFiltersProvider.notifier).search();
|
||||||
|
}
|
||||||
|
|
||||||
|
showFilterBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
isDismissible: true,
|
||||||
|
child: FilterBottomSheetScaffold(
|
||||||
|
title: 'search_filter_camera_title'.tr(),
|
||||||
|
onSearch: () => ref.read(isSearchingProvider.notifier).value = true,
|
||||||
|
onClear: handleClear,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: CameraPicker(
|
||||||
|
onSelect: handleOnSelect,
|
||||||
|
filter: filter.camera,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SearchFilterChip(
|
||||||
|
icon: Icons.camera_alt_rounded,
|
||||||
|
onTap: showCameraPicker,
|
||||||
|
label: 'search_filter_camera'.tr(),
|
||||||
|
currentFilter: Text(
|
||||||
|
'${filter.camera.make ?? ''} ${filter.camera.model ?? ''}',
|
||||||
|
style: context.textTheme.labelLarge,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
88
mobile/lib/pages/search/show_date_picker.dart
Normal file
88
mobile/lib/pages/search/show_date_picker.dart
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
||||||
|
import 'package:immich_mobile/providers/search/search_filters.provider.dart';
|
||||||
|
import 'package:immich_mobile/widgets/search/search_filter/search_filter_chip.dart';
|
||||||
|
|
||||||
|
class ShowDatePicker extends ConsumerWidget {
|
||||||
|
const ShowDatePicker({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final date =
|
||||||
|
ref.watch(searchFiltersProvider.select((filters) => filters.date));
|
||||||
|
|
||||||
|
showDatePicker() async {
|
||||||
|
final firstDate = DateTime(1900);
|
||||||
|
final lastDate = DateTime.now();
|
||||||
|
|
||||||
|
final dateRange = await showDateRangePicker(
|
||||||
|
context: context,
|
||||||
|
firstDate: firstDate,
|
||||||
|
lastDate: lastDate,
|
||||||
|
currentDate: DateTime.now(),
|
||||||
|
initialDateRange: DateTimeRange(
|
||||||
|
start: date.takenAfter ?? lastDate,
|
||||||
|
end: date.takenBefore ?? lastDate,
|
||||||
|
),
|
||||||
|
helpText: 'search_filter_date_title'.tr(),
|
||||||
|
cancelText: 'action_common_cancel'.tr(),
|
||||||
|
confirmText: 'action_common_select'.tr(),
|
||||||
|
saveText: 'action_common_save'.tr(),
|
||||||
|
errorFormatText: 'invalid_date_format'.tr(),
|
||||||
|
errorInvalidText: 'invalid_date'.tr(),
|
||||||
|
fieldStartHintText: 'start_date'.tr(),
|
||||||
|
fieldEndHintText: 'end_date'.tr(),
|
||||||
|
initialEntryMode: DatePickerEntryMode.calendar,
|
||||||
|
keyboardType: TextInputType.text,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (dateRange == null) {
|
||||||
|
ref.read(searchFiltersProvider.notifier).date =
|
||||||
|
const SearchDateFilter();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.read(searchFiltersProvider.notifier).date = SearchDateFilter(
|
||||||
|
takenAfter: dateRange.start,
|
||||||
|
takenBefore: dateRange.end.add(
|
||||||
|
const Duration(
|
||||||
|
hours: 23,
|
||||||
|
minutes: 59,
|
||||||
|
seconds: 59,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
ref.read(searchFiltersProvider.notifier).search();
|
||||||
|
}
|
||||||
|
|
||||||
|
return SearchFilterChip(
|
||||||
|
icon: Icons.date_range_rounded,
|
||||||
|
onTap: showDatePicker,
|
||||||
|
label: 'search_filter_date'.tr(),
|
||||||
|
currentFilter: Text(
|
||||||
|
getFormattedText(date.takenAfter, date.takenBefore),
|
||||||
|
style: context.textTheme.labelLarge,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String getFormattedText(DateTime? start, DateTime? end) {
|
||||||
|
if (start == null || end == null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (end.difference(start).inHours < 24) {
|
||||||
|
return DateFormat.yMMMd().format(start.toLocal());
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'search_filter_date_interval'.tr(
|
||||||
|
namedArgs: {
|
||||||
|
"start": DateFormat.yMMMd().format(start.toLocal()),
|
||||||
|
"end": DateFormat.yMMMd().format(end.toLocal()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
75
mobile/lib/pages/search/show_display_option_picker.dart
Normal file
75
mobile/lib/pages/search/show_display_option_picker.dart
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
||||||
|
import 'package:immich_mobile/providers/search/is_searching.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/search/search_filters.provider.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/search_filter_chip.dart';
|
||||||
|
import 'package:immich_mobile/widgets/search/search_filter/search_filter_utils.dart';
|
||||||
|
|
||||||
|
class ShowDisplayOptionsPicker extends ConsumerWidget {
|
||||||
|
const ShowDisplayOptionsPicker({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final display =
|
||||||
|
ref.watch(searchFiltersProvider.select((filters) => filters.display));
|
||||||
|
|
||||||
|
showDisplayOptionPicker() {
|
||||||
|
handleOnSelect(Map<DisplayOption, bool> value) {
|
||||||
|
value.forEach((key, value) {
|
||||||
|
ref.read(searchFiltersProvider.notifier).display = switch (key) {
|
||||||
|
DisplayOption.notInAlbum => display.copyWith(isNotInAlbum: value),
|
||||||
|
DisplayOption.archive => display.copyWith(isArchive: value),
|
||||||
|
DisplayOption.favorite => display.copyWith(isFavorite: value),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClear() {
|
||||||
|
ref.read(searchFiltersProvider.notifier).display =
|
||||||
|
const SearchDisplayFilters(
|
||||||
|
isNotInAlbum: false,
|
||||||
|
isArchive: false,
|
||||||
|
isFavorite: false,
|
||||||
|
);
|
||||||
|
ref.read(searchFiltersProvider.notifier).search();
|
||||||
|
}
|
||||||
|
|
||||||
|
showFilterBottomSheet(
|
||||||
|
context: context,
|
||||||
|
child: FilterBottomSheetScaffold(
|
||||||
|
title: 'search_filter_display_options_title'.tr(),
|
||||||
|
onSearch: () => ref.read(isSearchingProvider.notifier).value = true,
|
||||||
|
onClear: handleClear,
|
||||||
|
child: DisplayOptionPicker(
|
||||||
|
onSelect: handleOnSelect,
|
||||||
|
filter: display,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SearchFilterChip(
|
||||||
|
icon: Icons.display_settings_outlined,
|
||||||
|
onTap: showDisplayOptionPicker,
|
||||||
|
label: 'search_filter_display_options'.tr(),
|
||||||
|
currentFilter: Text(
|
||||||
|
getFormattedText(display),
|
||||||
|
style: context.textTheme.labelLarge,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getFormattedText(SearchDisplayFilters display) {
|
||||||
|
return [
|
||||||
|
if (display.isNotInAlbum)
|
||||||
|
'search_filter_display_option_not_in_album'.tr(),
|
||||||
|
if (display.isArchive) 'search_filter_display_option_archive'.tr(),
|
||||||
|
if (display.isFavorite) 'search_filter_display_option_favorite'.tr(),
|
||||||
|
].join(', ');
|
||||||
|
}
|
||||||
|
}
|
82
mobile/lib/pages/search/show_location_picker.dart
Normal file
82
mobile/lib/pages/search/show_location_picker.dart
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
||||||
|
import 'package:immich_mobile/providers/search/is_searching.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/search/search_filters.provider.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/search_filter_chip.dart';
|
||||||
|
import 'package:immich_mobile/widgets/search/search_filter/search_filter_utils.dart';
|
||||||
|
|
||||||
|
class ShowLocationPicker extends ConsumerWidget {
|
||||||
|
const ShowLocationPicker({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final location =
|
||||||
|
ref.watch(searchFiltersProvider.select((filters) => filters.location));
|
||||||
|
|
||||||
|
showLocationPicker() {
|
||||||
|
handleOnSelect(Map<String, String?> value) {
|
||||||
|
ref.read(searchFiltersProvider.notifier).location =
|
||||||
|
SearchLocationFilter(
|
||||||
|
country: value['country'],
|
||||||
|
city: value['city'],
|
||||||
|
state: value['state'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClear() {
|
||||||
|
ref.read(searchFiltersProvider.notifier).location =
|
||||||
|
const SearchLocationFilter();
|
||||||
|
ref.read(searchFiltersProvider.notifier).search();
|
||||||
|
}
|
||||||
|
|
||||||
|
showFilterBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
isDismissible: true,
|
||||||
|
child: FilterBottomSheetScaffold(
|
||||||
|
title: 'search_filter_location_title'.tr(),
|
||||||
|
onSearch: () => ref.read(isSearchingProvider.notifier).value = true,
|
||||||
|
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: location,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SearchFilterChip(
|
||||||
|
icon: Icons.location_pin,
|
||||||
|
onTap: showLocationPicker,
|
||||||
|
label: 'search_filter_location'.tr(),
|
||||||
|
currentFilter: Text(
|
||||||
|
getFormattedText(location.city, location.state, location.country),
|
||||||
|
style: context.textTheme.labelLarge,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String getFormattedText(String? city, String? state, String? country) {
|
||||||
|
return [
|
||||||
|
if (city != null) city,
|
||||||
|
if (state != null) state,
|
||||||
|
if (country != null) country,
|
||||||
|
].join(', ');
|
||||||
|
}
|
||||||
|
}
|
66
mobile/lib/pages/search/show_media_type_picker.dart
Normal file
66
mobile/lib/pages/search/show_media_type_picker.dart
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/providers/search/is_searching.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/search/search_filters.provider.dart';
|
||||||
|
import 'package:immich_mobile/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart';
|
||||||
|
import 'package:immich_mobile/widgets/search/search_filter/media_type_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';
|
||||||
|
|
||||||
|
class ShowMediaTypePicker extends ConsumerWidget {
|
||||||
|
const ShowMediaTypePicker({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final mediaType =
|
||||||
|
ref.watch(searchFiltersProvider.select((filters) => filters.mediaType));
|
||||||
|
|
||||||
|
showMediaTypePicker() {
|
||||||
|
handleOnSelected(AssetType assetType) {
|
||||||
|
ref.read(searchFiltersProvider.notifier).mediaType = assetType;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClear() {
|
||||||
|
ref.read(searchFiltersProvider.notifier).mediaType = AssetType.other;
|
||||||
|
ref.read(searchFiltersProvider.notifier).search();
|
||||||
|
}
|
||||||
|
|
||||||
|
showFilterBottomSheet(
|
||||||
|
context: context,
|
||||||
|
child: FilterBottomSheetScaffold(
|
||||||
|
title: 'search_filter_media_type_title'.tr(),
|
||||||
|
onSearch: () => ref.read(isSearchingProvider.notifier).value = true,
|
||||||
|
onClear: handleClear,
|
||||||
|
child: MediaTypePicker(
|
||||||
|
onSelect: handleOnSelected,
|
||||||
|
filter: mediaType,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SearchFilterChip(
|
||||||
|
icon: Icons.video_collection_outlined,
|
||||||
|
onTap: showMediaTypePicker,
|
||||||
|
label: 'search_filter_media_type'.tr(),
|
||||||
|
currentFilter: Text(
|
||||||
|
getFormattedText(mediaType),
|
||||||
|
style: context.textTheme.labelLarge,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String getFormattedText(AssetType mediaType) {
|
||||||
|
switch (mediaType) {
|
||||||
|
case AssetType.image:
|
||||||
|
return 'search_filter_media_type_image'.tr();
|
||||||
|
case AssetType.video:
|
||||||
|
return 'search_filter_media_type_video'.tr();
|
||||||
|
default:
|
||||||
|
return 'search_filter_media_type_all'.tr();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
65
mobile/lib/pages/search/show_people_picker.dart
Normal file
65
mobile/lib/pages/search/show_people_picker.dart
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/interfaces/person_api.interface.dart';
|
||||||
|
import 'package:immich_mobile/providers/search/is_searching.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/search/search_filters.provider.dart';
|
||||||
|
import 'package:immich_mobile/widgets/search/search_filter/filter_bottom_sheet_scaffold.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';
|
||||||
|
|
||||||
|
class ShowPeoplePicker extends ConsumerWidget {
|
||||||
|
const ShowPeoplePicker({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final people =
|
||||||
|
ref.watch(searchFiltersProvider.select((filters) => filters.people));
|
||||||
|
|
||||||
|
showPeoplePicker() {
|
||||||
|
handleOnSelect(Set<Person> value) {
|
||||||
|
ref.read(searchFiltersProvider.notifier).people = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClear() {
|
||||||
|
ref.read(searchFiltersProvider.notifier).people = const {};
|
||||||
|
ref.read(searchFiltersProvider.notifier).search();
|
||||||
|
}
|
||||||
|
|
||||||
|
showFilterBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
child: FractionallySizedBox(
|
||||||
|
heightFactor: 0.8,
|
||||||
|
child: FilterBottomSheetScaffold(
|
||||||
|
title: 'search_filter_people_title'.tr(),
|
||||||
|
expanded: true,
|
||||||
|
onSearch: () => ref.read(isSearchingProvider.notifier).value = true,
|
||||||
|
onClear: handleClear,
|
||||||
|
child: PeoplePicker(
|
||||||
|
onSelect: handleOnSelect,
|
||||||
|
filter: people,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SearchFilterChip(
|
||||||
|
icon: Icons.people_alt_rounded,
|
||||||
|
onTap: showPeoplePicker,
|
||||||
|
label: 'search_filter_people'.tr(),
|
||||||
|
currentFilter: Text(
|
||||||
|
getFormattedText(people),
|
||||||
|
style: context.textTheme.labelLarge,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String getFormattedText(Set<Person> people) {
|
||||||
|
final noName = 'no_name'.tr();
|
||||||
|
return people.map((e) => e.name != '' ? e.name : noName).join(', ');
|
||||||
|
}
|
||||||
|
}
|
22
mobile/lib/providers/search/is_searching.provider.dart
Normal file
22
mobile/lib/providers/search/is_searching.provider.dart
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
|
/// Whether to display the video part of a motion photo
|
||||||
|
final isSearchingProvider = StateNotifierProvider<IsSearching, bool>((ref) {
|
||||||
|
return IsSearching(ref);
|
||||||
|
});
|
||||||
|
|
||||||
|
class IsSearching extends StateNotifier<bool> {
|
||||||
|
IsSearching(this.ref) : super(false);
|
||||||
|
|
||||||
|
final Ref ref;
|
||||||
|
|
||||||
|
bool get value => state;
|
||||||
|
|
||||||
|
set value(bool value) {
|
||||||
|
state = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
void toggle() {
|
||||||
|
state = !state;
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,6 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.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/search/is_searching.provider.dart';
|
||||||
import 'package:immich_mobile/services/timeline.service.dart';
|
import 'package:immich_mobile/services/timeline.service.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';
|
||||||
@ -10,36 +11,41 @@ part 'paginated_search.provider.g.dart';
|
|||||||
|
|
||||||
final paginatedSearchProvider =
|
final paginatedSearchProvider =
|
||||||
StateNotifierProvider<PaginatedSearchNotifier, SearchResult>(
|
StateNotifierProvider<PaginatedSearchNotifier, SearchResult>(
|
||||||
(ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)),
|
(ref) => PaginatedSearchNotifier(ref),
|
||||||
);
|
);
|
||||||
|
|
||||||
class PaginatedSearchNotifier extends StateNotifier<SearchResult> {
|
class PaginatedSearchNotifier extends StateNotifier<SearchResult> {
|
||||||
final SearchService _searchService;
|
final Ref ref;
|
||||||
|
|
||||||
PaginatedSearchNotifier(this._searchService)
|
PaginatedSearchNotifier(this.ref)
|
||||||
: super(SearchResult(assets: [], nextPage: 1));
|
: super(const SearchResult(assets: [], nextPage: 1));
|
||||||
|
|
||||||
Future<bool> search(SearchFilter filter) async {
|
Future<bool> search(SearchFilter filter) async {
|
||||||
if (state.nextPage == null) {
|
if (state.nextPage == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
final result = await _searchService.search(filter, state.nextPage!);
|
ref.read(isSearchingProvider.notifier).value = true;
|
||||||
|
try {
|
||||||
|
final result =
|
||||||
|
await ref.read(searchServiceProvider).search(filter, state.nextPage!);
|
||||||
|
if (result == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (result == null) {
|
state = SearchResult(
|
||||||
return false;
|
assets: [...state.assets, ...result.assets],
|
||||||
|
nextPage: result.nextPage,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
ref.read(isSearchingProvider.notifier).value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
state = SearchResult(
|
|
||||||
assets: [...state.assets, ...result.assets],
|
|
||||||
nextPage: result.nextPage,
|
|
||||||
);
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
state = SearchResult(assets: [], nextPage: 1);
|
state = const SearchResult(assets: [], nextPage: 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
81
mobile/lib/providers/search/search_filters.provider.dart
Normal file
81
mobile/lib/providers/search/search_filters.provider.dart
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
|
import 'package:immich_mobile/interfaces/person_api.interface.dart';
|
||||||
|
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
||||||
|
import 'package:immich_mobile/providers/search/is_searching.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/search/paginated_search.provider.dart';
|
||||||
|
|
||||||
|
final searchFiltersProvider =
|
||||||
|
StateNotifierProvider<SearchFilterNotifier, SearchFilter>((ref) {
|
||||||
|
return SearchFilterNotifier(ref);
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchFiltersDefault = SearchFilter(
|
||||||
|
people: {},
|
||||||
|
location: SearchLocationFilter(),
|
||||||
|
camera: SearchCameraFilter(),
|
||||||
|
date: SearchDateFilter(),
|
||||||
|
display: SearchDisplayFilters(
|
||||||
|
isNotInAlbum: false,
|
||||||
|
isArchive: false,
|
||||||
|
isFavorite: false,
|
||||||
|
),
|
||||||
|
mediaType: AssetType.other,
|
||||||
|
);
|
||||||
|
|
||||||
|
class SearchFilterNotifier extends StateNotifier<SearchFilter> {
|
||||||
|
final Ref ref;
|
||||||
|
|
||||||
|
SearchFilterNotifier(this.ref) : super(searchFiltersDefault);
|
||||||
|
|
||||||
|
SearchFilter get value => state;
|
||||||
|
|
||||||
|
set value(SearchFilter value) {
|
||||||
|
state = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
void reset() {
|
||||||
|
state = searchFiltersDefault;
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<Person> get people => state.people;
|
||||||
|
|
||||||
|
SearchLocationFilter get location => state.location;
|
||||||
|
|
||||||
|
SearchCameraFilter get camera => state.camera;
|
||||||
|
|
||||||
|
SearchDateFilter get date => state.date;
|
||||||
|
|
||||||
|
SearchDisplayFilters get display => state.display;
|
||||||
|
|
||||||
|
AssetType get mediaType => state.mediaType;
|
||||||
|
|
||||||
|
set people(Set<Person> value) {
|
||||||
|
state = state.copyWith(people: value);
|
||||||
|
}
|
||||||
|
|
||||||
|
set location(SearchLocationFilter value) {
|
||||||
|
state = state.copyWith(location: value);
|
||||||
|
}
|
||||||
|
|
||||||
|
set camera(SearchCameraFilter value) {
|
||||||
|
state = state.copyWith(camera: value);
|
||||||
|
}
|
||||||
|
|
||||||
|
set date(SearchDateFilter value) {
|
||||||
|
state = state.copyWith(date: value);
|
||||||
|
}
|
||||||
|
|
||||||
|
set display(SearchDisplayFilters value) {
|
||||||
|
state = state.copyWith(display: value);
|
||||||
|
}
|
||||||
|
|
||||||
|
set mediaType(AssetType value) {
|
||||||
|
state = state.copyWith(mediaType: value);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> search() {
|
||||||
|
ref.read(paginatedSearchProvider.notifier).clear();
|
||||||
|
return ref.read(paginatedSearchProvider.notifier).search(state);
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user