From 32a065afc7d1041be1d564b66784906d0ddd2dea Mon Sep 17 00:00:00 2001 From: martyfuhry Date: Thu, 23 Mar 2023 11:08:14 -0400 Subject: [PATCH] feat(mobile): Use new search API and GridView for Places / Locations (#2043) * Use new search API and GridView for Places / Locations * Fixes search service by adding clip: true * Rebased from master, uses view all explore grid now * localized view all button * adds empty * style text * Fix issue with horizontal Things not render due to missing height info --------- Co-authored-by: Alex Tran --- mobile/assets/i18n/en-US.json | 5 +- .../search/models/curated_content.dart | 15 ++ .../search/services/search.service.dart | 6 +- .../lib/modules/search/ui/explore_grid.dart | 56 ++++ .../search/ui/thumbnail_with_info.dart | 95 +++---- .../search/views/curated_location_page.dart | 47 ++++ .../search/views/curated_object_page.dart | 50 ++++ .../lib/modules/search/views/search_page.dart | 247 +++++++++++------- mobile/lib/routing/router.dart | 4 + mobile/lib/routing/router.gr.dart | 52 ++++ mobile/lib/shared/services/api.service.dart | 2 + 11 files changed, 421 insertions(+), 158 deletions(-) create mode 100644 mobile/lib/modules/search/models/curated_content.dart create mode 100644 mobile/lib/modules/search/ui/explore_grid.dart create mode 100644 mobile/lib/modules/search/views/curated_location_page.dart create mode 100644 mobile/lib/modules/search/views/curated_object_page.dart diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 4b04bb8d69..cb39ddc331 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -246,5 +246,8 @@ "permission_onboarding_log_out": "Log out", "login_form_next_button": "Next", "album_thumbnail_shared_by": "Shared by {}", - "album_thumbnail_owned": "Owned" + "album_thumbnail_owned": "Owned", + "curated_object_page_title": "Things", + "curated_location_page_title": "Places", + "search_page_view_all_button": "View All" } diff --git a/mobile/lib/modules/search/models/curated_content.dart b/mobile/lib/modules/search/models/curated_content.dart new file mode 100644 index 0000000000..fbd0d58373 --- /dev/null +++ b/mobile/lib/modules/search/models/curated_content.dart @@ -0,0 +1,15 @@ +/// A wrapper for [CuratedLocationsResponseDto] objects +/// and [CuratedObjectsResponseDto] to be displayed in +/// a view +class CuratedContent { + /// The label to show associated with this curated object + final String label; + + /// The id to lookup the asset from the server + final String id; + + CuratedContent({ + required this.id, + required this.label, + }); +} diff --git a/mobile/lib/modules/search/services/search.service.dart b/mobile/lib/modules/search/services/search.service.dart index ae4fe55eca..936f1bc295 100644 --- a/mobile/lib/modules/search/services/search.service.dart +++ b/mobile/lib/modules/search/services/search.service.dart @@ -32,13 +32,13 @@ class SearchService { Future?> searchAsset(String searchTerm) async { // TODO search in local DB: 1. when offline, 2. to find local assets try { - final List? results = await _apiService.assetApi - .searchAsset(SearchAssetDto(searchTerm: searchTerm)); + final SearchResponseDto? results = await _apiService.searchApi + .search(query: searchTerm, clip: true); if (results == null) { return null; } // TODO local DB might be out of date; add assets not yet in DB? - return _db.assets.getAllByRemoteId(results.map((e) => e.id)); + return _db.assets.getAllByRemoteId(results.assets.items.map((e) => e.id)); } catch (e) { debugPrint("[ERROR] [searchAsset] ${e.toString()}"); return null; diff --git a/mobile/lib/modules/search/ui/explore_grid.dart b/mobile/lib/modules/search/ui/explore_grid.dart new file mode 100644 index 0000000000..b43899b1e2 --- /dev/null +++ b/mobile/lib/modules/search/ui/explore_grid.dart @@ -0,0 +1,56 @@ +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/ui/thumbnail_with_info.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/shared/models/store.dart'; + +class ExploreGrid extends StatelessWidget { + final List curatedContent; + const ExploreGrid({ + super.key, + required this.curatedContent, + }); + + @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 = + '${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/${content.id}'; + return ThumbnailWithInfo( + imageUrl: thumbnailRequestUrl, + textInfo: content.label, + onTap: () { + AutoRouter.of(context).push( + SearchResultRoute(searchTerm: content.label), + ); + }, + ); + }, + itemCount: curatedContent.length, + ); + } + +} diff --git a/mobile/lib/modules/search/ui/thumbnail_with_info.dart b/mobile/lib/modules/search/ui/thumbnail_with_info.dart index c1b22511aa..3e5e37f9c7 100644 --- a/mobile/lib/modules/search/ui/thumbnail_with_info.dart +++ b/mobile/lib/modules/search/ui/thumbnail_with_info.dart @@ -24,63 +24,50 @@ class ThumbnailWithInfo extends StatelessWidget { onTap: () { onTap(); }, - child: Padding( - padding: const EdgeInsets.only(right: 8.0), - child: SizedBox( - width: MediaQuery.of(context).size.width / 3, - child: Stack( - alignment: Alignment.bottomCenter, - children: [ - Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(25), - color: isDarkMode ? Colors.grey[900] : Colors.grey[100], - border: Border.all( - color: isDarkMode ? Colors.grey[800]! : Colors.grey[400]!, - width: 1, - ), - ), - child: imageUrl != null - ? ClipRRect( - borderRadius: BorderRadius.circular(20), - child: CachedNetworkImage( - width: 250, - height: 250, - fit: BoxFit.cover, - imageUrl: imageUrl!, - httpHeaders: { - "Authorization": - "Bearer ${Store.get(StoreKey.accessToken)}" - }, - errorWidget: (context, url, error) => - const Icon(Icons.image_not_supported_outlined), - ), - ) - : Center( - child: Icon( - noImageIcon ?? Icons.not_listed_location, - color: textAndIconColor, - ), - ), - ), - Positioned( - bottom: 12, - left: 14, - child: SizedBox( - width: MediaQuery.of(context).size.width / 3, - child: Text( - textInfo, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 12, + child: Stack( + alignment: Alignment.bottomCenter, + children: [ + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(25), + color: isDarkMode ? Colors.grey[900] : Colors.grey[100], + ), + child: imageUrl != null + ? ClipRRect( + borderRadius: BorderRadius.circular(20), + child: CachedNetworkImage( + width: double.infinity, + height: double.infinity, + fit: BoxFit.cover, + imageUrl: imageUrl!, + httpHeaders: { + "Authorization": + "Bearer ${Store.get(StoreKey.accessToken)}" + }, + errorWidget: (context, url, error) => + const Icon(Icons.image_not_supported_outlined), + ), + ) + : Center( + child: Icon( + noImageIcon ?? Icons.not_listed_location, + color: textAndIconColor, ), ), - ), - ), - ], ), - ), + Positioned( + bottom: 12, + left: 14, + child: Text( + textInfo, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + ], ), ); } diff --git a/mobile/lib/modules/search/views/curated_location_page.dart b/mobile/lib/modules/search/views/curated_location_page.dart new file mode 100644 index 0000000000..d4038a9651 --- /dev/null +++ b/mobile/lib/modules/search/views/curated_location_page.dart @@ -0,0 +1,47 @@ +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/models/curated_content.dart'; +import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart'; +import 'package:immich_mobile/modules/search/ui/explore_grid.dart'; +import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; +import 'package:openapi/api.dart'; + +class CuratedLocationPage extends HookConsumerWidget { + const CuratedLocationPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + AsyncValue> curatedLocation = + ref.watch(getCuratedLocationProvider); + + return Scaffold( + appBar: AppBar( + title: Text( + 'curated_location_page_title', + style: TextStyle( + color: Theme.of(context).primaryColor, + fontWeight: FontWeight.bold, + fontSize: 16.0, + ), + ).tr(), + ), + body: curatedLocation.when( + loading: () => const Center(child: ImmichLoadingIndicator()), + error: (err, stack) => Center( + child: Text('Error: $err'), + ), + data: (curatedLocations) => ExploreGrid( + curatedContent: curatedLocations + .map( + (l) => CuratedContent( + label: l.city, + id: l.id, + ), + ) + .toList(), + ), + ), + ); + } +} diff --git a/mobile/lib/modules/search/views/curated_object_page.dart b/mobile/lib/modules/search/views/curated_object_page.dart new file mode 100644 index 0000000000..7823950e6f --- /dev/null +++ b/mobile/lib/modules/search/views/curated_object_page.dart @@ -0,0 +1,50 @@ +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/models/curated_content.dart'; +import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart'; +import 'package:immich_mobile/modules/search/ui/explore_grid.dart'; +import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; +import 'package:immich_mobile/utils/capitalize_first_letter.dart'; +import 'package:openapi/api.dart'; + +class CuratedObjectPage extends HookConsumerWidget { + const CuratedObjectPage({ + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + AsyncValue> curatedObjects = + ref.watch(getCuratedObjectProvider); + + return Scaffold( + appBar: AppBar( + title: Text( + 'curated_object_page_title', + style: TextStyle( + color: Theme.of(context).primaryColor, + fontWeight: FontWeight.bold, + fontSize: 16.0, + ), + ).tr(), + ), + body: curatedObjects.when( + loading: () => const Center(child: ImmichLoadingIndicator()), + error: (err, stack) => Center( + child: Text('Error: $err'), + ), + data: (curatedLocations) => ExploreGrid( + curatedContent: curatedLocations + .map( + (l) => CuratedContent( + label: l.object.capitalizeFirstLetter(), + id: l.id, + ), + ) + .toList(), + ), + ), + ); + } +} diff --git a/mobile/lib/modules/search/views/search_page.dart b/mobile/lib/modules/search/views/search_page.dart index bb721a36cd..73ef4cf32b 100644 --- a/mobile/lib/modules/search/views/search_page.dart +++ b/mobile/lib/modules/search/views/search_page.dart @@ -45,51 +45,56 @@ class SearchPage extends HookConsumerWidget { } buildPlaces() { - return curatedLocation.when( - loading: () => SizedBox( - height: imageSize, - child: const Center(child: ImmichLoadingIndicator()), + return SizedBox( + height: imageSize, + child: curatedLocation.when( + loading: () => const Center(child: ImmichLoadingIndicator()), + error: (err, stack) => Center(child: Text('Error: $err')), + data: (curatedLocations) => ListView.builder( + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + scrollDirection: Axis.horizontal, + itemBuilder: (context, index) { + final locationInfo = curatedLocations[index]; + final thumbnailRequestUrl = + '${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/${locationInfo.id}'; + return SizedBox( + width: imageSize, + child: Padding( + padding: const EdgeInsets.only(right: 4.0), + child: ThumbnailWithInfo( + imageUrl: thumbnailRequestUrl, + textInfo: locationInfo.city, + onTap: () { + AutoRouter.of(context).push( + SearchResultRoute(searchTerm: locationInfo.city), + ); + }, + ), + ), + ); + }, + itemCount: curatedLocations.length.clamp(0, 10), + ), + ), + ); + } + + buildEmptyThumbnail() { + return Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: SizedBox( + width: imageSize, + height: imageSize, + child: ThumbnailWithInfo( + textInfo: '', + onTap: () {}, + ), + ), ), - error: (err, stack) => Text('Error: $err'), - data: (curatedLocations) { - return curatedLocations.isNotEmpty - ? SizedBox( - height: imageSize, - child: ListView.builder( - padding: const EdgeInsets.only(left: 16), - scrollDirection: Axis.horizontal, - itemCount: curatedLocation.value?.length, - itemBuilder: ((context, index) { - var locationInfo = curatedLocations[index]; - var thumbnailRequestUrl = - '${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/${locationInfo.id}'; - return ThumbnailWithInfo( - imageUrl: thumbnailRequestUrl, - textInfo: locationInfo.city, - onTap: () { - AutoRouter.of(context).push( - SearchResultRoute(searchTerm: locationInfo.city), - ); - }, - ); - }), - ), - ) - : SizedBox( - height: imageSize, - child: ListView.builder( - padding: const EdgeInsets.only(left: 16), - scrollDirection: Axis.horizontal, - itemCount: 1, - itemBuilder: ((context, index) { - return ThumbnailWithInfo( - textInfo: '', - onTap: () {}, - ); - }), - ), - ); - }, ); } @@ -99,51 +104,46 @@ class SearchPage extends HookConsumerWidget { height: imageSize, child: const Center(child: ImmichLoadingIndicator()), ), - error: (err, stack) => Text('Error: $err'), - data: (objects) { - return objects.isNotEmpty - ? SizedBox( - height: imageSize, - child: ListView.builder( - padding: const EdgeInsets.only(left: 16), - scrollDirection: Axis.horizontal, - itemCount: curatedObjects.value?.length, - itemBuilder: ((context, index) { - var curatedObjectInfo = objects[index]; - var thumbnailRequestUrl = - '${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/${curatedObjectInfo.id}'; - - return ThumbnailWithInfo( - imageUrl: thumbnailRequestUrl, - textInfo: curatedObjectInfo.object, - onTap: () { - AutoRouter.of(context).push( - SearchResultRoute( - searchTerm: curatedObjectInfo.object - .capitalizeFirstLetter(), - ), - ); - }, - ); - }), + error: (err, stack) => SizedBox( + height: imageSize, + child: Center(child: Text('Error: $err')), + ), + data: (objects) => objects.isEmpty + ? buildEmptyThumbnail() + : SizedBox( + height: imageSize, + child: ListView.builder( + shrinkWrap: true, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: 16, ), - ) - : SizedBox( - height: imageSize, - child: ListView.builder( - padding: const EdgeInsets.only(left: 16), - scrollDirection: Axis.horizontal, - itemCount: 1, - itemBuilder: ((context, index) { - return ThumbnailWithInfo( - textInfo: '', - noImageIcon: Icons.signal_cellular_no_sim_sharp, - onTap: () {}, - ); - }), - ), - ); - }, + itemBuilder: (context, index) { + final curatedObjectInfo = objects[index]; + final thumbnailRequestUrl = + '${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/${curatedObjectInfo.id}'; + return SizedBox( + width: imageSize, + child: Padding( + padding: const EdgeInsets.only(right: 4.0), + child: ThumbnailWithInfo( + imageUrl: thumbnailRequestUrl, + textInfo: curatedObjectInfo.object, + onTap: () { + AutoRouter.of(context).push( + SearchResultRoute( + searchTerm: curatedObjectInfo.object + .capitalizeFirstLetter(), + ), + ); + }, + ), + ), + ); + }, + itemCount: objects.length.clamp(0, 10), + ), + ), ); } @@ -160,24 +160,71 @@ class SearchPage extends HookConsumerWidget { child: Stack( children: [ ListView( - shrinkWrap: true, children: [ Padding( - padding: const EdgeInsets.all(16.0), - child: const Text( - "search_page_places", - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14), - ).tr(), + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 4.0, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + "search_page_places", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ).tr(), + TextButton( + child: Text( + 'search_page_view_all_button', + style: TextStyle( + color: Theme.of(context).primaryColor, + fontWeight: FontWeight.bold, + fontSize: 14.0, + ), + ).tr(), + onPressed: () => AutoRouter.of(context).push( + const CuratedLocationRoute(), + ), + ), + ], + ), ), buildPlaces(), Padding( - padding: const EdgeInsets.all(16.0), - child: const Text( - "search_page_things", - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14), - ).tr(), + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 4.0, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + "search_page_things", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ).tr(), + TextButton( + child: Text( + 'search_page_view_all_button', + style: TextStyle( + color: Theme.of(context).primaryColor, + fontWeight: FontWeight.bold, + fontSize: 14.0, + ), + ).tr(), + onPressed: () => AutoRouter.of(context).push( + const CuratedObjectRoute(), + ), + ), + ], + ), ), - buildThings() + buildThings(), ], ), if (isSearchEnabled) diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index c76f99304e..b85e01e864 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -21,6 +21,8 @@ import 'package:immich_mobile/modules/login/views/change_password_page.dart'; import 'package:immich_mobile/modules/login/views/login_page.dart'; import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/modules/onboarding/views/permission_onboarding_page.dart'; +import 'package:immich_mobile/modules/search/views/curated_location_page.dart'; +import 'package:immich_mobile/modules/search/views/curated_object_page.dart'; import 'package:immich_mobile/modules/search/views/search_page.dart'; import 'package:immich_mobile/modules/search/views/search_result_page.dart'; import 'package:immich_mobile/modules/settings/views/settings_page.dart'; @@ -64,6 +66,8 @@ part 'router.gr.dart'; AutoRoute(page: VideoViewerPage, guards: [AuthGuard, DuplicateGuard]), AutoRoute(page: BackupControllerPage, guards: [AuthGuard, DuplicateGuard]), AutoRoute(page: SearchResultPage, guards: [AuthGuard, DuplicateGuard]), + AutoRoute(page: CuratedLocationPage, guards: [AuthGuard, DuplicateGuard]), + AutoRoute(page: CuratedObjectPage, guards: [AuthGuard, DuplicateGuard]), AutoRoute(page: CreateAlbumPage, guards: [AuthGuard, DuplicateGuard]), AutoRoute(page: FavoritesPage, guards: [AuthGuard, DuplicateGuard]), CustomRoute( diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 091c0dc6b4..8af37a2f0b 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -102,6 +102,18 @@ class _$AppRouter extends RootStackRouter { ), ); }, + CuratedLocationRoute.name: (routeData) { + return MaterialPageX( + routeData: routeData, + child: const CuratedLocationPage(), + ); + }, + CuratedObjectRoute.name: (routeData) { + return MaterialPageX( + routeData: routeData, + child: const CuratedObjectPage(), + ); + }, CreateAlbumRoute.name: (routeData) { final args = routeData.argsAs(); return MaterialPageX( @@ -331,6 +343,22 @@ class _$AppRouter extends RootStackRouter { duplicateGuard, ], ), + RouteConfig( + CuratedLocationRoute.name, + path: '/curated-location-page', + guards: [ + authGuard, + duplicateGuard, + ], + ), + RouteConfig( + CuratedObjectRoute.name, + path: '/curated-object-page', + guards: [ + authGuard, + duplicateGuard, + ], + ), RouteConfig( CreateAlbumRoute.name, path: '/create-album-page', @@ -618,6 +646,30 @@ class SearchResultRouteArgs { } } +/// generated route for +/// [CuratedLocationPage] +class CuratedLocationRoute extends PageRouteInfo { + const CuratedLocationRoute() + : super( + CuratedLocationRoute.name, + path: '/curated-location-page', + ); + + static const String name = 'CuratedLocationRoute'; +} + +/// generated route for +/// [CuratedObjectPage] +class CuratedObjectRoute extends PageRouteInfo { + const CuratedObjectRoute() + : super( + CuratedObjectRoute.name, + path: '/curated-object-page', + ); + + static const String name = 'CuratedObjectRoute'; +} + /// generated route for /// [CreateAlbumPage] class CreateAlbumRoute extends PageRouteInfo { diff --git a/mobile/lib/shared/services/api.service.dart b/mobile/lib/shared/services/api.service.dart index 3af10cac6b..e4576214ee 100644 --- a/mobile/lib/shared/services/api.service.dart +++ b/mobile/lib/shared/services/api.service.dart @@ -14,6 +14,7 @@ class ApiService { late OAuthApi oAuthApi; late AlbumApi albumApi; late AssetApi assetApi; + late SearchApi searchApi; late ServerInfoApi serverInfoApi; late DeviceInfoApi deviceInfoApi; @@ -36,6 +37,7 @@ class ApiService { albumApi = AlbumApi(_apiClient); assetApi = AssetApi(_apiClient); serverInfoApi = ServerInfoApi(_apiClient); + searchApi = SearchApi(_apiClient); deviceInfoApi = DeviceInfoApi(_apiClient); }