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 <alex.tran1502@gmail.com>
This commit is contained in:
Daimolean 2025-07-08 23:37:57 +08:00 committed by GitHub
parent e703685d8d
commit a556de67b0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 294 additions and 13 deletions

View File

@ -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<List<LocalAlbum>> getAll() {
return _repository.getAll();
}
Future<LocalAsset?> getThumbnail(String albumId) {
return _repository.getThumbnail(albumId);
}
}

View File

@ -361,6 +361,24 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
batch.deleteWhere(_db.localAssetEntity, (f) => f.id.isIn(ids));
});
}
Future<LocalAsset?> 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 {

View File

@ -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,
),
);
},
);
}
}

View File

@ -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(),
),
];
},
),
),
),
),

View File

@ -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()),
),
);
}
}

View File

@ -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<DriftLocalAlbumRepository>(
(ref) => DriftLocalAlbumRepository(ref.watch(driftProvider)),
);
final localAlbumServiceProvider = Provider<LocalAlbumService>(
(ref) => LocalAlbumService(ref.watch(localAlbumRepository)),
);
final localAlbumProvider = FutureProvider<List<LocalAlbum>>(
(ref) => LocalAlbumService(ref.watch(localAlbumRepository)).getAll(),
);
final localAlbumThumbnailProvider = FutureProvider.family<LocalAsset?, String>(
(ref, albumId) =>
LocalAlbumService(ref.watch(localAlbumRepository)).getThumbnail(albumId),
);
final remoteAlbumRepository = Provider<DriftRemoteAlbumRepository>(
(ref) => DriftRemoteAlbumRepository(ref.watch(driftProvider)),
);

View File

@ -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: '/'),

View File

@ -715,6 +715,22 @@ class DriftLibraryRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [DriftLocalAlbumsPage]
class DriftLocalAlbumsRoute extends PageRouteInfo<void> {
const DriftLocalAlbumsRoute({List<PageRouteInfo>? 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<void> {

View File

@ -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),
),
);
}
}