feat(mobile): search enhancement (#8392)
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/modules/search/models/curated_content.dart';
|
||||
import 'package:immich_mobile/modules/search/models/search_filter.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/thumbnail_with_info.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
|
||||
@@ -57,7 +59,22 @@ class ExploreGrid extends StatelessWidget {
|
||||
),
|
||||
)
|
||||
: context.pushRoute(
|
||||
SearchResultRoute(searchTerm: 'm:${content.label}'),
|
||||
SearchInputRoute(
|
||||
prefilter: SearchFilter(
|
||||
people: {},
|
||||
location: SearchLocationFilter(
|
||||
city: content.label,
|
||||
),
|
||||
camera: SearchCameraFilter(),
|
||||
date: SearchDateFilter(),
|
||||
display: SearchDisplayFilters(
|
||||
isNotInAlbum: false,
|
||||
isArchive: false,
|
||||
isFavorite: false,
|
||||
),
|
||||
mediaType: AssetType.other,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
|
||||
|
||||
class ImmichSearchBar extends HookConsumerWidget
|
||||
implements PreferredSizeWidget {
|
||||
const ImmichSearchBar({
|
||||
super.key,
|
||||
required this.searchFocusNode,
|
||||
required this.onSubmitted,
|
||||
});
|
||||
|
||||
final FocusNode searchFocusNode;
|
||||
final Function(String) onSubmitted;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final searchTermController = useTextEditingController(text: "");
|
||||
final isSearchEnabled = ref.watch(searchPageStateProvider).isSearchEnabled;
|
||||
|
||||
focusSearch() {
|
||||
searchTermController.clear();
|
||||
ref.watch(searchPageStateProvider.notifier).getSuggestedSearchTerms();
|
||||
ref.watch(searchPageStateProvider.notifier).enableSearch();
|
||||
ref.watch(searchPageStateProvider.notifier).setSearchTerm("");
|
||||
|
||||
searchFocusNode.requestFocus();
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
searchFocusNotifier.addListener(focusSearch);
|
||||
return () {
|
||||
searchFocusNotifier.removeListener(focusSearch);
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
leading: isSearchEnabled
|
||||
? IconButton(
|
||||
onPressed: () {
|
||||
searchFocusNode.unfocus();
|
||||
ref.watch(searchPageStateProvider.notifier).disableSearch();
|
||||
searchTermController.clear();
|
||||
},
|
||||
icon: const Icon(Icons.arrow_back_ios_rounded),
|
||||
)
|
||||
: const Icon(
|
||||
Icons.search_rounded,
|
||||
size: 20,
|
||||
),
|
||||
title: TextField(
|
||||
controller: searchTermController,
|
||||
focusNode: searchFocusNode,
|
||||
autofocus: false,
|
||||
onTap: focusSearch,
|
||||
onSubmitted: (searchTerm) {
|
||||
onSubmitted(searchTerm);
|
||||
searchTermController.clear();
|
||||
ref.watch(searchPageStateProvider.notifier).setSearchTerm("");
|
||||
},
|
||||
onChanged: (value) {
|
||||
ref.watch(searchPageStateProvider.notifier).setSearchTerm(value);
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: 'search_bar_hint'.tr(),
|
||||
hintStyle: context.textTheme.bodyLarge?.copyWith(
|
||||
color: context.themeData.colorScheme.onSurface.withOpacity(0.75),
|
||||
),
|
||||
enabledBorder: const UnderlineInputBorder(
|
||||
borderSide: BorderSide(color: Colors.transparent),
|
||||
),
|
||||
focusedBorder: const UnderlineInputBorder(
|
||||
borderSide: BorderSide(color: Colors.transparent),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
}
|
||||
|
||||
// Used to focus search from outside this widget.
|
||||
// For example when double pressing the search nav icon.
|
||||
final searchFocusNotifier = SearchFocusNotifier();
|
||||
|
||||
class SearchFocusNotifier with ChangeNotifier {
|
||||
void requestFocus() {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
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/search_filter.provider.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class CameraPicker extends HookConsumerWidget {
|
||||
const CameraPicker({super.key, required this.onSelect, this.filter});
|
||||
|
||||
final Function(Map<String, String?>) onSelect;
|
||||
final SearchCameraFilter? filter;
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final makeTextController = useTextEditingController(text: filter?.make);
|
||||
final modelTextController = useTextEditingController(text: filter?.model);
|
||||
final selectedMake = useState<String?>(filter?.make);
|
||||
final selectedModel = useState<String?>(filter?.model);
|
||||
|
||||
final make = ref.watch(
|
||||
getSearchSuggestionsProvider(
|
||||
SearchSuggestionType.cameraMake,
|
||||
),
|
||||
);
|
||||
|
||||
final models = ref.watch(
|
||||
getSearchSuggestionsProvider(
|
||||
SearchSuggestionType.cameraModel,
|
||||
make: selectedMake.value,
|
||||
),
|
||||
);
|
||||
|
||||
final inputDecorationTheme = InputDecorationTheme(
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
contentPadding: const EdgeInsets.only(left: 16),
|
||||
);
|
||||
|
||||
final menuStyle = MenuStyle(
|
||||
shape: MaterialStatePropertyAll<OutlinedBorder>(
|
||||
RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.only(
|
||||
// bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
DropdownMenu(
|
||||
dropdownMenuEntries: switch (make) {
|
||||
AsyncError() => [],
|
||||
AsyncData(:final value) => value
|
||||
.map(
|
||||
(e) => DropdownMenuEntry(
|
||||
value: e,
|
||||
label: e,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
_ => [],
|
||||
},
|
||||
width: context.width * 0.45,
|
||||
menuHeight: 400,
|
||||
label: const Text('Make'),
|
||||
inputDecorationTheme: inputDecorationTheme,
|
||||
controller: makeTextController,
|
||||
menuStyle: menuStyle,
|
||||
leadingIcon: const Icon(Icons.photo_camera_rounded),
|
||||
trailingIcon: const Icon(Icons.arrow_drop_down_rounded),
|
||||
selectedTrailingIcon: const Icon(Icons.arrow_drop_up_rounded),
|
||||
onSelected: (value) {
|
||||
selectedMake.value = value.toString();
|
||||
onSelect({
|
||||
'make': selectedMake.value,
|
||||
'model': selectedModel.value,
|
||||
});
|
||||
},
|
||||
),
|
||||
DropdownMenu(
|
||||
dropdownMenuEntries: switch (models) {
|
||||
AsyncError() => [],
|
||||
AsyncData(:final value) => value
|
||||
.map(
|
||||
(e) => DropdownMenuEntry(
|
||||
value: e,
|
||||
label: e,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
_ => [],
|
||||
},
|
||||
width: context.width * 0.45,
|
||||
menuHeight: 400,
|
||||
label: const Text('Model'),
|
||||
inputDecorationTheme: inputDecorationTheme,
|
||||
controller: modelTextController,
|
||||
menuStyle: menuStyle,
|
||||
leadingIcon: const Icon(Icons.camera),
|
||||
trailingIcon: const Icon(Icons.arrow_drop_down_rounded),
|
||||
selectedTrailingIcon: const Icon(Icons.arrow_drop_up_rounded),
|
||||
onSelected: (value) {
|
||||
selectedModel.value = value.toString();
|
||||
onSelect({
|
||||
'make': selectedMake.value,
|
||||
'model': selectedModel.value,
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:immich_mobile/modules/search/models/search_filter.dart';
|
||||
|
||||
enum DisplayOption {
|
||||
notInAlbum,
|
||||
favorite,
|
||||
archive,
|
||||
}
|
||||
|
||||
class DisplayOptionPicker extends HookWidget {
|
||||
const DisplayOptionPicker({
|
||||
super.key,
|
||||
required this.onSelect,
|
||||
this.filter,
|
||||
});
|
||||
|
||||
final Function(Map<DisplayOption, bool>) onSelect;
|
||||
final SearchDisplayFilters? filter;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final options = useState<Map<DisplayOption, bool>>({
|
||||
DisplayOption.notInAlbum: filter?.isNotInAlbum ?? false,
|
||||
DisplayOption.favorite: filter?.isFavorite ?? false,
|
||||
DisplayOption.archive: filter?.isArchive ?? false,
|
||||
});
|
||||
|
||||
return ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
CheckboxListTile(
|
||||
title: const Text('Not in album'),
|
||||
value: options.value[DisplayOption.notInAlbum],
|
||||
onChanged: (bool? value) {
|
||||
options.value = {
|
||||
...options.value,
|
||||
DisplayOption.notInAlbum: value!,
|
||||
};
|
||||
onSelect(options.value);
|
||||
},
|
||||
),
|
||||
CheckboxListTile(
|
||||
title: const Text('Favorite'),
|
||||
value: options.value[DisplayOption.favorite],
|
||||
onChanged: (value) {
|
||||
options.value = {
|
||||
...options.value,
|
||||
DisplayOption.favorite: value!,
|
||||
};
|
||||
onSelect(options.value);
|
||||
},
|
||||
),
|
||||
CheckboxListTile(
|
||||
title: const Text('Archive'),
|
||||
value: options.value[DisplayOption.archive],
|
||||
onChanged: (value) {
|
||||
options.value = {
|
||||
...options.value,
|
||||
DisplayOption.archive: value!,
|
||||
};
|
||||
onSelect(options.value);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
|
||||
class FilterBottomSheetScaffold extends StatelessWidget {
|
||||
const FilterBottomSheetScaffold({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.onSearch,
|
||||
required this.onClear,
|
||||
required this.title,
|
||||
this.expanded,
|
||||
});
|
||||
|
||||
final bool? expanded;
|
||||
final String title;
|
||||
final Widget child;
|
||||
final Function() onSearch;
|
||||
final Function() onClear;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
buildChildWidget() {
|
||||
if (expanded != null && expanded == true) {
|
||||
return Expanded(child: child);
|
||||
}
|
||||
return child;
|
||||
}
|
||||
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
title,
|
||||
style: context.textTheme.headlineSmall,
|
||||
),
|
||||
),
|
||||
buildChildWidget(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
OutlinedButton(
|
||||
onPressed: () {
|
||||
onClear();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
onSearch();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Apply filter'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
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/search_filter.provider.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class LocationPicker extends HookConsumerWidget {
|
||||
const LocationPicker({super.key, required this.onSelected, this.filter});
|
||||
|
||||
final Function(Map<String, String?>) onSelected;
|
||||
final SearchLocationFilter? filter;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final countryTextController =
|
||||
useTextEditingController(text: filter?.country);
|
||||
final stateTextController = useTextEditingController(text: filter?.state);
|
||||
final cityTextController = useTextEditingController(text: filter?.city);
|
||||
|
||||
final selectedCountry = useState<String?>(filter?.country);
|
||||
final selectedState = useState<String?>(filter?.state);
|
||||
final selectedCity = useState<String?>(filter?.city);
|
||||
|
||||
final countries = ref.watch(
|
||||
getSearchSuggestionsProvider(
|
||||
SearchSuggestionType.country,
|
||||
locationCountry: selectedCountry.value,
|
||||
locationState: selectedState.value,
|
||||
),
|
||||
);
|
||||
|
||||
final states = ref.watch(
|
||||
getSearchSuggestionsProvider(
|
||||
SearchSuggestionType.state,
|
||||
locationCountry: selectedCountry.value,
|
||||
locationState: selectedState.value,
|
||||
),
|
||||
);
|
||||
|
||||
final cities = ref.watch(
|
||||
getSearchSuggestionsProvider(
|
||||
SearchSuggestionType.city,
|
||||
locationCountry: selectedCountry.value,
|
||||
locationState: selectedState.value,
|
||||
),
|
||||
);
|
||||
|
||||
final inputDecorationTheme = InputDecorationTheme(
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
contentPadding: const EdgeInsets.only(left: 16),
|
||||
);
|
||||
|
||||
final menuStyle = MenuStyle(
|
||||
shape: MaterialStatePropertyAll<OutlinedBorder>(
|
||||
RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
DropdownMenu(
|
||||
dropdownMenuEntries: switch (countries) {
|
||||
AsyncError() => [],
|
||||
AsyncData(:final value) => value
|
||||
.map(
|
||||
(e) => DropdownMenuEntry(
|
||||
value: e,
|
||||
label: e,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
_ => [],
|
||||
},
|
||||
menuHeight: 400,
|
||||
width: context.width * 0.9,
|
||||
label: const Text('Country'),
|
||||
inputDecorationTheme: inputDecorationTheme,
|
||||
menuStyle: menuStyle,
|
||||
controller: countryTextController,
|
||||
trailingIcon: const Icon(Icons.arrow_drop_down_rounded),
|
||||
selectedTrailingIcon: const Icon(Icons.arrow_drop_up_rounded),
|
||||
onSelected: (value) {
|
||||
selectedCountry.value = value.toString();
|
||||
onSelected({
|
||||
'country': selectedCountry.value,
|
||||
'state': selectedState.value,
|
||||
'city': selectedCity.value,
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
DropdownMenu(
|
||||
dropdownMenuEntries: switch (states) {
|
||||
AsyncError() => [],
|
||||
AsyncData(:final value) => value
|
||||
.map(
|
||||
(e) => DropdownMenuEntry(
|
||||
value: e,
|
||||
label: e,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
_ => [],
|
||||
},
|
||||
menuHeight: 400,
|
||||
width: context.width * 0.9,
|
||||
label: const Text('State'),
|
||||
inputDecorationTheme: inputDecorationTheme,
|
||||
menuStyle: menuStyle,
|
||||
controller: stateTextController,
|
||||
trailingIcon: const Icon(Icons.arrow_drop_down_rounded),
|
||||
selectedTrailingIcon: const Icon(Icons.arrow_drop_up_rounded),
|
||||
onSelected: (value) {
|
||||
selectedState.value = value.toString();
|
||||
onSelected({
|
||||
'country': selectedCountry.value,
|
||||
'state': selectedState.value,
|
||||
'city': selectedCity.value,
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
DropdownMenu(
|
||||
dropdownMenuEntries: switch (cities) {
|
||||
AsyncError() => [],
|
||||
AsyncData(:final value) => value
|
||||
.map(
|
||||
(e) => DropdownMenuEntry(
|
||||
value: e,
|
||||
label: e,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
_ => [],
|
||||
},
|
||||
menuHeight: 400,
|
||||
width: context.width * 0.9,
|
||||
label: const Text('City'),
|
||||
inputDecorationTheme: inputDecorationTheme,
|
||||
menuStyle: menuStyle,
|
||||
controller: cityTextController,
|
||||
trailingIcon: const Icon(Icons.arrow_drop_down_rounded),
|
||||
selectedTrailingIcon: const Icon(Icons.arrow_drop_up_rounded),
|
||||
onSelected: (value) {
|
||||
selectedCity.value = value.toString();
|
||||
onSelected({
|
||||
'country': selectedCountry.value,
|
||||
'state': selectedState.value,
|
||||
'city': selectedCity.value,
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
|
||||
class MediaTypePicker extends HookWidget {
|
||||
const MediaTypePicker({super.key, required this.onSelect, this.filter});
|
||||
|
||||
final Function(AssetType) onSelect;
|
||||
final AssetType? filter;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selectedMediaType = useState(filter ?? AssetType.other);
|
||||
|
||||
return ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
RadioListTile(
|
||||
title: const Text("All"),
|
||||
value: AssetType.other,
|
||||
onChanged: (value) {
|
||||
selectedMediaType.value = value!;
|
||||
onSelect(value);
|
||||
},
|
||||
groupValue: selectedMediaType.value,
|
||||
),
|
||||
RadioListTile(
|
||||
title: const Text("Image"),
|
||||
value: AssetType.image,
|
||||
onChanged: (value) {
|
||||
selectedMediaType.value = value!;
|
||||
onSelect(value);
|
||||
},
|
||||
groupValue: selectedMediaType.value,
|
||||
),
|
||||
RadioListTile(
|
||||
title: const Text("Video"),
|
||||
value: AssetType.video,
|
||||
onChanged: (value) {
|
||||
selectedMediaType.value = value!;
|
||||
onSelect(value);
|
||||
},
|
||||
groupValue: selectedMediaType.value,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/search/providers/people.provider.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart' as local_store;
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class PeoplePicker extends HookConsumerWidget {
|
||||
const PeoplePicker({super.key, required this.onSelect, this.filter});
|
||||
|
||||
final Function(Set<PersonResponseDto>) onSelect;
|
||||
final Set<PersonResponseDto>? filter;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
var imageSize = 45.0;
|
||||
final people = ref.watch(getAllPeopleProvider);
|
||||
final headers = {
|
||||
"x-immich-user-token":
|
||||
local_store.Store.get(local_store.StoreKey.accessToken),
|
||||
};
|
||||
final selectedPeople = useState<Set<PersonResponseDto>>(filter ?? {});
|
||||
|
||||
return people.widgetWhen(
|
||||
onData: (people) {
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: people.length,
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemBuilder: (context, index) {
|
||||
final person = people[index];
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(15)),
|
||||
),
|
||||
child: ListTile(
|
||||
title: Text(
|
||||
person.name,
|
||||
style: context.textTheme.bodyLarge,
|
||||
),
|
||||
leading: SizedBox(
|
||||
height: imageSize,
|
||||
child: Material(
|
||||
shape: const CircleBorder(side: BorderSide.none),
|
||||
elevation: 3,
|
||||
child: CircleAvatar(
|
||||
maxRadius: imageSize / 2,
|
||||
backgroundImage: NetworkImage(
|
||||
getFaceThumbnailUrl(person.id),
|
||||
headers: headers,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
if (selectedPeople.value.contains(person)) {
|
||||
selectedPeople.value.remove(person);
|
||||
} else {
|
||||
selectedPeople.value.add(person);
|
||||
}
|
||||
|
||||
selectedPeople.value = {...selectedPeople.value};
|
||||
onSelect(selectedPeople.value);
|
||||
},
|
||||
selected: selectedPeople.value.contains(person),
|
||||
selectedTileColor: context.primaryColor.withOpacity(0.2),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(15)),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
|
||||
class SearchFilterChip extends StatelessWidget {
|
||||
final String label;
|
||||
final Function() onTap;
|
||||
final Widget? currentFilter;
|
||||
final IconData icon;
|
||||
|
||||
const SearchFilterChip({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
required this.icon,
|
||||
this.currentFilter,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (currentFilter != null) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
color: context.primaryColor.withAlpha(25),
|
||||
shape: StadiumBorder(
|
||||
side: BorderSide(color: context.primaryColor),
|
||||
),
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 2.0, horizontal: 14.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 4.0),
|
||||
currentFilter!,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
shape:
|
||||
StadiumBorder(side: BorderSide(color: Colors.grey.withAlpha(100))),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2.0, horizontal: 14.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 4.0),
|
||||
Text(label),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
Future<T> showFilterBottomSheet<T>({
|
||||
required BuildContext context,
|
||||
required Widget child,
|
||||
bool isScrollControlled = false,
|
||||
bool isDismissible = true,
|
||||
}) async {
|
||||
return await showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: isScrollControlled,
|
||||
useSafeArea: false,
|
||||
isDismissible: isDismissible,
|
||||
showDragHandle: isDismissible,
|
||||
builder: (BuildContext context) {
|
||||
return child;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
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/modules/search/providers/search_page_state.provider.dart';
|
||||
|
||||
class SearchSuggestionList extends ConsumerWidget {
|
||||
const SearchSuggestionList({super.key, required this.onSubmitted});
|
||||
|
||||
final Function(String) onSubmitted;
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final searchTerm = ref.watch(searchPageStateProvider).searchTerm;
|
||||
final searchSuggestion =
|
||||
ref.watch(searchPageStateProvider).searchSuggestion;
|
||||
|
||||
return Container(
|
||||
color: searchTerm.isEmpty
|
||||
? Colors.black.withOpacity(0.5)
|
||||
: context.scaffoldBackgroundColor,
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
color: context.isDarkTheme ? Colors.grey[800] : Colors.grey[100],
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: 'search_suggestion_list_smart_search_hint_1'.tr(),
|
||||
style: context.textTheme.bodyMedium,
|
||||
),
|
||||
TextSpan(
|
||||
text: 'search_suggestion_list_smart_search_hint_2'.tr(),
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: true,
|
||||
child: ListView.builder(
|
||||
itemBuilder: ((context, index) {
|
||||
return ListTile(
|
||||
onTap: () {
|
||||
onSubmitted("m:${searchSuggestion[index]}");
|
||||
},
|
||||
title: Text(searchSuggestion[index]),
|
||||
);
|
||||
}),
|
||||
itemCount: searchSuggestion.length,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user