split up search page

This commit is contained in:
mertalev 2025-03-05 19:06:04 -05:00
parent c110c9b00e
commit 2c7e6071f5
No known key found for this signature in database
GPG Key ID: 3A2B5BFC678DBC80
13 changed files with 1005 additions and 640 deletions

View File

@ -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,

View File

@ -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,
}); });

View File

@ -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(

View 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,
);
}
}

View 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,
),
);
}
}

View 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()),
},
);
}
}

View 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(', ');
}
}

View 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(', ');
}
}

View 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();
}
}
}

View 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(', ');
}
}

View 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;
}
}

View File

@ -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);
} }
} }

View 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);
}
}