فهرست منبع

feat(mobile): memories (#2988)

* Add page view

* Nice page view

* refactor file structure

* Added card

* invalidating data

* transition

* styling

* correct styleing

* refactor

* click to navigate

* styling

* TODO

* clean up

* clean up

* pr feedback

* pr feedback

* better loading indicator
Alex 2 سال پیش
والد
کامیت
39a885a37c

BIN
mobile/fonts/WorkSans-Black.ttf


BIN
mobile/fonts/WorkSans-Bold.ttf


BIN
mobile/fonts/WorkSans-ExtraBold.ttf


BIN
mobile/fonts/WorkSans-Medium.ttf


BIN
mobile/fonts/WorkSans-SemiBold.ttf


+ 2 - 0
mobile/lib/modules/home/views/home_page.dart

@@ -14,6 +14,7 @@ import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
 import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
 import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart';
 import 'package:immich_mobile/modules/home/ui/home_page_app_bar.dart';
+import 'package:immich_mobile/modules/memories/ui/memory_lane.dart';
 import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer.dart';
 import 'package:immich_mobile/routing/router.dart';
 import 'package:immich_mobile/shared/models/album.dart';
@@ -310,6 +311,7 @@ class HomePage extends HookConsumerWidget {
                           listener: selectionListener,
                           selectionActive: selectionEnabledHook.value,
                           onRefresh: refreshAssets,
+                          topWidget: const MemoryLane(),
                         ),
                   error: (error, _) => Center(child: Text(error.toString())),
                   loading: buildLoadingIndicator,

+ 40 - 0
mobile/lib/modules/memories/models/memory.dart

@@ -0,0 +1,40 @@
+// ignore_for_file: public_member_api_docs, sort_constructors_first
+
+import 'package:collection/collection.dart';
+
+import 'package:immich_mobile/shared/models/asset.dart';
+
+class Memory {
+  final String title;
+  final List<Asset> assets;
+  Memory({
+    required this.title,
+    required this.assets,
+  });
+
+  Memory copyWith({
+    String? title,
+    List<Asset>? assets,
+  }) {
+    return Memory(
+      title: title ?? this.title,
+      assets: assets ?? this.assets,
+    );
+  }
+
+  @override
+  String toString() => 'Memory(title: $title, assets: $assets)';
+
+  @override
+  bool operator ==(Object other) {
+    if (identical(this, other)) return true;
+    final listEquals = const DeepCollectionEquality().equals;
+
+    return other is Memory &&
+        other.title == title &&
+        listEquals(other.assets, assets);
+  }
+
+  @override
+  int get hashCode => title.hashCode ^ assets.hashCode;
+}

+ 9 - 0
mobile/lib/modules/memories/providers/memory.provider.dart

@@ -0,0 +1,9 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/memories/models/memory.dart';
+import 'package:immich_mobile/modules/memories/services/memory.service.dart';
+
+final memoryFutureProvider = FutureProvider<List<Memory>?>((ref) async {
+  final service = ref.watch(memoryServiceProvider);
+
+  return await service.getMemoryLane();
+});

+ 50 - 0
mobile/lib/modules/memories/services/memory.service.dart

@@ -0,0 +1,50 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:immich_mobile/modules/memories/models/memory.dart';
+import 'package:immich_mobile/shared/providers/api.provider.dart';
+import 'package:immich_mobile/shared/services/api.service.dart';
+import 'package:logging/logging.dart';
+import 'package:openapi/api.dart';
+
+final memoryServiceProvider = StateProvider<MemoryService>((ref) {
+  return MemoryService(
+    ref.watch(apiServiceProvider),
+  );
+});
+
+class MemoryService {
+  final log = Logger("MemoryService");
+
+  final ApiService _apiService;
+
+  MemoryService(this._apiService);
+
+  Future<List<Memory>?> getMemoryLane() async {
+    try {
+      final now = DateTime.now();
+      final beginningOfDate = DateTime(now.year, now.month, now.day);
+      final data = await _apiService.assetApi.getMemoryLane(
+        beginningOfDate,
+      );
+
+      if (data == null) {
+        return null;
+      }
+
+      List<Memory> memories = [];
+      for (final MemoryLaneResponseDto(:title, :assets) in data) {
+        memories.add(
+          Memory(
+            title: title,
+            assets: assets.map((a) => Asset.remote(a)).toList(),
+          ),
+        );
+      }
+
+      return memories.isNotEmpty ? memories : null;
+    } catch (error, stack) {
+      log.severe("Cannot get memories ${error.toString()}", error, stack);
+      return null;
+    }
+  }
+}

+ 121 - 0
mobile/lib/modules/memories/ui/memory_card.dart

@@ -0,0 +1,121 @@
+import 'dart:ui';
+
+import 'package:cached_network_image/cached_network_image.dart';
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:immich_mobile/shared/models/store.dart';
+import 'package:immich_mobile/shared/ui/immich_image.dart';
+import 'package:immich_mobile/utils/image_url_builder.dart';
+import 'package:openapi/api.dart';
+
+class MemoryCard extends HookConsumerWidget {
+  final Asset asset;
+  final void Function() onTap;
+  final void Function() onClose;
+  final String title;
+  final String? rightCornerText;
+  final bool showTitle;
+
+  const MemoryCard({
+    required this.asset,
+    required this.onTap,
+    required this.onClose,
+    required this.title,
+    required this.showTitle,
+    this.rightCornerText,
+    super.key,
+  });
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final authToken = 'Bearer ${Store.get(StoreKey.accessToken)}';
+
+    buildTitle() {
+      return Text(
+        title,
+        style: const TextStyle(
+          color: Colors.white,
+          fontWeight: FontWeight.bold,
+          fontSize: 24.0,
+        ),
+      );
+    }
+
+    return Card(
+      color: Colors.black,
+      shape: RoundedRectangleBorder(
+        borderRadius: BorderRadius.circular(25.0),
+        side: const BorderSide(
+          color: Colors.black,
+          width: 1.0,
+        ),
+      ),
+      clipBehavior: Clip.hardEdge,
+      child: Stack(
+        children: [
+          Container(
+            decoration: BoxDecoration(
+              image: DecorationImage(
+                image: CachedNetworkImageProvider(
+                  getThumbnailUrl(
+                    asset,
+                  ),
+                  cacheKey: getThumbnailCacheKey(
+                    asset,
+                  ),
+                  headers: {"Authorization": authToken},
+                ),
+                fit: BoxFit.cover,
+              ),
+            ),
+            child: BackdropFilter(
+              filter: ImageFilter.blur(sigmaX: 60, sigmaY: 60),
+              child: Container(
+                decoration:
+                    BoxDecoration(color: Colors.black.withOpacity(0.25)),
+              ),
+            ),
+          ),
+          GestureDetector(
+            onTap: onTap,
+            child: ImmichImage(
+              asset,
+              fit: BoxFit.fitWidth,
+              height: double.infinity,
+              width: double.infinity,
+              type: ThumbnailFormat.JPEG,
+            ),
+          ),
+          Positioned(
+            top: 2.0,
+            left: 2.0,
+            child: IconButton(
+              onPressed: onClose,
+              icon: const Icon(Icons.close_rounded),
+              color: Colors.grey[400],
+            ),
+          ),
+          Positioned(
+            right: 18.0,
+            top: 18.0,
+            child: Text(
+              rightCornerText ?? "",
+              style: TextStyle(
+                color: Colors.grey[200],
+                fontSize: 12.0,
+                fontWeight: FontWeight.bold,
+              ),
+            ),
+          ),
+          if (showTitle)
+            Positioned(
+              left: 18.0,
+              bottom: 18.0,
+              child: buildTitle(),
+            )
+        ],
+      ),
+    );
+  }
+}

+ 89 - 0
mobile/lib/modules/memories/ui/memory_lane.dart

@@ -0,0 +1,89 @@
+import 'package:auto_route/auto_route.dart';
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/memories/providers/memory.provider.dart';
+import 'package:immich_mobile/routing/router.dart';
+import 'package:immich_mobile/shared/ui/immich_image.dart';
+
+class MemoryLane extends HookConsumerWidget {
+  const MemoryLane({super.key});
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final memoryLaneFutureProvider = ref.watch(memoryFutureProvider);
+
+    final memoryLane = memoryLaneFutureProvider
+        .whenData(
+          (memories) => memories != null
+              ? SizedBox(
+                  height: 200,
+                  child: ListView.builder(
+                    scrollDirection: Axis.horizontal,
+                    shrinkWrap: true,
+                    itemCount: memories.length,
+                    itemBuilder: (context, index) {
+                      final memory = memories[index];
+
+                      return Padding(
+                        padding: const EdgeInsets.only(right: 8.0, bottom: 8),
+                        child: GestureDetector(
+                          onTap: () {
+                            AutoRouter.of(context).push(
+                              VerticalRouteView(
+                                memories: memories,
+                                memoryIndex: index,
+                              ),
+                            );
+                          },
+                          child: Stack(
+                            children: [
+                              Card(
+                                elevation: 3,
+                                shape: RoundedRectangleBorder(
+                                  borderRadius: BorderRadius.circular(13.0),
+                                ),
+                                clipBehavior: Clip.hardEdge,
+                                child: ColorFiltered(
+                                  colorFilter: ColorFilter.mode(
+                                    Colors.black.withOpacity(0.1),
+                                    BlendMode.darken,
+                                  ),
+                                  child: ImmichImage(
+                                    memory.assets[0],
+                                    fit: BoxFit.cover,
+                                    width: 130,
+                                    height: 200,
+                                    useGrayBoxPlaceholder: true,
+                                  ),
+                                ),
+                              ),
+                              Positioned(
+                                bottom: 16,
+                                left: 16,
+                                child: ConstrainedBox(
+                                  constraints: const BoxConstraints(
+                                    maxWidth: 114,
+                                  ),
+                                  child: Text(
+                                    memory.title,
+                                    style: const TextStyle(
+                                      fontWeight: FontWeight.w500,
+                                      color: Colors.white,
+                                      fontSize: 14,
+                                    ),
+                                  ),
+                                ),
+                              ),
+                            ],
+                          ),
+                        ),
+                      );
+                    },
+                  ),
+                )
+              : const SizedBox(),
+        )
+        .value;
+
+    return memoryLane ?? const SizedBox();
+  }
+}

+ 140 - 0
mobile/lib/modules/memories/views/memory_page.dart

@@ -0,0 +1,140 @@
+import 'package:auto_route/auto_route.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/memories/models/memory.dart';
+import 'package:immich_mobile/modules/memories/ui/memory_card.dart';
+import 'package:intl/intl.dart';
+
+class MemoryPage extends HookConsumerWidget {
+  final List<Memory> memories;
+  final int memoryIndex;
+
+  const MemoryPage({
+    required this.memories,
+    required this.memoryIndex,
+    super.key,
+  });
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final memoryPageController = usePageController(initialPage: memoryIndex);
+    final memoryAssetPageController = usePageController();
+    final currentMemory = useState(memories[memoryIndex]);
+    final currentAssetPage = useState(0);
+    final assetProgress = useState(
+      "${currentAssetPage.value + 1}|${currentMemory.value.assets.length}",
+    );
+    const bgColor = Colors.black;
+
+    toNextMemory() {
+      memoryPageController.nextPage(
+        duration: const Duration(milliseconds: 500),
+        curve: Curves.easeIn,
+      );
+    }
+
+    toNextAsset(int currentAssetIndex) {
+      (currentAssetIndex + 1 < currentMemory.value.assets.length)
+          ? memoryAssetPageController.jumpToPage(
+              (currentAssetIndex + 1),
+            )
+          : toNextMemory();
+    }
+
+    updateProgressText() {
+      assetProgress.value =
+          "${currentAssetPage.value + 1}|${currentMemory.value.assets.length}";
+    }
+
+    onMemoryChanged(int otherIndex) {
+      HapticFeedback.mediumImpact();
+      currentMemory.value = memories[otherIndex];
+      currentAssetPage.value = 0;
+      updateProgressText();
+    }
+
+    onAssetChanged(int otherIndex) {
+      HapticFeedback.selectionClick();
+
+      currentAssetPage.value = otherIndex;
+      updateProgressText();
+    }
+
+    buildBottomInfo() {
+      return Padding(
+        padding: const EdgeInsets.all(16.0),
+        child: Row(
+          children: [
+            Column(
+              crossAxisAlignment: CrossAxisAlignment.start,
+              children: [
+                Text(
+                  currentMemory.value.title,
+                  style: TextStyle(
+                    color: Colors.grey[400],
+                    fontSize: 11.0,
+                    fontWeight: FontWeight.w600,
+                  ),
+                ),
+                Text(
+                  DateFormat.yMMMMd().format(
+                    currentMemory.value.assets[0].fileCreatedAt,
+                  ),
+                  style: const TextStyle(
+                    color: Colors.white,
+                    fontSize: 14.0,
+                    fontWeight: FontWeight.w500,
+                  ),
+                ),
+              ],
+            ),
+          ],
+        ),
+      );
+    }
+
+    return Scaffold(
+      backgroundColor: bgColor,
+      body: SafeArea(
+        child: PageView.builder(
+          scrollDirection: Axis.vertical,
+          controller: memoryPageController,
+          onPageChanged: onMemoryChanged,
+          itemCount: memories.length,
+          itemBuilder: (context, mIndex) {
+            // Build horizontal page
+            return Column(
+              children: [
+                Expanded(
+                  child: PageView.builder(
+                    controller: memoryAssetPageController,
+                    onPageChanged: onAssetChanged,
+                    scrollDirection: Axis.horizontal,
+                    itemCount: memories[mIndex].assets.length,
+                    itemBuilder: (context, index) {
+                      final asset = memories[mIndex].assets[index];
+                      return Container(
+                        color: Colors.black,
+                        child: MemoryCard(
+                          asset: asset,
+                          onTap: () => toNextAsset(index),
+                          onClose: () => AutoRouter.of(context).pop(),
+                          rightCornerText: assetProgress.value,
+                          title: memories[mIndex].title,
+                          showTitle: index == 0,
+                        ),
+                      );
+                    },
+                  ),
+                ),
+                buildBottomInfo(),
+              ],
+            );
+          },
+        ),
+      ),
+    );
+  }
+}

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

@@ -6,6 +6,8 @@ import 'package:immich_mobile/modules/album/views/album_viewer_page.dart';
 import 'package:immich_mobile/modules/album/views/asset_selection_page.dart';
 import 'package:immich_mobile/modules/album/views/create_album_page.dart';
 import 'package:immich_mobile/modules/album/views/library_page.dart';
+import 'package:immich_mobile/modules/memories/models/memory.dart';
+import 'package:immich_mobile/modules/memories/views/memory_page.dart';
 import 'package:immich_mobile/modules/partner/views/partner_detail_page.dart';
 import 'package:immich_mobile/modules/partner/views/partner_page.dart';
 import 'package:immich_mobile/modules/album/views/select_additional_user_for_sharing_page.dart';
@@ -151,6 +153,7 @@ part 'router.gr.dart';
       ],
     ),
     AutoRoute(page: AllPeoplePage, guards: [AuthGuard, DuplicateGuard]),
+    AutoRoute(page: MemoryPage, guards: [AuthGuard, DuplicateGuard]),
   ],
 )
 class AppRouter extends _$AppRouter {

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

@@ -290,6 +290,17 @@ class _$AppRouter extends RootStackRouter {
         child: const AllPeoplePage(),
       );
     },
+    VerticalRouteView.name: (routeData) {
+      final args = routeData.argsAs<VerticalRouteViewArgs>();
+      return MaterialPageX<dynamic>(
+        routeData: routeData,
+        child: MemoryPage(
+          memories: args.memories,
+          memoryIndex: args.memoryIndex,
+          key: args.key,
+        ),
+      );
+    },
     HomeRoute.name: (routeData) {
       return MaterialPageX<dynamic>(
         routeData: routeData,
@@ -589,6 +600,14 @@ class _$AppRouter extends RootStackRouter {
             duplicateGuard,
           ],
         ),
+        RouteConfig(
+          VerticalRouteView.name,
+          path: '/vertical-page-view',
+          guards: [
+            authGuard,
+            duplicateGuard,
+          ],
+        ),
       ];
 }
 
@@ -1281,6 +1300,45 @@ class AllPeopleRoute extends PageRouteInfo<void> {
   static const String name = 'AllPeopleRoute';
 }
 
+/// generated route for
+/// [MemoryPage]
+class VerticalRouteView extends PageRouteInfo<VerticalRouteViewArgs> {
+  VerticalRouteView({
+    required List<Memory> memories,
+    required int memoryIndex,
+    Key? key,
+  }) : super(
+          VerticalRouteView.name,
+          path: '/vertical-page-view',
+          args: VerticalRouteViewArgs(
+            memories: memories,
+            memoryIndex: memoryIndex,
+            key: key,
+          ),
+        );
+
+  static const String name = 'VerticalRouteView';
+}
+
+class VerticalRouteViewArgs {
+  const VerticalRouteViewArgs({
+    required this.memories,
+    required this.memoryIndex,
+    this.key,
+  });
+
+  final List<Memory> memories;
+
+  final int memoryIndex;
+
+  final Key? key;
+
+  @override
+  String toString() {
+    return 'VerticalRouteViewArgs{memories: $memories, memoryIndex: $memoryIndex, key: $key}';
+  }
+}
+
 /// generated route for
 /// [HomePage]
 class HomeRoute extends PageRouteInfo<void> {

+ 5 - 0
mobile/lib/routing/tab_navigation_observer.dart

@@ -1,6 +1,7 @@
 import 'package:auto_route/auto_route.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/album/providers/album.provider.dart';
+import 'package:immich_mobile/modules/memories/providers/memory.provider.dart';
 import 'package:immich_mobile/modules/search/providers/people.provider.dart';
 
 import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
@@ -43,6 +44,10 @@ class TabNavigationObserver extends AutoRouterObserver {
     if (route.name == 'LibraryRoute') {
       ref.read(albumProvider.notifier).getAllAlbums();
     }
+
+    if (route.name == 'HomeRoute') {
+      ref.invalidate(memoryFutureProvider);
+    }
     ref.watch(serverInfoProvider.notifier).getServerVersion();
   }
 }

+ 5 - 2
mobile/lib/shared/ui/immich_image.dart

@@ -6,6 +6,7 @@ import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/models/store.dart';
 import 'package:immich_mobile/utils/image_url_builder.dart';
 import 'package:photo_manager/photo_manager.dart';
+import 'package:openapi/api.dart' as api;
 
 /// Renders an Asset using local data if available, else remote data
 class ImmichImage extends StatelessWidget {
@@ -15,6 +16,7 @@ class ImmichImage extends StatelessWidget {
     this.height,
     this.fit = BoxFit.cover,
     this.useGrayBoxPlaceholder = false,
+    this.type = api.ThumbnailFormat.WEBP,
     super.key,
   });
   final Asset? asset;
@@ -22,6 +24,7 @@ class ImmichImage extends StatelessWidget {
   final double? width;
   final double? height;
   final BoxFit fit;
+  final api.ThumbnailFormat type;
 
   @override
   Widget build(BuildContext context) {
@@ -85,7 +88,7 @@ class ImmichImage extends StatelessWidget {
       );
     }
     final String? token = Store.get(StoreKey.accessToken);
-    final String thumbnailRequestUrl = getThumbnailUrl(asset);
+    final String thumbnailRequestUrl = getThumbnailUrl(asset, type: type);
     return CachedNetworkImage(
       imageUrl: thumbnailRequestUrl,
       httpHeaders: {"Authorization": "Bearer $token"},
@@ -105,7 +108,7 @@ class ImmichImage extends StatelessWidget {
         }
         return Transform.scale(
           scale: 0.2,
-          child: CircularProgressIndicator(
+          child: CircularProgressIndicator.adaptive(
             value: downloadProgress.progress,
           ),
         );

+ 10 - 0
mobile/pubspec.yaml

@@ -81,6 +81,16 @@ flutter:
         - asset: fonts/WorkSans.ttf
         - asset: fonts/WorkSans-Italic.ttf
           style: italic
+        # - asset: fonts/WorkSans-Medium.ttf
+        #   weight: 500
+        # - asset: fonts/WorkSans-SemiBold.ttf
+        #   weight: 600
+        # - asset: fonts/WorkSans-Bold.ttf
+        #   weight: 700
+        # - asset: fonts/WorkSans-ExtraBold.ttf
+        #   weight: 800
+        # - asset: fonts/WorkSans-Black.ttf
+        #   weight: 900
     - family: SnowburstOne
       fonts:
         - asset: fonts/SnowburstOne.ttf