Sfoglia il codice sorgente

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 <alex.tran1502@gmail.com>
martyfuhry 2 anni fa
parent
commit
32a065afc7

+ 4 - 1
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"
 }

+ 15 - 0
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,
+  });
+}

+ 3 - 3
mobile/lib/modules/search/services/search.service.dart

@@ -32,13 +32,13 @@ class SearchService {
   Future<List<Asset>?> searchAsset(String searchTerm) async {
     // TODO search in local DB: 1. when offline, 2. to find local assets
     try {
-      final List<AssetResponseDto>? 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;

+ 56 - 0
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> 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,
+    );
+  }
+
+}

+ 40 - 53
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,
               ),
-            ],
+            ),
           ),
-        ),
+        ],
       ),
     );
   }

+ 47 - 0
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<List<CuratedLocationsResponseDto>> 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(),
+        ),
+      ),
+    );
+  }
+}

+ 50 - 0
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<List<CuratedObjectsResponseDto>> 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(),
+        ),
+      ),
+    );
+  }
+}

+ 146 - 99
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()),
-        ),
-        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: () {},
+      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: () {},
+            ),
+          ),
+        ),
       );
     }
 
@@ -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(),
-                            ),
-                          );
-                        },
-                      );
-                    }),
-                  ),
-                )
-              : 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: () {},
-                      );
-                    }),
+        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,
                   ),
-                );
-        },
+                  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)

+ 4 - 0
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<AssetSelectionPageResult?>(

+ 52 - 0
mobile/lib/routing/router.gr.dart

@@ -102,6 +102,18 @@ class _$AppRouter extends RootStackRouter {
         ),
       );
     },
+    CuratedLocationRoute.name: (routeData) {
+      return MaterialPageX<dynamic>(
+        routeData: routeData,
+        child: const CuratedLocationPage(),
+      );
+    },
+    CuratedObjectRoute.name: (routeData) {
+      return MaterialPageX<dynamic>(
+        routeData: routeData,
+        child: const CuratedObjectPage(),
+      );
+    },
     CreateAlbumRoute.name: (routeData) {
       final args = routeData.argsAs<CreateAlbumRouteArgs>();
       return MaterialPageX<dynamic>(
@@ -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<void> {
+  const CuratedLocationRoute()
+      : super(
+          CuratedLocationRoute.name,
+          path: '/curated-location-page',
+        );
+
+  static const String name = 'CuratedLocationRoute';
+}
+
+/// generated route for
+/// [CuratedObjectPage]
+class CuratedObjectRoute extends PageRouteInfo<void> {
+  const CuratedObjectRoute()
+      : super(
+          CuratedObjectRoute.name,
+          path: '/curated-object-page',
+        );
+
+  static const String name = 'CuratedObjectRoute';
+}
+
 /// generated route for
 /// [CreateAlbumPage]
 class CreateAlbumRoute extends PageRouteInfo<CreateAlbumRouteArgs> {

+ 2 - 0
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);
   }