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:
parent
93863b0629
commit
0dde76bbbc
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@ -115,7 +115,7 @@ jobs:
|
||||
flutter-version: "3.10.0"
|
||||
- name: Run tests
|
||||
working-directory: ./mobile
|
||||
run: flutter test
|
||||
run: flutter test -j 1
|
||||
|
||||
generated-api-up-to-date:
|
||||
name: Check generated files are up-to-date
|
||||
|
@ -21,6 +21,7 @@
|
||||
"asset_list_layout_settings_group_by": "Group assets by",
|
||||
"asset_list_layout_settings_group_by_month": "Month",
|
||||
"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_title": "Photo Grid",
|
||||
"backup_album_selection_page_albums_device": "Albums on device ({})",
|
||||
@ -276,4 +277,4 @@
|
||||
"description_input_hint_text": "Add description...",
|
||||
"archive_page_title": "Archive ({})",
|
||||
"archive_page_no_archived_assets": "No archived assets found"
|
||||
}
|
||||
}
|
@ -2,47 +2,20 @@ import 'package:collection/collection.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
|
||||
class AssetSelectionPageResult {
|
||||
final Set<Asset> selectedNewAsset;
|
||||
final Set<Asset> selectedAdditionalAsset;
|
||||
final bool isAlbumExist;
|
||||
final Set<Asset> selectedAssets;
|
||||
|
||||
AssetSelectionPageResult({
|
||||
required this.selectedNewAsset,
|
||||
required this.selectedAdditionalAsset,
|
||||
required this.isAlbumExist,
|
||||
required this.selectedAssets,
|
||||
});
|
||||
|
||||
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
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
final setEquals = const DeepCollectionEquality().equals;
|
||||
|
||||
return other is AssetSelectionPageResult &&
|
||||
setEquals(other.selectedNewAsset, selectedNewAsset) &&
|
||||
setEquals(other.selectedAdditionalAsset, selectedAdditionalAsset) &&
|
||||
other.isAlbumExist == isAlbumExist;
|
||||
setEquals(other.selectedAssets, selectedAssets);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
selectedNewAsset.hashCode ^
|
||||
selectedAdditionalAsset.hashCode ^
|
||||
isAlbumExist.hashCode;
|
||||
int get hashCode => selectedAssets.hashCode;
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/services/album.service.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';
|
||||
|
||||
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 Isar _db;
|
||||
late final StreamSubscription<List<Album>> _streamSub;
|
||||
|
||||
Future<void> getAllAlbums() async {
|
||||
final User me = Store.get(StoreKey.currentUser);
|
||||
List<Album> albums = await _db.albums
|
||||
.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<void> getAllAlbums() => Future.wait([
|
||||
_albumService.refreshDeviceAlbums(),
|
||||
_albumService.refreshRemoteAlbums(isShared: false),
|
||||
]);
|
||||
|
||||
Future<bool> deleteAlbum(Album album) async {
|
||||
state = state.where((a) => a.id != album.id).toList();
|
||||
return _albumService.deleteAlbum(album);
|
||||
}
|
||||
Future<bool> deleteAlbum(Album album) => _albumService.deleteAlbum(album);
|
||||
|
||||
Future<Album?> createAlbum(
|
||||
String albumTitle,
|
||||
Set<Asset> assets,
|
||||
) async {
|
||||
Album? album = await _albumService.createAlbum(albumTitle, assets, []);
|
||||
if (album != null) {
|
||||
state = [...state, album];
|
||||
}
|
||||
return album;
|
||||
) =>
|
||||
_albumService.createAlbum(albumTitle, assets, []);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_streamSub.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
final albumProvider = StateNotifierProvider<AlbumNotifier, List<Album>>((ref) {
|
||||
final albumProvider =
|
||||
StateNotifierProvider.autoDispose<AlbumNotifier, List<Album>>((ref) {
|
||||
return AlbumNotifier(
|
||||
ref.watch(albumServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
|
@ -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();
|
||||
});
|
@ -1,7 +1,9 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.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/asset.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';
|
||||
|
||||
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 Isar _db;
|
||||
late final StreamSubscription<List<Album>> _streamSub;
|
||||
|
||||
Future<Album?> createSharedAlbum(
|
||||
String albumName,
|
||||
@ -20,46 +26,21 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
|
||||
Iterable<User> sharedUsers,
|
||||
) async {
|
||||
try {
|
||||
final Album? newAlbum = await _albumService.createAlbum(
|
||||
return await _albumService.createAlbum(
|
||||
albumName,
|
||||
assets,
|
||||
sharedUsers,
|
||||
);
|
||||
|
||||
if (newAlbum != null) {
|
||||
state = [...state, newAlbum];
|
||||
return newAlbum;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Error createSharedAlbum ${e.toString()}");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> getAllSharedAlbums() async {
|
||||
var albums = await _db.albums
|
||||
.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<void> getAllSharedAlbums() =>
|
||||
_albumService.refreshRemoteAlbums(isShared: true);
|
||||
|
||||
Future<bool> deleteAlbum(Album album) {
|
||||
state = state.where((a) => a.id != album.id).toList();
|
||||
return _albumService.deleteAlbum(album);
|
||||
}
|
||||
Future<bool> deleteAlbum(Album album) => _albumService.deleteAlbum(album);
|
||||
|
||||
Future<bool> leaveAlbum(Album album) async {
|
||||
var res = await _albumService.leaveAlbum(album);
|
||||
@ -75,10 +56,16 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
|
||||
Future<bool> removeAssetFromAlbum(Album album, Iterable<Asset> assets) {
|
||||
return _albumService.removeAssetFromAlbum(album, assets);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_streamSub.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
final sharedAlbumProvider =
|
||||
StateNotifierProvider<SharedAlbumNotifier, List<Album>>((ref) {
|
||||
StateNotifierProvider.autoDispose<SharedAlbumNotifier, List<Album>>((ref) {
|
||||
return SharedAlbumNotifier(
|
||||
ref.watch(albumServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
@ -86,10 +73,15 @@ final sharedAlbumProvider =
|
||||
});
|
||||
|
||||
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 Album? a = await sharedAlbumService.getAlbumDetail(albumId);
|
||||
await a?.loadSortedAssets();
|
||||
return a;
|
||||
await for (final a in sharedAlbumService.watchAlbum(albumId)) {
|
||||
if (a == null) {
|
||||
throw Exception("Album with ID=$albumId does not exist anymore!");
|
||||
}
|
||||
await for (final _ in a.watchRenderList(GroupAssetsBy.none)) {
|
||||
yield a;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -214,8 +214,9 @@ class AlbumService {
|
||||
);
|
||||
}
|
||||
|
||||
Future<Album?> getAlbumDetail(int albumId) {
|
||||
return _db.albums.get(albumId);
|
||||
Stream<Album?> watchAlbum(int albumId) async* {
|
||||
yield await _db.albums.get(albumId);
|
||||
yield* _db.albums.watchObject(albumId);
|
||||
}
|
||||
|
||||
Future<AddAssetsResponseDto?> addAdditionalAssetToAlbum(
|
||||
|
@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.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/asset_selection.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/ui/add_to_album_sliverlist.dart';
|
||||
@ -110,12 +109,6 @@ class AddToAlbumBottomSheet extends HookConsumerWidget {
|
||||
TextStyle(color: Theme.of(context).primaryColor),
|
||||
),
|
||||
onPressed: () {
|
||||
ref
|
||||
.watch(assetSelectionProvider.notifier)
|
||||
.removeAll();
|
||||
ref
|
||||
.watch(assetSelectionProvider.notifier)
|
||||
.addNewAssets(assets);
|
||||
AutoRouter.of(context).push(
|
||||
CreateAlbumRoute(
|
||||
isSharedAlbum: false,
|
||||
|
@ -9,12 +9,14 @@ class AddToAlbumSliverList extends HookConsumerWidget {
|
||||
final List<Album> albums;
|
||||
final List<Album> sharedAlbums;
|
||||
final void Function(Album) onAddToAlbum;
|
||||
final bool enabled;
|
||||
|
||||
const AddToAlbumSliverList({
|
||||
Key? key,
|
||||
required this.onAddToAlbum,
|
||||
required this.albums,
|
||||
required this.sharedAlbums,
|
||||
this.enabled = true,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
@ -28,14 +30,14 @@ class AddToAlbumSliverList extends HookConsumerWidget {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: ExpansionTile(
|
||||
title: Text('common_shared'.tr()),
|
||||
title: Text('common_shared'.tr()),
|
||||
tilePadding: const EdgeInsets.symmetric(horizontal: 10.0),
|
||||
leading: const Icon(Icons.group),
|
||||
children: sharedAlbums
|
||||
.map(
|
||||
(album) => AlbumThumbnailListTile(
|
||||
album: album,
|
||||
onTap: () => onAddToAlbum(album),
|
||||
onTap: enabled ? () => onAddToAlbum(album) : () {},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
@ -48,7 +50,7 @@ class AddToAlbumSliverList extends HookConsumerWidget {
|
||||
final album = albums[offset];
|
||||
return AlbumThumbnailListTile(
|
||||
album: album,
|
||||
onTap: () => onAddToAlbum(album),
|
||||
onTap: enabled ? () => onAddToAlbum(album) : () {},
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
@ -5,10 +5,10 @@ import 'package:fluttertoast/fluttertoast.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_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/routing/router.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/views/immich_loading_overlay.dart';
|
||||
|
||||
@ -18,17 +18,19 @@ class AlbumViewerAppbar extends HookConsumerWidget
|
||||
Key? key,
|
||||
required this.album,
|
||||
required this.userId,
|
||||
required this.selected,
|
||||
required this.selectionDisabled,
|
||||
required this.titleFocusNode,
|
||||
}) : super(key: key);
|
||||
|
||||
final Album album;
|
||||
final String userId;
|
||||
final Set<Asset> selected;
|
||||
final void Function() selectionDisabled;
|
||||
final FocusNode titleFocusNode;
|
||||
|
||||
@override
|
||||
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 isEditAlbum = ref.watch(albumViewerProvider).isEditAlbum;
|
||||
|
||||
@ -86,12 +88,12 @@ class AlbumViewerAppbar extends HookConsumerWidget
|
||||
bool isSuccess =
|
||||
await ref.watch(sharedAlbumProvider.notifier).removeAssetFromAlbum(
|
||||
album,
|
||||
selectedAssetsInAlbum,
|
||||
selected,
|
||||
);
|
||||
|
||||
if (isSuccess) {
|
||||
Navigator.pop(context);
|
||||
ref.watch(assetSelectionProvider.notifier).disableMultiselection();
|
||||
selectionDisabled();
|
||||
ref.watch(albumProvider.notifier).getAllAlbums();
|
||||
ref.invalidate(sharedAlbumDetailProvider(album.id));
|
||||
} else {
|
||||
@ -108,7 +110,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
|
||||
}
|
||||
|
||||
buildBottomSheetActionButton() {
|
||||
if (isMultiSelectionEnable) {
|
||||
if (selected.isNotEmpty) {
|
||||
if (album.ownerId == userId) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.delete_sweep_rounded),
|
||||
@ -163,11 +165,9 @@ class AlbumViewerAppbar extends HookConsumerWidget
|
||||
}
|
||||
|
||||
buildLeadingButton() {
|
||||
if (isMultiSelectionEnable) {
|
||||
if (selected.isNotEmpty) {
|
||||
return IconButton(
|
||||
onPressed: () => ref
|
||||
.watch(assetSelectionProvider.notifier)
|
||||
.disableMultiselection(),
|
||||
onPressed: selectionDisabled,
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
splashRadius: 25,
|
||||
);
|
||||
@ -202,9 +202,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
|
||||
return AppBar(
|
||||
elevation: 0,
|
||||
leading: buildLeadingButton(),
|
||||
title: isMultiSelectionEnable
|
||||
? Text('${selectedAssetsInAlbum.length}')
|
||||
: null,
|
||||
title: selected.isNotEmpty ? Text('${selected.length}') : null,
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
if (album.isRemote)
|
||||
|
@ -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(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -5,21 +5,18 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.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/providers/asset_selection.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.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_viewer_appbar.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/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.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/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_sliver_persistent_app_bar_delegate.dart';
|
||||
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
|
||||
@ -32,33 +29,51 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
FocusNode titleFocusNode = useFocusNode();
|
||||
ScrollController scrollController = useScrollController();
|
||||
final album = ref.watch(sharedAlbumDetailProvider(albumId));
|
||||
|
||||
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
|
||||
/// If they exist, add to selected asset state to show they are already selected.
|
||||
void onAddPhotosPressed(Album albumInfo) async {
|
||||
if (albumInfo.assets.isNotEmpty == true) {
|
||||
ref.watch(assetSelectionProvider.notifier).addNewAssets(
|
||||
albumInfo.assets,
|
||||
);
|
||||
}
|
||||
|
||||
ref.watch(assetSelectionProvider.notifier).setIsAlbumExist(true);
|
||||
|
||||
AssetSelectionPageResult? returnPayload = await AutoRouter.of(context)
|
||||
.push<AssetSelectionPageResult?>(const AssetSelectionRoute());
|
||||
AssetSelectionPageResult? returnPayload =
|
||||
await AutoRouter.of(context).push<AssetSelectionPageResult?>(
|
||||
AssetSelectionRoute(
|
||||
existingAssets: albumInfo.assets,
|
||||
isNewAlbum: false,
|
||||
),
|
||||
);
|
||||
|
||||
if (returnPayload != null) {
|
||||
// Check if there is new assets add
|
||||
if (returnPayload.selectedAdditionalAsset.isNotEmpty) {
|
||||
if (returnPayload.selectedAssets.isNotEmpty) {
|
||||
ImmichLoadingOverlayController.appLoader.show();
|
||||
|
||||
var addAssetsResult =
|
||||
await ref.watch(albumServiceProvider).addAdditionalAssetToAlbum(
|
||||
returnPayload.selectedAdditionalAsset,
|
||||
returnPayload.selectedAssets,
|
||||
albumInfo,
|
||||
);
|
||||
|
||||
@ -70,10 +85,6 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
|
||||
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);
|
||||
|
||||
if (isSuccess) {
|
||||
ref.invalidate(sharedAlbumDetailProvider(albumId));
|
||||
ref.invalidate(sharedAlbumDetailProvider(album.id));
|
||||
}
|
||||
|
||||
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) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 8, right: 8, top: 16),
|
||||
@ -146,171 +182,104 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
Widget buildHeader(Album album) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
buildTitle(album),
|
||||
if (album.assets.isNotEmpty == true) buildAlbumDateRange(album),
|
||||
if (album.shared)
|
||||
SizedBox(
|
||||
height: 60,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemBuilder: ((context, index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: CircleAvatar(
|
||||
backgroundColor: Colors.grey[300],
|
||||
radius: 18,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2.0),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(50.0),
|
||||
child: Image.asset(
|
||||
'assets/immich-logo-no-outline.png',
|
||||
),
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
buildTitle(album),
|
||||
if (album.assets.isNotEmpty == true) buildAlbumDateRange(album),
|
||||
if (album.shared)
|
||||
SizedBox(
|
||||
height: 50,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemBuilder: ((context, index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: CircleAvatar(
|
||||
backgroundColor: Colors.grey[300],
|
||||
radius: 18,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2.0),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(50.0),
|
||||
child: Image.asset(
|
||||
'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(
|
||||
appBar: album.when(
|
||||
data: (Album? data) {
|
||||
if (data != null) {
|
||||
return AlbumViewerAppbar(
|
||||
album: data,
|
||||
userId: userId,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
error: (e, _) => null,
|
||||
loading: () => null,
|
||||
data: (data) => AlbumViewerAppbar(
|
||||
titleFocusNode: titleFocusNode,
|
||||
album: data,
|
||||
userId: userId,
|
||||
selected: selection.value,
|
||||
selectionDisabled: disableSelection,
|
||||
),
|
||||
error: (error, stackTrace) => AppBar(title: const Text("Error")),
|
||||
loading: () => AppBar(),
|
||||
),
|
||||
body: album.when(
|
||||
data: (albumInfo) => albumInfo != null
|
||||
? buildBody(albumInfo)
|
||||
: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
data: (data) => WillPopScope(
|
||||
onWillPop: onWillPop,
|
||||
child: GestureDetector(
|
||||
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(
|
||||
child: ImmichLoadingIndicator(),
|
||||
),
|
||||
|
@ -4,54 +4,42 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.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/providers/asset_selection.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/asset_grid_by_month.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/month_group_title.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.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/modules/home/ui/draggable_scrollbar.dart';
|
||||
|
||||
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
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ScrollController scrollController = useScrollController();
|
||||
var assetGroupMonthYear = ref.watch(assetGroupByMonthYearProvider);
|
||||
final selectedAssets =
|
||||
ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
|
||||
final newAssetsForAlbum =
|
||||
ref.watch(assetSelectionProvider).selectedAdditionalAssetsForAlbum;
|
||||
final isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist;
|
||||
|
||||
List<Widget> imageGridGroup = [];
|
||||
final renderList = ref.watch(remoteAssetsProvider);
|
||||
final selected = useState<Set<Asset>>(existingAssets);
|
||||
final selectionEnabledHook = useState(true);
|
||||
|
||||
String buildAssetCountText() {
|
||||
if (isAlbumExist) {
|
||||
return (selectedAssets.length + newAssetsForAlbum.length).toString();
|
||||
} else {
|
||||
return selectedAssets.length.toString();
|
||||
}
|
||||
return selected.value.length.toString();
|
||||
}
|
||||
|
||||
Widget buildBody() {
|
||||
assetGroupMonthYear.forEach((monthYear, assetGroup) {
|
||||
imageGridGroup
|
||||
.add(MonthGroupTitle(month: monthYear, assetGroup: assetGroup));
|
||||
imageGridGroup.add(AssetGridByMonth(assetGroup: assetGroup));
|
||||
});
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
DraggableScrollbar.semicircle(
|
||||
backgroundColor: Theme.of(context).hintColor,
|
||||
controller: scrollController,
|
||||
heightScrollThumb: 48.0,
|
||||
child: CustomScrollView(
|
||||
controller: scrollController,
|
||||
slivers: [...imageGridGroup],
|
||||
),
|
||||
),
|
||||
],
|
||||
Widget buildBody(RenderList renderList) {
|
||||
return ImmichAssetGrid(
|
||||
renderList: renderList,
|
||||
listener: (active, assets) {
|
||||
selectionEnabledHook.value = active;
|
||||
selected.value = assets;
|
||||
},
|
||||
selectionActive: true,
|
||||
preselectedAssets: isNewAlbum ? selected.value : existingAssets,
|
||||
canDeselect: isNewAlbum,
|
||||
showMultiSelectIndicator: false,
|
||||
);
|
||||
}
|
||||
|
||||
@ -61,11 +49,10 @@ class AssetSelectionPage extends HookConsumerWidget {
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
onPressed: () {
|
||||
ref.watch(assetSelectionProvider.notifier).removeAll();
|
||||
AutoRouter.of(context).pop(null);
|
||||
AutoRouter.of(context).popForced(null);
|
||||
},
|
||||
),
|
||||
title: selectedAssets.isEmpty
|
||||
title: selected.value.isEmpty
|
||||
? const Text(
|
||||
'share_add_photos',
|
||||
style: TextStyle(fontSize: 18),
|
||||
@ -76,16 +63,13 @@ class AssetSelectionPage extends HookConsumerWidget {
|
||||
),
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
if ((!isAlbumExist && selectedAssets.isNotEmpty) ||
|
||||
(isAlbumExist && newAssetsForAlbum.isNotEmpty))
|
||||
if (selected.value.isNotEmpty)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
var payload = AssetSelectionPageResult(
|
||||
isAlbumExist: isAlbumExist,
|
||||
selectedAdditionalAsset: newAssetsForAlbum,
|
||||
selectedNewAsset: selectedAssets,
|
||||
);
|
||||
AutoRouter.of(context).pop(payload);
|
||||
var payload =
|
||||
AssetSelectionPageResult(selectedAssets: selected.value);
|
||||
AutoRouter.of(context)
|
||||
.popForced<AssetSelectionPageResult>(payload);
|
||||
},
|
||||
child: const Text(
|
||||
"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()),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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/providers/album.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_title_text_field.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 isAlbumTitleTextFieldFocus = useState(false);
|
||||
final isAlbumTitleEmpty = useState(true);
|
||||
final selectedAssets =
|
||||
ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
|
||||
final selectedAssets = useState<Set<Asset>>(const {});
|
||||
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
showSelectUserPage() {
|
||||
AutoRouter.of(context).push(const SelectUserForSharingRoute());
|
||||
showSelectUserPage() async {
|
||||
final bool? ok = await AutoRouter.of(context)
|
||||
.push<bool?>(SelectUserForSharingRoute(assets: selectedAssets.value));
|
||||
if (ok == true) {
|
||||
selectedAssets.value = {};
|
||||
}
|
||||
}
|
||||
|
||||
void onBackgroundTapped() {
|
||||
@ -52,13 +54,17 @@ class CreateAlbumPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
onSelectPhotosButtonPressed() async {
|
||||
ref.watch(assetSelectionProvider.notifier).setIsAlbumExist(false);
|
||||
|
||||
AssetSelectionPageResult? selectedAsset = await AutoRouter.of(context)
|
||||
.push<AssetSelectionPageResult?>(const AssetSelectionRoute());
|
||||
|
||||
AssetSelectionPageResult? selectedAsset =
|
||||
await AutoRouter.of(context).push<AssetSelectionPageResult?>(
|
||||
AssetSelectionRoute(
|
||||
existingAssets: selectedAssets.value,
|
||||
isNewAlbum: true,
|
||||
),
|
||||
);
|
||||
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() {
|
||||
if (selectedAssets.isEmpty) {
|
||||
if (selectedAssets.value.isEmpty) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 200, left: 18),
|
||||
@ -97,7 +103,7 @@ class CreateAlbumPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
buildSelectPhotosButton() {
|
||||
if (selectedAssets.isEmpty) {
|
||||
if (selectedAssets.value.isEmpty) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 16, left: 18, right: 18),
|
||||
@ -158,7 +164,7 @@ class CreateAlbumPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
buildSelectedImageGrid() {
|
||||
if (selectedAssets.isNotEmpty) {
|
||||
if (selectedAssets.value.isNotEmpty) {
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
sliver: SliverGrid(
|
||||
@ -172,11 +178,11 @@ class CreateAlbumPage extends HookConsumerWidget {
|
||||
return GestureDetector(
|
||||
onTap: onBackgroundTapped,
|
||||
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 {
|
||||
var newAlbum = await ref.watch(albumProvider.notifier).createAlbum(
|
||||
ref.watch(albumTitleProvider),
|
||||
ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum,
|
||||
selectedAssets.value,
|
||||
);
|
||||
|
||||
if (newAlbum != null) {
|
||||
ref.watch(albumProvider.notifier).getAllAlbums();
|
||||
ref.watch(assetSelectionProvider.notifier).removeAll();
|
||||
selectedAssets.value = {};
|
||||
ref.watch(albumTitleProvider.notifier).clearAlbumTitle();
|
||||
|
||||
AutoRouter.of(context).replace(AlbumViewerRoute(albumId: newAlbum.id));
|
||||
@ -207,7 +213,7 @@ class CreateAlbumPage extends HookConsumerWidget {
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
leading: IconButton(
|
||||
onPressed: () {
|
||||
ref.watch(assetSelectionProvider.notifier).removeAll();
|
||||
selectedAssets.value = {};
|
||||
AutoRouter.of(context).pop();
|
||||
},
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
@ -237,7 +243,7 @@ class CreateAlbumPage extends HookConsumerWidget {
|
||||
if (!isSharedAlbum)
|
||||
TextButton(
|
||||
onPressed: albumTitleController.text.isNotEmpty &&
|
||||
selectedAssets.isNotEmpty
|
||||
selectedAssets.value.isNotEmpty
|
||||
? createNonSharedAlbum
|
||||
: null,
|
||||
child: Text(
|
||||
@ -264,7 +270,7 @@ class CreateAlbumPage extends HookConsumerWidget {
|
||||
child: Column(
|
||||
children: [
|
||||
buildTitleInputField(),
|
||||
if (selectedAssets.isNotEmpty) buildControlButton(),
|
||||
if (selectedAssets.value.isNotEmpty) buildControlButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
@ -4,15 +4,18 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.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/asset_selection.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/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/models/user.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
|
||||
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
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@ -24,15 +27,15 @@ class SelectUserForSharingPage extends HookConsumerWidget {
|
||||
var newAlbum =
|
||||
await ref.watch(sharedAlbumProvider.notifier).createSharedAlbum(
|
||||
ref.watch(albumTitleProvider),
|
||||
ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum,
|
||||
assets,
|
||||
sharedUsersList.value,
|
||||
);
|
||||
|
||||
if (newAlbum != null) {
|
||||
await ref.watch(sharedAlbumProvider.notifier).getAllSharedAlbums();
|
||||
ref.watch(assetSelectionProvider.notifier).removeAll();
|
||||
// ref.watch(assetSelectionProvider.notifier).removeAll();
|
||||
ref.watch(albumTitleProvider.notifier).clearAlbumTitle();
|
||||
|
||||
AutoRouter.of(context).pop(true);
|
||||
AutoRouter.of(context)
|
||||
.navigate(const TabControllerRoute(children: [SharingRoute()]));
|
||||
}
|
||||
|
@ -1,55 +1,25 @@
|
||||
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/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 ArchiveSelectionNotifier extends StateNotifier<Set<int>> {
|
||||
ArchiveSelectionNotifier(this.db, this.assetNotifier) : super({}) {
|
||||
state = db.assets
|
||||
.filter()
|
||||
.isArchivedEqualTo(true)
|
||||
.findAllSync()
|
||||
.map((e) => e.id)
|
||||
.toSet();
|
||||
final archiveProvider = StreamProvider<RenderList>((ref) async* {
|
||||
final query = ref
|
||||
.watch(dbProvider)
|
||||
.assets
|
||||
.filter()
|
||||
.ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId)
|
||||
.isArchivedEqualTo(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 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),
|
||||
);
|
||||
});
|
||||
|
@ -1,46 +1,25 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.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: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/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/db.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
class ArchivePage extends HookConsumerWidget {
|
||||
const ArchivePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final User me = Store.get(StoreKey.currentUser);
|
||||
final query = ref
|
||||
.watch(dbProvider)
|
||||
.assets
|
||||
.filter()
|
||||
.ownerIdEqualTo(me.isarId)
|
||||
.isArchivedEqualTo(true);
|
||||
final stream = query.watch();
|
||||
final archivedAssets = useState<List<Asset>>([]);
|
||||
final archivedAssets = ref.watch(archiveProvider);
|
||||
final selectionEnabledHook = useState(false);
|
||||
final selection = useState(<Asset>{});
|
||||
|
||||
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;
|
||||
},
|
||||
[],
|
||||
);
|
||||
final processing = useState(false);
|
||||
|
||||
void selectionListener(
|
||||
bool multiselect,
|
||||
@ -50,7 +29,7 @@ class ArchivePage extends HookConsumerWidget {
|
||||
selection.value = selectedAssets;
|
||||
}
|
||||
|
||||
AppBar buildAppBar() {
|
||||
AppBar buildAppBar(String count) {
|
||||
return AppBar(
|
||||
leading: IconButton(
|
||||
onPressed: () => AutoRouter.of(context).pop(),
|
||||
@ -60,7 +39,7 @@ class ArchivePage extends HookConsumerWidget {
|
||||
automaticallyImplyLeading: false,
|
||||
title: const Text(
|
||||
'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(),
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
onTap: () {
|
||||
if (selection.value.isNotEmpty) {
|
||||
ref
|
||||
.watch(assetProvider.notifier)
|
||||
.toggleArchive(selection.value, false);
|
||||
onTap: processing.value
|
||||
? null
|
||||
: () async {
|
||||
processing.value = true;
|
||||
try {
|
||||
if (selection.value.isNotEmpty) {
|
||||
await ref
|
||||
.watch(assetProvider.notifier)
|
||||
.toggleArchive(
|
||||
selection.value.toList(),
|
||||
false,
|
||||
);
|
||||
|
||||
final assetOrAssets =
|
||||
selection.value.length > 1 ? 'assets' : 'asset';
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg:
|
||||
'Moved ${selection.value.length} $assetOrAssets to library',
|
||||
gravity: ToastGravity.CENTER,
|
||||
);
|
||||
}
|
||||
|
||||
selectionEnabledHook.value = false;
|
||||
},
|
||||
final assetOrAssets = selection.value.length > 1
|
||||
? 'assets'
|
||||
: 'asset';
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg:
|
||||
'Moved ${selection.value.length} $assetOrAssets to library',
|
||||
gravity: ToastGravity.CENTER,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
processing.value = false;
|
||||
selectionEnabledHook.value = false;
|
||||
}
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
@ -111,22 +100,34 @@ class ArchivePage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: buildAppBar(),
|
||||
body: archivedAssets.value.isEmpty
|
||||
? Center(
|
||||
child: Text('archive_page_no_archived_assets'.tr()),
|
||||
)
|
||||
: Stack(
|
||||
children: [
|
||||
ImmichAssetGrid(
|
||||
assets: archivedAssets.value,
|
||||
listener: selectionListener,
|
||||
selectionActive: selectionEnabledHook.value,
|
||||
),
|
||||
if (selectionEnabledHook.value) buildBottomBar()
|
||||
],
|
||||
),
|
||||
return archivedAssets.when(
|
||||
loading: () => Scaffold(
|
||||
appBar: buildAppBar("?"),
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (error, stackTrace) => Scaffold(
|
||||
appBar: buildAppBar("Error"),
|
||||
body: Center(child: Text(error.toString())),
|
||||
),
|
||||
data: (data) => Scaffold(
|
||||
appBar: buildAppBar(data.totalAssets.toString()),
|
||||
body: data.isEmpty
|
||||
? Center(
|
||||
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())
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +0,0 @@
|
||||
class RequestDownloadAssetInfo {
|
||||
final String assetId;
|
||||
final String deviceId;
|
||||
|
||||
RequestDownloadAssetInfo(this.assetId, this.deviceId);
|
||||
}
|
@ -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/shared/models/asset.dart';
|
||||
|
||||
final renderListProvider = FutureProvider.family<RenderList, List<Asset>>((ref, assets) {
|
||||
var settings = ref.watch(appSettingsServiceProvider);
|
||||
final renderListProvider =
|
||||
FutureProvider.family<RenderList, List<Asset>>((ref, assets) {
|
||||
final settings = ref.watch(appSettingsServiceProvider);
|
||||
|
||||
final layout = AssetGridLayoutParameters(
|
||||
settings.getSetting(AppSettingsEnum.tilesPerRow),
|
||||
settings.getSetting(AppSettingsEnum.dynamicLayout),
|
||||
return RenderList.fromAssets(
|
||||
assets,
|
||||
GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)],
|
||||
);
|
||||
|
||||
return RenderList.fromAssets(assets, layout);
|
||||
});
|
||||
|
@ -21,7 +21,7 @@ class TopControlAppBar extends HookConsumerWidget {
|
||||
final VoidCallback? onDownloadPressed;
|
||||
final VoidCallback onToggleMotionVideo;
|
||||
final VoidCallback onAddToAlbumPressed;
|
||||
final VoidCallback onFavorite;
|
||||
final VoidCallback? onFavorite;
|
||||
final bool isPlayingMotionVideo;
|
||||
final bool isFavorite;
|
||||
|
||||
@ -31,9 +31,7 @@ class TopControlAppBar extends HookConsumerWidget {
|
||||
|
||||
Widget buildFavoriteButton() {
|
||||
return IconButton(
|
||||
onPressed: () {
|
||||
onFavorite();
|
||||
},
|
||||
onPressed: onFavorite,
|
||||
icon: Icon(
|
||||
isFavorite ? Icons.favorite : Icons.favorite_border,
|
||||
color: Colors.grey[200],
|
||||
|
@ -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/top_control_app_bar.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/services/asset.service.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/services/app_settings.service.dart';
|
||||
@ -32,16 +30,16 @@ import 'package:openapi/api.dart' as api;
|
||||
|
||||
// ignore: must_be_immutable
|
||||
class GalleryViewerPage extends HookConsumerWidget {
|
||||
final List<Asset> assetList;
|
||||
final Asset asset;
|
||||
final Asset Function(int index) loadAsset;
|
||||
final int totalAssets;
|
||||
final int initialIndex;
|
||||
|
||||
GalleryViewerPage({
|
||||
super.key,
|
||||
required this.assetList,
|
||||
required this.asset,
|
||||
}) : controller = PageController(initialPage: assetList.indexOf(asset));
|
||||
|
||||
Asset? assetDetail;
|
||||
required this.initialIndex,
|
||||
required this.loadAsset,
|
||||
required this.totalAssets,
|
||||
}) : controller = PageController(initialPage: initialIndex);
|
||||
|
||||
final PageController controller;
|
||||
|
||||
@ -52,11 +50,15 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
final isLoadOriginal = useState(AppSettingsEnum.loadOriginal.defaultValue);
|
||||
final isZoomed = useState<bool>(false);
|
||||
final showAppBar = useState<bool>(true);
|
||||
final indexOfAsset = useState(assetList.indexOf(asset));
|
||||
final isPlayingMotionVideo = useState(false);
|
||||
final isPlayingVideo = useState(false);
|
||||
late Offset localPosition;
|
||||
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(() {
|
||||
// Change to and from immersive mode, hiding navigation and app bar
|
||||
@ -79,16 +81,9 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
[],
|
||||
);
|
||||
|
||||
void toggleFavorite(Asset asset) {
|
||||
ref.watch(favoriteProvider.notifier).toggleFavorite(asset);
|
||||
}
|
||||
|
||||
void getAssetExif() async {
|
||||
assetDetail = assetList[indexOfAsset.value];
|
||||
assetDetail = await ref
|
||||
.watch(assetServiceProvider)
|
||||
.loadExif(assetList[indexOfAsset.value]);
|
||||
}
|
||||
void toggleFavorite(Asset asset) => ref
|
||||
.watch(assetProvider.notifier)
|
||||
.toggleFavorite([asset], !asset.isFavorite);
|
||||
|
||||
/// Thumbnail image of a remote asset. Required asset.isRemote
|
||||
ImageProvider remoteThumbnailImageProvider(
|
||||
@ -138,8 +133,8 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
void precacheNextImage(int index) {
|
||||
if (index < assetList.length && index >= 0) {
|
||||
final asset = assetList[index];
|
||||
if (index < totalAssets && index >= 0) {
|
||||
final asset = loadAsset(index);
|
||||
|
||||
if (asset.isLocal) {
|
||||
// Preload the local asset
|
||||
@ -193,13 +188,13 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
if (ref
|
||||
.watch(appSettingsServiceProvider)
|
||||
.getSetting<bool>(AppSettingsEnum.advancedTroubleshooting)) {
|
||||
return AdvancedBottomSheet(assetDetail: assetDetail!);
|
||||
return AdvancedBottomSheet(assetDetail: asset());
|
||||
}
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: ExifBottomSheet(asset: assetDetail!),
|
||||
child: ExifBottomSheet(asset: asset()),
|
||||
);
|
||||
},
|
||||
);
|
||||
@ -211,7 +206,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
builder: (BuildContext _) {
|
||||
return DeleteDialog(
|
||||
onDelete: () {
|
||||
if (assetList.length == 1) {
|
||||
if (totalAssets == 1) {
|
||||
// Handle only one asset
|
||||
AutoRouter.of(context).pop();
|
||||
} else {
|
||||
@ -221,7 +216,6 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
curve: Curves.fastLinearToSlowEaseIn,
|
||||
);
|
||||
}
|
||||
assetList.remove(deleteAsset);
|
||||
ref.watch(assetProvider.notifier).deleteAssets({deleteAsset});
|
||||
},
|
||||
);
|
||||
@ -267,9 +261,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
shareAsset() {
|
||||
ref
|
||||
.watch(imageViewerStateProvider.notifier)
|
||||
.shareAsset(assetList[indexOfAsset.value], context);
|
||||
ref.watch(imageViewerStateProvider.notifier).shareAsset(asset(), context);
|
||||
}
|
||||
|
||||
handleArchive(Asset asset) {
|
||||
@ -291,30 +283,21 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
color: Colors.black.withOpacity(0.4),
|
||||
child: TopControlAppBar(
|
||||
isPlayingMotionVideo: isPlayingMotionVideo.value,
|
||||
asset: assetList[indexOfAsset.value],
|
||||
isFavorite: ref.watch(favoriteProvider).contains(
|
||||
assetList[indexOfAsset.value].id,
|
||||
),
|
||||
onMoreInfoPressed: () {
|
||||
showInfo();
|
||||
},
|
||||
onFavorite: () {
|
||||
toggleFavorite(assetList[indexOfAsset.value]);
|
||||
},
|
||||
onDownloadPressed: assetList[indexOfAsset.value].storage ==
|
||||
AssetState.local
|
||||
asset: asset(),
|
||||
isFavorite: asset().isFavorite,
|
||||
onMoreInfoPressed: showInfo,
|
||||
onFavorite: asset().isRemote ? () => toggleFavorite(asset()) : null,
|
||||
onDownloadPressed: asset().storage == AssetState.local
|
||||
? null
|
||||
: () {
|
||||
: () =>
|
||||
ref.watch(imageViewerStateProvider.notifier).downloadAsset(
|
||||
assetList[indexOfAsset.value],
|
||||
asset(),
|
||||
context,
|
||||
);
|
||||
},
|
||||
),
|
||||
onToggleMotionVideo: (() {
|
||||
isPlayingMotionVideo.value = !isPlayingMotionVideo.value;
|
||||
}),
|
||||
onAddToAlbumPressed: () =>
|
||||
addToAlbum(assetList[indexOfAsset.value]),
|
||||
onAddToAlbumPressed: () => addToAlbum(asset()),
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -324,8 +307,6 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
final show = (showAppBar.value || // onTap has the final say
|
||||
(showAppBar.value && !isZoomed.value)) &&
|
||||
!isPlayingVideo.value;
|
||||
final currentAsset = assetList[indexOfAsset.value];
|
||||
|
||||
return AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 100),
|
||||
opacity: show ? 1.0 : 0.0,
|
||||
@ -343,7 +324,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
label: 'control_bottom_app_bar_share'.tr(),
|
||||
tooltip: 'control_bottom_app_bar_share'.tr(),
|
||||
),
|
||||
currentAsset.isArchived
|
||||
asset().isArchived
|
||||
? BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.unarchive_rounded),
|
||||
label: 'control_bottom_app_bar_unarchive'.tr(),
|
||||
@ -366,10 +347,10 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
shareAsset();
|
||||
break;
|
||||
case 1:
|
||||
handleArchive(assetList[indexOfAsset.value]);
|
||||
handleArchive(asset());
|
||||
break;
|
||||
case 2:
|
||||
handleDelete(assetList[indexOfAsset.value]);
|
||||
handleDelete(asset());
|
||||
break;
|
||||
}
|
||||
},
|
||||
@ -399,33 +380,33 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
? const ScrollPhysics() // Use bouncing physics for iOS
|
||||
: const ClampingScrollPhysics() // Use heavy physics for Android
|
||||
),
|
||||
itemCount: assetList.length,
|
||||
itemCount: totalAssets,
|
||||
scrollDirection: Axis.horizontal,
|
||||
onPageChanged: (value) {
|
||||
// Precache image
|
||||
if (indexOfAsset.value < value) {
|
||||
if (currentIndex.value < value) {
|
||||
// Moving forwards, so precache the next asset
|
||||
precacheNextImage(value + 1);
|
||||
} else {
|
||||
// Moving backwards, so precache previous asset
|
||||
precacheNextImage(value - 1);
|
||||
}
|
||||
indexOfAsset.value = value;
|
||||
currentIndex.value = value;
|
||||
HapticFeedback.selectionClick();
|
||||
},
|
||||
loadingBuilder: isLoadPreview.value
|
||||
? (context, event) {
|
||||
final asset = assetList[indexOfAsset.value];
|
||||
if (!asset.isLocal) {
|
||||
final a = asset();
|
||||
if (!a.isLocal) {
|
||||
// Use the WEBP Thumbnail as a placeholder for the JPEG thumbnail to achieve
|
||||
// Three-Stage Loading (WEBP -> JPEG -> Original)
|
||||
final webPThumbnail = CachedNetworkImage(
|
||||
imageUrl: getThumbnailUrl(
|
||||
asset,
|
||||
a,
|
||||
type: api.ThumbnailFormat.WEBP,
|
||||
),
|
||||
cacheKey: getThumbnailCacheKey(
|
||||
asset,
|
||||
a,
|
||||
type: api.ThumbnailFormat.WEBP,
|
||||
),
|
||||
httpHeaders: {'Authorization': authToken},
|
||||
@ -444,11 +425,11 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
// makes sense if the original is loaded in the builder
|
||||
return CachedNetworkImage(
|
||||
imageUrl: getThumbnailUrl(
|
||||
asset,
|
||||
a,
|
||||
type: api.ThumbnailFormat.JPEG,
|
||||
),
|
||||
cacheKey: getThumbnailCacheKey(
|
||||
asset,
|
||||
a,
|
||||
type: api.ThumbnailFormat.JPEG,
|
||||
),
|
||||
httpHeaders: {'Authorization': authToken},
|
||||
@ -462,30 +443,30 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
}
|
||||
} else {
|
||||
return Image(
|
||||
image: localThumbnailImageProvider(asset),
|
||||
image: localThumbnailImageProvider(a),
|
||||
fit: BoxFit.contain,
|
||||
);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
builder: (context, index) {
|
||||
getAssetExif();
|
||||
if (assetList[index].isImage && !isPlayingMotionVideo.value) {
|
||||
final asset = loadAsset(index);
|
||||
if (asset.isImage && !isPlayingMotionVideo.value) {
|
||||
// Show photo
|
||||
final ImageProvider provider;
|
||||
if (assetList[index].isLocal) {
|
||||
provider = localImageProvider(assetList[index]);
|
||||
if (asset.isLocal) {
|
||||
provider = localImageProvider(asset);
|
||||
} else {
|
||||
if (isLoadOriginal.value) {
|
||||
provider = originalImageProvider(assetList[index]);
|
||||
provider = originalImageProvider(asset);
|
||||
} else if (isLoadPreview.value) {
|
||||
provider = remoteThumbnailImageProvider(
|
||||
assetList[index],
|
||||
asset,
|
||||
api.ThumbnailFormat.JPEG,
|
||||
);
|
||||
} else {
|
||||
provider = remoteThumbnailImageProvider(
|
||||
assetList[index],
|
||||
asset,
|
||||
api.ThumbnailFormat.WEBP,
|
||||
);
|
||||
}
|
||||
@ -499,13 +480,13 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
showAppBar.value = !showAppBar.value,
|
||||
imageProvider: provider,
|
||||
heroAttributes: PhotoViewHeroAttributes(
|
||||
tag: assetList[index].id,
|
||||
tag: asset.id,
|
||||
),
|
||||
filterQuality: FilterQuality.high,
|
||||
tightMode: true,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
errorBuilder: (context, error, stackTrace) => ImmichImage(
|
||||
assetList[indexOfAsset.value],
|
||||
asset,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
);
|
||||
@ -516,7 +497,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
onDragUpdate: (_, details, __) =>
|
||||
handleSwipeUpDown(details),
|
||||
heroAttributes: PhotoViewHeroAttributes(
|
||||
tag: assetList[index].id,
|
||||
tag: asset.id,
|
||||
),
|
||||
filterQuality: FilterQuality.high,
|
||||
maxScale: 1.0,
|
||||
@ -526,7 +507,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
child: VideoViewerPage(
|
||||
onPlaying: () => isPlayingVideo.value = true,
|
||||
onPaused: () => isPlayingVideo.value = false,
|
||||
asset: assetList[index],
|
||||
asset: asset,
|
||||
isMotionVideo: isPlayingMotionVideo.value,
|
||||
onVideoEnded: () {
|
||||
if (isPlayingMotionVideo.value) {
|
||||
|
@ -1,68 +1,25 @@
|
||||
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/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>> {
|
||||
FavoriteSelectionNotifier(this.assetsState, this.assetNotifier) : super({}) {
|
||||
state = assetsState.allAssets
|
||||
.where((asset) => asset.isFavorite)
|
||||
.map((asset) => asset.id)
|
||||
.toSet();
|
||||
final favoriteAssetsProvider = StreamProvider<RenderList>((ref) async* {
|
||||
final query = ref
|
||||
.watch(dbProvider)
|
||||
.assets
|
||||
.filter()
|
||||
.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();
|
||||
});
|
||||
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -1,15 +1,32 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.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: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/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 {
|
||||
const FavoritesPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
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() {
|
||||
return AppBar(
|
||||
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(
|
||||
appBar: buildAppBar(),
|
||||
body: ref.watch(favoriteAssetProvider).isEmpty
|
||||
? Center(
|
||||
child: Text('favorites_page_no_favorites'.tr()),
|
||||
)
|
||||
: ImmichAssetGrid(
|
||||
assets: ref.watch(favoriteAssetProvider),
|
||||
),
|
||||
body: ref.watch(favoriteAssetsProvider).when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, stackTrace) => Center(child: Text(error.toString())),
|
||||
data: (data) => data.isEmpty
|
||||
? Center(
|
||||
child: Text('favorites_page_no_favorites'.tr()),
|
||||
)
|
||||
: Stack(
|
||||
children: [
|
||||
ImmichAssetGrid(
|
||||
renderList: data,
|
||||
selectionActive: selectionEnabledHook.value,
|
||||
listener: selectionListener,
|
||||
),
|
||||
if (selectionEnabledHook.value) buildBottomBar()
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -2,212 +2,313 @@ import 'dart:math';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
final log = Logger('AssetGridDataStructure');
|
||||
|
||||
enum RenderAssetGridElementType {
|
||||
assets,
|
||||
assetRow,
|
||||
groupDividerTitle,
|
||||
monthTitle;
|
||||
}
|
||||
|
||||
class RenderAssetGridRow {
|
||||
final List<Asset> assets;
|
||||
final List<double> widthDistribution;
|
||||
|
||||
RenderAssetGridRow(this.assets, this.widthDistribution);
|
||||
}
|
||||
|
||||
class RenderAssetGridElement {
|
||||
final RenderAssetGridElementType type;
|
||||
final RenderAssetGridRow? assetRow;
|
||||
final String? title;
|
||||
final DateTime date;
|
||||
final List<Asset>? relatedAssetList;
|
||||
final int count;
|
||||
final int offset;
|
||||
final int totalCount;
|
||||
|
||||
RenderAssetGridElement(
|
||||
this.type, {
|
||||
this.assetRow,
|
||||
this.title,
|
||||
required this.date,
|
||||
this.relatedAssetList,
|
||||
this.count = 0,
|
||||
this.offset = 0,
|
||||
this.totalCount = 0,
|
||||
});
|
||||
}
|
||||
|
||||
enum GroupAssetsBy {
|
||||
day,
|
||||
month;
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
month,
|
||||
auto,
|
||||
none,
|
||||
;
|
||||
}
|
||||
|
||||
class RenderList {
|
||||
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(
|
||||
List<Asset> assets,
|
||||
GroupAssetsBy groupBy,
|
||||
) {
|
||||
if (groupBy == GroupAssetsBy.day) {
|
||||
return assets.groupListsBy(
|
||||
(element) {
|
||||
final date = element.fileCreatedAt.toLocal();
|
||||
return DateTime(date.year, date.month, date.day);
|
||||
},
|
||||
);
|
||||
} else if (groupBy == GroupAssetsBy.month) {
|
||||
return assets.groupListsBy(
|
||||
(element) {
|
||||
final date = element.fileCreatedAt.toLocal();
|
||||
return DateTime(date.year, date.month);
|
||||
},
|
||||
);
|
||||
/// global offset of assets in [_buf]
|
||||
int _bufOffset = 0;
|
||||
|
||||
RenderList(this.elements, this.query, this.allAssets)
|
||||
: totalAssets = allAssets?.length ?? query!.countSync();
|
||||
|
||||
bool get isEmpty => totalAssets == 0;
|
||||
|
||||
/// 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) {
|
||||
assert(offset >= 0);
|
||||
assert(count > 0);
|
||||
assert(offset + count <= totalAssets);
|
||||
if (allAssets != null) {
|
||||
// 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);
|
||||
}
|
||||
|
||||
return {};
|
||||
throw Exception("RenderList has neither assets nor query");
|
||||
}
|
||||
|
||||
static Future<RenderList> _processAssetGroupData(
|
||||
_AssetGroupsToRenderListComputeParameters data,
|
||||
/// Returns the requested asset either from cached buffer or directly from the database
|
||||
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 {
|
||||
// TODO: Make DateFormat use the configured locale.
|
||||
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;
|
||||
final List<RenderAssetGridElement> elements = [];
|
||||
|
||||
List<RenderAssetGridElement> elements = [];
|
||||
DateTime? lastDate;
|
||||
|
||||
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;
|
||||
}
|
||||
const pageSize = 500;
|
||||
const sectionSize = 60; // divides evenly by 2,3,4,5,6
|
||||
|
||||
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(
|
||||
RenderAssetGridElement(
|
||||
RenderAssetGridElementType.groupDividerTitle,
|
||||
title: formatDate.format(date),
|
||||
RenderAssetGridElementType.assets,
|
||||
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(
|
||||
List<Asset> assets,
|
||||
AssetGridLayoutParameters layout,
|
||||
) async {
|
||||
// Compute only allows for one parameter. Therefore we pass all parameters in a map
|
||||
return compute(
|
||||
_processAssetGroupData,
|
||||
_AssetGroupsToRenderListComputeParameters(
|
||||
assets,
|
||||
layout,
|
||||
),
|
||||
);
|
||||
}
|
||||
GroupAssetsBy groupBy,
|
||||
) =>
|
||||
_buildRenderList(assets, null, groupBy);
|
||||
}
|
||||
|
@ -396,8 +396,8 @@ class DraggableScrollbarState extends State<DraggableScrollbar>
|
||||
widget.scrollStateListener(true);
|
||||
|
||||
dragHaltTimer = Timer(
|
||||
const Duration(milliseconds: 200),
|
||||
() {
|
||||
const Duration(milliseconds: 500),
|
||||
() {
|
||||
widget.scrollStateListener(false);
|
||||
},
|
||||
);
|
||||
|
@ -19,8 +19,6 @@ class GroupDividerTitle extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
|
||||
|
||||
void handleTitleIconClick() {
|
||||
if (selected) {
|
||||
onDeselect();
|
||||
@ -32,7 +30,7 @@ class GroupDividerTitle extends ConsumerWidget {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 29.0,
|
||||
bottom: 29.0,
|
||||
bottom: 10.0,
|
||||
left: 12.0,
|
||||
right: 12.0,
|
||||
),
|
||||
|
@ -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/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
|
||||
class ImmichAssetGrid extends HookConsumerWidget {
|
||||
final int? assetsPerRow;
|
||||
@ -15,13 +16,19 @@ class ImmichAssetGrid extends HookConsumerWidget {
|
||||
final bool? showStorageIndicator;
|
||||
final ImmichAssetGridSelectionListener? listener;
|
||||
final bool selectionActive;
|
||||
final List<Asset> assets;
|
||||
final List<Asset>? assets;
|
||||
final RenderList? renderList;
|
||||
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({
|
||||
super.key,
|
||||
required this.assets,
|
||||
this.assets,
|
||||
this.onRefresh,
|
||||
this.renderList,
|
||||
this.assetsPerRow,
|
||||
@ -29,12 +36,16 @@ class ImmichAssetGrid extends HookConsumerWidget {
|
||||
this.listener,
|
||||
this.margin = 5.0,
|
||||
this.selectionActive = false,
|
||||
this.preselectedAssets,
|
||||
this.canDeselect = true,
|
||||
this.dynamicLayout,
|
||||
this.showMultiSelectIndicator = true,
|
||||
this.visibleItemsListener,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
var settings = ref.watch(appSettingsServiceProvider);
|
||||
final renderListFuture = ref.watch(renderListProvider(assets));
|
||||
|
||||
// Needs to suppress hero animations when navigating to this widget
|
||||
final enableHeroAnimations = useState(false);
|
||||
@ -64,34 +75,12 @@ class ImmichAssetGrid extends HookConsumerWidget {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (renderList != null) {
|
||||
Widget buildAssetGridView(RenderList renderList) {
|
||||
return WillPopScope(
|
||||
onWillPop: onWillPop,
|
||||
child: HeroMode(
|
||||
enabled: enableHeroAnimations.value,
|
||||
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,
|
||||
assetsPerRow: assetsPerRow ??
|
||||
settings.getSetting(AppSettingsEnum.tilesPerRow),
|
||||
@ -101,9 +90,22 @@ class ImmichAssetGrid extends HookConsumerWidget {
|
||||
renderList: renderList,
|
||||
margin: margin,
|
||||
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")),
|
||||
loading: () => const Center(
|
||||
child: ImmichLoadingIndicator(),
|
||||
|
@ -1,4 +1,5 @@
|
||||
import 'dart:collection';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:collection/collection.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/home/ui/asset_grid/thumbnail_image.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 'asset_grid_data_structure.dart';
|
||||
import 'group_divider_title.dart';
|
||||
@ -23,13 +25,11 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
||||
ItemPositionsListener.create();
|
||||
|
||||
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() {
|
||||
return _selectedAssets
|
||||
.map((e) => widget.allAssets.firstWhereOrNull((a) => a.id == e))
|
||||
.whereNotNull()
|
||||
.toSet();
|
||||
return Set.from(_selectedAssets);
|
||||
}
|
||||
|
||||
void _callSelectionListener(bool selectionActive) {
|
||||
@ -38,18 +38,14 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
||||
|
||||
void _selectAssets(List<Asset> assets) {
|
||||
setState(() {
|
||||
for (var e in assets) {
|
||||
_selectedAssets.add(e.id);
|
||||
}
|
||||
_selectedAssets.addAll(assets);
|
||||
_callSelectionListener(true);
|
||||
});
|
||||
}
|
||||
|
||||
void _deselectAssets(List<Asset> assets) {
|
||||
setState(() {
|
||||
for (var e in assets) {
|
||||
_selectedAssets.remove(e.id);
|
||||
}
|
||||
_selectedAssets.removeAll(assets);
|
||||
_callSelectionListener(_selectedAssets.isNotEmpty);
|
||||
});
|
||||
}
|
||||
@ -57,64 +53,86 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
||||
void _deselectAll() {
|
||||
setState(() {
|
||||
_selectedAssets.clear();
|
||||
if (!widget.canDeselect &&
|
||||
widget.preselectedAssets != null &&
|
||||
widget.preselectedAssets!.isNotEmpty) {
|
||||
_selectedAssets.addAll(widget.preselectedAssets!);
|
||||
}
|
||||
_callSelectionListener(false);
|
||||
});
|
||||
|
||||
_callSelectionListener(false);
|
||||
}
|
||||
|
||||
bool _allAssetsSelected(List<Asset> assets) {
|
||||
return widget.selectionActive &&
|
||||
assets.firstWhereOrNull((e) => !_selectedAssets.contains(e.id)) == null;
|
||||
assets.firstWhereOrNull((e) => !_selectedAssets.contains(e)) == null;
|
||||
}
|
||||
|
||||
Widget _buildThumbnailOrPlaceholder(
|
||||
Asset asset,
|
||||
bool placeholder,
|
||||
) {
|
||||
if (placeholder) {
|
||||
return const DecoratedBox(
|
||||
decoration: BoxDecoration(color: Colors.grey),
|
||||
);
|
||||
}
|
||||
Widget _buildThumbnailOrPlaceholder(Asset asset, int index) {
|
||||
return ThumbnailImage(
|
||||
asset: asset,
|
||||
assetList: widget.allAssets,
|
||||
index: index,
|
||||
loadAsset: widget.renderList.loadAsset,
|
||||
totalAssets: widget.renderList.totalAssets,
|
||||
multiselectEnabled: widget.selectionActive,
|
||||
isSelected: widget.selectionActive && _selectedAssets.contains(asset.id),
|
||||
isSelected: widget.selectionActive && _selectedAssets.contains(asset),
|
||||
onSelect: () => _selectAssets([asset]),
|
||||
onDeselect: () => _deselectAssets([asset]),
|
||||
onDeselect: widget.canDeselect ||
|
||||
widget.preselectedAssets == null ||
|
||||
!widget.preselectedAssets!.contains(asset)
|
||||
? () => _deselectAssets([asset])
|
||||
: null,
|
||||
useGrayBoxPlaceholder: true,
|
||||
showStorageIndicator: widget.showStorageIndicator,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAssetRow(
|
||||
Key key,
|
||||
BuildContext context,
|
||||
RenderAssetGridRow row,
|
||||
bool scrolling,
|
||||
List<Asset> assets,
|
||||
int absoluteOffset,
|
||||
double width,
|
||||
) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
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;
|
||||
// Default: All assets have the same width
|
||||
final widthDistribution = List.filled(assets.length, 1.0);
|
||||
|
||||
return Container(
|
||||
key: Key("asset-${asset.id}"),
|
||||
width: size * row.widthDistribution[index],
|
||||
height: size,
|
||||
margin: EdgeInsets.only(
|
||||
top: widget.margin,
|
||||
right: last ? 0.0 : widget.margin,
|
||||
),
|
||||
child: _buildThumbnailOrPlaceholder(asset, scrolling),
|
||||
);
|
||||
}).toList(),
|
||||
if (widget.dynamicLayout) {
|
||||
final aspectRatios =
|
||||
assets.map((e) => (e.width ?? 1) / (e.height ?? 1)).toList();
|
||||
final meanAspectRatio = aspectRatios.sum / assets.length;
|
||||
|
||||
// 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.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(
|
||||
key: Key("month-$title"),
|
||||
padding: const EdgeInsets.only(left: 12.0, top: 32),
|
||||
padding: const EdgeInsets.only(left: 12.0, top: 30),
|
||||
child: Text(
|
||||
title,
|
||||
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) {
|
||||
final item = widget.renderList.elements[position];
|
||||
|
||||
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!");
|
||||
return _buildSection(c, item, _scrolling);
|
||||
}
|
||||
|
||||
Text _labelBuilder(int pos) {
|
||||
@ -180,7 +268,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
||||
}
|
||||
|
||||
Widget _buildAssetGrid() {
|
||||
final useDragScrolling = widget.allAssets.length >= 20;
|
||||
final useDragScrolling = widget.renderList.totalAssets >= 20;
|
||||
|
||||
void dragScrolling(bool active) {
|
||||
setState(() {
|
||||
@ -225,6 +313,10 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
||||
setState(() {
|
||||
_selectedAssets.clear();
|
||||
});
|
||||
} else if (widget.preselectedAssets != null) {
|
||||
setState(() {
|
||||
_selectedAssets.addAll(widget.preselectedAssets!);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -241,14 +333,33 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
scrollToTopNotifierProvider.addListener(_scrollToTop);
|
||||
if (widget.visibleItemsListener != null) {
|
||||
_itemPositionsListener.itemPositions.addListener(_positionListener);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
scrollToTopNotifierProvider.removeListener(_scrollToTop);
|
||||
if (widget.visibleItemsListener != null) {
|
||||
_itemPositionsListener.itemPositions.removeListener(_positionListener);
|
||||
}
|
||||
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() {
|
||||
// for some reason, this is necessary as well in order
|
||||
// to correctly reposition the drag thumb scroll bar
|
||||
@ -268,7 +379,8 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
||||
child: Stack(
|
||||
children: [
|
||||
_buildAssetGrid(),
|
||||
if (widget.selectionActive) _buildMultiSelectIndicator(),
|
||||
if (widget.showMultiSelectIndicator && widget.selectionActive)
|
||||
_buildMultiSelectIndicator(),
|
||||
],
|
||||
),
|
||||
);
|
||||
@ -282,19 +394,28 @@ class ImmichAssetGridView extends StatefulWidget {
|
||||
final bool showStorageIndicator;
|
||||
final ImmichAssetGridSelectionListener? listener;
|
||||
final bool selectionActive;
|
||||
final List<Asset> allAssets;
|
||||
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({
|
||||
super.key,
|
||||
required this.renderList,
|
||||
required this.allAssets,
|
||||
required this.assetsPerRow,
|
||||
required this.showStorageIndicator,
|
||||
this.listener,
|
||||
this.margin = 5.0,
|
||||
this.selectionActive = false,
|
||||
this.onRefresh,
|
||||
this.preselectedAssets,
|
||||
this.canDeselect = true,
|
||||
this.dynamicLayout = true,
|
||||
this.showMultiSelectIndicator = true,
|
||||
this.visibleItemsListener,
|
||||
});
|
||||
|
||||
@override
|
||||
|
@ -2,7 +2,6 @@ import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.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/shared/models/asset.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 {
|
||||
final Asset asset;
|
||||
final List<Asset> assetList;
|
||||
final int index;
|
||||
final Asset Function(int index) loadAsset;
|
||||
final int totalAssets;
|
||||
final bool showStorageIndicator;
|
||||
final bool useGrayBoxPlaceholder;
|
||||
final bool isSelected;
|
||||
@ -21,7 +22,9 @@ class ThumbnailImage extends HookConsumerWidget {
|
||||
const ThumbnailImage({
|
||||
Key? key,
|
||||
required this.asset,
|
||||
required this.assetList,
|
||||
required this.index,
|
||||
required this.loadAsset,
|
||||
required this.totalAssets,
|
||||
this.showStorageIndicator = true,
|
||||
this.useGrayBoxPlaceholder = false,
|
||||
this.isSelected = false,
|
||||
@ -57,8 +60,9 @@ class ThumbnailImage extends HookConsumerWidget {
|
||||
} else {
|
||||
AutoRouter.of(context).push(
|
||||
GalleryViewerRoute(
|
||||
assetList: assetList,
|
||||
asset: asset,
|
||||
initialIndex: index,
|
||||
loadAsset: loadAsset,
|
||||
totalAssets: totalAssets,
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -100,7 +104,9 @@ class ThumbnailImage extends HookConsumerWidget {
|
||||
decoration: BoxDecoration(
|
||||
border: multiselectEnabled && isSelected
|
||||
? Border.all(
|
||||
color: Theme.of(context).primaryColorLight,
|
||||
color: onDeselect == null
|
||||
? Colors.grey
|
||||
: Theme.of(context).primaryColorLight,
|
||||
width: 10,
|
||||
)
|
||||
: const Border(),
|
||||
@ -130,7 +136,7 @@ class ThumbnailImage extends HookConsumerWidget {
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
if (ref.watch(favoriteProvider).contains(asset.id))
|
||||
if (asset.isFavorite)
|
||||
const Positioned(
|
||||
left: 10,
|
||||
bottom: 5,
|
||||
|
@ -7,15 +7,16 @@ import 'package:immich_mobile/shared/ui/drag_sheet.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
|
||||
class ControlBottomAppBar extends ConsumerWidget {
|
||||
final Function onShare;
|
||||
final Function onFavorite;
|
||||
final Function onArchive;
|
||||
final Function onDelete;
|
||||
final void Function() onShare;
|
||||
final void Function() onFavorite;
|
||||
final void Function() onArchive;
|
||||
final void Function() onDelete;
|
||||
final Function(Album album) onAddToAlbum;
|
||||
final void Function() onCreateNewAlbum;
|
||||
|
||||
final List<Album> albums;
|
||||
final List<Album> sharedAlbums;
|
||||
final bool enabled;
|
||||
|
||||
const ControlBottomAppBar({
|
||||
Key? key,
|
||||
@ -27,6 +28,7 @@ class ControlBottomAppBar extends ConsumerWidget {
|
||||
required this.albums,
|
||||
required this.onAddToAlbum,
|
||||
required this.onCreateNewAlbum,
|
||||
this.enabled = true,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
@ -39,35 +41,31 @@ class ControlBottomAppBar extends ConsumerWidget {
|
||||
ControlBoxButton(
|
||||
iconData: Icons.ios_share_rounded,
|
||||
label: "control_bottom_app_bar_share".tr(),
|
||||
onPressed: () {
|
||||
onShare();
|
||||
},
|
||||
onPressed: enabled ? onShare : null,
|
||||
),
|
||||
ControlBoxButton(
|
||||
iconData: Icons.favorite_border_rounded,
|
||||
label: "control_bottom_app_bar_favorite".tr(),
|
||||
onPressed: () {
|
||||
onFavorite();
|
||||
},
|
||||
onPressed: enabled ? onFavorite : null,
|
||||
),
|
||||
ControlBoxButton(
|
||||
iconData: Icons.delete_outline_rounded,
|
||||
label: "control_bottom_app_bar_delete".tr(),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return DeleteDialog(
|
||||
onDelete: onDelete,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
onPressed: enabled
|
||||
? () => showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return DeleteDialog(
|
||||
onDelete: onDelete,
|
||||
);
|
||||
},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
ControlBoxButton(
|
||||
iconData: Icons.archive,
|
||||
label: "control_bottom_app_bar_archive".tr(),
|
||||
onPressed: () => onArchive(),
|
||||
onPressed: enabled ? onArchive : null,
|
||||
),
|
||||
],
|
||||
);
|
||||
@ -108,7 +106,9 @@ class ControlBottomAppBar extends ConsumerWidget {
|
||||
endIndent: 16,
|
||||
thickness: 1,
|
||||
),
|
||||
AddToAlbumTitleRow(onCreateNewAlbum: onCreateNewAlbum),
|
||||
AddToAlbumTitleRow(
|
||||
onCreateNewAlbum: enabled ? onCreateNewAlbum : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -118,6 +118,7 @@ class ControlBottomAppBar extends ConsumerWidget {
|
||||
albums: albums,
|
||||
sharedAlbums: sharedAlbums,
|
||||
onAddToAlbum: onAddToAlbum,
|
||||
enabled: enabled,
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(
|
||||
@ -137,7 +138,7 @@ class AddToAlbumTitleRow extends StatelessWidget {
|
||||
required this.onCreateNewAlbum,
|
||||
});
|
||||
|
||||
final VoidCallback onCreateNewAlbum;
|
||||
final VoidCallback? onCreateNewAlbum;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
@ -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/shared_album.provider.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/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/home_page_app_bar.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/shared/models/album.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
@ -34,7 +31,6 @@ class HomePage extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final appSettingService = ref.watch(appSettingsServiceProvider);
|
||||
final multiselectEnabled = ref.watch(multiselectProvider.notifier);
|
||||
final selectionEnabledHook = useState(false);
|
||||
|
||||
@ -45,6 +41,7 @@ class HomePage extends HookConsumerWidget {
|
||||
|
||||
final tipOneOpacity = useState(0.0);
|
||||
final refreshCount = useState(0);
|
||||
final processing = useState(false);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
@ -97,7 +94,7 @@ class HomePage extends HookConsumerWidget {
|
||||
selectionEnabledHook.value = false;
|
||||
}
|
||||
|
||||
Iterable<Asset> remoteOnlySelection({String? localErrorMessage}) {
|
||||
List<Asset> remoteOnlySelection({String? localErrorMessage}) {
|
||||
final Set<Asset> assets = selection.value;
|
||||
final bool onlyRemote = assets.every((e) => e.isRemote);
|
||||
if (!onlyRemote) {
|
||||
@ -108,113 +105,139 @@ class HomePage extends HookConsumerWidget {
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
}
|
||||
return assets.where((a) => a.isRemote);
|
||||
return assets.where((a) => a.isRemote).toList();
|
||||
}
|
||||
return assets;
|
||||
return assets.toList();
|
||||
}
|
||||
|
||||
void onFavoriteAssets() {
|
||||
final remoteAssets = remoteOnlySelection(
|
||||
localErrorMessage: 'home_page_favorite_err_local'.tr(),
|
||||
);
|
||||
if (remoteAssets.isNotEmpty) {
|
||||
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,
|
||||
void onFavoriteAssets() async {
|
||||
processing.value = true;
|
||||
try {
|
||||
final remoteAssets = remoteOnlySelection(
|
||||
localErrorMessage: 'home_page_favorite_err_local'.tr(),
|
||||
);
|
||||
}
|
||||
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() {
|
||||
final remoteAssets = remoteOnlySelection(
|
||||
localErrorMessage: 'home_page_archive_err_local'.tr(),
|
||||
);
|
||||
if (remoteAssets.isNotEmpty) {
|
||||
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,
|
||||
void onArchiveAsset() async {
|
||||
processing.value = true;
|
||||
try {
|
||||
final remoteAssets = remoteOnlySelection(
|
||||
localErrorMessage: 'home_page_archive_err_local'.tr(),
|
||||
);
|
||||
}
|
||||
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() {
|
||||
ref.watch(assetProvider.notifier).deleteAssets(selection.value);
|
||||
selectionEnabledHook.value = false;
|
||||
void onDelete() async {
|
||||
processing.value = true;
|
||||
try {
|
||||
await ref.watch(assetProvider.notifier).deleteAssets(selection.value);
|
||||
selectionEnabledHook.value = false;
|
||||
} finally {
|
||||
processing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
void onAddToAlbum(Album album) async {
|
||||
final Iterable<Asset> assets = remoteOnlySelection(
|
||||
localErrorMessage: "home_page_add_to_album_err_local".tr(),
|
||||
);
|
||||
if (assets.isEmpty) {
|
||||
return;
|
||||
}
|
||||
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,
|
||||
);
|
||||
processing.value = true;
|
||||
try {
|
||||
final Iterable<Asset> assets = remoteOnlySelection(
|
||||
localErrorMessage: "home_page_add_to_album_err_local".tr(),
|
||||
);
|
||||
if (assets.isEmpty) {
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
void onCreateNewAlbum() async {
|
||||
final Iterable<Asset> assets = remoteOnlySelection(
|
||||
localErrorMessage: "home_page_add_to_album_err_local".tr(),
|
||||
);
|
||||
if (assets.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final result = await albumService.createAlbumWithGeneratedName(assets);
|
||||
processing.value = true;
|
||||
try {
|
||||
final Iterable<Asset> assets = remoteOnlySelection(
|
||||
localErrorMessage: "home_page_add_to_album_err_local".tr(),
|
||||
);
|
||||
if (assets.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final result =
|
||||
await albumService.createAlbumWithGeneratedName(assets);
|
||||
|
||||
if (result != null) {
|
||||
ref.watch(albumProvider.notifier).getAllAlbums();
|
||||
ref.watch(sharedAlbumProvider.notifier).getAllSharedAlbums();
|
||||
selectionEnabledHook.value = false;
|
||||
if (result != null) {
|
||||
ref.watch(albumProvider.notifier).getAllAlbums();
|
||||
ref.watch(sharedAlbumProvider.notifier).getAllSharedAlbums();
|
||||
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 {
|
||||
debugPrint("refreshCount.value ${refreshCount.value}");
|
||||
final fullRefresh = refreshCount.value > 0;
|
||||
await ref.read(assetProvider.notifier).getAllAsset(clear: fullRefresh);
|
||||
if (fullRefresh) {
|
||||
@ -277,20 +300,18 @@ class HomePage extends HookConsumerWidget {
|
||||
bottom: false,
|
||||
child: Stack(
|
||||
children: [
|
||||
ref.watch(assetProvider).renderList == null ||
|
||||
ref.watch(assetProvider).allAssets.isEmpty
|
||||
? buildLoadingIndicator()
|
||||
: ImmichAssetGrid(
|
||||
renderList: ref.watch(assetProvider).renderList!,
|
||||
assets: ref.read(assetProvider).allAssets,
|
||||
assetsPerRow: appSettingService
|
||||
.getSetting(AppSettingsEnum.tilesPerRow),
|
||||
showStorageIndicator: appSettingService
|
||||
.getSetting(AppSettingsEnum.storageIndicator),
|
||||
listener: selectionListener,
|
||||
selectionActive: selectionEnabledHook.value,
|
||||
onRefresh: refreshAssets,
|
||||
),
|
||||
ref.watch(assetsProvider).when(
|
||||
data: (data) => data.isEmpty
|
||||
? buildLoadingIndicator()
|
||||
: ImmichAssetGrid(
|
||||
renderList: data,
|
||||
listener: selectionListener,
|
||||
selectionActive: selectionEnabledHook.value,
|
||||
onRefresh: refreshAssets,
|
||||
),
|
||||
error: (error, _) => Center(child: Text(error.toString())),
|
||||
loading: buildLoadingIndicator,
|
||||
),
|
||||
if (selectionEnabledHook.value)
|
||||
ControlBottomAppBar(
|
||||
onShare: onShareAssets,
|
||||
@ -301,7 +322,9 @@ class HomePage extends HookConsumerWidget {
|
||||
albums: albums,
|
||||
sharedAlbums: sharedAlbums,
|
||||
onCreateNewAlbum: onCreateNewAlbum,
|
||||
enabled: !processing.value,
|
||||
),
|
||||
if (processing.value) const Center(child: ImmichLoadingIndicator())
|
||||
],
|
||||
),
|
||||
);
|
||||
|
@ -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/shared/models/user.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/utils/db.dart';
|
||||
import 'package:immich_mobile/utils/hash.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
||||
AuthenticationNotifier(
|
||||
this._apiService,
|
||||
this._db,
|
||||
) : super(
|
||||
AuthenticationState(
|
||||
deviceId: "",
|
||||
@ -31,6 +35,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
||||
);
|
||||
|
||||
final ApiService _apiService;
|
||||
final Isar _db;
|
||||
|
||||
Future<bool> login(
|
||||
String email,
|
||||
@ -91,7 +96,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
||||
try {
|
||||
await Future.wait([
|
||||
_apiService.authenticationApi.logout(),
|
||||
Store.delete(StoreKey.assetETag),
|
||||
clearAssetsAndAlbums(_db),
|
||||
Store.delete(StoreKey.currentUser),
|
||||
Store.delete(StoreKey.accessToken),
|
||||
]);
|
||||
@ -170,5 +175,6 @@ final authenticationProvider =
|
||||
StateNotifierProvider<AuthenticationNotifier, AuthenticationState>((ref) {
|
||||
return AuthenticationNotifier(
|
||||
ref.watch(apiServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
);
|
||||
});
|
||||
|
@ -8,6 +8,8 @@ class SearchResultGrid extends HookConsumerWidget {
|
||||
|
||||
final List<Asset> assets;
|
||||
|
||||
Asset _loadAsset(int index) => assets[index];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return GridView.builder(
|
||||
@ -22,7 +24,9 @@ class SearchResultGrid extends HookConsumerWidget {
|
||||
final asset = assets[index];
|
||||
return ThumbnailImage(
|
||||
asset: asset,
|
||||
assetList: assets,
|
||||
index: index,
|
||||
loadAsset: _loadAsset,
|
||||
totalAssets: assets.length,
|
||||
useGrayBoxPlaceholder: true,
|
||||
);
|
||||
},
|
||||
|
@ -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/settings/providers/app_settings.provider.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 {
|
||||
const LayoutSettings({
|
||||
@ -22,14 +21,17 @@ class LayoutSettings extends HookConsumerWidget {
|
||||
void switchChanged(bool value) {
|
||||
appSettingService.setSetting(AppSettingsEnum.dynamicLayout, value);
|
||||
useDynamicLayout.value = value;
|
||||
ref.watch(assetProvider.notifier).rebuildAssetGridDataStructure();
|
||||
ref.invalidate(appSettingsServiceProvider);
|
||||
}
|
||||
|
||||
void changeGroupValue(GroupAssetsBy? value) {
|
||||
if (value != null) {
|
||||
appSettingService.setSetting(AppSettingsEnum.groupAssetsBy, value.index);
|
||||
appSettingService.setSetting(
|
||||
AppSettingsEnum.groupAssetsBy,
|
||||
value.index,
|
||||
);
|
||||
groupBy.value = value;
|
||||
ref.watch(assetProvider.notifier).rebuildAssetGridDataStructure();
|
||||
ref.invalidate(appSettingsServiceProvider);
|
||||
}
|
||||
}
|
||||
|
||||
@ -37,8 +39,8 @@ class LayoutSettings extends HookConsumerWidget {
|
||||
() {
|
||||
useDynamicLayout.value =
|
||||
appSettingService.getSetting<bool>(AppSettingsEnum.dynamicLayout);
|
||||
groupBy.value =
|
||||
GroupAssetsBy.values[appSettingService.getSetting<int>(AppSettingsEnum.groupAssetsBy)];
|
||||
groupBy.value = GroupAssetsBy.values[
|
||||
appSettingService.getSetting<int>(AppSettingsEnum.groupAssetsBy)];
|
||||
|
||||
return null;
|
||||
},
|
||||
@ -93,6 +95,19 @@ class LayoutSettings extends HookConsumerWidget {
|
||||
onChanged: changeGroupValue,
|
||||
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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
@ -4,7 +4,6 @@ import 'package:flutter_hooks/flutter_hooks.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/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
|
||||
class StorageIndicator extends HookConsumerWidget {
|
||||
const StorageIndicator({
|
||||
@ -20,12 +19,13 @@ class StorageIndicator extends HookConsumerWidget {
|
||||
void switchChanged(bool value) {
|
||||
appSettingService.setSetting(AppSettingsEnum.storageIndicator, value);
|
||||
showStorageIndicator.value = value;
|
||||
ref.watch(assetProvider.notifier).rebuildAssetGridDataStructure();
|
||||
ref.invalidate(appSettingsServiceProvider);
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
showStorageIndicator.value = appSettingService.getSetting<bool>(AppSettingsEnum.storageIndicator);
|
||||
showStorageIndicator.value = appSettingService
|
||||
.getSetting<bool>(AppSettingsEnum.storageIndicator);
|
||||
|
||||
return null;
|
||||
},
|
||||
|
@ -4,7 +4,6 @@ import 'package:flutter_hooks/flutter_hooks.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/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
|
||||
class TilesPerRow extends HookConsumerWidget {
|
||||
const TilesPerRow({
|
||||
@ -20,10 +19,7 @@ class TilesPerRow extends HookConsumerWidget {
|
||||
void sliderChanged(double value) {
|
||||
appSettingService.setSetting(AppSettingsEnum.tilesPerRow, value.toInt());
|
||||
itemsValue.value = value;
|
||||
}
|
||||
|
||||
void sliderChangedEnd(double _) {
|
||||
ref.watch(assetProvider.notifier).rebuildAssetGridDataStructure();
|
||||
ref.invalidate(appSettingsServiceProvider);
|
||||
}
|
||||
|
||||
useEffect(
|
||||
@ -49,7 +45,6 @@ class TilesPerRow extends HookConsumerWidget {
|
||||
).tr(args: ["${itemsValue.value.toInt()}"]),
|
||||
),
|
||||
Slider(
|
||||
onChangeEnd: sliderChangedEnd,
|
||||
onChanged: sliderChanged,
|
||||
value: itemsValue.value,
|
||||
min: 2,
|
||||
|
@ -67,8 +67,9 @@ class _$AppRouter extends RootStackRouter {
|
||||
routeData: routeData,
|
||||
child: GalleryViewerPage(
|
||||
key: args.key,
|
||||
assetList: args.assetList,
|
||||
asset: args.asset,
|
||||
initialIndex: args.initialIndex,
|
||||
loadAsset: args.loadAsset,
|
||||
totalAssets: args.totalAssets,
|
||||
),
|
||||
);
|
||||
},
|
||||
@ -150,18 +151,27 @@ class _$AppRouter extends RootStackRouter {
|
||||
);
|
||||
},
|
||||
AssetSelectionRoute.name: (routeData) {
|
||||
final args = routeData.argsAs<AssetSelectionRouteArgs>();
|
||||
return CustomPage<AssetSelectionPageResult?>(
|
||||
routeData: routeData,
|
||||
child: const AssetSelectionPage(),
|
||||
child: AssetSelectionPage(
|
||||
key: args.key,
|
||||
existingAssets: args.existingAssets,
|
||||
isNewAlbum: args.isNewAlbum,
|
||||
),
|
||||
transitionsBuilder: TransitionsBuilders.slideBottom,
|
||||
opaque: true,
|
||||
barrierDismissible: false,
|
||||
);
|
||||
},
|
||||
SelectUserForSharingRoute.name: (routeData) {
|
||||
final args = routeData.argsAs<SelectUserForSharingRouteArgs>();
|
||||
return CustomPage<List<String>>(
|
||||
routeData: routeData,
|
||||
child: const SelectUserForSharingPage(),
|
||||
child: SelectUserForSharingPage(
|
||||
key: args.key,
|
||||
assets: args.assets,
|
||||
),
|
||||
transitionsBuilder: TransitionsBuilders.slideBottom,
|
||||
opaque: true,
|
||||
barrierDismissible: false,
|
||||
@ -582,15 +592,17 @@ class TabControllerRoute extends PageRouteInfo<void> {
|
||||
class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
|
||||
GalleryViewerRoute({
|
||||
Key? key,
|
||||
required List<Asset> assetList,
|
||||
required Asset asset,
|
||||
required int initialIndex,
|
||||
required Asset Function(int) loadAsset,
|
||||
required int totalAssets,
|
||||
}) : super(
|
||||
GalleryViewerRoute.name,
|
||||
path: '/gallery-viewer-page',
|
||||
args: GalleryViewerRouteArgs(
|
||||
key: key,
|
||||
assetList: assetList,
|
||||
asset: asset,
|
||||
initialIndex: initialIndex,
|
||||
loadAsset: loadAsset,
|
||||
totalAssets: totalAssets,
|
||||
),
|
||||
);
|
||||
|
||||
@ -600,19 +612,22 @@ class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
|
||||
class GalleryViewerRouteArgs {
|
||||
const GalleryViewerRouteArgs({
|
||||
this.key,
|
||||
required this.assetList,
|
||||
required this.asset,
|
||||
required this.initialIndex,
|
||||
required this.loadAsset,
|
||||
required this.totalAssets,
|
||||
});
|
||||
|
||||
final Key? key;
|
||||
|
||||
final List<Asset> assetList;
|
||||
final int initialIndex;
|
||||
|
||||
final Asset asset;
|
||||
final Asset Function(int) loadAsset;
|
||||
|
||||
final int totalAssets;
|
||||
|
||||
@override
|
||||
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,
|
||||
required Asset asset,
|
||||
required bool isMotionVideo,
|
||||
required void Function() onVideoEnded,
|
||||
void Function()? onPlaying,
|
||||
void Function()? onPaused,
|
||||
required dynamic onVideoEnded,
|
||||
dynamic onPlaying,
|
||||
dynamic onPaused,
|
||||
}) : super(
|
||||
VideoViewerRoute.name,
|
||||
path: '/video-viewer-page',
|
||||
@ -658,11 +673,11 @@ class VideoViewerRouteArgs {
|
||||
|
||||
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
|
||||
String toString() {
|
||||
@ -829,28 +844,78 @@ class RecentlyAddedRoute extends PageRouteInfo<void> {
|
||||
|
||||
/// generated route for
|
||||
/// [AssetSelectionPage]
|
||||
class AssetSelectionRoute extends PageRouteInfo<void> {
|
||||
const AssetSelectionRoute()
|
||||
: super(
|
||||
class AssetSelectionRoute extends PageRouteInfo<AssetSelectionRouteArgs> {
|
||||
AssetSelectionRoute({
|
||||
Key? key,
|
||||
required Set<Asset> existingAssets,
|
||||
bool isNewAlbum = false,
|
||||
}) : super(
|
||||
AssetSelectionRoute.name,
|
||||
path: '/asset-selection-page',
|
||||
args: AssetSelectionRouteArgs(
|
||||
key: key,
|
||||
existingAssets: existingAssets,
|
||||
isNewAlbum: isNewAlbum,
|
||||
),
|
||||
);
|
||||
|
||||
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
|
||||
/// [SelectUserForSharingPage]
|
||||
class SelectUserForSharingRoute extends PageRouteInfo<void> {
|
||||
const SelectUserForSharingRoute()
|
||||
: super(
|
||||
class SelectUserForSharingRoute
|
||||
extends PageRouteInfo<SelectUserForSharingRouteArgs> {
|
||||
SelectUserForSharingRoute({
|
||||
Key? key,
|
||||
required Set<Asset> assets,
|
||||
}) : super(
|
||||
SelectUserForSharingRoute.name,
|
||||
path: '/select-user-for-sharing-page',
|
||||
args: SelectUserForSharingRouteArgs(
|
||||
key: key,
|
||||
assets: assets,
|
||||
),
|
||||
);
|
||||
|
||||
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
|
||||
/// [AlbumViewerPage]
|
||||
class AlbumViewerRoute extends PageRouteInfo<AlbumViewerRouteArgs> {
|
||||
|
@ -1,4 +1,5 @@
|
||||
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/store.dart';
|
||||
import 'package:immich_mobile/shared/models/user.dart';
|
||||
@ -34,10 +35,10 @@ class Album {
|
||||
final IsarLinks<User> sharedUsers = IsarLinks<User>();
|
||||
final IsarLinks<Asset> assets = IsarLinks<Asset>();
|
||||
|
||||
List<Asset> _sortedAssets = [];
|
||||
RenderList _renderList = RenderList.empty();
|
||||
|
||||
@ignore
|
||||
List<Asset> get sortedAssets => _sortedAssets;
|
||||
RenderList get renderList => _renderList;
|
||||
|
||||
@ignore
|
||||
bool get isRemote => remoteId != null;
|
||||
@ -69,8 +70,14 @@ class Album {
|
||||
return name.join(' ');
|
||||
}
|
||||
|
||||
Future<void> loadSortedAssets() async {
|
||||
_sortedAssets = await assets.filter().sortByFileCreatedAt().findAll();
|
||||
Stream<void> watchRenderList(GroupAssetsBy groupAssetsBy) async* {
|
||||
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
|
||||
|
@ -225,7 +225,6 @@ class Asset {
|
||||
a.isLocal && !isLocal ||
|
||||
width == null && a.width != null ||
|
||||
height == null && a.height != null ||
|
||||
exifInfo == null && a.exifInfo != null ||
|
||||
livePhotoVideoId == null && a.livePhotoVideoId != null ||
|
||||
!isRemote && a.isRemote && isFavorite != a.isFavorite ||
|
||||
!isRemote && a.isRemote && isArchived != a.isArchived;
|
||||
|
@ -114,6 +114,45 @@ class ExifInfo {
|
||||
country: country ?? this.country,
|
||||
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) {
|
||||
|
@ -35,6 +35,10 @@ class Store {
|
||||
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)
|
||||
static T? tryGet<T>(StoreKey<T> key) => _cache[key.id];
|
||||
|
||||
|
@ -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/shared/models/exif_info.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/services/asset.service.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:collection/collection.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:intl/intl.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
@ -22,72 +18,23 @@ import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
/// State does not contain archived assets.
|
||||
/// Use database provider if you want to access the isArchived assets
|
||||
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 AssetsState {}
|
||||
|
||||
class AssetNotifier extends StateNotifier<AssetsState> {
|
||||
final AssetService _assetService;
|
||||
final AppSettingsService _settingsService;
|
||||
final AlbumService _albumService;
|
||||
final SyncService _syncService;
|
||||
final Isar _db;
|
||||
final log = Logger('AssetNotifier');
|
||||
bool _getAllAssetInProgress = false;
|
||||
bool _deleteInProgress = false;
|
||||
final AsyncMutex _stateUpdateLock = AsyncMutex();
|
||||
|
||||
AssetNotifier(
|
||||
this._assetService,
|
||||
this._settingsService,
|
||||
this._albumService,
|
||||
this._syncService,
|
||||
this._db,
|
||||
) : super(AssetsState.fromAssetList([]));
|
||||
|
||||
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);
|
||||
}
|
||||
) : super(AssetsState());
|
||||
|
||||
Future<void> getAllAsset({bool clear = false}) async {
|
||||
if (_getAllAssetInProgress || _deleteInProgress) {
|
||||
@ -97,79 +44,32 @@ class AssetNotifier extends StateNotifier<AssetsState> {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
try {
|
||||
_getAllAssetInProgress = true;
|
||||
final User me = Store.get(StoreKey.currentUser);
|
||||
if (clear) {
|
||||
await clearAssetsAndAlbums(_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 newLocal = await _albumService.refreshDeviceAlbums();
|
||||
debugPrint("newRemote: $newRemote, newLocal: $newLocal");
|
||||
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 {
|
||||
_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() {
|
||||
state = AssetsState.empty();
|
||||
return clearAssetsAndAlbums(_db);
|
||||
}
|
||||
|
||||
Future<void> onNewAssetUploaded(Asset newAsset) async {
|
||||
final bool ok = await _syncService.syncNewAssetToDb(newAsset);
|
||||
if (ok && _stateUpdateLock.enqueued <= 1) {
|
||||
// run this sequentially if there is at most 1 other task waiting
|
||||
await _stateUpdateLock.run(() async {
|
||||
final userId = Store.get(StoreKey.currentUser).isarId;
|
||||
final assets = await _getUserAssets(userId);
|
||||
await _updateAssetsState(assets);
|
||||
});
|
||||
}
|
||||
// eTag on device is not valid after partially modifying the assets
|
||||
Store.delete(StoreKey.assetETag);
|
||||
await _syncService.syncNewAssetToDb(newAsset);
|
||||
}
|
||||
|
||||
Future<void> deleteAssets(Set<Asset> deleteAssets) async {
|
||||
_deleteInProgress = true;
|
||||
try {
|
||||
_updateAssetsState(
|
||||
state.allAssets.whereNot(deleteAssets.contains).toList(),
|
||||
);
|
||||
final localDeleted = await _deleteLocalAssets(deleteAssets);
|
||||
final remoteDeleted = await _deleteRemoteAssets(deleteAssets);
|
||||
if (localDeleted.isNotEmpty || remoteDeleted.isNotEmpty) {
|
||||
@ -201,7 +101,7 @@ class AssetNotifier extends StateNotifier<AssetsState> {
|
||||
}
|
||||
if (local.isNotEmpty) {
|
||||
try {
|
||||
await PhotoManager.editor.deleteWithIds(local);
|
||||
return await PhotoManager.editor.deleteWithIds(local);
|
||||
} catch (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);
|
||||
}
|
||||
|
||||
Future<bool> toggleFavorite(Asset asset, bool status) async {
|
||||
final newAsset = await _assetService.changeFavoriteStatus(asset, status);
|
||||
|
||||
if (newAsset == null) {
|
||||
log.severe("Change favorite status failed for asset ${asset.id}");
|
||||
return asset.isFavorite;
|
||||
Future<void> toggleFavorite(List<Asset> assets, bool status) async {
|
||||
final newAssets = await _assetService.changeFavoriteStatus(assets, status);
|
||||
for (Asset? newAsset in newAssets) {
|
||||
if (newAsset == null) {
|
||||
log.severe("Change favorite status failed for asset");
|
||||
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 {
|
||||
final newAssets = await Future.wait(
|
||||
assets.map((a) => _assetService.changeArchiveStatus(a, status)),
|
||||
);
|
||||
Future<void> toggleArchive(List<Asset> assets, bool status) async {
|
||||
final newAssets = await _assetService.changeArchiveStatus(assets, status);
|
||||
int i = 0;
|
||||
bool unArchived = false;
|
||||
for (Asset oldAsset in assets) {
|
||||
final newAsset = newAssets[i++];
|
||||
if (newAsset == null) {
|
||||
log.severe("Change archive status failed for asset ${oldAsset.id}");
|
||||
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) {
|
||||
return AssetNotifier(
|
||||
ref.watch(assetServiceProvider),
|
||||
ref.watch(appSettingsServiceProvider),
|
||||
ref.watch(albumServiceProvider),
|
||||
ref.watch(syncServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final assetGroupByMonthYearProvider = StateProvider((ref) {
|
||||
// TODO: remove `where` once temporary workaround is no longer needed (to only
|
||||
// allow remote assets to be added to album). Keep `toList()` as to NOT sort
|
||||
// the original list/state
|
||||
final assets =
|
||||
ref.watch(assetProvider).allAssets.where((e) => e.isRemote).toList();
|
||||
|
||||
assets.sortByCompare<DateTime>(
|
||||
(e) => e.fileCreatedAt,
|
||||
(a, b) => b.compareTo(a),
|
||||
);
|
||||
|
||||
return assets.groupListsBy(
|
||||
(element) => DateFormat('MMMM, y').format(element.fileCreatedAt.toLocal()),
|
||||
);
|
||||
final assetDetailProvider =
|
||||
StreamProvider.autoDispose.family<Asset, Asset>((ref, asset) async* {
|
||||
yield await ref.watch(assetServiceProvider).loadExif(asset);
|
||||
final db = ref.watch(dbProvider);
|
||||
await for (final a in db.assets.watchObject(asset.id)) {
|
||||
if (a != null) yield await ref.watch(assetServiceProvider).loadExif(a);
|
||||
}
|
||||
});
|
||||
|
||||
final assetsProvider = StreamProvider.autoDispose<RenderList>((ref) async* {
|
||||
final query = ref
|
||||
.watch(dbProvider)
|
||||
.assets
|
||||
.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);
|
||||
}
|
||||
});
|
||||
|
@ -97,15 +97,18 @@ class AssetService {
|
||||
/// the exif info from the server (remote assets only)
|
||||
Future<Asset> loadExif(Asset a) async {
|
||||
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) {
|
||||
final dto = await _apiService.assetApi.getAssetById(a.remoteId!);
|
||||
if (dto != null && dto.exifInfo != null) {
|
||||
a.exifInfo = Asset.remote(dto).exifInfo!.copyWith(id: a.id);
|
||||
if (a.isInDb) {
|
||||
_db.writeTxn(() => a.put(_db));
|
||||
} else {
|
||||
debugPrint("[loadExif] parameter Asset is not from DB!");
|
||||
final newExif = Asset.remote(dto).exifInfo!.copyWith(id: a.id);
|
||||
if (newExif != a.exifInfo) {
|
||||
if (a.isInDb) {
|
||||
_db.writeTxn(() => a.put(_db));
|
||||
} else {
|
||||
debugPrint("[loadExif] parameter Asset is not from DB!");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@ -115,27 +118,39 @@ class AssetService {
|
||||
return a;
|
||||
}
|
||||
|
||||
Future<Asset?> updateAsset(
|
||||
Asset asset,
|
||||
Future<List<Asset?>> updateAssets(
|
||||
List<Asset> assets,
|
||||
UpdateAssetDto updateAssetDto,
|
||||
) async {
|
||||
final dto =
|
||||
await _apiService.assetApi.updateAsset(asset.remoteId!, updateAssetDto);
|
||||
if (dto != null) {
|
||||
final updated = asset.updatedCopy(Asset.remote(dto));
|
||||
if (updated.isInDb) {
|
||||
await _db.writeTxn(() => updated.put(_db));
|
||||
final List<AssetResponseDto?> dtos = await Future.wait(
|
||||
assets.map(
|
||||
(a) => _apiService.assetApi.updateAsset(a.remoteId!, updateAssetDto),
|
||||
),
|
||||
);
|
||||
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) {
|
||||
return updateAsset(asset, UpdateAssetDto(isFavorite: isFavorite));
|
||||
Future<List<Asset?>> changeFavoriteStatus(
|
||||
List<Asset> assets,
|
||||
bool isFavorite,
|
||||
) {
|
||||
return updateAssets(assets, UpdateAssetDto(isFavorite: isFavorite));
|
||||
}
|
||||
|
||||
Future<Asset?> changeArchiveStatus(Asset asset, bool isArchive) {
|
||||
return updateAsset(asset, UpdateAssetDto(isArchived: isArchive));
|
||||
Future<List<Asset?>> changeArchiveStatus(List<Asset> assets, bool isArchive) {
|
||||
return updateAssets(assets, UpdateAssetDto(isArchived: isArchive));
|
||||
}
|
||||
}
|
||||
|
@ -172,7 +172,7 @@ class SyncService {
|
||||
final idsToDelete = diff.third.map((e) => e.id).toList();
|
||||
try {
|
||||
await _db.writeTxn(() => _db.assets.deleteAll(idsToDelete));
|
||||
await _upsertAssetsWithExif(diff.first + diff.second);
|
||||
await upsertAssetsWithExif(diff.first + diff.second);
|
||||
} on IsarError catch (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
|
||||
final resultPair = await _linkWithExistingFromDb(toAdd);
|
||||
await _upsertAssetsWithExif(resultPair.second);
|
||||
await upsertAssetsWithExif(resultPair.second);
|
||||
final assetsToLink = resultPair.first + resultPair.second;
|
||||
final usersToLink = (await _db.users.getAllById(userIdsToAdd)).cast<User>();
|
||||
|
||||
@ -329,7 +329,7 @@ class SyncService {
|
||||
// put missing album assets into local DB
|
||||
final result = await _linkWithExistingFromDb(dto.getAssets());
|
||||
existing.addAll(result.first);
|
||||
await _upsertAssetsWithExif(result.second);
|
||||
await upsertAssetsWithExif(result.second);
|
||||
|
||||
final Album a = await Album.remote(dto);
|
||||
await _db.writeTxn(() => _db.albums.store(a));
|
||||
@ -540,7 +540,7 @@ class SyncService {
|
||||
_log.info(
|
||||
"${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);
|
||||
a.assets.addAll(result.first);
|
||||
a.assets.addAll(result.second);
|
||||
@ -600,7 +600,7 @@ class SyncService {
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
return;
|
||||
}
|
||||
|
@ -21,19 +21,19 @@ class ControlBoxButton extends StatelessWidget {
|
||||
Key? key,
|
||||
required this.label,
|
||||
required this.iconData,
|
||||
required this.onPressed,
|
||||
this.onPressed,
|
||||
}) : super(key: key);
|
||||
|
||||
final String label;
|
||||
final IconData iconData;
|
||||
final Function onPressed;
|
||||
final void Function()? onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialButton(
|
||||
padding: const EdgeInsets.all(10),
|
||||
shape: const CircleBorder(),
|
||||
onPressed: () => onPressed(),
|
||||
onPressed: onPressed,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
|
@ -1,3 +1,5 @@
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
extension DurationExtension on String {
|
||||
Duration? toDuration() {
|
||||
try {
|
||||
@ -34,4 +36,12 @@ extension ListExtension<E> on List<E> {
|
||||
length = length == 0 ? 0 : j;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -60,11 +60,7 @@ void main() {
|
||||
test('test grouped check months', () async {
|
||||
final renderList = await RenderList.fromAssets(
|
||||
assets,
|
||||
AssetGridLayoutParameters(
|
||||
3,
|
||||
false,
|
||||
GroupAssetsBy.day,
|
||||
),
|
||||
GroupAssetsBy.day,
|
||||
);
|
||||
|
||||
// Oct
|
||||
@ -78,32 +74,33 @@ void main() {
|
||||
// 5 Assets => 2 Rows
|
||||
// Day 1
|
||||
// 5 Assets => 2 Rows
|
||||
expect(renderList.elements.length, 18);
|
||||
expect(renderList.elements.length, 4);
|
||||
expect(
|
||||
renderList.elements[0].type,
|
||||
RenderAssetGridElementType.monthTitle,
|
||||
);
|
||||
expect(renderList.elements[0].date.month, 10);
|
||||
expect(renderList.elements[0].date.month, 1);
|
||||
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,
|
||||
);
|
||||
expect(renderList.elements[7].date.month, 2);
|
||||
expect(renderList.elements[2].date.month, 2);
|
||||
expect(
|
||||
renderList.elements[11].type,
|
||||
renderList.elements[3].type,
|
||||
RenderAssetGridElementType.monthTitle,
|
||||
);
|
||||
expect(renderList.elements[11].date.month, 1);
|
||||
expect(renderList.elements[3].date.month, 10);
|
||||
});
|
||||
|
||||
test('test grouped check types', () async {
|
||||
final renderList = await RenderList.fromAssets(
|
||||
assets,
|
||||
AssetGridLayoutParameters(
|
||||
5,
|
||||
false,
|
||||
GroupAssetsBy.day,
|
||||
),
|
||||
GroupAssetsBy.day,
|
||||
);
|
||||
|
||||
// Oct
|
||||
@ -120,17 +117,8 @@ void main() {
|
||||
final types = [
|
||||
RenderAssetGridElementType.monthTitle,
|
||||
RenderAssetGridElementType.groupDividerTitle,
|
||||
RenderAssetGridElementType.assetRow,
|
||||
RenderAssetGridElementType.assetRow,
|
||||
RenderAssetGridElementType.assetRow,
|
||||
RenderAssetGridElementType.monthTitle,
|
||||
RenderAssetGridElementType.groupDividerTitle,
|
||||
RenderAssetGridElementType.assetRow,
|
||||
RenderAssetGridElementType.monthTitle,
|
||||
RenderAssetGridElementType.groupDividerTitle,
|
||||
RenderAssetGridElementType.assetRow,
|
||||
RenderAssetGridElementType.groupDividerTitle,
|
||||
RenderAssetGridElementType.assetRow,
|
||||
];
|
||||
|
||||
expect(renderList.elements.length, types.length);
|
||||
|
@ -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));
|
||||
});
|
||||
});
|
||||
}
|
@ -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,
|
||||
);
|
||||
}
|
Loading…
Reference in New Issue
Block a user