1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

feat(mobile): lazy loading of assets (#2413)

This commit is contained in:
Fynn Petersen-Frey 2023-05-17 19:36:02 +02:00 committed by GitHub
parent 93863b0629
commit 0dde76bbbc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
54 changed files with 1494 additions and 2328 deletions

View File

@ -115,7 +115,7 @@ jobs:
flutter-version: "3.10.0" flutter-version: "3.10.0"
- name: Run tests - name: Run tests
working-directory: ./mobile working-directory: ./mobile
run: flutter test run: flutter test -j 1
generated-api-up-to-date: generated-api-up-to-date:
name: Check generated files are up-to-date name: Check generated files are up-to-date

View File

@ -21,6 +21,7 @@
"asset_list_layout_settings_group_by": "Group assets by", "asset_list_layout_settings_group_by": "Group assets by",
"asset_list_layout_settings_group_by_month": "Month", "asset_list_layout_settings_group_by_month": "Month",
"asset_list_layout_settings_group_by_month_day": "Month + day", "asset_list_layout_settings_group_by_month_day": "Month + day",
"asset_list_layout_settings_group_automatically": "Automatic",
"asset_list_settings_subtitle": "Photo grid layout settings", "asset_list_settings_subtitle": "Photo grid layout settings",
"asset_list_settings_title": "Photo Grid", "asset_list_settings_title": "Photo Grid",
"backup_album_selection_page_albums_device": "Albums on device ({})", "backup_album_selection_page_albums_device": "Albums on device ({})",
@ -276,4 +277,4 @@
"description_input_hint_text": "Add description...", "description_input_hint_text": "Add description...",
"archive_page_title": "Archive ({})", "archive_page_title": "Archive ({})",
"archive_page_no_archived_assets": "No archived assets found" "archive_page_no_archived_assets": "No archived assets found"
} }

View File

@ -2,47 +2,20 @@ import 'package:collection/collection.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
class AssetSelectionPageResult { class AssetSelectionPageResult {
final Set<Asset> selectedNewAsset; final Set<Asset> selectedAssets;
final Set<Asset> selectedAdditionalAsset;
final bool isAlbumExist;
AssetSelectionPageResult({ AssetSelectionPageResult({
required this.selectedNewAsset, required this.selectedAssets,
required this.selectedAdditionalAsset,
required this.isAlbumExist,
}); });
AssetSelectionPageResult copyWith({
Set<Asset>? selectedNewAsset,
Set<Asset>? selectedAdditionalAsset,
bool? isAlbumExist,
}) {
return AssetSelectionPageResult(
selectedNewAsset: selectedNewAsset ?? this.selectedNewAsset,
selectedAdditionalAsset:
selectedAdditionalAsset ?? this.selectedAdditionalAsset,
isAlbumExist: isAlbumExist ?? this.isAlbumExist,
);
}
@override
String toString() =>
'AssetSelectionPageResult(selectedNewAsset: $selectedNewAsset, selectedAdditionalAsset: $selectedAdditionalAsset, isAlbumExist: $isAlbumExist)';
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
if (identical(this, other)) return true; if (identical(this, other)) return true;
final setEquals = const DeepCollectionEquality().equals; final setEquals = const DeepCollectionEquality().equals;
return other is AssetSelectionPageResult && return other is AssetSelectionPageResult &&
setEquals(other.selectedNewAsset, selectedNewAsset) && setEquals(other.selectedAssets, selectedAssets);
setEquals(other.selectedAdditionalAsset, selectedAdditionalAsset) &&
other.isAlbumExist == isAlbumExist;
} }
@override @override
int get hashCode => int get hashCode => selectedAssets.hashCode;
selectedNewAsset.hashCode ^
selectedAdditionalAsset.hashCode ^
isAlbumExist.hashCode;
} }

View File

@ -1,4 +1,5 @@
import 'package:collection/collection.dart'; import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
@ -9,50 +10,38 @@ import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
class AlbumNotifier extends StateNotifier<List<Album>> { class AlbumNotifier extends StateNotifier<List<Album>> {
AlbumNotifier(this._albumService, this._db) : super([]); AlbumNotifier(this._albumService, Isar db) : super([]) {
final query = db.albums
.filter()
.owner((q) => q.isarIdEqualTo(Store.get(StoreKey.currentUser).isarId));
query.findAll().then((value) => state = value);
_streamSub = query.watch().listen((data) => state = data);
}
final AlbumService _albumService; final AlbumService _albumService;
final Isar _db; late final StreamSubscription<List<Album>> _streamSub;
Future<void> getAllAlbums() async { Future<void> getAllAlbums() => Future.wait([
final User me = Store.get(StoreKey.currentUser); _albumService.refreshDeviceAlbums(),
List<Album> albums = await _db.albums _albumService.refreshRemoteAlbums(isShared: false),
.filter() ]);
.owner((q) => q.isarIdEqualTo(me.isarId))
.findAll();
if (!const ListEquality().equals(albums, state)) {
state = albums;
}
await Future.wait([
_albumService.refreshDeviceAlbums(),
_albumService.refreshRemoteAlbums(isShared: false),
]);
albums = await _db.albums
.filter()
.owner((q) => q.isarIdEqualTo(me.isarId))
.findAll();
if (!const ListEquality().equals(albums, state)) {
state = albums;
}
}
Future<bool> deleteAlbum(Album album) async { Future<bool> deleteAlbum(Album album) => _albumService.deleteAlbum(album);
state = state.where((a) => a.id != album.id).toList();
return _albumService.deleteAlbum(album);
}
Future<Album?> createAlbum( Future<Album?> createAlbum(
String albumTitle, String albumTitle,
Set<Asset> assets, Set<Asset> assets,
) async { ) =>
Album? album = await _albumService.createAlbum(albumTitle, assets, []); _albumService.createAlbum(albumTitle, assets, []);
if (album != null) {
state = [...state, album]; @override
} void dispose() {
return album; _streamSub.cancel();
super.dispose();
} }
} }
final albumProvider = StateNotifierProvider<AlbumNotifier, List<Album>>((ref) { final albumProvider =
StateNotifierProvider.autoDispose<AlbumNotifier, List<Album>>((ref) {
return AlbumNotifier( return AlbumNotifier(
ref.watch(albumServiceProvider), ref.watch(albumServiceProvider),
ref.watch(dbProvider), ref.watch(dbProvider),

View File

@ -1,134 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/models/asset_selection_state.model.dart';
import 'package:immich_mobile/shared/models/asset.dart';
class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
AssetSelectionNotifier()
: super(
AssetSelectionState(
selectedNewAssetsForAlbum: {},
selectedMonths: {},
selectedAdditionalAssetsForAlbum: {},
selectedAssetsInAlbumViewer: {},
isAlbumExist: false,
isMultiselectEnable: false,
),
);
void setIsAlbumExist(bool isAlbumExist) {
state = state.copyWith(isAlbumExist: isAlbumExist);
}
void removeAssetsInMonth(
String removedMonth,
List<Asset> assetsInMonth,
) {
Set<Asset> currentAssetList = state.selectedNewAssetsForAlbum;
Set<String> currentMonthList = state.selectedMonths;
currentMonthList
.removeWhere((selectedMonth) => selectedMonth == removedMonth);
for (Asset asset in assetsInMonth) {
currentAssetList.removeWhere((e) => e.id == asset.id);
}
state = state.copyWith(
selectedNewAssetsForAlbum: currentAssetList,
selectedMonths: currentMonthList,
);
}
void addAdditionalAssets(List<Asset> assets) {
state = state.copyWith(
selectedAdditionalAssetsForAlbum: {
...state.selectedAdditionalAssetsForAlbum,
...assets
},
);
}
void addAllAssetsInMonth(String month, List<Asset> assetsInMonth) {
state = state.copyWith(
selectedMonths: {...state.selectedMonths, month},
selectedNewAssetsForAlbum: {
...state.selectedNewAssetsForAlbum,
...assetsInMonth
},
);
}
void addNewAssets(Iterable<Asset> assets) {
state = state.copyWith(
selectedNewAssetsForAlbum: {
...state.selectedNewAssetsForAlbum,
...assets
},
);
}
void removeSelectedNewAssets(List<Asset> assets) {
Set<Asset> currentList = state.selectedNewAssetsForAlbum;
for (Asset asset in assets) {
currentList.removeWhere((e) => e.id == asset.id);
}
state = state.copyWith(selectedNewAssetsForAlbum: currentList);
}
void removeSelectedAdditionalAssets(List<Asset> assets) {
Set<Asset> currentList = state.selectedAdditionalAssetsForAlbum;
for (Asset asset in assets) {
currentList.removeWhere((e) => e.id == asset.id);
}
state = state.copyWith(selectedAdditionalAssetsForAlbum: currentList);
}
void removeAll() {
state = state.copyWith(
selectedNewAssetsForAlbum: {},
selectedMonths: {},
selectedAdditionalAssetsForAlbum: {},
selectedAssetsInAlbumViewer: {},
isAlbumExist: false,
);
}
void enableMultiselection() {
state = state.copyWith(isMultiselectEnable: true);
}
void disableMultiselection() {
state = state.copyWith(
isMultiselectEnable: false,
selectedAssetsInAlbumViewer: {},
);
}
void addAssetsInAlbumViewer(List<Asset> assets) {
state = state.copyWith(
selectedAssetsInAlbumViewer: {
...state.selectedAssetsInAlbumViewer,
...assets
},
);
}
void removeAssetsInAlbumViewer(List<Asset> assets) {
Set<Asset> currentList = state.selectedAssetsInAlbumViewer;
for (Asset asset in assets) {
currentList.removeWhere((e) => e.id == asset.id);
}
state = state.copyWith(selectedAssetsInAlbumViewer: currentList);
}
}
final assetSelectionProvider =
StateNotifierProvider<AssetSelectionNotifier, AssetSelectionState>((ref) {
return AssetSelectionNotifier();
});

View File

@ -1,7 +1,9 @@
import 'package:collection/collection.dart'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/user.dart'; import 'package:immich_mobile/shared/models/user.dart';
@ -9,10 +11,14 @@ import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
class SharedAlbumNotifier extends StateNotifier<List<Album>> { class SharedAlbumNotifier extends StateNotifier<List<Album>> {
SharedAlbumNotifier(this._albumService, this._db) : super([]); SharedAlbumNotifier(this._albumService, Isar db) : super([]) {
final query = db.albums.filter().sharedEqualTo(true).sortByCreatedAtDesc();
query.findAll().then((value) => state = value);
_streamSub = query.watch().listen((data) => state = data);
}
final AlbumService _albumService; final AlbumService _albumService;
final Isar _db; late final StreamSubscription<List<Album>> _streamSub;
Future<Album?> createSharedAlbum( Future<Album?> createSharedAlbum(
String albumName, String albumName,
@ -20,46 +26,21 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
Iterable<User> sharedUsers, Iterable<User> sharedUsers,
) async { ) async {
try { try {
final Album? newAlbum = await _albumService.createAlbum( return await _albumService.createAlbum(
albumName, albumName,
assets, assets,
sharedUsers, sharedUsers,
); );
if (newAlbum != null) {
state = [...state, newAlbum];
return newAlbum;
}
} catch (e) { } catch (e) {
debugPrint("Error createSharedAlbum ${e.toString()}"); debugPrint("Error createSharedAlbum ${e.toString()}");
} }
return null; return null;
} }
Future<void> getAllSharedAlbums() async { Future<void> getAllSharedAlbums() =>
var albums = await _db.albums _albumService.refreshRemoteAlbums(isShared: true);
.filter()
.sharedEqualTo(true)
.sortByCreatedAtDesc()
.findAll();
if (!const ListEquality().equals(albums, state)) {
state = albums;
}
await _albumService.refreshRemoteAlbums(isShared: true);
albums = await _db.albums
.filter()
.sharedEqualTo(true)
.sortByCreatedAtDesc()
.findAll();
if (!const ListEquality().equals(albums, state)) {
state = albums;
}
}
Future<bool> deleteAlbum(Album album) { Future<bool> deleteAlbum(Album album) => _albumService.deleteAlbum(album);
state = state.where((a) => a.id != album.id).toList();
return _albumService.deleteAlbum(album);
}
Future<bool> leaveAlbum(Album album) async { Future<bool> leaveAlbum(Album album) async {
var res = await _albumService.leaveAlbum(album); var res = await _albumService.leaveAlbum(album);
@ -75,10 +56,16 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
Future<bool> removeAssetFromAlbum(Album album, Iterable<Asset> assets) { Future<bool> removeAssetFromAlbum(Album album, Iterable<Asset> assets) {
return _albumService.removeAssetFromAlbum(album, assets); return _albumService.removeAssetFromAlbum(album, assets);
} }
@override
void dispose() {
_streamSub.cancel();
super.dispose();
}
} }
final sharedAlbumProvider = final sharedAlbumProvider =
StateNotifierProvider<SharedAlbumNotifier, List<Album>>((ref) { StateNotifierProvider.autoDispose<SharedAlbumNotifier, List<Album>>((ref) {
return SharedAlbumNotifier( return SharedAlbumNotifier(
ref.watch(albumServiceProvider), ref.watch(albumServiceProvider),
ref.watch(dbProvider), ref.watch(dbProvider),
@ -86,10 +73,15 @@ final sharedAlbumProvider =
}); });
final sharedAlbumDetailProvider = final sharedAlbumDetailProvider =
FutureProvider.autoDispose.family<Album?, int>((ref, albumId) async { StreamProvider.autoDispose.family<Album, int>((ref, albumId) async* {
final AlbumService sharedAlbumService = ref.watch(albumServiceProvider); final AlbumService sharedAlbumService = ref.watch(albumServiceProvider);
final Album? a = await sharedAlbumService.getAlbumDetail(albumId); await for (final a in sharedAlbumService.watchAlbum(albumId)) {
await a?.loadSortedAssets(); if (a == null) {
return a; throw Exception("Album with ID=$albumId does not exist anymore!");
}
await for (final _ in a.watchRenderList(GroupAssetsBy.none)) {
yield a;
}
}
}); });

View File

@ -214,8 +214,9 @@ class AlbumService {
); );
} }
Future<Album?> getAlbumDetail(int albumId) { Stream<Album?> watchAlbum(int albumId) async* {
return _db.albums.get(albumId); yield await _db.albums.get(albumId);
yield* _db.albums.watchObject(albumId);
} }
Future<AddAssetsResponseDto?> addAdditionalAssetToAlbum( Future<AddAssetsResponseDto?> addAdditionalAssetToAlbum(

View File

@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart'; import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart'; import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart';
@ -110,12 +109,6 @@ class AddToAlbumBottomSheet extends HookConsumerWidget {
TextStyle(color: Theme.of(context).primaryColor), TextStyle(color: Theme.of(context).primaryColor),
), ),
onPressed: () { onPressed: () {
ref
.watch(assetSelectionProvider.notifier)
.removeAll();
ref
.watch(assetSelectionProvider.notifier)
.addNewAssets(assets);
AutoRouter.of(context).push( AutoRouter.of(context).push(
CreateAlbumRoute( CreateAlbumRoute(
isSharedAlbum: false, isSharedAlbum: false,

View File

@ -9,12 +9,14 @@ class AddToAlbumSliverList extends HookConsumerWidget {
final List<Album> albums; final List<Album> albums;
final List<Album> sharedAlbums; final List<Album> sharedAlbums;
final void Function(Album) onAddToAlbum; final void Function(Album) onAddToAlbum;
final bool enabled;
const AddToAlbumSliverList({ const AddToAlbumSliverList({
Key? key, Key? key,
required this.onAddToAlbum, required this.onAddToAlbum,
required this.albums, required this.albums,
required this.sharedAlbums, required this.sharedAlbums,
this.enabled = true,
}) : super(key: key); }) : super(key: key);
@override @override
@ -28,14 +30,14 @@ class AddToAlbumSliverList extends HookConsumerWidget {
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.only(bottom: 8),
child: ExpansionTile( child: ExpansionTile(
title: Text('common_shared'.tr()), title: Text('common_shared'.tr()),
tilePadding: const EdgeInsets.symmetric(horizontal: 10.0), tilePadding: const EdgeInsets.symmetric(horizontal: 10.0),
leading: const Icon(Icons.group), leading: const Icon(Icons.group),
children: sharedAlbums children: sharedAlbums
.map( .map(
(album) => AlbumThumbnailListTile( (album) => AlbumThumbnailListTile(
album: album, album: album,
onTap: () => onAddToAlbum(album), onTap: enabled ? () => onAddToAlbum(album) : () {},
), ),
) )
.toList(), .toList(),
@ -48,7 +50,7 @@ class AddToAlbumSliverList extends HookConsumerWidget {
final album = albums[offset]; final album = albums[offset];
return AlbumThumbnailListTile( return AlbumThumbnailListTile(
album: album, album: album,
onTap: () => onAddToAlbum(album), onTap: enabled ? () => onAddToAlbum(album) : () {},
); );
}), }),
); );

View File

@ -5,10 +5,10 @@ import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart'; import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart'; import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart';
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart'; import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart'; import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
@ -18,17 +18,19 @@ class AlbumViewerAppbar extends HookConsumerWidget
Key? key, Key? key,
required this.album, required this.album,
required this.userId, required this.userId,
required this.selected,
required this.selectionDisabled,
required this.titleFocusNode,
}) : super(key: key); }) : super(key: key);
final Album album; final Album album;
final String userId; final String userId;
final Set<Asset> selected;
final void Function() selectionDisabled;
final FocusNode titleFocusNode;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final isMultiSelectionEnable =
ref.watch(assetSelectionProvider).isMultiselectEnable;
final selectedAssetsInAlbum =
ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer;
final newAlbumTitle = ref.watch(albumViewerProvider).editTitleText; final newAlbumTitle = ref.watch(albumViewerProvider).editTitleText;
final isEditAlbum = ref.watch(albumViewerProvider).isEditAlbum; final isEditAlbum = ref.watch(albumViewerProvider).isEditAlbum;
@ -86,12 +88,12 @@ class AlbumViewerAppbar extends HookConsumerWidget
bool isSuccess = bool isSuccess =
await ref.watch(sharedAlbumProvider.notifier).removeAssetFromAlbum( await ref.watch(sharedAlbumProvider.notifier).removeAssetFromAlbum(
album, album,
selectedAssetsInAlbum, selected,
); );
if (isSuccess) { if (isSuccess) {
Navigator.pop(context); Navigator.pop(context);
ref.watch(assetSelectionProvider.notifier).disableMultiselection(); selectionDisabled();
ref.watch(albumProvider.notifier).getAllAlbums(); ref.watch(albumProvider.notifier).getAllAlbums();
ref.invalidate(sharedAlbumDetailProvider(album.id)); ref.invalidate(sharedAlbumDetailProvider(album.id));
} else { } else {
@ -108,7 +110,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
} }
buildBottomSheetActionButton() { buildBottomSheetActionButton() {
if (isMultiSelectionEnable) { if (selected.isNotEmpty) {
if (album.ownerId == userId) { if (album.ownerId == userId) {
return ListTile( return ListTile(
leading: const Icon(Icons.delete_sweep_rounded), leading: const Icon(Icons.delete_sweep_rounded),
@ -163,11 +165,9 @@ class AlbumViewerAppbar extends HookConsumerWidget
} }
buildLeadingButton() { buildLeadingButton() {
if (isMultiSelectionEnable) { if (selected.isNotEmpty) {
return IconButton( return IconButton(
onPressed: () => ref onPressed: selectionDisabled,
.watch(assetSelectionProvider.notifier)
.disableMultiselection(),
icon: const Icon(Icons.close_rounded), icon: const Icon(Icons.close_rounded),
splashRadius: 25, splashRadius: 25,
); );
@ -202,9 +202,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
return AppBar( return AppBar(
elevation: 0, elevation: 0,
leading: buildLeadingButton(), leading: buildLeadingButton(),
title: isMultiSelectionEnable title: selected.isNotEmpty ? Text('${selected.length}') : null,
? Text('${selectedAssetsInAlbum.length}')
: null,
centerTitle: false, centerTitle: false,
actions: [ actions: [
if (album.isRemote) if (album.isRemote)

View File

@ -1,163 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart';
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart';
import 'package:immich_mobile/utils/storage_indicator.dart';
class AlbumViewerThumbnail extends HookConsumerWidget {
final Asset asset;
final List<Asset> assetList;
final bool showStorageIndicator;
const AlbumViewerThumbnail({
Key? key,
required this.asset,
required this.assetList,
this.showStorageIndicator = true,
}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedAssetsInAlbumViewer =
ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer;
final isMultiSelectionEnable =
ref.watch(assetSelectionProvider).isMultiselectEnable;
final isFavorite = ref.watch(favoriteProvider).contains(asset.id);
viewAsset() {
AutoRouter.of(context).push(
GalleryViewerRoute(
asset: asset,
assetList: assetList,
),
);
}
BoxBorder drawBorderColor() {
if (selectedAssetsInAlbumViewer.contains(asset)) {
return Border.all(
color: Theme.of(context).primaryColorLight,
width: 10,
);
} else {
return const Border();
}
}
enableMultiSelection() {
ref.watch(assetSelectionProvider.notifier).enableMultiselection();
ref
.watch(assetSelectionProvider.notifier)
.addAssetsInAlbumViewer([asset]);
}
disableMultiSelection() {
ref.watch(assetSelectionProvider.notifier).disableMultiselection();
}
buildVideoLabel() {
return Positioned(
top: 5,
right: 5,
child: Row(
children: [
Text(
asset.duration.toString().substring(0, 7),
style: const TextStyle(
color: Colors.white,
fontSize: 10,
),
),
const Icon(
Icons.play_circle_outline_rounded,
color: Colors.white,
),
],
),
);
}
buildAssetStoreLocationIcon() {
return Positioned(
right: 10,
bottom: 5,
child: Icon(
storageIcon(asset),
color: Colors.white,
size: 18,
),
);
}
buildAssetFavoriteIcon() {
return const Positioned(
left: 10,
bottom: 5,
child: Icon(
Icons.favorite,
color: Colors.white,
size: 18,
),
);
}
buildAssetSelectionIcon() {
bool isSelected = selectedAssetsInAlbumViewer.contains(asset);
return Positioned(
left: 10,
top: 5,
child: isSelected
? Icon(
Icons.check_circle_rounded,
color: Theme.of(context).primaryColor,
)
: const Icon(
Icons.check_circle_outline_rounded,
color: Colors.white,
),
);
}
buildThumbnailImage() {
return Container(
decoration: BoxDecoration(border: drawBorderColor()),
child: ImmichImage(asset, width: 300, height: 300),
);
}
handleSelectionGesture() {
if (selectedAssetsInAlbumViewer.contains(asset)) {
ref
.watch(assetSelectionProvider.notifier)
.removeAssetsInAlbumViewer([asset]);
if (selectedAssetsInAlbumViewer.isEmpty) {
disableMultiSelection();
}
} else {
ref
.watch(assetSelectionProvider.notifier)
.addAssetsInAlbumViewer([asset]);
}
}
return GestureDetector(
onTap: isMultiSelectionEnable ? handleSelectionGesture : viewAsset,
onLongPress: enableMultiSelection,
child: Stack(
children: [
buildThumbnailImage(),
if (isFavorite) buildAssetFavoriteIcon(),
if (showStorageIndicator) buildAssetStoreLocationIcon(),
if (!asset.isImage) buildVideoLabel(),
if (isMultiSelectionEnable) buildAssetSelectionIcon(),
],
),
);
}
}

View File

@ -1,26 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/ui/selection_thumbnail_image.dart';
import 'package:immich_mobile/shared/models/asset.dart';
class AssetGridByMonth extends HookConsumerWidget {
final List<Asset> assetGroup;
const AssetGridByMonth({Key? key, required this.assetGroup})
: super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
return SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
crossAxisSpacing: 5.0,
mainAxisSpacing: 5,
),
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return SelectionThumbnailImage(asset: assetGroup[index]);
},
childCount: assetGroup.length,
),
);
}
}

View File

@ -1,117 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
import 'package:immich_mobile/shared/models/asset.dart';
class MonthGroupTitle extends HookConsumerWidget {
final String month;
final List<Asset> assetGroup;
const MonthGroupTitle({
Key? key,
required this.month,
required this.assetGroup,
}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedDateGroup = ref.watch(assetSelectionProvider).selectedMonths;
final selectedAssets =
ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
final isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist;
handleTitleIconClick() {
HapticFeedback.heavyImpact();
if (isAlbumExist) {
if (selectedDateGroup.contains(month)) {
ref
.watch(assetSelectionProvider.notifier)
.removeAssetsInMonth(month, []);
ref
.watch(assetSelectionProvider.notifier)
.removeSelectedAdditionalAssets(assetGroup);
} else {
ref
.watch(assetSelectionProvider.notifier)
.addAllAssetsInMonth(month, []);
// Deep clone assetGroup
var assetGroupWithNewItems = [...assetGroup];
for (var selectedAsset in selectedAssets) {
assetGroupWithNewItems.removeWhere((a) => a.id == selectedAsset.id);
}
ref
.watch(assetSelectionProvider.notifier)
.addAdditionalAssets(assetGroupWithNewItems);
}
} else {
if (selectedDateGroup.contains(month)) {
ref
.watch(assetSelectionProvider.notifier)
.removeAssetsInMonth(month, assetGroup);
} else {
ref
.watch(assetSelectionProvider.notifier)
.addAllAssetsInMonth(month, assetGroup);
}
}
}
getSimplifiedMonth() {
var monthAndYear = month.split(',');
var yearText = monthAndYear[1].trim();
var monthText = monthAndYear[0].trim();
var currentYear = DateTime.now().year.toString();
if (yearText == currentYear) {
return monthText;
} else {
return month;
}
}
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(
top: 29.0,
bottom: 29.0,
left: 14.0,
right: 8.0,
),
child: Row(
children: [
GestureDetector(
onTap: handleTitleIconClick,
child: selectedDateGroup.contains(month)
? Icon(
Icons.check_circle_rounded,
color: Theme.of(context).primaryColor,
)
: const Icon(
Icons.circle_outlined,
color: Colors.grey,
),
),
GestureDetector(
onTap: handleTitleIconClick,
child: Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Text(
getSimplifiedMonth(),
style: TextStyle(
fontSize: 24,
color: Theme.of(context).primaryColor,
),
),
),
),
],
),
),
);
}
}

View File

@ -1,141 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart';
class SelectionThumbnailImage extends HookConsumerWidget {
final Asset asset;
const SelectionThumbnailImage({Key? key, required this.asset})
: super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
var selectedAsset =
ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
var newAssetsForAlbum =
ref.watch(assetSelectionProvider).selectedAdditionalAssetsForAlbum;
var isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist;
Widget buildSelectionIcon(Asset asset) {
var isSelected = selectedAsset.map((item) => item.id).contains(asset.id);
var isNewlySelected =
newAssetsForAlbum.map((item) => item.id).contains(asset.id);
if (isSelected && !isAlbumExist) {
return Icon(
Icons.check_circle,
color: Theme.of(context).primaryColor,
);
} else if (isSelected && isAlbumExist) {
return const Icon(
Icons.check_circle,
color: Color.fromARGB(255, 233, 233, 233),
);
} else if (isNewlySelected && isAlbumExist) {
return Icon(
Icons.check_circle,
color: Theme.of(context).primaryColor,
);
} else {
return const Icon(
Icons.circle_outlined,
color: Colors.white,
);
}
}
BoxBorder drawBorderColor() {
var isSelected = selectedAsset.map((item) => item.id).contains(asset.id);
var isNewlySelected =
newAssetsForAlbum.map((item) => item.id).contains(asset.id);
if (isSelected && !isAlbumExist) {
return Border.all(
color: Theme.of(context).primaryColorLight,
width: 10,
);
} else if (isSelected && isAlbumExist) {
return Border.all(
color: const Color.fromARGB(255, 190, 190, 190),
width: 10,
);
} else if (isNewlySelected && isAlbumExist) {
return Border.all(
color: Theme.of(context).primaryColorLight,
width: 10,
);
}
return const Border();
}
return GestureDetector(
onTap: () {
var isSelected =
selectedAsset.map((item) => item.id).contains(asset.id);
var isNewlySelected =
newAssetsForAlbum.map((item) => item.id).contains(asset.id);
if (isAlbumExist) {
// Operation for existing album
if (!isSelected) {
if (isNewlySelected) {
ref
.watch(assetSelectionProvider.notifier)
.removeSelectedAdditionalAssets([asset]);
} else {
ref
.watch(assetSelectionProvider.notifier)
.addAdditionalAssets([asset]);
}
}
} else {
// Operation for new album
if (isSelected) {
ref
.watch(assetSelectionProvider.notifier)
.removeSelectedNewAssets([asset]);
} else {
ref.watch(assetSelectionProvider.notifier).addNewAssets([asset]);
}
}
},
child: Stack(
children: [
Container(
decoration: BoxDecoration(border: drawBorderColor()),
child: ImmichImage(asset, width: 150, height: 150),
),
Padding(
padding: const EdgeInsets.all(3.0),
child: Align(
alignment: Alignment.topLeft,
child: buildSelectionIcon(asset),
),
),
if (!asset.isImage)
Positioned(
bottom: 5,
right: 5,
child: Row(
children: [
Text(
asset.duration.toString().substring(0, 7),
style: const TextStyle(
color: Colors.white,
fontSize: 10,
),
),
const Icon(
Icons.play_circle_outline_rounded,
color: Colors.white,
),
],
),
),
],
),
);
}
}

View File

@ -5,21 +5,18 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart'; import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart'; import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/modules/album/ui/album_action_outlined_button.dart'; import 'package:immich_mobile/modules/album/ui/album_action_outlined_button.dart';
import 'package:immich_mobile/modules/album/ui/album_viewer_appbar.dart';
import 'package:immich_mobile/modules/album/ui/album_viewer_editable_title.dart'; import 'package:immich_mobile/modules/album/ui/album_viewer_editable_title.dart';
import 'package:immich_mobile/modules/album/ui/album_viewer_thumbnail.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/album/ui/album_viewer_appbar.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/immich_sliver_persistent_app_bar_delegate.dart'; import 'package:immich_mobile/shared/ui/immich_sliver_persistent_app_bar_delegate.dart';
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart'; import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
@ -32,33 +29,51 @@ class AlbumViewerPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
FocusNode titleFocusNode = useFocusNode(); FocusNode titleFocusNode = useFocusNode();
ScrollController scrollController = useScrollController();
final album = ref.watch(sharedAlbumDetailProvider(albumId)); final album = ref.watch(sharedAlbumDetailProvider(albumId));
final userId = ref.watch(authenticationProvider).userId; final userId = ref.watch(authenticationProvider).userId;
final selection = useState<Set<Asset>>({});
final multiSelectEnabled = useState(false);
bool? isTop;
Future<bool> onWillPop() async {
if (multiSelectEnabled.value) {
selection.value = {};
multiSelectEnabled.value = false;
return false;
}
return true;
}
void selectionListener(bool active, Set<Asset> selected) {
selection.value = selected;
multiSelectEnabled.value = selected.isNotEmpty;
}
void disableSelection() {
selection.value = {};
multiSelectEnabled.value = false;
}
/// Find out if the assets in album exist on the device /// Find out if the assets in album exist on the device
/// If they exist, add to selected asset state to show they are already selected. /// If they exist, add to selected asset state to show they are already selected.
void onAddPhotosPressed(Album albumInfo) async { void onAddPhotosPressed(Album albumInfo) async {
if (albumInfo.assets.isNotEmpty == true) { AssetSelectionPageResult? returnPayload =
ref.watch(assetSelectionProvider.notifier).addNewAssets( await AutoRouter.of(context).push<AssetSelectionPageResult?>(
albumInfo.assets, AssetSelectionRoute(
); existingAssets: albumInfo.assets,
} isNewAlbum: false,
),
ref.watch(assetSelectionProvider.notifier).setIsAlbumExist(true); );
AssetSelectionPageResult? returnPayload = await AutoRouter.of(context)
.push<AssetSelectionPageResult?>(const AssetSelectionRoute());
if (returnPayload != null) { if (returnPayload != null) {
// Check if there is new assets add // Check if there is new assets add
if (returnPayload.selectedAdditionalAsset.isNotEmpty) { if (returnPayload.selectedAssets.isNotEmpty) {
ImmichLoadingOverlayController.appLoader.show(); ImmichLoadingOverlayController.appLoader.show();
var addAssetsResult = var addAssetsResult =
await ref.watch(albumServiceProvider).addAdditionalAssetToAlbum( await ref.watch(albumServiceProvider).addAdditionalAssetToAlbum(
returnPayload.selectedAdditionalAsset, returnPayload.selectedAssets,
albumInfo, albumInfo,
); );
@ -70,10 +85,6 @@ class AlbumViewerPage extends HookConsumerWidget {
ImmichLoadingOverlayController.appLoader.hide(); ImmichLoadingOverlayController.appLoader.hide();
} }
ref.watch(assetSelectionProvider.notifier).removeAll();
} else {
ref.watch(assetSelectionProvider.notifier).removeAll();
} }
} }
@ -91,13 +102,38 @@ class AlbumViewerPage extends HookConsumerWidget {
.addAdditionalUserToAlbum(sharedUserIds, album); .addAdditionalUserToAlbum(sharedUserIds, album);
if (isSuccess) { if (isSuccess) {
ref.invalidate(sharedAlbumDetailProvider(albumId)); ref.invalidate(sharedAlbumDetailProvider(album.id));
} }
ImmichLoadingOverlayController.appLoader.hide(); ImmichLoadingOverlayController.appLoader.hide();
} }
} }
Widget buildControlButton(Album album) {
return Padding(
padding: const EdgeInsets.only(left: 16.0, top: 8, bottom: 8),
child: SizedBox(
height: 40,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
AlbumActionOutlinedButton(
iconData: Icons.add_photo_alternate_outlined,
onPressed: () => onAddPhotosPressed(album),
labelText: "share_add_photos".tr(),
),
if (userId == album.ownerId)
AlbumActionOutlinedButton(
iconData: Icons.person_add_alt_rounded,
onPressed: () => onAddUsersPressed(album),
labelText: "album_viewer_page_share_add_users".tr(),
),
],
),
),
);
}
Widget buildTitle(Album album) { Widget buildTitle(Album album) {
return Padding( return Padding(
padding: const EdgeInsets.only(left: 8, right: 8, top: 16), padding: const EdgeInsets.only(left: 8, right: 8, top: 16),
@ -146,171 +182,104 @@ class AlbumViewerPage extends HookConsumerWidget {
} }
Widget buildHeader(Album album) { Widget buildHeader(Album album) {
return SliverToBoxAdapter( return Column(
child: Column( mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
buildTitle(album), buildTitle(album),
if (album.assets.isNotEmpty == true) buildAlbumDateRange(album), if (album.assets.isNotEmpty == true) buildAlbumDateRange(album),
if (album.shared) if (album.shared)
SizedBox( SizedBox(
height: 60, height: 50,
child: ListView.builder( child: ListView.builder(
padding: const EdgeInsets.only(left: 16), padding: const EdgeInsets.only(left: 16),
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemBuilder: ((context, index) { itemBuilder: ((context, index) {
return Padding( return Padding(
padding: const EdgeInsets.only(right: 8.0), padding: const EdgeInsets.only(right: 8.0),
child: CircleAvatar( child: CircleAvatar(
backgroundColor: Colors.grey[300], backgroundColor: Colors.grey[300],
radius: 18, radius: 18,
child: Padding( child: Padding(
padding: const EdgeInsets.all(2.0), padding: const EdgeInsets.all(2.0),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(50.0), borderRadius: BorderRadius.circular(50.0),
child: Image.asset( child: Image.asset(
'assets/immich-logo-no-outline.png', 'assets/immich-logo-no-outline.png',
),
), ),
), ),
), ),
);
}),
itemCount: album.sharedUsers.length,
),
)
],
),
);
}
Widget buildImageGrid(Album album) {
final appSettingService = ref.watch(appSettingsServiceProvider);
final bool showStorageIndicator =
appSettingService.getSetting(AppSettingsEnum.storageIndicator);
if (album.sortedAssets.isNotEmpty) {
return SliverPadding(
padding: const EdgeInsets.only(top: 10.0),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount:
appSettingService.getSetting(AppSettingsEnum.tilesPerRow),
crossAxisSpacing: 5.0,
mainAxisSpacing: 5,
),
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return AlbumViewerThumbnail(
asset: album.sortedAssets[index],
assetList: album.sortedAssets,
showStorageIndicator: showStorageIndicator,
);
},
childCount: album.assetCount,
),
),
);
}
return const SliverToBoxAdapter();
}
Widget buildControlButton(Album album) {
return Padding(
padding: const EdgeInsets.only(left: 16.0, top: 8, bottom: 8),
child: SizedBox(
height: 40,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
AlbumActionOutlinedButton(
iconData: Icons.add_photo_alternate_outlined,
onPressed: () => onAddPhotosPressed(album),
labelText: "share_add_photos".tr(),
),
if (userId == album.ownerId)
AlbumActionOutlinedButton(
iconData: Icons.person_add_alt_rounded,
onPressed: () => onAddUsersPressed(album),
labelText: "album_viewer_page_share_add_users".tr(),
),
],
),
),
);
}
Future<bool> onWillPop() async {
final isMultiselectEnable = ref
.read(assetSelectionProvider)
.selectedAssetsInAlbumViewer
.isNotEmpty;
if (isMultiselectEnable) {
ref.watch(assetSelectionProvider.notifier).removeAll();
return false;
}
return true;
}
Widget buildBody(Album album) {
return WillPopScope(
onWillPop: onWillPop,
child: GestureDetector(
onTap: () {
titleFocusNode.unfocus();
},
child: DraggableScrollbar.semicircle(
backgroundColor: Theme.of(context).hintColor,
controller: scrollController,
heightScrollThumb: 48.0,
child: CustomScrollView(
controller: scrollController,
slivers: [
buildHeader(album),
if (album.isRemote)
SliverPersistentHeader(
pinned: true,
delegate: ImmichSliverPersistentAppBarDelegate(
minHeight: 50,
maxHeight: 50,
child: Container(
color: Theme.of(context).scaffoldBackgroundColor,
child: buildControlButton(album),
),
), ),
), );
SliverSafeArea( }),
sliver: buildImageGrid(album), itemCount: album.sharedUsers.length,
), ),
],
), ),
), ],
),
); );
} }
final scroll = ScrollController();
return Scaffold( return Scaffold(
appBar: album.when( appBar: album.when(
data: (Album? data) { data: (data) => AlbumViewerAppbar(
if (data != null) { titleFocusNode: titleFocusNode,
return AlbumViewerAppbar( album: data,
album: data, userId: userId,
userId: userId, selected: selection.value,
); selectionDisabled: disableSelection,
} ),
return null; error: (error, stackTrace) => AppBar(title: const Text("Error")),
}, loading: () => AppBar(),
error: (e, _) => null,
loading: () => null,
), ),
body: album.when( body: album.when(
data: (albumInfo) => albumInfo != null data: (data) => WillPopScope(
? buildBody(albumInfo) onWillPop: onWillPop,
: const Center( child: GestureDetector(
child: CircularProgressIndicator(), onTap: () {
titleFocusNode.unfocus();
},
child: NestedScrollView(
controller: scroll,
floatHeaderSlivers: true,
headerSliverBuilder: (context, innerBoxIsScrolled) => [
SliverToBoxAdapter(child: buildHeader(data)),
SliverPersistentHeader(
pinned: true,
delegate: ImmichSliverPersistentAppBarDelegate(
minHeight: 50,
maxHeight: 50,
child: Container(
color: Theme.of(context).scaffoldBackgroundColor,
child: buildControlButton(data),
),
),
)
],
body: ImmichAssetGrid(
renderList: data.renderList,
listener: selectionListener,
selectionActive: multiSelectEnabled.value,
showMultiSelectIndicator: false,
visibleItemsListener: (start, end) {
final top = start.index == 0 && start.itemLeadingEdge == 0.0;
if (top != isTop) {
isTop = top;
scroll.animateTo(
top
? scroll.position.minScrollExtent
: scroll.position.maxScrollExtent,
duration: const Duration(milliseconds: 500),
curve: top ? Curves.easeOut : Curves.easeIn,
);
}
},
), ),
error: (e, _) => Center(child: Text("Error loading album info $e")), ),
),
),
error: (e, _) => Center(child: Text("Error loading album info!\n$e")),
loading: () => const Center( loading: () => const Center(
child: ImmichLoadingIndicator(), child: ImmichLoadingIndicator(),
), ),

View File

@ -4,54 +4,42 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart'; import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/modules/album/ui/asset_grid_by_month.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/modules/album/ui/month_group_title.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart';
class AssetSelectionPage extends HookConsumerWidget { class AssetSelectionPage extends HookConsumerWidget {
const AssetSelectionPage({Key? key}) : super(key: key); const AssetSelectionPage({
Key? key,
required this.existingAssets,
this.isNewAlbum = false,
}) : super(key: key);
final Set<Asset> existingAssets;
final bool isNewAlbum;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
ScrollController scrollController = useScrollController(); final renderList = ref.watch(remoteAssetsProvider);
var assetGroupMonthYear = ref.watch(assetGroupByMonthYearProvider); final selected = useState<Set<Asset>>(existingAssets);
final selectedAssets = final selectionEnabledHook = useState(true);
ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
final newAssetsForAlbum =
ref.watch(assetSelectionProvider).selectedAdditionalAssetsForAlbum;
final isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist;
List<Widget> imageGridGroup = [];
String buildAssetCountText() { String buildAssetCountText() {
if (isAlbumExist) { return selected.value.length.toString();
return (selectedAssets.length + newAssetsForAlbum.length).toString();
} else {
return selectedAssets.length.toString();
}
} }
Widget buildBody() { Widget buildBody(RenderList renderList) {
assetGroupMonthYear.forEach((monthYear, assetGroup) { return ImmichAssetGrid(
imageGridGroup renderList: renderList,
.add(MonthGroupTitle(month: monthYear, assetGroup: assetGroup)); listener: (active, assets) {
imageGridGroup.add(AssetGridByMonth(assetGroup: assetGroup)); selectionEnabledHook.value = active;
}); selected.value = assets;
},
return Stack( selectionActive: true,
children: [ preselectedAssets: isNewAlbum ? selected.value : existingAssets,
DraggableScrollbar.semicircle( canDeselect: isNewAlbum,
backgroundColor: Theme.of(context).hintColor, showMultiSelectIndicator: false,
controller: scrollController,
heightScrollThumb: 48.0,
child: CustomScrollView(
controller: scrollController,
slivers: [...imageGridGroup],
),
),
],
); );
} }
@ -61,11 +49,10 @@ class AssetSelectionPage extends HookConsumerWidget {
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.close_rounded), icon: const Icon(Icons.close_rounded),
onPressed: () { onPressed: () {
ref.watch(assetSelectionProvider.notifier).removeAll(); AutoRouter.of(context).popForced(null);
AutoRouter.of(context).pop(null);
}, },
), ),
title: selectedAssets.isEmpty title: selected.value.isEmpty
? const Text( ? const Text(
'share_add_photos', 'share_add_photos',
style: TextStyle(fontSize: 18), style: TextStyle(fontSize: 18),
@ -76,16 +63,13 @@ class AssetSelectionPage extends HookConsumerWidget {
), ),
centerTitle: false, centerTitle: false,
actions: [ actions: [
if ((!isAlbumExist && selectedAssets.isNotEmpty) || if (selected.value.isNotEmpty)
(isAlbumExist && newAssetsForAlbum.isNotEmpty))
TextButton( TextButton(
onPressed: () { onPressed: () {
var payload = AssetSelectionPageResult( var payload =
isAlbumExist: isAlbumExist, AssetSelectionPageResult(selectedAssets: selected.value);
selectedAdditionalAsset: newAssetsForAlbum, AutoRouter.of(context)
selectedNewAsset: selectedAssets, .popForced<AssetSelectionPageResult>(payload);
);
AutoRouter.of(context).pop(payload);
}, },
child: const Text( child: const Text(
"share_add", "share_add",
@ -94,7 +78,13 @@ class AssetSelectionPage extends HookConsumerWidget {
), ),
], ],
), ),
body: buildBody(), body: renderList.when(
data: (data) => buildBody(data),
error: (error, stackTrace) => Center(
child: Text(error.toString()),
),
loading: () => const Center(child: CircularProgressIndicator()),
),
); );
} }
} }

View File

@ -6,7 +6,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart'; import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart'; import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/providers/album_title.provider.dart'; import 'package:immich_mobile/modules/album/providers/album_title.provider.dart';
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
import 'package:immich_mobile/modules/album/ui/album_action_outlined_button.dart'; import 'package:immich_mobile/modules/album/ui/album_action_outlined_button.dart';
import 'package:immich_mobile/modules/album/ui/album_title_text_field.dart'; import 'package:immich_mobile/modules/album/ui/album_title_text_field.dart';
import 'package:immich_mobile/modules/album/ui/shared_album_thumbnail_image.dart'; import 'package:immich_mobile/modules/album/ui/shared_album_thumbnail_image.dart';
@ -31,12 +30,15 @@ class CreateAlbumPage extends HookConsumerWidget {
final albumTitleTextFieldFocusNode = useFocusNode(); final albumTitleTextFieldFocusNode = useFocusNode();
final isAlbumTitleTextFieldFocus = useState(false); final isAlbumTitleTextFieldFocus = useState(false);
final isAlbumTitleEmpty = useState(true); final isAlbumTitleEmpty = useState(true);
final selectedAssets = final selectedAssets = useState<Set<Asset>>(const {});
ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
final isDarkTheme = Theme.of(context).brightness == Brightness.dark; final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
showSelectUserPage() { showSelectUserPage() async {
AutoRouter.of(context).push(const SelectUserForSharingRoute()); final bool? ok = await AutoRouter.of(context)
.push<bool?>(SelectUserForSharingRoute(assets: selectedAssets.value));
if (ok == true) {
selectedAssets.value = {};
}
} }
void onBackgroundTapped() { void onBackgroundTapped() {
@ -52,13 +54,17 @@ class CreateAlbumPage extends HookConsumerWidget {
} }
onSelectPhotosButtonPressed() async { onSelectPhotosButtonPressed() async {
ref.watch(assetSelectionProvider.notifier).setIsAlbumExist(false); AssetSelectionPageResult? selectedAsset =
await AutoRouter.of(context).push<AssetSelectionPageResult?>(
AssetSelectionPageResult? selectedAsset = await AutoRouter.of(context) AssetSelectionRoute(
.push<AssetSelectionPageResult?>(const AssetSelectionRoute()); existingAssets: selectedAssets.value,
isNewAlbum: true,
),
);
if (selectedAsset == null) { if (selectedAsset == null) {
ref.watch(assetSelectionProvider.notifier).removeAll(); selectedAssets.value = const {};
} else {
selectedAssets.value = selectedAsset.selectedAssets;
} }
} }
@ -78,7 +84,7 @@ class CreateAlbumPage extends HookConsumerWidget {
} }
buildTitle() { buildTitle() {
if (selectedAssets.isEmpty) { if (selectedAssets.value.isEmpty) {
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.only(top: 200, left: 18), padding: const EdgeInsets.only(top: 200, left: 18),
@ -97,7 +103,7 @@ class CreateAlbumPage extends HookConsumerWidget {
} }
buildSelectPhotosButton() { buildSelectPhotosButton() {
if (selectedAssets.isEmpty) { if (selectedAssets.value.isEmpty) {
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.only(top: 16, left: 18, right: 18), padding: const EdgeInsets.only(top: 16, left: 18, right: 18),
@ -158,7 +164,7 @@ class CreateAlbumPage extends HookConsumerWidget {
} }
buildSelectedImageGrid() { buildSelectedImageGrid() {
if (selectedAssets.isNotEmpty) { if (selectedAssets.value.isNotEmpty) {
return SliverPadding( return SliverPadding(
padding: const EdgeInsets.only(top: 16), padding: const EdgeInsets.only(top: 16),
sliver: SliverGrid( sliver: SliverGrid(
@ -172,11 +178,11 @@ class CreateAlbumPage extends HookConsumerWidget {
return GestureDetector( return GestureDetector(
onTap: onBackgroundTapped, onTap: onBackgroundTapped,
child: SharedAlbumThumbnailImage( child: SharedAlbumThumbnailImage(
asset: selectedAssets.elementAt(index), asset: selectedAssets.value.elementAt(index),
), ),
); );
}, },
childCount: selectedAssets.length, childCount: selectedAssets.value.length,
), ),
), ),
); );
@ -188,12 +194,12 @@ class CreateAlbumPage extends HookConsumerWidget {
createNonSharedAlbum() async { createNonSharedAlbum() async {
var newAlbum = await ref.watch(albumProvider.notifier).createAlbum( var newAlbum = await ref.watch(albumProvider.notifier).createAlbum(
ref.watch(albumTitleProvider), ref.watch(albumTitleProvider),
ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum, selectedAssets.value,
); );
if (newAlbum != null) { if (newAlbum != null) {
ref.watch(albumProvider.notifier).getAllAlbums(); ref.watch(albumProvider.notifier).getAllAlbums();
ref.watch(assetSelectionProvider.notifier).removeAll(); selectedAssets.value = {};
ref.watch(albumTitleProvider.notifier).clearAlbumTitle(); ref.watch(albumTitleProvider.notifier).clearAlbumTitle();
AutoRouter.of(context).replace(AlbumViewerRoute(albumId: newAlbum.id)); AutoRouter.of(context).replace(AlbumViewerRoute(albumId: newAlbum.id));
@ -207,7 +213,7 @@ class CreateAlbumPage extends HookConsumerWidget {
backgroundColor: Theme.of(context).scaffoldBackgroundColor, backgroundColor: Theme.of(context).scaffoldBackgroundColor,
leading: IconButton( leading: IconButton(
onPressed: () { onPressed: () {
ref.watch(assetSelectionProvider.notifier).removeAll(); selectedAssets.value = {};
AutoRouter.of(context).pop(); AutoRouter.of(context).pop();
}, },
icon: const Icon(Icons.close_rounded), icon: const Icon(Icons.close_rounded),
@ -237,7 +243,7 @@ class CreateAlbumPage extends HookConsumerWidget {
if (!isSharedAlbum) if (!isSharedAlbum)
TextButton( TextButton(
onPressed: albumTitleController.text.isNotEmpty && onPressed: albumTitleController.text.isNotEmpty &&
selectedAssets.isNotEmpty selectedAssets.value.isNotEmpty
? createNonSharedAlbum ? createNonSharedAlbum
: null, : null,
child: Text( child: Text(
@ -264,7 +270,7 @@ class CreateAlbumPage extends HookConsumerWidget {
child: Column( child: Column(
children: [ children: [
buildTitleInputField(), buildTitleInputField(),
if (selectedAssets.isNotEmpty) buildControlButton(), if (selectedAssets.value.isNotEmpty) buildControlButton(),
], ],
), ),
), ),

View File

@ -4,15 +4,18 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/providers/album_title.provider.dart'; import 'package:immich_mobile/modules/album/providers/album_title.provider.dart';
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/album/providers/suggested_shared_users.provider.dart'; import 'package:immich_mobile/modules/album/providers/suggested_shared_users.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/user.dart'; import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
class SelectUserForSharingPage extends HookConsumerWidget { class SelectUserForSharingPage extends HookConsumerWidget {
const SelectUserForSharingPage({Key? key}) : super(key: key); const SelectUserForSharingPage({Key? key, required this.assets})
: super(key: key);
final Set<Asset> assets;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@ -24,15 +27,15 @@ class SelectUserForSharingPage extends HookConsumerWidget {
var newAlbum = var newAlbum =
await ref.watch(sharedAlbumProvider.notifier).createSharedAlbum( await ref.watch(sharedAlbumProvider.notifier).createSharedAlbum(
ref.watch(albumTitleProvider), ref.watch(albumTitleProvider),
ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum, assets,
sharedUsersList.value, sharedUsersList.value,
); );
if (newAlbum != null) { if (newAlbum != null) {
await ref.watch(sharedAlbumProvider.notifier).getAllSharedAlbums(); await ref.watch(sharedAlbumProvider.notifier).getAllSharedAlbums();
ref.watch(assetSelectionProvider.notifier).removeAll(); // ref.watch(assetSelectionProvider.notifier).removeAll();
ref.watch(albumTitleProvider.notifier).clearAlbumTitle(); ref.watch(albumTitleProvider.notifier).clearAlbumTitle();
AutoRouter.of(context).pop(true);
AutoRouter.of(context) AutoRouter.of(context)
.navigate(const TabControllerRoute(children: [SharingRoute()])); .navigate(const TabControllerRoute(children: [SharingRoute()]));
} }

View File

@ -1,55 +1,25 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart'; import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
class ArchiveSelectionNotifier extends StateNotifier<Set<int>> { final archiveProvider = StreamProvider<RenderList>((ref) async* {
ArchiveSelectionNotifier(this.db, this.assetNotifier) : super({}) { final query = ref
state = db.assets .watch(dbProvider)
.filter() .assets
.isArchivedEqualTo(true) .filter()
.findAllSync() .ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId)
.map((e) => e.id) .isArchivedEqualTo(true)
.toSet(); .sortByFileCreatedAt();
final settings = ref.watch(appSettingsServiceProvider);
final groupBy =
GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)];
yield await RenderList.fromQuery(query, groupBy);
await for (final _ in query.watchLazy()) {
yield await RenderList.fromQuery(query, groupBy);
} }
final Isar db;
final AssetNotifier assetNotifier;
void _setArchiveForAssetId(int id, bool archive) {
if (!archive) {
state = state.difference({id});
} else {
state = state.union({id});
}
}
bool _isArchive(int id) {
return state.contains(id);
}
Future<void> toggleArchive(Asset asset) async {
if (asset.storage == AssetState.local) return;
_setArchiveForAssetId(asset.id, !_isArchive(asset.id));
await assetNotifier.toggleArchive(
[asset],
state.contains(asset.id),
);
}
Future<void> addToArchives(Iterable<Asset> assets) {
state = state.union(assets.map((a) => a.id).toSet());
return assetNotifier.toggleArchive(assets, true);
}
}
final archiveProvider =
StateNotifierProvider<ArchiveSelectionNotifier, Set<int>>((ref) {
return ArchiveSelectionNotifier(
ref.watch(dbProvider),
ref.watch(assetProvider.notifier),
);
}); });

View File

@ -1,46 +1,25 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/archive/providers/archive_asset_provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart'; import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart'; import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:isar/isar.dart';
class ArchivePage extends HookConsumerWidget { class ArchivePage extends HookConsumerWidget {
const ArchivePage({super.key}); const ArchivePage({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final User me = Store.get(StoreKey.currentUser); final archivedAssets = ref.watch(archiveProvider);
final query = ref
.watch(dbProvider)
.assets
.filter()
.ownerIdEqualTo(me.isarId)
.isArchivedEqualTo(true);
final stream = query.watch();
final archivedAssets = useState<List<Asset>>([]);
final selectionEnabledHook = useState(false); final selectionEnabledHook = useState(false);
final selection = useState(<Asset>{}); final selection = useState(<Asset>{});
final processing = useState(false);
useEffect(
() {
query.findAll().then((value) => archivedAssets.value = value);
final subscription = stream.listen((e) {
archivedAssets.value = e;
});
// Cancel the subscription when the widget is disposed
return subscription.cancel;
},
[],
);
void selectionListener( void selectionListener(
bool multiselect, bool multiselect,
@ -50,7 +29,7 @@ class ArchivePage extends HookConsumerWidget {
selection.value = selectedAssets; selection.value = selectedAssets;
} }
AppBar buildAppBar() { AppBar buildAppBar(String count) {
return AppBar( return AppBar(
leading: IconButton( leading: IconButton(
onPressed: () => AutoRouter.of(context).pop(), onPressed: () => AutoRouter.of(context).pop(),
@ -60,7 +39,7 @@ class ArchivePage extends HookConsumerWidget {
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
title: const Text( title: const Text(
'archive_page_title', 'archive_page_title',
).tr(args: [archivedAssets.value.length.toString()]), ).tr(args: [count]),
); );
} }
@ -84,24 +63,34 @@ class ArchivePage extends HookConsumerWidget {
'control_bottom_app_bar_unarchive'.tr(), 'control_bottom_app_bar_unarchive'.tr(),
style: const TextStyle(fontSize: 14), style: const TextStyle(fontSize: 14),
), ),
onTap: () { onTap: processing.value
if (selection.value.isNotEmpty) { ? null
ref : () async {
.watch(assetProvider.notifier) processing.value = true;
.toggleArchive(selection.value, false); try {
if (selection.value.isNotEmpty) {
await ref
.watch(assetProvider.notifier)
.toggleArchive(
selection.value.toList(),
false,
);
final assetOrAssets = final assetOrAssets = selection.value.length > 1
selection.value.length > 1 ? 'assets' : 'asset'; ? 'assets'
ImmichToast.show( : 'asset';
context: context, ImmichToast.show(
msg: context: context,
'Moved ${selection.value.length} $assetOrAssets to library', msg:
gravity: ToastGravity.CENTER, 'Moved ${selection.value.length} $assetOrAssets to library',
); gravity: ToastGravity.CENTER,
} );
}
selectionEnabledHook.value = false; } finally {
}, processing.value = false;
selectionEnabledHook.value = false;
}
},
) )
], ],
), ),
@ -111,22 +100,34 @@ class ArchivePage extends HookConsumerWidget {
); );
} }
return Scaffold( return archivedAssets.when(
appBar: buildAppBar(), loading: () => Scaffold(
body: archivedAssets.value.isEmpty appBar: buildAppBar("?"),
? Center( body: const Center(child: CircularProgressIndicator()),
child: Text('archive_page_no_archived_assets'.tr()), ),
) error: (error, stackTrace) => Scaffold(
: Stack( appBar: buildAppBar("Error"),
children: [ body: Center(child: Text(error.toString())),
ImmichAssetGrid( ),
assets: archivedAssets.value, data: (data) => Scaffold(
listener: selectionListener, appBar: buildAppBar(data.totalAssets.toString()),
selectionActive: selectionEnabledHook.value, body: data.isEmpty
), ? Center(
if (selectionEnabledHook.value) buildBottomBar() child: Text('archive_page_no_archived_assets'.tr()),
], )
), : Stack(
children: [
ImmichAssetGrid(
renderList: data,
listener: selectionListener,
selectionActive: selectionEnabledHook.value,
),
if (selectionEnabledHook.value) buildBottomBar(),
if (processing.value)
const Center(child: ImmichLoadingIndicator())
],
),
),
); );
} }
} }

View File

@ -1,6 +0,0 @@
class RequestDownloadAssetInfo {
final String assetId;
final String deviceId;
RequestDownloadAssetInfo(this.assetId, this.deviceId);
}

View File

@ -4,14 +4,12 @@ import 'package:immich_mobile/modules/settings/providers/app_settings.provider.d
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
final renderListProvider = FutureProvider.family<RenderList, List<Asset>>((ref, assets) { final renderListProvider =
var settings = ref.watch(appSettingsServiceProvider); FutureProvider.family<RenderList, List<Asset>>((ref, assets) {
final settings = ref.watch(appSettingsServiceProvider);
final layout = AssetGridLayoutParameters( return RenderList.fromAssets(
settings.getSetting(AppSettingsEnum.tilesPerRow), assets,
settings.getSetting(AppSettingsEnum.dynamicLayout),
GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)], GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)],
); );
return RenderList.fromAssets(assets, layout);
}); });

View File

@ -21,7 +21,7 @@ class TopControlAppBar extends HookConsumerWidget {
final VoidCallback? onDownloadPressed; final VoidCallback? onDownloadPressed;
final VoidCallback onToggleMotionVideo; final VoidCallback onToggleMotionVideo;
final VoidCallback onAddToAlbumPressed; final VoidCallback onAddToAlbumPressed;
final VoidCallback onFavorite; final VoidCallback? onFavorite;
final bool isPlayingMotionVideo; final bool isPlayingMotionVideo;
final bool isFavorite; final bool isFavorite;
@ -31,9 +31,7 @@ class TopControlAppBar extends HookConsumerWidget {
Widget buildFavoriteButton() { Widget buildFavoriteButton() {
return IconButton( return IconButton(
onPressed: () { onPressed: onFavorite,
onFavorite();
},
icon: Icon( icon: Icon(
isFavorite ? Icons.favorite : Icons.favorite_border, isFavorite ? Icons.favorite : Icons.favorite_border,
color: Colors.grey[200], color: Colors.grey[200],

View File

@ -12,9 +12,7 @@ import 'package:immich_mobile/modules/asset_viewer/ui/advanced_bottom_sheet.dart
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart'; import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart';
import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/services/asset.service.dart';
import 'package:immich_mobile/modules/home/ui/delete_dialog.dart'; import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
@ -32,16 +30,16 @@ import 'package:openapi/api.dart' as api;
// ignore: must_be_immutable // ignore: must_be_immutable
class GalleryViewerPage extends HookConsumerWidget { class GalleryViewerPage extends HookConsumerWidget {
final List<Asset> assetList; final Asset Function(int index) loadAsset;
final Asset asset; final int totalAssets;
final int initialIndex;
GalleryViewerPage({ GalleryViewerPage({
super.key, super.key,
required this.assetList, required this.initialIndex,
required this.asset, required this.loadAsset,
}) : controller = PageController(initialPage: assetList.indexOf(asset)); required this.totalAssets,
}) : controller = PageController(initialPage: initialIndex);
Asset? assetDetail;
final PageController controller; final PageController controller;
@ -52,11 +50,15 @@ class GalleryViewerPage extends HookConsumerWidget {
final isLoadOriginal = useState(AppSettingsEnum.loadOriginal.defaultValue); final isLoadOriginal = useState(AppSettingsEnum.loadOriginal.defaultValue);
final isZoomed = useState<bool>(false); final isZoomed = useState<bool>(false);
final showAppBar = useState<bool>(true); final showAppBar = useState<bool>(true);
final indexOfAsset = useState(assetList.indexOf(asset));
final isPlayingMotionVideo = useState(false); final isPlayingMotionVideo = useState(false);
final isPlayingVideo = useState(false); final isPlayingVideo = useState(false);
late Offset localPosition; late Offset localPosition;
final authToken = 'Bearer ${Store.get(StoreKey.accessToken)}'; final authToken = 'Bearer ${Store.get(StoreKey.accessToken)}';
final currentIndex = useState(initialIndex);
final currentAsset = loadAsset(currentIndex.value);
final watchedAsset = ref.watch(assetDetailProvider(currentAsset));
Asset asset() => watchedAsset.value ?? currentAsset;
showAppBar.addListener(() { showAppBar.addListener(() {
// Change to and from immersive mode, hiding navigation and app bar // Change to and from immersive mode, hiding navigation and app bar
@ -79,16 +81,9 @@ class GalleryViewerPage extends HookConsumerWidget {
[], [],
); );
void toggleFavorite(Asset asset) { void toggleFavorite(Asset asset) => ref
ref.watch(favoriteProvider.notifier).toggleFavorite(asset); .watch(assetProvider.notifier)
} .toggleFavorite([asset], !asset.isFavorite);
void getAssetExif() async {
assetDetail = assetList[indexOfAsset.value];
assetDetail = await ref
.watch(assetServiceProvider)
.loadExif(assetList[indexOfAsset.value]);
}
/// Thumbnail image of a remote asset. Required asset.isRemote /// Thumbnail image of a remote asset. Required asset.isRemote
ImageProvider remoteThumbnailImageProvider( ImageProvider remoteThumbnailImageProvider(
@ -138,8 +133,8 @@ class GalleryViewerPage extends HookConsumerWidget {
} }
void precacheNextImage(int index) { void precacheNextImage(int index) {
if (index < assetList.length && index >= 0) { if (index < totalAssets && index >= 0) {
final asset = assetList[index]; final asset = loadAsset(index);
if (asset.isLocal) { if (asset.isLocal) {
// Preload the local asset // Preload the local asset
@ -193,13 +188,13 @@ class GalleryViewerPage extends HookConsumerWidget {
if (ref if (ref
.watch(appSettingsServiceProvider) .watch(appSettingsServiceProvider)
.getSetting<bool>(AppSettingsEnum.advancedTroubleshooting)) { .getSetting<bool>(AppSettingsEnum.advancedTroubleshooting)) {
return AdvancedBottomSheet(assetDetail: assetDetail!); return AdvancedBottomSheet(assetDetail: asset());
} }
return Padding( return Padding(
padding: EdgeInsets.only( padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom, bottom: MediaQuery.of(context).viewInsets.bottom,
), ),
child: ExifBottomSheet(asset: assetDetail!), child: ExifBottomSheet(asset: asset()),
); );
}, },
); );
@ -211,7 +206,7 @@ class GalleryViewerPage extends HookConsumerWidget {
builder: (BuildContext _) { builder: (BuildContext _) {
return DeleteDialog( return DeleteDialog(
onDelete: () { onDelete: () {
if (assetList.length == 1) { if (totalAssets == 1) {
// Handle only one asset // Handle only one asset
AutoRouter.of(context).pop(); AutoRouter.of(context).pop();
} else { } else {
@ -221,7 +216,6 @@ class GalleryViewerPage extends HookConsumerWidget {
curve: Curves.fastLinearToSlowEaseIn, curve: Curves.fastLinearToSlowEaseIn,
); );
} }
assetList.remove(deleteAsset);
ref.watch(assetProvider.notifier).deleteAssets({deleteAsset}); ref.watch(assetProvider.notifier).deleteAssets({deleteAsset});
}, },
); );
@ -267,9 +261,7 @@ class GalleryViewerPage extends HookConsumerWidget {
} }
shareAsset() { shareAsset() {
ref ref.watch(imageViewerStateProvider.notifier).shareAsset(asset(), context);
.watch(imageViewerStateProvider.notifier)
.shareAsset(assetList[indexOfAsset.value], context);
} }
handleArchive(Asset asset) { handleArchive(Asset asset) {
@ -291,30 +283,21 @@ class GalleryViewerPage extends HookConsumerWidget {
color: Colors.black.withOpacity(0.4), color: Colors.black.withOpacity(0.4),
child: TopControlAppBar( child: TopControlAppBar(
isPlayingMotionVideo: isPlayingMotionVideo.value, isPlayingMotionVideo: isPlayingMotionVideo.value,
asset: assetList[indexOfAsset.value], asset: asset(),
isFavorite: ref.watch(favoriteProvider).contains( isFavorite: asset().isFavorite,
assetList[indexOfAsset.value].id, onMoreInfoPressed: showInfo,
), onFavorite: asset().isRemote ? () => toggleFavorite(asset()) : null,
onMoreInfoPressed: () { onDownloadPressed: asset().storage == AssetState.local
showInfo();
},
onFavorite: () {
toggleFavorite(assetList[indexOfAsset.value]);
},
onDownloadPressed: assetList[indexOfAsset.value].storage ==
AssetState.local
? null ? null
: () { : () =>
ref.watch(imageViewerStateProvider.notifier).downloadAsset( ref.watch(imageViewerStateProvider.notifier).downloadAsset(
assetList[indexOfAsset.value], asset(),
context, context,
); ),
},
onToggleMotionVideo: (() { onToggleMotionVideo: (() {
isPlayingMotionVideo.value = !isPlayingMotionVideo.value; isPlayingMotionVideo.value = !isPlayingMotionVideo.value;
}), }),
onAddToAlbumPressed: () => onAddToAlbumPressed: () => addToAlbum(asset()),
addToAlbum(assetList[indexOfAsset.value]),
), ),
), ),
); );
@ -324,8 +307,6 @@ class GalleryViewerPage extends HookConsumerWidget {
final show = (showAppBar.value || // onTap has the final say final show = (showAppBar.value || // onTap has the final say
(showAppBar.value && !isZoomed.value)) && (showAppBar.value && !isZoomed.value)) &&
!isPlayingVideo.value; !isPlayingVideo.value;
final currentAsset = assetList[indexOfAsset.value];
return AnimatedOpacity( return AnimatedOpacity(
duration: const Duration(milliseconds: 100), duration: const Duration(milliseconds: 100),
opacity: show ? 1.0 : 0.0, opacity: show ? 1.0 : 0.0,
@ -343,7 +324,7 @@ class GalleryViewerPage extends HookConsumerWidget {
label: 'control_bottom_app_bar_share'.tr(), label: 'control_bottom_app_bar_share'.tr(),
tooltip: 'control_bottom_app_bar_share'.tr(), tooltip: 'control_bottom_app_bar_share'.tr(),
), ),
currentAsset.isArchived asset().isArchived
? BottomNavigationBarItem( ? BottomNavigationBarItem(
icon: const Icon(Icons.unarchive_rounded), icon: const Icon(Icons.unarchive_rounded),
label: 'control_bottom_app_bar_unarchive'.tr(), label: 'control_bottom_app_bar_unarchive'.tr(),
@ -366,10 +347,10 @@ class GalleryViewerPage extends HookConsumerWidget {
shareAsset(); shareAsset();
break; break;
case 1: case 1:
handleArchive(assetList[indexOfAsset.value]); handleArchive(asset());
break; break;
case 2: case 2:
handleDelete(assetList[indexOfAsset.value]); handleDelete(asset());
break; break;
} }
}, },
@ -399,33 +380,33 @@ class GalleryViewerPage extends HookConsumerWidget {
? const ScrollPhysics() // Use bouncing physics for iOS ? const ScrollPhysics() // Use bouncing physics for iOS
: const ClampingScrollPhysics() // Use heavy physics for Android : const ClampingScrollPhysics() // Use heavy physics for Android
), ),
itemCount: assetList.length, itemCount: totalAssets,
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
onPageChanged: (value) { onPageChanged: (value) {
// Precache image // Precache image
if (indexOfAsset.value < value) { if (currentIndex.value < value) {
// Moving forwards, so precache the next asset // Moving forwards, so precache the next asset
precacheNextImage(value + 1); precacheNextImage(value + 1);
} else { } else {
// Moving backwards, so precache previous asset // Moving backwards, so precache previous asset
precacheNextImage(value - 1); precacheNextImage(value - 1);
} }
indexOfAsset.value = value; currentIndex.value = value;
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
}, },
loadingBuilder: isLoadPreview.value loadingBuilder: isLoadPreview.value
? (context, event) { ? (context, event) {
final asset = assetList[indexOfAsset.value]; final a = asset();
if (!asset.isLocal) { if (!a.isLocal) {
// Use the WEBP Thumbnail as a placeholder for the JPEG thumbnail to achieve // Use the WEBP Thumbnail as a placeholder for the JPEG thumbnail to achieve
// Three-Stage Loading (WEBP -> JPEG -> Original) // Three-Stage Loading (WEBP -> JPEG -> Original)
final webPThumbnail = CachedNetworkImage( final webPThumbnail = CachedNetworkImage(
imageUrl: getThumbnailUrl( imageUrl: getThumbnailUrl(
asset, a,
type: api.ThumbnailFormat.WEBP, type: api.ThumbnailFormat.WEBP,
), ),
cacheKey: getThumbnailCacheKey( cacheKey: getThumbnailCacheKey(
asset, a,
type: api.ThumbnailFormat.WEBP, type: api.ThumbnailFormat.WEBP,
), ),
httpHeaders: {'Authorization': authToken}, httpHeaders: {'Authorization': authToken},
@ -444,11 +425,11 @@ class GalleryViewerPage extends HookConsumerWidget {
// makes sense if the original is loaded in the builder // makes sense if the original is loaded in the builder
return CachedNetworkImage( return CachedNetworkImage(
imageUrl: getThumbnailUrl( imageUrl: getThumbnailUrl(
asset, a,
type: api.ThumbnailFormat.JPEG, type: api.ThumbnailFormat.JPEG,
), ),
cacheKey: getThumbnailCacheKey( cacheKey: getThumbnailCacheKey(
asset, a,
type: api.ThumbnailFormat.JPEG, type: api.ThumbnailFormat.JPEG,
), ),
httpHeaders: {'Authorization': authToken}, httpHeaders: {'Authorization': authToken},
@ -462,30 +443,30 @@ class GalleryViewerPage extends HookConsumerWidget {
} }
} else { } else {
return Image( return Image(
image: localThumbnailImageProvider(asset), image: localThumbnailImageProvider(a),
fit: BoxFit.contain, fit: BoxFit.contain,
); );
} }
} }
: null, : null,
builder: (context, index) { builder: (context, index) {
getAssetExif(); final asset = loadAsset(index);
if (assetList[index].isImage && !isPlayingMotionVideo.value) { if (asset.isImage && !isPlayingMotionVideo.value) {
// Show photo // Show photo
final ImageProvider provider; final ImageProvider provider;
if (assetList[index].isLocal) { if (asset.isLocal) {
provider = localImageProvider(assetList[index]); provider = localImageProvider(asset);
} else { } else {
if (isLoadOriginal.value) { if (isLoadOriginal.value) {
provider = originalImageProvider(assetList[index]); provider = originalImageProvider(asset);
} else if (isLoadPreview.value) { } else if (isLoadPreview.value) {
provider = remoteThumbnailImageProvider( provider = remoteThumbnailImageProvider(
assetList[index], asset,
api.ThumbnailFormat.JPEG, api.ThumbnailFormat.JPEG,
); );
} else { } else {
provider = remoteThumbnailImageProvider( provider = remoteThumbnailImageProvider(
assetList[index], asset,
api.ThumbnailFormat.WEBP, api.ThumbnailFormat.WEBP,
); );
} }
@ -499,13 +480,13 @@ class GalleryViewerPage extends HookConsumerWidget {
showAppBar.value = !showAppBar.value, showAppBar.value = !showAppBar.value,
imageProvider: provider, imageProvider: provider,
heroAttributes: PhotoViewHeroAttributes( heroAttributes: PhotoViewHeroAttributes(
tag: assetList[index].id, tag: asset.id,
), ),
filterQuality: FilterQuality.high, filterQuality: FilterQuality.high,
tightMode: true, tightMode: true,
minScale: PhotoViewComputedScale.contained, minScale: PhotoViewComputedScale.contained,
errorBuilder: (context, error, stackTrace) => ImmichImage( errorBuilder: (context, error, stackTrace) => ImmichImage(
assetList[indexOfAsset.value], asset,
fit: BoxFit.contain, fit: BoxFit.contain,
), ),
); );
@ -516,7 +497,7 @@ class GalleryViewerPage extends HookConsumerWidget {
onDragUpdate: (_, details, __) => onDragUpdate: (_, details, __) =>
handleSwipeUpDown(details), handleSwipeUpDown(details),
heroAttributes: PhotoViewHeroAttributes( heroAttributes: PhotoViewHeroAttributes(
tag: assetList[index].id, tag: asset.id,
), ),
filterQuality: FilterQuality.high, filterQuality: FilterQuality.high,
maxScale: 1.0, maxScale: 1.0,
@ -526,7 +507,7 @@ class GalleryViewerPage extends HookConsumerWidget {
child: VideoViewerPage( child: VideoViewerPage(
onPlaying: () => isPlayingVideo.value = true, onPlaying: () => isPlayingVideo.value = true,
onPaused: () => isPlayingVideo.value = false, onPaused: () => isPlayingVideo.value = false,
asset: assetList[index], asset: asset,
isMotionVideo: isPlayingMotionVideo.value, isMotionVideo: isPlayingMotionVideo.value,
onVideoEnded: () { onVideoEnded: () {
if (isPlayingMotionVideo.value) { if (isPlayingMotionVideo.value) {

View File

@ -1,68 +1,25 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:isar/isar.dart';
class FavoriteSelectionNotifier extends StateNotifier<Set<int>> { final favoriteAssetsProvider = StreamProvider<RenderList>((ref) async* {
FavoriteSelectionNotifier(this.assetsState, this.assetNotifier) : super({}) { final query = ref
state = assetsState.allAssets .watch(dbProvider)
.where((asset) => asset.isFavorite) .assets
.map((asset) => asset.id) .filter()
.toSet(); .ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId)
.isFavoriteEqualTo(true)
.sortByFileCreatedAt();
final settings = ref.watch(appSettingsServiceProvider);
final groupBy =
GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)];
yield await RenderList.fromQuery(query, groupBy);
await for (final _ in query.watchLazy()) {
yield await RenderList.fromQuery(query, groupBy);
} }
final AssetsState assetsState;
final AssetNotifier assetNotifier;
void _setFavoriteForAssetId(int id, bool favorite) {
if (!favorite) {
state = state.difference({id});
} else {
state = state.union({id});
}
}
bool _isFavorite(int id) {
return state.contains(id);
}
Future<void> toggleFavorite(Asset asset) async {
// TODO support local favorite assets
if (asset.storage == AssetState.local) return;
_setFavoriteForAssetId(asset.id, !_isFavorite(asset.id));
await assetNotifier.toggleFavorite(
asset,
state.contains(asset.id),
);
}
Future<void> addToFavorites(Iterable<Asset> assets) {
state = state.union(assets.map((a) => a.id).toSet());
final futures = assets.map(
(a) => assetNotifier.toggleFavorite(
a,
true,
),
);
return Future.wait(futures);
}
}
final favoriteProvider =
StateNotifierProvider<FavoriteSelectionNotifier, Set<int>>((ref) {
return FavoriteSelectionNotifier(
ref.watch(assetProvider),
ref.watch(assetProvider.notifier),
);
});
final favoriteAssetProvider = StateProvider((ref) {
final favorites = ref.watch(favoriteProvider);
return ref
.watch(assetProvider)
.allAssets
.where((element) => favorites.contains(element.id))
.toList();
}); });

View File

@ -1,36 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart';
class FavoriteImage extends HookConsumerWidget {
final Asset asset;
final List<Asset> assets;
const FavoriteImage(this.asset, this.assets, {super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
void viewAsset() {
AutoRouter.of(context).push(
GalleryViewerRoute(
asset: asset,
assetList: assets,
),
);
}
return GestureDetector(
onTap: viewAsset,
child: ImmichImage(
asset,
width: 300,
height: 300,
),
);
}
}

View File

@ -1,15 +1,32 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart'; import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
class FavoritesPage extends HookConsumerWidget { class FavoritesPage extends HookConsumerWidget {
const FavoritesPage({Key? key}) : super(key: key); const FavoritesPage({Key? key}) : super(key: key);
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final selectionEnabledHook = useState(false);
final selection = useState(<Asset>{});
final processing = useState(false);
void selectionListener(
bool multiselect,
Set<Asset> selectedAssets,
) {
selectionEnabledHook.value = multiselect;
selection.value = selectedAssets;
}
AppBar buildAppBar() { AppBar buildAppBar() {
return AppBar( return AppBar(
leading: IconButton( leading: IconButton(
@ -24,15 +41,77 @@ class FavoritesPage extends HookConsumerWidget {
); );
} }
void unfavorite() async {
try {
if (selection.value.isNotEmpty) {
await ref.watch(assetProvider.notifier).toggleFavorite(
selection.value.toList(),
false,
);
final assetOrAssets = selection.value.length > 1 ? 'assets' : 'asset';
ImmichToast.show(
context: context,
msg:
'Removed ${selection.value.length} $assetOrAssets from favorites',
gravity: ToastGravity.CENTER,
);
}
} finally {
processing.value = false;
selectionEnabledHook.value = false;
}
}
Widget buildBottomBar() {
return SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: SizedBox(
height: 64,
child: Card(
child: Column(
children: [
ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
leading: const Icon(
Icons.star_border,
),
title: const Text(
"Unfavorite",
style: TextStyle(fontSize: 14),
),
onTap: processing.value ? null : unfavorite,
)
],
),
),
),
),
);
}
return Scaffold( return Scaffold(
appBar: buildAppBar(), appBar: buildAppBar(),
body: ref.watch(favoriteAssetProvider).isEmpty body: ref.watch(favoriteAssetsProvider).when(
? Center( loading: () => const Center(child: CircularProgressIndicator()),
child: Text('favorites_page_no_favorites'.tr()), error: (error, stackTrace) => Center(child: Text(error.toString())),
) data: (data) => data.isEmpty
: ImmichAssetGrid( ? Center(
assets: ref.watch(favoriteAssetProvider), child: Text('favorites_page_no_favorites'.tr()),
), )
: Stack(
children: [
ImmichAssetGrid(
renderList: data,
selectionActive: selectionEnabledHook.value,
listener: selectionListener,
),
if (selectionEnabledHook.value) buildBottomBar()
],
),
),
); );
} }
} }

View File

@ -2,212 +2,313 @@ import 'dart:math';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
final log = Logger('AssetGridDataStructure'); final log = Logger('AssetGridDataStructure');
enum RenderAssetGridElementType { enum RenderAssetGridElementType {
assets,
assetRow, assetRow,
groupDividerTitle, groupDividerTitle,
monthTitle; monthTitle;
} }
class RenderAssetGridRow {
final List<Asset> assets;
final List<double> widthDistribution;
RenderAssetGridRow(this.assets, this.widthDistribution);
}
class RenderAssetGridElement { class RenderAssetGridElement {
final RenderAssetGridElementType type; final RenderAssetGridElementType type;
final RenderAssetGridRow? assetRow;
final String? title; final String? title;
final DateTime date; final DateTime date;
final List<Asset>? relatedAssetList; final int count;
final int offset;
final int totalCount;
RenderAssetGridElement( RenderAssetGridElement(
this.type, { this.type, {
this.assetRow,
this.title, this.title,
required this.date, required this.date,
this.relatedAssetList, this.count = 0,
this.offset = 0,
this.totalCount = 0,
}); });
} }
enum GroupAssetsBy { enum GroupAssetsBy {
day, day,
month; month,
} auto,
none,
class AssetGridLayoutParameters { ;
final int perRow;
final bool dynamicLayout;
final GroupAssetsBy groupBy;
AssetGridLayoutParameters(
this.perRow,
this.dynamicLayout,
this.groupBy,
);
}
class _AssetGroupsToRenderListComputeParameters {
final List<Asset> assets;
final AssetGridLayoutParameters layout;
_AssetGroupsToRenderListComputeParameters(
this.assets,
this.layout,
);
} }
class RenderList { class RenderList {
final List<RenderAssetGridElement> elements; final List<RenderAssetGridElement> elements;
final List<Asset>? allAssets;
final QueryBuilder<Asset, Asset, QAfterSortBy>? query;
final int totalAssets;
RenderList(this.elements); /// reference to batch of assets loaded from DB with offset [_bufOffset]
List<Asset> _buf = [];
static Map<DateTime, List<Asset>> _groupAssets( /// global offset of assets in [_buf]
List<Asset> assets, int _bufOffset = 0;
GroupAssetsBy groupBy,
) { RenderList(this.elements, this.query, this.allAssets)
if (groupBy == GroupAssetsBy.day) { : totalAssets = allAssets?.length ?? query!.countSync();
return assets.groupListsBy(
(element) { bool get isEmpty => totalAssets == 0;
final date = element.fileCreatedAt.toLocal();
return DateTime(date.year, date.month, date.day); /// Loads the requested assets from the database to an internal buffer if not cached
}, /// and returns a slice of that buffer
); List<Asset> loadAssets(int offset, int count) {
} else if (groupBy == GroupAssetsBy.month) { assert(offset >= 0);
return assets.groupListsBy( assert(count > 0);
(element) { assert(offset + count <= totalAssets);
final date = element.fileCreatedAt.toLocal(); if (allAssets != null) {
return DateTime(date.year, date.month); // if we already loaded all assets (e.g. from search result)
}, // simply return the requested slice of that array
); return allAssets!.slice(offset, offset + count);
} else if (query != null) {
// general case: we have the query to load assets via offset from the DB on demand
if (offset < _bufOffset || offset + count > _bufOffset + _buf.length) {
// the requested slice (offset:offset+count) is not contained in the cache buffer `_buf`
// thus, fill the buffer with a new batch of assets that at least contains the requested
// assets and some more
final bool forward = _bufOffset < offset;
// if the requested offset is greater than the cached offset, the user scrolls forward "down"
const batchSize = 256;
const oppositeSize = 64;
// make sure to load a meaningful amount of data (and not only the requested slice)
// otherwise, each call to [loadAssets] would result in DB call trashing performance
// fills small requests to [batchSize], adds some legroom into the opposite scroll direction for large requests
final len = max(batchSize, count + oppositeSize);
// when scrolling forward, start shortly before the requested offset...
// when scrolling backward, end shortly after the requested offset...
// ... to guard against the user scrolling in the other direction
// a tiny bit resulting in a another required load from the DB
final start = max(
0,
forward
? offset - oppositeSize
: (len > batchSize ? offset : offset + count - len),
);
// load the calculated batch (start:start+len) from the DB and put it into the buffer
_buf = query!.offset(start).limit(len).findAllSync();
_bufOffset = start;
}
assert(_bufOffset <= offset);
assert(_bufOffset + _buf.length >= offset + count);
// return the requested slice from the buffer (we made sure before that the assets are loaded!)
return _buf.slice(offset - _bufOffset, offset - _bufOffset + count);
} }
throw Exception("RenderList has neither assets nor query");
return {};
} }
static Future<RenderList> _processAssetGroupData( /// Returns the requested asset either from cached buffer or directly from the database
_AssetGroupsToRenderListComputeParameters data, Asset loadAsset(int index) {
if (allAssets != null) {
// all assets are already loaded (e.g. from search result)
return allAssets![index];
} else if (query != null) {
// general case: we have the DB query to load asset(s) on demand
if (index >= _bufOffset && index < _bufOffset + _buf.length) {
// lucky case: the requested asset is already cached in the buffer!
return _buf[index - _bufOffset];
}
// request the asset from the database (not changing the buffer!)
final asset = query!.offset(index).findFirstSync();
if (asset == null) {
throw Exception(
"Asset at index $index does no longer exist in database",
);
}
return asset;
}
throw Exception("RenderList has neither assets nor query");
}
static Future<RenderList> fromQuery(
QueryBuilder<Asset, Asset, QAfterSortBy> query,
GroupAssetsBy groupBy,
) =>
_buildRenderList(null, query, groupBy);
static Future<RenderList> _buildRenderList(
List<Asset>? assets,
QueryBuilder<Asset, Asset, QAfterSortBy>? query,
GroupAssetsBy groupBy,
) async { ) async {
// TODO: Make DateFormat use the configured locale. final List<RenderAssetGridElement> elements = [];
final monthFormat = DateFormat.yMMM();
final dayFormatSameYear = DateFormat.MMMEd();
final dayFormatOtherYear = DateFormat.yMMMEd();
final allAssets = data.assets;
final perRow = data.layout.perRow;
final dynamicLayout = data.layout.dynamicLayout;
final groupBy = data.layout.groupBy;
List<RenderAssetGridElement> elements = []; const pageSize = 500;
DateTime? lastDate; const sectionSize = 60; // divides evenly by 2,3,4,5,6
final groups = _groupAssets(allAssets, groupBy);
groups.entries.sortedBy((e) => e.key).reversed.forEach((entry) {
final date = entry.key;
final assets = entry.value;
try {
// Month title
if (groupBy == GroupAssetsBy.day &&
(lastDate == null || lastDate!.month != date.month)) {
elements.add(
RenderAssetGridElement(
RenderAssetGridElementType.monthTitle,
title: monthFormat.format(date),
date: date,
),
);
}
// Group divider title (day or month)
var formatDate = dayFormatOtherYear;
if (DateTime.now().year == date.year) {
formatDate = dayFormatSameYear;
}
if (groupBy == GroupAssetsBy.month) {
formatDate = monthFormat;
}
if (groupBy == GroupAssetsBy.none) {
final int total = assets?.length ?? query!.countSync();
for (int i = 0; i < total; i += sectionSize) {
final date = assets != null
? assets[i].fileCreatedAt
: await query!.offset(i).fileCreatedAtProperty().findFirst();
final int count = i + sectionSize > total ? total - i : sectionSize;
if (date == null) break;
elements.add( elements.add(
RenderAssetGridElement( RenderAssetGridElement(
RenderAssetGridElementType.groupDividerTitle, RenderAssetGridElementType.assets,
title: formatDate.format(date),
date: date, date: date,
relatedAssetList: assets, count: count,
totalCount: total,
offset: i,
), ),
); );
// Add rows
int cursor = 0;
while (cursor < assets.length) {
int rowElements = min(assets.length - cursor, perRow);
final rowAssets = assets.sublist(cursor, cursor + rowElements);
// Default: All assets have the same width
var widthDistribution = List.filled(rowElements, 1.0);
if (dynamicLayout) {
final aspectRatios =
rowAssets.map((e) => (e.width ?? 1) / (e.height ?? 1)).toList();
final meanAspectRatio = aspectRatios.sum / rowElements;
// 1: mean width
// 0.5: width < mean - threshold
// 1.5: width > mean + threshold
final arConfiguration = aspectRatios.map((e) {
if (e - meanAspectRatio > 0.3) return 1.5;
if (e - meanAspectRatio < -0.3) return 0.5;
return 1.0;
});
// Normalize:
final sum = arConfiguration.sum;
widthDistribution =
arConfiguration.map((e) => (e * rowElements) / sum).toList();
}
final rowElement = RenderAssetGridElement(
RenderAssetGridElementType.assetRow,
date: date,
assetRow: RenderAssetGridRow(
rowAssets,
widthDistribution,
),
);
elements.add(rowElement);
cursor += rowElements;
}
lastDate = date;
} catch (e, stackTrace) {
log.severe(e, stackTrace);
} }
}); return RenderList(elements, query, assets);
}
return RenderList(elements); final formatSameYear =
groupBy == GroupAssetsBy.month ? DateFormat.MMMM() : DateFormat.MMMEd();
final formatOtherYear = groupBy == GroupAssetsBy.month
? DateFormat.yMMMM()
: DateFormat.yMMMEd();
final currentYear = DateTime.now().year;
final formatMergedSameYear = DateFormat.MMMd();
final formatMergedOtherYear = DateFormat.yMMMd();
int offset = 0;
DateTime? last;
DateTime? current;
int lastOffset = 0;
int count = 0;
int monthCount = 0;
int lastMonthIndex = 0;
String formatDateRange(DateTime from, DateTime to) {
final startDate = (from.year == currentYear
? formatMergedSameYear
: formatMergedOtherYear)
.format(from);
final endDate = (to.year == currentYear
? formatMergedSameYear
: formatMergedOtherYear)
.format(to);
if (DateTime(from.year, from.month, from.day) ==
DateTime(to.year, to.month, to.day)) {
// format range with time when both dates are on the same day
final startTime = DateFormat.Hm().format(from);
final endTime = DateFormat.Hm().format(to);
return "$startDate $startTime - $endTime";
}
return "$startDate - $endDate";
}
void mergeMonth() {
if (last != null &&
groupBy == GroupAssetsBy.auto &&
monthCount <= 30 &&
elements.length > lastMonthIndex + 1) {
// merge all days into a single section
assert(elements[lastMonthIndex].date.month == last.month);
final e = elements[lastMonthIndex];
elements[lastMonthIndex] = RenderAssetGridElement(
RenderAssetGridElementType.monthTitle,
date: e.date,
count: monthCount,
totalCount: monthCount,
offset: e.offset,
title: formatDateRange(e.date, elements.last.date),
);
elements.removeRange(lastMonthIndex + 1, elements.length);
}
}
void addElems(DateTime d, DateTime? prevDate) {
final bool newMonth =
last == null || last.year != d.year || last.month != d.month;
if (newMonth) {
mergeMonth();
lastMonthIndex = elements.length;
monthCount = 0;
}
for (int j = 0; j < count; j += sectionSize) {
final type = j == 0
? (groupBy != GroupAssetsBy.month && newMonth
? RenderAssetGridElementType.monthTitle
: RenderAssetGridElementType.groupDividerTitle)
: (groupBy == GroupAssetsBy.auto
? RenderAssetGridElementType.groupDividerTitle
: RenderAssetGridElementType.assets);
final sectionCount = j + sectionSize > count ? count - j : sectionSize;
assert(sectionCount > 0 && sectionCount <= sectionSize);
elements.add(
RenderAssetGridElement(
type,
date: d,
count: sectionCount,
totalCount: groupBy == GroupAssetsBy.auto ? sectionCount : count,
offset: lastOffset + j,
title: j == 0
? (d.year == currentYear
? formatSameYear.format(d)
: formatOtherYear.format(d))
: (groupBy == GroupAssetsBy.auto
? formatDateRange(d, prevDate ?? d)
: null),
),
);
}
monthCount += count;
}
DateTime? prevDate;
while (true) {
// this iterates all assets (only their createdAt property) in batches
// memory usage is okay, however runtime is linear with number of assets
// TODO replace with groupBy once Isar supports such queries
final dates = assets != null
? assets.map((a) => a.fileCreatedAt)
: await query!
.offset(offset)
.limit(pageSize)
.fileCreatedAtProperty()
.findAll();
int i = 0;
for (final date in dates) {
final d = DateTime(
date.year,
date.month,
groupBy == GroupAssetsBy.month ? 1 : date.day,
);
current ??= d;
if (current != d) {
addElems(current, prevDate);
last = current;
current = d;
lastOffset = offset + i;
count = 0;
}
prevDate = date;
count++;
i++;
}
if (assets != null || dates.length != pageSize) break;
offset += pageSize;
}
if (count > 0 && current != null) {
addElems(current, prevDate);
mergeMonth();
}
assert(elements.every((e) => e.count <= sectionSize), "too large section");
return RenderList(elements, query, assets);
} }
static RenderList empty() => RenderList([], null, []);
static Future<RenderList> fromAssets( static Future<RenderList> fromAssets(
List<Asset> assets, List<Asset> assets,
AssetGridLayoutParameters layout, GroupAssetsBy groupBy,
) async { ) =>
// Compute only allows for one parameter. Therefore we pass all parameters in a map _buildRenderList(assets, null, groupBy);
return compute(
_processAssetGroupData,
_AssetGroupsToRenderListComputeParameters(
assets,
layout,
),
);
}
} }

View File

@ -396,8 +396,8 @@ class DraggableScrollbarState extends State<DraggableScrollbar>
widget.scrollStateListener(true); widget.scrollStateListener(true);
dragHaltTimer = Timer( dragHaltTimer = Timer(
const Duration(milliseconds: 200), const Duration(milliseconds: 500),
() { () {
widget.scrollStateListener(false); widget.scrollStateListener(false);
}, },
); );

View File

@ -19,8 +19,6 @@ class GroupDividerTitle extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
void handleTitleIconClick() { void handleTitleIconClick() {
if (selected) { if (selected) {
onDeselect(); onDeselect();
@ -32,7 +30,7 @@ class GroupDividerTitle extends ConsumerWidget {
return Padding( return Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
top: 29.0, top: 29.0,
bottom: 29.0, bottom: 10.0,
left: 12.0, left: 12.0,
right: 12.0, right: 12.0,
), ),

View File

@ -8,6 +8,7 @@ import 'package:immich_mobile/modules/settings/providers/app_settings.provider.d
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
class ImmichAssetGrid extends HookConsumerWidget { class ImmichAssetGrid extends HookConsumerWidget {
final int? assetsPerRow; final int? assetsPerRow;
@ -15,13 +16,19 @@ class ImmichAssetGrid extends HookConsumerWidget {
final bool? showStorageIndicator; final bool? showStorageIndicator;
final ImmichAssetGridSelectionListener? listener; final ImmichAssetGridSelectionListener? listener;
final bool selectionActive; final bool selectionActive;
final List<Asset> assets; final List<Asset>? assets;
final RenderList? renderList; final RenderList? renderList;
final Future<void> Function()? onRefresh; final Future<void> Function()? onRefresh;
final Set<Asset>? preselectedAssets;
final bool canDeselect;
final bool? dynamicLayout;
final bool showMultiSelectIndicator;
final void Function(ItemPosition start, ItemPosition end)?
visibleItemsListener;
const ImmichAssetGrid({ const ImmichAssetGrid({
super.key, super.key,
required this.assets, this.assets,
this.onRefresh, this.onRefresh,
this.renderList, this.renderList,
this.assetsPerRow, this.assetsPerRow,
@ -29,12 +36,16 @@ class ImmichAssetGrid extends HookConsumerWidget {
this.listener, this.listener,
this.margin = 5.0, this.margin = 5.0,
this.selectionActive = false, this.selectionActive = false,
this.preselectedAssets,
this.canDeselect = true,
this.dynamicLayout,
this.showMultiSelectIndicator = true,
this.visibleItemsListener,
}); });
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
var settings = ref.watch(appSettingsServiceProvider); var settings = ref.watch(appSettingsServiceProvider);
final renderListFuture = ref.watch(renderListProvider(assets));
// Needs to suppress hero animations when navigating to this widget // Needs to suppress hero animations when navigating to this widget
final enableHeroAnimations = useState(false); final enableHeroAnimations = useState(false);
@ -64,34 +75,12 @@ class ImmichAssetGrid extends HookConsumerWidget {
return true; return true;
} }
if (renderList != null) { Widget buildAssetGridView(RenderList renderList) {
return WillPopScope( return WillPopScope(
onWillPop: onWillPop, onWillPop: onWillPop,
child: HeroMode( child: HeroMode(
enabled: enableHeroAnimations.value, enabled: enableHeroAnimations.value,
child: ImmichAssetGridView( child: ImmichAssetGridView(
allAssets: assets,
onRefresh: onRefresh,
assetsPerRow: assetsPerRow ??
settings.getSetting(AppSettingsEnum.tilesPerRow),
listener: listener,
showStorageIndicator: showStorageIndicator ??
settings.getSetting(AppSettingsEnum.storageIndicator),
renderList: renderList!,
margin: margin,
selectionActive: selectionActive,
),
),
);
}
return renderListFuture.when(
data: (renderList) => WillPopScope(
onWillPop: onWillPop,
child: HeroMode(
enabled: enableHeroAnimations.value,
child: ImmichAssetGridView(
allAssets: assets,
onRefresh: onRefresh, onRefresh: onRefresh,
assetsPerRow: assetsPerRow ?? assetsPerRow: assetsPerRow ??
settings.getSetting(AppSettingsEnum.tilesPerRow), settings.getSetting(AppSettingsEnum.tilesPerRow),
@ -101,9 +90,22 @@ class ImmichAssetGrid extends HookConsumerWidget {
renderList: renderList, renderList: renderList,
margin: margin, margin: margin,
selectionActive: selectionActive, selectionActive: selectionActive,
preselectedAssets: preselectedAssets,
canDeselect: canDeselect,
dynamicLayout: dynamicLayout ??
settings.getSetting(AppSettingsEnum.dynamicLayout),
showMultiSelectIndicator: showMultiSelectIndicator,
visibleItemsListener: visibleItemsListener,
), ),
), ),
), );
}
if (renderList != null) return buildAssetGridView(renderList!);
final renderListFuture = ref.watch(renderListProvider(assets!));
return renderListFuture.when(
data: (renderList) => buildAssetGridView(renderList),
error: (err, stack) => Center(child: Text("$err")), error: (err, stack) => Center(child: Text("$err")),
loading: () => const Center( loading: () => const Center(
child: ImmichLoadingIndicator(), child: ImmichLoadingIndicator(),

View File

@ -1,4 +1,5 @@
import 'dart:collection'; import 'dart:collection';
import 'dart:math';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
@ -6,6 +7,7 @@ import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/scroll_notifier.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/scroll_notifier.provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/utils/builtin_extensions.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'asset_grid_data_structure.dart'; import 'asset_grid_data_structure.dart';
import 'group_divider_title.dart'; import 'group_divider_title.dart';
@ -23,13 +25,11 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
ItemPositionsListener.create(); ItemPositionsListener.create();
bool _scrolling = false; bool _scrolling = false;
final Set<int> _selectedAssets = HashSet(); final Set<Asset> _selectedAssets =
HashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id);
Set<Asset> _getSelectedAssets() { Set<Asset> _getSelectedAssets() {
return _selectedAssets return Set.from(_selectedAssets);
.map((e) => widget.allAssets.firstWhereOrNull((a) => a.id == e))
.whereNotNull()
.toSet();
} }
void _callSelectionListener(bool selectionActive) { void _callSelectionListener(bool selectionActive) {
@ -38,18 +38,14 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
void _selectAssets(List<Asset> assets) { void _selectAssets(List<Asset> assets) {
setState(() { setState(() {
for (var e in assets) { _selectedAssets.addAll(assets);
_selectedAssets.add(e.id);
}
_callSelectionListener(true); _callSelectionListener(true);
}); });
} }
void _deselectAssets(List<Asset> assets) { void _deselectAssets(List<Asset> assets) {
setState(() { setState(() {
for (var e in assets) { _selectedAssets.removeAll(assets);
_selectedAssets.remove(e.id);
}
_callSelectionListener(_selectedAssets.isNotEmpty); _callSelectionListener(_selectedAssets.isNotEmpty);
}); });
} }
@ -57,64 +53,86 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
void _deselectAll() { void _deselectAll() {
setState(() { setState(() {
_selectedAssets.clear(); _selectedAssets.clear();
if (!widget.canDeselect &&
widget.preselectedAssets != null &&
widget.preselectedAssets!.isNotEmpty) {
_selectedAssets.addAll(widget.preselectedAssets!);
}
_callSelectionListener(false);
}); });
_callSelectionListener(false);
} }
bool _allAssetsSelected(List<Asset> assets) { bool _allAssetsSelected(List<Asset> assets) {
return widget.selectionActive && return widget.selectionActive &&
assets.firstWhereOrNull((e) => !_selectedAssets.contains(e.id)) == null; assets.firstWhereOrNull((e) => !_selectedAssets.contains(e)) == null;
} }
Widget _buildThumbnailOrPlaceholder( Widget _buildThumbnailOrPlaceholder(Asset asset, int index) {
Asset asset,
bool placeholder,
) {
if (placeholder) {
return const DecoratedBox(
decoration: BoxDecoration(color: Colors.grey),
);
}
return ThumbnailImage( return ThumbnailImage(
asset: asset, asset: asset,
assetList: widget.allAssets, index: index,
loadAsset: widget.renderList.loadAsset,
totalAssets: widget.renderList.totalAssets,
multiselectEnabled: widget.selectionActive, multiselectEnabled: widget.selectionActive,
isSelected: widget.selectionActive && _selectedAssets.contains(asset.id), isSelected: widget.selectionActive && _selectedAssets.contains(asset),
onSelect: () => _selectAssets([asset]), onSelect: () => _selectAssets([asset]),
onDeselect: () => _deselectAssets([asset]), onDeselect: widget.canDeselect ||
widget.preselectedAssets == null ||
!widget.preselectedAssets!.contains(asset)
? () => _deselectAssets([asset])
: null,
useGrayBoxPlaceholder: true, useGrayBoxPlaceholder: true,
showStorageIndicator: widget.showStorageIndicator, showStorageIndicator: widget.showStorageIndicator,
); );
} }
Widget _buildAssetRow( Widget _buildAssetRow(
Key key,
BuildContext context, BuildContext context,
RenderAssetGridRow row, List<Asset> assets,
bool scrolling, int absoluteOffset,
double width,
) { ) {
return LayoutBuilder( // Default: All assets have the same width
builder: (context, constraints) { final widthDistribution = List.filled(assets.length, 1.0);
final size = constraints.maxWidth / widget.assetsPerRow -
widget.margin * (widget.assetsPerRow - 1) / widget.assetsPerRow;
return Row(
key: Key("asset-row-${row.assets.first.id}"),
children: row.assets.mapIndexed((int index, Asset asset) {
bool last = asset.id == row.assets.last.id;
return Container( if (widget.dynamicLayout) {
key: Key("asset-${asset.id}"), final aspectRatios =
width: size * row.widthDistribution[index], assets.map((e) => (e.width ?? 1) / (e.height ?? 1)).toList();
height: size, final meanAspectRatio = aspectRatios.sum / assets.length;
margin: EdgeInsets.only(
top: widget.margin, // 1: mean width
right: last ? 0.0 : widget.margin, // 0.5: width < mean - threshold
), // 1.5: width > mean + threshold
child: _buildThumbnailOrPlaceholder(asset, scrolling), final arConfiguration = aspectRatios.map((e) {
); if (e - meanAspectRatio > 0.3) return 1.5;
}).toList(), if (e - meanAspectRatio < -0.3) return 0.5;
return 1.0;
});
// Normalize:
final sum = arConfiguration.sum;
widthDistribution.setRange(
0,
widthDistribution.length,
arConfiguration.map((e) => (e * assets.length) / sum),
);
}
return Row(
key: key,
children: assets.mapIndexed((int index, Asset asset) {
final bool last = index + 1 == widget.assetsPerRow;
return Container(
key: ValueKey(index),
width: width * widthDistribution[index],
height: width,
margin: EdgeInsets.only(
top: widget.margin,
right: last ? 0.0 : widget.margin,
),
child: _buildThumbnailOrPlaceholder(asset, absoluteOffset + index),
); );
}, }).toList(),
); );
} }
@ -132,10 +150,14 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
); );
} }
Widget _buildMonthTitle(BuildContext context, String title) { Widget _buildMonthTitle(BuildContext context, DateTime date) {
final monthFormat = DateTime.now().year == date.year
? DateFormat.MMMM()
: DateFormat.yMMMM();
final String title = monthFormat.format(date);
return Padding( return Padding(
key: Key("month-$title"), key: Key("month-$title"),
padding: const EdgeInsets.only(left: 12.0, top: 32), padding: const EdgeInsets.only(left: 12.0, top: 30),
child: Text( child: Text(
title, title,
style: TextStyle( style: TextStyle(
@ -147,18 +169,84 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
); );
} }
Widget _buildPlaceHolderRow(Key key, int num, double width, double height) {
return Row(
key: key,
children: [
for (int i = 0; i < num; i++)
Container(
key: ValueKey(i),
width: width,
height: height,
margin: EdgeInsets.only(
top: widget.margin,
right: i + 1 == num ? 0.0 : widget.margin,
),
color: Colors.grey,
)
],
);
}
Widget _buildSection(
BuildContext context,
RenderAssetGridElement section,
bool scrolling,
) {
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth / widget.assetsPerRow -
widget.margin * (widget.assetsPerRow - 1) / widget.assetsPerRow;
final rows =
(section.count + widget.assetsPerRow - 1) ~/ widget.assetsPerRow;
final List<Asset> assetsToRender = scrolling
? []
: widget.renderList.loadAssets(section.offset, section.count);
return Column(
key: ValueKey(section.offset),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (section.type == RenderAssetGridElementType.monthTitle)
_buildMonthTitle(context, section.date),
if (section.type == RenderAssetGridElementType.groupDividerTitle ||
section.type == RenderAssetGridElementType.monthTitle)
_buildTitle(
context,
section.title!,
scrolling
? []
: widget.renderList
.loadAssets(section.offset, section.totalCount),
),
for (int i = 0; i < rows; i++)
scrolling
? _buildPlaceHolderRow(
ValueKey(i),
i + 1 == rows
? section.count - i * widget.assetsPerRow
: widget.assetsPerRow,
width,
width,
)
: _buildAssetRow(
ValueKey(i),
context,
assetsToRender.nestedSlice(
i * widget.assetsPerRow,
min((i + 1) * widget.assetsPerRow, section.count),
),
section.offset + i * widget.assetsPerRow,
width,
),
],
);
},
);
}
Widget _itemBuilder(BuildContext c, int position) { Widget _itemBuilder(BuildContext c, int position) {
final item = widget.renderList.elements[position]; final item = widget.renderList.elements[position];
return _buildSection(c, item, _scrolling);
if (item.type == RenderAssetGridElementType.groupDividerTitle) {
return _buildTitle(c, item.title!, item.relatedAssetList!);
} else if (item.type == RenderAssetGridElementType.monthTitle) {
return _buildMonthTitle(c, item.title!);
} else if (item.type == RenderAssetGridElementType.assetRow) {
return _buildAssetRow(c, item.assetRow!, _scrolling);
}
return const Text("Invalid widget type!");
} }
Text _labelBuilder(int pos) { Text _labelBuilder(int pos) {
@ -180,7 +268,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
} }
Widget _buildAssetGrid() { Widget _buildAssetGrid() {
final useDragScrolling = widget.allAssets.length >= 20; final useDragScrolling = widget.renderList.totalAssets >= 20;
void dragScrolling(bool active) { void dragScrolling(bool active) {
setState(() { setState(() {
@ -225,6 +313,10 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
setState(() { setState(() {
_selectedAssets.clear(); _selectedAssets.clear();
}); });
} else if (widget.preselectedAssets != null) {
setState(() {
_selectedAssets.addAll(widget.preselectedAssets!);
});
} }
} }
@ -241,14 +333,33 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
void initState() { void initState() {
super.initState(); super.initState();
scrollToTopNotifierProvider.addListener(_scrollToTop); scrollToTopNotifierProvider.addListener(_scrollToTop);
if (widget.visibleItemsListener != null) {
_itemPositionsListener.itemPositions.addListener(_positionListener);
}
} }
@override @override
void dispose() { void dispose() {
scrollToTopNotifierProvider.removeListener(_scrollToTop); scrollToTopNotifierProvider.removeListener(_scrollToTop);
if (widget.visibleItemsListener != null) {
_itemPositionsListener.itemPositions.removeListener(_positionListener);
}
super.dispose(); super.dispose();
} }
void _positionListener() {
final values = _itemPositionsListener.itemPositions.value;
final start = values.firstOrNull;
final end = values.lastOrNull;
if (start != null && end != null) {
if (start.index <= end.index) {
widget.visibleItemsListener?.call(start, end);
} else {
widget.visibleItemsListener?.call(end, start);
}
}
}
void _scrollToTop() { void _scrollToTop() {
// for some reason, this is necessary as well in order // for some reason, this is necessary as well in order
// to correctly reposition the drag thumb scroll bar // to correctly reposition the drag thumb scroll bar
@ -268,7 +379,8 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
child: Stack( child: Stack(
children: [ children: [
_buildAssetGrid(), _buildAssetGrid(),
if (widget.selectionActive) _buildMultiSelectIndicator(), if (widget.showMultiSelectIndicator && widget.selectionActive)
_buildMultiSelectIndicator(),
], ],
), ),
); );
@ -282,19 +394,28 @@ class ImmichAssetGridView extends StatefulWidget {
final bool showStorageIndicator; final bool showStorageIndicator;
final ImmichAssetGridSelectionListener? listener; final ImmichAssetGridSelectionListener? listener;
final bool selectionActive; final bool selectionActive;
final List<Asset> allAssets;
final Future<void> Function()? onRefresh; final Future<void> Function()? onRefresh;
final Set<Asset>? preselectedAssets;
final bool canDeselect;
final bool dynamicLayout;
final bool showMultiSelectIndicator;
final void Function(ItemPosition start, ItemPosition end)?
visibleItemsListener;
const ImmichAssetGridView({ const ImmichAssetGridView({
super.key, super.key,
required this.renderList, required this.renderList,
required this.allAssets,
required this.assetsPerRow, required this.assetsPerRow,
required this.showStorageIndicator, required this.showStorageIndicator,
this.listener, this.listener,
this.margin = 5.0, this.margin = 5.0,
this.selectionActive = false, this.selectionActive = false,
this.onRefresh, this.onRefresh,
this.preselectedAssets,
this.canDeselect = true,
this.dynamicLayout = true,
this.showMultiSelectIndicator = true,
this.visibleItemsListener,
}); });
@override @override

View File

@ -2,7 +2,6 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart'; import 'package:immich_mobile/shared/ui/immich_image.dart';
@ -10,7 +9,9 @@ import 'package:immich_mobile/utils/storage_indicator.dart';
class ThumbnailImage extends HookConsumerWidget { class ThumbnailImage extends HookConsumerWidget {
final Asset asset; final Asset asset;
final List<Asset> assetList; final int index;
final Asset Function(int index) loadAsset;
final int totalAssets;
final bool showStorageIndicator; final bool showStorageIndicator;
final bool useGrayBoxPlaceholder; final bool useGrayBoxPlaceholder;
final bool isSelected; final bool isSelected;
@ -21,7 +22,9 @@ class ThumbnailImage extends HookConsumerWidget {
const ThumbnailImage({ const ThumbnailImage({
Key? key, Key? key,
required this.asset, required this.asset,
required this.assetList, required this.index,
required this.loadAsset,
required this.totalAssets,
this.showStorageIndicator = true, this.showStorageIndicator = true,
this.useGrayBoxPlaceholder = false, this.useGrayBoxPlaceholder = false,
this.isSelected = false, this.isSelected = false,
@ -57,8 +60,9 @@ class ThumbnailImage extends HookConsumerWidget {
} else { } else {
AutoRouter.of(context).push( AutoRouter.of(context).push(
GalleryViewerRoute( GalleryViewerRoute(
assetList: assetList, initialIndex: index,
asset: asset, loadAsset: loadAsset,
totalAssets: totalAssets,
), ),
); );
} }
@ -100,7 +104,9 @@ class ThumbnailImage extends HookConsumerWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
border: multiselectEnabled && isSelected border: multiselectEnabled && isSelected
? Border.all( ? Border.all(
color: Theme.of(context).primaryColorLight, color: onDeselect == null
? Colors.grey
: Theme.of(context).primaryColorLight,
width: 10, width: 10,
) )
: const Border(), : const Border(),
@ -130,7 +136,7 @@ class ThumbnailImage extends HookConsumerWidget {
size: 18, size: 18,
), ),
), ),
if (ref.watch(favoriteProvider).contains(asset.id)) if (asset.isFavorite)
const Positioned( const Positioned(
left: 10, left: 10,
bottom: 5, bottom: 5,

View File

@ -7,15 +7,16 @@ import 'package:immich_mobile/shared/ui/drag_sheet.dart';
import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/album.dart';
class ControlBottomAppBar extends ConsumerWidget { class ControlBottomAppBar extends ConsumerWidget {
final Function onShare; final void Function() onShare;
final Function onFavorite; final void Function() onFavorite;
final Function onArchive; final void Function() onArchive;
final Function onDelete; final void Function() onDelete;
final Function(Album album) onAddToAlbum; final Function(Album album) onAddToAlbum;
final void Function() onCreateNewAlbum; final void Function() onCreateNewAlbum;
final List<Album> albums; final List<Album> albums;
final List<Album> sharedAlbums; final List<Album> sharedAlbums;
final bool enabled;
const ControlBottomAppBar({ const ControlBottomAppBar({
Key? key, Key? key,
@ -27,6 +28,7 @@ class ControlBottomAppBar extends ConsumerWidget {
required this.albums, required this.albums,
required this.onAddToAlbum, required this.onAddToAlbum,
required this.onCreateNewAlbum, required this.onCreateNewAlbum,
this.enabled = true,
}) : super(key: key); }) : super(key: key);
@override @override
@ -39,35 +41,31 @@ class ControlBottomAppBar extends ConsumerWidget {
ControlBoxButton( ControlBoxButton(
iconData: Icons.ios_share_rounded, iconData: Icons.ios_share_rounded,
label: "control_bottom_app_bar_share".tr(), label: "control_bottom_app_bar_share".tr(),
onPressed: () { onPressed: enabled ? onShare : null,
onShare();
},
), ),
ControlBoxButton( ControlBoxButton(
iconData: Icons.favorite_border_rounded, iconData: Icons.favorite_border_rounded,
label: "control_bottom_app_bar_favorite".tr(), label: "control_bottom_app_bar_favorite".tr(),
onPressed: () { onPressed: enabled ? onFavorite : null,
onFavorite();
},
), ),
ControlBoxButton( ControlBoxButton(
iconData: Icons.delete_outline_rounded, iconData: Icons.delete_outline_rounded,
label: "control_bottom_app_bar_delete".tr(), label: "control_bottom_app_bar_delete".tr(),
onPressed: () { onPressed: enabled
showDialog( ? () => showDialog(
context: context, context: context,
builder: (BuildContext context) { builder: (BuildContext context) {
return DeleteDialog( return DeleteDialog(
onDelete: onDelete, onDelete: onDelete,
); );
}, },
); )
}, : null,
), ),
ControlBoxButton( ControlBoxButton(
iconData: Icons.archive, iconData: Icons.archive,
label: "control_bottom_app_bar_archive".tr(), label: "control_bottom_app_bar_archive".tr(),
onPressed: () => onArchive(), onPressed: enabled ? onArchive : null,
), ),
], ],
); );
@ -108,7 +106,9 @@ class ControlBottomAppBar extends ConsumerWidget {
endIndent: 16, endIndent: 16,
thickness: 1, thickness: 1,
), ),
AddToAlbumTitleRow(onCreateNewAlbum: onCreateNewAlbum), AddToAlbumTitleRow(
onCreateNewAlbum: enabled ? onCreateNewAlbum : null,
),
], ],
), ),
), ),
@ -118,6 +118,7 @@ class ControlBottomAppBar extends ConsumerWidget {
albums: albums, albums: albums,
sharedAlbums: sharedAlbums, sharedAlbums: sharedAlbums,
onAddToAlbum: onAddToAlbum, onAddToAlbum: onAddToAlbum,
enabled: enabled,
), ),
), ),
const SliverToBoxAdapter( const SliverToBoxAdapter(
@ -137,7 +138,7 @@ class AddToAlbumTitleRow extends StatelessWidget {
required this.onCreateNewAlbum, required this.onCreateNewAlbum,
}); });
final VoidCallback onCreateNewAlbum; final VoidCallback? onCreateNewAlbum;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@ -10,14 +10,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart'; import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart';
import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart'; import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart'; import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart';
import 'package:immich_mobile/modules/home/ui/home_page_app_bar.dart'; import 'package:immich_mobile/modules/home/ui/home_page_app_bar.dart';
import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer.dart'; import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
@ -34,7 +31,6 @@ class HomePage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final appSettingService = ref.watch(appSettingsServiceProvider);
final multiselectEnabled = ref.watch(multiselectProvider.notifier); final multiselectEnabled = ref.watch(multiselectProvider.notifier);
final selectionEnabledHook = useState(false); final selectionEnabledHook = useState(false);
@ -45,6 +41,7 @@ class HomePage extends HookConsumerWidget {
final tipOneOpacity = useState(0.0); final tipOneOpacity = useState(0.0);
final refreshCount = useState(0); final refreshCount = useState(0);
final processing = useState(false);
useEffect( useEffect(
() { () {
@ -97,7 +94,7 @@ class HomePage extends HookConsumerWidget {
selectionEnabledHook.value = false; selectionEnabledHook.value = false;
} }
Iterable<Asset> remoteOnlySelection({String? localErrorMessage}) { List<Asset> remoteOnlySelection({String? localErrorMessage}) {
final Set<Asset> assets = selection.value; final Set<Asset> assets = selection.value;
final bool onlyRemote = assets.every((e) => e.isRemote); final bool onlyRemote = assets.every((e) => e.isRemote);
if (!onlyRemote) { if (!onlyRemote) {
@ -108,113 +105,139 @@ class HomePage extends HookConsumerWidget {
gravity: ToastGravity.BOTTOM, gravity: ToastGravity.BOTTOM,
); );
} }
return assets.where((a) => a.isRemote); return assets.where((a) => a.isRemote).toList();
} }
return assets; return assets.toList();
} }
void onFavoriteAssets() { void onFavoriteAssets() async {
final remoteAssets = remoteOnlySelection( processing.value = true;
localErrorMessage: 'home_page_favorite_err_local'.tr(), try {
); final remoteAssets = remoteOnlySelection(
if (remoteAssets.isNotEmpty) { localErrorMessage: 'home_page_favorite_err_local'.tr(),
ref.watch(favoriteProvider.notifier).addToFavorites(remoteAssets);
final assetOrAssets = remoteAssets.length > 1 ? 'assets' : 'asset';
ImmichToast.show(
context: context,
msg: 'Added ${remoteAssets.length} $assetOrAssets to favorites',
gravity: ToastGravity.BOTTOM,
); );
} if (remoteAssets.isNotEmpty) {
await ref
.watch(assetProvider.notifier)
.toggleFavorite(remoteAssets, true);
selectionEnabledHook.value = false; final assetOrAssets = remoteAssets.length > 1 ? 'assets' : 'asset';
ImmichToast.show(
context: context,
msg: 'Added ${remoteAssets.length} $assetOrAssets to favorites',
gravity: ToastGravity.BOTTOM,
);
}
} finally {
processing.value = false;
selectionEnabledHook.value = false;
}
} }
void onArchiveAsset() { void onArchiveAsset() async {
final remoteAssets = remoteOnlySelection( processing.value = true;
localErrorMessage: 'home_page_archive_err_local'.tr(), try {
); final remoteAssets = remoteOnlySelection(
if (remoteAssets.isNotEmpty) { localErrorMessage: 'home_page_archive_err_local'.tr(),
ref.watch(assetProvider.notifier).toggleArchive(remoteAssets, true);
final assetOrAssets = remoteAssets.length > 1 ? 'assets' : 'asset';
ImmichToast.show(
context: context,
msg: 'Moved ${remoteAssets.length} $assetOrAssets to archive',
gravity: ToastGravity.CENTER,
); );
} if (remoteAssets.isNotEmpty) {
await ref
.watch(assetProvider.notifier)
.toggleArchive(remoteAssets, true);
selectionEnabledHook.value = false; final assetOrAssets = remoteAssets.length > 1 ? 'assets' : 'asset';
ImmichToast.show(
context: context,
msg: 'Moved ${remoteAssets.length} $assetOrAssets to archive',
gravity: ToastGravity.CENTER,
);
}
} finally {
processing.value = false;
selectionEnabledHook.value = false;
}
} }
void onDelete() { void onDelete() async {
ref.watch(assetProvider.notifier).deleteAssets(selection.value); processing.value = true;
selectionEnabledHook.value = false; try {
await ref.watch(assetProvider.notifier).deleteAssets(selection.value);
selectionEnabledHook.value = false;
} finally {
processing.value = false;
}
} }
void onAddToAlbum(Album album) async { void onAddToAlbum(Album album) async {
final Iterable<Asset> assets = remoteOnlySelection( processing.value = true;
localErrorMessage: "home_page_add_to_album_err_local".tr(), try {
); final Iterable<Asset> assets = remoteOnlySelection(
if (assets.isEmpty) { localErrorMessage: "home_page_add_to_album_err_local".tr(),
return; );
} if (assets.isEmpty) {
final result = await albumService.addAdditionalAssetToAlbum( return;
assets,
album,
);
if (result != null) {
if (result.alreadyInAlbum.isNotEmpty) {
ImmichToast.show(
context: context,
msg: "home_page_add_to_album_conflicts".tr(
namedArgs: {
"album": album.name,
"added": result.successfullyAdded.toString(),
"failed": result.alreadyInAlbum.length.toString()
},
),
);
} else {
ImmichToast.show(
context: context,
msg: "home_page_add_to_album_success".tr(
namedArgs: {
"album": album.name,
"added": result.successfullyAdded.toString(),
},
),
toastType: ToastType.success,
);
} }
final result = await albumService.addAdditionalAssetToAlbum(
assets,
album,
);
if (result != null) {
if (result.alreadyInAlbum.isNotEmpty) {
ImmichToast.show(
context: context,
msg: "home_page_add_to_album_conflicts".tr(
namedArgs: {
"album": album.name,
"added": result.successfullyAdded.toString(),
"failed": result.alreadyInAlbum.length.toString()
},
),
);
} else {
ImmichToast.show(
context: context,
msg: "home_page_add_to_album_success".tr(
namedArgs: {
"album": album.name,
"added": result.successfullyAdded.toString(),
},
),
toastType: ToastType.success,
);
}
}
} finally {
processing.value = false;
selectionEnabledHook.value = false; selectionEnabledHook.value = false;
} }
} }
void onCreateNewAlbum() async { void onCreateNewAlbum() async {
final Iterable<Asset> assets = remoteOnlySelection( processing.value = true;
localErrorMessage: "home_page_add_to_album_err_local".tr(), try {
); final Iterable<Asset> assets = remoteOnlySelection(
if (assets.isEmpty) { localErrorMessage: "home_page_add_to_album_err_local".tr(),
return; );
} if (assets.isEmpty) {
final result = await albumService.createAlbumWithGeneratedName(assets); return;
}
final result =
await albumService.createAlbumWithGeneratedName(assets);
if (result != null) { if (result != null) {
ref.watch(albumProvider.notifier).getAllAlbums(); ref.watch(albumProvider.notifier).getAllAlbums();
ref.watch(sharedAlbumProvider.notifier).getAllSharedAlbums(); ref.watch(sharedAlbumProvider.notifier).getAllSharedAlbums();
selectionEnabledHook.value = false; selectionEnabledHook.value = false;
AutoRouter.of(context).push(AlbumViewerRoute(albumId: result.id)); AutoRouter.of(context).push(AlbumViewerRoute(albumId: result.id));
}
} finally {
processing.value = false;
} }
} }
Future<void> refreshAssets() async { Future<void> refreshAssets() async {
debugPrint("refreshCount.value ${refreshCount.value}");
final fullRefresh = refreshCount.value > 0; final fullRefresh = refreshCount.value > 0;
await ref.read(assetProvider.notifier).getAllAsset(clear: fullRefresh); await ref.read(assetProvider.notifier).getAllAsset(clear: fullRefresh);
if (fullRefresh) { if (fullRefresh) {
@ -277,20 +300,18 @@ class HomePage extends HookConsumerWidget {
bottom: false, bottom: false,
child: Stack( child: Stack(
children: [ children: [
ref.watch(assetProvider).renderList == null || ref.watch(assetsProvider).when(
ref.watch(assetProvider).allAssets.isEmpty data: (data) => data.isEmpty
? buildLoadingIndicator() ? buildLoadingIndicator()
: ImmichAssetGrid( : ImmichAssetGrid(
renderList: ref.watch(assetProvider).renderList!, renderList: data,
assets: ref.read(assetProvider).allAssets, listener: selectionListener,
assetsPerRow: appSettingService selectionActive: selectionEnabledHook.value,
.getSetting(AppSettingsEnum.tilesPerRow), onRefresh: refreshAssets,
showStorageIndicator: appSettingService ),
.getSetting(AppSettingsEnum.storageIndicator), error: (error, _) => Center(child: Text(error.toString())),
listener: selectionListener, loading: buildLoadingIndicator,
selectionActive: selectionEnabledHook.value, ),
onRefresh: refreshAssets,
),
if (selectionEnabledHook.value) if (selectionEnabledHook.value)
ControlBottomAppBar( ControlBottomAppBar(
onShare: onShareAssets, onShare: onShareAssets,
@ -301,7 +322,9 @@ class HomePage extends HookConsumerWidget {
albums: albums, albums: albums,
sharedAlbums: sharedAlbums, sharedAlbums: sharedAlbums,
onCreateNewAlbum: onCreateNewAlbum, onCreateNewAlbum: onCreateNewAlbum,
enabled: !processing.value,
), ),
if (processing.value) const Center(child: ImmichLoadingIndicator())
], ],
), ),
); );

View File

@ -9,13 +9,17 @@ import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart'; import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/shared/models/user.dart'; import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart'; import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart'; import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/utils/db.dart';
import 'package:immich_mobile/utils/hash.dart'; import 'package:immich_mobile/utils/hash.dart';
import 'package:isar/isar.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
class AuthenticationNotifier extends StateNotifier<AuthenticationState> { class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
AuthenticationNotifier( AuthenticationNotifier(
this._apiService, this._apiService,
this._db,
) : super( ) : super(
AuthenticationState( AuthenticationState(
deviceId: "", deviceId: "",
@ -31,6 +35,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
); );
final ApiService _apiService; final ApiService _apiService;
final Isar _db;
Future<bool> login( Future<bool> login(
String email, String email,
@ -91,7 +96,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
try { try {
await Future.wait([ await Future.wait([
_apiService.authenticationApi.logout(), _apiService.authenticationApi.logout(),
Store.delete(StoreKey.assetETag), clearAssetsAndAlbums(_db),
Store.delete(StoreKey.currentUser), Store.delete(StoreKey.currentUser),
Store.delete(StoreKey.accessToken), Store.delete(StoreKey.accessToken),
]); ]);
@ -170,5 +175,6 @@ final authenticationProvider =
StateNotifierProvider<AuthenticationNotifier, AuthenticationState>((ref) { StateNotifierProvider<AuthenticationNotifier, AuthenticationState>((ref) {
return AuthenticationNotifier( return AuthenticationNotifier(
ref.watch(apiServiceProvider), ref.watch(apiServiceProvider),
ref.watch(dbProvider),
); );
}); });

View File

@ -8,6 +8,8 @@ class SearchResultGrid extends HookConsumerWidget {
final List<Asset> assets; final List<Asset> assets;
Asset _loadAsset(int index) => assets[index];
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return GridView.builder( return GridView.builder(
@ -22,7 +24,9 @@ class SearchResultGrid extends HookConsumerWidget {
final asset = assets[index]; final asset = assets[index];
return ThumbnailImage( return ThumbnailImage(
asset: asset, asset: asset,
assetList: assets, index: index,
loadAsset: _loadAsset,
totalAssets: assets.length,
useGrayBoxPlaceholder: true, useGrayBoxPlaceholder: true,
); );
}, },

View File

@ -5,7 +5,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
class LayoutSettings extends HookConsumerWidget { class LayoutSettings extends HookConsumerWidget {
const LayoutSettings({ const LayoutSettings({
@ -22,14 +21,17 @@ class LayoutSettings extends HookConsumerWidget {
void switchChanged(bool value) { void switchChanged(bool value) {
appSettingService.setSetting(AppSettingsEnum.dynamicLayout, value); appSettingService.setSetting(AppSettingsEnum.dynamicLayout, value);
useDynamicLayout.value = value; useDynamicLayout.value = value;
ref.watch(assetProvider.notifier).rebuildAssetGridDataStructure(); ref.invalidate(appSettingsServiceProvider);
} }
void changeGroupValue(GroupAssetsBy? value) { void changeGroupValue(GroupAssetsBy? value) {
if (value != null) { if (value != null) {
appSettingService.setSetting(AppSettingsEnum.groupAssetsBy, value.index); appSettingService.setSetting(
AppSettingsEnum.groupAssetsBy,
value.index,
);
groupBy.value = value; groupBy.value = value;
ref.watch(assetProvider.notifier).rebuildAssetGridDataStructure(); ref.invalidate(appSettingsServiceProvider);
} }
} }
@ -37,8 +39,8 @@ class LayoutSettings extends HookConsumerWidget {
() { () {
useDynamicLayout.value = useDynamicLayout.value =
appSettingService.getSetting<bool>(AppSettingsEnum.dynamicLayout); appSettingService.getSetting<bool>(AppSettingsEnum.dynamicLayout);
groupBy.value = groupBy.value = GroupAssetsBy.values[
GroupAssetsBy.values[appSettingService.getSetting<int>(AppSettingsEnum.groupAssetsBy)]; appSettingService.getSetting<int>(AppSettingsEnum.groupAssetsBy)];
return null; return null;
}, },
@ -93,6 +95,19 @@ class LayoutSettings extends HookConsumerWidget {
onChanged: changeGroupValue, onChanged: changeGroupValue,
controlAffinity: ListTileControlAffinity.trailing, controlAffinity: ListTileControlAffinity.trailing,
), ),
RadioListTile(
activeColor: Theme.of(context).primaryColor,
title: const Text(
"asset_list_layout_settings_group_automatically",
style: TextStyle(
fontSize: 12,
),
).tr(),
value: GroupAssetsBy.auto,
groupValue: groupBy.value,
onChanged: changeGroupValue,
controlAffinity: ListTileControlAffinity.trailing,
),
], ],
); );
} }

View File

@ -4,7 +4,6 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
class StorageIndicator extends HookConsumerWidget { class StorageIndicator extends HookConsumerWidget {
const StorageIndicator({ const StorageIndicator({
@ -20,12 +19,13 @@ class StorageIndicator extends HookConsumerWidget {
void switchChanged(bool value) { void switchChanged(bool value) {
appSettingService.setSetting(AppSettingsEnum.storageIndicator, value); appSettingService.setSetting(AppSettingsEnum.storageIndicator, value);
showStorageIndicator.value = value; showStorageIndicator.value = value;
ref.watch(assetProvider.notifier).rebuildAssetGridDataStructure(); ref.invalidate(appSettingsServiceProvider);
} }
useEffect( useEffect(
() { () {
showStorageIndicator.value = appSettingService.getSetting<bool>(AppSettingsEnum.storageIndicator); showStorageIndicator.value = appSettingService
.getSetting<bool>(AppSettingsEnum.storageIndicator);
return null; return null;
}, },

View File

@ -4,7 +4,6 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
class TilesPerRow extends HookConsumerWidget { class TilesPerRow extends HookConsumerWidget {
const TilesPerRow({ const TilesPerRow({
@ -20,10 +19,7 @@ class TilesPerRow extends HookConsumerWidget {
void sliderChanged(double value) { void sliderChanged(double value) {
appSettingService.setSetting(AppSettingsEnum.tilesPerRow, value.toInt()); appSettingService.setSetting(AppSettingsEnum.tilesPerRow, value.toInt());
itemsValue.value = value; itemsValue.value = value;
} ref.invalidate(appSettingsServiceProvider);
void sliderChangedEnd(double _) {
ref.watch(assetProvider.notifier).rebuildAssetGridDataStructure();
} }
useEffect( useEffect(
@ -49,7 +45,6 @@ class TilesPerRow extends HookConsumerWidget {
).tr(args: ["${itemsValue.value.toInt()}"]), ).tr(args: ["${itemsValue.value.toInt()}"]),
), ),
Slider( Slider(
onChangeEnd: sliderChangedEnd,
onChanged: sliderChanged, onChanged: sliderChanged,
value: itemsValue.value, value: itemsValue.value,
min: 2, min: 2,

View File

@ -67,8 +67,9 @@ class _$AppRouter extends RootStackRouter {
routeData: routeData, routeData: routeData,
child: GalleryViewerPage( child: GalleryViewerPage(
key: args.key, key: args.key,
assetList: args.assetList, initialIndex: args.initialIndex,
asset: args.asset, loadAsset: args.loadAsset,
totalAssets: args.totalAssets,
), ),
); );
}, },
@ -150,18 +151,27 @@ class _$AppRouter extends RootStackRouter {
); );
}, },
AssetSelectionRoute.name: (routeData) { AssetSelectionRoute.name: (routeData) {
final args = routeData.argsAs<AssetSelectionRouteArgs>();
return CustomPage<AssetSelectionPageResult?>( return CustomPage<AssetSelectionPageResult?>(
routeData: routeData, routeData: routeData,
child: const AssetSelectionPage(), child: AssetSelectionPage(
key: args.key,
existingAssets: args.existingAssets,
isNewAlbum: args.isNewAlbum,
),
transitionsBuilder: TransitionsBuilders.slideBottom, transitionsBuilder: TransitionsBuilders.slideBottom,
opaque: true, opaque: true,
barrierDismissible: false, barrierDismissible: false,
); );
}, },
SelectUserForSharingRoute.name: (routeData) { SelectUserForSharingRoute.name: (routeData) {
final args = routeData.argsAs<SelectUserForSharingRouteArgs>();
return CustomPage<List<String>>( return CustomPage<List<String>>(
routeData: routeData, routeData: routeData,
child: const SelectUserForSharingPage(), child: SelectUserForSharingPage(
key: args.key,
assets: args.assets,
),
transitionsBuilder: TransitionsBuilders.slideBottom, transitionsBuilder: TransitionsBuilders.slideBottom,
opaque: true, opaque: true,
barrierDismissible: false, barrierDismissible: false,
@ -582,15 +592,17 @@ class TabControllerRoute extends PageRouteInfo<void> {
class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> { class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
GalleryViewerRoute({ GalleryViewerRoute({
Key? key, Key? key,
required List<Asset> assetList, required int initialIndex,
required Asset asset, required Asset Function(int) loadAsset,
required int totalAssets,
}) : super( }) : super(
GalleryViewerRoute.name, GalleryViewerRoute.name,
path: '/gallery-viewer-page', path: '/gallery-viewer-page',
args: GalleryViewerRouteArgs( args: GalleryViewerRouteArgs(
key: key, key: key,
assetList: assetList, initialIndex: initialIndex,
asset: asset, loadAsset: loadAsset,
totalAssets: totalAssets,
), ),
); );
@ -600,19 +612,22 @@ class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
class GalleryViewerRouteArgs { class GalleryViewerRouteArgs {
const GalleryViewerRouteArgs({ const GalleryViewerRouteArgs({
this.key, this.key,
required this.assetList, required this.initialIndex,
required this.asset, required this.loadAsset,
required this.totalAssets,
}); });
final Key? key; final Key? key;
final List<Asset> assetList; final int initialIndex;
final Asset asset; final Asset Function(int) loadAsset;
final int totalAssets;
@override @override
String toString() { String toString() {
return 'GalleryViewerRouteArgs{key: $key, assetList: $assetList, asset: $asset}'; return 'GalleryViewerRouteArgs{key: $key, initialIndex: $initialIndex, loadAsset: $loadAsset, totalAssets: $totalAssets}';
} }
} }
@ -623,9 +638,9 @@ class VideoViewerRoute extends PageRouteInfo<VideoViewerRouteArgs> {
Key? key, Key? key,
required Asset asset, required Asset asset,
required bool isMotionVideo, required bool isMotionVideo,
required void Function() onVideoEnded, required dynamic onVideoEnded,
void Function()? onPlaying, dynamic onPlaying,
void Function()? onPaused, dynamic onPaused,
}) : super( }) : super(
VideoViewerRoute.name, VideoViewerRoute.name,
path: '/video-viewer-page', path: '/video-viewer-page',
@ -658,11 +673,11 @@ class VideoViewerRouteArgs {
final bool isMotionVideo; final bool isMotionVideo;
final void Function() onVideoEnded; final dynamic onVideoEnded;
final void Function()? onPlaying; final dynamic onPlaying;
final void Function()? onPaused; final dynamic onPaused;
@override @override
String toString() { String toString() {
@ -829,28 +844,78 @@ class RecentlyAddedRoute extends PageRouteInfo<void> {
/// generated route for /// generated route for
/// [AssetSelectionPage] /// [AssetSelectionPage]
class AssetSelectionRoute extends PageRouteInfo<void> { class AssetSelectionRoute extends PageRouteInfo<AssetSelectionRouteArgs> {
const AssetSelectionRoute() AssetSelectionRoute({
: super( Key? key,
required Set<Asset> existingAssets,
bool isNewAlbum = false,
}) : super(
AssetSelectionRoute.name, AssetSelectionRoute.name,
path: '/asset-selection-page', path: '/asset-selection-page',
args: AssetSelectionRouteArgs(
key: key,
existingAssets: existingAssets,
isNewAlbum: isNewAlbum,
),
); );
static const String name = 'AssetSelectionRoute'; static const String name = 'AssetSelectionRoute';
} }
class AssetSelectionRouteArgs {
const AssetSelectionRouteArgs({
this.key,
required this.existingAssets,
this.isNewAlbum = false,
});
final Key? key;
final Set<Asset> existingAssets;
final bool isNewAlbum;
@override
String toString() {
return 'AssetSelectionRouteArgs{key: $key, existingAssets: $existingAssets, isNewAlbum: $isNewAlbum}';
}
}
/// generated route for /// generated route for
/// [SelectUserForSharingPage] /// [SelectUserForSharingPage]
class SelectUserForSharingRoute extends PageRouteInfo<void> { class SelectUserForSharingRoute
const SelectUserForSharingRoute() extends PageRouteInfo<SelectUserForSharingRouteArgs> {
: super( SelectUserForSharingRoute({
Key? key,
required Set<Asset> assets,
}) : super(
SelectUserForSharingRoute.name, SelectUserForSharingRoute.name,
path: '/select-user-for-sharing-page', path: '/select-user-for-sharing-page',
args: SelectUserForSharingRouteArgs(
key: key,
assets: assets,
),
); );
static const String name = 'SelectUserForSharingRoute'; static const String name = 'SelectUserForSharingRoute';
} }
class SelectUserForSharingRouteArgs {
const SelectUserForSharingRouteArgs({
this.key,
required this.assets,
});
final Key? key;
final Set<Asset> assets;
@override
String toString() {
return 'SelectUserForSharingRouteArgs{key: $key, assets: $assets}';
}
}
/// generated route for /// generated route for
/// [AlbumViewerPage] /// [AlbumViewerPage]
class AlbumViewerRoute extends PageRouteInfo<AlbumViewerRouteArgs> { class AlbumViewerRoute extends PageRouteInfo<AlbumViewerRouteArgs> {

View File

@ -1,4 +1,5 @@
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart'; import 'package:immich_mobile/shared/models/user.dart';
@ -34,10 +35,10 @@ class Album {
final IsarLinks<User> sharedUsers = IsarLinks<User>(); final IsarLinks<User> sharedUsers = IsarLinks<User>();
final IsarLinks<Asset> assets = IsarLinks<Asset>(); final IsarLinks<Asset> assets = IsarLinks<Asset>();
List<Asset> _sortedAssets = []; RenderList _renderList = RenderList.empty();
@ignore @ignore
List<Asset> get sortedAssets => _sortedAssets; RenderList get renderList => _renderList;
@ignore @ignore
bool get isRemote => remoteId != null; bool get isRemote => remoteId != null;
@ -69,8 +70,14 @@ class Album {
return name.join(' '); return name.join(' ');
} }
Future<void> loadSortedAssets() async { Stream<void> watchRenderList(GroupAssetsBy groupAssetsBy) async* {
_sortedAssets = await assets.filter().sortByFileCreatedAt().findAll(); final query = assets.filter().sortByFileCreatedAt();
_renderList = await RenderList.fromQuery(query, groupAssetsBy);
yield _renderList;
await for (final _ in query.watchLazy()) {
_renderList = await RenderList.fromQuery(query, groupAssetsBy);
yield _renderList;
}
} }
@override @override

View File

@ -225,7 +225,6 @@ class Asset {
a.isLocal && !isLocal || a.isLocal && !isLocal ||
width == null && a.width != null || width == null && a.width != null ||
height == null && a.height != null || height == null && a.height != null ||
exifInfo == null && a.exifInfo != null ||
livePhotoVideoId == null && a.livePhotoVideoId != null || livePhotoVideoId == null && a.livePhotoVideoId != null ||
!isRemote && a.isRemote && isFavorite != a.isFavorite || !isRemote && a.isRemote && isFavorite != a.isFavorite ||
!isRemote && a.isRemote && isArchived != a.isArchived; !isRemote && a.isRemote && isArchived != a.isArchived;

View File

@ -114,6 +114,45 @@ class ExifInfo {
country: country ?? this.country, country: country ?? this.country,
description: description ?? this.description, description: description ?? this.description,
); );
@override
bool operator ==(other) {
if (other is! ExifInfo) return false;
return id == other.id &&
fileSize == other.fileSize &&
make == other.make &&
model == other.model &&
lens == other.lens &&
f == other.f &&
mm == other.mm &&
iso == other.iso &&
exposureSeconds == other.exposureSeconds &&
lat == other.lat &&
long == other.long &&
city == other.city &&
state == other.state &&
country == other.country &&
description == other.description;
}
@override
@ignore
int get hashCode =>
id.hashCode ^
fileSize.hashCode ^
make.hashCode ^
model.hashCode ^
lens.hashCode ^
f.hashCode ^
mm.hashCode ^
iso.hashCode ^
exposureSeconds.hashCode ^
lat.hashCode ^
long.hashCode ^
city.hashCode ^
state.hashCode ^
country.hashCode ^
description.hashCode;
} }
double? _exposureTimeToSeconds(String? s) { double? _exposureTimeToSeconds(String? s) {

View File

@ -35,6 +35,10 @@ class Store {
return value; return value;
} }
/// Watches a specific key for changes
static Stream<T?> watch<T>(StoreKey<T> key) =>
_db.storeValues.watchObject(key.id).map((e) => e?._extract(key));
/// Returns the stored value for the given key (possibly null) /// Returns the stored value for the given key (possibly null)
static T? tryGet<T>(StoreKey<T> key) => _cache[key.id]; static T? tryGet<T>(StoreKey<T> key) => _cache[key.id];

View File

@ -3,18 +3,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/shared/models/exif_info.dart'; import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart'; import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/services/asset.service.dart'; import 'package:immich_mobile/shared/services/asset.service.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:collection/collection.dart';
import 'package:immich_mobile/shared/services/sync.service.dart'; import 'package:immich_mobile/shared/services/sync.service.dart';
import 'package:immich_mobile/utils/async_mutex.dart';
import 'package:immich_mobile/utils/db.dart'; import 'package:immich_mobile/utils/db.dart';
import 'package:intl/intl.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@ -22,72 +18,23 @@ import 'package:photo_manager/photo_manager.dart';
/// State does not contain archived assets. /// State does not contain archived assets.
/// Use database provider if you want to access the isArchived assets /// Use database provider if you want to access the isArchived assets
class AssetsState { class AssetsState {}
final List<Asset> allAssets;
final RenderList? renderList;
AssetsState(this.allAssets, {this.renderList});
Future<AssetsState> withRenderDataStructure(
AssetGridLayoutParameters layout,
) async {
return AssetsState(
allAssets,
renderList: await RenderList.fromAssets(
allAssets,
layout,
),
);
}
AssetsState withAdditionalAssets(List<Asset> toAdd) {
return AssetsState([...allAssets, ...toAdd]);
}
static AssetsState fromAssetList(List<Asset> assets) {
return AssetsState(assets);
}
static AssetsState empty() {
return AssetsState([]);
}
}
class AssetNotifier extends StateNotifier<AssetsState> { class AssetNotifier extends StateNotifier<AssetsState> {
final AssetService _assetService; final AssetService _assetService;
final AppSettingsService _settingsService;
final AlbumService _albumService; final AlbumService _albumService;
final SyncService _syncService; final SyncService _syncService;
final Isar _db; final Isar _db;
final log = Logger('AssetNotifier'); final log = Logger('AssetNotifier');
bool _getAllAssetInProgress = false; bool _getAllAssetInProgress = false;
bool _deleteInProgress = false; bool _deleteInProgress = false;
final AsyncMutex _stateUpdateLock = AsyncMutex();
AssetNotifier( AssetNotifier(
this._assetService, this._assetService,
this._settingsService,
this._albumService, this._albumService,
this._syncService, this._syncService,
this._db, this._db,
) : super(AssetsState.fromAssetList([])); ) : super(AssetsState());
Future<void> _updateAssetsState(List<Asset> newAssetList) async {
final layout = AssetGridLayoutParameters(
_settingsService.getSetting(AppSettingsEnum.tilesPerRow),
_settingsService.getSetting(AppSettingsEnum.dynamicLayout),
GroupAssetsBy
.values[_settingsService.getSetting(AppSettingsEnum.groupAssetsBy)],
);
state = await AssetsState.fromAssetList(newAssetList)
.withRenderDataStructure(layout);
}
// Just a little helper to trigger a rebuild of the state object
Future<void> rebuildAssetGridDataStructure() async {
await _updateAssetsState(state.allAssets);
}
Future<void> getAllAsset({bool clear = false}) async { Future<void> getAllAsset({bool clear = false}) async {
if (_getAllAssetInProgress || _deleteInProgress) { if (_getAllAssetInProgress || _deleteInProgress) {
@ -97,79 +44,32 @@ class AssetNotifier extends StateNotifier<AssetsState> {
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
try { try {
_getAllAssetInProgress = true; _getAllAssetInProgress = true;
final User me = Store.get(StoreKey.currentUser);
if (clear) { if (clear) {
await clearAssetsAndAlbums(_db); await clearAssetsAndAlbums(_db);
log.info("Manual refresh requested, cleared assets and albums from db"); log.info("Manual refresh requested, cleared assets and albums from db");
} else if (_stateUpdateLock.enqueued <= 1) {
final int cachedCount = await _userAssetQuery(me.isarId).count();
if (cachedCount > 0 && cachedCount != state.allAssets.length) {
await _stateUpdateLock.run(
() async => _updateAssetsState(await _getUserAssets(me.isarId)),
);
log.info(
"Reading assets ${state.allAssets.length} from DB: ${stopwatch.elapsedMilliseconds}ms",
);
stopwatch.reset();
}
} }
final bool newRemote = await _assetService.refreshRemoteAssets(); final bool newRemote = await _assetService.refreshRemoteAssets();
final bool newLocal = await _albumService.refreshDeviceAlbums(); final bool newLocal = await _albumService.refreshDeviceAlbums();
debugPrint("newRemote: $newRemote, newLocal: $newLocal"); debugPrint("newRemote: $newRemote, newLocal: $newLocal");
log.info("Load assets: ${stopwatch.elapsedMilliseconds}ms"); log.info("Load assets: ${stopwatch.elapsedMilliseconds}ms");
stopwatch.reset();
if (!newRemote &&
!newLocal &&
state.allAssets.length == await _userAssetQuery(me.isarId).count()) {
log.info("state is already up-to-date");
return;
}
stopwatch.reset();
if (_stateUpdateLock.enqueued <= 1) {
_stateUpdateLock.run(() async {
final assets = await _getUserAssets(me.isarId);
if (!const ListEquality().equals(assets, state.allAssets)) {
log.info("setting new asset state");
await _updateAssetsState(assets);
}
});
}
} finally { } finally {
_getAllAssetInProgress = false; _getAllAssetInProgress = false;
} }
} }
Future<List<Asset>> _getUserAssets(int userId) =>
_userAssetQuery(userId).sortByFileCreatedAtDesc().findAll();
QueryBuilder<Asset, Asset, QAfterFilterCondition> _userAssetQuery(
int userId,
) =>
_db.assets.filter().ownerIdEqualTo(userId).isArchivedEqualTo(false);
Future<void> clearAllAsset() { Future<void> clearAllAsset() {
state = AssetsState.empty();
return clearAssetsAndAlbums(_db); return clearAssetsAndAlbums(_db);
} }
Future<void> onNewAssetUploaded(Asset newAsset) async { Future<void> onNewAssetUploaded(Asset newAsset) async {
final bool ok = await _syncService.syncNewAssetToDb(newAsset); // eTag on device is not valid after partially modifying the assets
if (ok && _stateUpdateLock.enqueued <= 1) { Store.delete(StoreKey.assetETag);
// run this sequentially if there is at most 1 other task waiting await _syncService.syncNewAssetToDb(newAsset);
await _stateUpdateLock.run(() async {
final userId = Store.get(StoreKey.currentUser).isarId;
final assets = await _getUserAssets(userId);
await _updateAssetsState(assets);
});
}
} }
Future<void> deleteAssets(Set<Asset> deleteAssets) async { Future<void> deleteAssets(Set<Asset> deleteAssets) async {
_deleteInProgress = true; _deleteInProgress = true;
try { try {
_updateAssetsState(
state.allAssets.whereNot(deleteAssets.contains).toList(),
);
final localDeleted = await _deleteLocalAssets(deleteAssets); final localDeleted = await _deleteLocalAssets(deleteAssets);
final remoteDeleted = await _deleteRemoteAssets(deleteAssets); final remoteDeleted = await _deleteRemoteAssets(deleteAssets);
if (localDeleted.isNotEmpty || remoteDeleted.isNotEmpty) { if (localDeleted.isNotEmpty || remoteDeleted.isNotEmpty) {
@ -201,7 +101,7 @@ class AssetNotifier extends StateNotifier<AssetsState> {
} }
if (local.isNotEmpty) { if (local.isNotEmpty) {
try { try {
await PhotoManager.editor.deleteWithIds(local); return await PhotoManager.editor.deleteWithIds(local);
} catch (e, stack) { } catch (e, stack) {
log.severe("Failed to delete asset from device", e, stack); log.severe("Failed to delete asset from device", e, stack);
} }
@ -220,53 +120,25 @@ class AssetNotifier extends StateNotifier<AssetsState> {
.map((a) => a.id); .map((a) => a.id);
} }
Future<bool> toggleFavorite(Asset asset, bool status) async { Future<void> toggleFavorite(List<Asset> assets, bool status) async {
final newAsset = await _assetService.changeFavoriteStatus(asset, status); final newAssets = await _assetService.changeFavoriteStatus(assets, status);
for (Asset? newAsset in newAssets) {
if (newAsset == null) { if (newAsset == null) {
log.severe("Change favorite status failed for asset ${asset.id}"); log.severe("Change favorite status failed for asset");
return asset.isFavorite; continue;
}
} }
final index = state.allAssets.indexWhere((a) => asset.id == a.id);
if (index != -1) {
state.allAssets[index] = newAsset;
_updateAssetsState(state.allAssets);
}
return newAsset.isFavorite;
} }
Future<void> toggleArchive(Iterable<Asset> assets, bool status) async { Future<void> toggleArchive(List<Asset> assets, bool status) async {
final newAssets = await Future.wait( final newAssets = await _assetService.changeArchiveStatus(assets, status);
assets.map((a) => _assetService.changeArchiveStatus(a, status)),
);
int i = 0; int i = 0;
bool unArchived = false;
for (Asset oldAsset in assets) { for (Asset oldAsset in assets) {
final newAsset = newAssets[i++]; final newAsset = newAssets[i++];
if (newAsset == null) { if (newAsset == null) {
log.severe("Change archive status failed for asset ${oldAsset.id}"); log.severe("Change archive status failed for asset ${oldAsset.id}");
continue; continue;
} }
final index = state.allAssets.indexWhere((a) => oldAsset.id == a.id);
if (newAsset.isArchived) {
// remove from state
if (index != -1) {
state.allAssets.removeAt(index);
}
} else {
// add to state is difficult because the list is sorted
unArchived = true;
}
}
if (unArchived) {
final User me = Store.get(StoreKey.currentUser);
await _stateUpdateLock.run(
() async => _updateAssetsState(await _getUserAssets(me.isarId)),
);
} else {
_updateAssetsState(state.allAssets);
} }
} }
} }
@ -274,26 +146,53 @@ class AssetNotifier extends StateNotifier<AssetsState> {
final assetProvider = StateNotifierProvider<AssetNotifier, AssetsState>((ref) { final assetProvider = StateNotifierProvider<AssetNotifier, AssetsState>((ref) {
return AssetNotifier( return AssetNotifier(
ref.watch(assetServiceProvider), ref.watch(assetServiceProvider),
ref.watch(appSettingsServiceProvider),
ref.watch(albumServiceProvider), ref.watch(albumServiceProvider),
ref.watch(syncServiceProvider), ref.watch(syncServiceProvider),
ref.watch(dbProvider), ref.watch(dbProvider),
); );
}); });
final assetGroupByMonthYearProvider = StateProvider((ref) { final assetDetailProvider =
// TODO: remove `where` once temporary workaround is no longer needed (to only StreamProvider.autoDispose.family<Asset, Asset>((ref, asset) async* {
// allow remote assets to be added to album). Keep `toList()` as to NOT sort yield await ref.watch(assetServiceProvider).loadExif(asset);
// the original list/state final db = ref.watch(dbProvider);
final assets = await for (final a in db.assets.watchObject(asset.id)) {
ref.watch(assetProvider).allAssets.where((e) => e.isRemote).toList(); if (a != null) yield await ref.watch(assetServiceProvider).loadExif(a);
}
assets.sortByCompare<DateTime>( });
(e) => e.fileCreatedAt,
(a, b) => b.compareTo(a), final assetsProvider = StreamProvider.autoDispose<RenderList>((ref) async* {
); final query = ref
.watch(dbProvider)
return assets.groupListsBy( .assets
(element) => DateFormat('MMMM, y').format(element.fileCreatedAt.toLocal()), .filter()
); .ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId)
.isArchivedEqualTo(false)
.sortByFileCreatedAtDesc();
final settings = ref.watch(appSettingsServiceProvider);
final groupBy =
GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)];
yield await RenderList.fromQuery(query, groupBy);
await for (final _ in query.watchLazy()) {
yield await RenderList.fromQuery(query, groupBy);
}
});
final remoteAssetsProvider =
StreamProvider.autoDispose<RenderList>((ref) async* {
final query = ref
.watch(dbProvider)
.assets
.where()
.remoteIdIsNotNull()
.filter()
.ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId)
.sortByFileCreatedAt();
final settings = ref.watch(appSettingsServiceProvider);
final groupBy =
GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)];
yield await RenderList.fromQuery(query, groupBy);
await for (final _ in query.watchLazy()) {
yield await RenderList.fromQuery(query, groupBy);
}
}); });

View File

@ -97,15 +97,18 @@ class AssetService {
/// the exif info from the server (remote assets only) /// the exif info from the server (remote assets only)
Future<Asset> loadExif(Asset a) async { Future<Asset> loadExif(Asset a) async {
a.exifInfo ??= await _db.exifInfos.get(a.id); a.exifInfo ??= await _db.exifInfos.get(a.id);
if (a.exifInfo?.iso == null) { // fileSize is always filled on the server but not set on client
if (a.exifInfo?.fileSize == null) {
if (a.isRemote) { if (a.isRemote) {
final dto = await _apiService.assetApi.getAssetById(a.remoteId!); final dto = await _apiService.assetApi.getAssetById(a.remoteId!);
if (dto != null && dto.exifInfo != null) { if (dto != null && dto.exifInfo != null) {
a.exifInfo = Asset.remote(dto).exifInfo!.copyWith(id: a.id); final newExif = Asset.remote(dto).exifInfo!.copyWith(id: a.id);
if (a.isInDb) { if (newExif != a.exifInfo) {
_db.writeTxn(() => a.put(_db)); if (a.isInDb) {
} else { _db.writeTxn(() => a.put(_db));
debugPrint("[loadExif] parameter Asset is not from DB!"); } else {
debugPrint("[loadExif] parameter Asset is not from DB!");
}
} }
} }
} else { } else {
@ -115,27 +118,39 @@ class AssetService {
return a; return a;
} }
Future<Asset?> updateAsset( Future<List<Asset?>> updateAssets(
Asset asset, List<Asset> assets,
UpdateAssetDto updateAssetDto, UpdateAssetDto updateAssetDto,
) async { ) async {
final dto = final List<AssetResponseDto?> dtos = await Future.wait(
await _apiService.assetApi.updateAsset(asset.remoteId!, updateAssetDto); assets.map(
if (dto != null) { (a) => _apiService.assetApi.updateAsset(a.remoteId!, updateAssetDto),
final updated = asset.updatedCopy(Asset.remote(dto)); ),
if (updated.isInDb) { );
await _db.writeTxn(() => updated.put(_db)); bool allInDb = true;
for (int i = 0; i < assets.length; i++) {
final dto = dtos[i], old = assets[i];
if (dto != null) {
final remote = Asset.remote(dto);
if (old.canUpdate(remote)) {
assets[i] = old.updatedCopy(remote);
}
allInDb &= assets[i].isInDb;
} }
return updated;
} }
return null; final toUpdate = allInDb ? assets : assets.where((e) => e.isInDb).toList();
await _syncService.upsertAssetsWithExif(toUpdate);
return assets;
} }
Future<Asset?> changeFavoriteStatus(Asset asset, bool isFavorite) { Future<List<Asset?>> changeFavoriteStatus(
return updateAsset(asset, UpdateAssetDto(isFavorite: isFavorite)); List<Asset> assets,
bool isFavorite,
) {
return updateAssets(assets, UpdateAssetDto(isFavorite: isFavorite));
} }
Future<Asset?> changeArchiveStatus(Asset asset, bool isArchive) { Future<List<Asset?>> changeArchiveStatus(List<Asset> assets, bool isArchive) {
return updateAsset(asset, UpdateAssetDto(isArchived: isArchive)); return updateAssets(assets, UpdateAssetDto(isArchived: isArchive));
} }
} }

View File

@ -172,7 +172,7 @@ class SyncService {
final idsToDelete = diff.third.map((e) => e.id).toList(); final idsToDelete = diff.third.map((e) => e.id).toList();
try { try {
await _db.writeTxn(() => _db.assets.deleteAll(idsToDelete)); await _db.writeTxn(() => _db.assets.deleteAll(idsToDelete));
await _upsertAssetsWithExif(diff.first + diff.second); await upsertAssetsWithExif(diff.first + diff.second);
} on IsarError catch (e) { } on IsarError catch (e) {
_log.severe("Failed to sync remote assets to db: $e"); _log.severe("Failed to sync remote assets to db: $e");
} }
@ -272,7 +272,7 @@ class SyncService {
// for shared album: put missing album assets into local DB // for shared album: put missing album assets into local DB
final resultPair = await _linkWithExistingFromDb(toAdd); final resultPair = await _linkWithExistingFromDb(toAdd);
await _upsertAssetsWithExif(resultPair.second); await upsertAssetsWithExif(resultPair.second);
final assetsToLink = resultPair.first + resultPair.second; final assetsToLink = resultPair.first + resultPair.second;
final usersToLink = (await _db.users.getAllById(userIdsToAdd)).cast<User>(); final usersToLink = (await _db.users.getAllById(userIdsToAdd)).cast<User>();
@ -329,7 +329,7 @@ class SyncService {
// put missing album assets into local DB // put missing album assets into local DB
final result = await _linkWithExistingFromDb(dto.getAssets()); final result = await _linkWithExistingFromDb(dto.getAssets());
existing.addAll(result.first); existing.addAll(result.first);
await _upsertAssetsWithExif(result.second); await upsertAssetsWithExif(result.second);
final Album a = await Album.remote(dto); final Album a = await Album.remote(dto);
await _db.writeTxn(() => _db.albums.store(a)); await _db.writeTxn(() => _db.albums.store(a));
@ -540,7 +540,7 @@ class SyncService {
_log.info( _log.info(
"${result.first.length} assets already existed in DB, to upsert ${result.second.length}", "${result.first.length} assets already existed in DB, to upsert ${result.second.length}",
); );
await _upsertAssetsWithExif(result.second); await upsertAssetsWithExif(result.second);
existing.addAll(result.first); existing.addAll(result.first);
a.assets.addAll(result.first); a.assets.addAll(result.first);
a.assets.addAll(result.second); a.assets.addAll(result.second);
@ -600,7 +600,7 @@ class SyncService {
} }
/// Inserts or updates the assets in the database with their ExifInfo (if any) /// Inserts or updates the assets in the database with their ExifInfo (if any)
Future<void> _upsertAssetsWithExif(List<Asset> assets) async { Future<void> upsertAssetsWithExif(List<Asset> assets) async {
if (assets.isEmpty) { if (assets.isEmpty) {
return; return;
} }

View File

@ -21,19 +21,19 @@ class ControlBoxButton extends StatelessWidget {
Key? key, Key? key,
required this.label, required this.label,
required this.iconData, required this.iconData,
required this.onPressed, this.onPressed,
}) : super(key: key); }) : super(key: key);
final String label; final String label;
final IconData iconData; final IconData iconData;
final Function onPressed; final void Function()? onPressed;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialButton( return MaterialButton(
padding: const EdgeInsets.all(10), padding: const EdgeInsets.all(10),
shape: const CircleBorder(), shape: const CircleBorder(),
onPressed: () => onPressed(), onPressed: onPressed,
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,

View File

@ -1,3 +1,5 @@
import 'package:collection/collection.dart';
extension DurationExtension on String { extension DurationExtension on String {
Duration? toDuration() { Duration? toDuration() {
try { try {
@ -34,4 +36,12 @@ extension ListExtension<E> on List<E> {
length = length == 0 ? 0 : j; length = length == 0 ? 0 : j;
return this; return this;
} }
ListSlice<E> nestedSlice(int start, int end) {
if (this is ListSlice) {
final ListSlice<E> self = this as ListSlice<E>;
return ListSlice<E>(self.source, self.start + start, self.start + end);
}
return ListSlice<E>(this, start, end);
}
} }

View File

@ -60,11 +60,7 @@ void main() {
test('test grouped check months', () async { test('test grouped check months', () async {
final renderList = await RenderList.fromAssets( final renderList = await RenderList.fromAssets(
assets, assets,
AssetGridLayoutParameters( GroupAssetsBy.day,
3,
false,
GroupAssetsBy.day,
),
); );
// Oct // Oct
@ -78,32 +74,33 @@ void main() {
// 5 Assets => 2 Rows // 5 Assets => 2 Rows
// Day 1 // Day 1
// 5 Assets => 2 Rows // 5 Assets => 2 Rows
expect(renderList.elements.length, 18); expect(renderList.elements.length, 4);
expect( expect(
renderList.elements[0].type, renderList.elements[0].type,
RenderAssetGridElementType.monthTitle, RenderAssetGridElementType.monthTitle,
); );
expect(renderList.elements[0].date.month, 10); expect(renderList.elements[0].date.month, 1);
expect( expect(
renderList.elements[7].type, renderList.elements[1].type,
RenderAssetGridElementType.groupDividerTitle,
);
expect(renderList.elements[1].date.month, 1);
expect(
renderList.elements[2].type,
RenderAssetGridElementType.monthTitle, RenderAssetGridElementType.monthTitle,
); );
expect(renderList.elements[7].date.month, 2); expect(renderList.elements[2].date.month, 2);
expect( expect(
renderList.elements[11].type, renderList.elements[3].type,
RenderAssetGridElementType.monthTitle, RenderAssetGridElementType.monthTitle,
); );
expect(renderList.elements[11].date.month, 1); expect(renderList.elements[3].date.month, 10);
}); });
test('test grouped check types', () async { test('test grouped check types', () async {
final renderList = await RenderList.fromAssets( final renderList = await RenderList.fromAssets(
assets, assets,
AssetGridLayoutParameters( GroupAssetsBy.day,
5,
false,
GroupAssetsBy.day,
),
); );
// Oct // Oct
@ -120,17 +117,8 @@ void main() {
final types = [ final types = [
RenderAssetGridElementType.monthTitle, RenderAssetGridElementType.monthTitle,
RenderAssetGridElementType.groupDividerTitle, RenderAssetGridElementType.groupDividerTitle,
RenderAssetGridElementType.assetRow,
RenderAssetGridElementType.assetRow,
RenderAssetGridElementType.assetRow,
RenderAssetGridElementType.monthTitle, RenderAssetGridElementType.monthTitle,
RenderAssetGridElementType.groupDividerTitle,
RenderAssetGridElementType.assetRow,
RenderAssetGridElementType.monthTitle, RenderAssetGridElementType.monthTitle,
RenderAssetGridElementType.groupDividerTitle,
RenderAssetGridElementType.assetRow,
RenderAssetGridElementType.groupDividerTitle,
RenderAssetGridElementType.assetRow,
]; ];
expect(renderList.elements.length, types.length); expect(renderList.elements.length, types.length);

View File

@ -1,112 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
@GenerateNiceMocks([
MockSpec<AssetsState>(),
MockSpec<AssetNotifier>(),
])
import 'favorite_provider_test.mocks.dart';
Asset _getTestAsset(int id, bool favorite) {
final Asset a = Asset(
remoteId: id.toString(),
localId: id.toString(),
deviceId: 1,
ownerId: 1,
fileCreatedAt: DateTime.now(),
fileModifiedAt: DateTime.now(),
updatedAt: DateTime.now(),
isLocal: false,
durationInSeconds: 0,
type: AssetType.image,
fileName: '',
isFavorite: favorite,
isArchived: false,
);
a.id = id;
return a;
}
void main() {
group("Test favoriteProvider", () {
late MockAssetsState assetsState;
late MockAssetNotifier assetNotifier;
late ProviderContainer container;
late StateNotifierProvider<FavoriteSelectionNotifier, Set<int>>
testFavoritesProvider;
setUp(
() {
assetsState = MockAssetsState();
assetNotifier = MockAssetNotifier();
container = ProviderContainer();
testFavoritesProvider =
StateNotifierProvider<FavoriteSelectionNotifier, Set<int>>((ref) {
return FavoriteSelectionNotifier(
assetsState,
assetNotifier,
);
});
},
);
test("Empty favorites provider", () {
when(assetsState.allAssets).thenReturn([]);
expect(<int>{}, container.read(testFavoritesProvider));
});
test("Non-empty favorites provider", () {
when(assetsState.allAssets).thenReturn([
_getTestAsset(1, false),
_getTestAsset(2, true),
_getTestAsset(3, false),
_getTestAsset(4, false),
_getTestAsset(5, true),
]);
expect(<int>{2, 5}, container.read(testFavoritesProvider));
});
test("Toggle favorite", () {
when(assetNotifier.toggleFavorite(null, false))
.thenAnswer((_) async => false);
final testAsset1 = _getTestAsset(1, false);
final testAsset2 = _getTestAsset(2, true);
when(assetsState.allAssets).thenReturn([testAsset1, testAsset2]);
expect(<int>{2}, container.read(testFavoritesProvider));
container.read(testFavoritesProvider.notifier).toggleFavorite(testAsset2);
expect(<int>{}, container.read(testFavoritesProvider));
container.read(testFavoritesProvider.notifier).toggleFavorite(testAsset1);
expect(<int>{1}, container.read(testFavoritesProvider));
});
test("Add favorites", () {
when(assetNotifier.toggleFavorite(null, false))
.thenAnswer((_) async => false);
when(assetsState.allAssets).thenReturn([]);
expect(<int>{}, container.read(testFavoritesProvider));
container.read(testFavoritesProvider.notifier).addToFavorites(
[
_getTestAsset(1, false),
_getTestAsset(2, false),
],
);
expect(<int>{1, 2}, container.read(testFavoritesProvider));
});
});
}

View File

@ -1,298 +0,0 @@
// Mocks generated by Mockito 5.3.2 from annotations
// in immich_mobile/test/favorite_provider_test.dart.
// Do not manually edit this file.
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:async' as _i5;
import 'package:hooks_riverpod/hooks_riverpod.dart' as _i7;
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart'
as _i6;
import 'package:immich_mobile/shared/models/asset.dart' as _i4;
import 'package:immich_mobile/shared/providers/asset.provider.dart' as _i2;
import 'package:logging/logging.dart' as _i3;
import 'package:mockito/mockito.dart' as _i1;
import 'package:state_notifier/state_notifier.dart' as _i8;
// ignore_for_file: type=lint
// ignore_for_file: avoid_redundant_argument_values
// ignore_for_file: avoid_setters_without_getters
// ignore_for_file: comment_references
// ignore_for_file: implementation_imports
// ignore_for_file: invalid_use_of_visible_for_testing_member
// ignore_for_file: prefer_const_constructors
// ignore_for_file: unnecessary_parenthesis
// ignore_for_file: camel_case_types
// ignore_for_file: subtype_of_sealed_class
class _FakeAssetsState_0 extends _i1.SmartFake implements _i2.AssetsState {
_FakeAssetsState_0(
Object parent,
Invocation parentInvocation,
) : super(
parent,
parentInvocation,
);
}
class _FakeLogger_1 extends _i1.SmartFake implements _i3.Logger {
_FakeLogger_1(
Object parent,
Invocation parentInvocation,
) : super(
parent,
parentInvocation,
);
}
/// A class which mocks [AssetsState].
///
/// See the documentation for Mockito's code generation for more information.
class MockAssetsState extends _i1.Mock implements _i2.AssetsState {
@override
List<_i4.Asset> get allAssets => (super.noSuchMethod(
Invocation.getter(#allAssets),
returnValue: <_i4.Asset>[],
returnValueForMissingStub: <_i4.Asset>[],
) as List<_i4.Asset>);
@override
_i5.Future<_i2.AssetsState> withRenderDataStructure(
_i6.AssetGridLayoutParameters? layout) =>
(super.noSuchMethod(
Invocation.method(
#withRenderDataStructure,
[layout],
),
returnValue: _i5.Future<_i2.AssetsState>.value(_FakeAssetsState_0(
this,
Invocation.method(
#withRenderDataStructure,
[layout],
),
)),
returnValueForMissingStub:
_i5.Future<_i2.AssetsState>.value(_FakeAssetsState_0(
this,
Invocation.method(
#withRenderDataStructure,
[layout],
),
)),
) as _i5.Future<_i2.AssetsState>);
@override
_i2.AssetsState withAdditionalAssets(List<_i4.Asset>? toAdd) =>
(super.noSuchMethod(
Invocation.method(
#withAdditionalAssets,
[toAdd],
),
returnValue: _FakeAssetsState_0(
this,
Invocation.method(
#withAdditionalAssets,
[toAdd],
),
),
returnValueForMissingStub: _FakeAssetsState_0(
this,
Invocation.method(
#withAdditionalAssets,
[toAdd],
),
),
) as _i2.AssetsState);
}
/// A class which mocks [AssetNotifier].
///
/// See the documentation for Mockito's code generation for more information.
class MockAssetNotifier extends _i1.Mock implements _i2.AssetNotifier {
@override
_i3.Logger get log => (super.noSuchMethod(
Invocation.getter(#log),
returnValue: _FakeLogger_1(
this,
Invocation.getter(#log),
),
returnValueForMissingStub: _FakeLogger_1(
this,
Invocation.getter(#log),
),
) as _i3.Logger);
@override
set onError(_i7.ErrorListener? _onError) => super.noSuchMethod(
Invocation.setter(
#onError,
_onError,
),
returnValueForMissingStub: null,
);
@override
bool get mounted => (super.noSuchMethod(
Invocation.getter(#mounted),
returnValue: false,
returnValueForMissingStub: false,
) as bool);
@override
_i5.Stream<_i2.AssetsState> get stream => (super.noSuchMethod(
Invocation.getter(#stream),
returnValue: _i5.Stream<_i2.AssetsState>.empty(),
returnValueForMissingStub: _i5.Stream<_i2.AssetsState>.empty(),
) as _i5.Stream<_i2.AssetsState>);
@override
_i2.AssetsState get state => (super.noSuchMethod(
Invocation.getter(#state),
returnValue: _FakeAssetsState_0(
this,
Invocation.getter(#state),
),
returnValueForMissingStub: _FakeAssetsState_0(
this,
Invocation.getter(#state),
),
) as _i2.AssetsState);
@override
set state(_i2.AssetsState? value) => super.noSuchMethod(
Invocation.setter(
#state,
value,
),
returnValueForMissingStub: null,
);
@override
_i2.AssetsState get debugState => (super.noSuchMethod(
Invocation.getter(#debugState),
returnValue: _FakeAssetsState_0(
this,
Invocation.getter(#debugState),
),
returnValueForMissingStub: _FakeAssetsState_0(
this,
Invocation.getter(#debugState),
),
) as _i2.AssetsState);
@override
bool get hasListeners => (super.noSuchMethod(
Invocation.getter(#hasListeners),
returnValue: false,
returnValueForMissingStub: false,
) as bool);
@override
_i5.Future<void> rebuildAssetGridDataStructure() => (super.noSuchMethod(
Invocation.method(
#rebuildAssetGridDataStructure,
[],
),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
@override
_i5.Future<void> getAllAsset({bool? clear = false}) => (super.noSuchMethod(
Invocation.method(
#getAllAsset,
[],
{#clear: clear},
),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
@override
_i5.Future<void> clearAllAsset() => (super.noSuchMethod(
Invocation.method(
#clearAllAsset,
[],
),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
@override
_i5.Future<void> onNewAssetUploaded(_i4.Asset? newAsset) =>
(super.noSuchMethod(
Invocation.method(
#onNewAssetUploaded,
[newAsset],
),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
@override
_i5.Future<void> deleteAssets(Set<_i4.Asset>? deleteAssets) =>
(super.noSuchMethod(
Invocation.method(
#deleteAssets,
[deleteAssets],
),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
@override
_i5.Future<bool> toggleFavorite(
_i4.Asset? asset,
bool? status,
) =>
(super.noSuchMethod(
Invocation.method(
#toggleFavorite,
[
asset,
status,
],
),
returnValue: _i5.Future<bool>.value(false),
returnValueForMissingStub: _i5.Future<bool>.value(false),
) as _i5.Future<bool>);
@override
_i5.Future<void> toggleArchive(
Iterable<_i4.Asset>? assets,
bool? status,
) =>
(super.noSuchMethod(
Invocation.method(
#toggleArchive,
[
assets,
status,
],
),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
@override
bool updateShouldNotify(
_i2.AssetsState? old,
_i2.AssetsState? current,
) =>
(super.noSuchMethod(
Invocation.method(
#updateShouldNotify,
[
old,
current,
],
),
returnValue: false,
returnValueForMissingStub: false,
) as bool);
@override
_i7.RemoveListener addListener(
_i8.Listener<_i2.AssetsState>? listener, {
bool? fireImmediately = true,
}) =>
(super.noSuchMethod(
Invocation.method(
#addListener,
[listener],
{#fireImmediately: fireImmediately},
),
returnValue: () {},
returnValueForMissingStub: () {},
) as _i7.RemoveListener);
@override
void dispose() => super.noSuchMethod(
Invocation.method(
#dispose,
[],
),
returnValueForMissingStub: null,
);
}