Source Code added

This commit is contained in:
Fr4nz D13trich 2026-02-02 15:06:40 +01:00
parent 800376eafd
commit 9efa9bc6dd
3912 changed files with 754770 additions and 2 deletions

View file

@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/search/search_curated_content.model.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
class CuratedPeopleRow extends StatelessWidget {
static const double imageSize = 60.0;
final List<SearchCuratedContent> content;
final EdgeInsets? padding;
/// Callback with the content and the index when tapped
final Function(SearchCuratedContent, int)? onTap;
final Function(SearchCuratedContent, int)? onNameTap;
const CuratedPeopleRow({super.key, required this.content, this.onTap, this.padding, required this.onNameTap});
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
child: SingleChildScrollView(
padding: padding,
scrollDirection: Axis.horizontal,
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: List.generate(content.length, (index) {
final person = content[index];
final headers = ApiService.getRequestHeaders();
return Padding(
padding: const EdgeInsets.only(right: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
GestureDetector(
onTap: () => onTap?.call(person, index),
child: 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),
),
),
),
),
const SizedBox(height: 8),
SizedBox(width: imageSize, child: _buildPersonLabel(context, person, index)),
],
),
);
}),
),
),
);
}
Widget _buildPersonLabel(BuildContext context, SearchCuratedContent person, int index) {
if (person.label.isEmpty) {
return GestureDetector(
onTap: () => onNameTap?.call(person, index),
child: Text(
"exif_bottom_sheet_person_add_person",
style: context.textTheme.labelLarge?.copyWith(color: context.primaryColor),
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
).tr(),
);
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
person.label,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelLarge,
maxLines: 2,
),
if (person.subtitle != null) Text(person.subtitle!, textAlign: TextAlign.center),
],
);
}
}

View file

@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/models/search/search_curated_content.model.dart';
import 'package:immich_mobile/widgets/search/search_map_thumbnail.dart';
import 'package:immich_mobile/widgets/search/thumbnail_with_info.dart';
class CuratedPlacesRow extends StatelessWidget {
const CuratedPlacesRow({
super.key,
required this.content,
required this.imageSize,
this.isMapEnabled = true,
this.onTap,
});
final bool isMapEnabled;
final List<SearchCuratedContent> content;
final double imageSize;
/// Callback with the content and the index when tapped
final Function(SearchCuratedContent, int)? onTap;
@override
Widget build(BuildContext context) {
// Calculating the actual index of the content based on the whether map is enabled or not.
// If enabled, inject map as the first item in the list (index 0) and so the actual content will start from index 1
final int actualContentIndex = isMapEnabled ? 1 : 0;
return SizedBox(
height: imageSize,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
separatorBuilder: (context, index) => const SizedBox(width: 10),
itemBuilder: (context, index) {
// Injecting Map thumbnail as the first element
if (isMapEnabled && index == 0) {
return SizedBox.square(
dimension: imageSize,
child: SearchMapThumbnail(size: imageSize),
);
}
final actualIndex = index - actualContentIndex;
final object = content[actualIndex];
final thumbnailRequestUrl = '${Store.get(StoreKey.serverEndpoint)}/assets/${object.id}/thumbnail';
return SizedBox.square(
dimension: imageSize,
child: ThumbnailWithInfo(
imageUrl: thumbnailRequestUrl,
textInfo: object.label,
onTap: () => onTap?.call(object, actualIndex),
),
);
},
itemCount: content.length + actualContentIndex,
),
);
}
}

View file

@ -0,0 +1,69 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/models/search/search_curated_content.model.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/widgets/search/thumbnail_with_info.dart';
class ExploreGrid extends StatelessWidget {
final List<SearchCuratedContent> curatedContent;
final bool isPeople;
const ExploreGrid({super.key, required this.curatedContent, this.isPeople = false});
@override
Widget build(BuildContext context) {
if (curatedContent.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: SizedBox(
height: 100,
width: 100,
child: ThumbnailWithInfo(textInfo: '', onTap: () {}),
),
);
}
return GridView.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 140,
mainAxisSpacing: 4,
crossAxisSpacing: 4,
),
itemBuilder: (context, index) {
final content = curatedContent[index];
final thumbnailRequestUrl = isPeople
? getFaceThumbnailUrl(content.id)
: '${Store.get(StoreKey.serverEndpoint)}/assets/${content.id}/thumbnail';
return ThumbnailWithInfo(
imageUrl: thumbnailRequestUrl,
textInfo: content.label,
borderRadius: 0,
onTap: () {
isPeople
? context.pushRoute(PersonResultRoute(personId: content.id, personName: content.label))
: context.pushRoute(
SearchRoute(
prefilter: SearchFilter(
people: {},
location: SearchLocationFilter(city: content.label),
camera: SearchCameraFilter(),
date: SearchDateFilter(),
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
rating: SearchRatingFilter(),
mediaType: AssetType.other,
),
),
);
},
);
},
itemCount: curatedContent.length,
);
}
}

View file

@ -0,0 +1,65 @@
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/providers/search/people.provider.dart';
class PersonNameEditFormResult {
final bool success;
final String updatedName;
const PersonNameEditFormResult(this.success, this.updatedName);
}
class PersonNameEditForm extends HookConsumerWidget {
final String personId;
final String personName;
const PersonNameEditForm({super.key, required this.personId, required this.personName});
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = useTextEditingController(text: personName);
final isError = useState(false);
return AlertDialog(
title: const Text("add_a_name", style: TextStyle(fontWeight: FontWeight.bold)).tr(),
content: SingleChildScrollView(
child: TextFormField(
controller: controller,
textCapitalization: TextCapitalization.words,
autofocus: true,
decoration: InputDecoration(
hintText: 'name'.tr(),
border: const OutlineInputBorder(),
errorText: isError.value ? 'Error occurred' : null,
),
),
),
actions: [
TextButton(
onPressed: () => context.pop(const PersonNameEditFormResult(false, '')),
child: Text(
"cancel",
style: TextStyle(color: Colors.red[300], fontWeight: FontWeight.bold),
).tr(),
),
TextButton(
onPressed: () async {
isError.value = false;
final result = await ref.read(updatePersonNameProvider(personId, controller.text).future);
isError.value = !result;
if (result) {
context.pop(PersonNameEditFormResult(true, controller.text));
}
},
child: Text(
"save",
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold),
).tr(),
),
],
);
}
}

View file

@ -0,0 +1,75 @@
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/models/search/search_filter.model.dart';
import 'package:immich_mobile/providers/search/search_filter.provider.dart';
import 'package:immich_mobile/widgets/search/search_filter/common/dropdown.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 makeWidget = SearchDropdown(
dropdownMenuEntries: switch (make) {
AsyncError() => [],
AsyncData(:final value) => value.map((e) => DropdownMenuEntry(value: e, label: e)).toList(),
_ => [],
},
label: const Text('make').tr(),
controller: makeTextController,
leadingIcon: const Icon(Icons.photo_camera_rounded),
onSelected: (value) {
if (value.toString() == selectedMake.value) {
return;
}
selectedMake.value = value.toString();
modelTextController.value = TextEditingValue.empty;
onSelect({'make': selectedMake.value, 'model': null});
},
);
final modelWidget = SearchDropdown(
dropdownMenuEntries: switch (models) {
AsyncError() => [],
AsyncData(:final value) => value.map((e) => DropdownMenuEntry(value: e, label: e)).toList(),
_ => [],
},
label: const Text('model').tr(),
controller: modelTextController,
leadingIcon: const Icon(Icons.camera),
onSelected: (value) {
selectedModel.value = value.toString();
onSelect({'make': selectedMake.value, 'model': selectedModel.value});
},
);
if (context.isMobile) {
return Column(children: [makeWidget, const SizedBox(height: 8), modelWidget]);
}
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Expanded(child: makeWidget),
const SizedBox(width: 16),
Expanded(child: modelWidget),
],
);
}
}

View file

@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
class SearchDropdown<T> extends StatelessWidget {
const SearchDropdown({
super.key,
required this.dropdownMenuEntries,
required this.controller,
this.onSelected,
this.label,
this.leadingIcon,
});
final List<DropdownMenuEntry<T>> dropdownMenuEntries;
final TextEditingController controller;
final void Function(T?)? onSelected;
final Widget? label;
final Widget? leadingIcon;
@override
Widget build(BuildContext context) {
final menuStyle = const MenuStyle(
shape: WidgetStatePropertyAll<OutlinedBorder>(
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(15))),
),
);
return LayoutBuilder(
builder: (context, constraints) {
return DropdownMenu(
controller: controller,
leadingIcon: leadingIcon,
width: constraints.maxWidth,
dropdownMenuEntries: dropdownMenuEntries,
label: label,
menuStyle: menuStyle,
trailingIcon: const Icon(Icons.arrow_drop_down_rounded),
selectedTrailingIcon: const Icon(Icons.arrow_drop_up_rounded),
onSelected: onSelected,
);
},
);
}
}

View file

@ -0,0 +1,52 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/models/search/search_filter.model.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('search_filter_display_option_not_in_album').tr(),
value: options.value[DisplayOption.notInAlbum],
onChanged: (bool? value) {
options.value = {...options.value, DisplayOption.notInAlbum: value!};
onSelect(options.value);
},
),
CheckboxListTile(
title: const Text('favorite').tr(),
value: options.value[DisplayOption.favorite],
onChanged: (value) {
options.value = {...options.value, DisplayOption.favorite: value!};
onSelect(options.value);
},
),
CheckboxListTile(
title: const Text('archive').tr(),
value: options.value[DisplayOption.archive],
onChanged: (value) {
options.value = {...options.value, DisplayOption.archive: value!};
onSelect(options.value);
},
),
],
);
}
}

View file

@ -0,0 +1,68 @@
import 'package:easy_localization/easy_localization.dart';
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,
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();
context.pop();
},
child: const Text('clear').tr(),
),
if (onSearch != null) const SizedBox(width: 8),
if (onSearch != null)
ElevatedButton(
key: const Key('search_filter_apply'),
onPressed: () {
onSearch!();
context.pop();
},
child: const Text('search_filter_apply').tr(),
),
],
),
),
],
),
);
}
}

View file

@ -0,0 +1,105 @@
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/models/search/search_filter.model.dart';
import 'package:immich_mobile/providers/search/search_filter.provider.dart';
import 'package:immich_mobile/widgets/search/search_filter/common/dropdown.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,
),
);
return Column(
children: [
SearchDropdown(
dropdownMenuEntries: switch (countries) {
AsyncError() => [],
AsyncData(:final value) => value.map((e) => DropdownMenuEntry(value: e, label: e)).toList(),
_ => [],
},
label: const Text('country').tr(),
controller: countryTextController,
onSelected: (value) {
if (value.toString() == selectedCountry.value) {
return;
}
selectedCountry.value = value.toString();
stateTextController.value = TextEditingValue.empty;
cityTextController.value = TextEditingValue.empty;
onSelected({'country': selectedCountry.value, 'state': null, 'city': null});
},
),
const SizedBox(height: 16),
SearchDropdown(
dropdownMenuEntries: switch (states) {
AsyncError() => [],
AsyncData(:final value) => value.map((e) => DropdownMenuEntry(value: e, label: e)).toList(),
_ => [],
},
label: const Text('state').tr(),
controller: stateTextController,
onSelected: (value) {
if (value.toString() == selectedState.value) {
return;
}
selectedState.value = value.toString();
cityTextController.value = TextEditingValue.empty;
onSelected({'country': selectedCountry.value, 'state': selectedState.value, 'city': null});
},
),
const SizedBox(height: 16),
SearchDropdown(
dropdownMenuEntries: switch (cities) {
AsyncError() => [],
AsyncData(:final value) => value.map((e) => DropdownMenuEntry(value: e, label: e)).toList(),
_ => [],
},
label: const Text('city').tr(),
controller: cityTextController,
onSelected: (value) {
selectedCity.value = value.toString();
onSelected({'country': selectedCountry.value, 'state': selectedState.value, 'city': selectedCity.value});
},
),
],
);
}
}

View file

@ -0,0 +1,31 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/entities/asset.entity.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 RadioGroup(
onChanged: (value) {
selectedMediaType.value = value!;
onSelect(value);
},
groupValue: selectedMediaType.value,
child: Column(
children: [
RadioListTile(key: const Key("all"), title: const Text("all").tr(), value: AssetType.other),
RadioListTile(key: const Key("image"), title: const Text("image").tr(), value: AssetType.image),
RadioListTile(key: const Key("video"), title: const Text("video").tr(), value: AssetType.video),
],
),
);
}
}

View file

@ -0,0 +1,105 @@
import 'package:flutter/material.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/providers/search/people.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
class PeoplePicker extends HookConsumerWidget {
const PeoplePicker({super.key, required this.onSelect, this.filter});
final Function(Set<PersonDto>) onSelect;
final Set<PersonDto>? filter;
@override
Widget build(BuildContext context, WidgetRef ref) {
final formFocus = useFocusNode();
final imageSize = 60.0;
final searchQuery = useState('');
final people = ref.watch(getAllPeopleProvider);
final headers = ApiService.getRequestHeaders();
final selectedPeople = useState<Set<PersonDto>>(filter ?? {});
return Column(
children: [
Padding(
padding: const EdgeInsets.all(8),
child: SearchField(
focusNode: formFocus,
onChanged: (value) => searchQuery.value = value,
onTapOutside: (_) => formFocus.unfocus(),
filled: true,
hintText: 'filter_people'.tr(),
),
),
Padding(
padding: const EdgeInsets.only(left: 16.0, right: 16.0, bottom: 0),
child: Divider(color: context.colorScheme.surfaceContainerHighest, thickness: 1),
),
Expanded(
child: people.widgetWhen(
onData: (people) {
return ListView.builder(
shrinkWrap: true,
itemCount: people
.where((person) => person.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
.length,
padding: const EdgeInsets.all(8),
itemBuilder: (context, index) {
final person = people
.where((person) => person.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
.toList()[index];
final isSelected = selectedPeople.value.contains(person);
return Padding(
padding: const EdgeInsets.only(bottom: 2.0),
child: LargeLeadingTile(
title: Text(
person.name,
style: context.textTheme.bodyLarge?.copyWith(
fontSize: 20,
fontWeight: FontWeight.w500,
color: isSelected ? context.colorScheme.onPrimary : context.colorScheme.onSurface,
),
),
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: isSelected,
selectedTileColor: context.primaryColor,
tileColor: context.primaryColor.withAlpha(25),
),
);
},
);
},
),
),
],
);
}
}

View file

@ -0,0 +1,40 @@
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.withValues(alpha: .5),
shape: StadiumBorder(side: BorderSide(color: context.colorScheme.secondaryContainer)),
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: context.colorScheme.outline.withAlpha(15))),
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)]),
),
),
);
}
}

View file

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

View file

@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
class StarRatingPicker extends HookWidget {
const StarRatingPicker({super.key, required this.onSelect, this.filter});
final Function(SearchRatingFilter) onSelect;
final SearchRatingFilter? filter;
@override
Widget build(BuildContext context) {
final selectedRating = useState(filter);
return RadioGroup(
groupValue: selectedRating.value?.rating,
onChanged: (int? newValue) {
if (newValue == null) return;
final newFilter = SearchRatingFilter(rating: newValue);
selectedRating.value = newFilter;
onSelect(newFilter);
},
child: Column(
children: List.generate(
6,
(index) => RadioListTile<int>(
key: Key("star_$index"),
title: Text('rating_count'.t(args: {'count': (index)})),
value: index,
),
),
),
);
}
}

View file

@ -0,0 +1,27 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
import 'package:immich_mobile/widgets/search/thumbnail_with_info_container.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
class SearchMapThumbnail extends StatelessWidget {
const SearchMapThumbnail({super.key, this.size = 60.0});
final double size;
final bool showTitle = true;
@override
Widget build(BuildContext context) {
return ThumbnailWithInfoContainer(
label: 'search_page_your_map'.tr(),
onTap: () {
context.pushRoute(MapRoute());
},
child: IgnorePointer(
child: MapThumbnail(zoom: 2, centre: const LatLng(47, 5), height: size, width: size, showAttribution: false),
),
);
}
}

View file

@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/widgets/search/search_row_title.dart';
class SearchRowSection extends StatelessWidget {
const SearchRowSection({
super.key,
required this.onViewAllPressed,
required this.title,
this.isEmpty = false,
required this.child,
});
final Function() onViewAllPressed;
final String title;
final bool isEmpty;
final Widget child;
@override
Widget build(BuildContext context) {
if (isEmpty) {
return const SizedBox.shrink();
}
return Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SearchRowTitle(onViewAllPressed: onViewAllPressed, title: title),
),
child,
],
);
}
}

View file

@ -0,0 +1,27 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class SearchRowTitle extends StatelessWidget {
const SearchRowTitle({super.key, required this.onViewAllPressed, required this.title});
final Function() onViewAllPressed;
final String title;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(title, style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
TextButton(
onPressed: onViewAllPressed,
child: Text(
'search_page_view_all_button',
style: context.textTheme.labelLarge?.copyWith(color: context.primaryColor),
).tr(),
),
],
);
}
}

View file

@ -0,0 +1,45 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/widgets/search/thumbnail_with_info_container.dart';
import 'package:immich_mobile/services/api.service.dart';
class ThumbnailWithInfo extends StatelessWidget {
const ThumbnailWithInfo({
super.key,
required this.textInfo,
this.imageUrl,
this.noImageIcon,
this.borderRadius = 10,
this.onTap,
});
final String textInfo;
final String? imageUrl;
final VoidCallback? onTap;
final IconData? noImageIcon;
final double borderRadius;
@override
Widget build(BuildContext context) {
var textAndIconColor = context.isDarkTheme ? Colors.grey[100] : Colors.grey[700];
return ThumbnailWithInfoContainer(
onTap: onTap,
borderRadius: borderRadius,
label: textInfo,
child: imageUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(borderRadius),
child: CachedNetworkImage(
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
imageUrl: imageUrl!,
httpHeaders: ApiService.getRequestHeaders(),
errorWidget: (context, url, error) => const Icon(Icons.image_not_supported_outlined),
),
)
: Center(child: Icon(noImageIcon ?? Icons.not_listed_location, color: textAndIconColor)),
);
}
}

View file

@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
class ThumbnailWithInfoContainer extends StatelessWidget {
const ThumbnailWithInfoContainer({
super.key,
this.onTap,
this.borderRadius = 10,
required this.label,
required this.child,
});
final VoidCallback? onTap;
final double borderRadius;
final String label;
final Widget child;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Stack(
alignment: Alignment.bottomLeft,
children: [
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(borderRadius),
gradient: LinearGradient(
colors: [context.colorScheme.surfaceContainer, context.colorScheme.surfaceContainer.darken(amount: .1)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
foregroundDecoration: BoxDecoration(
borderRadius: BorderRadius.circular(borderRadius),
color: Colors.white,
gradient: LinearGradient(
begin: FractionalOffset.topCenter,
end: FractionalOffset.bottomCenter,
colors: [
Colors.transparent,
label == '' ? Colors.black.withValues(alpha: 0.1) : Colors.black.withValues(alpha: 0.5),
],
stops: const [0.0, 1.0],
),
),
child: child,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8) + const EdgeInsets.only(bottom: 8),
child: Text(
label,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14),
maxLines: 2,
softWrap: false,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}