mirror of
https://github.com/immich-app/immich.git
synced 2025-07-09 03:04:16 -04:00
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:
parent
bb8755021d
commit
3f330c6476
@ -21,6 +21,8 @@ class Album {
|
|||||||
final String? thumbnailAssetId;
|
final String? thumbnailAssetId;
|
||||||
final bool isActivityEnabled;
|
final bool isActivityEnabled;
|
||||||
final AlbumAssetOrder order;
|
final AlbumAssetOrder order;
|
||||||
|
final int assetCount;
|
||||||
|
final String ownerName;
|
||||||
|
|
||||||
const Album({
|
const Album({
|
||||||
required this.id,
|
required this.id,
|
||||||
@ -32,20 +34,24 @@ class Album {
|
|||||||
this.thumbnailAssetId,
|
this.thumbnailAssetId,
|
||||||
required this.isActivityEnabled,
|
required this.isActivityEnabled,
|
||||||
required this.order,
|
required this.order,
|
||||||
|
required this.assetCount,
|
||||||
|
required this.ownerName,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return '''Album {
|
return '''Album {
|
||||||
id: $id,
|
id: $id,
|
||||||
name: $name,
|
name: $name,
|
||||||
ownerId: $ownerId,
|
ownerId: $ownerId,
|
||||||
description: $description,
|
description: $description,
|
||||||
createdAt: $createdAt,
|
createdAt: $createdAt,
|
||||||
updatedAt: $updatedAt,
|
updatedAt: $updatedAt,
|
||||||
isActivityEnabled: $isActivityEnabled,
|
isActivityEnabled: $isActivityEnabled,
|
||||||
order: $order,
|
order: $order,
|
||||||
thumbnailAssetId: ${thumbnailAssetId ?? "<NA>"}
|
thumbnailAssetId: ${thumbnailAssetId ?? "<NA>"}
|
||||||
|
assetCount: $assetCount
|
||||||
|
ownerName: $ownerName
|
||||||
}''';
|
}''';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,7 +67,9 @@ class Album {
|
|||||||
updatedAt == other.updatedAt &&
|
updatedAt == other.updatedAt &&
|
||||||
thumbnailAssetId == other.thumbnailAssetId &&
|
thumbnailAssetId == other.thumbnailAssetId &&
|
||||||
isActivityEnabled == other.isActivityEnabled &&
|
isActivityEnabled == other.isActivityEnabled &&
|
||||||
order == other.order;
|
order == other.order &&
|
||||||
|
assetCount == other.assetCount &&
|
||||||
|
ownerName == other.ownerName;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -74,6 +82,8 @@ class Album {
|
|||||||
updatedAt.hashCode ^
|
updatedAt.hashCode ^
|
||||||
thumbnailAssetId.hashCode ^
|
thumbnailAssetId.hashCode ^
|
||||||
isActivityEnabled.hashCode ^
|
isActivityEnabled.hashCode ^
|
||||||
order.hashCode;
|
order.hashCode ^
|
||||||
|
assetCount.hashCode ^
|
||||||
|
ownerName.hashCode;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
60
mobile/lib/domain/services/remote_album.service.dart
Normal file
60
mobile/lib/domain/services/remote_album.service.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -10,26 +10,48 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
|||||||
const DriftRemoteAlbumRepository(this._db) : super(_db);
|
const DriftRemoteAlbumRepository(this._db) : super(_db);
|
||||||
|
|
||||||
Future<List<Album>> getAll({Set<SortRemoteAlbumsBy> sortBy = const {}}) {
|
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) {
|
if (sortBy.isNotEmpty) {
|
||||||
final orderings = <OrderClauseGenerator<$RemoteAlbumEntityTable>>[];
|
final orderings = <OrderingTerm>[];
|
||||||
for (final sort in sortBy) {
|
for (final sort in sortBy) {
|
||||||
orderings.add(
|
orderings.add(
|
||||||
switch (sort) {
|
switch (sort) {
|
||||||
SortRemoteAlbumsBy.id => (row) => OrderingTerm.asc(row.id),
|
SortRemoteAlbumsBy.id => OrderingTerm.asc(_db.remoteAlbumEntity.id),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
query.orderBy(orderings);
|
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 {
|
extension on RemoteAlbumEntityData {
|
||||||
Album toDto() {
|
Album toDto({int assetCount = 0, required String ownerName}) {
|
||||||
return Album(
|
return Album(
|
||||||
id: id,
|
id: id,
|
||||||
name: name,
|
name: name,
|
||||||
@ -40,6 +62,8 @@ extension on RemoteAlbumEntityData {
|
|||||||
thumbnailAssetId: thumbnailAssetId,
|
thumbnailAssetId: thumbnailAssetId,
|
||||||
isActivityEnabled: isActivityEnabled,
|
isActivityEnabled: isActivityEnabled,
|
||||||
order: order,
|
order: order,
|
||||||
|
assetCount: assetCount,
|
||||||
|
ownerName: ownerName,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -143,7 +143,7 @@ class TabShellPage extends ConsumerWidget {
|
|||||||
routes: [
|
routes: [
|
||||||
const MainTimelineRoute(),
|
const MainTimelineRoute(),
|
||||||
SearchRoute(),
|
SearchRoute(),
|
||||||
const AlbumsRoute(),
|
const DriftAlbumsRoute(),
|
||||||
const LibraryRoute(),
|
const LibraryRoute(),
|
||||||
],
|
],
|
||||||
duration: const Duration(milliseconds: 600),
|
duration: const Duration(milliseconds: 600),
|
||||||
|
767
mobile/lib/presentation/pages/drift_album.page.dart
Normal file
767
mobile/lib/presentation/pages/drift_album.page.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -10,20 +10,39 @@ import 'package:octo_image/octo_image.dart';
|
|||||||
|
|
||||||
class Thumbnail extends StatelessWidget {
|
class Thumbnail extends StatelessWidget {
|
||||||
const Thumbnail({
|
const Thumbnail({
|
||||||
required this.asset,
|
this.asset,
|
||||||
|
this.remoteId,
|
||||||
this.size = const Size.square(256),
|
this.size = const Size.square(256),
|
||||||
this.fit = BoxFit.cover,
|
this.fit = BoxFit.cover,
|
||||||
super.key,
|
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 Size size;
|
||||||
final BoxFit fit;
|
final BoxFit fit;
|
||||||
|
|
||||||
static ImageProvider imageProvider({
|
static ImageProvider imageProvider({
|
||||||
required BaseAsset asset,
|
BaseAsset? asset,
|
||||||
|
String? remoteId,
|
||||||
Size size = const Size.square(256),
|
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) {
|
if (asset is LocalAsset) {
|
||||||
return LocalThumbProvider(
|
return LocalThumbProvider(
|
||||||
asset: asset,
|
asset: asset,
|
||||||
@ -47,7 +66,8 @@ class Thumbnail extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final thumbHash =
|
final thumbHash =
|
||||||
asset is RemoteAsset ? (asset as RemoteAsset).thumbHash : null;
|
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(
|
return OctoImage.fromSet(
|
||||||
image: provider,
|
image: provider,
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
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/local_album.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/remote_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/db.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart';
|
||||||
|
|
||||||
final localAlbumRepository = Provider<DriftLocalAlbumRepository>(
|
final localAlbumRepository = Provider<DriftLocalAlbumRepository>(
|
||||||
(ref) => DriftLocalAlbumRepository(ref.watch(driftProvider)),
|
(ref) => DriftLocalAlbumRepository(ref.watch(driftProvider)),
|
||||||
@ -10,3 +12,14 @@ final localAlbumRepository = Provider<DriftLocalAlbumRepository>(
|
|||||||
final remoteAlbumRepository = Provider<DriftRemoteAlbumRepository>(
|
final remoteAlbumRepository = Provider<DriftRemoteAlbumRepository>(
|
||||||
(ref) => DriftRemoteAlbumRepository(ref.watch(driftProvider)),
|
(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],
|
||||||
|
);
|
||||||
|
121
mobile/lib/providers/infrastructure/remote_album.provider.dart
Normal file
121
mobile/lib/providers/infrastructure/remote_album.provider.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
@ -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/main_timeline.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/dev/media_stat.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/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/api.provider.dart';
|
||||||
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
||||||
import 'package:immich_mobile/routing/auth_guard.dart';
|
import 'package:immich_mobile/routing/auth_guard.dart';
|
||||||
@ -172,7 +173,7 @@ class AppRouter extends RootStackRouter {
|
|||||||
guards: [_authGuard, _duplicateGuard],
|
guards: [_authGuard, _duplicateGuard],
|
||||||
),
|
),
|
||||||
AutoRoute(
|
AutoRoute(
|
||||||
page: AlbumsRoute.page,
|
page: DriftAlbumsRoute.page,
|
||||||
guards: [_authGuard, _duplicateGuard],
|
guards: [_authGuard, _duplicateGuard],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
@ -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
|
/// generated route for
|
||||||
/// [EditImagePage]
|
/// [EditImagePage]
|
||||||
class EditImageRoute extends PageRouteInfo<EditImageRouteArgs> {
|
class EditImageRoute extends PageRouteInfo<EditImageRouteArgs> {
|
||||||
|
71
mobile/lib/utils/remote_album.utils.dart
Normal file
71
mobile/lib/utils/remote_album.utils.dart
Normal 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);
|
||||||
|
}
|
@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/extensions/build_context_extensions.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/backup/backup_state.model.dart';
|
||||||
import 'package:immich_mobile/models/server_info/server_info.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/backup/backup.provider.dart';
|
||||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||||
@ -62,8 +63,14 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.science_rounded),
|
icon: const Icon(Icons.swipe_left_alt_rounded),
|
||||||
onPressed: () => context.pushRoute(const FeatInDevRoute()),
|
onPressed: () => context.pop(),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => ref.read(backgroundSyncProvider).syncRemote(),
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.sync,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (isCasting)
|
if (isCasting)
|
||||||
Padding(
|
Padding(
|
||||||
|
Loading…
x
Reference in New Issue
Block a user