diff --git a/mobile/fonts/WorkSans-Black.ttf b/mobile/fonts/WorkSans-Black.ttf new file mode 100644 index 000000000..f0f61fbde Binary files /dev/null and b/mobile/fonts/WorkSans-Black.ttf differ diff --git a/mobile/fonts/WorkSans-Bold.ttf b/mobile/fonts/WorkSans-Bold.ttf new file mode 100644 index 000000000..c30cb0708 Binary files /dev/null and b/mobile/fonts/WorkSans-Bold.ttf differ diff --git a/mobile/fonts/WorkSans-ExtraBold.ttf b/mobile/fonts/WorkSans-ExtraBold.ttf new file mode 100644 index 000000000..2d0d46a3a Binary files /dev/null and b/mobile/fonts/WorkSans-ExtraBold.ttf differ diff --git a/mobile/fonts/WorkSans-Medium.ttf b/mobile/fonts/WorkSans-Medium.ttf new file mode 100644 index 000000000..1800fe2d8 Binary files /dev/null and b/mobile/fonts/WorkSans-Medium.ttf differ diff --git a/mobile/fonts/WorkSans-SemiBold.ttf b/mobile/fonts/WorkSans-SemiBold.ttf new file mode 100644 index 000000000..bce808c82 Binary files /dev/null and b/mobile/fonts/WorkSans-SemiBold.ttf differ diff --git a/mobile/lib/modules/home/views/home_page.dart b/mobile/lib/modules/home/views/home_page.dart index 292389c05..3dc3a41be 100644 --- a/mobile/lib/modules/home/views/home_page.dart +++ b/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, diff --git a/mobile/lib/modules/memories/models/memory.dart b/mobile/lib/modules/memories/models/memory.dart new file mode 100644 index 000000000..60e44edf6 --- /dev/null +++ b/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 assets; + Memory({ + required this.title, + required this.assets, + }); + + Memory copyWith({ + String? title, + List? 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; +} diff --git a/mobile/lib/modules/memories/providers/memory.provider.dart b/mobile/lib/modules/memories/providers/memory.provider.dart new file mode 100644 index 000000000..6ad437aac --- /dev/null +++ b/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?>((ref) async { + final service = ref.watch(memoryServiceProvider); + + return await service.getMemoryLane(); +}); diff --git a/mobile/lib/modules/memories/services/memory.service.dart b/mobile/lib/modules/memories/services/memory.service.dart new file mode 100644 index 000000000..ab38c0694 --- /dev/null +++ b/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((ref) { + return MemoryService( + ref.watch(apiServiceProvider), + ); +}); + +class MemoryService { + final log = Logger("MemoryService"); + + final ApiService _apiService; + + MemoryService(this._apiService); + + Future?> 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 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; + } + } +} diff --git a/mobile/lib/modules/memories/ui/memory_card.dart b/mobile/lib/modules/memories/ui/memory_card.dart new file mode 100644 index 000000000..493685ddd --- /dev/null +++ b/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(), + ) + ], + ), + ); + } +} diff --git a/mobile/lib/modules/memories/ui/memory_lane.dart b/mobile/lib/modules/memories/ui/memory_lane.dart new file mode 100644 index 000000000..ffad6d5e1 --- /dev/null +++ b/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(); + } +} diff --git a/mobile/lib/modules/memories/views/memory_page.dart b/mobile/lib/modules/memories/views/memory_page.dart new file mode 100644 index 000000000..ee8833ff1 --- /dev/null +++ b/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 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(), + ], + ); + }, + ), + ), + ); + } +} diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 200d357dc..f5d038906 100644 --- a/mobile/lib/routing/router.dart +++ b/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 { diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 7c03a9b88..3ab78b69d 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -290,6 +290,17 @@ class _$AppRouter extends RootStackRouter { child: const AllPeoplePage(), ); }, + VerticalRouteView.name: (routeData) { + final args = routeData.argsAs(); + return MaterialPageX( + routeData: routeData, + child: MemoryPage( + memories: args.memories, + memoryIndex: args.memoryIndex, + key: args.key, + ), + ); + }, HomeRoute.name: (routeData) { return MaterialPageX( 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 { static const String name = 'AllPeopleRoute'; } +/// generated route for +/// [MemoryPage] +class VerticalRouteView extends PageRouteInfo { + VerticalRouteView({ + required List 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 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 { diff --git a/mobile/lib/routing/tab_navigation_observer.dart b/mobile/lib/routing/tab_navigation_observer.dart index 7a9f4a5a8..b43283a89 100644 --- a/mobile/lib/routing/tab_navigation_observer.dart +++ b/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(); } } diff --git a/mobile/lib/shared/ui/immich_image.dart b/mobile/lib/shared/ui/immich_image.dart index 223319207..f17bb3b3f 100644 --- a/mobile/lib/shared/ui/immich_image.dart +++ b/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, ), ); diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index bb7eefefe..30c06a624 100644 --- a/mobile/pubspec.yaml +++ b/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