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
This commit is contained in:
Alex 2025-06-30 21:24:50 -05:00 committed by GitHub
parent bb8755021d
commit 3f330c6476
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1135 additions and 25 deletions

View File

@ -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,6 +34,8 @@ class Album {
this.thumbnailAssetId,
required this.isActivityEnabled,
required this.order,
required this.assetCount,
required this.ownerName,
});
@override
@ -46,6 +50,8 @@ class Album {
isActivityEnabled: $isActivityEnabled,
order: $order,
thumbnailAssetId: ${thumbnailAssetId ?? "<NA>"}
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;
}
}

View File

@ -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<List<Album>> getAll() {
return _repository.getAll();
}
List<Album> sortAlbums(
List<Album> albums,
RemoteAlbumSortMode sortMode, {
bool isReverse = false,
}) {
return sortMode.sortFn(albums, isReverse);
}
List<Album> searchAlbums(
List<Album> albums,
String query,
String? userId, [
QuickFilterMode filterMode = QuickFilterMode.all,
]) {
final lowerQuery = query.toLowerCase();
List<Album> 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;
}
}

View File

@ -10,26 +10,48 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
const DriftRemoteAlbumRepository(this._db) : super(_db);
Future<List<Album>> getAll({Set<SortRemoteAlbumsBy> 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 = <OrderClauseGenerator<$RemoteAlbumEntityTable>>[];
final orderings = <OrderingTerm>[];
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,
);
}
}

View File

@ -143,7 +143,7 @@ class TabShellPage extends ConsumerWidget {
routes: [
const MainTimelineRoute(),
SearchRoute(),
const AlbumsRoute(),
const DriftAlbumsRoute(),
const LibraryRoute(),
],
duration: const Duration(milliseconds: 600),

View File

@ -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<DriftAlbumsPage> createState() => _DriftAlbumsPageState();
}
class _DriftAlbumsPageState extends ConsumerState<DriftAlbumsPage> {
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<void> 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<Album> 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<Album> 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,
),
),
],
),
),
),
],
),
),
);
}
}

View File

@ -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,

View File

@ -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<DriftLocalAlbumRepository>(
(ref) => DriftLocalAlbumRepository(ref.watch(driftProvider)),
@ -10,3 +12,14 @@ final localAlbumRepository = Provider<DriftLocalAlbumRepository>(
final remoteAlbumRepository = Provider<DriftRemoteAlbumRepository>(
(ref) => DriftRemoteAlbumRepository(ref.watch(driftProvider)),
);
final remoteAlbumServiceProvider = Provider<RemoteAlbumService>(
(ref) => RemoteAlbumService(ref.watch(remoteAlbumRepository)),
dependencies: [remoteAlbumRepository],
);
final remoteAlbumProvider =
NotifierProvider<RemoteAlbumNotifier, RemoteAlbumState>(
RemoteAlbumNotifier.new,
dependencies: [remoteAlbumServiceProvider],
);

View File

@ -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<Album> albums;
final List<Album> filteredAlbums;
final bool isLoading;
final String? error;
const RemoteAlbumState({
required this.albums,
List<Album>? filteredAlbums,
this.isLoading = false,
this.error,
}) : filteredAlbums = filteredAlbums ?? albums;
RemoteAlbumState copyWith({
List<Album>? albums,
List<Album>? 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<RemoteAlbumState> {
late final RemoteAlbumService _remoteAlbumService;
@override
RemoteAlbumState build() {
_remoteAlbumService = ref.read(remoteAlbumServiceProvider);
return const RemoteAlbumState(albums: [], filteredAlbums: []);
}
Future<List<Album>> 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<void> 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);
}
}

View File

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

View File

@ -550,6 +550,22 @@ class CropImageRouteArgs {
}
}
/// generated route for
/// [DriftAlbumsPage]
class DriftAlbumsRoute extends PageRouteInfo<void> {
const DriftAlbumsRoute({List<PageRouteInfo>? 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<EditImageRouteArgs> {

View File

@ -0,0 +1,71 @@
import 'package:collection/collection.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
typedef AlbumSortFn = List<Album> Function(List<Album> albums, bool isReverse);
class _RemoteAlbumSortHandlers {
const _RemoteAlbumSortHandlers._();
static const AlbumSortFn created = _sortByCreated;
static List<Album> _sortByCreated(List<Album> albums, bool isReverse) {
final sorted = albums.sortedBy((album) => album.createdAt);
return (isReverse ? sorted.reversed : sorted).toList();
}
static const AlbumSortFn title = _sortByTitle;
static List<Album> _sortByTitle(List<Album> albums, bool isReverse) {
final sorted = albums.sortedBy((album) => album.name);
return (isReverse ? sorted.reversed : sorted).toList();
}
static const AlbumSortFn lastModified = _sortByLastModified;
static List<Album> _sortByLastModified(List<Album> albums, bool isReverse) {
final sorted = albums.sortedBy((album) => album.updatedAt);
return (isReverse ? sorted.reversed : sorted).toList();
}
static const AlbumSortFn assetCount = _sortByAssetCount;
static List<Album> _sortByAssetCount(List<Album> 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<Album> _sortByMostRecent(List<Album> 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<Album> _sortByMostOldest(List<Album> 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);
}

View File

@ -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(