From eb987c14c1aeb12fbfdd32806348ff042fc7f677 Mon Sep 17 00:00:00 2001 From: RanKKI Date: Tue, 18 Jun 2024 01:47:04 +1000 Subject: [PATCH] fix(mobile): search page (#10385) * refactor(search): hide people/places if empty * refactor(search): remove unused stack * refactor(search): fix dropdown menu's width * feat(search): show camera make/model vertically on mobile devices * fix: lint errors --- mobile/lib/pages/search/search.page.dart | 228 +++++++++--------- .../widgets/search/curated_people_row.dart | 137 +++++------ .../widgets/search/curated_places_row.dart | 155 ++++-------- mobile/lib/widgets/search/curated_row.dart | 66 ----- .../search/search_filter/camera_picker.dart | 141 +++++------ .../search/search_filter/common/dropdown.dart | 52 ++++ .../filter_bottom_sheet_scaffold.dart | 5 +- .../search/search_filter/location_picker.dart | 41 +--- .../widgets/search/search_map_thumbnail.dart | 76 ++++++ .../widgets/search/search_row_section.dart | 37 +++ .../lib/widgets/search/search_row_title.dart | 51 ++-- 11 files changed, 466 insertions(+), 523 deletions(-) delete mode 100644 mobile/lib/widgets/search/curated_row.dart create mode 100644 mobile/lib/widgets/search/search_filter/common/dropdown.dart create mode 100644 mobile/lib/widgets/search/search_map_thumbnail.dart create mode 100644 mobile/lib/widgets/search/search_row_section.dart diff --git a/mobile/lib/pages/search/search.page.dart b/mobile/lib/pages/search/search.page.dart index 637bff42fc..2c578925c1 100644 --- a/mobile/lib/pages/search/search.page.dart +++ b/mobile/lib/pages/search/search.page.dart @@ -13,7 +13,7 @@ import 'package:immich_mobile/providers/search/search_page_state.provider.dart'; import 'package:immich_mobile/widgets/search/curated_people_row.dart'; import 'package:immich_mobile/widgets/search/curated_places_row.dart'; import 'package:immich_mobile/widgets/search/person_name_edit_form.dart'; -import 'package:immich_mobile/widgets/search/search_row_title.dart'; +import 'package:immich_mobile/widgets/search/search_row_section.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; @@ -31,7 +31,7 @@ class SearchPage extends HookConsumerWidget { final curatedPeople = ref.watch(getAllPeopleProvider); final isMapEnabled = ref.watch(serverInfoProvider.select((v) => v.serverFeatures.map)); - double imageSize = math.min(context.width / 3, 150); + final double imageSize = math.min(context.width / 3, 150); TextStyle categoryTitleStyle = const TextStyle( fontWeight: FontWeight.w500, @@ -53,16 +53,15 @@ class SearchPage extends HookConsumerWidget { } buildPeople() { - return SizedBox( - height: imageSize, - child: curatedPeople.widgetWhen( - onError: (error, stack) => const ScaffoldErrorBody(withIcon: false), - onData: (people) => Padding( - padding: const EdgeInsets.only( - left: 16, - top: 8, - ), + return curatedPeople.widgetWhen( + onError: (error, stack) => const ScaffoldErrorBody(withIcon: false), + onData: (people) { + return SearchRowSection( + onViewAllPressed: () => context.pushRoute(const AllPeopleRoute()), + title: "search_page_people".tr(), + isEmpty: people.isEmpty, child: CuratedPeopleRow( + padding: const EdgeInsets.symmetric(horizontal: 16), content: people .map((e) => SearchCuratedContent(label: e.name, id: e.id)) .take(12) @@ -79,42 +78,46 @@ class SearchPage extends HookConsumerWidget { showNameEditModel(person.id, person.label), }, ), - ), - ), + ); + }, ); } buildPlaces() { - return SizedBox( - height: imageSize, - child: places.widgetWhen( - onError: (error, stack) => const ScaffoldErrorBody(withIcon: false), - onData: (data) => CuratedPlacesRow( - isMapEnabled: isMapEnabled, - content: data, - imageSize: imageSize, - onTap: (content, index) { - context.pushRoute( - SearchInputRoute( - prefilter: SearchFilter( - people: {}, - location: SearchLocationFilter( - city: content.label, + return places.widgetWhen( + onError: (error, stack) => const ScaffoldErrorBody(withIcon: false), + onData: (data) { + return SearchRowSection( + onViewAllPressed: () => context.pushRoute(const AllPlacesRoute()), + title: "search_page_places".tr(), + isEmpty: !isMapEnabled && data.isEmpty, + child: CuratedPlacesRow( + isMapEnabled: isMapEnabled, + content: data, + imageSize: imageSize, + onTap: (content, index) { + context.pushRoute( + SearchInputRoute( + prefilter: SearchFilter( + people: {}, + location: SearchLocationFilter( + city: content.label, + ), + camera: SearchCameraFilter(), + date: SearchDateFilter(), + display: SearchDisplayFilters( + isNotInAlbum: false, + isArchive: false, + isFavorite: false, + ), + mediaType: AssetType.other, ), - camera: SearchCameraFilter(), - date: SearchDateFilter(), - display: SearchDisplayFilters( - isNotInAlbum: false, - isArchive: false, - isFavorite: false, - ), - mediaType: AssetType.other, ), - ), - ); - }, - ), - ), + ); + }, + ), + ); + }, ); } @@ -160,88 +163,73 @@ class SearchPage extends HookConsumerWidget { return Scaffold( appBar: const ImmichAppBar(), - body: Stack( + body: ListView( children: [ - ListView( - children: [ - buildSearchButton(), - SearchRowTitle( - title: "search_page_people".tr(), - onViewAllPressed: () => - context.pushRoute(const AllPeopleRoute()), + buildSearchButton(), + const SizedBox(height: 8.0), + buildPeople(), + const SizedBox(height: 8.0), + buildPlaces(), + const SizedBox(height: 24.0), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'search_page_your_activity', + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, ), - buildPeople(), - SearchRowTitle( - title: "search_page_places".tr(), - onViewAllPressed: () => - context.pushRoute(const AllPlacesRoute()), - top: 0, + ).tr(), + ), + ListTile( + leading: Icon( + Icons.favorite_border_rounded, + color: categoryIconColor, + ), + title: + Text('search_page_favorites', style: categoryTitleStyle).tr(), + onTap: () => context.pushRoute(const FavoritesRoute()), + ), + const CategoryDivider(), + ListTile( + leading: Icon( + Icons.schedule_outlined, + color: categoryIconColor, + ), + title: Text( + 'search_page_recently_added', + style: categoryTitleStyle, + ).tr(), + onTap: () => context.pushRoute(const RecentlyAddedRoute()), + ), + const SizedBox(height: 24.0), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + 'search_page_categories', + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, ), - const SizedBox(height: 10.0), - buildPlaces(), - const SizedBox(height: 24.0), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text( - 'search_page_your_activity', - style: context.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - ), - ).tr(), - ), - ListTile( - leading: Icon( - Icons.favorite_border_rounded, - color: categoryIconColor, - ), - title: Text('search_page_favorites', style: categoryTitleStyle) - .tr(), - onTap: () => context.pushRoute(const FavoritesRoute()), - ), - const CategoryDivider(), - ListTile( - leading: Icon( - Icons.schedule_outlined, - color: categoryIconColor, - ), - title: Text( - 'search_page_recently_added', - style: categoryTitleStyle, - ).tr(), - onTap: () => context.pushRoute(const RecentlyAddedRoute()), - ), - const SizedBox(height: 24.0), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Text( - 'search_page_categories', - style: context.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - ), - ).tr(), - ), - ListTile( - title: - Text('search_page_videos', style: categoryTitleStyle).tr(), - leading: Icon( - Icons.play_circle_outline, - color: categoryIconColor, - ), - onTap: () => context.pushRoute(const AllVideosRoute()), - ), - const CategoryDivider(), - ListTile( - title: Text( - 'search_page_motion_photos', - style: categoryTitleStyle, - ).tr(), - leading: Icon( - Icons.motion_photos_on_outlined, - color: categoryIconColor, - ), - onTap: () => context.pushRoute(const AllMotionPhotosRoute()), - ), - ], + ).tr(), + ), + ListTile( + title: Text('search_page_videos', style: categoryTitleStyle).tr(), + leading: Icon( + Icons.play_circle_outline, + color: categoryIconColor, + ), + onTap: () => context.pushRoute(const AllVideosRoute()), + ), + const CategoryDivider(), + ListTile( + title: Text( + 'search_page_motion_photos', + style: categoryTitleStyle, + ).tr(), + leading: Icon( + Icons.motion_photos_on_outlined, + color: categoryIconColor, + ), + onTap: () => context.pushRoute(const AllMotionPhotosRoute()), ), ], ), diff --git a/mobile/lib/widgets/search/curated_people_row.dart b/mobile/lib/widgets/search/curated_people_row.dart index 50a4c3b427..897cd454f6 100644 --- a/mobile/lib/widgets/search/curated_people_row.dart +++ b/mobile/lib/widgets/search/curated_people_row.dart @@ -2,11 +2,12 @@ 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/widgets/search/thumbnail_with_info.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; class CuratedPeopleRow extends StatelessWidget { + static const double imageSize = 60.0; + final List content; final EdgeInsets? padding; @@ -24,88 +25,68 @@ class CuratedPeopleRow extends StatelessWidget { @override Widget build(BuildContext context) { - const imageSize = 60.0; - - // Guard empty [content] - if (content.isEmpty) { - // Return empty thumbnail - return Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: SizedBox( - width: imageSize, - height: imageSize, - child: ThumbnailWithInfo( - textInfo: '', - onTap: () {}, - ), - ), - ), - ); - } - - return ListView.builder( - padding: padding, - scrollDirection: Axis.horizontal, - itemBuilder: (context, index) { - final person = content[index]; - final headers = { - "x-immich-user-token": Store.get(StoreKey.accessToken), - }; - return Padding( - padding: const EdgeInsets.only(right: 18.0), - child: SizedBox( - width: imageSize, - 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, - ), + return SizedBox( + height: imageSize + 30, + child: ListView.separated( + padding: padding, + scrollDirection: Axis.horizontal, + separatorBuilder: (context, index) => const SizedBox(width: 16), + itemBuilder: (context, index) { + final person = content[index]; + final headers = { + "x-immich-user-token": Store.get(StoreKey.accessToken), + }; + return 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, ), ), ), ), - if (person.label == "") - GestureDetector( - onTap: () => onNameTap?.call(person, index), - child: Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Text( - "exif_bottom_sheet_person_add_person", - style: context.textTheme.labelLarge?.copyWith( - color: context.primaryColor, - ), - ).tr(), - ), - ) - else - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Text( - person.label, - textAlign: TextAlign.center, - overflow: TextOverflow.ellipsis, - style: context.textTheme.labelLarge, - ), - ), - ], - ), + ), + const SizedBox(height: 8), + _buildPersonLabel(context, person, index), + ], + ); + }, + itemCount: content.length, + ), + ); + } + + 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, ), - ); - }, - itemCount: content.length, + ).tr(), + ); + } + return Text( + person.label, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelLarge, ); } } diff --git a/mobile/lib/widgets/search/curated_places_row.dart b/mobile/lib/widgets/search/curated_places_row.dart index babb20035a..4488f9cb7d 100644 --- a/mobile/lib/widgets/search/curated_places_row.dart +++ b/mobile/lib/widgets/search/curated_places_row.dart @@ -1,135 +1,64 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:immich_mobile/widgets/map/map_thumbnail.dart'; -import 'package:immich_mobile/widgets/search/curated_row.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'; -import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; - -class CuratedPlacesRow extends CuratedRow { - final bool isMapEnabled; +class CuratedPlacesRow extends StatelessWidget { const CuratedPlacesRow({ super.key, - required super.content, + required this.content, + required this.imageSize, this.isMapEnabled = true, - super.imageSize, - super.onTap, + this.onTap, }); + final bool isMapEnabled; + final List 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; - Widget buildMapThumbnail() { - return GestureDetector( - onTap: () => context.pushRoute( - const MapRoute(), - ), - child: SizedBox.square( - dimension: imageSize, - child: Stack( - children: [ - Padding( - padding: const EdgeInsets.only(right: 10.0), - child: MapThumbnail( - zoom: 2, - centre: const LatLng( - 47, - 5, - ), - height: imageSize, - width: imageSize, - showAttribution: false, - ), - ), - Padding( - padding: const EdgeInsets.only(right: 10.0), - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - color: Colors.black, - gradient: LinearGradient( - begin: FractionalOffset.topCenter, - end: FractionalOffset.bottomCenter, - colors: [ - Colors.blueGrey.withOpacity(0.0), - Colors.black.withOpacity(0.4), - ], - stops: const [0.0, 0.4], - ), - ), - ), - ), - Align( - alignment: Alignment.bottomCenter, - child: Padding( - padding: const EdgeInsets.only(bottom: 10), - child: const Text( - "search_page_your_map", - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 14, - ), - ).tr(), - ), - ), - ], - ), - ), - ); - } - // Return empty thumbnail - if (!isMapEnabled && content.isEmpty) { - return Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: SizedBox( + return SizedBox( + height: imageSize, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + itemBuilder: (context, index) { + // Injecting Map thumbnail as the first element + if (isMapEnabled && index == 0) { + return SearchMapThumbnail( + size: imageSize, + ); + } + final actualIndex = index - actualContentIndex; + final object = content[actualIndex]; + final thumbnailRequestUrl = + '${Store.get(StoreKey.serverEndpoint)}/assets/${object.id}/thumbnail'; + return SizedBox( width: imageSize, height: imageSize, - child: ThumbnailWithInfo( - textInfo: '', - onTap: () {}, + child: Padding( + padding: const EdgeInsets.only(right: 10.0), + child: ThumbnailWithInfo( + imageUrl: thumbnailRequestUrl, + textInfo: object.label, + onTap: () => onTap?.call(object, actualIndex), + ), ), - ), - ), - ); - } - - return ListView.builder( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric( - horizontal: 16, + ); + }, + itemCount: content.length + actualContentIndex, ), - itemBuilder: (context, index) { - // Injecting Map thumbnail as the first element - if (isMapEnabled && index == 0) { - return buildMapThumbnail(); - } - final actualIndex = index - actualContentIndex; - final object = content[actualIndex]; - final thumbnailRequestUrl = - '${Store.get(StoreKey.serverEndpoint)}/assets/${object.id}/thumbnail'; - return SizedBox( - width: imageSize, - height: imageSize, - child: Padding( - padding: const EdgeInsets.only(right: 10.0), - child: ThumbnailWithInfo( - imageUrl: thumbnailRequestUrl, - textInfo: object.label, - onTap: () => onTap?.call(object, actualIndex), - ), - ), - ); - }, - itemCount: content.length + actualContentIndex, ); } } diff --git a/mobile/lib/widgets/search/curated_row.dart b/mobile/lib/widgets/search/curated_row.dart deleted file mode 100644 index 96537f65b4..0000000000 --- a/mobile/lib/widgets/search/curated_row.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:immich_mobile/models/search/search_curated_content.model.dart'; -import 'package:immich_mobile/widgets/search/thumbnail_with_info.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; - -class CuratedRow extends StatelessWidget { - final List content; - final double imageSize; - - /// Callback with the content and the index when tapped - final Function(SearchCuratedContent, int)? onTap; - - const CuratedRow({ - super.key, - required this.content, - this.imageSize = 200, - this.onTap, - }); - - @override - Widget build(BuildContext context) { - // Guard empty [content] - if (content.isEmpty) { - // Return empty thumbnail - return Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: SizedBox( - width: imageSize, - height: imageSize, - child: ThumbnailWithInfo( - textInfo: '', - onTap: () {}, - ), - ), - ), - ); - } - - return ListView.builder( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric( - horizontal: 16, - ), - itemBuilder: (context, index) { - final object = content[index]; - final thumbnailRequestUrl = - '${Store.get(StoreKey.serverEndpoint)}/assets/${object.id}/thumbnail'; - return SizedBox( - width: imageSize, - height: imageSize, - child: Padding( - padding: const EdgeInsets.only(right: 4.0), - child: ThumbnailWithInfo( - imageUrl: thumbnailRequestUrl, - textInfo: object.label, - onTap: () => onTap?.call(object, index), - ), - ), - ); - }, - itemCount: content.length, - ); - } -} diff --git a/mobile/lib/widgets/search/search_filter/camera_picker.dart b/mobile/lib/widgets/search/search_filter/camera_picker.dart index ea347141a7..2e5618c9e0 100644 --- a/mobile/lib/widgets/search/search_filter/camera_picker.dart +++ b/mobile/lib/widgets/search/search_filter/camera_picker.dart @@ -5,6 +5,7 @@ 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 { @@ -12,6 +13,7 @@ class CameraPicker extends HookConsumerWidget { final Function(Map) onSelect; final SearchCameraFilter? filter; + @override Widget build(BuildContext context, WidgetRef ref) { final makeTextController = useTextEditingController(text: filter?.make); @@ -32,90 +34,73 @@ class CameraPicker extends HookConsumerWidget { ), ); - final inputDecorationTheme = InputDecorationTheme( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(20), - ), - contentPadding: const EdgeInsets.only(left: 16), + final makeWidget = SearchDropdown( + dropdownMenuEntries: switch (make) { + AsyncError() => [], + AsyncData(:final value) => value + .map( + (e) => DropdownMenuEntry( + value: e, + label: e, + ), + ) + .toList(), + _ => [], + }, + label: const Text('search_filter_camera_make').tr(), + controller: makeTextController, + leadingIcon: const Icon(Icons.photo_camera_rounded), + onSelected: (value) { + selectedMake.value = value.toString(); + onSelect({ + 'make': selectedMake.value, + 'model': selectedModel.value, + }); + }, ); - final menuStyle = MenuStyle( - shape: WidgetStatePropertyAll( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(15), - ), - ), + final modelWidget = SearchDropdown( + dropdownMenuEntries: switch (models) { + AsyncError() => [], + AsyncData(:final value) => value + .map( + (e) => DropdownMenuEntry( + value: e, + label: e, + ), + ) + .toList(), + _ => [], + }, + label: const Text('search_filter_camera_model').tr(), + controller: modelTextController, + leadingIcon: const Icon(Icons.camera), + onSelected: (value) { + selectedModel.value = value.toString(); + onSelect({ + 'make': selectedMake.value, + 'model': selectedModel.value, + }); + }, ); - return Container( - padding: const EdgeInsets.only( - // bottom: MediaQuery.of(context).viewInsets.bottom, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + if (context.isMobile) { + return Column( 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('search_filter_camera_make').tr(), - 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('search_filter_camera_model').tr(), - 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, - }); - }, - ), + makeWidget, + const SizedBox(height: 8), + modelWidget, ], - ), + ); + } + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Expanded(child: makeWidget), + const SizedBox(width: 16), + Expanded(child: modelWidget), + ], ); } } diff --git a/mobile/lib/widgets/search/search_filter/common/dropdown.dart b/mobile/lib/widgets/search/search_filter/common/dropdown.dart new file mode 100644 index 0000000000..55b54ce46a --- /dev/null +++ b/mobile/lib/widgets/search/search_filter/common/dropdown.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; + +class SearchDropdown extends StatelessWidget { + const SearchDropdown({ + super.key, + required this.dropdownMenuEntries, + required this.controller, + this.onSelected, + this.label, + this.leadingIcon, + }); + + final List> dropdownMenuEntries; + final TextEditingController controller; + final void Function(T?)? onSelected; + final Widget? label; + final Widget? leadingIcon; + + @override + Widget build(BuildContext context) { + final inputDecorationTheme = InputDecorationTheme( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + ), + contentPadding: const EdgeInsets.only(left: 16), + ); + + final menuStyle = MenuStyle( + shape: WidgetStatePropertyAll( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15), + ), + ), + ); + + return LayoutBuilder( + builder: (context, constraints) { + return DropdownMenu( + leadingIcon: leadingIcon, + width: constraints.maxWidth, + dropdownMenuEntries: dropdownMenuEntries, + label: label, + inputDecorationTheme: inputDecorationTheme, + menuStyle: menuStyle, + trailingIcon: const Icon(Icons.arrow_drop_down_rounded), + selectedTrailingIcon: const Icon(Icons.arrow_drop_up_rounded), + onSelected: onSelected, + ); + }, + ); + } +} diff --git a/mobile/lib/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart b/mobile/lib/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart index d636c8c7ce..95dc8b60e1 100644 --- a/mobile/lib/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart +++ b/mobile/lib/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart @@ -38,7 +38,10 @@ class FilterBottomSheetScaffold extends StatelessWidget { style: context.textTheme.headlineSmall, ), ), - buildChildWidget(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: buildChildWidget(), + ), Padding( padding: const EdgeInsets.all(8.0), child: Row( diff --git a/mobile/lib/widgets/search/search_filter/location_picker.dart b/mobile/lib/widgets/search/search_filter/location_picker.dart index 3aee57c3ca..595d380300 100644 --- a/mobile/lib/widgets/search/search_filter/location_picker.dart +++ b/mobile/lib/widgets/search/search_filter/location_picker.dart @@ -2,9 +2,9 @@ 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 LocationPicker extends HookConsumerWidget { @@ -48,24 +48,9 @@ class LocationPicker extends HookConsumerWidget { ), ); - final inputDecorationTheme = InputDecorationTheme( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(20), - ), - contentPadding: const EdgeInsets.only(left: 16), - ); - - final menuStyle = MenuStyle( - shape: WidgetStatePropertyAll( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(15), - ), - ), - ); - return Column( children: [ - DropdownMenu( + SearchDropdown( dropdownMenuEntries: switch (countries) { AsyncError() => [], AsyncData(:final value) => value @@ -78,14 +63,8 @@ class LocationPicker extends HookConsumerWidget { .toList(), _ => [], }, - menuHeight: 400, - width: context.width * 0.9, label: const Text('search_filter_location_country').tr(), - inputDecorationTheme: inputDecorationTheme, - menuStyle: menuStyle, controller: countryTextController, - trailingIcon: const Icon(Icons.arrow_drop_down_rounded), - selectedTrailingIcon: const Icon(Icons.arrow_drop_up_rounded), onSelected: (value) { if (value.toString() == selectedCountry.value) { return; @@ -103,7 +82,7 @@ class LocationPicker extends HookConsumerWidget { const SizedBox( height: 16, ), - DropdownMenu( + SearchDropdown( dropdownMenuEntries: switch (states) { AsyncError() => [], AsyncData(:final value) => value @@ -116,14 +95,8 @@ class LocationPicker extends HookConsumerWidget { .toList(), _ => [], }, - menuHeight: 400, - width: context.width * 0.9, label: const Text('search_filter_location_state').tr(), - inputDecorationTheme: inputDecorationTheme, - menuStyle: menuStyle, controller: stateTextController, - trailingIcon: const Icon(Icons.arrow_drop_down_rounded), - selectedTrailingIcon: const Icon(Icons.arrow_drop_up_rounded), onSelected: (value) { if (value.toString() == selectedState.value) { return; @@ -140,7 +113,7 @@ class LocationPicker extends HookConsumerWidget { const SizedBox( height: 16, ), - DropdownMenu( + SearchDropdown( dropdownMenuEntries: switch (cities) { AsyncError() => [], AsyncData(:final value) => value @@ -153,14 +126,8 @@ class LocationPicker extends HookConsumerWidget { .toList(), _ => [], }, - menuHeight: 400, - width: context.width * 0.9, label: const Text('search_filter_location_city').tr(), - 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({ diff --git a/mobile/lib/widgets/search/search_map_thumbnail.dart b/mobile/lib/widgets/search/search_map_thumbnail.dart new file mode 100644 index 0000000000..f0c36a8192 --- /dev/null +++ b/mobile/lib/widgets/search/search_map_thumbnail.dart @@ -0,0 +1,76 @@ +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:maplibre_gl/maplibre_gl.dart'; + +class SearchMapThumbnail extends StatelessWidget { + const SearchMapThumbnail({ + super.key, + this.size = 60.0, + }); + + final double size; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => context.pushRoute( + const MapRoute(), + ), + child: SizedBox.square( + dimension: size, + child: Stack( + children: [ + Padding( + padding: const EdgeInsets.only(right: 10.0), + child: MapThumbnail( + zoom: 2, + centre: const LatLng( + 47, + 5, + ), + height: size, + width: size, + showAttribution: false, + ), + ), + Padding( + padding: const EdgeInsets.only(right: 10.0), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: Colors.black, + gradient: LinearGradient( + begin: FractionalOffset.topCenter, + end: FractionalOffset.bottomCenter, + colors: [ + Colors.blueGrey.withOpacity(0.0), + Colors.black.withOpacity(0.4), + ], + stops: const [0.0, 0.4], + ), + ), + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.only(bottom: 10), + child: const Text( + "search_page_your_map", + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ).tr(), + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/widgets/search/search_row_section.dart b/mobile/lib/widgets/search/search_row_section.dart new file mode 100644 index 0000000000..352c7f6a40 --- /dev/null +++ b/mobile/lib/widgets/search/search_row_section.dart @@ -0,0 +1,37 @@ +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, + ], + ); + } +} diff --git a/mobile/lib/widgets/search/search_row_title.dart b/mobile/lib/widgets/search/search_row_title.dart index 830bc94c98..4fa0d1f854 100644 --- a/mobile/lib/widgets/search/search_row_title.dart +++ b/mobile/lib/widgets/search/search_row_title.dart @@ -3,45 +3,36 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; class SearchRowTitle extends StatelessWidget { - final Function() onViewAllPressed; - final String title; - final double top; - const SearchRowTitle({ super.key, required this.onViewAllPressed, required this.title, - this.top = 12, }); + final Function() onViewAllPressed; + final String title; + @override Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.only( - left: 16.0, - right: 16.0, - top: top, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - title, - style: context.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, + 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, ), - ), - TextButton( - onPressed: onViewAllPressed, - child: Text( - 'search_page_view_all_button', - style: context.textTheme.labelLarge?.copyWith( - color: context.primaryColor, - ), - ).tr(), - ), - ], - ), + ).tr(), + ), + ], ); } }