Sfoglia il codice sorgente

feat(mobile): Explore favorites, recently added, videos, and motion photos (#2076)

* Added placeholder for search explore

* refactor immich asset grid to use ref and provider

* all videos page

* got favorites, recently added, videos, and motion videos all using the immich grid

* Fixed issue with hero animations

* theming

* localization

* delete empty file

* style text

* Styling icons

* more styling

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
martyfuhry 2 anni fa
parent
commit
501b96baf7
23 ha cambiato i file con 1011 aggiunte e 522 eliminazioni
  1. 10 1
      mobile/assets/i18n/en-US.json
  2. 17 0
      mobile/lib/modules/asset_viewer/providers/render_list.provider.dart
  3. 3 37
      mobile/lib/modules/favorite/views/favorites_page.dart
  4. 86 282
      mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart
  5. 300 0
      mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart
  6. 1 1
      mobile/lib/modules/home/views/home_page.dart
  7. 29 0
      mobile/lib/modules/search/providers/all_motion_photos.provider.dart
  8. 28 0
      mobile/lib/modules/search/providers/all_video_assets.provider.dart
  9. 20 0
      mobile/lib/modules/search/providers/recently_added.provider.dart
  10. 2 13
      mobile/lib/modules/search/providers/search_result_page.provider.dart
  11. 67 0
      mobile/lib/modules/search/ui/curated_row.dart
  12. 35 0
      mobile/lib/modules/search/views/all_motion_videos_page.dart
  13. 35 0
      mobile/lib/modules/search/views/all_videos_page.dart
  14. 5 0
      mobile/lib/modules/search/views/curated_location_page.dart
  15. 5 0
      mobile/lib/modules/search/views/curated_object_page.dart
  16. 35 0
      mobile/lib/modules/search/views/recently_added_page.dart
  17. 135 100
      mobile/lib/modules/search/views/search_page.dart
  18. 3 23
      mobile/lib/modules/search/views/search_result_page.dart
  19. 6 0
      mobile/lib/routing/router.dart
  20. 83 0
      mobile/lib/routing/router.gr.dart
  21. 4 1
      mobile/lib/shared/views/tab_controller_page.dart
  22. 27 0
      mobile/lib/utils/immich_app_theme.dart
  23. 75 64
      mobile/openapi/lib/model/asset_response_dto.dart

+ 10 - 1
mobile/assets/i18n/en-US.json

@@ -249,5 +249,14 @@
   "album_thumbnail_owned": "Owned",
   "curated_object_page_title": "Things",
   "curated_location_page_title": "Places",
-  "search_page_view_all_button": "View All"
+  "search_page_view_all_button": "View all",
+  "search_page_your_activity": "Your activity",
+  "search_page_favorites": "Favorites",
+  "search_page_videos": "Videos",
+  "all_videos_page_title": "Videos",
+  "recently_added_page_title": "Recently Added",
+  "motion_photos_page_title": "Motion Photos",
+  "search_page_motion_photos": "Motion Photos",
+  "search_page_recently_added": "Recently added",
+  "search_page_categories": "Categories"
 }

+ 17 - 0
mobile/lib/modules/asset_viewer/providers/render_list.provider.dart

@@ -0,0 +1,17 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
+import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
+import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
+
+final renderListProvider = FutureProvider.family<RenderList, List<Asset>>((ref, assets) {
+  var settings = ref.watch(appSettingsServiceProvider);
+
+  final layout = AssetGridLayoutParameters(
+    settings.getSetting(AppSettingsEnum.tilesPerRow),
+    settings.getSetting(AppSettingsEnum.dynamicLayout),
+    GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)],
+  );
+
+  return RenderList.fromAssets(assets, layout);
+});

+ 3 - 37
mobile/lib/modules/favorite/views/favorites_page.dart

@@ -3,9 +3,7 @@ import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart';
-import 'package:immich_mobile/modules/favorite/ui/favorite_image.dart';
-import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
-import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
+import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
 
 class FavoritesPage extends HookConsumerWidget {
   const FavoritesPage({Key? key}) : super(key: key);
@@ -22,46 +20,14 @@ class FavoritesPage extends HookConsumerWidget {
         automaticallyImplyLeading: false,
         title: const Text(
           'favorites_page_title',
-          style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
         ).tr(),
       );
     }
 
-    Widget buildImageGrid() {
-      final appSettingService = ref.watch(appSettingsServiceProvider);
-
-      if (ref.watch(favoriteAssetProvider).isNotEmpty) {
-        return SliverPadding(
-          padding: const EdgeInsets.only(top: 10.0),
-          sliver: SliverGrid(
-            gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
-              crossAxisCount:
-                  appSettingService.getSetting(AppSettingsEnum.tilesPerRow),
-              crossAxisSpacing: 5.0,
-              mainAxisSpacing: 5,
-            ),
-            delegate: SliverChildBuilderDelegate(
-              (
-                BuildContext context,
-                int index,
-              ) {
-                return FavoriteImage(
-                  ref.watch(favoriteAssetProvider)[index],
-                  ref.watch(favoriteAssetProvider),
-                );
-              },
-              childCount: ref.watch(favoriteAssetProvider).length,
-            ),
-          ),
-        );
-      }
-      return const SliverToBoxAdapter();
-    }
-
     return Scaffold(
       appBar: buildAppBar(),
-      body: CustomScrollView(
-        slivers: [buildImageGrid()],
+      body: ImmichAssetGrid(
+        assets: ref.watch(favoriteAssetProvider),
       ),
     );
   }

+ 86 - 282
mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart

@@ -1,300 +1,104 @@
-import 'dart:collection';
-
-import 'package:collection/collection.dart';
-import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
-import 'package:immich_mobile/modules/asset_viewer/providers/scroll_notifier.provider.dart';
-import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/asset_viewer/providers/render_list.provider.dart';
+import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
+import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid_view.dart';
+import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
+import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
-import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
-import 'asset_grid_data_structure.dart';
-import 'group_divider_title.dart';
-import 'disable_multi_select_button.dart';
-import 'draggable_scrollbar_custom.dart';
-
-typedef ImmichAssetGridSelectionListener = void Function(
-  bool,
-  Set<Asset>,
-);
-
-class ImmichAssetGridState extends State<ImmichAssetGrid> {
-  final ItemScrollController _itemScrollController = ItemScrollController();
-  final ItemPositionsListener _itemPositionsListener =
-      ItemPositionsListener.create();
-
-  bool _scrolling = false;
-  final Set<int> _selectedAssets = HashSet();
+import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
 
-  Set<Asset> _getSelectedAssets() {
-    return _selectedAssets
-        .map((e) => widget.allAssets.firstWhereOrNull((a) => a.id == e))
-        .whereNotNull()
-        .toSet();
-  }
+class ImmichAssetGrid extends HookConsumerWidget {
+  final int? assetsPerRow;
+  final double margin;
+  final bool? showStorageIndicator;
+  final ImmichAssetGridSelectionListener? listener;
+  final bool selectionActive;
+  final List<Asset> assets;
+  final RenderList? renderList;
 
-  void _callSelectionListener(bool selectionActive) {
-    widget.listener?.call(selectionActive, _getSelectedAssets());
-  }
+  const ImmichAssetGrid({
+    super.key,
+    required this.assets,
+    this.renderList,
+    this.assetsPerRow,
+    this.showStorageIndicator,
+    this.listener,
+    this.margin = 5.0,
+    this.selectionActive = false,
+  });
 
-  void _selectAssets(List<Asset> assets) {
-    setState(() {
-      for (var e in assets) {
-        _selectedAssets.add(e.id);
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    var settings = ref.watch(appSettingsServiceProvider);
+    final renderListFuture = ref.watch(renderListProvider(assets));
+
+    // Needs to suppress hero animations when navigating to this widget
+    final enableHeroAnimations = useState(false);
+
+    // Wait for transition to complete, then re-enable
+    ModalRoute.of(context)?.animation?.addListener(() {
+      // If we've already enabled, we are done
+      if (enableHeroAnimations.value) {
+        return;
       }
-      _callSelectionListener(true);
-    });
-  }
-
-  void _deselectAssets(List<Asset> assets) {
-    setState(() {
-      for (var e in assets) {
-        _selectedAssets.remove(e.id);
+      final animation = ModalRoute.of(context)?.animation;
+      if (animation != null) {
+        // When the animation is complete, re-enable hero animations
+        enableHeroAnimations.value = animation.isCompleted;
       }
-      _callSelectionListener(_selectedAssets.isNotEmpty);
-    });
-  }
-
-  void _deselectAll() {
-    setState(() {
-      _selectedAssets.clear();
     });
 
-    _callSelectionListener(false);
-  }
-
-  bool _allAssetsSelected(List<Asset> assets) {
-    return widget.selectionActive &&
-        assets.firstWhereOrNull((e) => !_selectedAssets.contains(e.id)) == null;
-  }
-
-  Widget _buildThumbnailOrPlaceholder(
-    Asset asset,
-    bool placeholder,
-  ) {
-    if (placeholder) {
-      return const DecoratedBox(
-        decoration: BoxDecoration(color: Colors.grey),
-      );
+    Future<bool> onWillPop() async {
+      enableHeroAnimations.value = false;
+      return true;
     }
-    return ThumbnailImage(
-      asset: asset,
-      assetList: widget.allAssets,
-      multiselectEnabled: widget.selectionActive,
-      isSelected: widget.selectionActive && _selectedAssets.contains(asset.id),
-      onSelect: () => _selectAssets([asset]),
-      onDeselect: () => _deselectAssets([asset]),
-      useGrayBoxPlaceholder: true,
-      showStorageIndicator: widget.showStorageIndicator,
-    );
-  }
-
-  Widget _buildAssetRow(
-    BuildContext context,
-    RenderAssetGridRow row,
-    bool scrolling,
-  ) {
-    return LayoutBuilder(
-      builder: (context, constraints) {
-        final size = constraints.maxWidth / widget.assetsPerRow -
-            widget.margin * (widget.assetsPerRow - 1) / widget.assetsPerRow;
-        return Row(
-          key: Key("asset-row-${row.assets.first.id}"),
-          children: row.assets.mapIndexed((int index, Asset asset) {
-            bool last = asset.id == row.assets.last.id;
-
-            return Container(
-              key: Key("asset-${asset.id}"),
-              width: size * row.widthDistribution[index],
-              height: size,
-              margin: EdgeInsets.only(
-                top: widget.margin,
-                right: last ? 0.0 : widget.margin,
-              ),
-              child: _buildThumbnailOrPlaceholder(asset, scrolling),
-            );
-          }).toList(),
-        );
-      },
-    );
-  }
-
-  Widget _buildTitle(
-    BuildContext context,
-    String title,
-    List<Asset> assets,
-  ) {
-    return GroupDividerTitle(
-      text: title,
-      multiselectEnabled: widget.selectionActive,
-      onSelect: () => _selectAssets(assets),
-      onDeselect: () => _deselectAssets(assets),
-      selected: _allAssetsSelected(assets),
-    );
-  }
 
-  Widget _buildMonthTitle(BuildContext context, String title) {
-    return Padding(
-      key: Key("month-$title"),
-      padding: const EdgeInsets.only(left: 12.0, top: 32),
-      child: Text(
-        title,
-        style: TextStyle(
-          fontSize: 26,
-          fontWeight: FontWeight.bold,
-          color: Theme.of(context).textTheme.displayLarge?.color,
+    if (renderList != null) {
+      return WillPopScope(
+        onWillPop: onWillPop,
+        child: HeroMode(
+          enabled: enableHeroAnimations.value,
+          child: ImmichAssetGridView(
+            allAssets: assets,
+            assetsPerRow: assetsPerRow 
+              ?? settings.getSetting(AppSettingsEnum.tilesPerRow),
+            listener: listener,
+            showStorageIndicator: showStorageIndicator 
+              ?? settings.getSetting(AppSettingsEnum.storageIndicator),
+            renderList: renderList!,
+            margin: margin,
+            selectionActive: selectionActive,
+          ),
         ),
-      ),
-    );
-  }
-
-  Widget _itemBuilder(BuildContext c, int position) {
-    final item = widget.renderList.elements[position];
-
-    if (item.type == RenderAssetGridElementType.groupDividerTitle) {
-      return _buildTitle(c, item.title!, item.relatedAssetList!);
-    } else if (item.type == RenderAssetGridElementType.monthTitle) {
-      return _buildMonthTitle(c, item.title!);
-    } else if (item.type == RenderAssetGridElementType.assetRow) {
-      return _buildAssetRow(c, item.assetRow!, _scrolling);
-    }
-
-    return const Text("Invalid widget type!");
-  }
-
-  Text _labelBuilder(int pos) {
-    final date = widget.renderList.elements[pos].date;
-    return Text(
-      DateFormat.yMMMM().format(date),
-      style: const TextStyle(
-        color: Colors.white,
-        fontWeight: FontWeight.bold,
-      ),
-    );
-  }
-
-  Widget _buildMultiSelectIndicator() {
-    return DisableMultiSelectButton(
-      onPressed: () => _deselectAll(),
-      selectedItemCount: _selectedAssets.length,
-    );
-  }
-
-  Widget _buildAssetGrid() {
-    final useDragScrolling = widget.allAssets.length >= 20;
-
-    void dragScrolling(bool active) {
-      setState(() {
-        _scrolling = active;
-      });
-    }
-
-    final listWidget = ScrollablePositionedList.builder(
-      padding: const EdgeInsets.only(
-        bottom: 220,
-      ),
-      itemBuilder: _itemBuilder,
-      itemPositionsListener: _itemPositionsListener,
-      itemScrollController: _itemScrollController,
-      itemCount: widget.renderList.elements.length,
-      addRepaintBoundaries: true,
-    );
-
-    if (!useDragScrolling) {
-      return listWidget;
-    }
-
-    return DraggableScrollbar.semicircle(
-      scrollStateListener: dragScrolling,
-      itemPositionsListener: _itemPositionsListener,
-      controller: _itemScrollController,
-      backgroundColor: Theme.of(context).hintColor,
-      labelTextBuilder: _labelBuilder,
-      labelConstraints: const BoxConstraints(maxHeight: 28),
-      scrollbarAnimationDuration: const Duration(seconds: 1),
-      scrollbarTimeToFade: const Duration(seconds: 4),
-      child: listWidget,
-    );
-  }
-
-  @override
-  void didUpdateWidget(ImmichAssetGrid oldWidget) {
-    super.didUpdateWidget(oldWidget);
-    if (!widget.selectionActive) {
-      setState(() {
-        _selectedAssets.clear();
-      });
-    }
-  }
-
-  Future<bool> onWillPop() async {
-    if (widget.selectionActive && _selectedAssets.isNotEmpty) {
-      _deselectAll();
-      return false;
+      );
     }
 
-    return true;
-  }
-
-  @override
-  void initState() {
-    super.initState();
-    scrollToTopNotifierProvider.addListener(_scrollToTop);
-  }
-
-  @override
-  void dispose() {
-    scrollToTopNotifierProvider.removeListener(_scrollToTop);
-    super.dispose();
-  }
-
-  void _scrollToTop() {
-    // for some reason, this is necessary as well in order 
-    // to correctly reposition the drag thumb scroll bar
-    _itemScrollController.jumpTo(
-      index: 0,
-    );
-    _itemScrollController.scrollTo(
-      index: 0,
-      duration: const Duration(milliseconds: 200),
-    );
-  }
-
-  @override
-  Widget build(BuildContext context) {
-    return WillPopScope(
-      onWillPop: onWillPop,
-      child: Stack(
-        children: [
-          _buildAssetGrid(),
-          if (widget.selectionActive) _buildMultiSelectIndicator(),
-        ],
+    return renderListFuture.when(
+      data: (renderList) =>
+        WillPopScope(
+          onWillPop: onWillPop,
+          child: HeroMode(
+            enabled: enableHeroAnimations.value,
+            child: ImmichAssetGridView(
+              allAssets: assets,
+              assetsPerRow: assetsPerRow 
+                ?? settings.getSetting(AppSettingsEnum.tilesPerRow),
+              listener: listener,
+              showStorageIndicator: showStorageIndicator 
+                ?? settings.getSetting(AppSettingsEnum.storageIndicator),
+              renderList: renderList,
+              margin: margin,
+              selectionActive: selectionActive,
+            ),
+          ),
+        ),
+      error: (err, stack) =>
+        Center(child: Text("$err")),
+      loading: () => const Center(
+        child: ImmichLoadingIndicator(),
       ),
     );
   }
 }
-
-class ImmichAssetGrid extends StatefulWidget {
-  final RenderList renderList;
-  final int assetsPerRow;
-  final double margin;
-  final bool showStorageIndicator;
-  final ImmichAssetGridSelectionListener? listener;
-  final bool selectionActive;
-  final List<Asset> allAssets;
-
-  const ImmichAssetGrid({
-    super.key,
-    required this.renderList,
-    required this.allAssets,
-    required this.assetsPerRow,
-    required this.showStorageIndicator,
-    this.listener,
-    this.margin = 5.0,
-    this.selectionActive = false,
-  });
-
-  @override
-  State<StatefulWidget> createState() {
-    return ImmichAssetGridState();
-  }
-}

+ 300 - 0
mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart

@@ -0,0 +1,300 @@
+import 'dart:collection';
+
+import 'package:collection/collection.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/material.dart';
+import 'package:immich_mobile/modules/asset_viewer/providers/scroll_notifier.provider.dart';
+import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
+import 'asset_grid_data_structure.dart';
+import 'group_divider_title.dart';
+import 'disable_multi_select_button.dart';
+import 'draggable_scrollbar_custom.dart';
+
+typedef ImmichAssetGridSelectionListener = void Function(
+  bool,
+  Set<Asset>,
+);
+
+class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
+  final ItemScrollController _itemScrollController = ItemScrollController();
+  final ItemPositionsListener _itemPositionsListener =
+      ItemPositionsListener.create();
+
+  bool _scrolling = false;
+  final Set<int> _selectedAssets = HashSet();
+
+  Set<Asset> _getSelectedAssets() {
+    return _selectedAssets
+        .map((e) => widget.allAssets.firstWhereOrNull((a) => a.id == e))
+        .whereNotNull()
+        .toSet();
+  }
+
+  void _callSelectionListener(bool selectionActive) {
+    widget.listener?.call(selectionActive, _getSelectedAssets());
+  }
+
+  void _selectAssets(List<Asset> assets) {
+    setState(() {
+      for (var e in assets) {
+        _selectedAssets.add(e.id);
+      }
+      _callSelectionListener(true);
+    });
+  }
+
+  void _deselectAssets(List<Asset> assets) {
+    setState(() {
+      for (var e in assets) {
+        _selectedAssets.remove(e.id);
+      }
+      _callSelectionListener(_selectedAssets.isNotEmpty);
+    });
+  }
+
+  void _deselectAll() {
+    setState(() {
+      _selectedAssets.clear();
+    });
+
+    _callSelectionListener(false);
+  }
+
+  bool _allAssetsSelected(List<Asset> assets) {
+    return widget.selectionActive &&
+        assets.firstWhereOrNull((e) => !_selectedAssets.contains(e.id)) == null;
+  }
+
+  Widget _buildThumbnailOrPlaceholder(
+    Asset asset,
+    bool placeholder,
+  ) {
+    if (placeholder) {
+      return const DecoratedBox(
+        decoration: BoxDecoration(color: Colors.grey),
+      );
+    }
+    return ThumbnailImage(
+      asset: asset,
+      assetList: widget.allAssets,
+      multiselectEnabled: widget.selectionActive,
+      isSelected: widget.selectionActive && _selectedAssets.contains(asset.id),
+      onSelect: () => _selectAssets([asset]),
+      onDeselect: () => _deselectAssets([asset]),
+      useGrayBoxPlaceholder: true,
+      showStorageIndicator: widget.showStorageIndicator,
+    );
+  }
+
+  Widget _buildAssetRow(
+    BuildContext context,
+    RenderAssetGridRow row,
+    bool scrolling,
+  ) {
+    return LayoutBuilder(
+      builder: (context, constraints) {
+        final size = constraints.maxWidth / widget.assetsPerRow -
+            widget.margin * (widget.assetsPerRow - 1) / widget.assetsPerRow;
+        return Row(
+          key: Key("asset-row-${row.assets.first.id}"),
+          children: row.assets.mapIndexed((int index, Asset asset) {
+            bool last = asset.id == row.assets.last.id;
+
+            return Container(
+              key: Key("asset-${asset.id}"),
+              width: size * row.widthDistribution[index],
+              height: size,
+              margin: EdgeInsets.only(
+                top: widget.margin,
+                right: last ? 0.0 : widget.margin,
+              ),
+              child: _buildThumbnailOrPlaceholder(asset, scrolling),
+            );
+          }).toList(),
+        );
+      },
+    );
+  }
+
+  Widget _buildTitle(
+    BuildContext context,
+    String title,
+    List<Asset> assets,
+  ) {
+    return GroupDividerTitle(
+      text: title,
+      multiselectEnabled: widget.selectionActive,
+      onSelect: () => _selectAssets(assets),
+      onDeselect: () => _deselectAssets(assets),
+      selected: _allAssetsSelected(assets),
+    );
+  }
+
+  Widget _buildMonthTitle(BuildContext context, String title) {
+    return Padding(
+      key: Key("month-$title"),
+      padding: const EdgeInsets.only(left: 12.0, top: 32),
+      child: Text(
+        title,
+        style: TextStyle(
+          fontSize: 26,
+          fontWeight: FontWeight.bold,
+          color: Theme.of(context).textTheme.displayLarge?.color,
+        ),
+      ),
+    );
+  }
+
+  Widget _itemBuilder(BuildContext c, int position) {
+    final item = widget.renderList.elements[position];
+
+    if (item.type == RenderAssetGridElementType.groupDividerTitle) {
+      return _buildTitle(c, item.title!, item.relatedAssetList!);
+    } else if (item.type == RenderAssetGridElementType.monthTitle) {
+      return _buildMonthTitle(c, item.title!);
+    } else if (item.type == RenderAssetGridElementType.assetRow) {
+      return _buildAssetRow(c, item.assetRow!, _scrolling);
+    }
+
+    return const Text("Invalid widget type!");
+  }
+
+  Text _labelBuilder(int pos) {
+    final date = widget.renderList.elements[pos].date;
+    return Text(
+      DateFormat.yMMMM().format(date),
+      style: const TextStyle(
+        color: Colors.white,
+        fontWeight: FontWeight.bold,
+      ),
+    );
+  }
+
+  Widget _buildMultiSelectIndicator() {
+    return DisableMultiSelectButton(
+      onPressed: () => _deselectAll(),
+      selectedItemCount: _selectedAssets.length,
+    );
+  }
+
+  Widget _buildAssetGrid() {
+    final useDragScrolling = widget.allAssets.length >= 20;
+
+    void dragScrolling(bool active) {
+      setState(() {
+        _scrolling = active;
+      });
+    }
+
+    final listWidget = ScrollablePositionedList.builder(
+      padding: const EdgeInsets.only(
+        bottom: 220,
+      ),
+      itemBuilder: _itemBuilder,
+      itemPositionsListener: _itemPositionsListener,
+      itemScrollController: _itemScrollController,
+      itemCount: widget.renderList.elements.length,
+      addRepaintBoundaries: true,
+    );
+
+    if (!useDragScrolling) {
+      return listWidget;
+    }
+
+    return DraggableScrollbar.semicircle(
+      scrollStateListener: dragScrolling,
+      itemPositionsListener: _itemPositionsListener,
+      controller: _itemScrollController,
+      backgroundColor: Theme.of(context).hintColor,
+      labelTextBuilder: _labelBuilder,
+      labelConstraints: const BoxConstraints(maxHeight: 28),
+      scrollbarAnimationDuration: const Duration(seconds: 1),
+      scrollbarTimeToFade: const Duration(seconds: 4),
+      child: listWidget,
+    );
+  }
+
+  @override
+  void didUpdateWidget(ImmichAssetGridView oldWidget) {
+    super.didUpdateWidget(oldWidget);
+    if (!widget.selectionActive) {
+      setState(() {
+        _selectedAssets.clear();
+      });
+    }
+  }
+
+  Future<bool> onWillPop() async {
+    if (widget.selectionActive && _selectedAssets.isNotEmpty) {
+      _deselectAll();
+      return false;
+    }
+
+    return true;
+  }
+
+  @override
+  void initState() {
+    super.initState();
+    scrollToTopNotifierProvider.addListener(_scrollToTop);
+  }
+
+  @override
+  void dispose() {
+    scrollToTopNotifierProvider.removeListener(_scrollToTop);
+    super.dispose();
+  }
+
+  void _scrollToTop() {
+    // for some reason, this is necessary as well in order 
+    // to correctly reposition the drag thumb scroll bar
+    _itemScrollController.jumpTo(
+      index: 0,
+    );
+    _itemScrollController.scrollTo(
+      index: 0,
+      duration: const Duration(milliseconds: 200),
+    );
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return WillPopScope(
+      onWillPop: onWillPop,
+      child: Stack(
+        children: [
+          _buildAssetGrid(),
+          if (widget.selectionActive) _buildMultiSelectIndicator(),
+        ],
+      ),
+    );
+  }
+}
+
+class ImmichAssetGridView extends StatefulWidget {
+  final RenderList renderList;
+  final int assetsPerRow;
+  final double margin;
+  final bool showStorageIndicator;
+  final ImmichAssetGridSelectionListener? listener;
+  final bool selectionActive;
+  final List<Asset> allAssets;
+
+  const ImmichAssetGridView({
+    super.key,
+    required this.renderList,
+    required this.allAssets,
+    required this.assetsPerRow,
+    required this.showStorageIndicator,
+    this.listener,
+    this.margin = 5.0,
+    this.selectionActive = false,
+  });
+
+  @override
+  State<StatefulWidget> createState() {
+    return ImmichAssetGridViewState();
+  }
+}

+ 1 - 1
mobile/lib/modules/home/views/home_page.dart

@@ -234,7 +234,7 @@ class HomePage extends HookConsumerWidget {
                 ? buildLoadingIndicator()
                 : ImmichAssetGrid(
                     renderList: ref.watch(assetProvider).renderList!,
-                    allAssets: ref.watch(assetProvider).allAssets,
+                    assets: ref.watch(assetProvider).allAssets,
                     assetsPerRow: appSettingService
                         .getSetting(AppSettingsEnum.tilesPerRow),
                     showStorageIndicator: appSettingService

+ 29 - 0
mobile/lib/modules/search/providers/all_motion_photos.provider.dart

@@ -0,0 +1,29 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:immich_mobile/shared/providers/api.provider.dart';
+import 'package:immich_mobile/shared/providers/db.provider.dart';
+
+final allMotionPhotosProvider = FutureProvider<List<Asset>>( (ref) async {
+  final search = await ref.watch(apiServiceProvider).searchApi.search(
+    motion: true,
+  );
+
+  if (search == null) {
+    return [];
+  }
+
+  return ref.watch(dbProvider)
+      .assets
+      .getAllByRemoteId(
+        search.assets.items.map((e) => e.id),
+      );
+
+
+  /// This works offline, but we use the above
+  /*
+     return ref.watch(dbProvider).assets
+      .filter()
+      .livePhotoVideoIdIsNotNull()
+      .findAll();
+  */
+});

+ 28 - 0
mobile/lib/modules/search/providers/all_video_assets.provider.dart

@@ -0,0 +1,28 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:immich_mobile/shared/providers/api.provider.dart';
+import 'package:immich_mobile/shared/providers/db.provider.dart';
+
+final allVideoAssetsProvider = FutureProvider<List<Asset>>( (ref) async {
+  final search = await ref.watch(apiServiceProvider).searchApi.search(
+    type: 'VIDEO',
+  );
+
+  if (search == null) {
+    return [];
+  }
+
+  return ref.watch(dbProvider)
+      .assets
+      .getAllByRemoteId(
+        search.assets.items.map((e) => e.id),
+      );
+
+  /// This works offline, but we use the above
+  /* 
+  return ref.watch(dbProvider).assets
+      .filter()
+      .durationInSecondsGreaterThan(0)
+      .findAll();
+  */
+});

+ 20 - 0
mobile/lib/modules/search/providers/recently_added.provider.dart

@@ -0,0 +1,20 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:immich_mobile/shared/providers/api.provider.dart';
+import 'package:immich_mobile/shared/providers/db.provider.dart';
+
+final recentlyAddedProvider = FutureProvider<List<Asset>>( (ref) async {
+  final search = await ref.watch(apiServiceProvider).searchApi.search(
+    recent: true,
+  );
+
+  if (search == null) {
+    return [];
+  }
+
+  return ref.watch(dbProvider)
+      .assets
+      .getAllByRemoteId(
+        search.assets.items.map((e) => e.id),
+      );
+});

+ 2 - 13
mobile/lib/modules/search/providers/search_result_page.provider.dart

@@ -1,10 +1,8 @@
 import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
+import 'package:immich_mobile/modules/asset_viewer/providers/render_list.provider.dart';
 import 'package:immich_mobile/modules/search/models/search_result_page_state.model.dart';
 
 import 'package:immich_mobile/modules/search/services/search.service.dart';
-import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
-import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 
 class SearchResultPageNotifier extends StateNotifier<SearchResultPageState> {
@@ -55,15 +53,6 @@ final searchResultPageProvider =
 });
 
 final searchRenderListProvider = FutureProvider((ref) {
-  var settings = ref.watch(appSettingsServiceProvider);
-
   final assets = ref.watch(searchResultPageProvider).searchResult;
-
-  final layout = AssetGridLayoutParameters(
-    settings.getSetting(AppSettingsEnum.tilesPerRow),
-    settings.getSetting(AppSettingsEnum.dynamicLayout),
-    GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)],
-  );
-
-  return RenderList.fromAssets(assets, layout);
+  return ref.watch(renderListProvider(assets));
 });

+ 67 - 0
mobile/lib/modules/search/ui/curated_row.dart

@@ -0,0 +1,67 @@
+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/shared/models/store.dart';
+
+class CuratedRow extends StatelessWidget {
+  final List<CuratedContent> content;
+  final double imageSize;
+  
+  /// Callback with the content and the index when tapped
+  final Function(CuratedContent, 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)}/asset/thumbnail/${object.id}';
+        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,
+    );
+  }
+}

+ 35 - 0
mobile/lib/modules/search/views/all_motion_videos_page.dart

@@ -0,0 +1,35 @@
+import 'package:auto_route/auto_route.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/material.dart';
+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/all_motion_photos.provider.dart';
+import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
+
+class AllMotionPhotosPage extends HookConsumerWidget {
+  const AllMotionPhotosPage({super.key});
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final motionPhotos = ref.watch(allMotionPhotosProvider);
+
+    return Scaffold(
+      appBar: AppBar(
+        title: const Text('motion_photos_page_title').tr(),
+        leading: IconButton(
+          onPressed: () => AutoRouter.of(context).pop(),
+          icon: const Icon(Icons.arrow_back_ios_rounded),
+        ),
+      ),
+      body: motionPhotos.when(
+        data: (assets) => ImmichAssetGrid(
+          assets: assets,
+        ),
+        error: (e, s) => Text(e.toString()),
+        loading: () => const Center(
+          child: ImmichLoadingIndicator(),
+        ),
+      ),
+    );
+  }
+}

+ 35 - 0
mobile/lib/modules/search/views/all_videos_page.dart

@@ -0,0 +1,35 @@
+import 'package:auto_route/auto_route.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/material.dart';
+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/all_video_assets.provider.dart';
+import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
+
+class AllVideosPage extends HookConsumerWidget {
+  const AllVideosPage({super.key});
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final videos = ref.watch(allVideoAssetsProvider);
+
+    return Scaffold(
+      appBar: AppBar(
+        title: const Text('all_videos_page_title').tr(),
+        leading: IconButton(
+          onPressed: () => AutoRouter.of(context).pop(),
+          icon: const Icon(Icons.arrow_back_ios_rounded),
+        ),
+      ),
+      body: videos.when(
+        data: (assets) => ImmichAssetGrid(
+          assets: assets,
+        ),
+        error: (e, s) => Text(e.toString()),
+        loading: () => const Center(
+          child: ImmichLoadingIndicator(),
+        ),
+      ),
+    );
+  }
+}

+ 5 - 0
mobile/lib/modules/search/views/curated_location_page.dart

@@ -1,3 +1,4 @@
+import 'package:auto_route/auto_route.dart';
 import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -25,6 +26,10 @@ class CuratedLocationPage extends HookConsumerWidget {
             fontSize: 16.0,
           ),
         ).tr(),
+        leading: IconButton(
+          onPressed: () => AutoRouter.of(context).pop(),
+          icon: const Icon(Icons.arrow_back_ios_rounded),
+        ),
       ),
       body: curatedLocation.when(
         loading: () => const Center(child: ImmichLoadingIndicator()),

+ 5 - 0
mobile/lib/modules/search/views/curated_object_page.dart

@@ -1,3 +1,4 @@
+import 'package:auto_route/auto_route.dart';
 import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -28,6 +29,10 @@ class CuratedObjectPage extends HookConsumerWidget {
             fontSize: 16.0,
           ),
         ).tr(),
+        leading: IconButton(
+          onPressed: () => AutoRouter.of(context).pop(),
+          icon: const Icon(Icons.arrow_back_ios_rounded),
+        ),
       ),
       body: curatedObjects.when(
         loading: () => const Center(child: ImmichLoadingIndicator()),

+ 35 - 0
mobile/lib/modules/search/views/recently_added_page.dart

@@ -0,0 +1,35 @@
+import 'package:auto_route/auto_route.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/material.dart';
+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/recently_added.provider.dart';
+import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
+
+class RecentlyAddedPage extends HookConsumerWidget {
+  const RecentlyAddedPage({super.key});
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final recents = ref.watch(recentlyAddedProvider);
+
+    return Scaffold(
+      appBar: AppBar(
+        title: const Text('recently_added_page_title').tr(),
+        leading: IconButton(
+          onPressed: () => AutoRouter.of(context).pop(),
+          icon: const Icon(Icons.arrow_back_ios_rounded),
+        ),
+      ),
+      body: recents.when(
+        data: (searchResponse) => ImmichAssetGrid(
+          assets: searchResponse,
+        ),
+        error: (e, s) => Text(e.toString()),
+        loading: () => const Center(
+          child: ImmichLoadingIndicator(),
+        ),
+      ),
+    );
+  }
+}

+ 135 - 100
mobile/lib/modules/search/views/search_page.dart

@@ -3,14 +3,13 @@ import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
 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/curated_row.dart';
 import 'package:immich_mobile/modules/search/ui/search_bar.dart';
 import 'package:immich_mobile/modules/search/ui/search_suggestion_list.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';
 import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
-import 'package:immich_mobile/utils/capitalize_first_letter.dart';
 import 'package:openapi/api.dart';
 
 // ignore: must_be_immutable
@@ -26,8 +25,14 @@ class SearchPage extends HookConsumerWidget {
         ref.watch(getCuratedLocationProvider);
     AsyncValue<List<CuratedObjectsResponseDto>> curatedObjects =
         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,
+    );
+
+    Color categoryIconColor = isDarkTheme ? Colors.white : Colors.black;
 
     useEffect(
       () {
@@ -50,103 +55,58 @@ class SearchPage extends HookConsumerWidget {
         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),
-                      );
-                    },
+          data: (locations) => CuratedRow(
+            content: locations
+                .map(
+                  (o) => CuratedContent(
+                    id: o.id,
+                    label: o.city,
                   ),
-                ),
+                )
+                .toList(),
+            imageSize: imageSize,
+            onTap: (content, index) {
+              AutoRouter.of(context).push(
+                SearchResultRoute(searchTerm: content.label),
               );
             },
-            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,
+    buildThings() {
+      return SizedBox(
+        height: imageSize,
+        child: curatedObjects.when(
+          loading: () => SizedBox(
             height: imageSize,
-            child: ThumbnailWithInfo(
-              textInfo: '',
-              onTap: () {},
-            ),
+            child: const Center(child: ImmichLoadingIndicator()),
+          ),
+          error: (err, stack) => SizedBox(
+            height: imageSize,
+            child: Center(child: Text('Error: $err')),
+          ),
+          data: (objects) => CuratedRow(
+            content: objects
+                .map(
+                  (o) => CuratedContent(
+                    id: o.id,
+                    label: o.object,
+                  ),
+                )
+                .toList(),
+            imageSize: imageSize,
+            onTap: (content, index) {
+              AutoRouter.of(context).push(
+                SearchResultRoute(searchTerm: content.label),
+              );
+            },
           ),
         ),
       );
     }
 
-    buildThings() {
-      return curatedObjects.when(
-        loading: () => SizedBox(
-          height: imageSize,
-          child: const Center(child: ImmichLoadingIndicator()),
-        ),
-        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),
-                ),
-              ),
-      );
-    }
-
     return Scaffold(
       appBar: SearchBar(
         searchFocusNode: searchFocusNode,
@@ -169,12 +129,9 @@ class SearchPage extends HookConsumerWidget {
                   child: Row(
                     mainAxisAlignment: MainAxisAlignment.spaceBetween,
                     children: [
-                      const Text(
+                      Text(
                         "search_page_places",
-                        style: TextStyle(
-                          fontWeight: FontWeight.bold,
-                          fontSize: 16,
-                        ),
+                        style: Theme.of(context).textTheme.titleMedium,
                       ).tr(),
                       TextButton(
                         child: Text(
@@ -194,19 +151,18 @@ class SearchPage extends HookConsumerWidget {
                 ),
                 buildPlaces(),
                 Padding(
-                  padding: const EdgeInsets.symmetric(
-                    horizontal: 16.0,
-                    vertical: 4.0,
+                  padding: const EdgeInsets.only(
+                    top: 24.0,
+                    bottom: 4.0,
+                    left: 16.0,
+                    right: 16.0,
                   ),
                   child: Row(
                     mainAxisAlignment: MainAxisAlignment.spaceBetween,
                     children: [
-                      const Text(
+                      Text(
                         "search_page_things",
-                        style: TextStyle(
-                          fontWeight: FontWeight.bold,
-                          fontSize: 16,
-                        ),
+                        style: Theme.of(context).textTheme.titleMedium,
                       ).tr(),
                       TextButton(
                         child: Text(
@@ -225,6 +181,85 @@ class SearchPage extends HookConsumerWidget {
                   ),
                 ),
                 buildThings(),
+                const SizedBox(height: 24.0),
+                Padding(
+                  padding: const EdgeInsets.symmetric(horizontal: 16),
+                  child: Text(
+                    'search_page_your_activity',
+                    style: Theme.of(context).textTheme.titleMedium,
+                  ).tr(),
+                ),
+                ListTile(
+                  leading: Icon(
+                    Icons.star_outline,
+                    color: categoryIconColor,
+                  ),
+                  title:
+                      Text('search_page_favorites', style: categoryTitleStyle)
+                          .tr(),
+                  onTap: () => AutoRouter.of(context).push(
+                    const FavoritesRoute(),
+                  ),
+                ),
+                const Padding(
+                  padding: EdgeInsets.only(
+                    left: 72,
+                    right: 16,
+                  ),
+                  child: Divider(),
+                ),
+                ListTile(
+                  leading: Icon(
+                    Icons.schedule_outlined,
+                    color: categoryIconColor,
+                  ),
+                  title: Text(
+                    'search_page_recently_added',
+                    style: categoryTitleStyle,
+                  ).tr(),
+                  onTap: () => AutoRouter.of(context).push(
+                    const RecentlyAddedRoute(),
+                  ),
+                ),
+                const SizedBox(height: 24.0),
+                Padding(
+                  padding: const EdgeInsets.symmetric(horizontal: 16.0),
+                  child: Text(
+                    'search_page_categories',
+                    style: Theme.of(context).textTheme.titleMedium,
+                  ).tr(),
+                ),
+                ListTile(
+                  title: Text('search_page_videos', style: categoryTitleStyle)
+                      .tr(),
+                  leading: Icon(
+                    Icons.play_circle_outline,
+                    color: categoryIconColor,
+                  ),
+                  onTap: () => AutoRouter.of(context).push(
+                    const AllVideosRoute(),
+                  ),
+                ),
+                const Padding(
+                  padding: EdgeInsets.only(
+                    left: 72,
+                    right: 16,
+                  ),
+                  child: Divider(),
+                ),
+                ListTile(
+                  title: Text(
+                    'search_page_motion_photos',
+                    style: categoryTitleStyle,
+                  ).tr(),
+                  leading: Icon(
+                    Icons.motion_photos_on_outlined,
+                    color: categoryIconColor,
+                  ),
+                  onTap: () => AutoRouter.of(context).push(
+                    const AllMotionPhotosRoute(),
+                  ),
+                ),
               ],
             ),
             if (isSearchEnabled)

+ 3 - 23
mobile/lib/modules/search/views/search_result_page.dart

@@ -7,8 +7,6 @@ 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_suggestion_list.dart';
-import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
-import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
 import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
 
 class SearchResultPage extends HookConsumerWidget {
@@ -110,14 +108,8 @@ class SearchResultPage extends HookConsumerWidget {
 
     buildSearchResult() {
       var searchResultPageState = ref.watch(searchResultPageProvider);
-      var searchResultRenderList = ref.watch(searchRenderListProvider);
       var allSearchAssets = ref.watch(searchResultPageProvider).searchResult;
 
-      var settings = ref.watch(appSettingsServiceProvider);
-      final assetsPerRow = settings.getSetting(AppSettingsEnum.tilesPerRow);
-      final showStorageIndicator =
-          settings.getSetting(AppSettingsEnum.storageIndicator);
-
       if (searchResultPageState.isError) {
         return Padding(
           padding: const EdgeInsets.all(12),
@@ -129,22 +121,10 @@ class SearchResultPage extends HookConsumerWidget {
         return const Center(child: ImmichLoadingIndicator());
       }
 
+
       if (searchResultPageState.isSuccess) {
-        return searchResultRenderList.when(
-          data: (result) {
-            return ImmichAssetGrid(
-              allAssets: allSearchAssets,
-              renderList: result,
-              assetsPerRow: assetsPerRow,
-              showStorageIndicator: showStorageIndicator,
-            );
-          },
-          error: (err, stack) {
-            return Text("$err");
-          },
-          loading: () {
-            return const CircularProgressIndicator();
-          },
+        return ImmichAssetGrid(
+            assets: allSearchAssets,
         );
       }
 

+ 6 - 0
mobile/lib/routing/router.dart

@@ -21,8 +21,11 @@ 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/all_motion_videos_page.dart';
+import 'package:immich_mobile/modules/search/views/all_videos_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/recently_added_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';
@@ -70,6 +73,9 @@ part 'router.gr.dart';
     AutoRoute(page: CuratedObjectPage, guards: [AuthGuard, DuplicateGuard]),
     AutoRoute(page: CreateAlbumPage, guards: [AuthGuard, DuplicateGuard]),
     AutoRoute(page: FavoritesPage, guards: [AuthGuard, DuplicateGuard]),
+    AutoRoute(page: AllVideosPage, guards: [AuthGuard, DuplicateGuard]),
+    AutoRoute(page: AllMotionPhotosPage, guards: [AuthGuard, DuplicateGuard]),
+    AutoRoute(page: RecentlyAddedPage, guards: [AuthGuard, DuplicateGuard],),
     CustomRoute<AssetSelectionPageResult?>(
       page: AssetSelectionPage,
       guards: [AuthGuard, DuplicateGuard],

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

@@ -131,6 +131,29 @@ class _$AppRouter extends RootStackRouter {
         child: const FavoritesPage(),
       );
     },
+    AllVideosRoute.name: (routeData) {
+      return MaterialPageX<dynamic>(
+        routeData: routeData,
+        child: const AllVideosPage(),
+      );
+    },
+    AllMotionPhotosRoute.name: (routeData) {
+      return MaterialPageX<dynamic>(
+        routeData: routeData,
+        child: const AllMotionPhotosPage(),
+      );
+    },
+    RecentlyAddedRoute.name: (routeData) {
+      return CustomPage<dynamic>(
+        routeData: routeData,
+        child: const RecentlyAddedPage(),
+        transitionsBuilder: TransitionsBuilders.noTransition,
+        durationInMilliseconds: 200,
+        reverseDurationInMilliseconds: 200,
+        opaque: true,
+        barrierDismissible: false,
+      );
+    },
     AssetSelectionRoute.name: (routeData) {
       return CustomPage<AssetSelectionPageResult?>(
         routeData: routeData,
@@ -375,6 +398,30 @@ class _$AppRouter extends RootStackRouter {
             duplicateGuard,
           ],
         ),
+        RouteConfig(
+          AllVideosRoute.name,
+          path: '/all-videos-page',
+          guards: [
+            authGuard,
+            duplicateGuard,
+          ],
+        ),
+        RouteConfig(
+          AllMotionPhotosRoute.name,
+          path: '/all-motion-photos-page',
+          guards: [
+            authGuard,
+            duplicateGuard,
+          ],
+        ),
+        RouteConfig(
+          RecentlyAddedRoute.name,
+          path: '/recently-added-page',
+          guards: [
+            authGuard,
+            duplicateGuard,
+          ],
+        ),
         RouteConfig(
           AssetSelectionRoute.name,
           path: '/asset-selection-page',
@@ -721,6 +768,42 @@ class FavoritesRoute extends PageRouteInfo<void> {
   static const String name = 'FavoritesRoute';
 }
 
+/// generated route for
+/// [AllVideosPage]
+class AllVideosRoute extends PageRouteInfo<void> {
+  const AllVideosRoute()
+      : super(
+          AllVideosRoute.name,
+          path: '/all-videos-page',
+        );
+
+  static const String name = 'AllVideosRoute';
+}
+
+/// generated route for
+/// [AllMotionPhotosPage]
+class AllMotionPhotosRoute extends PageRouteInfo<void> {
+  const AllMotionPhotosRoute()
+      : super(
+          AllMotionPhotosRoute.name,
+          path: '/all-motion-photos-page',
+        );
+
+  static const String name = 'AllMotionPhotosRoute';
+}
+
+/// generated route for
+/// [RecentlyAddedPage]
+class RecentlyAddedRoute extends PageRouteInfo<void> {
+  const RecentlyAddedRoute()
+      : super(
+          RecentlyAddedRoute.name,
+          path: '/recently-added-page',
+        );
+
+  static const String name = 'RecentlyAddedRoute';
+}
+
 /// generated route for
 /// [AssetSelectionPage]
 class AssetSelectionRoute extends PageRouteInfo<void> {

+ 4 - 1
mobile/lib/shared/views/tab_controller_page.dart

@@ -169,7 +169,10 @@ class TabControllerPage extends ConsumerWidget {
                 );
               }
               return Scaffold(
-                body: body,
+                body: HeroControllerScope(
+                  controller: HeroController(),
+                  child: body,
+                ),
                 bottomNavigationBar: multiselectEnabled ? null : bottom,
               );
             },

+ 27 - 0
mobile/lib/utils/immich_app_theme.dart

@@ -41,6 +41,8 @@ ThemeData immichLightTheme = ThemeData(
     titleTextStyle: const TextStyle(
       fontFamily: 'WorkSans',
       color: Colors.indigo,
+      fontWeight: FontWeight.bold,
+      fontSize: 18,
     ),
     backgroundColor: immichBackgroundColor,
     foregroundColor: Colors.indigo,
@@ -75,6 +77,17 @@ ThemeData immichLightTheme = ThemeData(
       fontWeight: FontWeight.bold,
       color: Colors.indigo,
     ),
+    titleSmall: TextStyle(
+      fontSize: 16.0,
+    ),
+    titleMedium: TextStyle(
+      fontSize: 18.0,
+      fontWeight: FontWeight.bold,
+    ),
+    titleLarge: TextStyle(
+      fontSize: 26.0,
+      fontWeight: FontWeight.bold,
+    ),
   ),
   elevatedButtonTheme: ElevatedButtonThemeData(
     style: ElevatedButton.styleFrom(
@@ -127,6 +140,8 @@ ThemeData immichDarkTheme = ThemeData(
     titleTextStyle: TextStyle(
       fontFamily: 'WorkSans',
       color: immichDarkThemePrimaryColor,
+      fontWeight: FontWeight.bold,
+      fontSize: 18,
     ),
     backgroundColor: const Color.fromARGB(255, 32, 33, 35),
     foregroundColor: immichDarkThemePrimaryColor,
@@ -159,6 +174,18 @@ ThemeData immichDarkTheme = ThemeData(
       fontWeight: FontWeight.bold,
       color: immichDarkThemePrimaryColor,
     ),
+    titleSmall: const TextStyle(
+      fontSize: 16.0,
+    ),
+    titleMedium: const TextStyle(
+      fontSize: 18.0,
+      fontWeight: FontWeight.bold,
+    ),
+    titleLarge: const TextStyle(
+      fontSize: 26.0,
+      fontWeight: FontWeight.bold,
+    ),
+
   ),
   cardColor: Colors.grey[900],
   elevatedButtonTheme: ElevatedButtonThemeData(

+ 75 - 64
mobile/openapi/lib/model/asset_response_dto.dart

@@ -85,76 +85,79 @@ class AssetResponseDto {
   List<TagResponseDto> tags;
 
   @override
-  bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
-     other.type == type &&
-     other.id == id &&
-     other.deviceAssetId == deviceAssetId &&
-     other.ownerId == ownerId &&
-     other.deviceId == deviceId &&
-     other.originalPath == originalPath &&
-     other.resizePath == resizePath &&
-     other.fileCreatedAt == fileCreatedAt &&
-     other.fileModifiedAt == fileModifiedAt &&
-     other.updatedAt == updatedAt &&
-     other.isFavorite == isFavorite &&
-     other.mimeType == mimeType &&
-     other.duration == duration &&
-     other.webpPath == webpPath &&
-     other.encodedVideoPath == encodedVideoPath &&
-     other.exifInfo == exifInfo &&
-     other.smartInfo == smartInfo &&
-     other.livePhotoVideoId == livePhotoVideoId &&
-     other.tags == tags;
+  bool operator ==(Object other) =>
+      identical(this, other) ||
+      other is AssetResponseDto &&
+          other.type == type &&
+          other.id == id &&
+          other.deviceAssetId == deviceAssetId &&
+          other.ownerId == ownerId &&
+          other.deviceId == deviceId &&
+          other.originalPath == originalPath &&
+          other.resizePath == resizePath &&
+          other.fileCreatedAt == fileCreatedAt &&
+          other.fileModifiedAt == fileModifiedAt &&
+          other.updatedAt == updatedAt &&
+          other.isFavorite == isFavorite &&
+          other.mimeType == mimeType &&
+          other.duration == duration &&
+          other.webpPath == webpPath &&
+          other.encodedVideoPath == encodedVideoPath &&
+          other.exifInfo == exifInfo &&
+          other.smartInfo == smartInfo &&
+          other.livePhotoVideoId == livePhotoVideoId &&
+          other.tags == tags;
 
   @override
   int get hashCode =>
-    // ignore: unnecessary_parenthesis
-    (type.hashCode) +
-    (id.hashCode) +
-    (deviceAssetId.hashCode) +
-    (ownerId.hashCode) +
-    (deviceId.hashCode) +
-    (originalPath.hashCode) +
-    (resizePath == null ? 0 : resizePath!.hashCode) +
-    (fileCreatedAt.hashCode) +
-    (fileModifiedAt.hashCode) +
-    (updatedAt.hashCode) +
-    (isFavorite.hashCode) +
-    (mimeType == null ? 0 : mimeType!.hashCode) +
-    (duration.hashCode) +
-    (webpPath == null ? 0 : webpPath!.hashCode) +
-    (encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) +
-    (exifInfo == null ? 0 : exifInfo!.hashCode) +
-    (smartInfo == null ? 0 : smartInfo!.hashCode) +
-    (livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) +
-    (tags.hashCode);
+      // ignore: unnecessary_parenthesis
+      (type.hashCode) +
+      (id.hashCode) +
+      (deviceAssetId.hashCode) +
+      (ownerId.hashCode) +
+      (deviceId.hashCode) +
+      (originalPath.hashCode) +
+      (resizePath == null ? 0 : resizePath!.hashCode) +
+      (fileCreatedAt.hashCode) +
+      (fileModifiedAt.hashCode) +
+      (updatedAt.hashCode) +
+      (isFavorite.hashCode) +
+      (mimeType == null ? 0 : mimeType!.hashCode) +
+      (duration.hashCode) +
+      (webpPath == null ? 0 : webpPath!.hashCode) +
+      (encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) +
+      (exifInfo == null ? 0 : exifInfo!.hashCode) +
+      (smartInfo == null ? 0 : smartInfo!.hashCode) +
+      (livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) +
+      (tags.hashCode);
 
   @override
-  String toString() => 'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, updatedAt=$updatedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo, livePhotoVideoId=$livePhotoVideoId, tags=$tags]';
+  String toString() =>
+      'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, updatedAt=$updatedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo, livePhotoVideoId=$livePhotoVideoId, tags=$tags]';
 
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
-      json[r'type'] = this.type;
-      json[r'id'] = this.id;
-      json[r'deviceAssetId'] = this.deviceAssetId;
-      json[r'ownerId'] = this.ownerId;
-      json[r'deviceId'] = this.deviceId;
-      json[r'originalPath'] = this.originalPath;
+    json[r'type'] = this.type;
+    json[r'id'] = this.id;
+    json[r'deviceAssetId'] = this.deviceAssetId;
+    json[r'ownerId'] = this.ownerId;
+    json[r'deviceId'] = this.deviceId;
+    json[r'originalPath'] = this.originalPath;
     if (this.resizePath != null) {
       json[r'resizePath'] = this.resizePath;
     } else {
       // json[r'resizePath'] = null;
     }
-      json[r'fileCreatedAt'] = this.fileCreatedAt;
-      json[r'fileModifiedAt'] = this.fileModifiedAt;
-      json[r'updatedAt'] = this.updatedAt;
-      json[r'isFavorite'] = this.isFavorite;
+    json[r'fileCreatedAt'] = this.fileCreatedAt;
+    json[r'fileModifiedAt'] = this.fileModifiedAt;
+    json[r'updatedAt'] = this.updatedAt;
+    json[r'isFavorite'] = this.isFavorite;
     if (this.mimeType != null) {
       json[r'mimeType'] = this.mimeType;
     } else {
       // json[r'mimeType'] = null;
     }
-      json[r'duration'] = this.duration;
+    json[r'duration'] = this.duration;
     if (this.webpPath != null) {
       json[r'webpPath'] = this.webpPath;
     } else {
@@ -180,7 +183,7 @@ class AssetResponseDto {
     } else {
       // json[r'livePhotoVideoId'] = null;
     }
-      json[r'tags'] = this.tags;
+    json[r'tags'] = this.tags;
     return json;
   }
 
@@ -194,13 +197,13 @@ class AssetResponseDto {
       // Ensure that the map contains the required keys.
       // Note 1: the values aren't checked for validity beyond being non-null.
       // Note 2: this code is stripped in release mode!
-      assert(() {
-        requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "AssetResponseDto[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "AssetResponseDto[$key]" has a null value in JSON.');
-        });
-        return true;
-      }());
+      // assert(() {
+      //   requiredKeys.forEach((key) {
+      //     assert(json.containsKey(key), 'Required key "AssetResponseDto[$key]" is missing from JSON.');
+      //     assert(json[key] != null, 'Required key "AssetResponseDto[$key]" has a null value in JSON.');
+      //   });
+      //   return true;
+      // }());
 
       return AssetResponseDto(
         type: AssetTypeEnum.fromJson(json[r'type'])!,
@@ -227,7 +230,10 @@ class AssetResponseDto {
     return null;
   }
 
-  static List<AssetResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
+  static List<AssetResponseDto>? listFromJson(
+    dynamic json, {
+    bool growable = false,
+  }) {
     final result = <AssetResponseDto>[];
     if (json is List && json.isNotEmpty) {
       for (final row in json) {
@@ -255,12 +261,18 @@ class AssetResponseDto {
   }
 
   // maps a json object with a list of AssetResponseDto-objects as value to a dart map
-  static Map<String, List<AssetResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+  static Map<String, List<AssetResponseDto>> mapListFromJson(
+    dynamic json, {
+    bool growable = false,
+  }) {
     final map = <String, List<AssetResponseDto>>{};
     if (json is Map && json.isNotEmpty) {
       json = json.cast<String, dynamic>(); // ignore: parameter_assignments
       for (final entry in json.entries) {
-        final value = AssetResponseDto.listFromJson(entry.value, growable: growable,);
+        final value = AssetResponseDto.listFromJson(
+          entry.value,
+          growable: growable,
+        );
         if (value != null) {
           map[entry.key] = value;
         }
@@ -287,4 +299,3 @@ class AssetResponseDto {
     'webpPath',
   };
 }
-