mirror of
https://github.com/immich-app/immich.git
synced 2024-12-29 11:24:37 +02:00
564 lines
17 KiB
Dart
564 lines
17 KiB
Dart
|
import 'dart:async';
|
||
|
|
||
|
import 'package:auto_route/auto_route.dart';
|
||
|
import 'package:flutter/material.dart';
|
||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||
|
import 'package:immich_mobile/modules/search/models/search_filter.dart';
|
||
|
import 'package:immich_mobile/modules/search/providers/paginated_search.provider.dart';
|
||
|
import 'package:immich_mobile/modules/search/ui/search_filter/camera_picker.dart';
|
||
|
import 'package:immich_mobile/modules/search/ui/search_filter/display_option_picker.dart';
|
||
|
import 'package:immich_mobile/modules/search/ui/search_filter/filter_bottom_sheet_scaffold.dart';
|
||
|
import 'package:immich_mobile/modules/search/ui/search_filter/location_picker.dart';
|
||
|
import 'package:immich_mobile/modules/search/ui/search_filter/media_type_picker.dart';
|
||
|
import 'package:immich_mobile/modules/search/ui/search_filter/people_picker.dart';
|
||
|
import 'package:immich_mobile/modules/search/ui/search_filter/search_filter_chip.dart';
|
||
|
import 'package:immich_mobile/modules/search/ui/search_filter/search_filter_utils.dart';
|
||
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||
|
import 'package:immich_mobile/shared/ui/asset_grid/multiselect_grid.dart';
|
||
|
import 'package:openapi/api.dart';
|
||
|
|
||
|
@RoutePage()
|
||
|
class SearchInputPage extends HookConsumerWidget {
|
||
|
const SearchInputPage({super.key, this.prefilter});
|
||
|
|
||
|
final SearchFilter? prefilter;
|
||
|
|
||
|
@override
|
||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||
|
final isContextualSearch = useState(true);
|
||
|
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(filter.value);
|
||
|
|
||
|
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 currentPage = useState(1);
|
||
|
final searchProvider = ref.watch(paginatedSearchProvider);
|
||
|
final searchResultCount = useState(0);
|
||
|
|
||
|
search() async {
|
||
|
if (prefilter == null && filter.value == previousFilter.value) return;
|
||
|
|
||
|
ref.watch(paginatedSearchProvider.notifier).clear();
|
||
|
|
||
|
currentPage.value = 1;
|
||
|
|
||
|
final searchResult = await ref
|
||
|
.watch(paginatedSearchProvider.notifier)
|
||
|
.getNextPage(filter.value, currentPage.value);
|
||
|
previousFilter.value = filter.value;
|
||
|
|
||
|
searchResultCount.value = searchResult.length;
|
||
|
}
|
||
|
|
||
|
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(
|
||
|
() {
|
||
|
searchPrefilter();
|
||
|
return null;
|
||
|
},
|
||
|
[],
|
||
|
);
|
||
|
|
||
|
loadMoreSearchResult() async {
|
||
|
currentPage.value += 1;
|
||
|
final searchResult = await ref
|
||
|
.watch(paginatedSearchProvider.notifier)
|
||
|
.getNextPage(filter.value, currentPage.value);
|
||
|
searchResultCount.value = searchResult.length;
|
||
|
}
|
||
|
|
||
|
showPeoplePicker() {
|
||
|
handleOnSelect(Set<PersonResponseDto> value) {
|
||
|
filter.value = filter.value.copyWith(
|
||
|
people: value,
|
||
|
);
|
||
|
|
||
|
peopleCurrentFilterWidget.value = Text(
|
||
|
value.map((e) => e.name != '' ? e.name : "No name").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: 'Select people',
|
||
|
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: false,
|
||
|
child: FilterBottomSheetScaffold(
|
||
|
title: 'Select location',
|
||
|
onSearch: search,
|
||
|
onClear: handleClear,
|
||
|
child: Padding(
|
||
|
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||
|
child: Container(
|
||
|
padding: EdgeInsets.only(
|
||
|
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||
|
),
|
||
|
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: false,
|
||
|
child: FilterBottomSheetScaffold(
|
||
|
title: 'Select camera type',
|
||
|
onSearch: search,
|
||
|
onClear: handleClear,
|
||
|
child: Padding(
|
||
|
padding: const EdgeInsets.symmetric(vertical: 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: 'Select a date range',
|
||
|
cancelText: 'Cancel',
|
||
|
confirmText: 'Select',
|
||
|
saveText: 'Save',
|
||
|
errorFormatText: 'Invalid date format',
|
||
|
errorInvalidText: 'Invalid date',
|
||
|
fieldStartHintText: 'Start date',
|
||
|
fieldEndHintText: 'End date',
|
||
|
initialEntryMode: DatePickerEntryMode.input,
|
||
|
);
|
||
|
|
||
|
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(
|
||
|
date.start.toLocal().toIso8601String().split('T').first,
|
||
|
style: context.textTheme.labelLarge,
|
||
|
);
|
||
|
} else {
|
||
|
dateRangeCurrentFilterWidget.value = Text(
|
||
|
'${date.start.toLocal().toIso8601String().split('T').first} to ${date.end.toLocal().toIso8601String().split('T').first}',
|
||
|
style: context.textTheme.labelLarge,
|
||
|
);
|
||
|
}
|
||
|
|
||
|
search();
|
||
|
}
|
||
|
|
||
|
// MEDIA PICKER
|
||
|
showMediaTypePicker() {
|
||
|
handleOnSelected(AssetType assetType) {
|
||
|
filter.value = filter.value.copyWith(
|
||
|
mediaType: assetType,
|
||
|
);
|
||
|
|
||
|
mediaTypeCurrentFilterWidget.value = Text(
|
||
|
assetType == AssetType.image ? 'Image' : 'Video',
|
||
|
style: context.textTheme.labelLarge,
|
||
|
);
|
||
|
}
|
||
|
|
||
|
handleClear() {
|
||
|
filter.value = filter.value.copyWith(
|
||
|
mediaType: AssetType.other,
|
||
|
);
|
||
|
|
||
|
mediaTypeCurrentFilterWidget.value = null;
|
||
|
search();
|
||
|
}
|
||
|
|
||
|
showFilterBottomSheet(
|
||
|
context: context,
|
||
|
child: FilterBottomSheetScaffold(
|
||
|
title: 'Select media type',
|
||
|
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('Not in album');
|
||
|
break;
|
||
|
case DisplayOption.archive:
|
||
|
filter.value = filter.value.copyWith(
|
||
|
display: filter.value.display.copyWith(
|
||
|
isArchive: value,
|
||
|
),
|
||
|
);
|
||
|
if (value) filterText.add('Archive');
|
||
|
break;
|
||
|
case DisplayOption.favorite:
|
||
|
filter.value = filter.value.copyWith(
|
||
|
display: filter.value.display.copyWith(
|
||
|
isFavorite: value,
|
||
|
),
|
||
|
);
|
||
|
if (value) filterText.add('Favorite');
|
||
|
break;
|
||
|
}
|
||
|
});
|
||
|
|
||
|
displayOptionCurrentFilterWidget.value = Text(
|
||
|
filterText.join(', '),
|
||
|
style: context.textTheme.labelLarge,
|
||
|
);
|
||
|
}
|
||
|
|
||
|
handleClear() {
|
||
|
filter.value = filter.value.copyWith(
|
||
|
display: SearchDisplayFilters(
|
||
|
isNotInAlbum: false,
|
||
|
isArchive: false,
|
||
|
isFavorite: false,
|
||
|
),
|
||
|
);
|
||
|
|
||
|
displayOptionCurrentFilterWidget.value = null;
|
||
|
search();
|
||
|
}
|
||
|
|
||
|
showFilterBottomSheet(
|
||
|
context: context,
|
||
|
child: FilterBottomSheetScaffold(
|
||
|
title: 'Display options',
|
||
|
onSearch: search,
|
||
|
onClear: handleClear,
|
||
|
child: DisplayOptionPicker(
|
||
|
onSelect: handleOnSelect,
|
||
|
filter: filter.value.display,
|
||
|
),
|
||
|
),
|
||
|
);
|
||
|
}
|
||
|
|
||
|
handleTextSubmitted(String value) {
|
||
|
if (isContextualSearch.value) {
|
||
|
filter.value = filter.value.copyWith(
|
||
|
context: value,
|
||
|
filename: null,
|
||
|
);
|
||
|
} else {
|
||
|
filter.value = filter.value.copyWith(filename: value, context: null);
|
||
|
}
|
||
|
|
||
|
search();
|
||
|
}
|
||
|
|
||
|
buildSearchResult() {
|
||
|
return switch (searchProvider) {
|
||
|
AsyncData() => Expanded(
|
||
|
child: Padding(
|
||
|
padding: const EdgeInsets.only(top: 8.0),
|
||
|
child: NotificationListener<ScrollEndNotification>(
|
||
|
onNotification: (notification) {
|
||
|
final metrics = notification.metrics;
|
||
|
final shouldLoadMore = searchResultCount.value > 75;
|
||
|
if (metrics.pixels >= metrics.maxScrollExtent &&
|
||
|
shouldLoadMore) {
|
||
|
loadMoreSearchResult();
|
||
|
}
|
||
|
return true;
|
||
|
},
|
||
|
child: MultiselectGrid(
|
||
|
renderListProvider: paginatedSearchRenderListProvider,
|
||
|
archiveEnabled: true,
|
||
|
deleteEnabled: true,
|
||
|
editEnabled: true,
|
||
|
favoriteEnabled: true,
|
||
|
stackEnabled: false,
|
||
|
emptyIndicator: const SizedBox(),
|
||
|
),
|
||
|
),
|
||
|
),
|
||
|
),
|
||
|
AsyncError(:final error) => Text('Error: $error'),
|
||
|
_ => const Expanded(child: Center(child: CircularProgressIndicator())),
|
||
|
};
|
||
|
}
|
||
|
|
||
|
return Scaffold(
|
||
|
resizeToAvoidBottomInset: true,
|
||
|
appBar: AppBar(
|
||
|
automaticallyImplyLeading: true,
|
||
|
actions: [
|
||
|
IconButton(
|
||
|
icon: isContextualSearch.value
|
||
|
? const Icon(Icons.abc_rounded)
|
||
|
: const Icon(Icons.image_search_rounded),
|
||
|
onPressed: () {
|
||
|
isContextualSearch.value = !isContextualSearch.value;
|
||
|
textSearchController.clear();
|
||
|
},
|
||
|
),
|
||
|
],
|
||
|
leading: IconButton(
|
||
|
icon: const Icon(Icons.arrow_back_ios_new_rounded),
|
||
|
onPressed: () {
|
||
|
context.router.pop();
|
||
|
},
|
||
|
),
|
||
|
title: TextField(
|
||
|
controller: textSearchController,
|
||
|
decoration: InputDecoration(
|
||
|
hintText: isContextualSearch.value
|
||
|
? 'Sunrise on the beach'
|
||
|
: 'File name or extension',
|
||
|
hintStyle: context.textTheme.bodyLarge?.copyWith(
|
||
|
color: context.themeData.colorScheme.onSurface.withOpacity(0.75),
|
||
|
fontWeight: FontWeight.w500,
|
||
|
),
|
||
|
enabledBorder: const UnderlineInputBorder(
|
||
|
borderSide: BorderSide(color: Colors.transparent),
|
||
|
),
|
||
|
focusedBorder: const UnderlineInputBorder(
|
||
|
borderSide: BorderSide(color: Colors.transparent),
|
||
|
),
|
||
|
),
|
||
|
onSubmitted: handleTextSubmitted,
|
||
|
),
|
||
|
),
|
||
|
body: Column(
|
||
|
children: [
|
||
|
Padding(
|
||
|
padding: const EdgeInsets.only(top: 12.0),
|
||
|
child: SizedBox(
|
||
|
height: 50,
|
||
|
child: ListView(
|
||
|
shrinkWrap: true,
|
||
|
scrollDirection: Axis.horizontal,
|
||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||
|
children: [
|
||
|
SearchFilterChip(
|
||
|
icon: Icons.people_alt_rounded,
|
||
|
onTap: showPeoplePicker,
|
||
|
label: 'People',
|
||
|
currentFilter: peopleCurrentFilterWidget.value,
|
||
|
),
|
||
|
SearchFilterChip(
|
||
|
icon: Icons.location_pin,
|
||
|
onTap: showLocationPicker,
|
||
|
label: 'Location',
|
||
|
currentFilter: locationCurrentFilterWidget.value,
|
||
|
),
|
||
|
SearchFilterChip(
|
||
|
icon: Icons.camera_alt_rounded,
|
||
|
onTap: showCameraPicker,
|
||
|
label: 'Camera',
|
||
|
currentFilter: cameraCurrentFilterWidget.value,
|
||
|
),
|
||
|
SearchFilterChip(
|
||
|
icon: Icons.date_range_rounded,
|
||
|
onTap: showDatePicker,
|
||
|
label: 'Date',
|
||
|
currentFilter: dateRangeCurrentFilterWidget.value,
|
||
|
),
|
||
|
SearchFilterChip(
|
||
|
icon: Icons.video_collection_outlined,
|
||
|
onTap: showMediaTypePicker,
|
||
|
label: 'Media Type',
|
||
|
currentFilter: mediaTypeCurrentFilterWidget.value,
|
||
|
),
|
||
|
SearchFilterChip(
|
||
|
icon: Icons.display_settings_outlined,
|
||
|
onTap: showDisplayOptionPicker,
|
||
|
label: 'Display Options',
|
||
|
currentFilter: displayOptionCurrentFilterWidget.value,
|
||
|
),
|
||
|
],
|
||
|
),
|
||
|
),
|
||
|
),
|
||
|
buildSearchResult(),
|
||
|
],
|
||
|
),
|
||
|
);
|
||
|
}
|
||
|
}
|