From a556de67b0ef330610f9d08fed660f093a767aee Mon Sep 17 00:00:00 2001 From: Daimolean <92239625+wuzihao051119@users.noreply.github.com> Date: Tue, 8 Jul 2025 23:37:57 +0800 Subject: [PATCH] feat(mobile): drift local albums page (#19817) * feat(mobile): drift local albums page * fix: lint * refactor: use AsyncValue * fix: lint * local album thumbnail --------- Co-authored-by: Alex --- .../domain/services/local_album.service.dart | 17 +++ .../repositories/local_album.repository.dart | 18 +++ .../pages/dev/drift_local_album.page.dart | 116 ++++++++++++++++++ .../pages/drift_library.page.dart | 40 ++++-- .../images/local_album_thumbnail.widget.dart | 54 ++++++++ .../infrastructure/album.provider.dart | 16 +++ mobile/lib/routing/router.dart | 5 + mobile/lib/routing/router.gr.dart | 16 +++ .../common/local_album_sliver_app_bar.dart | 25 ++++ 9 files changed, 294 insertions(+), 13 deletions(-) create mode 100644 mobile/lib/domain/services/local_album.service.dart create mode 100644 mobile/lib/presentation/pages/dev/drift_local_album.page.dart create mode 100644 mobile/lib/presentation/widgets/images/local_album_thumbnail.widget.dart create mode 100644 mobile/lib/widgets/common/local_album_sliver_app_bar.dart diff --git a/mobile/lib/domain/services/local_album.service.dart b/mobile/lib/domain/services/local_album.service.dart new file mode 100644 index 0000000000..9af12ce595 --- /dev/null +++ b/mobile/lib/domain/services/local_album.service.dart @@ -0,0 +1,17 @@ +import 'package:immich_mobile/domain/models/album/local_album.model.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; + +class LocalAlbumService { + final DriftLocalAlbumRepository _repository; + + const LocalAlbumService(this._repository); + + Future> getAll() { + return _repository.getAll(); + } + + Future getThumbnail(String albumId) { + return _repository.getThumbnail(albumId); + } +} diff --git a/mobile/lib/infrastructure/repositories/local_album.repository.dart b/mobile/lib/infrastructure/repositories/local_album.repository.dart index 33d61848db..154e79149a 100644 --- a/mobile/lib/infrastructure/repositories/local_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_album.repository.dart @@ -361,6 +361,24 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository { batch.deleteWhere(_db.localAssetEntity, (f) => f.id.isIn(ids)); }); } + + Future getThumbnail(String albumId) async { + final query = _db.localAlbumAssetEntity.select().join([ + innerJoin( + _db.localAssetEntity, + _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id), + ), + ]) + ..where(_db.localAlbumAssetEntity.albumId.equals(albumId)) + ..orderBy([OrderingTerm.asc(_db.localAssetEntity.id)]) + ..limit(1); + + final results = await query + .map((row) => row.readTable(_db.localAssetEntity).toDto()) + .get(); + + return results.isNotEmpty ? results.first : null; + } } extension on LocalAlbumEntityData { diff --git a/mobile/lib/presentation/pages/dev/drift_local_album.page.dart b/mobile/lib/presentation/pages/dev/drift_local_album.page.dart new file mode 100644 index 0000000000..f47811b6da --- /dev/null +++ b/mobile/lib/presentation/pages/dev/drift_local_album.page.dart @@ -0,0 +1,116 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/pages/common/large_leading_tile.dart'; +import 'package:immich_mobile/presentation/widgets/images/local_album_thumbnail.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/widgets/common/local_album_sliver_app_bar.dart'; + +@RoutePage() +class DriftLocalAlbumsPage extends StatelessWidget { + const DriftLocalAlbumsPage({super.key}); + + @override + Widget build(BuildContext context) { + return const Scaffold( + body: CustomScrollView( + slivers: [ + LocalAlbumsSliverAppBar(), + _AlbumList(), + ], + ), + ); + } +} + +class _AlbumList extends ConsumerWidget { + const _AlbumList(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final albums = ref.watch(localAlbumProvider); + + return albums.when( + loading: () => const SliverToBoxAdapter( + child: Center( + child: Padding( + padding: EdgeInsets.all(20.0), + child: CircularProgressIndicator(), + ), + ), + ), + error: (error, stack) => SliverToBoxAdapter( + child: Center( + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Text( + 'Error loading albums: $error, stack: $stack', + style: TextStyle( + color: context.colorScheme.error, + ), + ), + ), + ), + ), + data: (albums) { + if (albums.isEmpty) { + return const SliverToBoxAdapter( + child: Center( + child: Padding( + padding: EdgeInsets.all(20.0), + child: Text('No albums found'), + ), + ), + ); + } + + return SliverPadding( + padding: const EdgeInsets.all(18.0), + sliver: SliverList.builder( + itemBuilder: (_, index) { + final album = albums[index]; + + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: LargeLeadingTile( + leadingPadding: const EdgeInsets.only( + right: 16, + ), + leading: SizedBox( + width: 80, + height: 80, + child: LocalAlbumThumbnail( + albumId: album.id, + ), + ), + title: Text( + album.name, + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + subtitle: Text( + 'items_count'.t( + context: context, + args: {'count': album.assetCount}, + ), + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.onSurfaceSecondary, + ), + ), + onTap: () => + context.pushRoute(LocalTimelineRoute(albumId: album.id)), + ), + ); + }, + itemCount: albums.length, + ), + ); + }, + ); + } +} diff --git a/mobile/lib/presentation/pages/drift_library.page.dart b/mobile/lib/presentation/pages/drift_library.page.dart index 1b3452009a..0efa8040e7 100644 --- a/mobile/lib/presentation/pages/drift_library.page.dart +++ b/mobile/lib/presentation/pages/drift_library.page.dart @@ -5,14 +5,14 @@ import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; -import 'package:immich_mobile/providers/album/album.provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/local_album_thumbnail.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/partner.provider.dart'; import 'package:immich_mobile/providers/search/people.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; -import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart'; import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart'; import 'package:immich_mobile/widgets/common/user_avatar.dart'; import 'package:immich_mobile/widgets/map/map_thumbnail.dart'; @@ -305,8 +305,7 @@ class _LocalAlbumsCollectionCard extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - // TODO: Migrate to the drift after local album page - final albums = ref.watch(localAlbumsProvider); + final albums = ref.watch(localAlbumProvider); return LayoutBuilder( builder: (context, constraints) { @@ -315,9 +314,7 @@ class _LocalAlbumsCollectionCard extends ConsumerWidget { final size = context.width * widthFactor - 20.0; return GestureDetector( - onTap: () => context.pushRoute( - const LocalAlbumsRoute(), - ), + onTap: () => context.pushRoute(const DriftLocalAlbumsRoute()), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -342,12 +339,29 @@ class _LocalAlbumsCollectionCard extends ConsumerWidget { crossAxisSpacing: 8, mainAxisSpacing: 8, physics: const NeverScrollableScrollPhysics(), - children: albums.take(4).map((album) { - return AlbumThumbnailCard( - album: album, - showTitle: false, - ); - }).toList(), + children: albums.when( + data: (data) { + return data.take(4).map((album) { + return LocalAlbumThumbnail( + albumId: album.id, + ); + }).toList(); + }, + error: (error, _) { + return [ + Center( + child: Text('Error: $error'), + ), + ]; + }, + loading: () { + return [ + const Center( + child: CircularProgressIndicator(), + ), + ]; + }, + ), ), ), ), diff --git a/mobile/lib/presentation/widgets/images/local_album_thumbnail.widget.dart b/mobile/lib/presentation/widgets/images/local_album_thumbnail.widget.dart new file mode 100644 index 0000000000..dcf0f28527 --- /dev/null +++ b/mobile/lib/presentation/widgets/images/local_album_thumbnail.widget.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; + +class LocalAlbumThumbnail extends ConsumerWidget { + const LocalAlbumThumbnail({ + super.key, + required this.albumId, + }); + + final String albumId; + @override + Widget build(BuildContext context, WidgetRef ref) { + final localAlbumThumbnail = ref.watch(localAlbumThumbnailProvider(albumId)); + return localAlbumThumbnail.when( + data: (data) { + if (data == null) { + return Container( + decoration: BoxDecoration( + color: context.colorScheme.surfaceContainer, + borderRadius: const BorderRadius.all(Radius.circular(16)), + border: Border.all( + color: context.colorScheme.outline.withAlpha(50), + width: 1, + ), + ), + child: Icon( + Icons.collections, + size: 24, + color: context.primaryColor, + ), + ); + } + + return ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(16)), + child: Thumbnail( + asset: data, + ), + ); + }, + error: (error, stack) { + return const Icon(Icons.error, size: 24); + }, + loading: () => const SizedBox( + width: 24, + height: 24, + child: Center(child: CircularProgressIndicator()), + ), + ); + } +} diff --git a/mobile/lib/providers/infrastructure/album.provider.dart b/mobile/lib/providers/infrastructure/album.provider.dart index f222e20f50..4a6db50697 100644 --- a/mobile/lib/providers/infrastructure/album.provider.dart +++ b/mobile/lib/providers/infrastructure/album.provider.dart @@ -1,4 +1,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/album/local_album.model.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/services/local_album.service.dart'; import 'package:immich_mobile/domain/services/remote_album.service.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; @@ -9,6 +12,19 @@ final localAlbumRepository = Provider( (ref) => DriftLocalAlbumRepository(ref.watch(driftProvider)), ); +final localAlbumServiceProvider = Provider( + (ref) => LocalAlbumService(ref.watch(localAlbumRepository)), +); + +final localAlbumProvider = FutureProvider>( + (ref) => LocalAlbumService(ref.watch(localAlbumRepository)).getAll(), +); + +final localAlbumThumbnailProvider = FutureProvider.family( + (ref, albumId) => + LocalAlbumService(ref.watch(localAlbumRepository)).getThumbnail(albumId), +); + final remoteAlbumRepository = Provider( (ref) => DriftRemoteAlbumRepository(ref.watch(driftProvider)), ); diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index c8905d79c0..2dbd835ce8 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -69,6 +69,7 @@ import 'package:immich_mobile/pages/search/search.page.dart'; import 'package:immich_mobile/pages/share_intent/share_intent.page.dart'; import 'package:immich_mobile/presentation/pages/dev/drift_favorite.page.dart'; import 'package:immich_mobile/presentation/pages/dev/drift_partner_detail.page.dart'; +import 'package:immich_mobile/presentation/pages/dev/drift_local_album.page.dart'; import 'package:immich_mobile/presentation/pages/dev/drift_recently_taken.page.dart'; import 'package:immich_mobile/presentation/pages/dev/drift_video.page.dart'; import 'package:immich_mobile/presentation/pages/dev/drift_trash.page.dart'; @@ -438,6 +439,10 @@ class AppRouter extends RootStackRouter { page: DriftRecentlyTakenRoute.page, guards: [_authGuard, _duplicateGuard], ), + AutoRoute( + page: DriftLocalAlbumsRoute.page, + guards: [_authGuard, _duplicateGuard], + ), // required to handle all deeplinks in deep_link.service.dart // auto_route_library#1722 RedirectRoute(path: '*', redirectTo: '/'), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 56ccb83aa9..b13320b1c0 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -715,6 +715,22 @@ class DriftLibraryRoute extends PageRouteInfo { ); } +/// generated route for +/// [DriftLocalAlbumsPage] +class DriftLocalAlbumsRoute extends PageRouteInfo { + const DriftLocalAlbumsRoute({List? children}) + : super(DriftLocalAlbumsRoute.name, initialChildren: children); + + static const String name = 'DriftLocalAlbumsRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const DriftLocalAlbumsPage(); + }, + ); +} + /// generated route for /// [DriftLockedFolderPage] class DriftLockedFolderRoute extends PageRouteInfo { diff --git a/mobile/lib/widgets/common/local_album_sliver_app_bar.dart b/mobile/lib/widgets/common/local_album_sliver_app_bar.dart new file mode 100644 index 0000000000..4880865e66 --- /dev/null +++ b/mobile/lib/widgets/common/local_album_sliver_app_bar.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; + +class LocalAlbumsSliverAppBar extends StatelessWidget { + const LocalAlbumsSliverAppBar({super.key}); + + @override + Widget build(BuildContext context) { + return SliverAppBar( + floating: true, + pinned: true, + snap: false, + backgroundColor: context.colorScheme.surfaceContainer, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(5)), + ), + automaticallyImplyLeading: true, + centerTitle: true, + title: Text( + "on_this_device".t(context: context), + ), + ); + } +}