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

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

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

View File

@ -115,7 +115,7 @@ jobs:
flutter-version: "3.10.0"
- 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

View File

@ -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"
}
}

View File

@ -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;
}

View File

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

View File

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

View File

@ -1,7 +1,9 @@
import 'package:collection/collection.dart';
import 'dart:async';
import 'package:flutter/material.dart';
import 'package: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;
}
}
});

View File

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

View File

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

View File

@ -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) : () {},
);
}),
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,21 +5,18 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_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(),
),

View File

@ -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()),
),
);
}
}

View File

@ -6,7 +6,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
import 'package:immich_mobile/modules/album/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(),
],
),
),

View File

@ -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()]));
}

View File

@ -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),
);
});

View File

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

View File

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

View File

@ -4,14 +4,12 @@ import 'package:immich_mobile/modules/settings/providers/app_settings.provider.d
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/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);
});

View File

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

View File

@ -12,9 +12,7 @@ import 'package:immich_mobile/modules/asset_viewer/ui/advanced_bottom_sheet.dart
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/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) {

View File

@ -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();
});

View File

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

View File

@ -1,15 +1,32 @@
import 'package:auto_route/auto_route.dart';
import 'package: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()
],
),
),
);
}
}

View File

@ -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);
}

View File

@ -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);
},
);

View File

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

View File

@ -8,6 +8,7 @@ import 'package:immich_mobile/modules/settings/providers/app_settings.provider.d
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/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(),

View File

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

View File

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

View File

@ -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) {

View File

@ -10,14 +10,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/providers/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())
],
),
);

View File

@ -9,13 +9,17 @@ import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/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),
);
});

View File

@ -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,
);
},

View File

@ -5,7 +5,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/modules/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,
),
],
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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) {

View File

@ -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];

View File

@ -3,18 +3,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/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);
}
});

View File

@ -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));
}
}

View File

@ -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;
}

View File

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

View File

@ -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);
}
}

View File

@ -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);

View File

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

View File

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