diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 787cebcf45..9d88ff1a98 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -258,5 +258,9 @@ "motion_photos_page_title": "Motion Photos", "search_page_motion_photos": "Motion Photos", "search_page_recently_added": "Recently added", - "search_page_categories": "Categories" + "search_page_categories": "Categories", + "search_page_screenshots": "Screenshots", + "search_page_selfies": "Selfies", + "search_suggestion_list_smart_search_hint_1": "Smart search is enabled by default, to search for metadata use the syntax ", + "search_suggestion_list_smart_search_hint_2": "m:your-search-term" } diff --git a/mobile/flutter_01.png b/mobile/flutter_01.png deleted file mode 100644 index e496e25ffb..0000000000 Binary files a/mobile/flutter_01.png and /dev/null differ diff --git a/mobile/lib/modules/login/ui/login_form.dart b/mobile/lib/modules/login/ui/login_form.dart index 12441be464..a216bdb038 100644 --- a/mobile/lib/modules/login/ui/login_form.dart +++ b/mobile/lib/modules/login/ui/login_form.dart @@ -454,6 +454,10 @@ class EmailInput extends StatelessWidget { labelText: 'login_form_label_email'.tr(), border: const OutlineInputBorder(), hintText: 'login_form_email_hint'.tr(), + hintStyle: const TextStyle( + fontWeight: FontWeight.normal, + fontSize: 14, + ), ), validator: _validateInput, autovalidateMode: AutovalidateMode.always, @@ -487,6 +491,10 @@ class PasswordInput extends StatelessWidget { labelText: 'login_form_label_password'.tr(), border: const OutlineInputBorder(), hintText: 'login_form_password_hint'.tr(), + hintStyle: const TextStyle( + fontWeight: FontWeight.normal, + fontSize: 14, + ), ), autofillHints: const [AutofillHints.password], keyboardType: TextInputType.text, diff --git a/mobile/lib/modules/search/providers/search_result_page.provider.dart b/mobile/lib/modules/search/providers/search_result_page.provider.dart index ab93f0670a..02d3ccd0f4 100644 --- a/mobile/lib/modules/search/providers/search_result_page.provider.dart +++ b/mobile/lib/modules/search/providers/search_result_page.provider.dart @@ -18,7 +18,7 @@ class SearchResultPageNotifier extends StateNotifier { final SearchService _searchService; - void search(String searchTerm) async { + void search(String searchTerm, {bool clipEnable = true}) async { state = state.copyWith( searchResult: [], isError: false, @@ -26,7 +26,10 @@ class SearchResultPageNotifier extends StateNotifier { isSuccess: false, ); - List? assets = await _searchService.searchAsset(searchTerm); + List? assets = await _searchService.searchAsset( + searchTerm, + clipEnable: clipEnable, + ); if (assets != null) { state = state.copyWith( diff --git a/mobile/lib/modules/search/services/search.service.dart b/mobile/lib/modules/search/services/search.service.dart index 936f1bc295..746bb455fa 100644 --- a/mobile/lib/modules/search/services/search.service.dart +++ b/mobile/lib/modules/search/services/search.service.dart @@ -29,11 +29,16 @@ class SearchService { } } - Future?> searchAsset(String searchTerm) async { + Future?> searchAsset( + String searchTerm, { + bool clipEnable = true, + }) async { // TODO search in local DB: 1. when offline, 2. to find local assets try { - final SearchResponseDto? results = await _apiService.searchApi - .search(query: searchTerm, clip: true); + final SearchResponseDto? results = await _apiService.searchApi.search( + query: searchTerm, + clip: clipEnable, + ); if (results == null) { return null; } diff --git a/mobile/lib/modules/search/ui/curated_row.dart b/mobile/lib/modules/search/ui/curated_row.dart index 2c09828679..9f5130764f 100644 --- a/mobile/lib/modules/search/ui/curated_row.dart +++ b/mobile/lib/modules/search/ui/curated_row.dart @@ -6,7 +6,7 @@ import 'package:immich_mobile/shared/models/store.dart'; class CuratedRow extends StatelessWidget { final List content; final double imageSize; - + /// Callback with the content and the index when tapped final Function(CuratedContent, int)? onTap; @@ -19,7 +19,6 @@ class CuratedRow extends StatelessWidget { @override Widget build(BuildContext context) { - // Guard empty [content] if (content.isEmpty) { // Return empty thumbnail diff --git a/mobile/lib/modules/search/ui/explore_grid.dart b/mobile/lib/modules/search/ui/explore_grid.dart index b43899b1e2..788a05dd75 100644 --- a/mobile/lib/modules/search/ui/explore_grid.dart +++ b/mobile/lib/modules/search/ui/explore_grid.dart @@ -22,8 +22,7 @@ class ExploreGrid extends StatelessWidget { width: 100, child: ThumbnailWithInfo( textInfo: '', - onTap: () { - }, + onTap: () {}, ), ), ); @@ -42,9 +41,10 @@ class ExploreGrid extends StatelessWidget { return ThumbnailWithInfo( imageUrl: thumbnailRequestUrl, textInfo: content.label, + borderRadius: 0, onTap: () { AutoRouter.of(context).push( - SearchResultRoute(searchTerm: content.label), + SearchResultRoute(searchTerm: 'm:${content.label}'), ); }, ); @@ -52,5 +52,4 @@ class ExploreGrid extends StatelessWidget { itemCount: curatedContent.length, ); } - } diff --git a/mobile/lib/modules/search/ui/search_bar.dart b/mobile/lib/modules/search/ui/search_bar.dart index 0ef743f395..bec2a98427 100644 --- a/mobile/lib/modules/search/ui/search_bar.dart +++ b/mobile/lib/modules/search/ui/search_bar.dart @@ -30,7 +30,10 @@ class SearchBar extends HookConsumerWidget with PreferredSizeWidget { }, icon: const Icon(Icons.arrow_back_ios_rounded), ) - : const Icon(Icons.search_rounded), + : const Icon( + Icons.search_rounded, + size: 20, + ), title: TextField( controller: searchTermController, focusNode: searchFocusNode, @@ -55,6 +58,8 @@ class SearchBar extends HookConsumerWidget with PreferredSizeWidget { hintText: 'search_bar_hint'.tr(), hintStyle: Theme.of(context).textTheme.titleSmall?.copyWith( color: Theme.of(context).colorScheme.onSurface.withOpacity(0.5), + fontWeight: FontWeight.w500, + fontSize: 14, ), enabledBorder: const UnderlineInputBorder( borderSide: BorderSide(color: Colors.transparent), diff --git a/mobile/lib/modules/search/ui/search_result_grid.dart b/mobile/lib/modules/search/ui/search_result_grid.dart new file mode 100644 index 0000000000..14ccfb2949 --- /dev/null +++ b/mobile/lib/modules/search/ui/search_result_grid.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; + +class SearchResultGrid extends HookConsumerWidget { + const SearchResultGrid({super.key, required this.assets}); + + final List assets; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return GridView.builder( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + childAspectRatio: 1, + crossAxisSpacing: 4, + mainAxisSpacing: 4, + ), + itemCount: assets.length, + itemBuilder: (context, index) { + final asset = assets[index]; + return ThumbnailImage( + asset: asset, + assetList: assets, + useGrayBoxPlaceholder: true, + ); + }, + ); + } +} diff --git a/mobile/lib/modules/search/ui/search_suggestion_list.dart b/mobile/lib/modules/search/ui/search_suggestion_list.dart index 95feb12005..e4c6061953 100644 --- a/mobile/lib/modules/search/ui/search_suggestion_list.dart +++ b/mobile/lib/modules/search/ui/search_suggestion_list.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart'; @@ -12,6 +13,7 @@ class SearchSuggestionList extends ConsumerWidget { final searchTerm = ref.watch(searchPageStateProvider).searchTerm; final searchSuggestion = ref.watch(searchPageStateProvider).searchSuggestion; + var isDarkTheme = Theme.of(context).brightness == Brightness.dark; return Container( color: searchTerm.isEmpty @@ -19,13 +21,38 @@ class SearchSuggestionList extends ConsumerWidget { : Theme.of(context).scaffoldBackgroundColor, child: CustomScrollView( slivers: [ + SliverToBoxAdapter( + child: Container( + color: 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: Theme.of(context).textTheme.bodyMedium, + ), + TextSpan( + text: 'search_suggestion_list_smart_search_hint_2'.tr(), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).primaryColor, + fontWeight: FontWeight.bold, + ), + ) + ], + ), + ), + ), + ), + ), SliverFillRemaining( hasScrollBody: true, child: ListView.builder( itemBuilder: ((context, index) { return ListTile( onTap: () { - onSubmitted(searchSuggestion[index]); + onSubmitted("m:${searchSuggestion[index]}"); }, title: Text(searchSuggestion[index]), ); diff --git a/mobile/lib/modules/search/ui/thumbnail_with_info.dart b/mobile/lib/modules/search/ui/thumbnail_with_info.dart index 3e5e37f9c7..1d297497fe 100644 --- a/mobile/lib/modules/search/ui/thumbnail_with_info.dart +++ b/mobile/lib/modules/search/ui/thumbnail_with_info.dart @@ -1,13 +1,16 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/shared/models/store.dart'; +import 'package:immich_mobile/utils/capitalize_first_letter.dart'; +// ignore: must_be_immutable class ThumbnailWithInfo extends StatelessWidget { - const ThumbnailWithInfo({ + ThumbnailWithInfo({ Key? key, required this.textInfo, this.imageUrl, this.noImageIcon, + this.borderRadius = 10, required this.onTap, }) : super(key: key); @@ -15,6 +18,7 @@ class ThumbnailWithInfo extends StatelessWidget { final String? imageUrl; final Function onTap; final IconData? noImageIcon; + double borderRadius; @override Widget build(BuildContext context) { @@ -29,12 +33,12 @@ class ThumbnailWithInfo extends StatelessWidget { children: [ Container( decoration: BoxDecoration( - borderRadius: BorderRadius.circular(25), + borderRadius: BorderRadius.circular(borderRadius), color: isDarkMode ? Colors.grey[900] : Colors.grey[100], ), child: imageUrl != null ? ClipRRect( - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(borderRadius), child: CachedNetworkImage( width: double.infinity, height: double.infinity, @@ -55,15 +59,32 @@ class ThumbnailWithInfo extends StatelessWidget { ), ), ), + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(borderRadius), + color: Colors.white, + gradient: LinearGradient( + begin: FractionalOffset.topCenter, + end: FractionalOffset.bottomCenter, + colors: [ + Colors.grey.withOpacity(0.0), + textInfo == '' + ? Colors.black.withOpacity(0.1) + : Colors.black.withOpacity(0.5), + ], + stops: const [0.0, 1.0], + ), + ), + ), Positioned( bottom: 12, left: 14, child: Text( - textInfo, + textInfo == '' ? textInfo : textInfo.capitalizeFirstLetter(), style: const TextStyle( color: Colors.white, fontWeight: FontWeight.bold, - fontSize: 12, + fontSize: 14, ), ), ), diff --git a/mobile/lib/modules/search/views/search_page.dart b/mobile/lib/modules/search/views/search_page.dart index 06cc5e687b..9f9628179f 100644 --- a/mobile/lib/modules/search/views/search_page.dart +++ b/mobile/lib/modules/search/views/search_page.dart @@ -27,6 +27,7 @@ class SearchPage extends HookConsumerWidget { ref.watch(getCuratedObjectProvider); var isDarkTheme = Theme.of(context).brightness == Brightness.dark; double imageSize = MediaQuery.of(context).size.width / 3; + TextStyle categoryTitleStyle = const TextStyle( fontWeight: FontWeight.bold, fontSize: 14.0, @@ -46,7 +47,11 @@ class SearchPage extends HookConsumerWidget { searchFocusNode.unfocus(); ref.watch(searchPageStateProvider.notifier).disableSearch(); - AutoRouter.of(context).push(SearchResultRoute(searchTerm: searchTerm)); + AutoRouter.of(context).push( + SearchResultRoute( + searchTerm: searchTerm, + ), + ); } buildPlaces() { @@ -67,7 +72,9 @@ class SearchPage extends HookConsumerWidget { imageSize: imageSize, onTap: (content, index) { AutoRouter.of(context).push( - SearchResultRoute(searchTerm: content.label), + SearchResultRoute( + searchTerm: 'm:${content.label}', + ), ); }, ), @@ -99,7 +106,9 @@ class SearchPage extends HookConsumerWidget { imageSize: imageSize, onTap: (content, index) { AutoRouter.of(context).push( - SearchResultRoute(searchTerm: content.label), + SearchResultRoute( + searchTerm: 'm:${content.label}', + ), ); }, ), @@ -131,7 +140,7 @@ class SearchPage extends HookConsumerWidget { children: [ Text( "search_page_places", - style: Theme.of(context).textTheme.titleMedium, + style: Theme.of(context).textTheme.titleSmall, ).tr(), TextButton( child: Text( @@ -162,7 +171,7 @@ class SearchPage extends HookConsumerWidget { children: [ Text( "search_page_things", - style: Theme.of(context).textTheme.titleMedium, + style: Theme.of(context).textTheme.titleSmall, ).tr(), TextButton( child: Text( @@ -186,7 +195,7 @@ class SearchPage extends HookConsumerWidget { padding: const EdgeInsets.symmetric(horizontal: 16), child: Text( 'search_page_your_activity', - style: Theme.of(context).textTheme.titleMedium, + style: Theme.of(context).textTheme.titleSmall, ).tr(), ), ListTile( @@ -201,13 +210,7 @@ class SearchPage extends HookConsumerWidget { const FavoritesRoute(), ), ), - const Padding( - padding: EdgeInsets.only( - left: 72, - right: 16, - ), - child: Divider(), - ), + const CategoryDivider(), ListTile( leading: Icon( Icons.schedule_outlined, @@ -226,9 +229,36 @@ class SearchPage extends HookConsumerWidget { padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Text( 'search_page_categories', - style: Theme.of(context).textTheme.titleMedium, + style: Theme.of(context).textTheme.titleSmall, ).tr(), ), + ListTile( + title: Text('Screenshots', style: categoryTitleStyle).tr(), + leading: Icon( + Icons.screenshot, + color: categoryIconColor, + ), + onTap: () => AutoRouter.of(context).push( + SearchResultRoute( + searchTerm: 'screenshots', + ), + ), + ), + const CategoryDivider(), + ListTile( + title: Text('search_page_selfies', style: categoryTitleStyle) + .tr(), + leading: Icon( + Icons.photo_camera_front_outlined, + color: categoryIconColor, + ), + onTap: () => AutoRouter.of(context).push( + SearchResultRoute( + searchTerm: 'selfies', + ), + ), + ), + const CategoryDivider(), ListTile( title: Text('search_page_videos', style: categoryTitleStyle) .tr(), @@ -240,13 +270,7 @@ class SearchPage extends HookConsumerWidget { const AllVideosRoute(), ), ), - const Padding( - padding: EdgeInsets.only( - left: 72, - right: 16, - ), - child: Divider(), - ), + const CategoryDivider(), ListTile( title: Text( 'search_page_motion_photos', @@ -270,3 +294,20 @@ class SearchPage extends HookConsumerWidget { ); } } + +class CategoryDivider extends StatelessWidget { + const CategoryDivider({super.key}); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: EdgeInsets.only( + left: 72, + right: 16, + ), + child: Divider( + height: 0, + ), + ); + } +} diff --git a/mobile/lib/modules/search/views/search_result_page.dart b/mobile/lib/modules/search/views/search_result_page.dart index 83b4b0deac..d6b1ea9a9e 100644 --- a/mobile/lib/modules/search/views/search_result_page.dart +++ b/mobile/lib/modules/search/views/search_result_page.dart @@ -6,12 +6,30 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart'; import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart'; import 'package:immich_mobile/modules/search/providers/search_result_page.provider.dart'; +import 'package:immich_mobile/modules/search/ui/search_result_grid.dart'; import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart'; import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; +class SearchType { + SearchType({required this.isClip, required this.searchTerm}); + + final bool isClip; + final String searchTerm; +} + +SearchType _getSearchType(String searchTerm) { + if (searchTerm.startsWith('m:')) { + return SearchType(isClip: false, searchTerm: searchTerm.substring(2)); + } else { + return SearchType(isClip: true, searchTerm: searchTerm); + } +} + class SearchResultPage extends HookConsumerWidget { - const SearchResultPage({Key? key, required this.searchTerm}) - : super(key: key); + const SearchResultPage({ + Key? key, + required this.searchTerm, + }) : super(key: key); final String searchTerm; @@ -20,6 +38,8 @@ class SearchResultPage extends HookConsumerWidget { final searchTermController = useTextEditingController(text: ""); final isNewSearch = useState(false); final currentSearchTerm = useState(searchTerm); + final isDarkTheme = Theme.of(context).brightness == Brightness.dark; + final isDisplayDateGroup = useState(true); FocusNode? searchFocusNode; @@ -27,9 +47,16 @@ class SearchResultPage extends HookConsumerWidget { () { searchFocusNode = FocusNode(); + var searchType = _getSearchType(searchTerm); + searchType.isClip + ? isDisplayDateGroup.value = false + : isDisplayDateGroup.value = true; + Future.delayed( Duration.zero, - () => ref.read(searchResultPageProvider.notifier).search(searchTerm), + () => ref + .read(searchResultPageProvider.notifier) + .search(searchType.searchTerm, clipEnable: searchType.isClip), ); return () => searchFocusNode?.dispose(); }, @@ -41,7 +68,15 @@ class SearchResultPage extends HookConsumerWidget { searchFocusNode?.unfocus(); isNewSearch.value = false; currentSearchTerm.value = newSearchTerm; - ref.watch(searchResultPageProvider.notifier).search(newSearchTerm); + + var searchType = _getSearchType(newSearchTerm); + searchType.isClip + ? isDisplayDateGroup.value = false + : isDisplayDateGroup.value = true; + + ref + .watch(searchResultPageProvider.notifier) + .search(searchType.searchTerm, clipEnable: searchType.isClip); } buildTextField() { @@ -74,6 +109,12 @@ class SearchResultPage extends HookConsumerWidget { focusedBorder: const UnderlineInputBorder( borderSide: BorderSide(color: Colors.transparent), ), + hintStyle: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16.0, + color: + isDarkTheme ? Colors.grey[500] : Colors.black.withOpacity(0.5), + ), ), ); } @@ -121,11 +162,16 @@ class SearchResultPage extends HookConsumerWidget { return const Center(child: ImmichLoadingIndicator()); } - if (searchResultPageState.isSuccess) { - return ImmichAssetGrid( + if (isDisplayDateGroup.value) { + return ImmichAssetGrid( assets: allSearchAssets, - ); + ); + } else { + return SearchResultGrid( + assets: allSearchAssets, + ); + } } return const SizedBox(); diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index d8a9ada72d..02b7ce5452 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -144,14 +144,9 @@ class _$AppRouter extends RootStackRouter { ); }, RecentlyAddedRoute.name: (routeData) { - return CustomPage( + return MaterialPageX( routeData: routeData, child: const RecentlyAddedPage(), - transitionsBuilder: TransitionsBuilders.noTransition, - durationInMilliseconds: 200, - reverseDurationInMilliseconds: 200, - opaque: true, - barrierDismissible: false, ); }, AssetSelectionRoute.name: (routeData) { diff --git a/mobile/lib/utils/immich_app_theme.dart b/mobile/lib/utils/immich_app_theme.dart index 77571781f8..b7e8559f1e 100644 --- a/mobile/lib/utils/immich_app_theme.dart +++ b/mobile/lib/utils/immich_app_theme.dart @@ -79,6 +79,7 @@ ThemeData immichLightTheme = ThemeData( ), titleSmall: TextStyle( fontSize: 16.0, + fontWeight: FontWeight.bold, ), titleMedium: TextStyle( fontSize: 18.0, @@ -176,6 +177,7 @@ ThemeData immichDarkTheme = ThemeData( ), titleSmall: const TextStyle( fontSize: 16.0, + fontWeight: FontWeight.bold, ), titleMedium: const TextStyle( fontSize: 18.0, @@ -185,7 +187,6 @@ ThemeData immichDarkTheme = ThemeData( fontSize: 26.0, fontWeight: FontWeight.bold, ), - ), cardColor: Colors.grey[900], elevatedButtonTheme: ElevatedButtonThemeData( diff --git a/mobile/test/favorite_provider_test.mocks.dart b/mobile/test/favorite_provider_test.mocks.dart index 0447cba0f2..a42bc13429 100644 --- a/mobile/test/favorite_provider_test.mocks.dart +++ b/mobile/test/favorite_provider_test.mocks.dart @@ -187,6 +187,16 @@ class MockAssetNotifier extends _i1.Mock implements _i2.AssetNotifier { returnValueForMissingStub: _i5.Future.value(), ) as _i5.Future); @override + _i5.Future getAllAsset({bool? clear = false}) => (super.noSuchMethod( + Invocation.method( + #getAllAsset, + [], + {#clear: clear}, + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + @override _i5.Future clearAllAsset() => (super.noSuchMethod( Invocation.method( #clearAllAsset,