From 3f330c6476654da5fea59ac9c94bc9b6e61c6c48 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 30 Jun 2025 21:24:50 -0500 Subject: [PATCH] feat: drift album page (#19564) * feat: drift album page * feat: page renderred * feat: asset count * refactor: use statefulwidget * refactor: private widgets * refactor: service layer * refactor: import * feat: get owner name * pr feedback * pr feedback * pr feedback * pr feedback --- .../lib/domain/models/album/album.model.dart | 32 +- .../domain/services/remote_album.service.dart | 60 ++ .../repositories/remote_album.repository.dart | 34 +- mobile/lib/pages/common/tab_shell.page.dart | 2 +- .../presentation/pages/drift_album.page.dart | 767 ++++++++++++++++++ .../widgets/images/thumbnail.widget.dart | 30 +- .../infrastructure/album.provider.dart | 13 + .../infrastructure/remote_album.provider.dart | 121 +++ mobile/lib/routing/router.dart | 3 +- mobile/lib/routing/router.gr.dart | 16 + mobile/lib/utils/remote_album.utils.dart | 71 ++ .../widgets/common/immich_sliver_app_bar.dart | 11 +- 12 files changed, 1135 insertions(+), 25 deletions(-) create mode 100644 mobile/lib/domain/services/remote_album.service.dart create mode 100644 mobile/lib/presentation/pages/drift_album.page.dart create mode 100644 mobile/lib/providers/infrastructure/remote_album.provider.dart create mode 100644 mobile/lib/utils/remote_album.utils.dart diff --git a/mobile/lib/domain/models/album/album.model.dart b/mobile/lib/domain/models/album/album.model.dart index 1f433b84fe..29f75f29ef 100644 --- a/mobile/lib/domain/models/album/album.model.dart +++ b/mobile/lib/domain/models/album/album.model.dart @@ -21,6 +21,8 @@ class Album { final String? thumbnailAssetId; final bool isActivityEnabled; final AlbumAssetOrder order; + final int assetCount; + final String ownerName; const Album({ required this.id, @@ -32,20 +34,24 @@ class Album { this.thumbnailAssetId, required this.isActivityEnabled, required this.order, + required this.assetCount, + required this.ownerName, }); @override String toString() { return '''Album { - id: $id, - name: $name, - ownerId: $ownerId, - description: $description, - createdAt: $createdAt, - updatedAt: $updatedAt, - isActivityEnabled: $isActivityEnabled, - order: $order, - thumbnailAssetId: ${thumbnailAssetId ?? ""} + id: $id, + name: $name, + ownerId: $ownerId, + description: $description, + createdAt: $createdAt, + updatedAt: $updatedAt, + isActivityEnabled: $isActivityEnabled, + order: $order, + thumbnailAssetId: ${thumbnailAssetId ?? ""} + assetCount: $assetCount + ownerName: $ownerName }'''; } @@ -61,7 +67,9 @@ class Album { updatedAt == other.updatedAt && thumbnailAssetId == other.thumbnailAssetId && isActivityEnabled == other.isActivityEnabled && - order == other.order; + order == other.order && + assetCount == other.assetCount && + ownerName == other.ownerName; } @override @@ -74,6 +82,8 @@ class Album { updatedAt.hashCode ^ thumbnailAssetId.hashCode ^ isActivityEnabled.hashCode ^ - order.hashCode; + order.hashCode ^ + assetCount.hashCode ^ + ownerName.hashCode; } } diff --git a/mobile/lib/domain/services/remote_album.service.dart b/mobile/lib/domain/services/remote_album.service.dart new file mode 100644 index 0000000000..f3106470d2 --- /dev/null +++ b/mobile/lib/domain/services/remote_album.service.dart @@ -0,0 +1,60 @@ +import 'package:immich_mobile/domain/models/album/album.model.dart'; +import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; +import 'package:immich_mobile/models/albums/album_search.model.dart'; +import 'package:immich_mobile/utils/remote_album.utils.dart'; + +class RemoteAlbumService { + final DriftRemoteAlbumRepository _repository; + + const RemoteAlbumService(this._repository); + + Future> getAll() { + return _repository.getAll(); + } + + List sortAlbums( + List albums, + RemoteAlbumSortMode sortMode, { + bool isReverse = false, + }) { + return sortMode.sortFn(albums, isReverse); + } + + List searchAlbums( + List albums, + String query, + String? userId, [ + QuickFilterMode filterMode = QuickFilterMode.all, + ]) { + final lowerQuery = query.toLowerCase(); + List filtered = albums; + + // Apply text search filter + if (query.isNotEmpty) { + filtered = filtered + .where( + (album) => + album.name.toLowerCase().contains(lowerQuery) || + album.description.toLowerCase().contains(lowerQuery), + ) + .toList(); + } + + if (userId != null) { + switch (filterMode) { + case QuickFilterMode.myAlbums: + filtered = + filtered.where((album) => album.ownerId == userId).toList(); + break; + case QuickFilterMode.sharedWithMe: + filtered = + filtered.where((album) => album.ownerId != userId).toList(); + break; + case QuickFilterMode.all: + break; + } + } + + return filtered; + } +} diff --git a/mobile/lib/infrastructure/repositories/remote_album.repository.dart b/mobile/lib/infrastructure/repositories/remote_album.repository.dart index dd237c95bf..0e49b45bc5 100644 --- a/mobile/lib/infrastructure/repositories/remote_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_album.repository.dart @@ -10,26 +10,48 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository { const DriftRemoteAlbumRepository(this._db) : super(_db); Future> getAll({Set sortBy = const {}}) { - final query = _db.remoteAlbumEntity.select(); + final assetCount = _db.remoteAlbumAssetEntity.assetId.count(); + + final query = _db.remoteAlbumEntity.select().join([ + leftOuterJoin( + _db.remoteAlbumAssetEntity, + _db.remoteAlbumAssetEntity.albumId.equalsExp(_db.remoteAlbumEntity.id), + useColumns: false, + ), + leftOuterJoin( + _db.userEntity, + _db.userEntity.id.equalsExp(_db.remoteAlbumEntity.ownerId), + ), + ]); + query + ..addColumns([assetCount]) + ..groupBy([_db.remoteAlbumEntity.id]); if (sortBy.isNotEmpty) { - final orderings = >[]; + final orderings = []; for (final sort in sortBy) { orderings.add( switch (sort) { - SortRemoteAlbumsBy.id => (row) => OrderingTerm.asc(row.id), + SortRemoteAlbumsBy.id => OrderingTerm.asc(_db.remoteAlbumEntity.id), }, ); } query.orderBy(orderings); } - return query.map((row) => row.toDto()).get(); + return query + .map( + (row) => row.readTable(_db.remoteAlbumEntity).toDto( + assetCount: row.read(assetCount) ?? 0, + ownerName: row.readTable(_db.userEntity).name, + ), + ) + .get(); } } extension on RemoteAlbumEntityData { - Album toDto() { + Album toDto({int assetCount = 0, required String ownerName}) { return Album( id: id, name: name, @@ -40,6 +62,8 @@ extension on RemoteAlbumEntityData { thumbnailAssetId: thumbnailAssetId, isActivityEnabled: isActivityEnabled, order: order, + assetCount: assetCount, + ownerName: ownerName, ); } } diff --git a/mobile/lib/pages/common/tab_shell.page.dart b/mobile/lib/pages/common/tab_shell.page.dart index 7315f05c7f..aa5b695999 100644 --- a/mobile/lib/pages/common/tab_shell.page.dart +++ b/mobile/lib/pages/common/tab_shell.page.dart @@ -143,7 +143,7 @@ class TabShellPage extends ConsumerWidget { routes: [ const MainTimelineRoute(), SearchRoute(), - const AlbumsRoute(), + const DriftAlbumsRoute(), const LibraryRoute(), ], duration: const Duration(milliseconds: 600), diff --git a/mobile/lib/presentation/pages/drift_album.page.dart b/mobile/lib/presentation/pages/drift_album.page.dart new file mode 100644 index 0000000000..3298f12b65 --- /dev/null +++ b/mobile/lib/presentation/pages/drift_album.page.dart @@ -0,0 +1,767 @@ +import 'dart:async'; +import 'dart:math'; + +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/domain/models/album/album.model.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/models/albums/album_search.model.dart'; +import 'package:immich_mobile/pages/common/large_leading_tile.dart'; +import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/utils/remote_album.utils.dart'; +import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart'; +import 'package:immich_mobile/widgets/common/search_field.dart'; + +@RoutePage() +class DriftAlbumsPage extends ConsumerStatefulWidget { + const DriftAlbumsPage({super.key}); + + @override + ConsumerState createState() => _DriftAlbumsPageState(); +} + +class _DriftAlbumsPageState extends ConsumerState { + bool isGrid = false; + final searchController = TextEditingController(); + QuickFilterMode filterMode = QuickFilterMode.all; + final searchFocusNode = FocusNode(); + + @override + void initState() { + super.initState(); + + // Load albums when component mounts + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(remoteAlbumProvider.notifier).getAll(); + }); + + searchController.addListener(() { + onSearch(searchController.text, filterMode); + }); + } + + void onSearch(String searchTerm, QuickFilterMode sortMode) { + final userId = ref.watch(currentUserProvider)?.id; + ref + .read(remoteAlbumProvider.notifier) + .searchAlbums(searchTerm, userId, sortMode); + } + + Future onRefresh() async { + await ref.read(remoteAlbumProvider.notifier).refresh(); + } + + void toggleViewMode() { + setState(() { + isGrid = !isGrid; + }); + } + + void changeFilter(QuickFilterMode sortMode) { + setState(() { + filterMode = sortMode; + }); + } + + void clearSearch() { + setState(() { + filterMode = QuickFilterMode.all; + searchController.clear(); + ref.read(remoteAlbumProvider.notifier).clearSearch(); + }); + } + + @override + void dispose() { + searchController.dispose(); + searchFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final albumState = ref.watch(remoteAlbumProvider); + final albums = albumState.filteredAlbums; + final isLoading = albumState.isLoading; + final error = albumState.error; + final userId = ref.watch(currentUserProvider)?.id; + + return RefreshIndicator( + onRefresh: onRefresh, + child: CustomScrollView( + slivers: [ + const ImmichSliverAppBar(), + _SearchBar( + searchController: searchController, + searchFocusNode: searchFocusNode, + onSearch: onSearch, + filterMode: filterMode, + onClearSearch: clearSearch, + ), + _QuickFilterButtonRow( + filterMode: filterMode, + onChangeFilter: changeFilter, + onSearch: onSearch, + searchController: searchController, + ), + _QuickSortAndViewMode( + isGrid: isGrid, + onToggleViewMode: toggleViewMode, + ), + isGrid + ? _AlbumGrid( + albums: albums, + userId: userId, + isLoading: isLoading, + error: error, + ) + : _AlbumList( + albums: albums, + userId: userId, + isLoading: isLoading, + error: error, + ), + ], + ), + ); + } +} + +class _SortButton extends ConsumerStatefulWidget { + const _SortButton(); + + @override + ConsumerState<_SortButton> createState() => _SortButtonState(); +} + +class _SortButtonState extends ConsumerState<_SortButton> { + RemoteAlbumSortMode albumSortOption = RemoteAlbumSortMode.lastModified; + bool albumSortIsReverse = true; + + void onMenuTapped(RemoteAlbumSortMode sortMode) { + final selected = albumSortOption == sortMode; + // Switch direction + if (selected) { + setState(() { + albumSortIsReverse = !albumSortIsReverse; + }); + ref.read(remoteAlbumProvider.notifier).sortFilteredAlbums( + sortMode, + isReverse: albumSortIsReverse, + ); + } else { + setState(() { + albumSortOption = sortMode; + }); + ref.read(remoteAlbumProvider.notifier).sortFilteredAlbums( + sortMode, + isReverse: albumSortIsReverse, + ); + } + } + + @override + Widget build(BuildContext context) { + return MenuAnchor( + style: MenuStyle( + elevation: const WidgetStatePropertyAll(1), + shape: WidgetStateProperty.all( + const RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(24), + ), + ), + ), + padding: const WidgetStatePropertyAll( + EdgeInsets.all(4), + ), + ), + consumeOutsideTap: true, + menuChildren: RemoteAlbumSortMode.values + .map( + (sortMode) => MenuItemButton( + leadingIcon: albumSortOption == sortMode + ? albumSortIsReverse + ? Icon( + Icons.keyboard_arrow_down, + color: albumSortOption == sortMode + ? context.colorScheme.onPrimary + : context.colorScheme.onSurface, + ) + : Icon( + Icons.keyboard_arrow_up_rounded, + color: albumSortOption == sortMode + ? context.colorScheme.onPrimary + : context.colorScheme.onSurface, + ) + : const Icon(Icons.abc, color: Colors.transparent), + onPressed: () => onMenuTapped(sortMode), + style: ButtonStyle( + padding: WidgetStateProperty.all( + const EdgeInsets.fromLTRB(16, 16, 32, 16), + ), + backgroundColor: WidgetStateProperty.all( + albumSortOption == sortMode + ? context.colorScheme.primary + : Colors.transparent, + ), + shape: WidgetStateProperty.all( + const RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(24), + ), + ), + ), + ), + child: Text( + sortMode.key.t(context: context), + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: albumSortOption == sortMode + ? context.colorScheme.onPrimary + : context.colorScheme.onSurface.withAlpha(185), + ), + ), + ), + ) + .toList(), + builder: (context, controller, child) { + return GestureDetector( + onTap: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(right: 5), + child: albumSortIsReverse + ? const Icon( + Icons.keyboard_arrow_down, + ) + : const Icon( + Icons.keyboard_arrow_up_rounded, + ), + ), + Text( + albumSortOption.key.t(context: context), + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + color: context.colorScheme.onSurface.withAlpha(225), + ), + ), + ], + ), + ); + }, + ); + } +} + +class _SearchBar extends StatelessWidget { + const _SearchBar({ + required this.searchController, + required this.searchFocusNode, + required this.onSearch, + required this.filterMode, + required this.onClearSearch, + }); + + final TextEditingController searchController; + final FocusNode searchFocusNode; + final void Function(String, QuickFilterMode) onSearch; + final QuickFilterMode filterMode; + final VoidCallback onClearSearch; + + @override + Widget build(BuildContext context) { + return SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + sliver: SliverToBoxAdapter( + child: Container( + decoration: BoxDecoration( + border: Border.all( + color: context.colorScheme.onSurface.withAlpha(0), + width: 0, + ), + borderRadius: const BorderRadius.all( + Radius.circular(24), + ), + gradient: LinearGradient( + colors: [ + context.colorScheme.primary.withValues(alpha: 0.075), + context.colorScheme.primary.withValues(alpha: 0.09), + context.colorScheme.primary.withValues(alpha: 0.075), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + transform: const GradientRotation(0.5 * pi), + ), + ), + child: SearchField( + autofocus: false, + contentPadding: const EdgeInsets.all(16), + hintText: 'search_albums'.tr(), + prefixIcon: const Icon(Icons.search_rounded), + suffixIcon: searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear_rounded), + onPressed: onClearSearch, + ) + : null, + controller: searchController, + onChanged: (_) => onSearch(searchController.text, filterMode), + focusNode: searchFocusNode, + onTapOutside: (_) => searchFocusNode.unfocus(), + ), + ), + ), + ); + } +} + +class _QuickFilterButtonRow extends StatelessWidget { + const _QuickFilterButtonRow({ + required this.filterMode, + required this.onChangeFilter, + required this.onSearch, + required this.searchController, + }); + + final QuickFilterMode filterMode; + final void Function(QuickFilterMode) onChangeFilter; + final void Function(String, QuickFilterMode) onSearch; + final TextEditingController searchController; + + @override + Widget build(BuildContext context) { + return SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverToBoxAdapter( + child: Wrap( + spacing: 4, + runSpacing: 4, + children: [ + _QuickFilterButton( + label: 'all'.tr(), + isSelected: filterMode == QuickFilterMode.all, + onTap: () { + onChangeFilter(QuickFilterMode.all); + onSearch(searchController.text, QuickFilterMode.all); + }, + ), + _QuickFilterButton( + label: 'shared_with_me'.tr(), + isSelected: filterMode == QuickFilterMode.sharedWithMe, + onTap: () { + onChangeFilter(QuickFilterMode.sharedWithMe); + onSearch( + searchController.text, + QuickFilterMode.sharedWithMe, + ); + }, + ), + _QuickFilterButton( + label: 'my_albums'.tr(), + isSelected: filterMode == QuickFilterMode.myAlbums, + onTap: () { + onChangeFilter(QuickFilterMode.myAlbums); + onSearch( + searchController.text, + QuickFilterMode.myAlbums, + ); + }, + ), + ], + ), + ), + ); + } +} + +class _QuickFilterButton extends StatelessWidget { + const _QuickFilterButton({ + required this.isSelected, + required this.onTap, + required this.label, + }); + + final bool isSelected; + final VoidCallback onTap; + final String label; + + @override + Widget build(BuildContext context) { + return TextButton( + onPressed: onTap, + style: ButtonStyle( + backgroundColor: WidgetStateProperty.all( + isSelected ? context.colorScheme.primary : Colors.transparent, + ), + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: const BorderRadius.all( + Radius.circular(20), + ), + side: BorderSide( + color: context.colorScheme.onSurface.withAlpha(25), + width: 1, + ), + ), + ), + ), + child: Text( + label, + style: TextStyle( + color: isSelected + ? context.colorScheme.onPrimary + : context.colorScheme.onSurface, + fontSize: 14, + ), + ), + ); + } +} + +class _QuickSortAndViewMode extends StatelessWidget { + const _QuickSortAndViewMode({ + required this.isGrid, + required this.onToggleViewMode, + }); + + final bool isGrid; + final VoidCallback onToggleViewMode; + + @override + Widget build(BuildContext context) { + return SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverToBoxAdapter( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const _SortButton(), + IconButton( + icon: Icon( + isGrid ? Icons.view_list_outlined : Icons.grid_view_outlined, + size: 24, + ), + onPressed: onToggleViewMode, + ), + ], + ), + ), + ); + } +} + +class _AlbumList extends StatelessWidget { + const _AlbumList({ + required this.isLoading, + required this.error, + required this.albums, + required this.userId, + }); + + final bool isLoading; + final String? error; + final List albums; + final String? userId; + + @override + Widget build(BuildContext context) { + if (isLoading) { + return const SliverToBoxAdapter( + child: Center( + child: Padding( + padding: EdgeInsets.all(20.0), + child: CircularProgressIndicator(), + ), + ), + ); + } + + if (error != null) { + return SliverToBoxAdapter( + child: Center( + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Text( + 'Error loading albums: $error', + style: TextStyle( + color: context.colorScheme.error, + ), + ), + ), + ), + ); + } + + 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.symmetric(horizontal: 16.0), + sliver: SliverList.builder( + itemBuilder: (_, index) { + final album = albums[index]; + + return Padding( + padding: const EdgeInsets.only( + bottom: 8.0, + ), + child: LargeLeadingTile( + title: Text( + album.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + subtitle: Text( + '${'items_count'.t( + context: context, + args: { + 'count': album.assetCount, + }, + )} • ${album.ownerId != userId ? 'shared_by_user'.t( + context: context, + args: { + 'user': album.ownerName, + }, + ) : 'owned'.t(context: context)}', + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.onSurfaceSecondary, + ), + ), + onTap: () => context.router.push( + RemoteTimelineRoute(albumId: album.id), + ), + leadingPadding: const EdgeInsets.only( + right: 16, + ), + leading: album.thumbnailAssetId != null + ? ClipRRect( + borderRadius: const BorderRadius.all( + Radius.circular(15), + ), + child: SizedBox( + width: 80, + height: 80, + child: Thumbnail( + remoteId: album.thumbnailAssetId, + ), + ), + ) + : const SizedBox( + width: 80, + height: 80, + child: Icon( + Icons.photo_album_rounded, + size: 40, + color: Colors.grey, + ), + ), + ), + ); + }, + itemCount: albums.length, + ), + ); + } +} + +class _AlbumGrid extends StatelessWidget { + const _AlbumGrid({ + required this.albums, + required this.userId, + required this.isLoading, + required this.error, + }); + + final List albums; + final String? userId; + final bool isLoading; + final String? error; + + @override + Widget build(BuildContext context) { + if (isLoading) { + return const SliverToBoxAdapter( + child: Center( + child: Padding( + padding: EdgeInsets.all(20.0), + child: CircularProgressIndicator(), + ), + ), + ); + } + + if (error != null) { + return SliverToBoxAdapter( + child: Center( + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Text( + 'Error loading albums: $error', + style: TextStyle( + color: context.colorScheme.error, + ), + ), + ), + ), + ); + } + + 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.symmetric(horizontal: 16.0), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 250, + mainAxisSpacing: 4, + crossAxisSpacing: 4, + childAspectRatio: .7, + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + final album = albums[index]; + return _GridAlbumCard( + album: album, + userId: userId, + ); + }, + childCount: albums.length, + ), + ), + ); + } +} + +class _GridAlbumCard extends StatelessWidget { + const _GridAlbumCard({ + required this.album, + required this.userId, + }); + + final Album album; + final String? userId; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => context.router.push( + RemoteTimelineRoute(albumId: album.id), + ), + child: Card( + elevation: 0, + color: context.colorScheme.surfaceBright, + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.all( + Radius.circular(16), + ), + side: BorderSide( + color: context.colorScheme.onSurface.withAlpha(25), + width: 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 2, + child: ClipRRect( + borderRadius: const BorderRadius.vertical( + top: Radius.circular(15), + ), + child: SizedBox( + width: double.infinity, + child: album.thumbnailAssetId != null + ? Thumbnail( + remoteId: album.thumbnailAssetId, + ) + : Container( + color: context.colorScheme.surfaceContainerHighest, + child: const Icon( + Icons.photo_album_rounded, + size: 40, + color: Colors.grey, + ), + ), + ), + ), + ), + Expanded( + flex: 1, + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Text( + album.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + Text( + '${'items_count'.t( + context: context, + args: { + 'count': album.assetCount, + }, + )} • ${album.ownerId != userId ? 'shared_by_user'.t( + context: context, + args: { + 'user': album.ownerName, + }, + ) : 'owned'.t(context: context)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelMedium?.copyWith( + color: context.colorScheme.onSurfaceSecondary, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/images/thumbnail.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart index 82aef61633..bdf0baa3ca 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart @@ -10,20 +10,39 @@ import 'package:octo_image/octo_image.dart'; class Thumbnail extends StatelessWidget { const Thumbnail({ - required this.asset, + this.asset, + this.remoteId, this.size = const Size.square(256), this.fit = BoxFit.cover, super.key, - }); + }) : assert( + asset != null || remoteId != null, + 'Either asset or remoteId must be provided', + ); - final BaseAsset asset; + final BaseAsset? asset; + final String? remoteId; final Size size; final BoxFit fit; static ImageProvider imageProvider({ - required BaseAsset asset, + BaseAsset? asset, + String? remoteId, Size size = const Size.square(256), }) { + assert( + asset != null || remoteId != null, + 'Either asset or remoteId must be provided', + ); + + if (remoteId != null) { + return RemoteThumbProvider( + assetId: remoteId, + height: size.height, + width: size.width, + ); + } + if (asset is LocalAsset) { return LocalThumbProvider( asset: asset, @@ -47,7 +66,8 @@ class Thumbnail extends StatelessWidget { Widget build(BuildContext context) { final thumbHash = asset is RemoteAsset ? (asset as RemoteAsset).thumbHash : null; - final provider = imageProvider(asset: asset, size: size); + final provider = + imageProvider(asset: asset, remoteId: remoteId, size: size); return OctoImage.fromSet( image: provider, diff --git a/mobile/lib/providers/infrastructure/album.provider.dart b/mobile/lib/providers/infrastructure/album.provider.dart index b9dd212042..f222e20f50 100644 --- a/mobile/lib/providers/infrastructure/album.provider.dart +++ b/mobile/lib/providers/infrastructure/album.provider.dart @@ -1,7 +1,9 @@ import 'package:hooks_riverpod/hooks_riverpod.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'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart'; final localAlbumRepository = Provider( (ref) => DriftLocalAlbumRepository(ref.watch(driftProvider)), @@ -10,3 +12,14 @@ final localAlbumRepository = Provider( final remoteAlbumRepository = Provider( (ref) => DriftRemoteAlbumRepository(ref.watch(driftProvider)), ); + +final remoteAlbumServiceProvider = Provider( + (ref) => RemoteAlbumService(ref.watch(remoteAlbumRepository)), + dependencies: [remoteAlbumRepository], +); + +final remoteAlbumProvider = + NotifierProvider( + RemoteAlbumNotifier.new, + dependencies: [remoteAlbumServiceProvider], +); diff --git a/mobile/lib/providers/infrastructure/remote_album.provider.dart b/mobile/lib/providers/infrastructure/remote_album.provider.dart new file mode 100644 index 0000000000..2e5a475b9c --- /dev/null +++ b/mobile/lib/providers/infrastructure/remote_album.provider.dart @@ -0,0 +1,121 @@ +import 'package:collection/collection.dart'; +import 'package:immich_mobile/domain/models/album/album.model.dart'; +import 'package:immich_mobile/domain/services/remote_album.service.dart'; +import 'package:immich_mobile/models/albums/album_search.model.dart'; +import 'package:immich_mobile/utils/remote_album.utils.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'album.provider.dart'; + +class RemoteAlbumState { + final List albums; + final List filteredAlbums; + final bool isLoading; + final String? error; + + const RemoteAlbumState({ + required this.albums, + List? filteredAlbums, + this.isLoading = false, + this.error, + }) : filteredAlbums = filteredAlbums ?? albums; + + RemoteAlbumState copyWith({ + List? albums, + List? filteredAlbums, + bool? isLoading, + String? error, + }) { + return RemoteAlbumState( + albums: albums ?? this.albums, + filteredAlbums: filteredAlbums ?? this.filteredAlbums, + isLoading: isLoading ?? this.isLoading, + error: error ?? this.error, + ); + } + + @override + String toString() => + 'RemoteAlbumState(albums: ${albums.length}, filteredAlbums: ${filteredAlbums.length}, isLoading: $isLoading, error: $error)'; + + @override + bool operator ==(covariant RemoteAlbumState other) { + if (identical(this, other)) return true; + final listEquals = const DeepCollectionEquality().equals; + + return listEquals(other.albums, albums) && + listEquals(other.filteredAlbums, filteredAlbums) && + other.isLoading == isLoading && + other.error == error; + } + + @override + int get hashCode => + albums.hashCode ^ + filteredAlbums.hashCode ^ + isLoading.hashCode ^ + error.hashCode; +} + +class RemoteAlbumNotifier extends Notifier { + late final RemoteAlbumService _remoteAlbumService; + + @override + RemoteAlbumState build() { + _remoteAlbumService = ref.read(remoteAlbumServiceProvider); + return const RemoteAlbumState(albums: [], filteredAlbums: []); + } + + Future> getAll() async { + state = state.copyWith(isLoading: true, error: null); + + try { + final albums = await _remoteAlbumService.getAll(); + state = state.copyWith( + albums: albums, + filteredAlbums: albums, + isLoading: false, + ); + return albums; + } catch (e) { + state = state.copyWith(isLoading: false, error: e.toString()); + rethrow; + } + } + + Future refresh() async { + await getAll(); + } + + void searchAlbums( + String query, + String? userId, [ + QuickFilterMode filterMode = QuickFilterMode.all, + ]) { + final filtered = _remoteAlbumService.searchAlbums( + state.albums, + query, + userId, + filterMode, + ); + + state = state.copyWith( + filteredAlbums: filtered, + ); + } + + void clearSearch() { + state = state.copyWith( + filteredAlbums: state.albums, + ); + } + + void sortFilteredAlbums( + RemoteAlbumSortMode sortMode, { + bool isReverse = false, + }) { + final sortedAlbums = _remoteAlbumService + .sortAlbums(state.filteredAlbums, sortMode, isReverse: isReverse); + state = state.copyWith(filteredAlbums: sortedAlbums); + } +} diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 708171896a..31e1d715a1 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -69,6 +69,7 @@ import 'package:immich_mobile/presentation/pages/dev/local_timeline.page.dart'; import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart'; import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart'; import 'package:immich_mobile/presentation/pages/dev/remote_timeline.page.dart'; +import 'package:immich_mobile/presentation/pages/drift_album.page.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/routing/auth_guard.dart'; @@ -172,7 +173,7 @@ class AppRouter extends RootStackRouter { guards: [_authGuard, _duplicateGuard], ), AutoRoute( - page: AlbumsRoute.page, + page: DriftAlbumsRoute.page, guards: [_authGuard, _duplicateGuard], ), ], diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index a5c2c58614..2ea10491b3 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -550,6 +550,22 @@ class CropImageRouteArgs { } } +/// generated route for +/// [DriftAlbumsPage] +class DriftAlbumsRoute extends PageRouteInfo { + const DriftAlbumsRoute({List? children}) + : super(DriftAlbumsRoute.name, initialChildren: children); + + static const String name = 'DriftAlbumsRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const DriftAlbumsPage(); + }, + ); +} + /// generated route for /// [EditImagePage] class EditImageRoute extends PageRouteInfo { diff --git a/mobile/lib/utils/remote_album.utils.dart b/mobile/lib/utils/remote_album.utils.dart new file mode 100644 index 0000000000..4fc7ba5f74 --- /dev/null +++ b/mobile/lib/utils/remote_album.utils.dart @@ -0,0 +1,71 @@ +import 'package:collection/collection.dart'; +import 'package:immich_mobile/domain/models/album/album.model.dart'; + +typedef AlbumSortFn = List Function(List albums, bool isReverse); + +class _RemoteAlbumSortHandlers { + const _RemoteAlbumSortHandlers._(); + + static const AlbumSortFn created = _sortByCreated; + static List _sortByCreated(List albums, bool isReverse) { + final sorted = albums.sortedBy((album) => album.createdAt); + return (isReverse ? sorted.reversed : sorted).toList(); + } + + static const AlbumSortFn title = _sortByTitle; + static List _sortByTitle(List albums, bool isReverse) { + final sorted = albums.sortedBy((album) => album.name); + return (isReverse ? sorted.reversed : sorted).toList(); + } + + static const AlbumSortFn lastModified = _sortByLastModified; + static List _sortByLastModified(List albums, bool isReverse) { + final sorted = albums.sortedBy((album) => album.updatedAt); + return (isReverse ? sorted.reversed : sorted).toList(); + } + + static const AlbumSortFn assetCount = _sortByAssetCount; + static List _sortByAssetCount(List albums, bool isReverse) { + final sorted = + albums.sorted((a, b) => a.assetCount.compareTo(b.assetCount)); + return (isReverse ? sorted.reversed : sorted).toList(); + } + + static const AlbumSortFn mostRecent = _sortByMostRecent; + static List _sortByMostRecent(List albums, bool isReverse) { + final sorted = albums.sorted((a, b) { + // For most recent, we sort by updatedAt in descending order + return b.updatedAt.compareTo(a.updatedAt); + }); + return (isReverse ? sorted.reversed : sorted).toList(); + } + + static const AlbumSortFn mostOldest = _sortByMostOldest; + static List _sortByMostOldest(List albums, bool isReverse) { + final sorted = albums.sorted((a, b) { + // For oldest, we sort by createdAt in ascending order + return a.createdAt.compareTo(b.createdAt); + }); + return (isReverse ? sorted.reversed : sorted).toList(); + } +} + +enum RemoteAlbumSortMode { + title("library_page_sort_title", _RemoteAlbumSortHandlers.title), + assetCount( + "library_page_sort_asset_count", + _RemoteAlbumSortHandlers.assetCount, + ), + lastModified( + "library_page_sort_last_modified", + _RemoteAlbumSortHandlers.lastModified, + ), + created("library_page_sort_created", _RemoteAlbumSortHandlers.created), + mostRecent("sort_recent", _RemoteAlbumSortHandlers.mostRecent), + mostOldest("sort_oldest", _RemoteAlbumSortHandlers.mostOldest); + + final String key; + final AlbumSortFn sortFn; + + const RemoteAlbumSortMode(this.key, this.sortFn); +} diff --git a/mobile/lib/widgets/common/immich_sliver_app_bar.dart b/mobile/lib/widgets/common/immich_sliver_app_bar.dart index 64b5ee0136..51a3a136b9 100644 --- a/mobile/lib/widgets/common/immich_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/immich_sliver_app_bar.dart @@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/models/server_info/server_info.model.dart'; +import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; @@ -62,8 +63,14 @@ class ImmichSliverAppBar extends ConsumerWidget { ), ), IconButton( - icon: const Icon(Icons.science_rounded), - onPressed: () => context.pushRoute(const FeatInDevRoute()), + icon: const Icon(Icons.swipe_left_alt_rounded), + onPressed: () => context.pop(), + ), + IconButton( + onPressed: () => ref.read(backgroundSyncProvider).syncRemote(), + icon: const Icon( + Icons.sync, + ), ), if (isCasting) Padding(