Compare commits

...

8 Commits

Author SHA1 Message Date
shenlong-tanwen 14978ece65 chore: pull main 2024-03-03 05:14:34 +05:30
shenlong 289194a356 refactor(mobile): bring back backup selected local assets to timeline (#7090)
* feat(mobile): select which local assets to display in timeline

* remove album selection chips

* refactor: move backup selection to device asset

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2024-02-24 21:49:58 -06:00
shenlong-tanwen 7f20d689ea fix: shared album route pass isarId 2024-02-14 09:24:06 +05:30
shenlong-tanwen f168f9e117 refactor: sync shared albums after device albums from homepage 2024-02-14 09:21:01 +05:30
Alex Tran 8bd90669c0 merge main 2024-02-13 20:31:14 -06:00
shenlong-tanwen 8f7e06bebd fix: use mutex for sync local albums 2024-02-10 22:26:09 +05:30
shenlong-tanwen eac150114f refactor(album): bring back backup_albums 2024-02-09 22:40:03 +05:30
shenlong-tanwen 296ae54335 refactor(mobile): split albums into remote / local 2024-02-04 17:35:42 +05:30
93 changed files with 6303 additions and 5146 deletions
+1 -1
View File
@@ -180,4 +180,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: 64c9b5291666c0ca3caabdfe9865c141ac40321d
COCOAPODS: 1.12.1
COCOAPODS: 1.11.3
@@ -0,0 +1,39 @@
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:isar/isar.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
extension LocalAlbumIsarHelper on IsarCollection<LocalAlbum> {
Future<void> store(LocalAlbum a) async {
await put(a);
await a.thumb.save();
await a.assets.save();
}
}
extension RemoteAlbumIsarHelper on IsarCollection<RemoteAlbum> {
Future<void> store(RemoteAlbum a) async {
await put(a);
await a.owner.save();
await a.thumb.save();
await a.sharedUsers.save();
await a.assets.save();
}
}
extension BackupAlbumIsarHelper on IsarCollection<BackupAlbum> {
Future<void> store(BackupAlbum a) async {
await put(a);
await a.album.save();
}
}
extension AlbumResponseDtoHelper on AlbumResponseDto {
List<Asset> getAssets() => assets.map(Asset.remote).toList();
}
extension AssetPathEntityHelper on AssetPathEntity {
String get eTagKeyAssetCount => "device-album-$id-asset-count";
}
@@ -0,0 +1,11 @@
extension OptionalCast on Object {
T? tryCast<T>() => this is T ? this as T : null;
}
extension NullUtilities on Object? {
/// Returns true if object is null or is empty
bool get isNullOrEmpty => (this == null || _isIterableAndEmpty);
bool get _isIterableAndEmpty =>
this is Iterable ? (this as Iterable).isEmpty : false;
}
+11 -11
View File
@@ -9,20 +9,19 @@ import 'package:flutter/services.dart';
import 'package:flutter_displaymode/flutter_displaymode.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
import 'package:immich_mobile/shared/models/device_asset.dart';
import 'package:timezone/data/latest.dart';
import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
import 'package:immich_mobile/modules/backup/models/duplicated_asset.model.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/routing/tab_navigation_observer.dart';
import 'package:immich_mobile/shared/cache/widgets_binding.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/android_device_asset.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/etag.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/models/ios_device_asset.dart';
import 'package:immich_mobile/shared/models/logger_message.model.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
@@ -73,14 +72,15 @@ Future<void> initApp() async {
FlutterError.onError = (details) {
FlutterError.presentError(details);
log.severe(
'FlutterError - Catch all',
"${details.toString()}\nException: ${details.exception}\nLibrary: ${details.library}\nContext: ${details.context}",
'FlutterError - Catch all error: ${details.toString()} - ${details.exception} - ${details.library} - ${details.context} - ${details.stack}',
details,
details.stack,
);
};
PlatformDispatcher.instance.onError = (error, stack) {
log.severe('PlatformDispatcher - Catch all', error, stack);
log.severe('PlatformDispatcher - Catch all error: $error', error, stack);
debugPrint("PlatformDispatcher - Catch all error: $error $stack");
return true;
};
@@ -94,14 +94,14 @@ Future<Isar> loadDb() async {
StoreValueSchema,
ExifInfoSchema,
AssetSchema,
AlbumSchema,
UserSchema,
BackupAlbumSchema,
LocalAlbumSchema,
RemoteAlbumSchema,
UserSchema,
DuplicatedAssetSchema,
LoggerMessageSchema,
ETagSchema,
if (Platform.isAndroid) AndroidDeviceAssetSchema,
if (Platform.isIOS) IOSDeviceAssetSchema,
DeviceAssetSchema,
],
directory: dir.path,
maxSizeMiB: 256,
@@ -10,6 +10,7 @@ import 'package:immich_mobile/modules/activities/providers/activity.provider.dar
import 'package:immich_mobile/modules/activities/widgets/activity_text_field.dart';
import 'package:immich_mobile/modules/activities/widgets/activity_tile.dart';
import 'package:immich_mobile/modules/activities/widgets/dismissible_activity.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/providers/current_album.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/current_asset.provider.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
@@ -22,15 +23,15 @@ class ActivitiesPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// Album has to be set in the provider before reaching this page
final album = ref.watch(currentAlbumProvider)!;
// Album has to be set in the provider before reaching this page and has to be a RemoteAlbum
final album = ref.watch(currentAlbumProvider)! as RemoteAlbum;
final asset = ref.watch(currentAssetProvider);
final user = ref.watch(currentUserProvider);
final activityNotifier = ref
.read(albumActivityProvider(album.remoteId!, asset?.remoteId).notifier);
final activityNotifier =
ref.read(albumActivityProvider(album.id, asset?.remoteId).notifier);
final activities =
ref.watch(albumActivityProvider(album.remoteId!, asset?.remoteId));
ref.watch(albumActivityProvider(album.id, asset?.remoteId));
final listViewScrollController = useScrollController();
@@ -24,8 +24,8 @@ class ActivityTextField extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final album = ref.watch(currentAlbumProvider)!;
final asset = ref.watch(currentAssetProvider);
final activityNotifier = ref
.read(albumActivityProvider(album.remoteId!, asset?.remoteId).notifier);
final activityNotifier =
ref.read(albumActivityProvider(album.id, asset?.remoteId).notifier);
final user = ref.watch(currentUserProvider);
final inputController = useTextEditingController();
final inputFocusNode = useFocusNode();
@@ -0,0 +1,216 @@
// ignore_for_file: add-copy-with
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:isar/isar.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
part 'album.model.g.dart';
/// Acts as a common class for RemoteAlbums and LocalAlbums to perform generic album handling irrespective of
/// where the album is from
sealed class Album {
Id get isarId => fastHash(id);
@Index(unique: true, replace: true, type: IndexType.hash)
final String id;
String name;
DateTime modifiedAt;
final IsarLink<Asset> thumb = IsarLink<Asset>();
static const assetsLinkId = 'assets';
final IsarLinks<Asset> assets = IsarLinks<Asset>();
@ignore
int get assetCount => assets.length;
@ignore
Asset? get thumbnail => thumb.value;
Album({
required this.id,
required this.name,
required this.modifiedAt,
});
@override
String toString() {
return 'Album(id: $id, name: $name, assetCount: $assetCount)';
}
@override
bool operator ==(covariant Album other) {
if (identical(this, other)) return true;
return other.id == id &&
other.name == name &&
other.modifiedAt == modifiedAt &&
other.thumb == thumb &&
other.assetCount == assetCount;
}
@override
@ignore
int get hashCode {
return id.hashCode ^
name.hashCode ^
modifiedAt.hashCode ^
thumb.hashCode ^
assetCount.hashCode;
}
}
@Collection()
class LocalAlbum extends Album {
static const isAllId = 'isAll';
@Backlink(to: BackupAlbum.albumLinkId)
final IsarLink<BackupAlbum> backup = IsarLink<BackupAlbum>();
LocalAlbum({
required super.id,
required super.name,
required super.modifiedAt,
});
@override
String toString() {
return 'LocalAlbum(id: $id, name: $name, assetCount: $assetCount)';
}
static LocalAlbum fromAssetPathEntity(
AssetPathEntity ape, {
Asset? thumbnail,
Iterable<Asset>? assets,
}) {
final album = LocalAlbum(
id: ape.id,
name: ape.name,
modifiedAt: ape.lastModified?.toUtc() ?? DateTime.now().toUtc(),
);
if (assets != null) {
album.assets.addAll(assets);
}
album.thumb.value = thumbnail;
return album;
}
}
@Collection()
class RemoteAlbum extends Album {
DateTime createdAt;
DateTime? startDate;
DateTime? endDate;
DateTime? lastModifiedAssetTimestamp;
bool shared;
bool activityEnabled;
final IsarLink<User> owner = IsarLink<User>();
final IsarLinks<User> sharedUsers = IsarLinks<User>();
@ignore
String? get ownerId => owner.value?.id;
@ignore
String? get ownerName {
// Guard null owner
if (owner.value == null) {
return null;
}
final name = <String>[];
if (owner.value?.name != null) {
name.add(owner.value!.name);
}
return name.join(' ');
}
RemoteAlbum({
required super.id,
required super.name,
required super.modifiedAt,
required this.createdAt,
this.startDate,
this.endDate,
this.lastModifiedAssetTimestamp,
this.shared = false,
this.activityEnabled = true,
});
@override
String toString() {
return 'RemoteAlbum(id: $id, name: $name, assetCount: $assetCount, createdAt: $createdAt, startDate: $startDate, endDate: $endDate, lastModifiedAssetTimestamp: $lastModifiedAssetTimestamp, shared: $shared, activityEnabled: $activityEnabled)';
}
@override
bool operator ==(covariant RemoteAlbum other) {
if (identical(this, other)) return true;
final lastModifiedAssetTimestampIsSetAndEqual =
lastModifiedAssetTimestamp != null &&
other.lastModifiedAssetTimestamp != null
? lastModifiedAssetTimestamp!
.isAtSameMomentAs(other.lastModifiedAssetTimestamp!)
: true;
return super == other &&
other.createdAt == createdAt &&
other.startDate == startDate &&
other.endDate == endDate &&
lastModifiedAssetTimestampIsSetAndEqual &&
other.shared == shared &&
other.activityEnabled == activityEnabled;
}
@override
int get hashCode {
return super.hashCode ^
createdAt.hashCode ^
startDate.hashCode ^
endDate.hashCode ^
lastModifiedAssetTimestamp.hashCode ^
shared.hashCode ^
activityEnabled.hashCode;
}
static Future<RemoteAlbum> fromDto(AlbumResponseDto dto, Isar db) async {
final album = RemoteAlbum(
id: dto.id,
name: dto.albumName,
createdAt: dto.createdAt,
modifiedAt: dto.updatedAt,
lastModifiedAssetTimestamp: dto.lastModifiedAssetTimestamp,
shared: dto.shared,
startDate: dto.startDate,
endDate: dto.endDate,
activityEnabled: dto.isActivityEnabled,
);
album.owner.value = await db.users.getById(dto.ownerId);
if (dto.albumThumbnailAssetId != null) {
album.thumb.value = await db.assets
.where()
.remoteIdEqualTo(dto.albumThumbnailAssetId)
.findFirst();
}
if (dto.sharedUsers.isNotEmpty) {
final users = await db.users
.getAllById(dto.sharedUsers.map((e) => e.id).toList(growable: false));
album.sharedUsers.addAll(users.cast());
}
if (dto.assets.isNotEmpty) {
final assets =
await db.assets.getAllByRemoteId(dto.assets.map((e) => e.id));
album.assets.addAll(assets);
}
return album;
}
}
File diff suppressed because it is too large Load Diff
@@ -1,74 +1,26 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/modules/album/models/album.model.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/album.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/utils/renderlist_generator.dart';
import 'package:isar/isar.dart';
class AlbumNotifier extends StateNotifier<List<Album>> {
AlbumNotifier(this._albumService, Isar db) : super([]) {
final query = db.albums
.filter()
.owner((q) => q.isarIdEqualTo(Store.get(StoreKey.currentUser).isarId));
query.findAll().then((value) {
if (mounted) {
state = value;
}
});
_streamSub = query.watch().listen((data) => state = data);
}
final AlbumService _albumService;
late final StreamSubscription<List<Album>> _streamSub;
Future<void> getAllAlbums() => Future.wait([
_albumService.refreshDeviceAlbums(),
_albumService.refreshRemoteAlbums(isShared: false),
]);
Future<void> getDeviceAlbums() => _albumService.refreshDeviceAlbums();
Future<bool> deleteAlbum(Album album) => _albumService.deleteAlbum(album);
Future<Album?> createAlbum(
String albumTitle,
Set<Asset> assets,
) =>
_albumService.createAlbum(albumTitle, assets, []);
@override
void dispose() {
_streamSub.cancel();
super.dispose();
}
}
final albumProvider =
StateNotifierProvider.autoDispose<AlbumNotifier, List<Album>>((ref) {
return AlbumNotifier(
ref.watch(albumServiceProvider),
ref.watch(dbProvider),
);
});
final albumWatcher =
StreamProvider.autoDispose.family<Album, int>((ref, albumId) async* {
final remoteAlbumWatcher =
StreamProvider.autoDispose.family<RemoteAlbum, int>((ref, albumId) async* {
final db = ref.watch(dbProvider);
final a = await db.albums.get(albumId);
final a = await db.remoteAlbums.get(albumId);
if (a != null) yield a;
await for (final a in db.albums.watchObject(albumId, fireImmediately: true)) {
await for (final a
in db.remoteAlbums.watchObject(albumId, fireImmediately: true)) {
if (a != null) yield a;
}
});
final albumRenderlistProvider =
final remoteAlbumRenderlistProvider =
StreamProvider.autoDispose.family<RenderList, int>((ref, albumId) {
final album = ref.watch(albumWatcher(albumId)).value;
final album = ref.watch(remoteAlbumWatcher(albumId)).value;
if (album != null) {
final query =
album.assets.filter().isTrashedEqualTo(false).sortByFileCreatedAtDesc();
@@ -76,3 +28,25 @@ final albumRenderlistProvider =
}
return const Stream.empty();
});
final localAlbumWatcher =
StreamProvider.autoDispose.family<LocalAlbum, int>((ref, albumId) async* {
final db = ref.watch(dbProvider);
final a = await db.localAlbums.get(albumId);
if (a != null) yield a;
await for (final a
in db.localAlbums.watchObject(albumId, fireImmediately: true)) {
if (a != null) yield a;
}
});
final localAlbumRenderlistProvider =
StreamProvider.autoDispose.family<RenderList, int>((ref, albumId) {
final album = ref.watch(localAlbumWatcher(albumId)).value;
if (album != null) {
final query =
album.assets.filter().localIdIsNotNull().sortByFileCreatedAtDesc();
return renderListGeneratorWithGroupBy(query, GroupAssetsBy.none);
}
return const Stream.empty();
});
@@ -1,7 +1,7 @@
import 'package:collection/collection.dart';
import 'package:immich_mobile/modules/album/models/album.model.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/album.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'album_sort_by_options.provider.g.dart';
@@ -11,9 +11,13 @@ typedef AlbumSortFn = List<Album> Function(List<Album> albums, bool isReverse);
class _AlbumSortHandlers {
const _AlbumSortHandlers._();
/// Sorts a List<Album> based on their created date.
///
/// ! This is not support for LocalAlbums and they are filtered out from the result
static const AlbumSortFn created = _sortByCreated;
static List<Album> _sortByCreated(List<Album> albums, bool isReverse) {
final sorted = albums.sortedBy((album) => album.createdAt);
final sorted =
albums.whereType<RemoteAlbum>().sortedBy((album) => album.createdAt);
return (isReverse ? sorted.reversed : sorted).toList();
}
@@ -36,9 +40,12 @@ class _AlbumSortHandlers {
return (isReverse ? sorted.reversed : sorted).toList();
}
/// Sorts a List<Album> based on the most recent assets.
///
/// ! This is not support for LocalAlbums and they are filtered out from the result
static const AlbumSortFn mostRecent = _sortByMostRecent;
static List<Album> _sortByMostRecent(List<Album> albums, bool isReverse) {
final sorted = albums.sorted((a, b) {
final sorted = albums.whereType<RemoteAlbum>().sorted((a, b) {
if (a.endDate != null && b.endDate != null) {
return a.endDate!.compareTo(b.endDate!);
}
@@ -49,9 +56,12 @@ class _AlbumSortHandlers {
return (isReverse ? sorted.reversed : sorted).toList();
}
/// Sorts a List<Album> based on the most oldest assets.
///
/// ! This is not support for LocalAlbums and they are filtered out from the result
static const AlbumSortFn mostOldest = _sortByMostOldest;
static List<Album> _sortByMostOldest(List<Album> albums, bool isReverse) {
final sorted = albums.sorted((a, b) {
final sorted = albums.whereType<RemoteAlbum>().sorted((a, b) {
if (a.startDate != null && b.startDate != null) {
return a.startDate!.compareTo(b.startDate!);
}
@@ -1,8 +1,8 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/models/album_viewer_page_state.model.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/shared/models/album.dart';
class AlbumViewerNotifier extends StateNotifier<AlbumViewerPageState> {
AlbumViewerNotifier(this.ref)
@@ -31,7 +31,7 @@ class AlbumViewerNotifier extends StateNotifier<AlbumViewerPageState> {
}
Future<bool> changeAlbumTitle(
Album album,
RemoteAlbum album,
String newAlbumTitle,
) async {
AlbumService service = ref.watch(albumServiceProvider);
@@ -1,4 +1,4 @@
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'current_album.provider.g.dart';
@@ -0,0 +1,22 @@
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/providers/local_album_service.provider.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:isar/isar.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'local_album.provider.g.dart';
@riverpod
class LocalAlbums extends _$LocalAlbums {
@override
Stream<List<LocalAlbum>> build() async* {
final db = ref.read(dbProvider);
final stream =
db.localAlbums.where().watch().listen((v) => state = AsyncData(v));
ref.onDispose(() => stream.cancel());
yield await db.localAlbums.where().findAll();
}
Future<void> getDeviceAlbums() =>
ref.read(localAlbumServiceProvider).refreshDeviceAlbums();
}
@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'local_album.provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$localAlbumsHash() => r'2142afa2aef7d84062c71d999035f851a9c3978b';
/// See also [LocalAlbums].
@ProviderFor(LocalAlbums)
final localAlbumsProvider =
AutoDisposeStreamNotifierProvider<LocalAlbums, List<LocalAlbum>>.internal(
LocalAlbums.new,
name: r'localAlbumsProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$localAlbumsHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$LocalAlbums = AutoDisposeStreamNotifier<List<LocalAlbum>>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
@@ -0,0 +1,16 @@
import 'package:immich_mobile/modules/album/services/local_album.service.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/services/hash.service.dart';
import 'package:immich_mobile/shared/services/sync.service.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'local_album_service.provider.g.dart';
@Riverpod(keepAlive: true)
LocalAlbumService localAlbumService(LocalAlbumServiceRef ref) =>
LocalAlbumService(
ref.watch(dbProvider),
ref.read(hashServiceProvider),
ref.read(syncServiceProvider),
ref,
);
@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'local_album_service.provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$localAlbumServiceHash() => r'1b9bb2dfb0ade102f9e7c97e9b8a50c060cdd580';
/// See also [localAlbumService].
@ProviderFor(localAlbumService)
final localAlbumServiceProvider = Provider<LocalAlbumService>.internal(
localAlbumService,
name: r'localAlbumServiceProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$localAlbumServiceHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef LocalAlbumServiceRef = ProviderRef<LocalAlbumService>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
@@ -0,0 +1,32 @@
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:isar/isar.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'remote_album.provider.g.dart';
@riverpod
class RemoteAlbums extends _$RemoteAlbums {
@override
Stream<List<RemoteAlbum>> build() async* {
final db = ref.read(dbProvider);
final stream =
db.remoteAlbums.where().watch().listen((v) => state = AsyncData(v));
ref.onDispose(() => stream.cancel());
yield await db.remoteAlbums.where().findAll();
}
Future<void> getRemoteAlbums([bool isShared = false]) =>
ref.read(albumServiceProvider).refreshRemoteAlbums(isShared: isShared);
Future<bool> deleteAlbum(RemoteAlbum album) =>
ref.read(albumServiceProvider).deleteAlbum(album);
Future<RemoteAlbum?> createAlbum(
String albumTitle,
Set<Asset> assets,
) =>
ref.read(albumServiceProvider).createAlbum(albumTitle, assets, []);
}
@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'remote_album.provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$remoteAlbumsHash() => r'bae71f612bbfcd3e2f193dd63eef53ea1d822af2';
/// See also [RemoteAlbums].
@ProviderFor(RemoteAlbums)
final remoteAlbumsProvider =
AutoDisposeStreamNotifierProvider<RemoteAlbums, List<RemoteAlbum>>.internal(
RemoteAlbums.new,
name: r'remoteAlbumsProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$remoteAlbumsHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$RemoteAlbums = AutoDisposeStreamNotifier<List<RemoteAlbum>>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
@@ -2,16 +2,17 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/services/album.service.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';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:isar/isar.dart';
class SharedAlbumNotifier extends StateNotifier<List<Album>> {
class SharedAlbumNotifier extends StateNotifier<List<RemoteAlbum>> {
SharedAlbumNotifier(this._albumService, Isar db) : super([]) {
final query = db.albums.filter().sharedEqualTo(true).sortByCreatedAtDesc();
final query =
db.remoteAlbums.filter().sharedEqualTo(true).sortByCreatedAtDesc();
query.findAll().then((value) {
if (mounted) {
state = value;
@@ -21,9 +22,9 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
}
final AlbumService _albumService;
late final StreamSubscription<List<Album>> _streamSub;
late final StreamSubscription<List<RemoteAlbum>> _streamSub;
Future<Album?> createSharedAlbum(
Future<RemoteAlbum?> createSharedAlbum(
String albumName,
Iterable<Asset> assets,
Iterable<User> sharedUsers,
@@ -43,9 +44,10 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
Future<void> getAllSharedAlbums() =>
_albumService.refreshRemoteAlbums(isShared: true);
Future<bool> deleteAlbum(Album album) => _albumService.deleteAlbum(album);
Future<bool> deleteAlbum(RemoteAlbum album) =>
_albumService.deleteAlbum(album);
Future<bool> leaveAlbum(Album album) async {
Future<bool> leaveAlbum(RemoteAlbum album) async {
var res = await _albumService.leaveAlbum(album);
if (res) {
@@ -56,11 +58,11 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
}
}
Future<bool> removeAssetFromAlbum(Album album, Iterable<Asset> assets) {
Future<bool> removeAssetFromAlbum(RemoteAlbum album, Iterable<Asset> assets) {
return _albumService.removeAssetFromAlbum(album, assets);
}
Future<bool> removeUserFromAlbum(Album album, User user) async {
Future<bool> removeUserFromAlbum(RemoteAlbum album, User user) async {
final result = await _albumService.removeUserFromAlbum(album, user);
if (result && album.sharedUsers.isEmpty) {
@@ -70,7 +72,7 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
return result;
}
Future<bool> setActivityEnabled(Album album, bool activityEnabled) {
Future<bool> setActivityEnabled(RemoteAlbum album, bool activityEnabled) {
return _albumService.setActivityEnabled(album, activityEnabled);
}
@@ -82,7 +84,8 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
}
final sharedAlbumProvider =
StateNotifierProvider.autoDispose<SharedAlbumNotifier, List<Album>>((ref) {
StateNotifierProvider.autoDispose<SharedAlbumNotifier, List<RemoteAlbum>>(
(ref) {
return SharedAlbumNotifier(
ref.watch(albumServiceProvider),
ref.watch(dbProvider),
@@ -0,0 +1,12 @@
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/providers/album_sort_by_options.provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'sorted_album.provider.g.dart';
@riverpod
List<Album> sortedAlbum(SortedAlbumRef ref, List<Album> albums) {
final albumSortOption = ref.watch(albumSortByOptionsProvider);
final albumSortIsReverse = ref.watch(albumSortOrderProvider);
return albumSortOption.sortFn(albums, albumSortIsReverse);
}
@@ -0,0 +1,158 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'sorted_album.provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$sortedAlbumHash() => r'350f537324a42ba9e01e50ca3722878ec3a8330c';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
/// See also [sortedAlbum].
@ProviderFor(sortedAlbum)
const sortedAlbumProvider = SortedAlbumFamily();
/// See also [sortedAlbum].
class SortedAlbumFamily extends Family<List<Album>> {
/// See also [sortedAlbum].
const SortedAlbumFamily();
/// See also [sortedAlbum].
SortedAlbumProvider call(
List<Album> albums,
) {
return SortedAlbumProvider(
albums,
);
}
@override
SortedAlbumProvider getProviderOverride(
covariant SortedAlbumProvider provider,
) {
return call(
provider.albums,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'sortedAlbumProvider';
}
/// See also [sortedAlbum].
class SortedAlbumProvider extends AutoDisposeProvider<List<Album>> {
/// See also [sortedAlbum].
SortedAlbumProvider(
List<Album> albums,
) : this._internal(
(ref) => sortedAlbum(
ref as SortedAlbumRef,
albums,
),
from: sortedAlbumProvider,
name: r'sortedAlbumProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$sortedAlbumHash,
dependencies: SortedAlbumFamily._dependencies,
allTransitiveDependencies:
SortedAlbumFamily._allTransitiveDependencies,
albums: albums,
);
SortedAlbumProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.albums,
}) : super.internal();
final List<Album> albums;
@override
Override overrideWith(
List<Album> Function(SortedAlbumRef provider) create,
) {
return ProviderOverride(
origin: this,
override: SortedAlbumProvider._internal(
(ref) => create(ref as SortedAlbumRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
albums: albums,
),
);
}
@override
AutoDisposeProviderElement<List<Album>> createElement() {
return _SortedAlbumProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is SortedAlbumProvider && other.albums == albums;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, albums.hashCode);
return _SystemHash.finish(hash);
}
}
mixin SortedAlbumRef on AutoDisposeProviderRef<List<Album>> {
/// The parameter `albums` of this provider.
List<Album> get albums;
}
class _SortedAlbumProviderElement
extends AutoDisposeProviderElement<List<Album>> with SortedAlbumRef {
_SortedAlbumProviderElement(super.provider);
@override
List<Album> get albums => (origin as SortedAlbumProvider).albums;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
@@ -1,14 +1,10 @@
import 'dart:async';
import 'dart:collection';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/album_extensions.dart';
import 'package:immich_mobile/modules/album/models/add_asset_response.model.dart';
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/modules/album/models/album.model.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';
@@ -18,9 +14,7 @@ import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/shared/services/sync.service.dart';
import 'package:immich_mobile/shared/services/user.service.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
final albumServiceProvider = Provider(
(ref) => AlbumService(
@@ -28,7 +22,6 @@ final albumServiceProvider = Provider(
ref.watch(userServiceProvider),
ref.watch(syncServiceProvider),
ref.watch(dbProvider),
ref.watch(backupServiceProvider),
),
);
@@ -37,9 +30,6 @@ class AlbumService {
final UserService _userService;
final SyncService _syncService;
final Isar _db;
final BackupService _backupService;
final Logger _log = Logger('AlbumService');
Completer<bool> _localCompleter = Completer()..complete(false);
Completer<bool> _remoteCompleter = Completer()..complete(false);
AlbumService(
@@ -47,98 +37,8 @@ class AlbumService {
this._userService,
this._syncService,
this._db,
this._backupService,
);
/// Checks all selected device albums for changes of albums and their assets
/// Updates the local database and returns `true` if there were any changes
Future<bool> refreshDeviceAlbums() async {
if (!_localCompleter.isCompleted) {
// guard against concurrent calls
_log.info("refreshDeviceAlbums is already in progress");
return _localCompleter.future;
}
_localCompleter = Completer();
final Stopwatch sw = Stopwatch()..start();
bool changes = false;
try {
final List<String> excludedIds =
await _backupService.excludedAlbumsQuery().idProperty().findAll();
final List<String> selectedIds =
await _backupService.selectedAlbumsQuery().idProperty().findAll();
if (selectedIds.isEmpty) {
final numLocal = await _db.albums.where().localIdIsNotNull().count();
if (numLocal > 0) {
_syncService.removeAllLocalAlbumsAndAssets();
}
return false;
}
final List<AssetPathEntity> onDevice =
await PhotoManager.getAssetPathList(
hasAll: true,
filterOption: FilterOptionGroup(containsPathModified: true),
);
_log.info("Found ${onDevice.length} device albums");
Set<String>? excludedAssets;
if (excludedIds.isNotEmpty) {
if (Platform.isIOS) {
// iOS and Android device album working principle differ significantly
// on iOS, an asset can be in multiple albums
// on Android, an asset can only be in exactly one album (folder!) at the same time
// thus, on Android, excluding an album can be done by ignoring that album
// however, on iOS, it it necessary to load the assets from all excluded
// albums and check every asset from any selected album against the set
// of excluded assets
excludedAssets = await _loadExcludedAssetIds(onDevice, excludedIds);
_log.info("Found ${excludedAssets.length} assets to exclude");
}
// remove all excluded albums
onDevice.removeWhere((e) => excludedIds.contains(e.id));
_log.info(
"Ignoring ${excludedIds.length} excluded albums resulting in ${onDevice.length} device albums",
);
}
final hasAll = selectedIds
.map((id) => onDevice.firstWhereOrNull((a) => a.id == id))
.whereNotNull()
.any((a) => a.isAll);
if (hasAll) {
if (Platform.isAndroid) {
// remove the virtual "Recent" album and keep and individual albums
// on Android, the virtual "Recent" `lastModified` value is always null
onDevice.removeWhere((e) => e.isAll);
_log.info("'Recents' is selected, keeping all individual albums");
}
} else {
// keep only the explicitly selected albums
onDevice.removeWhere((e) => !selectedIds.contains(e.id));
_log.info("'Recents' is not selected, keeping only selected albums");
}
changes =
await _syncService.syncLocalAlbumAssetsToDb(onDevice, excludedAssets);
_log.info("Syncing completed. Changes: $changes");
} finally {
_localCompleter.complete(changes);
}
debugPrint("refreshDeviceAlbums took ${sw.elapsedMilliseconds}ms");
return changes;
}
Future<Set<String>> _loadExcludedAssetIds(
List<AssetPathEntity> albums,
List<String> excludedAlbumIds,
) async {
final Set<String> result = HashSet<String>();
for (AssetPathEntity a in albums) {
if (excludedAlbumIds.contains(a.id)) {
final List<AssetEntity> assets =
await a.getAssetListRange(start: 0, end: 0x7fffffffffffffff);
result.addAll(assets.map((e) => e.id));
}
}
return result;
}
/// Checks remote albums (owned if `isShared` is false) for changes,
/// updates the local database and returns `true` if there were any changes
Future<bool> refreshRemoteAlbums({required bool isShared}) async {
@@ -170,7 +70,7 @@ class AlbumService {
return changes;
}
Future<Album?> createAlbum(
Future<RemoteAlbum?> createAlbum(
String albumName,
Iterable<Asset> assets, [
Iterable<User> sharedUsers = const [],
@@ -184,8 +84,8 @@ class AlbumService {
),
);
if (remote != null) {
Album album = await Album.remote(remote);
await _db.writeTxn(() => _db.albums.store(album));
RemoteAlbum album = await RemoteAlbum.fromDto(remote, _db);
await _db.writeTxn(() => _db.remoteAlbums.store(album));
return album;
}
} catch (e) {
@@ -203,13 +103,16 @@ class AlbumService {
final proposedName = "$baseName${round == 0 ? "" : " ($round)"}";
if (null ==
await _db.albums.filter().nameEqualTo(proposedName).findFirst()) {
await _db.remoteAlbums
.filter()
.nameEqualTo(proposedName)
.findFirst()) {
return proposedName;
}
}
}
Future<Album?> createAlbumWithGeneratedName(
Future<RemoteAlbum?> createAlbumWithGeneratedName(
Iterable<Asset> assets,
) async {
return createAlbum(
@@ -221,11 +124,17 @@ class AlbumService {
Future<AddAssetsResponse?> addAdditionalAssetToAlbum(
Iterable<Asset> assets,
Album album,
RemoteAlbum album,
) async {
try {
final remoteAlbum =
await _db.remoteAlbums.where().idEqualTo(album.id).findFirst();
if (remoteAlbum == null) {
return null;
}
var response = await _apiService.albumApi.addAssetsToAlbum(
album.remoteId!,
remoteAlbum.id,
BulkIdsDto(ids: assets.map((asset) => asset.remoteId!).toList()),
);
@@ -244,10 +153,10 @@ class AlbumService {
}
await _db.writeTxn(() async {
await album.assets.update(link: successAssets);
final a = await _db.albums.get(album.id);
await remoteAlbum.assets.update(link: successAssets);
final a = await _db.remoteAlbums.get(remoteAlbum.isarId);
// trigger watcher
await _db.albums.put(a!);
await _db.remoteAlbums.put(a!);
});
return AddAssetsResponse(
@@ -264,11 +173,11 @@ class AlbumService {
Future<bool> addAdditionalUserToAlbum(
List<String> sharedUserIds,
Album album,
RemoteAlbum album,
) async {
try {
final result = await _apiService.albumApi.addUsersToAlbum(
album.remoteId!,
album.id,
AddUsersDto(sharedUserIds: sharedUserIds),
);
if (result != null) {
@@ -276,7 +185,7 @@ class AlbumService {
.addAll((await _db.users.getAllById(sharedUserIds)).cast());
album.shared = result.shared;
await _db.writeTxn(() async {
await _db.albums.put(album);
await _db.remoteAlbums.put(album);
await album.sharedUsers.save();
});
return true;
@@ -287,15 +196,15 @@ class AlbumService {
return false;
}
Future<bool> setActivityEnabled(Album album, bool enabled) async {
Future<bool> setActivityEnabled(RemoteAlbum album, bool enabled) async {
try {
final result = await _apiService.albumApi.updateAlbumInfo(
album.remoteId!,
album.id,
UpdateAlbumDto(isActivityEnabled: enabled),
);
if (result != null) {
album.activityEnabled = enabled;
await _db.writeTxn(() => _db.albums.put(album));
await _db.writeTxn(() => _db.remoteAlbums.put(album));
return true;
}
} catch (e) {
@@ -304,20 +213,20 @@ class AlbumService {
return false;
}
Future<bool> deleteAlbum(Album album) async {
Future<bool> deleteAlbum(RemoteAlbum album) async {
try {
final userId = Store.get(StoreKey.currentUser).isarId;
if (album.owner.value?.isarId == userId) {
await _apiService.albumApi.deleteAlbum(album.remoteId!);
await _apiService.albumApi.deleteAlbum(album.id);
}
if (album.shared) {
final foreignAssets =
await album.assets.filter().not().ownerIdEqualTo(userId).findAll();
await _db.writeTxn(() => _db.albums.delete(album.id));
final List<Album> albums =
await _db.albums.filter().sharedEqualTo(true).findAll();
await _db.writeTxn(() => _db.remoteAlbums.delete(album.isarId));
final List<RemoteAlbum> albums =
await _db.remoteAlbums.filter().sharedEqualTo(true).findAll();
final List<Asset> existing = [];
for (Album a in albums) {
for (RemoteAlbum a in albums) {
existing.addAll(
await a.assets.filter().not().ownerIdEqualTo(userId).findAll(),
);
@@ -328,7 +237,7 @@ class AlbumService {
await _db.writeTxn(() => _db.assets.deleteAll(idsToRemove));
}
} else {
await _db.writeTxn(() => _db.albums.delete(album.id));
await _db.writeTxn(() => _db.remoteAlbums.delete(album.isarId));
}
return true;
} catch (e) {
@@ -337,9 +246,9 @@ class AlbumService {
return false;
}
Future<bool> leaveAlbum(Album album) async {
Future<bool> leaveAlbum(RemoteAlbum album) async {
try {
await _apiService.albumApi.removeUserFromAlbum(album.remoteId!, "me");
await _apiService.albumApi.removeUserFromAlbum(album.id, "me");
return true;
} catch (e) {
debugPrint("Error deleteAlbum ${e.toString()}");
@@ -348,21 +257,21 @@ class AlbumService {
}
Future<bool> removeAssetFromAlbum(
Album album,
RemoteAlbum album,
Iterable<Asset> assets,
) async {
try {
await _apiService.albumApi.removeAssetFromAlbum(
album.remoteId!,
album.id,
BulkIdsDto(
ids: assets.map((asset) => asset.remoteId!).toList(),
),
);
await _db.writeTxn(() async {
await album.assets.update(unlink: assets);
final a = await _db.albums.get(album.id);
final a = await _db.remoteAlbums.get(album.isarId);
// trigger watcher
await _db.albums.put(a!);
await _db.remoteAlbums.put(a!);
});
return true;
@@ -373,21 +282,21 @@ class AlbumService {
}
Future<bool> removeUserFromAlbum(
Album album,
RemoteAlbum album,
User user,
) async {
try {
await _apiService.albumApi.removeUserFromAlbum(
album.remoteId!,
album.id,
user.id,
);
album.sharedUsers.remove(user);
await _db.writeTxn(() async {
await album.sharedUsers.update(unlink: [user]);
final a = await _db.albums.get(album.id);
final a = await _db.remoteAlbums.get(album.isarId);
// trigger watcher
await _db.albums.put(a!);
await _db.remoteAlbums.put(a!);
});
return true;
@@ -398,18 +307,18 @@ class AlbumService {
}
Future<bool> changeTitleAlbum(
Album album,
RemoteAlbum album,
String newAlbumTitle,
) async {
try {
await _apiService.albumApi.updateAlbumInfo(
album.remoteId!,
album.id,
UpdateAlbumDto(
albumName: newAlbumTitle,
),
);
album.name = newAlbumTitle;
await _db.writeTxn(() => _db.albums.put(album));
await _db.writeTxn(() => _db.remoteAlbums.put(album));
return true;
} catch (e) {
@@ -0,0 +1,363 @@
import 'dart:async';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/collection_extensions.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/extensions/album_extensions.dart';
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
import 'package:immich_mobile/modules/backup/providers/backup_album.provider.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/etag.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/services/hash.service.dart';
import 'package:immich_mobile/shared/services/sync.service.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:photo_manager/photo_manager.dart';
class LocalAlbumService {
Completer<bool> _localCompleter = Completer()..complete(false);
final Logger _log = Logger('LocalAlbumService');
final Isar _db;
final Ref _ref;
final HashService _hashService;
final SyncService _syncService;
LocalAlbumService(this._db, this._hashService, this._syncService, this._ref);
Future<bool> refreshDeviceAlbums() async =>
SyncService.lock.run(_refreshDeviceAlbums);
/// Checks all selected device albums for changes of albums and their assets
/// Updates the local database and returns `true` if there were any changes
Future<bool> _refreshDeviceAlbums() async {
if (!_localCompleter.isCompleted) {
// guard against concurrent calls
_log.info("refreshDeviceAlbums is already in progress");
return _localCompleter.future;
}
_localCompleter = Completer();
final Stopwatch sw = Stopwatch()..start();
bool changes = false;
try {
final List<AssetPathEntity> onDevice =
await PhotoManager.getAssetPathList(
filterOption: FilterOptionGroup(containsPathModified: true),
);
_log.fine("Found ${onDevice.length} device albums");
changes = await _syncLocalAlbumAssetsToDb(onDevice);
_log.fine("Syncing completed. Changes: $changes");
} finally {
_localCompleter.complete(changes);
}
_log.fine("refreshDeviceAlbums took ${sw.elapsedMilliseconds}ms");
return changes;
}
/// Syncs all device albums and their assets to the database
/// Returns `true` if there were any changes
Future<bool> _syncLocalAlbumAssetsToDb(List<AssetPathEntity> onDevice) async {
onDevice.sort((a, b) => a.id.compareTo(b.id));
final inDb = await _db.localAlbums.where().sortById().findAll();
final List<Asset> deleteCandidates = [];
final List<Asset> existing = [];
assert(inDb.isSorted((a, b) => a.id.compareTo(b.id)), "sort!");
final bool anyChanges = await diffSortedLists(
onDevice,
inDb,
compare: (AssetPathEntity a, LocalAlbum b) => a.id.compareTo(b.id),
both: (AssetPathEntity ape, LocalAlbum album) =>
_syncAlbumInDbAndOnDevice(ape, album, deleteCandidates, existing),
onlyFirst: (AssetPathEntity ape) => _addAlbumFromDevice(ape, existing),
onlySecond: (LocalAlbum a) => _removeAlbumFromDb(a, deleteCandidates),
);
_log.fine(
"Syncing all local albums almost done. Collected ${deleteCandidates.length} asset candidates to delete",
);
final (toDelete, toUpdate) =
_handleAssetRemoval(deleteCandidates, existing, remote: false);
_log.fine(
"${toDelete.length} assets to delete, ${toUpdate.length} to update",
);
if (toDelete.isNotEmpty || toUpdate.isNotEmpty) {
await _db.writeTxn(() async {
await _db.assets.deleteAll(toDelete);
await _db.exifInfos.deleteAll(toDelete);
await _db.assets.putAll(toUpdate);
});
_log.info(
"Removed ${toDelete.length} and updated ${toUpdate.length} local assets from DB",
);
}
return anyChanges;
}
/// Accumulates all suitable album assets to the `deleteCandidates` and
/// removes the album from the database.
Future<void> _removeAlbumFromDb(
LocalAlbum album,
List<Asset> deleteCandidates,
) async {
_log.info("Removing local album $album from DB");
// delete assets in DB unless they are remote or part of some other album
deleteCandidates.addAll(
await album.assets.filter().remoteIdIsNull().findAll(),
);
await album.backup.load();
final backupAlbum = album.backup.value;
try {
final ok = await _db.writeTxn(() async {
return await _db.localAlbums.delete(album.isarId) &&
(backupAlbum == null ||
await _db.backupAlbums.delete(album.isarId));
});
assert(ok);
_log.info("Removed local album $album from DB");
} catch (e, stack) {
_log.severe("Failed to remove local album $album from DB: $e", stack);
}
}
/// returns a tuple (toDelete toUpdate) when assets are to be deleted
(List<int> toDelete, List<Asset> toUpdate) _handleAssetRemoval(
List<Asset> deleteCandidates,
List<Asset> existing, {
bool? remote,
}) {
if (deleteCandidates.isEmpty) {
return const ([], []);
}
deleteCandidates.sort(Asset.compareById);
deleteCandidates.uniqueConsecutive(compare: Asset.compareById);
existing.sort(Asset.compareById);
existing.uniqueConsecutive(compare: Asset.compareById);
final (tooAdd, toUpdate, toRemove) = _syncService.diffAssets(
existing,
deleteCandidates,
compare: Asset.compareById,
remote: remote,
);
assert(tooAdd.isEmpty, "toAdd should be empty in _handleAssetRemoval");
return (toRemove.map((e) => e.id).toList(), toUpdate);
}
/// Syncs the device album to the album in the database
/// returns `true` if there were any changes
/// Accumulates asset candidates to delete and those already existing in DB
Future<bool> _syncAlbumInDbAndOnDevice(
AssetPathEntity ape,
LocalAlbum album,
List<Asset> deleteCandidates,
List<Asset> existing, [
bool forceRefresh = false,
]) async {
if (!forceRefresh && !await _hasAssetPathEntityChanged(ape, album)) {
_log.fine("Local album ${ape.name} has not changed. Skipping sync.");
return false;
}
if (!forceRefresh && await _syncDeviceAlbumFast(ape, album)) {
return true;
}
// general case, e.g. some assets have been deleted or there are excluded albums on iOS
final inDb = await album.assets
.filter()
.ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId)
.sortByChecksum()
.findAll();
assert(inDb.isSorted(Asset.compareByChecksum), "inDb not sorted!");
final List<Asset> onDevice = await _hashService.getHashedAssets(ape);
// _removeDuplicates sorts `onDevice` by checksum
_removeDuplicates(onDevice);
final (toAdd, toUpdate, toDelete) = _syncService.diffAssets(onDevice, inDb);
_log.fine(
"Syncing local album ${ape.name}. ${toAdd.length} assets to add, ${toUpdate.length} to update, ${toDelete.length} to delete",
);
final (existingInDb, updated) = await _linkWithExistingFromDb(toAdd);
_log.fine(
"Linking assets to add with existing from db. ${existingInDb.length} existing, ${updated.length} to update",
);
deleteCandidates.addAll(toDelete);
existing.addAll(existingInDb);
album.name = ape.name;
album.modifiedAt = ape.lastModified ?? DateTime.now();
if (album.thumb.value != null && toDelete.contains(album.thumbnail)) {
album.thumb.value = null;
}
try {
await _db.writeTxn(() async {
await _db.assets.putAll(updated);
await _db.assets.putAll(toUpdate);
await album.assets
.update(link: existingInDb + updated, unlink: toDelete);
album.thumb.value ??= await album.assets.filter().findFirst();
await _db.localAlbums.store(album);
});
_updateETagCount(ape);
_log.info("Synced changes of local album ${ape.name} to DB");
} on IsarError catch (e, stack) {
_log.severe("Failed to update synced album ${ape.name} in DB: $e", stack);
}
return true;
}
/// returns `true` if the albums differ on the surface
Future<bool> _hasAssetPathEntityChanged(
AssetPathEntity a,
LocalAlbum b,
) async {
final lastKnownTotal =
(await _db.eTags.getById(a.eTagKeyAssetCount))?.assetCount ?? 0;
final hasSameLastModified = !(Platform.isAndroid && a.isAll) &&
(a.lastModified == null ||
!a.lastModified!.isAtSameMomentAs(b.modifiedAt));
return a.name != b.name ||
hasSameLastModified ||
await a.assetCountAsync != lastKnownTotal;
}
/// Adds a new album from the device to the database and Accumulates all
/// assets already existing in the database to the list of `existing` assets
Future<void> _addAlbumFromDevice(
AssetPathEntity ape,
List<Asset> existing,
) async {
_log.info("Syncing a new local album to DB: ${ape.name}");
final assets = await _hashService.getHashedAssets(ape);
_removeDuplicates(assets);
final (existingInDb, updated) = await _linkWithExistingFromDb(assets);
_log.info(
"${existingInDb.length} assets already existed in DB, to upsert ${updated.length}",
);
await _syncService.upsertAssetsWithExif(updated);
existing.addAll(existingInDb);
final thumb = existingInDb.firstOrNull ?? updated.firstOrNull;
final LocalAlbum a = LocalAlbum.fromAssetPathEntity(
ape,
thumbnail: thumb,
assets: existingInDb.followedBy(updated),
);
try {
await _db.writeTxn(() => _db.localAlbums.store(a));
_updateETagCount(ape);
_log.info("Added a new local album to DB: ${ape.name}");
} on IsarError catch (e, stack) {
_log.severe("Failed to add new local album ${ape.name} to DB: $e", stack);
}
await _ref.read(backupAlbumsProvider.notifier).syncWithLocalAlbum(a);
}
/// fast path for common case: only new assets were added to device album
/// returns `true` if successfull, else `false`
Future<bool> _syncDeviceAlbumFast(
AssetPathEntity ape,
LocalAlbum album,
) async {
if (!(ape.lastModified ?? DateTime.now()).isAfter(album.modifiedAt)) {
return false;
}
final int totalOnDevice = await ape.assetCountAsync;
final int lastKnownTotal =
(await _db.eTags.getById(ape.eTagKeyAssetCount))?.assetCount ?? 0;
final AssetPathEntity? modified = totalOnDevice > lastKnownTotal
? await ape.fetchPathProperties(
filterOptionGroup: FilterOptionGroup(
updateTimeCond: DateTimeCond(
min: album.modifiedAt.add(const Duration(seconds: 1)),
max: ape.lastModified ?? DateTime.now(),
),
),
)
: null;
if (modified == null) {
return false;
}
final List<Asset> newAssets = await _hashService.getHashedAssets(modified);
if (totalOnDevice != lastKnownTotal + newAssets.length) {
return false;
}
album.modifiedAt = ape.lastModified ?? DateTime.now();
_removeDuplicates(newAssets);
final (existingInDb, updated) = await _linkWithExistingFromDb(newAssets);
try {
await _db.writeTxn(() async {
await _db.assets.putAll(updated);
await album.assets.update(link: existingInDb + updated);
await _db.localAlbums.put(album);
});
_updateETagCount(ape);
_log.info("Fast synced local album ${ape.name} to DB");
} on IsarError catch (e, stack) {
_log.severe(
"Failed to fast sync local album ${ape.name} to DB: $e",
stack,
);
return false;
}
return true;
}
Future<void> _updateETagCount(AssetPathEntity ape) async {
final assetCountOnDevice = await ape.assetCountAsync;
return _db.writeTxn(
() => _db.eTags.put(
ETag(id: ape.eTagKeyAssetCount, assetCount: assetCountOnDevice),
),
);
}
List<Asset> _removeDuplicates(List<Asset> assets) {
final int before = assets.length;
assets.sort(Asset.compareByOwnerChecksumCreatedModified);
assets.uniqueConsecutive(
compare: Asset.compareByOwnerChecksum,
onDuplicate: (a, b) =>
_log.fine("Ignoring duplicate assets on device:\n$a\n$b"),
);
final int duplicates = before - assets.length;
if (duplicates > 0) {
_log.warning("Ignored $duplicates duplicate assets on device");
}
return assets;
}
/// Returns a tuple (existing, updated)
Future<(List<Asset> existing, List<Asset> updated)> _linkWithExistingFromDb(
List<Asset> assets,
) async {
if (assets.isEmpty) return ([].cast<Asset>(), [].cast<Asset>());
final List<Asset?> inDb = await _db.assets.getAllByOwnerIdChecksum(
assets.map((a) => a.ownerId).toInt64List(),
assets.map((a) => a.checksum).toList(growable: false),
);
assert(inDb.length == assets.length);
final List<Asset> existing = [], toUpsert = [];
for (int i = 0; i < assets.length; i++) {
final Asset? b = inDb[i];
if (b == null) {
toUpsert.add(assets[i]);
continue;
}
if (b.canUpdate(assets[i])) {
final updated = b.updatedCopy(assets[i]);
assert(updated.id != Isar.autoIncrement);
toUpsert.add(updated);
} else {
existing.add(b);
}
}
assert(existing.length + toUpsert.length == assets.length);
return (existing, toUpsert);
}
}
@@ -4,12 +4,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/providers/remote_album.provider.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.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/drag_sheet.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
@@ -25,14 +25,14 @@ class AddToAlbumBottomSheet extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
final albums = ref.watch(remoteAlbumsProvider).valueOrNull ?? [];
final albumService = ref.watch(albumServiceProvider);
final sharedAlbums = ref.watch(sharedAlbumProvider);
useEffect(
() {
// Fetch album updates, e.g., cover image
ref.read(albumProvider.notifier).getAllAlbums();
ref.read(remoteAlbumsProvider.notifier).getRemoteAlbums();
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
return null;
@@ -40,7 +40,7 @@ class AddToAlbumBottomSheet extends HookConsumerWidget {
[],
);
void addToAlbum(Album album) async {
void addToAlbum(RemoteAlbum album) async {
final result = await albumService.addAdditionalAssetToAlbum(
assets,
album,
@@ -1,15 +1,15 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/providers/album_sort_by_options.provider.dart';
import 'package:immich_mobile/modules/album/ui/album_thumbnail_listtile.dart';
import 'package:immich_mobile/shared/models/album.dart';
class AddToAlbumSliverList extends HookConsumerWidget {
/// The asset to add to an album
final List<Album> albums;
final List<Album> sharedAlbums;
final void Function(Album) onAddToAlbum;
final List<RemoteAlbum> albums;
final List<RemoteAlbum> sharedAlbums;
final void Function(RemoteAlbum) onAddToAlbum;
final bool enabled;
const AddToAlbumSliverList({
@@ -46,9 +46,11 @@ class AddToAlbumSliverList extends HookConsumerWidget {
physics: const ClampingScrollPhysics(),
itemCount: sortedSharedAlbums.length,
itemBuilder: (context, index) => AlbumThumbnailListTile(
album: sortedSharedAlbums[index],
album: sortedSharedAlbums[index] as RemoteAlbum,
onTap: enabled
? () => onAddToAlbum(sortedSharedAlbums[index])
? () => onAddToAlbum(
sortedSharedAlbums[index] as RemoteAlbum,
)
: () {},
),
),
@@ -59,7 +61,7 @@ class AddToAlbumSliverList extends HookConsumerWidget {
// Build albums list
final offset = index - (sharedAlbums.isNotEmpty ? 1 : 0);
final album = sortedAlbums[offset];
final album = sortedAlbums[offset] as RemoteAlbum;
return AlbumThumbnailListTile(
album: album,
onTap: enabled ? () => onAddToAlbum(album) : () {},
@@ -0,0 +1,82 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/providers/album_sort_by_options.provider.dart';
class AlbumSortSelector extends ConsumerWidget {
final List<AlbumSortMode> sortModes;
const AlbumSortSelector({required this.sortModes, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final albumSortMode = ref.watch(albumSortByOptionsProvider);
final albumSortIsReverse = ref.watch(albumSortOrderProvider);
List<PopupMenuEntry<AlbumSortMode>> buildOptions(BuildContext ctx) {
{
return sortModes.map((option) {
final selected = albumSortMode == option;
return PopupMenuItem(
value: option,
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 12.0),
child: Icon(
Icons.check,
color: selected ? context.primaryColor : Colors.transparent,
),
),
Text(
option.label.tr(),
style: TextStyle(
color: selected ? context.primaryColor : null,
fontSize: 14.0,
),
),
],
),
);
}).toList();
}
}
void onSelected(AlbumSortMode mode) {
final isAlreadySelected = albumSortMode == mode;
// Switch direction
if (isAlreadySelected) {
return ref
.read(albumSortOrderProvider.notifier)
.changeSortDirection(!albumSortIsReverse);
}
return ref.read(albumSortByOptionsProvider.notifier).changeSortMode(mode);
}
return PopupMenuButton(
position: PopupMenuPosition.over,
itemBuilder: buildOptions,
onSelected: onSelected,
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 5),
child: Icon(
albumSortIsReverse
? Icons.arrow_downward_rounded
: Icons.arrow_upward_rounded,
size: 14,
color: context.primaryColor,
),
),
Text(
albumSortMode.label.tr(),
style: context.textTheme.labelLarge
?.copyWith(color: context.primaryColor),
),
],
),
);
}
}
@@ -1,12 +1,16 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/extensions/object_extensions.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/ui/immich_thumbnail.dart';
class AlbumThumbnailCard extends StatelessWidget {
final Album album;
final Function()? onTap;
final bool showAssetCount;
final Icon? emptyThumbnailPlaceholder;
/// Whether or not to show the owner of the album (or "Owned")
/// in the subtitle of the album
@@ -16,11 +20,11 @@ class AlbumThumbnailCard extends StatelessWidget {
super.key,
required this.album,
this.onTap,
this.showAssetCount = true,
this.showOwner = false,
this.emptyThumbnailPlaceholder,
});
final Album album;
@override
Widget build(BuildContext context) {
var isDarkTheme = context.isDarkTheme;
@@ -29,62 +33,6 @@ class AlbumThumbnailCard extends StatelessWidget {
builder: (context, constraints) {
var cardSize = constraints.maxWidth;
buildEmptyThumbnail() {
return Container(
height: cardSize,
width: cardSize,
decoration: BoxDecoration(
color: isDarkTheme ? Colors.grey[800] : Colors.grey[200],
),
child: Center(
child: Icon(
Icons.no_photography,
size: cardSize * .15,
),
),
);
}
buildAlbumThumbnail() => ImmichThumbnail(
asset: album.thumbnail.value,
width: cardSize,
height: cardSize,
);
buildAlbumTextRow() {
// Add the owner name to the subtitle
String? owner;
if (showOwner) {
if (album.ownerId == Store.get(StoreKey.currentUser).id) {
owner = 'album_thumbnail_owned'.tr();
} else if (album.ownerName != null) {
owner = 'album_thumbnail_shared_by'.tr(args: [album.ownerName!]);
}
}
return RichText(
overflow: TextOverflow.fade,
text: TextSpan(
children: [
TextSpan(
text: album.assetCount == 1
? 'album_thumbnail_card_item'
.tr(args: ['${album.assetCount}'])
: 'album_thumbnail_card_items'
.tr(args: ['${album.assetCount}']),
style: context.textTheme.bodyMedium,
),
if (owner != null) const TextSpan(text: ' · '),
if (owner != null)
TextSpan(
text: owner,
style: context.textTheme.bodyMedium,
),
],
),
);
}
return GestureDetector(
onTap: onTap,
child: Flex(
@@ -98,14 +46,46 @@ class AlbumThumbnailCard extends StatelessWidget {
width: cardSize,
height: cardSize,
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: album.thumbnail.value == null
? buildEmptyThumbnail()
: buildAlbumThumbnail(),
borderRadius:
const BorderRadius.all(Radius.circular(20)),
child: album.thumbnail == null
// Empty placeholder
? Container(
height: cardSize,
width: cardSize,
decoration: BoxDecoration(
border: Border.all(
color: isDarkTheme
? const Color.fromARGB(255, 53, 53, 53)
: const Color.fromARGB(
255,
203,
203,
203,
),
),
color: isDarkTheme
? Colors.grey[900]
: Colors.grey[50],
),
child: Center(
child: emptyThumbnailPlaceholder ??
Icon(
Icons.no_photography,
size: cardSize * .15,
),
),
)
// Thumbnail image
: ImmichThumbnail(
asset: album.thumbnail,
width: cardSize,
height: cardSize,
),
),
),
Padding(
padding: const EdgeInsets.only(top: 8.0),
padding: const EdgeInsets.only(top: 8.0, left: 8.0),
child: SizedBox(
width: cardSize,
child: Text(
@@ -118,7 +98,14 @@ class AlbumThumbnailCard extends StatelessWidget {
),
),
),
buildAlbumTextRow(),
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: _AlbumTextRow(
album: album,
showAssetCount: showAssetCount,
showOwner: showOwner,
),
),
],
),
),
@@ -129,3 +116,55 @@ class AlbumThumbnailCard extends StatelessWidget {
);
}
}
class _AlbumTextRow extends StatelessWidget {
final Album album;
final bool showAssetCount;
/// Whether or not to show the owner of the album (or "Owned")
/// in the subtitle of the album
final bool showOwner;
const _AlbumTextRow({
required this.album,
required this.showAssetCount,
required this.showOwner,
});
@override
Widget build(BuildContext context) {
String? owner;
if (showOwner) {
if (album.tryCast<RemoteAlbum>()?.ownerId ==
Store.get(StoreKey.currentUser).id) {
owner = 'album_thumbnail_owned'.tr();
} else if (album.tryCast<RemoteAlbum>()?.ownerName != null) {
owner = 'album_thumbnail_shared_by'
.tr(args: [(album as RemoteAlbum).ownerName!]);
}
}
return Text.rich(
overflow: TextOverflow.fade,
TextSpan(
children: [
if (showAssetCount)
TextSpan(
text: album.assetCount == 1
? 'album_thumbnail_card_item'
.tr(args: ['${album.assetCount}'])
: 'album_thumbnail_card_items'
.tr(args: ['${album.assetCount}']),
style: context.textTheme.bodyMedium,
),
if (owner != null) const TextSpan(text: ' · '),
TextSpan(
text: owner,
style: context.textTheme.bodyMedium,
),
],
),
);
}
}
@@ -3,8 +3,8 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
@@ -16,7 +16,7 @@ class AlbumThumbnailListTile extends StatelessWidget {
this.onTap,
});
final Album album;
final RemoteAlbum album;
final void Function()? onTap;
@override
@@ -61,7 +61,7 @@ class AlbumThumbnailListTile extends StatelessWidget {
behavior: HitTestBehavior.opaque,
onTap: onTap ??
() {
context.pushRoute(AlbumViewerRoute(albumId: album.id));
context.pushRoute(RemoteAlbumViewerRoute(albumId: album.isarId));
},
child: Padding(
padding: const EdgeInsets.only(bottom: 12.0),
@@ -70,7 +70,7 @@ class AlbumThumbnailListTile extends StatelessWidget {
children: [
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: album.thumbnail.value == null
child: album.thumbnail == null
? buildEmptyThumbnail()
: buildAlbumThumbnail(),
),
@@ -3,11 +3,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart';
import 'package:immich_mobile/shared/models/album.dart';
class AlbumViewerEditableTitle extends HookConsumerWidget {
final Album album;
final RemoteAlbum album;
final FocusNode titleFocusNode;
const AlbumViewerEditableTitle({
super.key,
@@ -0,0 +1,50 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class LibraryNavButton extends StatelessWidget {
final String label;
final IconData icon;
final Function() onClick;
const LibraryNavButton({
super.key,
required this.label,
required this.icon,
required this.onClick,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 180.0,
child: OutlinedButton.icon(
onPressed: onClick,
label: Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Text(
label,
style: TextStyle(
color: context.isDarkTheme
? Colors.white
: Colors.black.withAlpha(200),
),
).tr(),
),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
backgroundColor:
context.isDarkTheme ? Colors.grey[900] : Colors.grey[50],
side: BorderSide(
color: context.isDarkTheme ? Colors.grey[800]! : Colors.grey[300]!,
),
alignment: Alignment.centerLeft,
),
icon: Icon(
icon,
color: context.primaryColor,
),
),
);
}
}
@@ -5,17 +5,17 @@ import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/activities/providers/activity_statistics.provider.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart';
import 'package:immich_mobile/modules/album/providers/remote_album.provider.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
class AlbumViewerAppbar extends HookConsumerWidget
class RemoteAlbumViewerAppbar extends HookConsumerWidget
implements PreferredSizeWidget {
const AlbumViewerAppbar({
const RemoteAlbumViewerAppbar({
super.key,
required this.album,
required this.userId,
@@ -25,21 +25,20 @@ class AlbumViewerAppbar extends HookConsumerWidget
required this.onActivities,
});
final Album album;
final RemoteAlbum album;
final String userId;
final FocusNode titleFocusNode;
final Function(Album album)? onAddPhotos;
final Function(Album album)? onAddUsers;
final Function(Album album) onActivities;
final Function(RemoteAlbum album)? onAddPhotos;
final Function(RemoteAlbum album)? onAddUsers;
final Function() onActivities;
@override
Widget build(BuildContext context, WidgetRef ref) {
final newAlbumTitle = ref.watch(albumViewerProvider).editTitleText;
final isEditAlbum = ref.watch(albumViewerProvider).isEditAlbum;
final isProcessing = useProcessingOverlay();
final comments = album.shared
? ref.watch(activityStatisticsProvider(album.remoteId!))
: 0;
final comments =
album.shared ? ref.watch(activityStatisticsProvider(album.id)) : 0;
deleteAlbum() async {
isProcessing.value = true;
@@ -51,7 +50,8 @@ class AlbumViewerAppbar extends HookConsumerWidget
context
.navigateTo(const TabControllerRoute(children: [SharingRoute()]));
} else {
success = await ref.watch(albumProvider.notifier).deleteAlbum(album);
success =
await ref.watch(remoteAlbumsProvider.notifier).deleteAlbum(album);
context
.navigateTo(const TabControllerRoute(children: [LibraryRoute()]));
}
@@ -172,7 +172,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
ListTile(
leading: const Icon(Icons.share_rounded),
onTap: () {
context.pushRoute(SharedLinkEditRoute(albumId: album.remoteId));
context.pushRoute(SharedLinkEditRoute(albumId: album.id));
context.pop();
},
title: const Text(
@@ -228,9 +228,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
Widget buildActivitiesButton() {
return IconButton(
onPressed: () {
onActivities(album);
},
onPressed: () => onActivities(),
icon: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
@@ -291,12 +289,11 @@ class AlbumViewerAppbar extends HookConsumerWidget
actions: [
if (album.shared && (album.activityEnabled || comments != 0))
buildActivitiesButton(),
if (album.isRemote)
IconButton(
splashRadius: 25,
onPressed: buildBottomSheet,
icon: const Icon(Icons.more_horiz_rounded),
),
IconButton(
splashRadius: 25,
onPressed: buildBottomSheet,
icon: const Icon(Icons.more_horiz_rounded),
),
],
);
}
@@ -5,10 +5,10 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
@@ -16,7 +16,7 @@ import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
@RoutePage()
class AlbumOptionsPage extends HookConsumerWidget {
final Album album;
final RemoteAlbum album;
const AlbumOptionsPage({super.key, required this.album});
@@ -5,8 +5,8 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.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/remote_album.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';
@@ -194,17 +194,17 @@ class CreateAlbumPage extends HookConsumerWidget {
}
createNonSharedAlbum() async {
var newAlbum = await ref.watch(albumProvider.notifier).createAlbum(
var newAlbum = await ref.watch(remoteAlbumsProvider.notifier).createAlbum(
ref.watch(albumTitleProvider),
selectedAssets.value,
);
if (newAlbum != null) {
ref.watch(albumProvider.notifier).getAllAlbums();
ref.watch(remoteAlbumsProvider.notifier).getRemoteAlbums();
selectedAssets.value = {};
ref.watch(albumTitleProvider.notifier).clearAlbumTitle();
context.replaceRoute(AlbumViewerRoute(albumId: newAlbum.id));
context.replaceRoute(RemoteAlbumViewerRoute(albumId: newAlbum.isarId));
}
}
+170 -291
View File
@@ -1,12 +1,19 @@
// ignore_for_file: prefer-sliver-prefix
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:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/providers/album_sort_by_options.provider.dart';
import 'package:immich_mobile/modules/album/providers/local_album.provider.dart';
import 'package:immich_mobile/modules/album/providers/remote_album.provider.dart';
import 'package:immich_mobile/modules/album/providers/sorted_album.provider.dart';
import 'package:immich_mobile/modules/album/ui/album_sort_selector.dart';
import 'package:immich_mobile/modules/album/ui/album_thumbnail_card.dart';
import 'package:immich_mobile/modules/album/ui/library_nav_button.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/ui/immich_app_bar.dart';
@@ -17,180 +24,37 @@ class LibraryPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final trashEnabled =
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
final albums = ref.watch(albumProvider);
final isDarkTheme = context.isDarkTheme;
final albumSortOption = ref.watch(albumSortByOptionsProvider);
final albumSortIsReverse = ref.watch(albumSortOrderProvider);
useEffect(
() {
ref.read(albumProvider.notifier).getAllAlbums();
ref.read(remoteAlbumsProvider.notifier).getRemoteAlbums();
ref.read(localAlbumsProvider.notifier).getDeviceAlbums();
return null;
},
[],
);
Widget buildSortButton() {
return PopupMenuButton(
position: PopupMenuPosition.over,
itemBuilder: (BuildContext context) {
return AlbumSortMode.values
.map<PopupMenuEntry<AlbumSortMode>>((option) {
final selected = albumSortOption == option;
return PopupMenuItem(
value: option,
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 12.0),
child: Icon(
Icons.check,
color:
selected ? context.primaryColor : Colors.transparent,
),
),
Text(
option.label.tr(),
style: TextStyle(
color: selected ? context.primaryColor : null,
fontSize: 14.0,
),
),
],
),
);
}).toList();
},
onSelected: (AlbumSortMode value) {
final selected = albumSortOption == value;
// Switch direction
if (selected) {
ref
.read(albumSortOrderProvider.notifier)
.changeSortDirection(!albumSortIsReverse);
} else {
ref.read(albumSortByOptionsProvider.notifier).changeSortMode(value);
}
},
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 5),
child: Icon(
albumSortIsReverse
? Icons.arrow_downward_rounded
: Icons.arrow_upward_rounded,
size: 14,
color: context.primaryColor,
),
),
Text(
albumSortOption.label.tr(),
style: context.textTheme.labelLarge?.copyWith(
color: context.primaryColor,
),
),
],
),
);
}
return Scaffold(
appBar: _LibraryAppBar(),
body: CustomScrollView(
slivers: [
_SilverLibraryNavigationButtons(),
_SilverLibraryRemoteAlbumHeader(),
_SilverLibraryRemoteAlbumGrid(),
_SilverLibraryLocalAlbumHeader(),
_SilverLibraryLocalAlbumGrid(),
],
),
);
}
}
Widget buildCreateAlbumButton() {
return LayoutBuilder(
builder: (context, constraints) {
var cardSize = constraints.maxWidth;
return GestureDetector(
onTap: () =>
context.pushRoute(CreateAlbumRoute(isSharedAlbum: false)),
child: Padding(
padding:
const EdgeInsets.only(bottom: 32), // Adjust padding to suit
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: cardSize,
height: cardSize,
decoration: BoxDecoration(
border: Border.all(
color: isDarkTheme
? const Color.fromARGB(255, 53, 53, 53)
: const Color.fromARGB(255, 203, 203, 203),
),
color: isDarkTheme ? Colors.grey[900] : Colors.grey[50],
borderRadius: const BorderRadius.all(Radius.circular(20)),
),
child: Center(
child: Icon(
Icons.add_rounded,
size: 28,
color: context.primaryColor,
),
),
),
Padding(
padding: const EdgeInsets.only(
top: 8.0,
bottom: 16,
),
child: Text(
'library_page_new_album',
style: context.textTheme.labelLarge,
).tr(),
),
],
),
),
);
},
);
}
Widget buildLibraryNavButton(
String label,
IconData icon,
Function() onClick,
) {
return Expanded(
child: OutlinedButton.icon(
onPressed: onClick,
label: Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Text(
label,
style: TextStyle(
color: context.isDarkTheme
? Colors.white
: Colors.black.withAlpha(200),
),
),
),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
backgroundColor: isDarkTheme ? Colors.grey[900] : Colors.grey[50],
side: BorderSide(
color: isDarkTheme ? Colors.grey[800]! : Colors.grey[300]!,
),
alignment: Alignment.centerLeft,
),
icon: Icon(
icon,
color: context.primaryColor,
),
),
);
}
final remote = albums.where((a) => a.isRemote).toList();
final sorted = albumSortOption.sortFn(remote, albumSortIsReverse);
final local = albums.where((a) => a.isLocal).toList();
Widget? shareTrashButton() {
return trashEnabled
class _LibraryAppBar extends ImmichAppBar {
@override
Widget build(BuildContext context, WidgetRef ref) {
final trashEnabled =
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
return ImmichAppBar(
action: trashEnabled
? InkWell(
onTap: () => context.pushRoute(const TrashRoute()),
borderRadius: const BorderRadius.all(Radius.circular(12)),
@@ -199,133 +63,148 @@ class LibraryPage extends HookConsumerWidget {
size: 25,
),
)
: null;
}
: null,
);
}
}
return Scaffold(
appBar: ImmichAppBar(
action: shareTrashButton(),
),
body: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(
left: 12.0,
right: 12.0,
top: 24.0,
bottom: 12.0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
buildLibraryNavButton(
"library_page_favorites".tr(), Icons.favorite_border, () {
context.navigateTo(const FavoritesRoute());
}),
const SizedBox(width: 12.0),
buildLibraryNavButton(
"library_page_archive".tr(), Icons.archive_outlined, () {
context.navigateTo(const ArchiveRoute());
}),
],
),
class _SilverLibraryNavigationButtons extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
LibraryNavButton(
label: "library_page_favorites",
icon: Icons.favorite_border,
onClick: () => context.navigateTo(const FavoritesRoute()),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(
top: 12.0,
left: 12.0,
right: 12.0,
bottom: 20.0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'library_page_albums',
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
).tr(),
buildSortButton(),
],
),
LibraryNavButton(
label: "library_page_archive",
icon: Icons.archive_outlined,
onClick: () => context.navigateTo(const ArchiveRoute()),
),
),
SliverPadding(
padding: const EdgeInsets.all(12.0),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 250,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: .7,
),
delegate: SliverChildBuilderDelegate(
childCount: sorted.length + 1,
(context, index) {
if (index == 0) {
return buildCreateAlbumButton();
}
return AlbumThumbnailCard(
album: sorted[index - 1],
onTap: () => context.pushRoute(
AlbumViewerRoute(
albumId: sorted[index - 1].id,
),
),
);
},
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(
top: 12.0,
left: 12.0,
right: 12.0,
bottom: 20.0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'library_page_device_albums',
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
).tr(),
],
),
),
),
SliverPadding(
padding: const EdgeInsets.all(12.0),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 250,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: .7,
),
delegate: SliverChildBuilderDelegate(
childCount: local.length,
(context, index) => AlbumThumbnailCard(
album: local[index],
onTap: () => context.pushRoute(
AlbumViewerRoute(
albumId: local[index].id,
),
),
),
),
),
),
],
],
),
),
);
}
}
class _SilverLibraryRemoteAlbumHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'library_page_albums',
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
).tr(),
const AlbumSortSelector(sortModes: AlbumSortMode.values),
],
),
),
);
}
}
class _SilverLibraryRemoteAlbumGrid extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final remoteAlbums = ref.watch(remoteAlbumsProvider);
final remoteSorted =
ref.watch(sortedAlbumProvider(remoteAlbums.valueOrNull ?? []));
return SliverPadding(
padding: const EdgeInsets.all(12),
sliver: SliverGrid.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 250,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: .7,
),
itemCount: remoteSorted.length + 1,
itemBuilder: (ctx, index) {
if (index == 0) {
Album placeholder = LocalAlbum(
id: 'Placeholder',
name: 'library_page_new_album'.tr(),
modifiedAt: DateTime.now(),
);
return AlbumThumbnailCard(
album: placeholder,
showAssetCount: false,
emptyThumbnailPlaceholder: Icon(
Icons.add_rounded,
size: 28,
color: context.primaryColor,
),
onTap: () =>
context.pushRoute(CreateAlbumRoute(isSharedAlbum: false)),
);
}
final remoteAlbum = remoteSorted[index - 1];
return AlbumThumbnailCard(
album: remoteAlbum,
onTap: () => context
.pushRoute(RemoteAlbumViewerRoute(albumId: remoteAlbum.isarId)),
);
},
),
);
}
}
class _SilverLibraryLocalAlbumHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24),
child: Text(
'library_page_device_albums',
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
).tr(),
),
);
}
}
class _SilverLibraryLocalAlbumGrid extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final localAlbums = ref.watch(localAlbumsProvider).valueOrNull ?? [];
final localWithoutRecents =
localAlbums.where((e) => e.id != LocalAlbum.isAllId).toList();
return SliverPadding(
padding: const EdgeInsets.all(12),
sliver: SliverGrid.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 250,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: .7,
),
itemCount: localWithoutRecents.length,
itemBuilder: (ctx, index) => AlbumThumbnailCard(
album: localWithoutRecents[index],
onTap: () => context.pushRoute(
LocalAlbumViewerRoute(album: localWithoutRecents[index]),
),
),
),
);
}
@@ -0,0 +1,37 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/shared/ui/asset_grid/multiselect_grid.dart';
@RoutePage()
class LocalAlbumViewerPage extends HookConsumerWidget {
final LocalAlbum album;
final bool selectEnabled;
const LocalAlbumViewerPage({
super.key,
required this.album,
this.selectEnabled = true,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(),
body: MultiselectGrid(
topWidget: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 24),
child: Text(
album.name,
style: context.textTheme.headlineMedium,
),
),
renderListProvider: localAlbumRenderlistProvider(album.isarId),
selectedEnabled: selectEnabled,
),
);
}
}
@@ -8,6 +8,7 @@ import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/providers/current_album.provider.dart';
@@ -17,9 +18,8 @@ import 'package:immich_mobile/modules/album/ui/album_action_outlined_button.dart
import 'package:immich_mobile/modules/album/ui/album_viewer_editable_title.dart';
import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/album/ui/album_viewer_appbar.dart';
import 'package:immich_mobile/modules/album/ui/remote_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/providers/asset.provider.dart';
import 'package:immich_mobile/shared/ui/asset_grid/multiselect_grid.dart';
@@ -28,15 +28,15 @@ import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
@RoutePage()
class AlbumViewerPage extends HookConsumerWidget {
class RemoteAlbumViewerPage extends HookConsumerWidget {
final int albumId;
const AlbumViewerPage({super.key, required this.albumId});
const RemoteAlbumViewerPage({super.key, required this.albumId});
@override
Widget build(BuildContext context, WidgetRef ref) {
FocusNode titleFocusNode = useFocusNode();
final album = ref.watch(albumWatcher(albumId));
final album = ref.watch(remoteAlbumWatcher(albumId));
// Listen provider to prevent autoDispose when navigating to other routes from within the viewer page
ref.listen(currentAlbumProvider, (_, __) {});
album.whenData(
@@ -67,7 +67,7 @@ class AlbumViewerPage extends HookConsumerWidget {
/// 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 {
void onAddPhotosPressed(RemoteAlbum albumInfo) async {
AssetSelectionPageResult? returnPayload =
await context.pushRoute<AssetSelectionPageResult?>(
AssetSelectionRoute(
@@ -90,7 +90,7 @@ class AlbumViewerPage extends HookConsumerWidget {
}
}
void onAddUsersPressed(Album album) async {
void onAddUsersPressed(RemoteAlbum album) async {
List<String>? sharedUserIds = await context.pushRoute<List<String>?>(
SelectAdditionalUserForSharingRoute(album: album),
);
@@ -106,7 +106,7 @@ class AlbumViewerPage extends HookConsumerWidget {
}
}
Widget buildControlButton(Album album) {
Widget buildControlButton(RemoteAlbum album) {
return Padding(
padding: const EdgeInsets.only(left: 16.0, top: 8, bottom: 16),
child: SizedBox(
@@ -131,16 +131,16 @@ class AlbumViewerPage extends HookConsumerWidget {
);
}
Widget buildTitle(Album album) {
Widget buildTitle(RemoteAlbum album) {
return Padding(
padding: const EdgeInsets.only(left: 8, right: 8, top: 24),
child: userId == album.ownerId && album.isRemote
child: userId == album.ownerId
? AlbumViewerEditableTitle(
album: album,
titleFocusNode: titleFocusNode,
)
: Padding(
padding: const EdgeInsets.only(left: 8.0),
padding: const EdgeInsets.only(left: 8.0, bottom: 24),
child: Text(
album.name,
style: context.textTheme.headlineMedium,
@@ -149,7 +149,7 @@ class AlbumViewerPage extends HookConsumerWidget {
);
}
Widget buildAlbumDateRange(Album album) {
Widget buildAlbumDateRange(RemoteAlbum album) {
final DateTime? startDate = album.startDate;
final DateTime? endDate = album.endDate;
@@ -183,7 +183,7 @@ class AlbumViewerPage extends HookConsumerWidget {
);
}
Widget buildSharedUserIconsRow(Album album) {
Widget buildSharedUserIconsRow(RemoteAlbum album) {
return GestureDetector(
onTap: () => context.pushRoute(AlbumOptionsRoute(album: album)),
child: SizedBox(
@@ -207,49 +207,42 @@ class AlbumViewerPage extends HookConsumerWidget {
);
}
Widget buildHeader(Album album) {
Widget buildHeader(RemoteAlbum album) {
return Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buildTitle(album),
if (album.assets.isNotEmpty == true) buildAlbumDateRange(album),
if (album.shared) buildSharedUserIconsRow(album),
if (album.assets.isNotEmpty) buildAlbumDateRange(album),
if (album.shared && album.sharedUsers.isNotEmpty)
buildSharedUserIconsRow(album),
],
);
}
onActivitiesPressed(Album album) {
if (album.remoteId != null) {
context.pushRoute(
const ActivitiesRoute(),
);
}
}
return Scaffold(
appBar: ref.watch(multiselectProvider)
? null
: album.when(
data: (data) => AlbumViewerAppbar(
data: (data) => RemoteAlbumViewerAppbar(
titleFocusNode: titleFocusNode,
album: data,
userId: userId,
onAddPhotos: onAddPhotosPressed,
onAddUsers: onAddUsersPressed,
onActivities: onActivitiesPressed,
onActivities: () => context.pushRoute(const ActivitiesRoute()),
),
error: (error, stackTrace) => AppBar(title: const Text("Error")),
loading: () => AppBar(),
),
body: album.widgetWhen(
onData: (data) => MultiselectGrid(
renderListProvider: albumRenderlistProvider(albumId),
renderListProvider: remoteAlbumRenderlistProvider(albumId),
topWidget: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buildHeader(data),
if (data.isRemote) buildControlButton(data),
buildControlButton(data),
],
),
onRemoveFromAlbum: onRemoveFromAlbumPressed,
@@ -5,14 +5,14 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/providers/suggested_shared_users.provider.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
@RoutePage<List<String>?>()
class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
final Album album;
final RemoteAlbum album;
const SelectAdditionalUserForSharingPage({super.key, required this.album});
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/providers/album_sort_by_options.provider.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/album/ui/album_thumbnail_card.dart';
@@ -51,7 +52,9 @@ class SharingPage extends HookConsumerWidget {
album: sharedAlbums[index],
showOwner: true,
onTap: () => context.pushRoute(
AlbumViewerRoute(albumId: sharedAlbums[index].id),
RemoteAlbumViewerRoute(
albumId: sharedAlbums[index].isarId,
),
),
);
},
@@ -65,7 +68,7 @@ class SharingPage extends HookConsumerWidget {
return SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
final album = sharedAlbums[index];
final album = sharedAlbums[index] as RemoteAlbum;
final isOwner = album.ownerId == userId;
return ListTile(
@@ -73,7 +76,7 @@ class SharingPage extends HookConsumerWidget {
leading: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: ImmichThumbnail(
asset: album.thumbnail.value,
asset: album.thumbnail,
width: 60,
height: 60,
),
@@ -99,8 +102,11 @@ class SharingPage extends HookConsumerWidget {
style: context.textTheme.bodyMedium,
)
: null,
onTap: () => context
.pushRoute(AlbumViewerRoute(albumId: sharedAlbums[index].id)),
onTap: () => context.pushRoute(
RemoteAlbumViewerRoute(
albumId: sharedAlbums[index].isarId,
),
),
);
},
childCount: sharedAlbums.length,
@@ -3,7 +3,8 @@ import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/modules/album/providers/local_album_service.provider.dart';
import 'package:immich_mobile/modules/album/services/local_album.service.dart';
import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart';
import 'package:immich_mobile/modules/asset_viewer/services/image_viewer.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
@@ -14,7 +15,7 @@ import 'package:immich_mobile/shared/ui/share_dialog.dart';
class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> {
final ImageViewerService _imageViewerService;
final ShareService _shareService;
final AlbumService _albumService;
final LocalAlbumService _albumService;
ImageViewerStateNotifier(
this._imageViewerService,
@@ -83,6 +84,6 @@ final imageViewerStateProvider =
((ref) => ImageViewerStateNotifier(
ref.watch(imageViewerServiceProvider),
ref.watch(shareServiceProvider),
ref.watch(albumServiceProvider),
ref.watch(localAlbumServiceProvider),
)),
);
@@ -1,7 +1,9 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/object_extensions.dart';
import 'package:immich_mobile/modules/activities/providers/activity_statistics.provider.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/providers/current_album.provider.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
@@ -39,11 +41,15 @@ class TopControlAppBar extends HookConsumerWidget {
const double iconSize = 22.0;
final a = ref.watch(assetWatcher(asset)).value ?? asset;
final album = ref.watch(currentAlbumProvider);
final comments = album != null &&
album.remoteId != null &&
asset.remoteId != null
? ref.watch(activityStatisticsProvider(album.remoteId!, asset.remoteId))
: 0;
final comments =
album != null && album is RemoteAlbum && asset.remoteId != null
? ref.watch(
activityStatisticsProvider(
album.tryCast<RemoteAlbum>()!.id,
asset.remoteId,
),
)
: 0;
Widget buildFavoriteButton(a) {
return IconButton(
@@ -171,7 +177,8 @@ class TopControlAppBar extends HookConsumerWidget {
if (asset.isRemote && !asset.isLocal && !asset.isOffline && isOwner)
buildDownloadButton(),
if (asset.isRemote && (isOwner || isPartner)) buildAddToAlbumButtom(),
if (album != null && album.shared) buildActivitiesButton(),
if (album != null && album is RemoteAlbum && album.shared)
buildActivitiesButton(),
buildMoreInfoButton(),
],
);
@@ -10,6 +10,7 @@ import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/providers/current_album.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_image_provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/asset_stack.provider.dart';
@@ -351,7 +352,7 @@ class GalleryViewerPage extends HookConsumerWidget {
}
handleActivities() {
if (album != null && album.shared && album.remoteId != null) {
if (album != null && album is RemoteAlbum && album.shared) {
context.pushRoute(const ActivitiesRoute());
}
}
@@ -4,7 +4,6 @@ import 'dart:io';
import 'dart:isolate';
import 'dart:ui' show DartPluginRegistrant, IsolateNameServer, PluginUtilities;
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
@@ -19,7 +18,6 @@ import 'package:immich_mobile/modules/settings/services/app_settings.service.dar
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/utils/backup_progress.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart';
import 'package:path_provider_ios/path_provider_ios.dart';
import 'package:photo_manager/photo_manager.dart';
@@ -364,30 +362,17 @@ class BackgroundService {
);
if (backupOk) {
await Store.delete(StoreKey.backupFailedSince);
final backupAlbums = [...selectedAlbums, ...excludedAlbums];
backupAlbums.sortBy((e) => e.id);
db.writeTxnSync(() {
final dbAlbums = db.backupAlbums.where().sortById().findAllSync();
final List<int> toDelete = [];
final List<BackupAlbum> toUpsert = [];
// stores the most recent `lastBackup` per album but always keeps the `selection` from the most recent DB state
diffSortedListsSync(
dbAlbums,
backupAlbums,
compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id),
both: (BackupAlbum a, BackupAlbum b) {
a.lastBackup = a.lastBackup.isAfter(b.lastBackup)
? a.lastBackup
: b.lastBackup;
toUpsert.add(a);
return true;
},
onlyFirst: (BackupAlbum a) => toUpsert.add(a),
onlySecond: (BackupAlbum b) => toDelete.add(b.isarId),
);
db.backupAlbums.deleteAllSync(toDelete);
db.backupAlbums.putAllSync(toUpsert);
});
// TODO: update album specific last backup time
final backupAlbums = await db.backupAlbums
.filter()
.not()
.selectionEqualTo(BackupSelection.none)
.findAll();
List<BackupAlbum> selectedAlbums = backupAlbums.map((e) {
e.lastBackup = DateTime.now();
return e;
}).toList();
await db.writeTxn(() => db.backupAlbums.putAll(selectedAlbums));
} else if (Store.tryGet(StoreKey.backupFailedSince) == null) {
Store.put(StoreKey.backupFailedSince, DateTime.now());
return false;
@@ -1,48 +0,0 @@
import 'dart:typed_data';
import 'package:photo_manager/photo_manager.dart';
class AvailableAlbum {
final AssetPathEntity albumEntity;
final DateTime? lastBackup;
final Uint8List? thumbnailData;
AvailableAlbum({
required this.albumEntity,
this.lastBackup,
this.thumbnailData,
});
AvailableAlbum copyWith({
AssetPathEntity? albumEntity,
DateTime? lastBackup,
Uint8List? thumbnailData,
}) {
return AvailableAlbum(
albumEntity: albumEntity ?? this.albumEntity,
lastBackup: lastBackup ?? this.lastBackup,
thumbnailData: thumbnailData ?? this.thumbnailData,
);
}
String get name => albumEntity.name;
Future<int> get assetCount => albumEntity.assetCountAsync;
String get id => albumEntity.id;
bool get isAll => albumEntity.isAll;
@override
String toString() =>
'AvailableAlbum(albumEntity: $albumEntity, lastBackup: $lastBackup, thumbnailData: $thumbnailData)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is AvailableAlbum && other.albumEntity == albumEntity;
}
@override
int get hashCode => albumEntity.hashCode;
}
@@ -1,22 +1,58 @@
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:isar/isar.dart';
part 'backup_album.model.g.dart';
@Collection(inheritance: false)
class BackupAlbum {
String id;
DateTime lastBackup;
@Enumerated(EnumType.ordinal)
BackupSelection selection;
BackupAlbum(this.id, this.lastBackup, this.selection);
Id get isarId => fastHash(id);
}
enum BackupSelection {
none,
select,
exclude;
}
@Collection(inheritance: false)
class BackupAlbum {
Id get isarId => fastHash(id);
String id;
DateTime lastBackup;
@enumerated
BackupSelection selection;
static const albumLinkId = 'album';
final album = IsarLink<LocalAlbum>();
BackupAlbum({
required this.id,
required this.lastBackup,
this.selection = BackupSelection.none,
});
BackupAlbum copyWith({
String? id,
DateTime? lastBackup,
BackupSelection? selection,
}) {
return BackupAlbum(
id: id ?? this.id,
lastBackup: lastBackup ?? this.lastBackup,
selection: selection ?? this.selection,
);
}
@override
String toString() =>
'BackupAlbum(id: $id, lastBackup: $lastBackup, selection: $selection)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is BackupAlbum &&
other.id == id &&
other.lastBackup == lastBackup &&
other.selection == selection;
}
@override
int get hashCode => id.hashCode ^ lastBackup.hashCode ^ selection.hashCode;
}
+138 -16
View File
@@ -17,18 +17,23 @@ const BackupAlbumSchema = CollectionSchema(
name: r'BackupAlbum',
id: 8308487201128361847,
properties: {
r'id': PropertySchema(
r'hashCode': PropertySchema(
id: 0,
name: r'hashCode',
type: IsarType.long,
),
r'id': PropertySchema(
id: 1,
name: r'id',
type: IsarType.string,
),
r'lastBackup': PropertySchema(
id: 1,
id: 2,
name: r'lastBackup',
type: IsarType.dateTime,
),
r'selection': PropertySchema(
id: 2,
id: 3,
name: r'selection',
type: IsarType.byte,
enumMap: _BackupAlbumselectionEnumValueMap,
@@ -40,7 +45,14 @@ const BackupAlbumSchema = CollectionSchema(
deserializeProp: _backupAlbumDeserializeProp,
idName: r'isarId',
indexes: {},
links: {},
links: {
r'album': LinkSchema(
id: 4803574038667272895,
name: r'album',
target: r'LocalAlbum',
single: true,
)
},
embeddedSchemas: {},
getId: _backupAlbumGetId,
getLinks: _backupAlbumGetLinks,
@@ -64,9 +76,10 @@ void _backupAlbumSerialize(
List<int> offsets,
Map<Type, List<int>> allOffsets,
) {
writer.writeString(offsets[0], object.id);
writer.writeDateTime(offsets[1], object.lastBackup);
writer.writeByte(offsets[2], object.selection.index);
writer.writeLong(offsets[0], object.hashCode);
writer.writeString(offsets[1], object.id);
writer.writeDateTime(offsets[2], object.lastBackup);
writer.writeByte(offsets[3], object.selection.index);
}
BackupAlbum _backupAlbumDeserialize(
@@ -76,10 +89,11 @@ BackupAlbum _backupAlbumDeserialize(
Map<Type, List<int>> allOffsets,
) {
final object = BackupAlbum(
reader.readString(offsets[0]),
reader.readDateTime(offsets[1]),
_BackupAlbumselectionValueEnumMap[reader.readByteOrNull(offsets[2])] ??
BackupSelection.none,
id: reader.readString(offsets[1]),
lastBackup: reader.readDateTime(offsets[2]),
selection:
_BackupAlbumselectionValueEnumMap[reader.readByteOrNull(offsets[3])] ??
BackupSelection.none,
);
return object;
}
@@ -92,10 +106,12 @@ P _backupAlbumDeserializeProp<P>(
) {
switch (propertyId) {
case 0:
return (reader.readString(offset)) as P;
return (reader.readLong(offset)) as P;
case 1:
return (reader.readDateTime(offset)) as P;
return (reader.readString(offset)) as P;
case 2:
return (reader.readDateTime(offset)) as P;
case 3:
return (_BackupAlbumselectionValueEnumMap[
reader.readByteOrNull(offset)] ??
BackupSelection.none) as P;
@@ -120,11 +136,13 @@ Id _backupAlbumGetId(BackupAlbum object) {
}
List<IsarLinkBase<dynamic>> _backupAlbumGetLinks(BackupAlbum object) {
return [];
return [object.album];
}
void _backupAlbumAttach(
IsarCollection<dynamic> col, Id id, BackupAlbum object) {}
IsarCollection<dynamic> col, Id id, BackupAlbum object) {
object.album.attach(col, col.isar.collection<LocalAlbum>(), r'album', id);
}
extension BackupAlbumQueryWhereSort
on QueryBuilder<BackupAlbum, BackupAlbum, QWhere> {
@@ -209,6 +227,61 @@ extension BackupAlbumQueryWhere
extension BackupAlbumQueryFilter
on QueryBuilder<BackupAlbum, BackupAlbum, QFilterCondition> {
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> hashCodeEqualTo(
int value) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'hashCode',
value: value,
));
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
hashCodeGreaterThan(
int value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'hashCode',
value: value,
));
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
hashCodeLessThan(
int value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'hashCode',
value: value,
));
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> hashCodeBetween(
int lower,
int upper, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'hashCode',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
));
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> idEqualTo(
String value, {
bool caseSensitive = true,
@@ -510,10 +583,35 @@ extension BackupAlbumQueryObject
on QueryBuilder<BackupAlbum, BackupAlbum, QFilterCondition> {}
extension BackupAlbumQueryLinks
on QueryBuilder<BackupAlbum, BackupAlbum, QFilterCondition> {}
on QueryBuilder<BackupAlbum, BackupAlbum, QFilterCondition> {
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> album(
FilterQuery<LocalAlbum> q) {
return QueryBuilder.apply(this, (query) {
return query.link(q, r'album');
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> albumIsNull() {
return QueryBuilder.apply(this, (query) {
return query.linkLength(r'album', 0, true, 0, true);
});
}
}
extension BackupAlbumQuerySortBy
on QueryBuilder<BackupAlbum, BackupAlbum, QSortBy> {
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> sortByHashCode() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'hashCode', Sort.asc);
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> sortByHashCodeDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'hashCode', Sort.desc);
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> sortById() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'id', Sort.asc);
@@ -553,6 +651,18 @@ extension BackupAlbumQuerySortBy
extension BackupAlbumQuerySortThenBy
on QueryBuilder<BackupAlbum, BackupAlbum, QSortThenBy> {
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenByHashCode() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'hashCode', Sort.asc);
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenByHashCodeDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'hashCode', Sort.desc);
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenById() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'id', Sort.asc);
@@ -604,6 +714,12 @@ extension BackupAlbumQuerySortThenBy
extension BackupAlbumQueryWhereDistinct
on QueryBuilder<BackupAlbum, BackupAlbum, QDistinct> {
QueryBuilder<BackupAlbum, BackupAlbum, QDistinct> distinctByHashCode() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'hashCode');
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QDistinct> distinctById(
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
@@ -632,6 +748,12 @@ extension BackupAlbumQueryProperty
});
}
QueryBuilder<BackupAlbum, int, QQueryOperations> hashCodeProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'hashCode');
});
}
QueryBuilder<BackupAlbum, String, QQueryOperations> idProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'id');
@@ -0,0 +1,39 @@
import 'package:collection/collection.dart';
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
class BackupAlbumState {
final List<BackupAlbum> selectedBackupAlbums;
final List<BackupAlbum> excludedBackupAlbums;
const BackupAlbumState({
required this.selectedBackupAlbums,
required this.excludedBackupAlbums,
});
BackupAlbumState copyWith({
List<BackupAlbum>? selectedBackupAlbums,
List<BackupAlbum>? excludedBackupAlbums,
}) {
return BackupAlbumState(
selectedBackupAlbums: selectedBackupAlbums ?? this.selectedBackupAlbums,
excludedBackupAlbums: excludedBackupAlbums ?? this.excludedBackupAlbums,
);
}
@override
String toString() =>
'BackupAlbumState(selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums)';
@override
bool operator ==(covariant BackupAlbumState other) {
if (identical(this, other)) return true;
final listEquals = const DeepCollectionEquality().equals;
return listEquals(other.selectedBackupAlbums, selectedBackupAlbums) &&
listEquals(other.excludedBackupAlbums, excludedBackupAlbums);
}
@override
int get hashCode =>
selectedBackupAlbums.hashCode ^ excludedBackupAlbums.hashCode;
}
@@ -2,9 +2,8 @@
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
import 'package:immich_mobile/shared/models/server_info/server_disk_info.model.dart';
@@ -30,16 +29,11 @@ class BackUpState {
final bool backupRequireCharging;
final int backupTriggerDelay;
/// All available albums on the device
final List<AvailableAlbum> availableAlbums;
final Set<AvailableAlbum> selectedBackupAlbums;
final Set<AvailableAlbum> excludedBackupAlbums;
/// Assets that are not overlapping in selected backup albums and excluded backup albums
final Set<AssetEntity> allUniqueAssets;
final Set<Asset> allUniqueAssets;
/// All assets from the selected albums that have been backup
final Set<String> selectedAlbumsBackupAssetsIds;
final int backedUpAssetsCount;
// Current Backup Asset
final CurrentUploadAsset currentUploadAsset;
@@ -56,11 +50,8 @@ class BackUpState {
required this.backupRequireWifi,
required this.backupRequireCharging,
required this.backupTriggerDelay,
required this.availableAlbums,
required this.selectedBackupAlbums,
required this.excludedBackupAlbums,
required this.allUniqueAssets,
required this.selectedAlbumsBackupAssetsIds,
required this.backedUpAssetsCount,
required this.currentUploadAsset,
});
@@ -76,11 +67,8 @@ class BackUpState {
bool? backupRequireWifi,
bool? backupRequireCharging,
int? backupTriggerDelay,
List<AvailableAlbum>? availableAlbums,
Set<AvailableAlbum>? selectedBackupAlbums,
Set<AvailableAlbum>? excludedBackupAlbums,
Set<AssetEntity>? allUniqueAssets,
Set<String>? selectedAlbumsBackupAssetsIds,
Set<Asset>? allUniqueAssets,
int? backedUpAssetsCount,
CurrentUploadAsset? currentUploadAsset,
}) {
return BackUpState(
@@ -97,19 +85,15 @@ class BackUpState {
backupRequireCharging:
backupRequireCharging ?? this.backupRequireCharging,
backupTriggerDelay: backupTriggerDelay ?? this.backupTriggerDelay,
availableAlbums: availableAlbums ?? this.availableAlbums,
selectedBackupAlbums: selectedBackupAlbums ?? this.selectedBackupAlbums,
excludedBackupAlbums: excludedBackupAlbums ?? this.excludedBackupAlbums,
allUniqueAssets: allUniqueAssets ?? this.allUniqueAssets,
selectedAlbumsBackupAssetsIds:
selectedAlbumsBackupAssetsIds ?? this.selectedAlbumsBackupAssetsIds,
backedUpAssetsCount: backedUpAssetsCount ?? this.backedUpAssetsCount,
currentUploadAsset: currentUploadAsset ?? this.currentUploadAsset,
);
}
@override
String toString() {
return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, iCloudDownloadProgress: $iCloudDownloadProgress, cancelToken: $cancelToken, serverInfo: $serverInfo, autoBackup: $autoBackup, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, backupTriggerDelay: $backupTriggerDelay, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)';
return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, iCloudDownloadProgress: $iCloudDownloadProgress, cancelToken: $cancelToken, serverInfo: $serverInfo, autoBackup: $autoBackup, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, backupTriggerDelay: $backupTriggerDelay, allUniqueAssets: $allUniqueAssets, backedUpAssetsCount: $backedUpAssetsCount, currentUploadAsset: $currentUploadAsset)';
}
@override
@@ -128,14 +112,8 @@ class BackUpState {
other.backupRequireWifi == backupRequireWifi &&
other.backupRequireCharging == backupRequireCharging &&
other.backupTriggerDelay == backupTriggerDelay &&
collectionEquals(other.availableAlbums, availableAlbums) &&
collectionEquals(other.selectedBackupAlbums, selectedBackupAlbums) &&
collectionEquals(other.excludedBackupAlbums, excludedBackupAlbums) &&
collectionEquals(other.allUniqueAssets, allUniqueAssets) &&
collectionEquals(
other.selectedAlbumsBackupAssetsIds,
selectedAlbumsBackupAssetsIds,
) &&
other.backedUpAssetsCount == backedUpAssetsCount &&
other.currentUploadAsset == currentUploadAsset;
}
@@ -152,11 +130,8 @@ class BackUpState {
backupRequireWifi.hashCode ^
backupRequireCharging.hashCode ^
backupTriggerDelay.hashCode ^
availableAlbums.hashCode ^
selectedBackupAlbums.hashCode ^
excludedBackupAlbums.hashCode ^
allUniqueAssets.hashCode ^
selectedAlbumsBackupAssetsIds.hashCode ^
backedUpAssetsCount.hashCode ^
currentUploadAsset.hashCode;
}
}
@@ -0,0 +1,32 @@
import 'package:collection/collection.dart';
class DeviceAssetState {
final List<String> assetIdsForBackup;
const DeviceAssetState({
required this.assetIdsForBackup,
});
DeviceAssetState copyWith({
List<String>? assetIdsForBackup,
}) {
return DeviceAssetState(
assetIdsForBackup: assetIdsForBackup ?? this.assetIdsForBackup,
);
}
@override
String toString() =>
'DeviceAssetState(assetIdsForBackup: $assetIdsForBackup)';
@override
bool operator ==(covariant DeviceAssetState other) {
if (identical(this, other)) return true;
final listEquals = const DeepCollectionEquality().equals;
return listEquals(other.assetIdsForBackup, assetIdsForBackup);
}
@override
int get hashCode => assetIdsForBackup.hashCode;
}
@@ -1,26 +1,25 @@
import 'dart:io';
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
import 'package:immich_mobile/modules/backup/providers/backup_album.provider.dart';
import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart';
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/server_info/server_disk_info.model.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/providers/app_state.provider.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/services/server_info.service.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:permission_handler/permission_handler.dart';
@@ -53,11 +52,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
diskUse: "0",
diskUsagePercentage: 0,
),
availableAlbums: const [],
selectedBackupAlbums: const {},
excludedBackupAlbums: const {},
allUniqueAssets: const {},
selectedAlbumsBackupAssetsIds: const {},
backedUpAssetsCount: 0,
currentUploadAsset: CurrentUploadAsset(
id: '...',
fileCreatedAt: DateTime.parse('2020-10-04'),
@@ -78,61 +74,6 @@ class BackupNotifier extends StateNotifier<BackUpState> {
final Isar _db;
final Ref ref;
///
/// UI INTERACTION
///
/// Album selection
/// Due to the overlapping assets across multiple albums on the device
/// We have method to include and exclude albums
/// The total unique assets will be used for backing mechanism
///
void addAlbumForBackup(AvailableAlbum album) {
if (state.excludedBackupAlbums.contains(album)) {
removeExcludedAlbumForBackup(album);
}
state = state
.copyWith(selectedBackupAlbums: {...state.selectedBackupAlbums, album});
}
void addExcludedAlbumForBackup(AvailableAlbum album) {
if (state.selectedBackupAlbums.contains(album)) {
removeAlbumForBackup(album);
}
state = state
.copyWith(excludedBackupAlbums: {...state.excludedBackupAlbums, album});
}
void removeAlbumForBackup(AvailableAlbum album) {
Set<AvailableAlbum> currentSelectedAlbums = state.selectedBackupAlbums;
currentSelectedAlbums.removeWhere((a) => a == album);
state = state.copyWith(selectedBackupAlbums: currentSelectedAlbums);
}
void removeExcludedAlbumForBackup(AvailableAlbum album) {
Set<AvailableAlbum> currentExcludedAlbums = state.excludedBackupAlbums;
currentExcludedAlbums.removeWhere((a) => a == album);
state = state.copyWith(excludedBackupAlbums: currentExcludedAlbums);
}
Future<void> backupAlbumSelectionDone() {
if (state.selectedBackupAlbums.isEmpty) {
// disable any backup
cancelBackup();
setAutoBackup(false);
configureBackgroundBackup(
enabled: false,
onError: (msg) {},
onBatteryInfo: () {},
);
}
return _updateBackupAssetCount();
}
void setAutoBackup(bool enabled) {
Store.put(StoreKey.autoBackup, enabled);
state = state.copyWith(autoBackup: enabled);
@@ -204,102 +145,6 @@ class BackupNotifier extends StateNotifier<BackUpState> {
}
}
///
/// Get all album on the device
/// Get all selected and excluded album from the user's persistent storage
/// If this is the first time performing backup - set the default selected album to be
/// the one that has all assets (`Recent` on Android, `Recents` on iOS)
///
Future<void> _getBackupAlbumsInfo() async {
Stopwatch stopwatch = Stopwatch()..start();
// Get all albums on the device
List<AvailableAlbum> availableAlbums = [];
List<AssetPathEntity> albums = await PhotoManager.getAssetPathList(
hasAll: true,
type: RequestType.common,
);
// Map of id -> album for quick album lookup later on.
Map<String, AssetPathEntity> albumMap = {};
log.info('Found ${albums.length} local albums');
for (AssetPathEntity album in albums) {
AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
final assetCountInAlbum = await album.assetCountAsync;
if (assetCountInAlbum > 0) {
final assetList = await album.getAssetListPaged(page: 0, size: 1);
// Even though we check assetCountInAlbum to make sure that there are assets in album
// The `getAssetListPaged` method still return empty list and cause not assets get rendered
if (assetList.isEmpty) {
continue;
}
final thumbnailAsset = assetList.first;
try {
final thumbnailData = await thumbnailAsset
.thumbnailDataWithSize(const ThumbnailSize(512, 512));
availableAlbum =
availableAlbum.copyWith(thumbnailData: thumbnailData);
} catch (e, stack) {
log.severe(
"Failed to get thumbnail for album ${album.name}",
e,
stack,
);
}
availableAlbums.add(availableAlbum);
albumMap[album.id] = album;
}
}
state = state.copyWith(availableAlbums: availableAlbums);
final List<BackupAlbum> excludedBackupAlbums =
await _backupService.excludedAlbumsQuery().findAll();
final List<BackupAlbum> selectedBackupAlbums =
await _backupService.selectedAlbumsQuery().findAll();
// Generate AssetPathEntity from id to add to local state
final Set<AvailableAlbum> selectedAlbums = {};
for (final BackupAlbum ba in selectedBackupAlbums) {
final albumAsset = albumMap[ba.id];
if (albumAsset != null) {
selectedAlbums.add(
AvailableAlbum(albumEntity: albumAsset, lastBackup: ba.lastBackup),
);
} else {
log.severe('Selected album not found');
}
}
final Set<AvailableAlbum> excludedAlbums = {};
for (final BackupAlbum ba in excludedBackupAlbums) {
final albumAsset = albumMap[ba.id];
if (albumAsset != null) {
excludedAlbums.add(
AvailableAlbum(albumEntity: albumAsset, lastBackup: ba.lastBackup),
);
} else {
log.severe('Excluded album not found');
}
}
state = state.copyWith(
selectedBackupAlbums: selectedAlbums,
excludedBackupAlbums: excludedAlbums,
);
log.info(
"_getBackupAlbumsInfo: Found ${availableAlbums.length} available albums",
);
debugPrint("_getBackupAlbumsInfo takes ${stopwatch.elapsedMilliseconds}ms");
}
///
/// From all the selected and albums assets
/// Find the assets that are not overlapping between the two sets
@@ -307,26 +152,19 @@ class BackupNotifier extends StateNotifier<BackUpState> {
///
Future<void> _updateBackupAssetCount() async {
final duplicatedAssetIds = await _backupService.getDuplicatedAssetIds();
final Set<AssetEntity> assetsFromSelectedAlbums = {};
final Set<AssetEntity> assetsFromExcludedAlbums = {};
final backupAlbums = await ref.read(backupAlbumsProvider.future);
final Set<Asset> assetsFromSelectedAlbums = {};
final Set<Asset> assetsFromExcludedAlbums = {};
for (final album in state.selectedBackupAlbums) {
final assets = await album.albumEntity.getAssetListRange(
start: 0,
end: await album.albumEntity.assetCountAsync,
);
assetsFromSelectedAlbums.addAll(assets);
for (final selected in backupAlbums.selectedBackupAlbums) {
assetsFromSelectedAlbums.addAll(selected.album.value?.assets ?? []);
}
for (final album in state.excludedBackupAlbums) {
final assets = await album.albumEntity.getAssetListRange(
start: 0,
end: await album.albumEntity.assetCountAsync,
);
assetsFromExcludedAlbums.addAll(assets);
for (final excluded in backupAlbums.excludedBackupAlbums) {
assetsFromExcludedAlbums.addAll(excluded.album.value?.assets ?? []);
}
final Set<AssetEntity> allUniqueAssets =
final Set<Asset> allUniqueAssets =
assetsFromSelectedAlbums.difference(assetsFromExcludedAlbums);
final allAssetsInDatabase = await _backupService.getDeviceBackupAsset();
@@ -336,34 +174,30 @@ class BackupNotifier extends StateNotifier<BackUpState> {
// Find asset that were backup from selected albums
final Set<String> selectedAlbumsBackupAssets =
Set.from(allUniqueAssets.map((e) => e.id));
allUniqueAssets.map((e) => e.localId).nonNulls.toSet();
selectedAlbumsBackupAssets
.removeWhere((assetId) => !allAssetsInDatabase.contains(assetId));
// Remove duplicated asset from all unique assets
allUniqueAssets.removeWhere(
(asset) => duplicatedAssetIds.contains(asset.id),
);
allUniqueAssets
.removeWhere((asset) => duplicatedAssetIds.contains(asset.localId));
if (allUniqueAssets.isEmpty) {
log.info("No assets are selected for back up");
log.fine("No assets are selected for back up");
state = state.copyWith(
backupProgress: BackUpProgressEnum.idle,
allAssetsInDatabase: allAssetsInDatabase,
allUniqueAssets: {},
selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets,
backedUpAssetsCount: selectedAlbumsBackupAssets.length,
);
} else {
state = state.copyWith(
allAssetsInDatabase: allAssetsInDatabase,
allUniqueAssets: allUniqueAssets,
selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets,
backedUpAssetsCount: selectedAlbumsBackupAssets.length,
);
}
// Save to persistent storage
await _updatePersistentAlbumsSelection();
}
/// Get all necessary information for calculating the available albums,
@@ -378,7 +212,6 @@ class BackupNotifier extends StateNotifier<BackUpState> {
}
if (state.backupProgress != BackUpProgressEnum.inBackground) {
await _getBackupAlbumsInfo();
await updateServerInfo();
await _updateBackupAssetCount();
} else {
@@ -386,40 +219,6 @@ class BackupNotifier extends StateNotifier<BackUpState> {
}
}
/// Save user selection of selected albums and excluded albums to database
Future<void> _updatePersistentAlbumsSelection() {
final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
final selected = state.selectedBackupAlbums.map(
(e) => BackupAlbum(e.id, e.lastBackup ?? epoch, BackupSelection.select),
);
final excluded = state.excludedBackupAlbums.map(
(e) => BackupAlbum(e.id, e.lastBackup ?? epoch, BackupSelection.exclude),
);
final backupAlbums = selected.followedBy(excluded).toList();
backupAlbums.sortBy((e) => e.id);
return _db.writeTxn(() async {
final dbAlbums = await _db.backupAlbums.where().sortById().findAll();
final List<int> toDelete = [];
final List<BackupAlbum> toUpsert = [];
// stores the most recent `lastBackup` per album but always keeps the `selection` the user just made
diffSortedListsSync(
dbAlbums,
backupAlbums,
compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id),
both: (BackupAlbum a, BackupAlbum b) {
b.lastBackup =
a.lastBackup.isAfter(b.lastBackup) ? a.lastBackup : b.lastBackup;
toUpsert.add(b);
return true;
},
onlyFirst: (BackupAlbum a) => toDelete.add(a.isarId),
onlySecond: (BackupAlbum b) => toUpsert.add(b),
);
await _db.backupAlbums.deleteAll(toDelete);
await _db.backupAlbums.putAll(toUpsert);
});
}
/// Invoke backup process
Future<void> startBackupProcess() async {
debugPrint("Start backup process");
@@ -473,12 +272,6 @@ class BackupNotifier extends StateNotifier<BackUpState> {
}
}
void setAvailableAlbums(availableAlbums) {
state = state.copyWith(
availableAlbums: availableAlbums,
);
}
void _onBackupError(ErrorUploadAsset errorAssetInfo) {
ref.watch(errorBackupListProvider.notifier).add(errorAssetInfo);
}
@@ -506,39 +299,16 @@ class BackupNotifier extends StateNotifier<BackUpState> {
if (isDuplicated) {
state = state.copyWith(
allUniqueAssets: state.allUniqueAssets
.where((asset) => asset.id != deviceAssetId)
.where((asset) => asset.localId != deviceAssetId)
.toSet(),
);
} else {
state = state.copyWith(
selectedAlbumsBackupAssetsIds: {
...state.selectedAlbumsBackupAssetsIds,
deviceAssetId,
},
backedUpAssetsCount: state.backedUpAssetsCount + 1,
allAssetsInDatabase: [...state.allAssetsInDatabase, deviceAssetId],
);
}
if (state.allUniqueAssets.length -
state.selectedAlbumsBackupAssetsIds.length ==
0) {
final latestAssetBackup =
state.allUniqueAssets.map((e) => e.modifiedDateTime).reduce(
(v, e) => e.isAfter(v) ? e : v,
);
state = state.copyWith(
selectedBackupAlbums: state.selectedBackupAlbums
.map((e) => e.copyWith(lastBackup: latestAssetBackup))
.toSet(),
excludedBackupAlbums: state.excludedBackupAlbums
.map((e) => e.copyWith(lastBackup: latestAssetBackup))
.toSet(),
backupProgress: BackUpProgressEnum.done,
progressInPercentage: 0.0,
);
_updatePersistentAlbumsSelection();
}
updateServerInfo();
}
@@ -595,35 +365,19 @@ class BackupNotifier extends StateNotifier<BackUpState> {
}
Future<void> resumeBackup() async {
final List<BackupAlbum> selectedBackupAlbums = await _db.backupAlbums
.filter()
.selectionEqualTo(BackupSelection.select)
.findAll();
final List<BackupAlbum> excludedBackupAlbums = await _db.backupAlbums
.filter()
.selectionEqualTo(BackupSelection.exclude)
.findAll();
Set<AvailableAlbum> selectedAlbums = state.selectedBackupAlbums;
Set<AvailableAlbum> excludedAlbums = state.excludedBackupAlbums;
if (selectedAlbums.isNotEmpty) {
selectedAlbums = _updateAlbumsBackupTime(
selectedAlbums,
selectedBackupAlbums,
);
}
if (excludedAlbums.isNotEmpty) {
excludedAlbums = _updateAlbumsBackupTime(
excludedAlbums,
excludedBackupAlbums,
);
}
final BackUpProgressEnum previous = state.backupProgress;
state = state.copyWith(
backupProgress: BackUpProgressEnum.inBackground,
selectedBackupAlbums: selectedAlbums,
excludedBackupAlbums: excludedAlbums,
);
state = state.copyWith(backupProgress: BackUpProgressEnum.inBackground);
// TODO: update album specific last backup time
final backupAlbums = await ref.read(backupAlbumsProvider.future);
List<BackupAlbum> selectedAlbums = backupAlbums.selectedBackupAlbums
.followedBy(backupAlbums.excludedBackupAlbums)
.map((e) {
e.lastBackup = DateTime.now();
return e;
}).toList();
await _db.writeTxn(() => _db.backupAlbums.putAll(selectedAlbums));
// assumes the background service is currently running
// if true, waits until it has stopped to start the backup
final bool hasLock = await _backgroundService.acquireLock();
@@ -633,26 +387,6 @@ class BackupNotifier extends StateNotifier<BackUpState> {
return _resumeBackup();
}
Set<AvailableAlbum> _updateAlbumsBackupTime(
Set<AvailableAlbum> albums,
List<BackupAlbum> backupAlbums,
) {
Set<AvailableAlbum> result = {};
for (BackupAlbum ba in backupAlbums) {
try {
AvailableAlbum a = albums.firstWhere((e) => e.id == ba.id);
result.add(a.copyWith(lastBackup: ba.lastBackup));
} on StateError {
log.severe(
"[_updateAlbumBackupTime] failed to find album in state",
"State Error",
StackTrace.current,
);
}
}
return result;
}
Future<void> notifyBackgroundServiceCanRun() async {
const allowedStates = [
AppStateEnum.inactive,
@@ -0,0 +1,202 @@
import 'package:immich_mobile/extensions/album_extensions.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
import 'package:immich_mobile/modules/backup/models/backup_album_state.model.dart';
import 'package:immich_mobile/modules/backup/providers/device_assets.provider.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/device_asset.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'backup_album.provider.g.dart';
@riverpod
class BackupAlbums extends _$BackupAlbums {
final Logger _logger = Logger("BackupAlbumsProvider");
@override
Future<BackupAlbumState> build() async {
final db = ref.read(dbProvider);
return BackupAlbumState(
selectedBackupAlbums: await db.backupAlbums
.filter()
.selectionEqualTo(BackupSelection.select)
.findAll(),
excludedBackupAlbums: await db.backupAlbums
.filter()
.selectionEqualTo(BackupSelection.exclude)
.findAll(),
);
}
Future<void> addBackupAlbum(
LocalAlbum album,
BackupSelection selection,
) async {
final db = ref.read(dbProvider);
final albumInDB =
await db.backupAlbums.filter().idEqualTo(album.id).findFirst();
final backupAlbum = albumInDB ??
BackupAlbum(
id: album.id,
lastBackup: DateTime.fromMillisecondsSinceEpoch(0, isUtc: true),
selection: selection,
);
backupAlbum.selection = selection;
backupAlbum.album.value = album;
final assets = await _updateDeviceAssetsToSelection(album, selection);
await db.writeTxn(() async {
await db.backupAlbums.store(backupAlbum);
await db.deviceAssets.putAll(assets);
});
ref.invalidateSelf();
}
Future<void> syncWithLocalAlbum(LocalAlbum album) async {
final db = ref.read(dbProvider);
final albumInDB =
await db.backupAlbums.filter().idEqualTo(album.id).findFirst();
if (albumInDB == null) {
_logger.fine("No backup album for local album - ${album.name}");
return;
}
albumInDB.album.value = album;
final assets =
await _updateDeviceAssetsToSelection(album, albumInDB.selection);
await db.writeTxn(() async {
await db.backupAlbums.store(albumInDB);
await db.deviceAssets.putAll(assets);
});
ref.invalidateSelf();
}
Future<void> _updateAlbumSelection(
LocalAlbum localAlbum,
BackupSelection selection,
) async {
await localAlbum.backup.load();
final backupAlbum = localAlbum.backup.value;
if (backupAlbum == null) {
return addBackupAlbum(localAlbum, selection);
}
final db = ref.read(dbProvider);
backupAlbum.selection = selection;
final assets = await _updateDeviceAssetsToSelection(localAlbum, selection);
await db.writeTxn(() async {
await db.backupAlbums.store(backupAlbum);
await db.deviceAssets.putAll(assets);
});
ref.invalidateSelf();
}
Future<void> selectAlbumForBackup(LocalAlbum album) =>
_updateAlbumSelection(album, BackupSelection.select);
Future<void> excludeAlbumFromBackup(LocalAlbum album) =>
_updateAlbumSelection(album, BackupSelection.exclude);
Future<void> deSelectAlbum(LocalAlbum album) =>
_updateAlbumSelection(album, BackupSelection.none);
Future<List<LocalAlbum>> _getAllLocalAlbumWithAsset(Asset asset) async {
return await ref
.read(dbProvider)
.localAlbums
.filter()
.assets((q) => q.idEqualTo(asset.id))
.findAll();
}
Future<List<DeviceAsset>> _updateDeviceAssetsToSelection(
LocalAlbum album,
BackupSelection selection,
) async {
await album.assets.load();
final assets = album.assets.toList();
final updatedAssets = <DeviceAsset>[];
for (final asset in assets) {
if (!asset.isLocal) {
_logger.warning("Local id not available for asset ID - ${asset.id}");
continue;
}
final deviceAsset = await ref
.read(dbProvider)
.deviceAssets
.where()
.idEqualTo(asset.localId!)
.findFirst();
if (deviceAsset == null) {
_logger.warning(
"Device asset not available for local asset ID - ${asset.id}",
);
continue;
}
// Exclude takes priority
if (selection == BackupSelection.exclude) {
deviceAsset.backupSelection = selection;
} else if (selection == BackupSelection.select) {
bool shouldExclude = false;
final localAlbums = await _getAllLocalAlbumWithAsset(asset);
for (final a in localAlbums) {
await a.backup.load();
// Check if there is any other excluded albums in which the asset is present
if (a.backup.value?.selection == BackupSelection.exclude &&
a.id != album.id) {
shouldExclude = true;
break;
}
}
// Force exclude ignoring selection if asset is part of another excluded album
deviceAsset.backupSelection =
shouldExclude ? BackupSelection.exclude : BackupSelection.select;
} else if (selection == BackupSelection.none) {
bool setToNone = true;
BackupSelection? oldSelection;
final localAlbums = await _getAllLocalAlbumWithAsset(asset);
for (final a in localAlbums) {
await a.backup.load();
// Check if there is any other albums in which the asset is present
if (a.backup.value?.selection != BackupSelection.none &&
a.id != album.id) {
setToNone = false;
oldSelection = a.backup.value?.selection;
break;
}
}
// Only set to none when the asset is not part of any other selected or excluded albums
if (setToNone) {
deviceAsset.backupSelection = BackupSelection.none;
} else if (oldSelection != null) {
deviceAsset.backupSelection = oldSelection;
}
}
updatedAssets.add(deviceAsset);
}
if (updatedAssets.isNotEmpty) {
ref.invalidate(deviceAssetsProvider);
}
return updatedAssets;
}
}
@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'backup_album.provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$backupAlbumsHash() => r'b4fcbf7b0bb6c6ee3323165470cf8d4758185487';
/// See also [BackupAlbums].
@ProviderFor(BackupAlbums)
final backupAlbumsProvider =
AutoDisposeAsyncNotifierProvider<BackupAlbums, BackupAlbumState>.internal(
BackupAlbums.new,
name: r'backupAlbumsProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$backupAlbumsHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$BackupAlbums = AutoDisposeAsyncNotifier<BackupAlbumState>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
@@ -0,0 +1,23 @@
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
import 'package:immich_mobile/modules/backup/models/device_album_state.model.dart';
import 'package:immich_mobile/shared/models/device_asset.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:isar/isar.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'device_assets.provider.g.dart';
@riverpod
class DeviceAssets extends _$DeviceAssets {
@override
Future<DeviceAssetState> build() async {
final db = ref.read(dbProvider);
return DeviceAssetState(
assetIdsForBackup: await db.deviceAssets
.filter()
.backupSelectionEqualTo(BackupSelection.select)
.idProperty()
.findAll(),
);
}
}
@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'device_assets.provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$deviceAssetsHash() => r'e6409a8971bbaed64e18672cb1ff8e980dd3fda5';
/// See also [DeviceAssets].
@ProviderFor(DeviceAssets)
final deviceAssetsProvider =
AutoDisposeAsyncNotifierProvider<DeviceAssets, DeviceAssetState>.internal(
DeviceAssets.new,
name: r'deviceAssetsProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$deviceAssetsHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$DeviceAssets = AutoDisposeAsyncNotifier<DeviceAssetState>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
@@ -1,223 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
class AlbumInfoCard extends HookConsumerWidget {
final Uint8List? imageData;
final AvailableAlbum albumInfo;
const AlbumInfoCard({super.key, this.imageData, required this.albumInfo});
@override
Widget build(BuildContext context, WidgetRef ref) {
final bool isSelected =
ref.watch(backupProvider).selectedBackupAlbums.contains(albumInfo);
final bool isExcluded =
ref.watch(backupProvider).excludedBackupAlbums.contains(albumInfo);
final isDarkTheme = context.isDarkTheme;
ColorFilter selectedFilter = ColorFilter.mode(
context.primaryColor.withAlpha(100),
BlendMode.darken,
);
ColorFilter excludedFilter =
ColorFilter.mode(Colors.red.withAlpha(75), BlendMode.darken);
ColorFilter unselectedFilter =
const ColorFilter.mode(Colors.black, BlendMode.color);
buildSelectedTextBox() {
if (isSelected) {
return Chip(
visualDensity: VisualDensity.compact,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
label: Text(
"album_info_card_backup_album_included",
style: TextStyle(
fontSize: 10,
color: isDarkTheme ? Colors.black : Colors.white,
fontWeight: FontWeight.bold,
),
).tr(),
backgroundColor: context.primaryColor,
);
} else if (isExcluded) {
return Chip(
visualDensity: VisualDensity.compact,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
label: Text(
"album_info_card_backup_album_excluded",
style: TextStyle(
fontSize: 10,
color: isDarkTheme ? Colors.black : Colors.white,
fontWeight: FontWeight.bold,
),
).tr(),
backgroundColor: Colors.red[300],
);
}
return const SizedBox();
}
buildImageFilter() {
if (isSelected) {
return selectedFilter;
} else if (isExcluded) {
return excludedFilter;
} else {
return unselectedFilter;
}
}
return GestureDetector(
onTap: () {
HapticFeedback.selectionClick();
if (isSelected) {
ref.read(backupProvider.notifier).removeAlbumForBackup(albumInfo);
} else {
ref.read(backupProvider.notifier).addAlbumForBackup(albumInfo);
}
},
onDoubleTap: () {
HapticFeedback.selectionClick();
if (isExcluded) {
// Remove from exclude album list
ref
.read(backupProvider.notifier)
.removeExcludedAlbumForBackup(albumInfo);
} else {
// Add to exclude album list
if (albumInfo.id == 'isAll' || albumInfo.name == 'Recents') {
ImmichToast.show(
context: context,
msg: 'Cannot exclude album contains all assets',
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
return;
}
ref
.read(backupProvider.notifier)
.addExcludedAlbumForBackup(albumInfo);
}
},
child: Card(
clipBehavior: Clip.hardEdge,
margin: const EdgeInsets.all(1),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), // if you need this
side: BorderSide(
color: isDarkTheme
? const Color.fromARGB(255, 37, 35, 35)
: const Color(0xFFC9C9C9),
width: 1,
),
),
elevation: 0,
borderOnForeground: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Stack(
clipBehavior: Clip.hardEdge,
children: [
ColorFiltered(
colorFilter: buildImageFilter(),
child: Image(
width: double.infinity,
height: double.infinity,
image: imageData != null
? MemoryImage(imageData!)
: const AssetImage(
'assets/immich-logo-no-outline.png',
) as ImageProvider,
fit: BoxFit.cover,
),
),
Positioned(
bottom: 10,
right: 25,
child: buildSelectedTextBox(),
),
],
),
),
Padding(
padding: const EdgeInsets.only(
left: 25,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
albumInfo.name,
style: TextStyle(
fontSize: 14,
color: context.primaryColor,
fontWeight: FontWeight.bold,
),
),
Padding(
padding: const EdgeInsets.only(top: 2.0),
child: FutureBuilder(
builder: ((context, snapshot) {
if (snapshot.hasData) {
return Text(
snapshot.data.toString() +
(albumInfo.isAll
? " (${'backup_all'.tr()})"
: ""),
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
);
}
return const Text("0");
}),
future: albumInfo.assetCount,
),
),
],
),
),
IconButton(
onPressed: () {
context.pushRoute(
AlbumPreviewRoute(album: albumInfo.albumEntity),
);
},
icon: Icon(
Icons.image_outlined,
color: context.primaryColor,
size: 24,
),
splashRadius: 25,
),
],
),
),
],
),
),
);
}
}
@@ -1,151 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
class AlbumInfoListTile extends HookConsumerWidget {
final Uint8List? imageData;
final AvailableAlbum albumInfo;
const AlbumInfoListTile({super.key, this.imageData, required this.albumInfo});
@override
Widget build(BuildContext context, WidgetRef ref) {
final bool isSelected =
ref.watch(backupProvider).selectedBackupAlbums.contains(albumInfo);
final bool isExcluded =
ref.watch(backupProvider).excludedBackupAlbums.contains(albumInfo);
ColorFilter selectedFilter = ColorFilter.mode(
context.primaryColor.withAlpha(100),
BlendMode.darken,
);
ColorFilter excludedFilter =
ColorFilter.mode(Colors.red.withAlpha(75), BlendMode.darken);
ColorFilter unselectedFilter =
const ColorFilter.mode(Colors.black, BlendMode.color);
var assetCount = useState(0);
useEffect(
() {
albumInfo.assetCount.then((value) => assetCount.value = value);
return null;
},
[albumInfo],
);
buildImageFilter() {
if (isSelected) {
return selectedFilter;
} else if (isExcluded) {
return excludedFilter;
} else {
return unselectedFilter;
}
}
buildTileColor() {
if (isSelected) {
return context.isDarkTheme
? context.primaryColor.withAlpha(100)
: context.primaryColor.withAlpha(25);
} else if (isExcluded) {
return context.isDarkTheme
? Colors.red[300]?.withAlpha(150)
: Colors.red[100]?.withAlpha(150);
} else {
return Colors.transparent;
}
}
return GestureDetector(
onDoubleTap: () {
HapticFeedback.selectionClick();
if (isExcluded) {
// Remove from exclude album list
ref
.read(backupProvider.notifier)
.removeExcludedAlbumForBackup(albumInfo);
} else {
// Add to exclude album list
if (albumInfo.id == 'isAll' || albumInfo.name == 'Recents') {
ImmichToast.show(
context: context,
msg: 'Cannot exclude album contains all assets',
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
return;
}
ref
.read(backupProvider.notifier)
.addExcludedAlbumForBackup(albumInfo);
}
},
child: ListTile(
tileColor: buildTileColor(),
contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
onTap: () {
HapticFeedback.selectionClick();
if (isSelected) {
ref.read(backupProvider.notifier).removeAlbumForBackup(albumInfo);
} else {
ref.read(backupProvider.notifier).addAlbumForBackup(albumInfo);
}
},
leading: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
height: 80,
width: 80,
child: ColorFiltered(
colorFilter: buildImageFilter(),
child: Image(
width: double.infinity,
height: double.infinity,
image: imageData != null
? MemoryImage(imageData!)
: const AssetImage(
'assets/immich-logo-no-outline.png',
) as ImageProvider,
fit: BoxFit.cover,
),
),
),
),
title: Text(
albumInfo.name,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
subtitle: Text(assetCount.value.toString()),
trailing: IconButton(
onPressed: () {
context.pushRoute(
AlbumPreviewRoute(album: albumInfo.albumEntity),
);
},
icon: Icon(
Icons.image_outlined,
color: context.primaryColor,
size: 24,
),
splashRadius: 25,
),
),
);
}
}
@@ -0,0 +1,324 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
import 'package:immich_mobile/modules/backup/providers/backup_album.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/shared/ui/immich_toast.dart';
import 'package:photo_manager/photo_manager.dart';
class BackupAlbumInfoListItem extends ConsumerWidget {
final LocalAlbum album;
const BackupAlbumInfoListItem({super.key, required this.album});
@override
Widget build(BuildContext context, WidgetRef ref) {
final backupAlbums = ref.watch(backupAlbumsProvider);
final backupAlbumNotifier = ref.read(backupAlbumsProvider.notifier);
final isSelected =
backupAlbums.value?.selectedBackupAlbums.any((a) => a.id == album.id) ??
false;
final isExcluded =
backupAlbums.value?.excludedBackupAlbums.any((a) => a.id == album.id) ??
false;
final backupSelection = isSelected
? BackupSelection.select
: isExcluded
? BackupSelection.exclude
: BackupSelection.none;
void onTap() {
HapticFeedback.selectionClick();
if (isSelected || isExcluded) {
backupAlbumNotifier.deSelectAlbum(album);
} else {
backupAlbumNotifier.selectAlbumForBackup(album);
}
}
void onDoubleTap() {
HapticFeedback.selectionClick();
if (isExcluded) {
backupAlbumNotifier.deSelectAlbum(album);
} else {
if (album.id == LocalAlbum.isAllId || album.name == 'Recents') {
ImmichToast.show(
context: context,
msg: 'Cannot exclude album contains all assets',
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
return;
}
backupAlbumNotifier.excludeAlbumFromBackup(album);
}
}
return GestureDetector(
onTap: onTap,
onDoubleTap: onDoubleTap,
child: context.isMobile
? _AlbumDetailListTile(album, backupSelection)
: _AlbumDetailCard(album, backupSelection),
);
}
}
class _AlbumFilteredThumbnail extends StatelessWidget {
final Asset? thumbnail;
final BackupSelection selection;
const _AlbumFilteredThumbnail(this.thumbnail, this.selection);
@override
Widget build(BuildContext context) {
ColorFilter selectedFilter = ColorFilter.mode(
context.primaryColor.withAlpha(100),
BlendMode.darken,
);
ColorFilter excludedFilter =
ColorFilter.mode(Colors.red.withAlpha(75), BlendMode.darken);
ColorFilter unselectedFilter =
const ColorFilter.mode(Colors.black, BlendMode.color);
return ColorFiltered(
colorFilter: switch (selection) {
BackupSelection.select => selectedFilter,
BackupSelection.exclude => excludedFilter,
BackupSelection.none => unselectedFilter,
},
child: ImmichImage(thumbnail),
);
}
}
/// Portrait list components
class _AlbumDetailListTile extends StatelessWidget {
final LocalAlbum album;
final BackupSelection selection;
const _AlbumDetailListTile(this.album, this.selection);
@override
Widget build(BuildContext context) {
// Wrapped ListTile with Material to prevent tileColor overflow
// https://github.com/flutter/flutter/issues/86584
return Material(
type: MaterialType.transparency,
child: ListTile(
tileColor: switch (selection) {
BackupSelection.select => context.isDarkTheme
? context.primaryColor.withAlpha(100)
: context.primaryColor.withAlpha(25),
BackupSelection.exclude => context.isDarkTheme
? Colors.red[300]?.withAlpha(150)
: Colors.red[100]?.withAlpha(150),
BackupSelection.none => Colors.transparent,
},
contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
leading: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: SizedBox(
height: 80,
width: 80,
child: _AlbumFilteredThumbnail(album.thumbnail, selection),
),
),
title: Text(
album.name,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
subtitle: Text(album.assetCount.toString()),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
_AlbumDetailCardChip(selection),
_AlbumPreviewButton(album),
],
),
),
);
}
}
/// Landscape card components
class _AlbumDetailCard extends StatelessWidget {
final LocalAlbum album;
final BackupSelection selection;
const _AlbumDetailCard(this.album, this.selection);
@override
Widget build(BuildContext context) {
return Card(
clipBehavior: Clip.hardEdge,
margin: const EdgeInsets.all(1),
shape: RoundedRectangleBorder(
borderRadius:
const BorderRadius.all(Radius.circular(12)), // if you need this
side: BorderSide(
color: context.isDarkTheme
? const Color.fromARGB(255, 37, 35, 35)
: const Color(0xFFC9C9C9),
width: 1,
),
),
elevation: 0,
borderOnForeground: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Stack(
clipBehavior: Clip.hardEdge,
children: [
_AlbumFilteredThumbnail(
album.thumbnail,
selection,
),
if (selection != BackupSelection.none)
Positioned(
bottom: 10,
right: 25,
child: _AlbumDetailCardChip(selection),
),
],
),
),
Padding(
padding: const EdgeInsets.only(left: 25),
child: _AlbumDetailCardDetails(album),
),
],
),
);
}
}
class _AlbumDetailCardChip extends StatelessWidget {
final BackupSelection selection;
const _AlbumDetailCardChip(this.selection);
@override
Widget build(BuildContext context) {
if (selection == BackupSelection.none) {
return const SizedBox();
}
return Chip(
visualDensity: VisualDensity.compact,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(5)),
),
label: Text(
selection == BackupSelection.select
? "album_info_card_backup_album_included"
: "album_info_card_backup_album_excluded",
style: TextStyle(
fontSize: 10,
color: context.isDarkTheme ? Colors.black : Colors.white,
fontWeight: FontWeight.bold,
),
).tr(),
backgroundColor: selection == BackupSelection.select
? context.primaryColor
: Colors.red[300],
);
}
}
class _AlbumDetailCardDetails extends StatelessWidget {
final LocalAlbum album;
const _AlbumDetailCardDetails(this.album);
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
album.name,
style: TextStyle(
fontSize: 14,
color: context.primaryColor,
fontWeight: FontWeight.bold,
),
),
Padding(
padding: const EdgeInsets.only(top: 2.0),
child: FutureBuilder(
builder: ((context, snapshot) {
if (snapshot.hasData) {
return Text(
album.assetCount.toString() +
(snapshot.data!.isAll
? " (${'backup_all'.tr()})"
: ""),
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
);
}
return const Text("0");
}),
future: AssetPathEntity.fromId(album.id),
),
),
],
),
),
_AlbumPreviewButton(album),
],
);
}
}
class _AlbumPreviewButton extends StatelessWidget {
final LocalAlbum album;
const _AlbumPreviewButton(this.album);
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: () => context.pushRoute(
LocalAlbumViewerRoute(
album: album,
selectEnabled: false,
),
),
icon: Icon(
Icons.image_outlined,
color: context.primaryColor,
size: 24,
),
splashRadius: 25,
);
}
}
@@ -1,98 +0,0 @@
import 'dart:typed_data';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:photo_manager/photo_manager.dart';
@RoutePage()
class AlbumPreviewPage extends HookConsumerWidget {
final AssetPathEntity album;
const AlbumPreviewPage({super.key, required this.album});
@override
Widget build(BuildContext context, WidgetRef ref) {
final assets = useState<List<AssetEntity>>([]);
getAssetsInAlbum() async {
assets.value = await album.getAssetListRange(
start: 0,
end: await album.assetCountAsync,
);
}
useEffect(
() {
getAssetsInAlbum();
return null;
},
[],
);
return Scaffold(
appBar: AppBar(
elevation: 0,
title: Column(
children: [
Text(
album.name,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
),
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
"ID ${album.id}",
style: TextStyle(
fontSize: 10,
color: Colors.grey[600],
fontWeight: FontWeight.bold,
),
),
),
],
),
leading: IconButton(
onPressed: () => context.popRoute(),
icon: const Icon(Icons.arrow_back_ios_new_rounded),
),
),
body: GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 5,
crossAxisSpacing: 2,
mainAxisSpacing: 2,
),
itemCount: assets.value.length,
itemBuilder: (context, index) {
Future<Uint8List?> thumbData =
assets.value[index].thumbnailDataWithSize(
const ThumbnailSize(200, 200),
quality: 50,
);
return FutureBuilder<Uint8List?>(
future: thumbData,
builder: ((context, snapshot) {
if (snapshot.hasData && snapshot.data != null) {
return Image.memory(
snapshot.data!,
width: 100,
height: 100,
fit: BoxFit.cover,
);
}
return const SizedBox(
width: 100,
height: 100,
child: ImmichLoadingIndicator(),
);
}),
);
},
),
);
}
}
@@ -3,11 +3,12 @@ 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/constants/immich_colors.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/providers/local_album.provider.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/modules/backup/ui/album_info_card.dart';
import 'package:immich_mobile/modules/backup/ui/album_info_list_tile.dart';
import 'package:immich_mobile/modules/backup/ui/backup_album_info_list_item.dart';
import 'package:immich_mobile/shared/ui/immich_app_bar.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
@RoutePage()
@@ -15,194 +16,19 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
const BackupAlbumSelectionPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// final availableAlbums = ref.watch(backupProvider).availableAlbums;
final selectedBackupAlbums = ref.watch(backupProvider).selectedBackupAlbums;
final excludedBackupAlbums = ref.watch(backupProvider).excludedBackupAlbums;
final isDarkTheme = context.isDarkTheme;
final albums = ref.watch(backupProvider).availableAlbums;
final localAlbums = ref.watch(localAlbumsProvider);
final searchValue = useValueNotifier('');
useEffect(
() {
ref.watch(backupProvider.notifier).getBackupInfo();
return null;
ref.read(localAlbumsProvider.notifier).getDeviceAlbums();
return ref.read(backupProvider.notifier).getBackupInfo;
},
[],
);
buildAlbumSelectionList() {
if (albums.isEmpty) {
return const SliverToBoxAdapter(
child: Center(
child: ImmichLoadingIndicator(),
),
);
}
return SliverPadding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
((context, index) {
var thumbnailData = albums[index].thumbnailData;
return AlbumInfoListTile(
imageData: thumbnailData,
albumInfo: albums[index],
);
}),
childCount: albums.length,
),
),
);
}
buildAlbumSelectionGrid() {
if (albums.isEmpty) {
return const SliverToBoxAdapter(
child: Center(
child: ImmichLoadingIndicator(),
),
);
}
return SliverPadding(
padding: const EdgeInsets.all(12.0),
sliver: SliverGrid.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 300,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
),
itemCount: albums.length,
itemBuilder: ((context, index) {
var thumbnailData = albums[index].thumbnailData;
return AlbumInfoCard(
imageData: thumbnailData,
albumInfo: albums[index],
);
}),
),
);
}
buildSelectedAlbumNameChip() {
return selectedBackupAlbums.map((album) {
void removeSelection() =>
ref.read(backupProvider.notifier).removeAlbumForBackup(album);
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: GestureDetector(
onTap: removeSelection,
child: Chip(
label: Text(
album.name,
style: TextStyle(
fontSize: 12,
color: isDarkTheme ? Colors.black : Colors.white,
fontWeight: FontWeight.bold,
),
),
backgroundColor: context.primaryColor,
deleteIconColor: isDarkTheme ? Colors.black : Colors.white,
deleteIcon: const Icon(
Icons.cancel_rounded,
size: 15,
),
onDeleted: removeSelection,
),
),
);
}).toSet();
}
buildExcludedAlbumNameChip() {
return excludedBackupAlbums.map((album) {
void removeSelection() {
ref
.watch(backupProvider.notifier)
.removeExcludedAlbumForBackup(album);
}
return GestureDetector(
onTap: removeSelection,
child: Padding(
padding: const EdgeInsets.only(right: 8.0),
child: Chip(
label: Text(
album.name,
style: TextStyle(
fontSize: 12,
color: isDarkTheme ? Colors.black : immichBackgroundColor,
fontWeight: FontWeight.bold,
),
),
backgroundColor: Colors.red[300],
deleteIconColor:
isDarkTheme ? Colors.black : immichBackgroundColor,
deleteIcon: const Icon(
Icons.cancel_rounded,
size: 15,
),
onDeleted: removeSelection,
),
),
);
}).toSet();
}
// buildSearchBar() {
// return Padding(
// padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 8.0),
// child: TextFormField(
// onChanged: (searchValue) {
// // if (searchValue.isEmpty) {
// // albums = availableAlbums;
// // } else {
// // albums.value = availableAlbums
// // .where(
// // (album) => album.name
// // .toLowerCase()
// // .contains(searchValue.toLowerCase()),
// // )
// // .toList();
// // }
// },
// decoration: InputDecoration(
// contentPadding: const EdgeInsets.symmetric(
// horizontal: 8.0,
// vertical: 8.0,
// ),
// hintText: "Search",
// hintStyle: TextStyle(
// color: isDarkTheme ? Colors.white : Colors.grey,
// fontSize: 14.0,
// ),
// prefixIcon: const Icon(
// Icons.search,
// color: Colors.grey,
// ),
// border: OutlineInputBorder(
// borderRadius: BorderRadius.circular(10),
// borderSide: BorderSide.none,
// ),
// filled: true,
// fillColor: isDarkTheme ? Colors.white30 : Colors.grey[200],
// ),
// ),
// );
// }
return Scaffold(
appBar: AppBar(
leading: IconButton(
onPressed: () => context.popRoute(),
icon: const Icon(Icons.arrow_back_ios_rounded),
),
title: const Text(
"backup_album_selection_page_select_albums",
).tr(),
elevation: 0,
),
appBar: _AppBar(),
body: CustomScrollView(
physics: const ClampingScrollPhysics(),
slivers: [
@@ -210,105 +36,26 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(
vertical: 8.0,
horizontal: 16.0,
),
child: Text(
"backup_album_selection_page_selection_info",
style: context.textTheme.titleSmall,
).tr(),
),
// Selected Album Chips
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Wrap(
children: [
...buildSelectedAlbumNameChip(),
...buildExcludedAlbumNameChip(),
],
),
),
ListTile(
title: Text(
"backup_album_selection_page_albums_device".tr(
args: [
ref
.watch(backupProvider)
.availableAlbums
.length
.toString(),
],
),
style: context.textTheme.titleSmall,
),
subtitle: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
"backup_album_selection_page_albums_tap",
style: context.textTheme.labelLarge?.copyWith(
color: context.primaryColor,
),
).tr(),
),
trailing: IconButton(
splashRadius: 16,
icon: Icon(
Icons.info,
size: 20,
color: context.primaryColor,
),
onPressed: () {
// show the dialog
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
elevation: 5,
title: Text(
'backup_album_selection_page_selection_info',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: context.primaryColor,
),
).tr(),
content: SingleChildScrollView(
child: ListBody(
children: [
const Text(
'backup_album_selection_page_assets_scatter',
style: TextStyle(
fontSize: 14,
),
).tr(),
],
),
),
);
},
);
},
),
),
// buildSearchBar(),
_AlbumBackupInfoRow(localAlbums.valueOrNull?.length ?? 0),
_AlbumSearchBar(onSearch: (value) => searchValue.value = value),
],
),
),
SliverLayoutBuilder(
builder: (context, constraints) {
if (constraints.crossAxisExtent > 600) {
return buildAlbumSelectionGrid();
} else {
return buildAlbumSelectionList();
}
ValueListenableBuilder(
valueListenable: searchValue,
builder: (ctx, search, _) {
final filteredAlbums = searchValue.value.isEmpty
? localAlbums
: localAlbums.whenData(
(albums) => albums
.where(
(a) => a.name
.toLowerCase()
.contains(searchValue.value.toLowerCase()),
)
.toList(),
);
return _SilverLocalAlbumSelectionList(albums: filteredAlbums);
},
),
],
@@ -316,3 +63,172 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
);
}
}
class _AppBar extends ImmichAppBar {
@override
Widget build(BuildContext context, WidgetRef ref) {
return AppBar(
leading: IconButton(
onPressed: () => context.popRoute(),
icon: const Icon(Icons.arrow_back_ios_rounded),
),
title: const Text(
"backup_album_selection_page_select_albums",
).tr(),
elevation: 0,
);
}
}
class _AlbumBackupInfoRow extends StatelessWidget {
final int albumCount;
const _AlbumBackupInfoRow(this.albumCount);
@override
Widget build(BuildContext context) {
void showBackupSelectionInfoDialog() {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(10)),
),
elevation: 5,
title: Text(
'backup_album_selection_page_selection_info',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: context.primaryColor,
),
).tr(),
content: SingleChildScrollView(
child: ListBody(
children: [
const Text(
'backup_album_selection_page_assets_scatter',
style: TextStyle(
fontSize: 14,
),
).tr(),
],
),
),
);
},
);
}
return ListTile(
title: Text(
"backup_album_selection_page_albums_device"
.tr(args: [albumCount.toString()]),
style: context.textTheme.titleSmall,
),
subtitle: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
"backup_album_selection_page_albums_tap",
style: context.textTheme.labelLarge?.copyWith(
color: context.primaryColor,
),
).tr(),
),
trailing: IconButton(
splashRadius: 16,
icon: Icon(
Icons.info,
size: 20,
color: context.primaryColor,
),
onPressed: showBackupSelectionInfoDialog,
),
);
}
}
class _AlbumSearchBar extends StatelessWidget {
final Function(String) onSearch;
const _AlbumSearchBar({required this.onSearch});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 8.0),
child: TextFormField(
onChanged: onSearch,
decoration: InputDecoration(
contentPadding: const EdgeInsets.all(8.0),
hintText: "Search",
hintStyle: TextStyle(
color: context.isDarkTheme ? Colors.white : Colors.grey,
fontSize: 14.0,
),
prefixIcon: const Icon(
Icons.search,
color: Colors.grey,
),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(10)),
borderSide: BorderSide.none,
),
filled: true,
fillColor: context.isDarkTheme ? Colors.white30 : Colors.grey[200],
),
),
);
}
}
// ignore: prefer-sliver-prefix
class _SilverLocalAlbumSelectionList extends StatelessWidget {
final AsyncValue<List<LocalAlbum>> albums;
const _SilverLocalAlbumSelectionList({required this.albums});
@override
Widget build(BuildContext context) {
if (albums.isLoading) {
return const SliverToBoxAdapter(
child: Center(
child: ImmichLoadingIndicator(),
),
);
}
if (albums.hasError) {
return SliverToBoxAdapter(child: Text("Error occured: ${albums.error}"));
}
return SliverPadding(
padding: const EdgeInsets.all(12.0),
sliver: context.isMobile
? SliverList(
delegate: SliverChildBuilderDelegate(
((context, index) {
return BackupAlbumInfoListItem(
album: albums.requireValue.elementAt(index),
);
}),
childCount: albums.requireValue.length,
),
)
: SliverGrid.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 300,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
),
itemCount: albums.requireValue.length,
itemBuilder: ((context, index) {
return BackupAlbumInfoListItem(
album: albums.requireValue.elementAt(index),
);
}),
),
);
}
}
@@ -7,7 +7,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/extensions/object_extensions.dart';
import 'package:immich_mobile/modules/album/providers/local_album.provider.dart';
import 'package:immich_mobile/modules/backup/providers/backup_album.provider.dart';
import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart';
import 'package:immich_mobile/modules/backup/providers/ios_background_settings.provider.dart';
import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart';
@@ -25,16 +27,18 @@ class BackupControllerPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
BackUpState backupState = ref.watch(backupProvider);
final hasAnyAlbum = backupState.selectedBackupAlbums.isNotEmpty;
final backupAlbums = ref.watch(backupAlbumsProvider);
final hasAnyAlbum =
backupAlbums.valueOrNull?.selectedBackupAlbums.isNotEmpty ?? false;
bool hasExclusiveAccess =
backupState.backupProgress != BackUpProgressEnum.inBackground;
bool shouldBackup = backupState.allUniqueAssets.length -
backupState.selectedAlbumsBackupAssetsIds.length ==
0 ||
!hasExclusiveAccess
? false
: true;
bool shouldBackup =
backupState.allUniqueAssets.length - backupState.backedUpAssetsCount ==
0 ||
!hasExclusiveAccess
? false
: true;
useEffect(
() {
@@ -58,27 +62,43 @@ class BackupControllerPage extends HookConsumerWidget {
[],
);
Widget buildSelectedAlbumName() {
Future<String> getSelectedAlbumNames() async {
var text = "backup_controller_page_backup_selected".tr();
var albums = ref.watch(backupProvider).selectedBackupAlbums;
if (albums.isNotEmpty) {
for (var album in albums) {
if (album.name == "Recent" || album.name == "Recents") {
text += "${album.name} (${'backup_all'.tr()}), ";
} else {
text += "${album.name}, ";
}
final selectedAlbums =
backupAlbums.valueOrNull?.selectedBackupAlbums ?? [];
for (final selected in selectedAlbums) {
await selected.album.load();
final album = selected.album.value;
if (album == null) {
continue;
}
if (album.name == "Recent" || album.name == "Recents") {
text += "${album.name} (${'backup_all'.tr()}), ";
} else {
text += "${album.name}, ";
}
}
return text;
}
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
text.trim().substring(0, text.length - 2),
style: context.textTheme.labelLarge?.copyWith(
color: context.primaryColor,
),
),
Widget buildSelectedAlbumName() {
final selectedAlbums =
backupAlbums.valueOrNull?.selectedBackupAlbums ?? [];
if (selectedAlbums.isNotEmpty) {
return FutureBuilder(
future: getSelectedAlbumNames(),
builder: (_, data) => data.hasData
? Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
data.data!.trim().substring(0, data.data!.length - 2),
style: context.textTheme.labelLarge?.copyWith(
color: context.primaryColor,
),
),
)
: const SizedBox.shrink(),
);
} else {
return Padding(
@@ -93,23 +113,39 @@ class BackupControllerPage extends HookConsumerWidget {
}
}
Widget buildExcludedAlbumName() {
Future<String> getExcludedAlbumNames() async {
var text = "backup_controller_page_excluded".tr();
var albums = ref.watch(backupProvider).excludedBackupAlbums;
if (albums.isNotEmpty) {
for (var album in albums) {
text += "${album.name}, ";
final excludedAlbums =
backupAlbums.valueOrNull?.excludedBackupAlbums ?? [];
for (final excluded in excludedAlbums) {
excluded.album.loadSync();
final album = excluded.album.value;
if (album == null) {
continue;
}
text += "${album.name}, ";
}
return text;
}
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
text.trim().substring(0, text.length - 2),
style: context.textTheme.labelLarge?.copyWith(
color: Colors.red[300],
),
),
Widget buildExcludedAlbumName() {
final excludedAlbums =
backupAlbums.valueOrNull?.excludedBackupAlbums ?? [];
if (excludedAlbums.isNotEmpty) {
return FutureBuilder(
future: getExcludedAlbumNames(),
builder: (_, data) => data.hasData
? Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
data.data!.trim().substring(0, data.data!.length - 2),
style: context.textTheme.labelLarge?.copyWith(
color: Colors.red[300],
),
),
)
: const SizedBox.shrink(),
);
} else {
return const SizedBox();
@@ -121,7 +157,7 @@ class BackupControllerPage extends HookConsumerWidget {
padding: const EdgeInsets.only(top: 8.0),
child: Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
borderRadius: const BorderRadius.all(Radius.circular(20)),
side: BorderSide(
color: context.isDarkTheme
? const Color.fromARGB(255, 56, 56, 56)
@@ -152,15 +188,8 @@ class BackupControllerPage extends HookConsumerWidget {
),
),
trailing: ElevatedButton(
onPressed: () async {
await context.pushRoute(const BackupAlbumSelectionRoute());
// waited until returning from selection
await ref
.read(backupProvider.notifier)
.backupAlbumSelectionDone();
// waited until backup albums are stored in DB
ref.read(albumProvider.notifier).getDeviceAlbums();
},
onPressed: () =>
context.pushRoute(const BackupAlbumSelectionRoute()),
child: const Text(
"backup_controller_page_select",
style: TextStyle(
@@ -274,23 +303,28 @@ class BackupControllerPage extends HookConsumerWidget {
BackupInfoCard(
title: "backup_controller_page_total".tr(),
subtitle: "backup_controller_page_total_sub".tr(),
info: ref.watch(backupProvider).availableAlbums.isEmpty
? "..."
: "${backupState.allUniqueAssets.length}",
info:
ref.watch(localAlbumsProvider).valueOrNull.isNullOrEmpty
? "..."
: "${backupState.allUniqueAssets.length}",
),
BackupInfoCard(
title: "backup_controller_page_backup".tr(),
subtitle: "backup_controller_page_backup_sub".tr(),
info: ref.watch(backupProvider).availableAlbums.isEmpty
? "..."
: "${backupState.selectedAlbumsBackupAssetsIds.length}",
info:
ref.watch(localAlbumsProvider).valueOrNull.isNullOrEmpty
? "..."
: "${backupState.backedUpAssetsCount}",
),
BackupInfoCard(
title: "backup_controller_page_remainder".tr(),
subtitle: "backup_controller_page_remainder_sub".tr(),
info: ref.watch(backupProvider).availableAlbums.isEmpty
info: ref
.watch(localAlbumsProvider)
.valueOrNull
.isNullOrEmpty
? "..."
: "${max(0, backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length)}",
: "${max(0, backupState.allUniqueAssets.length - backupState.backedUpAssetsCount)}",
),
const Divider(),
const CurrentUploadingAssetInfoBox(),
@@ -65,7 +65,7 @@ class BackupOptionsPage extends HookConsumerWidget {
try {
checkInProgress.value = true;
if (backupState.allUniqueAssets.length >
backupState.selectedAlbumsBackupAssetsIds.length) {
backupState.backedUpAssetsCount) {
ImmichToast.show(
context: context,
msg: "Backup all assets before starting this check!",
@@ -2,7 +2,8 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/providers/remote_album.provider.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart';
import 'package:immich_mobile/modules/home/models/selection_state.dart';
@@ -10,7 +11,6 @@ import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
import 'package:immich_mobile/modules/home/ui/upload_dialog.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
import 'package:immich_mobile/shared/models/album.dart';
class ControlBottomAppBar extends ConsumerWidget {
final void Function(bool shareLocal) onShare;
@@ -19,7 +19,7 @@ class ControlBottomAppBar extends ConsumerWidget {
final void Function([bool force])? onDelete;
final void Function([bool force])? onDeleteServer;
final void Function(bool onlyBackedUp)? onDeleteLocal;
final Function(Album album) onAddToAlbum;
final Function(RemoteAlbum album) onAddToAlbum;
final void Function() onCreateNewAlbum;
final void Function() onUpload;
final void Function()? onStack;
@@ -61,7 +61,7 @@ class ControlBottomAppBar extends ConsumerWidget {
selectionAssetState.hasLocal || selectionAssetState.hasMerged;
final trashEnabled =
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
final albums = ref.watch(remoteAlbumsProvider).valueOrNull ?? [];
final sharedAlbums = ref.watch(sharedAlbumProvider);
const bottomPadding = 0.20;
+4 -2
View File
@@ -6,7 +6,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/providers/local_album.provider.dart';
import 'package:immich_mobile/modules/album/providers/remote_album.provider.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
import 'package:immich_mobile/modules/memories/ui/memory_lane.dart';
@@ -34,8 +35,9 @@ class HomePage extends HookConsumerWidget {
ref.read(websocketProvider.notifier).connect();
Future(() => ref.read(assetProvider.notifier).getAllAsset());
ref.read(assetProvider.notifier).getPartnerAssets();
ref.read(albumProvider.notifier).getAllAlbums();
ref.read(remoteAlbumsProvider.notifier).getRemoteAlbums();
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
ref.read(localAlbumsProvider.notifier).getDeviceAlbums();
ref.read(serverInfoProvider.notifier).getServerInfo();
return;
},
@@ -5,7 +5,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_udid/flutter_udid.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/remote_album.provider.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
@@ -116,7 +116,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
Store.delete(StoreKey.currentUser),
Store.delete(StoreKey.accessToken),
]);
_ref.invalidate(albumProvider);
_ref.invalidate(remoteAlbumsProvider);
_ref.invalidate(sharedAlbumProvider);
state = state.copyWith(
+6 -7
View File
@@ -2,9 +2,10 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/activities/views/activities_page.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
import 'package:immich_mobile/modules/album/views/album_options_part.dart';
import 'package:immich_mobile/modules/album/views/album_viewer_page.dart';
import 'package:immich_mobile/modules/album/views/album_options_page.dart';
import 'package:immich_mobile/modules/album/views/remote_album_viewer_page.dart';
import 'package:immich_mobile/modules/album/views/asset_selection_page.dart';
import 'package:immich_mobile/modules/album/views/create_album_page.dart';
import 'package:immich_mobile/modules/album/views/library_page.dart';
@@ -21,7 +22,7 @@ import 'package:immich_mobile/modules/album/views/sharing_page.dart';
import 'package:immich_mobile/modules/archive/views/archive_page.dart';
import 'package:immich_mobile/modules/asset_viewer/views/gallery_viewer.dart';
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
import 'package:immich_mobile/modules/backup/views/album_preview_page.dart';
import 'package:immich_mobile/modules/album/views/local_album_viewer_page.dart';
import 'package:immich_mobile/modules/backup/views/backup_album_selection_page.dart';
import 'package:immich_mobile/modules/backup/views/backup_controller_page.dart';
import 'package:immich_mobile/modules/backup/views/failed_backup_status_page.dart';
@@ -49,7 +50,6 @@ import 'package:immich_mobile/routing/custom_transition_builders.dart';
import 'package:immich_mobile/routing/duplicate_guard.dart';
import 'package:immich_mobile/routing/backup_permission_guard.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/logger_message.model.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
@@ -60,7 +60,6 @@ import 'package:immich_mobile/shared/views/splash_screen.dart';
import 'package:immich_mobile/shared/views/tab_controller_page.dart';
import 'package:isar/isar.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:photo_manager/photo_manager.dart' hide LatLng;
part 'router.gr.dart';
@@ -157,7 +156,7 @@ class AppRouter extends _$AppRouter {
transitionsBuilder: TransitionsBuilders.slideBottom,
),
AutoRoute(
page: AlbumViewerRoute.page,
page: RemoteAlbumViewerRoute.page,
guards: [_authGuard, _duplicateGuard],
),
CustomRoute(
@@ -170,7 +169,7 @@ class AppRouter extends _$AppRouter {
guards: [_authGuard, _duplicateGuard],
),
AutoRoute(
page: AlbumPreviewRoute.page,
page: LocalAlbumViewerRoute.page,
guards: [_authGuard, _duplicateGuard],
),
CustomRoute(
+106 -100
View File
@@ -31,26 +31,6 @@ abstract class _$AppRouter extends RootStackRouter {
),
);
},
AlbumPreviewRoute.name: (routeData) {
final args = routeData.argsAs<AlbumPreviewRouteArgs>();
return AutoRoutePage<dynamic>(
routeData: routeData,
child: AlbumPreviewPage(
key: args.key,
album: args.album,
),
);
},
AlbumViewerRoute.name: (routeData) {
final args = routeData.argsAs<AlbumViewerRouteArgs>();
return AutoRoutePage<dynamic>(
routeData: routeData,
child: AlbumViewerPage(
key: args.key,
albumId: args.albumId,
),
);
},
AllMotionPhotosRoute.name: (routeData) {
return AutoRoutePage<dynamic>(
routeData: routeData,
@@ -182,6 +162,17 @@ abstract class _$AppRouter extends RootStackRouter {
child: const LibraryPage(),
);
},
LocalAlbumViewerRoute.name: (routeData) {
final args = routeData.argsAs<LocalAlbumViewerRouteArgs>();
return AutoRoutePage<dynamic>(
routeData: routeData,
child: LocalAlbumViewerPage(
key: args.key,
album: args.album,
selectEnabled: args.selectEnabled,
),
);
},
LoginRoute.name: (routeData) {
return AutoRoutePage<dynamic>(
routeData: routeData,
@@ -255,6 +246,16 @@ abstract class _$AppRouter extends RootStackRouter {
child: const RecentlyAddedPage(),
);
},
RemoteAlbumViewerRoute.name: (routeData) {
final args = routeData.argsAs<RemoteAlbumViewerRouteArgs>();
return AutoRoutePage<dynamic>(
routeData: routeData,
child: RemoteAlbumViewerPage(
key: args.key,
albumId: args.albumId,
),
);
},
SearchRoute.name: (routeData) {
final args = routeData.argsAs<SearchRouteArgs>(
orElse: () => const SearchRouteArgs());
@@ -382,7 +383,7 @@ class ActivitiesRoute extends PageRouteInfo<void> {
class AlbumOptionsRoute extends PageRouteInfo<AlbumOptionsRouteArgs> {
AlbumOptionsRoute({
Key? key,
required Album album,
required RemoteAlbum album,
List<PageRouteInfo>? children,
}) : super(
AlbumOptionsRoute.name,
@@ -407,7 +408,7 @@ class AlbumOptionsRouteArgs {
final Key? key;
final Album album;
final RemoteAlbum album;
@override
String toString() {
@@ -415,82 +416,6 @@ class AlbumOptionsRouteArgs {
}
}
/// generated route for
/// [AlbumPreviewPage]
class AlbumPreviewRoute extends PageRouteInfo<AlbumPreviewRouteArgs> {
AlbumPreviewRoute({
Key? key,
required AssetPathEntity album,
List<PageRouteInfo>? children,
}) : super(
AlbumPreviewRoute.name,
args: AlbumPreviewRouteArgs(
key: key,
album: album,
),
initialChildren: children,
);
static const String name = 'AlbumPreviewRoute';
static const PageInfo<AlbumPreviewRouteArgs> page =
PageInfo<AlbumPreviewRouteArgs>(name);
}
class AlbumPreviewRouteArgs {
const AlbumPreviewRouteArgs({
this.key,
required this.album,
});
final Key? key;
final AssetPathEntity album;
@override
String toString() {
return 'AlbumPreviewRouteArgs{key: $key, album: $album}';
}
}
/// generated route for
/// [AlbumViewerPage]
class AlbumViewerRoute extends PageRouteInfo<AlbumViewerRouteArgs> {
AlbumViewerRoute({
Key? key,
required int albumId,
List<PageRouteInfo>? children,
}) : super(
AlbumViewerRoute.name,
args: AlbumViewerRouteArgs(
key: key,
albumId: albumId,
),
initialChildren: children,
);
static const String name = 'AlbumViewerRoute';
static const PageInfo<AlbumViewerRouteArgs> page =
PageInfo<AlbumViewerRouteArgs>(name);
}
class AlbumViewerRouteArgs {
const AlbumViewerRouteArgs({
this.key,
required this.albumId,
});
final Key? key;
final int albumId;
@override
String toString() {
return 'AlbumViewerRouteArgs{key: $key, albumId: $albumId}';
}
}
/// generated route for
/// [AllMotionPhotosPage]
class AllMotionPhotosRoute extends PageRouteInfo<void> {
@@ -874,6 +799,49 @@ class LibraryRoute extends PageRouteInfo<void> {
static const PageInfo<void> page = PageInfo<void>(name);
}
/// generated route for
/// [LocalAlbumViewerPage]
class LocalAlbumViewerRoute extends PageRouteInfo<LocalAlbumViewerRouteArgs> {
LocalAlbumViewerRoute({
Key? key,
required LocalAlbum album,
bool selectEnabled = true,
List<PageRouteInfo>? children,
}) : super(
LocalAlbumViewerRoute.name,
args: LocalAlbumViewerRouteArgs(
key: key,
album: album,
selectEnabled: selectEnabled,
),
initialChildren: children,
);
static const String name = 'LocalAlbumViewerRoute';
static const PageInfo<LocalAlbumViewerRouteArgs> page =
PageInfo<LocalAlbumViewerRouteArgs>(name);
}
class LocalAlbumViewerRouteArgs {
const LocalAlbumViewerRouteArgs({
this.key,
required this.album,
this.selectEnabled = true,
});
final Key? key;
final LocalAlbum album;
final bool selectEnabled;
@override
String toString() {
return 'LocalAlbumViewerRouteArgs{key: $key, album: $album, selectEnabled: $selectEnabled}';
}
}
/// generated route for
/// [LoginPage]
class LoginRoute extends PageRouteInfo<void> {
@@ -1105,6 +1073,44 @@ class RecentlyAddedRoute extends PageRouteInfo<void> {
static const PageInfo<void> page = PageInfo<void>(name);
}
/// generated route for
/// [RemoteAlbumViewerPage]
class RemoteAlbumViewerRoute extends PageRouteInfo<RemoteAlbumViewerRouteArgs> {
RemoteAlbumViewerRoute({
Key? key,
required int albumId,
List<PageRouteInfo>? children,
}) : super(
RemoteAlbumViewerRoute.name,
args: RemoteAlbumViewerRouteArgs(
key: key,
albumId: albumId,
),
initialChildren: children,
);
static const String name = 'RemoteAlbumViewerRoute';
static const PageInfo<RemoteAlbumViewerRouteArgs> page =
PageInfo<RemoteAlbumViewerRouteArgs>(name);
}
class RemoteAlbumViewerRouteArgs {
const RemoteAlbumViewerRouteArgs({
this.key,
required this.albumId,
});
final Key? key;
final int albumId;
@override
String toString() {
return 'RemoteAlbumViewerRouteArgs{key: $key, albumId: $albumId}';
}
}
/// generated route for
/// [SearchPage]
class SearchRoute extends PageRouteInfo<SearchRouteArgs> {
@@ -1177,7 +1183,7 @@ class SelectAdditionalUserForSharingRoute
extends PageRouteInfo<SelectAdditionalUserForSharingRouteArgs> {
SelectAdditionalUserForSharingRoute({
Key? key,
required Album album,
required RemoteAlbum album,
List<PageRouteInfo>? children,
}) : super(
SelectAdditionalUserForSharingRoute.name,
@@ -1202,7 +1208,7 @@ class SelectAdditionalUserForSharingRouteArgs {
final Key? key;
final Album album;
final RemoteAlbum album;
@override
String toString() {
@@ -1,7 +1,8 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/foundation.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/local_album.provider.dart';
import 'package:immich_mobile/modules/album/providers/remote_album.provider.dart';
import 'package:immich_mobile/modules/memories/providers/memory.provider.dart';
import 'package:immich_mobile/modules/search/providers/people.provider.dart';
@@ -47,7 +48,8 @@ class TabNavigationObserver extends AutoRouterObserver {
}
if (route.name == 'LibraryRoute') {
ref.read(albumProvider.notifier).getAllAlbums();
ref.read(remoteAlbumsProvider.notifier).getRemoteAlbums();
ref.read(localAlbumsProvider.notifier).getDeviceAlbums();
}
if (route.name == 'HomeRoute') {
-181
View File
@@ -1,181 +0,0 @@
import 'package:flutter/foundation.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:isar/isar.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
part 'album.g.dart';
@Collection(inheritance: false)
class Album {
@protected
Album({
this.remoteId,
this.localId,
required this.name,
required this.createdAt,
required this.modifiedAt,
this.startDate,
this.endDate,
this.lastModifiedAssetTimestamp,
required this.shared,
required this.activityEnabled,
});
Id id = Isar.autoIncrement;
@Index(unique: false, replace: false, type: IndexType.hash)
String? remoteId;
@Index(unique: false, replace: false, type: IndexType.hash)
String? localId;
String name;
DateTime createdAt;
DateTime modifiedAt;
DateTime? startDate;
DateTime? endDate;
DateTime? lastModifiedAssetTimestamp;
bool shared;
bool activityEnabled;
final IsarLink<User> owner = IsarLink<User>();
final IsarLink<Asset> thumbnail = IsarLink<Asset>();
final IsarLinks<User> sharedUsers = IsarLinks<User>();
final IsarLinks<Asset> assets = IsarLinks<Asset>();
@ignore
bool get isRemote => remoteId != null;
@ignore
bool get isLocal => localId != null;
@ignore
int get assetCount => assets.length;
@ignore
String? get ownerId => owner.value?.id;
@ignore
String? get ownerName {
// Guard null owner
if (owner.value == null) {
return null;
}
final name = <String>[];
if (owner.value?.name != null) {
name.add(owner.value!.name);
}
return name.join(' ');
}
@override
bool operator ==(other) {
if (other is! Album) return false;
final lastModifiedAssetTimestampIsSetAndEqual =
lastModifiedAssetTimestamp != null &&
other.lastModifiedAssetTimestamp != null
? lastModifiedAssetTimestamp!
.isAtSameMomentAs(other.lastModifiedAssetTimestamp!)
: true;
return id == other.id &&
remoteId == other.remoteId &&
localId == other.localId &&
name == other.name &&
createdAt.isAtSameMomentAs(other.createdAt) &&
modifiedAt.isAtSameMomentAs(other.modifiedAt) &&
lastModifiedAssetTimestampIsSetAndEqual &&
shared == other.shared &&
activityEnabled == other.activityEnabled &&
owner.value == other.owner.value &&
thumbnail.value == other.thumbnail.value &&
sharedUsers.length == other.sharedUsers.length &&
assets.length == other.assets.length;
}
@override
@ignore
int get hashCode =>
id.hashCode ^
remoteId.hashCode ^
localId.hashCode ^
name.hashCode ^
createdAt.hashCode ^
modifiedAt.hashCode ^
lastModifiedAssetTimestamp.hashCode ^
shared.hashCode ^
activityEnabled.hashCode ^
owner.value.hashCode ^
thumbnail.value.hashCode ^
sharedUsers.length.hashCode ^
assets.length.hashCode;
static Album local(AssetPathEntity ape) {
final Album a = Album(
name: ape.name,
createdAt: ape.lastModified?.toUtc() ?? DateTime.now().toUtc(),
modifiedAt: ape.lastModified?.toUtc() ?? DateTime.now().toUtc(),
shared: false,
activityEnabled: false,
);
a.owner.value = Store.get(StoreKey.currentUser);
a.localId = ape.id;
return a;
}
static Future<Album> remote(AlbumResponseDto dto) async {
final Isar db = Isar.getInstance()!;
final Album a = Album(
remoteId: dto.id,
name: dto.albumName,
createdAt: dto.createdAt,
modifiedAt: dto.updatedAt,
lastModifiedAssetTimestamp: dto.lastModifiedAssetTimestamp,
shared: dto.shared,
startDate: dto.startDate,
endDate: dto.endDate,
activityEnabled: dto.isActivityEnabled,
);
a.owner.value = await db.users.getById(dto.ownerId);
if (dto.albumThumbnailAssetId != null) {
a.thumbnail.value = await db.assets
.where()
.remoteIdEqualTo(dto.albumThumbnailAssetId)
.findFirst();
}
if (dto.sharedUsers.isNotEmpty) {
final users = await db.users
.getAllById(dto.sharedUsers.map((e) => e.id).toList(growable: false));
a.sharedUsers.addAll(users.cast());
}
if (dto.assets.isNotEmpty) {
final assets =
await db.assets.getAllByRemoteId(dto.assets.map((e) => e.id));
a.assets.addAll(assets);
}
return a;
}
@override
String toString() => name;
}
extension AssetsHelper on IsarCollection<Album> {
Future<void> store(Album a) async {
await put(a);
await a.owner.save();
await a.thumbnail.save();
await a.sharedUsers.save();
await a.assets.save();
}
}
extension AlbumResponseDtoHelper on AlbumResponseDto {
List<Asset> getAssets() => assets.map(Asset.remote).toList();
}
extension AssetPathEntityHelper on AssetPathEntity {
String get eTagKeyAssetCount => "device-album-$id-asset-count";
}
File diff suppressed because it is too large Load Diff
@@ -1,10 +0,0 @@
import 'package:immich_mobile/shared/models/device_asset.dart';
import 'package:isar/isar.dart';
part 'android_device_asset.g.dart';
@Collection()
class AndroidDeviceAsset extends DeviceAsset {
AndroidDeviceAsset({required this.id, required super.hash});
Id id;
}
-493
View File
@@ -1,493 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'android_device_asset.dart';
// **************************************************************************
// IsarCollectionGenerator
// **************************************************************************
// coverage:ignore-file
// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types
extension GetAndroidDeviceAssetCollection on Isar {
IsarCollection<AndroidDeviceAsset> get androidDeviceAssets =>
this.collection();
}
const AndroidDeviceAssetSchema = CollectionSchema(
name: r'AndroidDeviceAsset',
id: -6758387181232899335,
properties: {
r'hash': PropertySchema(
id: 0,
name: r'hash',
type: IsarType.byteList,
)
},
estimateSize: _androidDeviceAssetEstimateSize,
serialize: _androidDeviceAssetSerialize,
deserialize: _androidDeviceAssetDeserialize,
deserializeProp: _androidDeviceAssetDeserializeProp,
idName: r'id',
indexes: {
r'hash': IndexSchema(
id: -7973251393006690288,
name: r'hash',
unique: false,
replace: false,
properties: [
IndexPropertySchema(
name: r'hash',
type: IndexType.hash,
caseSensitive: false,
)
],
)
},
links: {},
embeddedSchemas: {},
getId: _androidDeviceAssetGetId,
getLinks: _androidDeviceAssetGetLinks,
attach: _androidDeviceAssetAttach,
version: '3.1.0+1',
);
int _androidDeviceAssetEstimateSize(
AndroidDeviceAsset object,
List<int> offsets,
Map<Type, List<int>> allOffsets,
) {
var bytesCount = offsets.last;
bytesCount += 3 + object.hash.length;
return bytesCount;
}
void _androidDeviceAssetSerialize(
AndroidDeviceAsset object,
IsarWriter writer,
List<int> offsets,
Map<Type, List<int>> allOffsets,
) {
writer.writeByteList(offsets[0], object.hash);
}
AndroidDeviceAsset _androidDeviceAssetDeserialize(
Id id,
IsarReader reader,
List<int> offsets,
Map<Type, List<int>> allOffsets,
) {
final object = AndroidDeviceAsset(
hash: reader.readByteList(offsets[0]) ?? [],
id: id,
);
return object;
}
P _androidDeviceAssetDeserializeProp<P>(
IsarReader reader,
int propertyId,
int offset,
Map<Type, List<int>> allOffsets,
) {
switch (propertyId) {
case 0:
return (reader.readByteList(offset) ?? []) as P;
default:
throw IsarError('Unknown property with id $propertyId');
}
}
Id _androidDeviceAssetGetId(AndroidDeviceAsset object) {
return object.id;
}
List<IsarLinkBase<dynamic>> _androidDeviceAssetGetLinks(
AndroidDeviceAsset object) {
return [];
}
void _androidDeviceAssetAttach(
IsarCollection<dynamic> col, Id id, AndroidDeviceAsset object) {
object.id = id;
}
extension AndroidDeviceAssetQueryWhereSort
on QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QWhere> {
QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterWhere> anyId() {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(const IdWhereClause.any());
});
}
}
extension AndroidDeviceAssetQueryWhere
on QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QWhereClause> {
QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterWhereClause>
idEqualTo(Id id) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IdWhereClause.between(
lower: id,
upper: id,
));
});
}
QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterWhereClause>
idNotEqualTo(Id id) {
return QueryBuilder.apply(this, (query) {
if (query.whereSort == Sort.asc) {
return query
.addWhereClause(
IdWhereClause.lessThan(upper: id, includeUpper: false),
)
.addWhereClause(
IdWhereClause.greaterThan(lower: id, includeLower: false),
);
} else {
return query
.addWhereClause(
IdWhereClause.greaterThan(lower: id, includeLower: false),
)
.addWhereClause(
IdWhereClause.lessThan(upper: id, includeUpper: false),
);
}
});
}
QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterWhereClause>
idGreaterThan(Id id, {bool include = false}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(
IdWhereClause.greaterThan(lower: id, includeLower: include),
);
});
}
QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterWhereClause>
idLessThan(Id id, {bool include = false}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(
IdWhereClause.lessThan(upper: id, includeUpper: include),
);
});
}
QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterWhereClause>
idBetween(
Id lowerId,
Id upperId, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IdWhereClause.between(
lower: lowerId,
includeLower: includeLower,
upper: upperId,
includeUpper: includeUpper,
));
});
}
QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterWhereClause>
hashEqualTo(List<int> hash) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IndexWhereClause.equalTo(
indexName: r'hash',
value: [hash],
));
});
}
QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterWhereClause>
hashNotEqualTo(List<int> hash) {
return QueryBuilder.apply(this, (query) {
if (query.whereSort == Sort.asc) {
return query
.addWhereClause(IndexWhereClause.between(
indexName: r'hash',
lower: [],
upper: [hash],
includeUpper: false,
))
.addWhereClause(IndexWhereClause.between(
indexName: r'hash',
lower: [hash],
includeLower: false,
upper: [],
));
} else {
return query
.addWhereClause(IndexWhereClause.between(
indexName: r'hash',
lower: [hash],
includeLower: false,
upper: [],
))
.addWhereClause(IndexWhereClause.between(
indexName: r'hash',
lower: [],
upper: [hash],
includeUpper: false,
));
}
});
}
}
extension AndroidDeviceAssetQueryFilter
on QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QFilterCondition> {
QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterFilterCondition>
hashElementEqualTo(int value) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'hash',
value: value,
));
});
}
QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterFilterCondition>
hashElementGreaterThan(
int value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'hash',
value: value,
));
});
}
QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterFilterCondition>
hashElementLessThan(
int value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'hash',
value: value,
));
});
}
QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterFilterCondition>
hashElementBetween(
int lower,
int upper, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'hash',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
));
});
}
QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterFilterCondition>
hashLengthEqualTo(int length) {
return QueryBuilder.apply(this, (query) {
return query.listLength(
r'hash',
length,
true,
length,
true,
);
});
}
QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterFilterCondition>
hashIsEmpty() {
return QueryBuilder.apply(this, (query) {
return query.listLength(
r'hash',
0,
true,
0,
true,
);
});
}
QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterFilterCondition>
hashIsNotEmpty() {
return QueryBuilder.apply(this, (query) {
return query.listLength(
r'hash',
0,
false,
999999,
true,
);
});
}
QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterFilterCondition>
hashLengthLessThan(
int length, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.listLength(
r'hash',
0,
true,
length,
include,
);
});
}
QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterFilterCondition>
hashLengthGreaterThan(
int length, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.listLength(
r'hash',
length,
include,
999999,
true,
);
});
}
QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterFilterCondition>
hashLengthBetween(
int lower,
int upper, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.listLength(
r'hash',
lower,
includeLower,
upper,
includeUpper,
);
});
}
QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterFilterCondition>
idEqualTo(Id value) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'id',
value: value,
));
});
}
QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterFilterCondition>
idGreaterThan(
Id value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'id',
value: value,
));
});
}
QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterFilterCondition>
idLessThan(
Id value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'id',
value: value,
));
});
}
QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterFilterCondition>
idBetween(
Id lower,
Id upper, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'id',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
));
});
}
}
extension AndroidDeviceAssetQueryObject
on QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QFilterCondition> {}
extension AndroidDeviceAssetQueryLinks
on QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QFilterCondition> {}
extension AndroidDeviceAssetQuerySortBy
on QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QSortBy> {}
extension AndroidDeviceAssetQuerySortThenBy
on QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QSortThenBy> {
QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterSortBy>
thenById() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'id', Sort.asc);
});
}
QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterSortBy>
thenByIdDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'id', Sort.desc);
});
}
}
extension AndroidDeviceAssetQueryWhereDistinct
on QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QDistinct> {
QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QDistinct>
distinctByHash() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'hash');
});
}
}
extension AndroidDeviceAssetQueryProperty
on QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QQueryProperty> {
QueryBuilder<AndroidDeviceAsset, int, QQueryOperations> idProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'id');
});
}
QueryBuilder<AndroidDeviceAsset, List<int>, QQueryOperations> hashProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'hash');
});
}
}
+37 -1
View File
@@ -1,8 +1,44 @@
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
import 'package:isar/isar.dart';
part 'device_asset.g.dart';
@Collection()
class DeviceAsset {
DeviceAsset({required this.hash});
DeviceAsset({
required this.id,
required this.hash,
this.backupSelection = BackupSelection.none,
});
Id get isarId => Isar.autoIncrement;
@Index(replace: true, unique: true, type: IndexType.hash)
String id;
@Index(unique: false, type: IndexType.hash)
List<byte> hash;
@enumerated
BackupSelection backupSelection;
@override
String toString() {
return 'DeviceAsset(id: $id, hash: $hash, backupSelection: $backupSelection)';
}
@override
bool operator ==(covariant DeviceAsset other) {
if (identical(this, other)) return true;
return other.id == id &&
listEquals(other.hash, hash) &&
other.backupSelection == backupSelection;
}
@override
@ignore
int get hashCode {
return id.hashCode ^ hash.hashCode ^ backupSelection.hashCode;
}
}
@@ -1,6 +1,6 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'ios_device_asset.dart';
part of 'device_asset.dart';
// **************************************************************************
// IsarCollectionGenerator
@@ -9,29 +9,35 @@ part of 'ios_device_asset.dart';
// coverage:ignore-file
// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types
extension GetIOSDeviceAssetCollection on Isar {
IsarCollection<IOSDeviceAsset> get iOSDeviceAssets => this.collection();
extension GetDeviceAssetCollection on Isar {
IsarCollection<DeviceAsset> get deviceAssets => this.collection();
}
const IOSDeviceAssetSchema = CollectionSchema(
name: r'IOSDeviceAsset',
id: -1671546753821948030,
const DeviceAssetSchema = CollectionSchema(
name: r'DeviceAsset',
id: -2122558759826746878,
properties: {
r'hash': PropertySchema(
r'backupSelection': PropertySchema(
id: 0,
name: r'backupSelection',
type: IsarType.byte,
enumMap: _DeviceAssetbackupSelectionEnumValueMap,
),
r'hash': PropertySchema(
id: 1,
name: r'hash',
type: IsarType.byteList,
),
r'id': PropertySchema(
id: 1,
id: 2,
name: r'id',
type: IsarType.string,
)
},
estimateSize: _iOSDeviceAssetEstimateSize,
serialize: _iOSDeviceAssetSerialize,
deserialize: _iOSDeviceAssetDeserialize,
deserializeProp: _iOSDeviceAssetDeserializeProp,
estimateSize: _deviceAssetEstimateSize,
serialize: _deviceAssetSerialize,
deserialize: _deviceAssetDeserialize,
deserializeProp: _deviceAssetDeserializeProp,
idName: r'isarId',
indexes: {
r'id': IndexSchema(
@@ -63,14 +69,14 @@ const IOSDeviceAssetSchema = CollectionSchema(
},
links: {},
embeddedSchemas: {},
getId: _iOSDeviceAssetGetId,
getLinks: _iOSDeviceAssetGetLinks,
attach: _iOSDeviceAssetAttach,
getId: _deviceAssetGetId,
getLinks: _deviceAssetGetLinks,
attach: _deviceAssetAttach,
version: '3.1.0+1',
);
int _iOSDeviceAssetEstimateSize(
IOSDeviceAsset object,
int _deviceAssetEstimateSize(
DeviceAsset object,
List<int> offsets,
Map<Type, List<int>> allOffsets,
) {
@@ -80,30 +86,34 @@ int _iOSDeviceAssetEstimateSize(
return bytesCount;
}
void _iOSDeviceAssetSerialize(
IOSDeviceAsset object,
void _deviceAssetSerialize(
DeviceAsset object,
IsarWriter writer,
List<int> offsets,
Map<Type, List<int>> allOffsets,
) {
writer.writeByteList(offsets[0], object.hash);
writer.writeString(offsets[1], object.id);
writer.writeByte(offsets[0], object.backupSelection.index);
writer.writeByteList(offsets[1], object.hash);
writer.writeString(offsets[2], object.id);
}
IOSDeviceAsset _iOSDeviceAssetDeserialize(
DeviceAsset _deviceAssetDeserialize(
Id id,
IsarReader reader,
List<int> offsets,
Map<Type, List<int>> allOffsets,
) {
final object = IOSDeviceAsset(
hash: reader.readByteList(offsets[0]) ?? [],
id: reader.readString(offsets[1]),
final object = DeviceAsset(
backupSelection: _DeviceAssetbackupSelectionValueEnumMap[
reader.readByteOrNull(offsets[0])] ??
BackupSelection.none,
hash: reader.readByteList(offsets[1]) ?? [],
id: reader.readString(offsets[2]),
);
return object;
}
P _iOSDeviceAssetDeserializeProp<P>(
P _deviceAssetDeserializeProp<P>(
IsarReader reader,
int propertyId,
int offset,
@@ -111,31 +121,46 @@ P _iOSDeviceAssetDeserializeProp<P>(
) {
switch (propertyId) {
case 0:
return (reader.readByteList(offset) ?? []) as P;
return (_DeviceAssetbackupSelectionValueEnumMap[
reader.readByteOrNull(offset)] ??
BackupSelection.none) as P;
case 1:
return (reader.readByteList(offset) ?? []) as P;
case 2:
return (reader.readString(offset)) as P;
default:
throw IsarError('Unknown property with id $propertyId');
}
}
Id _iOSDeviceAssetGetId(IOSDeviceAsset object) {
const _DeviceAssetbackupSelectionEnumValueMap = {
'none': 0,
'select': 1,
'exclude': 2,
};
const _DeviceAssetbackupSelectionValueEnumMap = {
0: BackupSelection.none,
1: BackupSelection.select,
2: BackupSelection.exclude,
};
Id _deviceAssetGetId(DeviceAsset object) {
return object.isarId;
}
List<IsarLinkBase<dynamic>> _iOSDeviceAssetGetLinks(IOSDeviceAsset object) {
List<IsarLinkBase<dynamic>> _deviceAssetGetLinks(DeviceAsset object) {
return [];
}
void _iOSDeviceAssetAttach(
IsarCollection<dynamic> col, Id id, IOSDeviceAsset object) {}
void _deviceAssetAttach(
IsarCollection<dynamic> col, Id id, DeviceAsset object) {}
extension IOSDeviceAssetByIndex on IsarCollection<IOSDeviceAsset> {
Future<IOSDeviceAsset?> getById(String id) {
extension DeviceAssetByIndex on IsarCollection<DeviceAsset> {
Future<DeviceAsset?> getById(String id) {
return getByIndex(r'id', [id]);
}
IOSDeviceAsset? getByIdSync(String id) {
DeviceAsset? getByIdSync(String id) {
return getByIndexSync(r'id', [id]);
}
@@ -147,12 +172,12 @@ extension IOSDeviceAssetByIndex on IsarCollection<IOSDeviceAsset> {
return deleteByIndexSync(r'id', [id]);
}
Future<List<IOSDeviceAsset?>> getAllById(List<String> idValues) {
Future<List<DeviceAsset?>> getAllById(List<String> idValues) {
final values = idValues.map((e) => [e]).toList();
return getAllByIndex(r'id', values);
}
List<IOSDeviceAsset?> getAllByIdSync(List<String> idValues) {
List<DeviceAsset?> getAllByIdSync(List<String> idValues) {
final values = idValues.map((e) => [e]).toList();
return getAllByIndexSync(r'id', values);
}
@@ -167,36 +192,35 @@ extension IOSDeviceAssetByIndex on IsarCollection<IOSDeviceAsset> {
return deleteAllByIndexSync(r'id', values);
}
Future<Id> putById(IOSDeviceAsset object) {
Future<Id> putById(DeviceAsset object) {
return putByIndex(r'id', object);
}
Id putByIdSync(IOSDeviceAsset object, {bool saveLinks = true}) {
Id putByIdSync(DeviceAsset object, {bool saveLinks = true}) {
return putByIndexSync(r'id', object, saveLinks: saveLinks);
}
Future<List<Id>> putAllById(List<IOSDeviceAsset> objects) {
Future<List<Id>> putAllById(List<DeviceAsset> objects) {
return putAllByIndex(r'id', objects);
}
List<Id> putAllByIdSync(List<IOSDeviceAsset> objects,
{bool saveLinks = true}) {
List<Id> putAllByIdSync(List<DeviceAsset> objects, {bool saveLinks = true}) {
return putAllByIndexSync(r'id', objects, saveLinks: saveLinks);
}
}
extension IOSDeviceAssetQueryWhereSort
on QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QWhere> {
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterWhere> anyIsarId() {
extension DeviceAssetQueryWhereSort
on QueryBuilder<DeviceAsset, DeviceAsset, QWhere> {
QueryBuilder<DeviceAsset, DeviceAsset, QAfterWhere> anyIsarId() {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(const IdWhereClause.any());
});
}
}
extension IOSDeviceAssetQueryWhere
on QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QWhereClause> {
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterWhereClause> isarIdEqualTo(
extension DeviceAssetQueryWhere
on QueryBuilder<DeviceAsset, DeviceAsset, QWhereClause> {
QueryBuilder<DeviceAsset, DeviceAsset, QAfterWhereClause> isarIdEqualTo(
Id isarId) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IdWhereClause.between(
@@ -206,8 +230,8 @@ extension IOSDeviceAssetQueryWhere
});
}
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterWhereClause>
isarIdNotEqualTo(Id isarId) {
QueryBuilder<DeviceAsset, DeviceAsset, QAfterWhereClause> isarIdNotEqualTo(
Id isarId) {
return QueryBuilder.apply(this, (query) {
if (query.whereSort == Sort.asc) {
return query
@@ -229,8 +253,9 @@ extension IOSDeviceAssetQueryWhere
});
}
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterWhereClause>
isarIdGreaterThan(Id isarId, {bool include = false}) {
QueryBuilder<DeviceAsset, DeviceAsset, QAfterWhereClause> isarIdGreaterThan(
Id isarId,
{bool include = false}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(
IdWhereClause.greaterThan(lower: isarId, includeLower: include),
@@ -238,8 +263,9 @@ extension IOSDeviceAssetQueryWhere
});
}
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterWhereClause>
isarIdLessThan(Id isarId, {bool include = false}) {
QueryBuilder<DeviceAsset, DeviceAsset, QAfterWhereClause> isarIdLessThan(
Id isarId,
{bool include = false}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(
IdWhereClause.lessThan(upper: isarId, includeUpper: include),
@@ -247,7 +273,7 @@ extension IOSDeviceAssetQueryWhere
});
}
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterWhereClause> isarIdBetween(
QueryBuilder<DeviceAsset, DeviceAsset, QAfterWhereClause> isarIdBetween(
Id lowerIsarId,
Id upperIsarId, {
bool includeLower = true,
@@ -263,7 +289,7 @@ extension IOSDeviceAssetQueryWhere
});
}
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterWhereClause> idEqualTo(
QueryBuilder<DeviceAsset, DeviceAsset, QAfterWhereClause> idEqualTo(
String id) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IndexWhereClause.equalTo(
@@ -273,7 +299,7 @@ extension IOSDeviceAssetQueryWhere
});
}
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterWhereClause> idNotEqualTo(
QueryBuilder<DeviceAsset, DeviceAsset, QAfterWhereClause> idNotEqualTo(
String id) {
return QueryBuilder.apply(this, (query) {
if (query.whereSort == Sort.asc) {
@@ -308,7 +334,7 @@ extension IOSDeviceAssetQueryWhere
});
}
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterWhereClause> hashEqualTo(
QueryBuilder<DeviceAsset, DeviceAsset, QAfterWhereClause> hashEqualTo(
List<int> hash) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IndexWhereClause.equalTo(
@@ -318,8 +344,8 @@ extension IOSDeviceAssetQueryWhere
});
}
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterWhereClause>
hashNotEqualTo(List<int> hash) {
QueryBuilder<DeviceAsset, DeviceAsset, QAfterWhereClause> hashNotEqualTo(
List<int> hash) {
return QueryBuilder.apply(this, (query) {
if (query.whereSort == Sort.asc) {
return query
@@ -354,9 +380,65 @@ extension IOSDeviceAssetQueryWhere
}
}
extension IOSDeviceAssetQueryFilter
on QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QFilterCondition> {
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
extension DeviceAssetQueryFilter
on QueryBuilder<DeviceAsset, DeviceAsset, QFilterCondition> {
QueryBuilder<DeviceAsset, DeviceAsset, QAfterFilterCondition>
backupSelectionEqualTo(BackupSelection value) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'backupSelection',
value: value,
));
});
}
QueryBuilder<DeviceAsset, DeviceAsset, QAfterFilterCondition>
backupSelectionGreaterThan(
BackupSelection value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'backupSelection',
value: value,
));
});
}
QueryBuilder<DeviceAsset, DeviceAsset, QAfterFilterCondition>
backupSelectionLessThan(
BackupSelection value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'backupSelection',
value: value,
));
});
}
QueryBuilder<DeviceAsset, DeviceAsset, QAfterFilterCondition>
backupSelectionBetween(
BackupSelection lower,
BackupSelection upper, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'backupSelection',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
));
});
}
QueryBuilder<DeviceAsset, DeviceAsset, QAfterFilterCondition>
hashElementEqualTo(int value) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
@@ -366,7 +448,7 @@ extension IOSDeviceAssetQueryFilter
});
}
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
QueryBuilder<DeviceAsset, DeviceAsset, QAfterFilterCondition>
hashElementGreaterThan(
int value, {
bool include = false,
@@ -380,7 +462,7 @@ extension IOSDeviceAssetQueryFilter
});
}
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
QueryBuilder<DeviceAsset, DeviceAsset, QAfterFilterCondition>
hashElementLessThan(
int value, {
bool include = false,
@@ -394,7 +476,7 @@ extension IOSDeviceAssetQueryFilter
});
}
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
QueryBuilder<DeviceAsset, DeviceAsset, QAfterFilterCondition>
hashElementBetween(
int lower,
int upper, {
@@ -412,7 +494,7 @@ extension IOSDeviceAssetQueryFilter
});
}
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
QueryBuilder<DeviceAsset, DeviceAsset, QAfterFilterCondition>
hashLengthEqualTo(int length) {
return QueryBuilder.apply(this, (query) {
return query.listLength(
@@ -425,8 +507,7 @@ extension IOSDeviceAssetQueryFilter
});
}
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
hashIsEmpty() {
QueryBuilder<DeviceAsset, DeviceAsset, QAfterFilterCondition> hashIsEmpty() {
return QueryBuilder.apply(this, (query) {
return query.listLength(
r'hash',
@@ -438,7 +519,7 @@ extension IOSDeviceAssetQueryFilter
});
}
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
QueryBuilder<DeviceAsset, DeviceAsset, QAfterFilterCondition>
hashIsNotEmpty() {
return QueryBuilder.apply(this, (query) {
return query.listLength(
@@ -451,7 +532,7 @@ extension IOSDeviceAssetQueryFilter
});
}
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
QueryBuilder<DeviceAsset, DeviceAsset, QAfterFilterCondition>
hashLengthLessThan(
int length, {
bool include = false,
@@ -467,7 +548,7 @@ extension IOSDeviceAssetQueryFilter
});
}
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
QueryBuilder<DeviceAsset, DeviceAsset, QAfterFilterCondition>
hashLengthGreaterThan(
int length, {
bool include = false,
@@ -483,7 +564,7 @@ extension IOSDeviceAssetQueryFilter
});
}
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
QueryBuilder<DeviceAsset, DeviceAsset, QAfterFilterCondition>
hashLengthBetween(
int lower,
int upper, {
@@ -501,7 +582,7 @@ extension IOSDeviceAssetQueryFilter
});
}
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition> idEqualTo(
QueryBuilder<DeviceAsset, DeviceAsset, QAfterFilterCondition> idEqualTo(
String value, {
bool caseSensitive = true,
}) {
@@ -514,8 +595,7 @@ extension IOSDeviceAssetQueryFilter
});
}
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
idGreaterThan(
QueryBuilder<DeviceAsset, DeviceAsset, QAfterFilterCondition> idGreaterThan(
String value, {
bool include = false,
bool caseSensitive = true,
@@ -530,8 +610,7 @@ extension IOSDeviceAssetQueryFilter
});
}
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
idLessThan(
QueryBuilder<DeviceAsset, DeviceAsset, QAfterFilterCondition> idLessThan(
String value, {
bool include = false,
bool caseSensitive = true,
@@ -546,7 +625,7 @@ extension IOSDeviceAssetQueryFilter
});
}
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition> idBetween(
QueryBuilder<DeviceAsset, DeviceAsset, QAfterFilterCondition> idBetween(
String lower,
String upper, {
bool includeLower = true,
@@ -565,8 +644,7 @@ extension IOSDeviceAssetQueryFilter
});
}
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
idStartsWith(
QueryBuilder<DeviceAsset, DeviceAsset, QAfterFilterCondition> idStartsWith(
String value, {
bool caseSensitive = true,
}) {
@@ -579,8 +657,7 @@ extension IOSDeviceAssetQueryFilter
});
}
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
idEndsWith(
QueryBuilder<DeviceAsset, DeviceAsset, QAfterFilterCondition> idEndsWith(
String value, {
bool caseSensitive = true,
}) {
@@ -593,8 +670,9 @@ extension IOSDeviceAssetQueryFilter
});
}
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
idContains(String value, {bool caseSensitive = true}) {
QueryBuilder<DeviceAsset, DeviceAsset, QAfterFilterCondition> idContains(
String value,
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.contains(
property: r'id',
@@ -604,7 +682,7 @@ extension IOSDeviceAssetQueryFilter
});
}
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition> idMatches(
QueryBuilder<DeviceAsset, DeviceAsset, QAfterFilterCondition> idMatches(
String pattern,
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
@@ -616,8 +694,7 @@ extension IOSDeviceAssetQueryFilter
});
}
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
idIsEmpty() {
QueryBuilder<DeviceAsset, DeviceAsset, QAfterFilterCondition> idIsEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'id',
@@ -626,8 +703,7 @@ extension IOSDeviceAssetQueryFilter
});
}
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
idIsNotEmpty() {
QueryBuilder<DeviceAsset, DeviceAsset, QAfterFilterCondition> idIsNotEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
property: r'id',
@@ -636,8 +712,8 @@ extension IOSDeviceAssetQueryFilter
});
}
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
isarIdEqualTo(Id value) {
QueryBuilder<DeviceAsset, DeviceAsset, QAfterFilterCondition> isarIdEqualTo(
Id value) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'isarId',
@@ -646,7 +722,7 @@ extension IOSDeviceAssetQueryFilter
});
}
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
QueryBuilder<DeviceAsset, DeviceAsset, QAfterFilterCondition>
isarIdGreaterThan(
Id value, {
bool include = false,
@@ -660,8 +736,7 @@ extension IOSDeviceAssetQueryFilter
});
}
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
isarIdLessThan(
QueryBuilder<DeviceAsset, DeviceAsset, QAfterFilterCondition> isarIdLessThan(
Id value, {
bool include = false,
}) {
@@ -674,8 +749,7 @@ extension IOSDeviceAssetQueryFilter
});
}
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
isarIdBetween(
QueryBuilder<DeviceAsset, DeviceAsset, QAfterFilterCondition> isarIdBetween(
Id lower,
Id upper, {
bool includeLower = true,
@@ -693,64 +767,96 @@ extension IOSDeviceAssetQueryFilter
}
}
extension IOSDeviceAssetQueryObject
on QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QFilterCondition> {}
extension DeviceAssetQueryObject
on QueryBuilder<DeviceAsset, DeviceAsset, QFilterCondition> {}
extension IOSDeviceAssetQueryLinks
on QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QFilterCondition> {}
extension DeviceAssetQueryLinks
on QueryBuilder<DeviceAsset, DeviceAsset, QFilterCondition> {}
extension IOSDeviceAssetQuerySortBy
on QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QSortBy> {
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterSortBy> sortById() {
extension DeviceAssetQuerySortBy
on QueryBuilder<DeviceAsset, DeviceAsset, QSortBy> {
QueryBuilder<DeviceAsset, DeviceAsset, QAfterSortBy> sortByBackupSelection() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'backupSelection', Sort.asc);
});
}
QueryBuilder<DeviceAsset, DeviceAsset, QAfterSortBy>
sortByBackupSelectionDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'backupSelection', Sort.desc);
});
}
QueryBuilder<DeviceAsset, DeviceAsset, QAfterSortBy> sortById() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'id', Sort.asc);
});
}
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterSortBy> sortByIdDesc() {
QueryBuilder<DeviceAsset, DeviceAsset, QAfterSortBy> sortByIdDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'id', Sort.desc);
});
}
}
extension IOSDeviceAssetQuerySortThenBy
on QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QSortThenBy> {
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterSortBy> thenById() {
extension DeviceAssetQuerySortThenBy
on QueryBuilder<DeviceAsset, DeviceAsset, QSortThenBy> {
QueryBuilder<DeviceAsset, DeviceAsset, QAfterSortBy> thenByBackupSelection() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'backupSelection', Sort.asc);
});
}
QueryBuilder<DeviceAsset, DeviceAsset, QAfterSortBy>
thenByBackupSelectionDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'backupSelection', Sort.desc);
});
}
QueryBuilder<DeviceAsset, DeviceAsset, QAfterSortBy> thenById() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'id', Sort.asc);
});
}
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterSortBy> thenByIdDesc() {
QueryBuilder<DeviceAsset, DeviceAsset, QAfterSortBy> thenByIdDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'id', Sort.desc);
});
}
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterSortBy> thenByIsarId() {
QueryBuilder<DeviceAsset, DeviceAsset, QAfterSortBy> thenByIsarId() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'isarId', Sort.asc);
});
}
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterSortBy>
thenByIsarIdDesc() {
QueryBuilder<DeviceAsset, DeviceAsset, QAfterSortBy> thenByIsarIdDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'isarId', Sort.desc);
});
}
}
extension IOSDeviceAssetQueryWhereDistinct
on QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QDistinct> {
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QDistinct> distinctByHash() {
extension DeviceAssetQueryWhereDistinct
on QueryBuilder<DeviceAsset, DeviceAsset, QDistinct> {
QueryBuilder<DeviceAsset, DeviceAsset, QDistinct>
distinctByBackupSelection() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'backupSelection');
});
}
QueryBuilder<DeviceAsset, DeviceAsset, QDistinct> distinctByHash() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'hash');
});
}
QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QDistinct> distinctById(
QueryBuilder<DeviceAsset, DeviceAsset, QDistinct> distinctById(
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'id', caseSensitive: caseSensitive);
@@ -758,21 +864,28 @@ extension IOSDeviceAssetQueryWhereDistinct
}
}
extension IOSDeviceAssetQueryProperty
on QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QQueryProperty> {
QueryBuilder<IOSDeviceAsset, int, QQueryOperations> isarIdProperty() {
extension DeviceAssetQueryProperty
on QueryBuilder<DeviceAsset, DeviceAsset, QQueryProperty> {
QueryBuilder<DeviceAsset, int, QQueryOperations> isarIdProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'isarId');
});
}
QueryBuilder<IOSDeviceAsset, List<int>, QQueryOperations> hashProperty() {
QueryBuilder<DeviceAsset, BackupSelection, QQueryOperations>
backupSelectionProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'backupSelection');
});
}
QueryBuilder<DeviceAsset, List<int>, QQueryOperations> hashProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'hash');
});
}
QueryBuilder<IOSDeviceAsset, String, QQueryOperations> idProperty() {
QueryBuilder<DeviceAsset, String, QQueryOperations> idProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'id');
});
@@ -1,14 +0,0 @@
import 'package:immich_mobile/shared/models/device_asset.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:isar/isar.dart';
part 'ios_device_asset.g.dart';
@Collection()
class IOSDeviceAsset extends DeviceAsset {
IOSDeviceAsset({required this.id, required super.hash});
@Index(replace: true, unique: true, type: IndexType.hash)
String id;
Id get isarId => fastHash(id);
}
+3 -3
View File
@@ -1,6 +1,6 @@
import 'dart:ui';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:isar/isar.dart';
import 'package:openapi/api.dart';
@@ -92,9 +92,9 @@ class User {
bool get hasQuota => quotaSizeInBytes > 0;
@Backlink(to: 'owner')
final IsarLinks<Album> albums = IsarLinks<Album>();
final IsarLinks<RemoteAlbum> albums = IsarLinks<RemoteAlbum>();
@Backlink(to: 'sharedUsers')
final IsarLinks<Album> sharedAlbums = IsarLinks<Album>();
final IsarLinks<RemoteAlbum> sharedAlbums = IsarLinks<RemoteAlbum>();
@override
bool operator ==(other) {
+9 -8
View File
@@ -111,16 +111,16 @@ const UserSchema = CollectionSchema(
},
links: {
r'albums': LinkSchema(
id: -8764917375410137318,
id: 745273729987915243,
name: r'albums',
target: r'Album',
target: r'RemoteAlbum',
single: false,
linkName: r'owner',
),
r'sharedAlbums': LinkSchema(
id: -7037628715076287024,
id: 8917117772714542143,
name: r'sharedAlbums',
target: r'Album',
target: r'RemoteAlbum',
single: false,
linkName: r'sharedUsers',
)
@@ -268,9 +268,9 @@ List<IsarLinkBase<dynamic>> _userGetLinks(User object) {
}
void _userAttach(IsarCollection<dynamic> col, Id id, User object) {
object.albums.attach(col, col.isar.collection<Album>(), r'albums', id);
object.albums.attach(col, col.isar.collection<RemoteAlbum>(), r'albums', id);
object.sharedAlbums
.attach(col, col.isar.collection<Album>(), r'sharedAlbums', id);
.attach(col, col.isar.collection<RemoteAlbum>(), r'sharedAlbums', id);
}
extension UserByIndex on IsarCollection<User> {
@@ -1286,7 +1286,8 @@ extension UserQueryFilter on QueryBuilder<User, User, QFilterCondition> {
extension UserQueryObject on QueryBuilder<User, User, QFilterCondition> {}
extension UserQueryLinks on QueryBuilder<User, User, QFilterCondition> {
QueryBuilder<User, User, QAfterFilterCondition> albums(FilterQuery<Album> q) {
QueryBuilder<User, User, QAfterFilterCondition> albums(
FilterQuery<RemoteAlbum> q) {
return QueryBuilder.apply(this, (query) {
return query.link(q, r'albums');
});
@@ -1342,7 +1343,7 @@ extension UserQueryLinks on QueryBuilder<User, User, QFilterCondition> {
}
QueryBuilder<User, User, QAfterFilterCondition> sharedAlbums(
FilterQuery<Album> q) {
FilterQuery<RemoteAlbum> q) {
return QueryBuilder.apply(this, (query) {
return query.link(q, r'sharedAlbums');
});
@@ -1,5 +1,6 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/providers/local_album.provider.dart';
import 'package:immich_mobile/modules/album/providers/remote_album.provider.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
@@ -63,7 +64,8 @@ class AppStateNotiifer extends StateNotifier<AppStateEnum> {
_ref.read(assetProvider.notifier).getPartnerAssets();
_ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
case TabEnum.library:
_ref.read(albumProvider.notifier).getAllAlbums();
_ref.read(remoteAlbumsProvider.notifier).getRemoteAlbums();
_ref.read(localAlbumsProvider.notifier).getDeviceAlbums();
}
}
+33 -13
View File
@@ -1,6 +1,8 @@
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/album/providers/local_album_service.provider.dart';
import 'package:immich_mobile/modules/album/services/local_album.service.dart';
import 'package:immich_mobile/modules/backup/providers/device_assets.provider.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';
@@ -16,10 +18,13 @@ import 'package:immich_mobile/utils/renderlist_generator.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'asset.provider.g.dart';
class AssetNotifier extends StateNotifier<bool> {
final AssetService _assetService;
final AlbumService _albumService;
final LocalAlbumService _albumService;
final UserService _userService;
final SyncService _syncService;
final Isar _db;
@@ -47,13 +52,14 @@ class AssetNotifier extends StateNotifier<bool> {
state = true;
if (clear) {
await clearAssetsAndAlbums(_db);
await _userService.refreshUsers();
log.info("Manual refresh requested, cleared assets and albums from db");
}
final bool newRemote = await _assetService.refreshRemoteAssets();
final bool newLocal = await _albumService.refreshDeviceAlbums();
debugPrint("newRemote: $newRemote, newLocal: $newLocal");
log.info("Load assets: ${stopwatch.elapsedMilliseconds}ms");
log.fine("Load assets: ${stopwatch.elapsedMilliseconds}ms");
} finally {
_getAllAssetInProgress = false;
state = false;
@@ -75,7 +81,7 @@ class AssetNotifier extends StateNotifier<bool> {
} else {
await _assetService.refreshRemoteAssets(partner);
}
log.info("Load partner assets: ${stopwatch.elapsedMilliseconds}ms");
log.fine("Load partner assets: ${stopwatch.elapsedMilliseconds}ms");
} finally {
_getPartnerAssetsInProgress = false;
}
@@ -317,7 +323,7 @@ class AssetNotifier extends StateNotifier<bool> {
final assetProvider = StateNotifierProvider<AssetNotifier, bool>((ref) {
return AssetNotifier(
ref.watch(assetServiceProvider),
ref.watch(albumServiceProvider),
ref.watch(localAlbumServiceProvider),
ref.watch(userServiceProvider),
ref.watch(syncServiceProvider),
ref.watch(dbProvider),
@@ -339,24 +345,27 @@ final assetWatcher =
return db.assets.watchObject(asset.id, fireImmediately: true);
});
final assetsProvider = StreamProvider.family<RenderList, int?>((ref, userId) {
@riverpod
Stream<RenderList> assets(AssetsRef ref, int? userId) {
if (userId == null) return const Stream.empty();
final query = _commonFilterAndSort(
ref,
_assets(ref).where().ownerIdEqualToAnyChecksum(userId),
);
return renderListGenerator(query, ref);
});
return renderListGeneratorAutoDispose(query, ref);
}
final multiUserAssetsProvider =
StreamProvider.family<RenderList, List<int>>((ref, userIds) {
@riverpod
Stream<RenderList> multiUserAssets(MultiUserAssetsRef ref, List<int> userIds) {
if (userIds.isEmpty) return const Stream.empty();
final query = _commonFilterAndSort(
ref,
_assets(ref)
.where()
.anyOf(userIds, (q, u) => q.ownerIdEqualToAnyChecksum(u)),
);
return renderListGenerator(query, ref);
});
return renderListGeneratorAutoDispose(query, ref);
}
QueryBuilder<Asset, Asset, QAfterSortBy>? getRemoteAssetQuery(WidgetRef ref) {
final userId = ref.watch(currentUserProvider)?.isarId;
@@ -375,16 +384,27 @@ QueryBuilder<Asset, Asset, QAfterSortBy>? getRemoteAssetQuery(WidgetRef ref) {
.sortByFileCreatedAtDesc();
}
IsarCollection<Asset> _assets(StreamProviderRef<RenderList> ref) =>
IsarCollection<Asset> _assets(AutoDisposeStreamProviderRef<RenderList> ref) =>
ref.watch(dbProvider).assets;
QueryBuilder<Asset, Asset, QAfterSortBy> _commonFilterAndSort(
AutoDisposeStreamProviderRef<RenderList> ref,
QueryBuilder<Asset, Asset, QAfterWhereClause> query,
) {
final localIds =
ref.watch(deviceAssetsProvider).valueOrNull?.assetIdsForBackup ??
<String>[];
return query
.filter()
.isArchivedEqualTo(false)
.isTrashedEqualTo(false)
.stackParentIdIsNull()
.group(
(q) => q
.remoteIdIsNotNull()
.or()
.anyOf(localIds, (q, id) => q.localIdEqualTo(id)),
)
.sortByFileCreatedAtDesc();
}
+286
View File
@@ -0,0 +1,286 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'asset.provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$assetsHash() => r'04c89e5af379ea61478631e41c273c264efdb0ea';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
/// See also [assets].
@ProviderFor(assets)
const assetsProvider = AssetsFamily();
/// See also [assets].
class AssetsFamily extends Family<AsyncValue<RenderList>> {
/// See also [assets].
const AssetsFamily();
/// See also [assets].
AssetsProvider call(
int? userId,
) {
return AssetsProvider(
userId,
);
}
@override
AssetsProvider getProviderOverride(
covariant AssetsProvider provider,
) {
return call(
provider.userId,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'assetsProvider';
}
/// See also [assets].
class AssetsProvider extends AutoDisposeStreamProvider<RenderList> {
/// See also [assets].
AssetsProvider(
int? userId,
) : this._internal(
(ref) => assets(
ref as AssetsRef,
userId,
),
from: assetsProvider,
name: r'assetsProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$assetsHash,
dependencies: AssetsFamily._dependencies,
allTransitiveDependencies: AssetsFamily._allTransitiveDependencies,
userId: userId,
);
AssetsProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.userId,
}) : super.internal();
final int? userId;
@override
Override overrideWith(
Stream<RenderList> Function(AssetsRef provider) create,
) {
return ProviderOverride(
origin: this,
override: AssetsProvider._internal(
(ref) => create(ref as AssetsRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
userId: userId,
),
);
}
@override
AutoDisposeStreamProviderElement<RenderList> createElement() {
return _AssetsProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is AssetsProvider && other.userId == userId;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, userId.hashCode);
return _SystemHash.finish(hash);
}
}
mixin AssetsRef on AutoDisposeStreamProviderRef<RenderList> {
/// The parameter `userId` of this provider.
int? get userId;
}
class _AssetsProviderElement
extends AutoDisposeStreamProviderElement<RenderList> with AssetsRef {
_AssetsProviderElement(super.provider);
@override
int? get userId => (origin as AssetsProvider).userId;
}
String _$multiUserAssetsHash() => r'4212e042ecfa53eaf54f7e26db2a106b5f6f16f4';
/// See also [multiUserAssets].
@ProviderFor(multiUserAssets)
const multiUserAssetsProvider = MultiUserAssetsFamily();
/// See also [multiUserAssets].
class MultiUserAssetsFamily extends Family<AsyncValue<RenderList>> {
/// See also [multiUserAssets].
const MultiUserAssetsFamily();
/// See also [multiUserAssets].
MultiUserAssetsProvider call(
List<int> userIds,
) {
return MultiUserAssetsProvider(
userIds,
);
}
@override
MultiUserAssetsProvider getProviderOverride(
covariant MultiUserAssetsProvider provider,
) {
return call(
provider.userIds,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'multiUserAssetsProvider';
}
/// See also [multiUserAssets].
class MultiUserAssetsProvider extends AutoDisposeStreamProvider<RenderList> {
/// See also [multiUserAssets].
MultiUserAssetsProvider(
List<int> userIds,
) : this._internal(
(ref) => multiUserAssets(
ref as MultiUserAssetsRef,
userIds,
),
from: multiUserAssetsProvider,
name: r'multiUserAssetsProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$multiUserAssetsHash,
dependencies: MultiUserAssetsFamily._dependencies,
allTransitiveDependencies:
MultiUserAssetsFamily._allTransitiveDependencies,
userIds: userIds,
);
MultiUserAssetsProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.userIds,
}) : super.internal();
final List<int> userIds;
@override
Override overrideWith(
Stream<RenderList> Function(MultiUserAssetsRef provider) create,
) {
return ProviderOverride(
origin: this,
override: MultiUserAssetsProvider._internal(
(ref) => create(ref as MultiUserAssetsRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
userIds: userIds,
),
);
}
@override
AutoDisposeStreamProviderElement<RenderList> createElement() {
return _MultiUserAssetsProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is MultiUserAssetsProvider && other.userIds == userIds;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, userIds.hashCode);
return _SystemHash.finish(hash);
}
}
mixin MultiUserAssetsRef on AutoDisposeStreamProviderRef<RenderList> {
/// The parameter `userIds` of this provider.
List<int> get userIds;
}
class _MultiUserAssetsProviderElement
extends AutoDisposeStreamProviderElement<RenderList>
with MultiUserAssetsRef {
_MultiUserAssetsProviderElement(super.provider);
@override
List<int> get userIds => (origin as MultiUserAssetsProvider).userIds;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
+4 -23
View File
@@ -1,14 +1,9 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
import 'package:immich_mobile/shared/models/android_device_asset.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/device_asset.dart';
import 'package:immich_mobile/shared/models/ios_device_asset.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:photo_manager/photo_manager.dart';
@@ -41,10 +36,8 @@ class HashService {
const int batchFileCount = 128;
const int batchDataSize = 1024 * 1024 * 1024; // 1GB
final ids = assetEntities
.map(Platform.isAndroid ? (a) => a.id.toInt() : (a) => a.id)
.toList();
final List<DeviceAsset?> hashes = await _lookupHashes(ids);
final ids = assetEntities.map((a) => a.id).toList();
final List<DeviceAsset?> hashes = await _db.deviceAssets.getAllById(ids);
final List<DeviceAsset> toAdd = [];
final List<String> toHash = [];
@@ -63,9 +56,7 @@ class HashService {
}
bytes += await file.length();
toHash.add(file.path);
final deviceAsset = Platform.isAndroid
? AndroidDeviceAsset(id: ids[i] as int, hash: const [])
: IOSDeviceAsset(id: ids[i] as String, hash: const []);
final deviceAsset = DeviceAsset(id: ids[i], hash: const []);
toAdd.add(deviceAsset);
hashes[i] = deviceAsset;
if (toHash.length == batchFileCount || bytes >= batchDataSize) {
@@ -81,12 +72,6 @@ class HashService {
return _mapAllHashedAssets(assetEntities, hashes);
}
/// Lookup hashes of assets by their local ID
Future<List<DeviceAsset?>> _lookupHashes(List<Object> ids) =>
Platform.isAndroid
? _db.androidDeviceAssets.getAll(ids.cast())
: _db.iOSDeviceAssets.getAllById(ids.cast());
/// Processes a batch of files and saves any successfully hashed
/// values to the DB table.
Future<void> _processBatch(
@@ -106,11 +91,7 @@ class HashService {
final validHashes = anyNull
? toAdd.where((e) => e.hash.length == 20).toList(growable: false)
: toAdd;
await _db.writeTxn(
() => Platform.isAndroid
? _db.androidDeviceAssets.putAll(validHashes.cast())
: _db.iOSDeviceAssets.putAll(validHashes.cast()),
);
await _db.writeTxn(() => _db.deviceAssets.putAll(validHashes));
_log.fine("Hashed ${validHashes.length}/${toHash.length} assets");
}
+94 -391
View File
@@ -2,40 +2,38 @@ import 'dart:async';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/extensions/album_extensions.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/etag.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/hash.service.dart';
import 'package:immich_mobile/utils/async_mutex.dart';
import 'package:immich_mobile/extensions/collection_extensions.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
final syncServiceProvider = Provider(
(ref) => SyncService(ref.watch(dbProvider), ref.watch(hashServiceProvider)),
(ref) => SyncService(ref.watch(dbProvider)),
);
class SyncService {
final Isar _db;
final HashService _hashService;
final AsyncMutex _lock = AsyncMutex();
static final AsyncMutex lock = AsyncMutex();
final Logger _log = Logger('SyncService');
SyncService(this._db, this._hashService);
SyncService(this._db);
// public methods:
/// Syncs users from the server to the local database
/// Returns `true`if there were any changes
Future<bool> syncUsersFromServer(List<User> users) =>
_lock.run(() => _syncUsersFromServer(users));
lock.run(() => _syncUsersFromServer(users));
/// Syncs remote assets owned by the logged-in user to the DB
/// Returns `true` if there were any changes
@@ -47,7 +45,7 @@ class SyncService {
) getChangedAssets,
FutureOr<List<Asset>?> Function(User user) loadAssets,
) =>
_lock.run(
lock.run(
() async =>
await _syncRemoteAssetChanges(user, getChangedAssets) ??
await _syncRemoteAssetsFull(user, loadAssets),
@@ -60,15 +58,7 @@ class SyncService {
required bool isShared,
required FutureOr<AlbumResponseDto> Function(AlbumResponseDto) loadDetails,
}) =>
_lock.run(() => _syncRemoteAlbumsToDb(remote, isShared, loadDetails));
/// Syncs all device albums and their assets to the database
/// Returns `true` if there were any changes
Future<bool> syncLocalAlbumAssetsToDb(
List<AssetPathEntity> onDevice, [
Set<String>? excludedAssets,
]) =>
_lock.run(() => _syncLocalAlbumAssetsToDb(onDevice, excludedAssets));
lock.run(() => _syncRemoteAlbumsToDb(remote, isShared, loadDetails));
/// returns all Asset IDs that are not contained in the existing list
List<int> sharedAssetsToRemove(
@@ -80,7 +70,7 @@ class SyncService {
}
deleteCandidates.sort(Asset.compareById);
existing.sort(Asset.compareById);
return _diffAssets(existing, deleteCandidates, compare: Asset.compareById)
return diffAssets(existing, deleteCandidates, compare: Asset.compareById)
.$3
.map((e) => e.id)
.toList();
@@ -88,10 +78,7 @@ class SyncService {
/// Syncs a new asset to the db. Returns `true` if successful
Future<bool> syncNewAssetToDb(Asset newAsset) =>
_lock.run(() => _syncNewAssetToDb(newAsset));
Future<bool> removeAllLocalAlbumsAndAssets() =>
_lock.run(_removeAllLocalAlbumsAndAssets);
lock.run(() => _syncNewAssetToDb(newAsset));
// private methods:
@@ -222,7 +209,7 @@ class SyncService {
// filter our duplicates that might be introduced by the chunked retrieval
remote.uniqueConsecutive(compare: Asset.compareByChecksum);
final (toAdd, toUpdate, toRemove) = _diffAssets(remote, inDb, remote: true);
final (toAdd, toUpdate, toRemove) = diffAssets(remote, inDb, remote: true);
if (toAdd.isEmpty && toUpdate.isEmpty && toRemove.isEmpty) {
await _updateUserAssetsETag(user, now);
return false;
@@ -250,16 +237,16 @@ class SyncService {
) async {
remote.sortBy((e) => e.id);
final baseQuery = _db.albums.where().remoteIdIsNotNull().filter();
final QueryBuilder<Album, Album, QAfterFilterCondition> query;
final baseQuery = _db.remoteAlbums.where().filter();
final QueryBuilder<RemoteAlbum, RemoteAlbum, QAfterFilterCondition> query;
if (isShared) {
query = baseQuery.sharedEqualTo(true);
} else {
final User me = Store.get(StoreKey.currentUser);
query = baseQuery.owner((q) => q.isarIdEqualTo(me.isarId));
}
final List<Album> dbAlbums = await query.sortByRemoteId().findAll();
assert(dbAlbums.isSortedBy((e) => e.remoteId!), "dbAlbums not sorted!");
final List<RemoteAlbum> dbAlbums = await query.sortById().findAll();
assert(dbAlbums.isSortedBy((e) => e.id), "dbAlbums not sorted!");
final List<Asset> toDelete = [];
final List<Asset> existing = [];
@@ -267,12 +254,12 @@ class SyncService {
final bool changes = await diffSortedLists(
remote,
dbAlbums,
compare: (AlbumResponseDto a, Album b) => a.id.compareTo(b.remoteId!),
both: (AlbumResponseDto a, Album b) =>
compare: (AlbumResponseDto a, RemoteAlbum b) => a.id.compareTo(b.id),
both: (AlbumResponseDto a, RemoteAlbum b) =>
_syncRemoteAlbum(a, b, toDelete, existing, loadDetails),
onlyFirst: (AlbumResponseDto a) =>
_addAlbumFromServer(a, existing, loadDetails),
onlySecond: (Album a) => _removeAlbumFromDb(a, toDelete),
onlySecond: (RemoteAlbum a) => _removeAlbumFromDb(a, toDelete),
);
if (isShared && toDelete.isNotEmpty) {
@@ -294,7 +281,7 @@ class SyncService {
/// accumulates
Future<bool> _syncRemoteAlbum(
AlbumResponseDto dto,
Album album,
RemoteAlbum album,
List<Asset> deleteCandidates,
List<Asset> existing,
FutureOr<AlbumResponseDto> Function(AlbumResponseDto) loadDetails,
@@ -314,7 +301,7 @@ class SyncService {
assert(assetsInDb.isSorted(Asset.compareByOwnerChecksum), "inDb unsorted!");
final List<Asset> assetsOnRemote = dto.getAssets();
assetsOnRemote.sort(Asset.compareByOwnerChecksum);
final (toAdd, toUpdate, toUnlink) = _diffAssets(
final (toAdd, toUpdate, toUnlink) = diffAssets(
assetsOnRemote,
assetsInDb,
compare: Asset.compareByOwnerChecksum,
@@ -345,8 +332,8 @@ class SyncService {
album.shared = dto.shared;
album.modifiedAt = dto.updatedAt;
album.lastModifiedAssetTimestamp = originalDto.lastModifiedAssetTimestamp;
if (album.thumbnail.value?.remoteId != dto.albumThumbnailAssetId) {
album.thumbnail.value = await _db.assets
if (album.thumbnail?.remoteId != dto.albumThumbnailAssetId) {
album.thumb.value = await _db.assets
.where()
.remoteIdEqualTo(dto.albumThumbnailAssetId)
.findFirst();
@@ -356,11 +343,11 @@ class SyncService {
try {
await _db.writeTxn(() async {
await _db.assets.putAll(toUpdate);
await album.thumbnail.save();
await album.thumb.save();
await album.sharedUsers
.update(link: usersToLink, unlink: usersToUnlink);
await album.assets.update(link: assetsToLink, unlink: toUnlink.cast());
await _db.albums.put(album);
await _db.remoteAlbums.put(album);
});
_log.info("Synced changes of remote album ${album.name} to DB");
} on IsarError catch (e) {
@@ -399,8 +386,8 @@ class SyncService {
existing.addAll(existingInDb);
await upsertAssetsWithExif(updated);
final Album a = await Album.remote(dto);
await _db.writeTxn(() => _db.albums.store(a));
final RemoteAlbum a = await RemoteAlbum.fromDto(dto, _db);
await _db.writeTxn(() => _db.remoteAlbums.store(a));
} else {
_log.warning(
"Failed to add album from server: assetCount ${dto.assetCount} != "
@@ -411,16 +398,10 @@ class SyncService {
/// Accumulates all suitable album assets to the `deleteCandidates` and
/// removes the album from the database.
Future<void> _removeAlbumFromDb(
Album album,
RemoteAlbum album,
List<Asset> deleteCandidates,
) async {
if (album.isLocal) {
_log.info("Removing local album $album from DB");
// delete assets in DB unless they are remote or part of some other album
deleteCandidates.addAll(
await album.assets.filter().remoteIdIsNull().findAll(),
);
} else if (album.shared) {
if (album.shared) {
final User user = Store.get(StoreKey.currentUser);
// delete assets in DB unless they belong to this user or are part of some other shared album or belong to a partner
final userIds = await _db.users
@@ -437,7 +418,8 @@ class SyncService {
deleteCandidates.addAll(orphanedAssets);
}
try {
final bool ok = await _db.writeTxn(() => _db.albums.delete(album.id));
final bool ok =
await _db.writeTxn(() => _db.remoteAlbums.delete(album.isarId));
assert(ok);
_log.info("Removed local album $album from DB");
} catch (e) {
@@ -445,221 +427,6 @@ class SyncService {
}
}
/// Syncs all device albums and their assets to the database
/// Returns `true` if there were any changes
Future<bool> _syncLocalAlbumAssetsToDb(
List<AssetPathEntity> onDevice, [
Set<String>? excludedAssets,
]) async {
onDevice.sort((a, b) => a.id.compareTo(b.id));
final inDb =
await _db.albums.where().localIdIsNotNull().sortByLocalId().findAll();
final List<Asset> deleteCandidates = [];
final List<Asset> existing = [];
assert(inDb.isSorted((a, b) => a.localId!.compareTo(b.localId!)), "sort!");
final bool anyChanges = await diffSortedLists(
onDevice,
inDb,
compare: (AssetPathEntity a, Album b) => a.id.compareTo(b.localId!),
both: (AssetPathEntity ape, Album album) => _syncAlbumInDbAndOnDevice(
ape,
album,
deleteCandidates,
existing,
excludedAssets,
),
onlyFirst: (AssetPathEntity ape) =>
_addAlbumFromDevice(ape, existing, excludedAssets),
onlySecond: (Album a) => _removeAlbumFromDb(a, deleteCandidates),
);
_log.fine(
"Syncing all local albums almost done. Collected ${deleteCandidates.length} asset candidates to delete",
);
final (toDelete, toUpdate) =
_handleAssetRemoval(deleteCandidates, existing, remote: false);
_log.fine(
"${toDelete.length} assets to delete, ${toUpdate.length} to update",
);
if (toDelete.isNotEmpty || toUpdate.isNotEmpty) {
await _db.writeTxn(() async {
await _db.assets.deleteAll(toDelete);
await _db.exifInfos.deleteAll(toDelete);
await _db.assets.putAll(toUpdate);
});
_log.info(
"Removed ${toDelete.length} and updated ${toUpdate.length} local assets from DB",
);
}
return anyChanges;
}
/// Syncs the device album to the album in the database
/// returns `true` if there were any changes
/// Accumulates asset candidates to delete and those already existing in DB
Future<bool> _syncAlbumInDbAndOnDevice(
AssetPathEntity ape,
Album album,
List<Asset> deleteCandidates,
List<Asset> existing, [
Set<String>? excludedAssets,
bool forceRefresh = false,
]) async {
if (!forceRefresh && !await _hasAssetPathEntityChanged(ape, album)) {
_log.fine("Local album ${ape.name} has not changed. Skipping sync.");
return false;
}
if (!forceRefresh &&
excludedAssets == null &&
await _syncDeviceAlbumFast(ape, album)) {
return true;
}
// general case, e.g. some assets have been deleted or there are excluded albums on iOS
final inDb = await album.assets
.filter()
.ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId)
.sortByChecksum()
.findAll();
assert(inDb.isSorted(Asset.compareByChecksum), "inDb not sorted!");
final int assetCountOnDevice = await ape.assetCountAsync;
final List<Asset> onDevice =
await _hashService.getHashedAssets(ape, excludedAssets: excludedAssets);
_removeDuplicates(onDevice);
// _removeDuplicates sorts `onDevice` by checksum
final (toAdd, toUpdate, toDelete) = _diffAssets(onDevice, inDb);
if (toAdd.isEmpty &&
toUpdate.isEmpty &&
toDelete.isEmpty &&
album.name == ape.name &&
ape.lastModified != null &&
album.modifiedAt.isAtSameMomentAs(ape.lastModified!)) {
// changes only affeted excluded albums
_log.fine(
"Only excluded assets in local album ${ape.name} changed. Stopping sync.",
);
if (assetCountOnDevice !=
_db.eTags.getByIdSync(ape.eTagKeyAssetCount)?.assetCount) {
await _db.writeTxn(
() => _db.eTags.put(
ETag(id: ape.eTagKeyAssetCount, assetCount: assetCountOnDevice),
),
);
}
return false;
}
_log.fine(
"Syncing local album ${ape.name}. ${toAdd.length} assets to add, ${toUpdate.length} to update, ${toDelete.length} to delete",
);
final (existingInDb, updated) = await _linkWithExistingFromDb(toAdd);
_log.fine(
"Linking assets to add with existing from db. ${existingInDb.length} existing, ${updated.length} to update",
);
deleteCandidates.addAll(toDelete);
existing.addAll(existingInDb);
album.name = ape.name;
album.modifiedAt = ape.lastModified ?? DateTime.now();
if (album.thumbnail.value != null &&
toDelete.contains(album.thumbnail.value)) {
album.thumbnail.value = null;
}
try {
await _db.writeTxn(() async {
await _db.assets.putAll(updated);
await _db.assets.putAll(toUpdate);
await album.assets
.update(link: existingInDb + updated, unlink: toDelete);
await _db.albums.put(album);
album.thumbnail.value ??= await album.assets.filter().findFirst();
await album.thumbnail.save();
await _db.eTags.put(
ETag(id: ape.eTagKeyAssetCount, assetCount: assetCountOnDevice),
);
});
_log.info("Synced changes of local album ${ape.name} to DB");
} on IsarError catch (e) {
_log.severe("Failed to update synced album ${ape.name} in DB", e);
}
return true;
}
/// fast path for common case: only new assets were added to device album
/// returns `true` if successfull, else `false`
Future<bool> _syncDeviceAlbumFast(AssetPathEntity ape, Album album) async {
if (!(ape.lastModified ?? DateTime.now()).isAfter(album.modifiedAt)) {
return false;
}
final int totalOnDevice = await ape.assetCountAsync;
final int lastKnownTotal =
(await _db.eTags.getById(ape.eTagKeyAssetCount))?.assetCount ?? 0;
final AssetPathEntity? modified = totalOnDevice > lastKnownTotal
? await ape.fetchPathProperties(
filterOptionGroup: FilterOptionGroup(
updateTimeCond: DateTimeCond(
min: album.modifiedAt.add(const Duration(seconds: 1)),
max: ape.lastModified ?? DateTime.now(),
),
),
)
: null;
if (modified == null) {
return false;
}
final List<Asset> newAssets = await _hashService.getHashedAssets(modified);
if (totalOnDevice != lastKnownTotal + newAssets.length) {
return false;
}
album.modifiedAt = ape.lastModified ?? DateTime.now();
_removeDuplicates(newAssets);
final (existingInDb, updated) = await _linkWithExistingFromDb(newAssets);
try {
await _db.writeTxn(() async {
await _db.assets.putAll(updated);
await album.assets.update(link: existingInDb + updated);
await _db.albums.put(album);
await _db.eTags
.put(ETag(id: ape.eTagKeyAssetCount, assetCount: totalOnDevice));
});
_log.info("Fast synced local album ${ape.name} to DB");
} on IsarError catch (e) {
_log.severe("Failed to fast sync local album ${ape.name} to DB", e);
return false;
}
return true;
}
/// Adds a new album from the device to the database and Accumulates all
/// assets already existing in the database to the list of `existing` assets
Future<void> _addAlbumFromDevice(
AssetPathEntity ape,
List<Asset> existing, [
Set<String>? excludedAssets,
]) async {
_log.info("Syncing a new local album to DB: ${ape.name}");
final Album a = Album.local(ape);
final assets =
await _hashService.getHashedAssets(ape, excludedAssets: excludedAssets);
_removeDuplicates(assets);
final (existingInDb, updated) = await _linkWithExistingFromDb(assets);
_log.info(
"${existingInDb.length} assets already existed in DB, to upsert ${updated.length}",
);
await upsertAssetsWithExif(updated);
existing.addAll(existingInDb);
a.assets.addAll(existingInDb);
a.assets.addAll(updated);
final thumb = existingInDb.firstOrNull ?? updated.firstOrNull;
a.thumbnail.value = thumb;
try {
await _db.writeTxn(() => _db.albums.store(a));
_log.info("Added a new local album to DB: ${ape.name}");
} on IsarError catch (e) {
_log.severe("Failed to add new local album ${ape.name} to DB", e);
}
}
/// Returns a tuple (existing, updated)
Future<(List<Asset> existing, List<Asset> updated)> _linkWithExistingFromDb(
List<Asset> assets,
@@ -738,137 +505,73 @@ class SyncService {
}
}
List<Asset> _removeDuplicates(List<Asset> assets) {
final int before = assets.length;
assets.sort(Asset.compareByOwnerChecksumCreatedModified);
assets.uniqueConsecutive(
compare: Asset.compareByOwnerChecksum,
onDuplicate: (a, b) =>
_log.info("Ignoring duplicate assets on device:\n$a\n$b"),
);
final int duplicates = before - assets.length;
if (duplicates > 0) {
_log.warning("Ignored $duplicates duplicate assets on device");
/// Returns a triple(toAdd, toUpdate, toRemove)
(List<Asset> toAdd, List<Asset> toUpdate, List<Asset> toRemove) diffAssets(
List<Asset> assets,
List<Asset> inDb, {
bool? remote,
int Function(Asset, Asset) compare = Asset.compareByChecksum,
}) {
// fast paths for trivial cases: reduces memory usage during initial sync etc.
if (assets.isEmpty && inDb.isEmpty) {
return const ([], [], []);
} else if (assets.isEmpty && remote == null) {
// remove all from database
return (const [], const [], inDb);
} else if (inDb.isEmpty) {
// add all assets
return (assets, const [], const []);
}
return assets;
final List<Asset> toAdd = [];
final List<Asset> toUpdate = [];
final List<Asset> toRemove = [];
diffSortedListsSync(
inDb,
assets,
compare: compare,
both: (Asset a, Asset b) {
if (a.canUpdate(b)) {
toUpdate.add(a.updatedCopy(b));
return true;
}
return false;
},
onlyFirst: (Asset a) {
if (remote == true && a.isLocal) {
if (a.remoteId != null) {
a.remoteId = null;
toUpdate.add(a);
}
} else if (remote == false && a.isRemote) {
if (a.isLocal) {
a.localId = null;
toUpdate.add(a);
}
} else {
toRemove.add(a);
}
},
onlySecond: (Asset b) => toAdd.add(b),
);
return (toAdd, toUpdate, toRemove);
}
/// returns `true` if the albums differ on the surface
Future<bool> _hasAssetPathEntityChanged(AssetPathEntity a, Album b) async {
return a.name != b.name ||
a.lastModified == null ||
!a.lastModified!.isAtSameMomentAs(b.modifiedAt) ||
await a.assetCountAsync !=
(await _db.eTags.getById(a.eTagKeyAssetCount))?.assetCount;
}
Future<bool> _removeAllLocalAlbumsAndAssets() async {
try {
final assets = await _db.assets.where().localIdIsNotNull().findAll();
final (toDelete, toUpdate) =
_handleAssetRemoval(assets, [], remote: false);
await _db.writeTxn(() async {
await _db.assets.deleteAll(toDelete);
await _db.assets.putAll(toUpdate);
await _db.albums.where().localIdIsNotNull().deleteAll();
});
return true;
} catch (e) {
_log.severe("Failed to remove all local albums and assets", e);
return false;
}
bool _hasAlbumResponseDtoChanged(AlbumResponseDto dto, RemoteAlbum a) {
return dto.assetCount != a.assetCount ||
dto.albumName != a.name ||
dto.albumThumbnailAssetId != a.thumbnail?.remoteId ||
dto.shared != a.shared ||
dto.sharedUsers.length != a.sharedUsers.length ||
!dto.updatedAt.isAtSameMomentAs(a.modifiedAt) ||
(dto.lastModifiedAssetTimestamp == null &&
a.lastModifiedAssetTimestamp != null) ||
(dto.lastModifiedAssetTimestamp != null &&
a.lastModifiedAssetTimestamp == null) ||
(dto.lastModifiedAssetTimestamp != null &&
a.lastModifiedAssetTimestamp != null &&
!dto.lastModifiedAssetTimestamp!
.isAtSameMomentAs(a.lastModifiedAssetTimestamp!));
}
}
/// Returns a triple(toAdd, toUpdate, toRemove)
(List<Asset> toAdd, List<Asset> toUpdate, List<Asset> toRemove) _diffAssets(
List<Asset> assets,
List<Asset> inDb, {
bool? remote,
int Function(Asset, Asset) compare = Asset.compareByChecksum,
}) {
// fast paths for trivial cases: reduces memory usage during initial sync etc.
if (assets.isEmpty && inDb.isEmpty) {
return const ([], [], []);
} else if (assets.isEmpty && remote == null) {
// remove all from database
return (const [], const [], inDb);
} else if (inDb.isEmpty) {
// add all assets
return (assets, const [], const []);
}
final List<Asset> toAdd = [];
final List<Asset> toUpdate = [];
final List<Asset> toRemove = [];
diffSortedListsSync(
inDb,
assets,
compare: compare,
both: (Asset a, Asset b) {
if (a.canUpdate(b)) {
toUpdate.add(a.updatedCopy(b));
return true;
}
return false;
},
onlyFirst: (Asset a) {
if (remote == true && a.isLocal) {
if (a.remoteId != null) {
a.remoteId = null;
toUpdate.add(a);
}
} else if (remote == false && a.isRemote) {
if (a.isLocal) {
a.localId = null;
toUpdate.add(a);
}
} else {
toRemove.add(a);
}
},
onlySecond: (Asset b) => toAdd.add(b),
);
return (toAdd, toUpdate, toRemove);
}
/// returns a tuple (toDelete toUpdate) when assets are to be deleted
(List<int> toDelete, List<Asset> toUpdate) _handleAssetRemoval(
List<Asset> deleteCandidates,
List<Asset> existing, {
bool? remote,
}) {
if (deleteCandidates.isEmpty) {
return const ([], []);
}
deleteCandidates.sort(Asset.compareById);
deleteCandidates.uniqueConsecutive(compare: Asset.compareById);
existing.sort(Asset.compareById);
existing.uniqueConsecutive(compare: Asset.compareById);
final (tooAdd, toUpdate, toRemove) = _diffAssets(
existing,
deleteCandidates,
compare: Asset.compareById,
remote: remote,
);
assert(tooAdd.isEmpty, "toAdd should be empty in _handleAssetRemoval");
return (toRemove.map((e) => e.id).toList(), toUpdate);
}
/// returns `true` if the albums differ on the surface
bool _hasAlbumResponseDtoChanged(AlbumResponseDto dto, Album a) {
return dto.assetCount != a.assetCount ||
dto.albumName != a.name ||
dto.albumThumbnailAssetId != a.thumbnail.value?.remoteId ||
dto.shared != a.shared ||
dto.sharedUsers.length != a.sharedUsers.length ||
!dto.updatedAt.isAtSameMomentAs(a.modifiedAt) ||
(dto.lastModifiedAssetTimestamp == null &&
a.lastModifiedAssetTimestamp != null) ||
(dto.lastModifiedAssetTimestamp != null &&
a.lastModifiedAssetTimestamp == null) ||
(dto.lastModifiedAssetTimestamp != null &&
a.lastModifiedAssetTimestamp != null &&
!dto.lastModifiedAssetTimestamp!
.isAtSameMomentAs(a.lastModifiedAssetTimestamp!));
}
@@ -8,7 +8,8 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/collection_extensions.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/providers/remote_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/asset_viewer/services/asset_stack.service.dart';
@@ -19,7 +20,6 @@ import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structu
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/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
@@ -43,6 +43,7 @@ class MultiselectGrid extends HookConsumerWidget {
this.editEnabled = false,
this.unarchive = false,
this.unfavorite = false,
this.selectedEnabled = true,
});
final ProviderListenable<AsyncValue<RenderList>> renderListProvider;
@@ -57,6 +58,7 @@ class MultiselectGrid extends HookConsumerWidget {
final bool favoriteEnabled;
final bool unfavorite;
final bool editEnabled;
final bool selectedEnabled;
Widget buildDefaultLoadingIndicator() =>
const Center(child: ImmichLoadingIndicator());
@@ -94,7 +96,7 @@ class MultiselectGrid extends HookConsumerWidget {
bool multiselect,
Set<Asset> selectedAssets,
) {
selectionEnabledHook.value = multiselect;
selectionEnabledHook.value = selectedEnabled && multiselect;
selection.value = selectedAssets;
selectionAssetState.value =
SelectionAssetState.fromSelection(selectedAssets);
@@ -277,7 +279,7 @@ class MultiselectGrid extends HookConsumerWidget {
}
}
void onAddToAlbum(Album album) async {
void onAddToAlbum(RemoteAlbum album) async {
processing.value = true;
try {
final Iterable<Asset> assets = remoteSelection(
@@ -337,11 +339,11 @@ class MultiselectGrid extends HookConsumerWidget {
.createAlbumWithGeneratedName(assets);
if (result != null) {
ref.watch(albumProvider.notifier).getAllAlbums();
ref.watch(remoteAlbumsProvider.notifier).getRemoteAlbums();
ref.watch(sharedAlbumProvider.notifier).getAllSharedAlbums();
selectionEnabledHook.value = false;
context.pushRoute(AlbumViewerRoute(albumId: result.id));
context.pushRoute(RemoteAlbumViewerRoute(albumId: result.isarId));
}
} finally {
processing.value = false;
+3 -2
View File
@@ -1,4 +1,4 @@
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/etag.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
@@ -11,7 +11,8 @@ Future<void> clearAssetsAndAlbums(Isar db) async {
await db.writeTxn(() async {
await db.assets.clear();
await db.exifInfos.clear();
await db.albums.clear();
await db.localAlbums.clear();
await db.remoteAlbums.clear();
await db.eTags.clear();
await db.users.clear();
});
+5 -5
View File
@@ -1,4 +1,4 @@
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:isar/isar.dart';
@@ -33,11 +33,11 @@ String getAlbumThumbnailUrl(
final Album album, {
ThumbnailFormat type = ThumbnailFormat.WEBP,
}) {
if (album.thumbnail.value?.remoteId == null) {
if (album.thumbnail?.remoteId == null) {
return '';
}
return getThumbnailUrlForRemoteId(
album.thumbnail.value!.remoteId!,
album.thumbnail!.remoteId!,
type: type,
);
}
@@ -46,11 +46,11 @@ String getAlbumThumbNailCacheKey(
final Album album, {
ThumbnailFormat type = ThumbnailFormat.WEBP,
}) {
if (album.thumbnail.value?.remoteId == null) {
if (album.thumbnail?.remoteId == null) {
return '';
}
return getThumbnailCacheKeyForRemoteId(
album.thumbnail.value!.remoteId!,
album.thumbnail!.remoteId!,
type: type,
);
}
+2
View File
@@ -15,6 +15,8 @@ Future<void> migrateDatabaseIfNeeded(Isar db) async {
await _migrateTo(db, 4);
case 4:
await _migrateTo(db, 5);
case 5:
await _migrateTo(db, 6);
}
}
@@ -5,6 +5,7 @@ import 'package:immich_mobile/modules/settings/services/app_settings.service.dar
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:isar/isar.dart';
// TODO: Remove this after migrating all providers to generator based
Stream<RenderList> renderListGenerator(
QueryBuilder<Asset, Asset, QAfterSortBy> query,
StreamProviderRef<RenderList> ref,
@@ -15,6 +16,16 @@ Stream<RenderList> renderListGenerator(
return renderListGeneratorWithGroupBy(query, groupBy);
}
Stream<RenderList> renderListGeneratorAutoDispose(
QueryBuilder<Asset, Asset, QAfterSortBy> query,
AutoDisposeStreamProviderRef<RenderList> ref,
) {
final settings = ref.watch(appSettingsServiceProvider);
final groupBy =
GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)];
return renderListGeneratorWithGroupBy(query, groupBy);
}
Stream<RenderList> renderListGeneratorWithGroupBy(
QueryBuilder<Asset, Asset, QAfterSortBy> query,
GroupAssetsBy groupBy,
+10 -15
View File
@@ -1,4 +1,4 @@
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'asset.stub.dart';
import 'user.stub.dart';
@@ -6,10 +6,9 @@ import 'user.stub.dart';
final class AlbumStub {
const AlbumStub._();
static final emptyAlbum = Album(
static final emptyAlbum = RemoteAlbum(
name: "empty-album",
localId: "empty-album-local",
remoteId: "empty-album-remote",
id: "empty-album-remote",
createdAt: DateTime(2000),
modifiedAt: DateTime(2023),
shared: false,
@@ -17,10 +16,9 @@ final class AlbumStub {
startDate: DateTime(2020),
);
static final sharedWithUser = Album(
static final sharedWithUser = RemoteAlbum(
name: "empty-album-shared-with-user",
localId: "empty-album-shared-with-user-local",
remoteId: "empty-album-shared-with-user-remote",
id: "empty-album-shared-with-user-remote",
createdAt: DateTime(2023),
modifiedAt: DateTime(2023),
shared: true,
@@ -28,10 +26,9 @@ final class AlbumStub {
endDate: DateTime(2020),
)..sharedUsers.addAll([UserStub.admin]);
static final oneAsset = Album(
static final oneAsset = RemoteAlbum(
name: "album-with-single-asset",
localId: "album-with-single-asset-local",
remoteId: "album-with-single-asset-remote",
id: "album-with-single-asset-remote",
createdAt: DateTime(2022),
modifiedAt: DateTime(2023),
shared: false,
@@ -40,18 +37,16 @@ final class AlbumStub {
endDate: DateTime(2023),
)..assets.addAll([AssetStub.image1]);
static final twoAsset = Album(
static final twoAsset = RemoteAlbum(
name: "album-with-two-assets",
localId: "album-with-two-assets-local",
remoteId: "album-with-two-assets-remote",
id: "album-with-two-assets-remote",
createdAt: DateTime(2001),
modifiedAt: DateTime(2010),
shared: false,
activityEnabled: false,
activityEnabled: true,
startDate: DateTime(2019),
endDate: DateTime(2020),
)
..assets.addAll([AssetStub.image1, AssetStub.image2])
..activityEnabled = true
..owner.value = UserStub.admin;
}
@@ -8,9 +8,9 @@ import 'package:immich_mobile/modules/activities/providers/activity.provider.dar
import 'package:immich_mobile/modules/activities/views/activities_page.dart';
import 'package:immich_mobile/modules/activities/widgets/activity_text_field.dart';
import 'package:immich_mobile/modules/activities/widgets/dismissible_activity.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/providers/current_album.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/current_asset.provider.dart';
import 'package:immich_mobile/shared/models/album.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';
@@ -82,7 +82,7 @@ void main() {
activityMock = MockAlbumActivity(_activities);
overrides = [
albumActivityProvider(
AlbumStub.twoAsset.remoteId!,
AlbumStub.twoAsset.id,
AssetStub.image1.remoteId!,
).overrideWith(() => activityMock),
currentAlbumProvider.overrideWith(() => mockCurrentAlbumProvider),
@@ -94,11 +94,11 @@ void main() {
// Save all assets
await db.users.put(UserStub.admin);
await db.assets.putAll([AssetStub.image1, AssetStub.image2]);
await db.albums.put(AlbumStub.twoAsset);
await db.remoteAlbums.put(AlbumStub.twoAsset);
await AlbumStub.twoAsset.owner.save();
await AlbumStub.twoAsset.assets.save();
});
expect(db.albums.countSync(), 1);
expect(db.remoteAlbums.countSync(), 1);
expect(db.assets.countSync(), 2);
expect(db.users.countSync(), 1);
});
@@ -40,7 +40,7 @@ void main() {
activityMock = MockAlbumActivity();
overrides = [
currentAlbumProvider.overrideWith(() => mockCurrentAlbumProvider),
albumActivityProvider(AlbumStub.twoAsset.remoteId!)
albumActivityProvider(AlbumStub.twoAsset.id)
.overrideWith(() => activityMock),
];
});
+3 -3
View File
@@ -1,15 +1,15 @@
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/providers/current_album.provider.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:mocktail/mocktail.dart';
class MockCurrentAlbumProvider extends CurrentAlbum
with Mock
implements CurrentAlbumInternal {
Album? initAlbum;
RemoteAlbum? initAlbum;
MockCurrentAlbumProvider([this.initAlbum]);
@override
Album? build() {
RemoteAlbum? build() {
return initAlbum;
}
}
@@ -1,9 +1,9 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/providers/album_sort_by_options.provider.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:isar/isar.dart';
import 'package:mocktail/mocktail.dart';
@@ -34,13 +34,13 @@ void main() {
db.clearSync();
// Save all assets
db.assets.putAllSync([AssetStub.image1, AssetStub.image2]);
db.albums.putAllSync(albums);
db.remoteAlbums.putAllSync(albums);
for (final album in albums) {
album.sharedUsers.saveSync();
album.assets.saveSync();
}
});
expect(db.albums.countSync(), 4);
expect(db.remoteAlbums.countSync(), 4);
expect(db.assets.countSync(), 2);
});
@@ -8,7 +8,6 @@ import 'package:immich_mobile/shared/services/sync.service.dart';
import 'package:isar/isar.dart';
import '../../test_utils.dart';
import 'shared_mocks.dart';
void main() {
Asset makeAsset({
@@ -38,7 +37,6 @@ void main() {
group('Test SyncService grouped', () {
late final Isar db;
final MockHashService hs = MockHashService();
final owner = User(
id: "1",
updatedAt: DateTime.now(),
@@ -68,7 +66,7 @@ void main() {
});
});
test('test inserting existing assets', () async {
SyncService s = SyncService(db, hs);
SyncService s = SyncService(db);
final List<Asset> remoteAssets = [
makeAsset(checksum: "a", remoteId: "0-1"),
makeAsset(checksum: "b", remoteId: "2-1"),
@@ -82,7 +80,7 @@ void main() {
});
test('test inserting new assets', () async {
SyncService s = SyncService(db, hs);
SyncService s = SyncService(db);
final List<Asset> remoteAssets = [
makeAsset(checksum: "a", remoteId: "0-1"),
makeAsset(checksum: "b", remoteId: "2-1"),
@@ -99,7 +97,7 @@ void main() {
});
test('test syncing duplicate assets', () async {
SyncService s = SyncService(db, hs);
SyncService s = SyncService(db);
final List<Asset> remoteAssets = [
makeAsset(checksum: "a", remoteId: "0-1"),
makeAsset(checksum: "b", remoteId: "1-1"),
@@ -131,7 +129,7 @@ void main() {
});
test('test efficient sync', () async {
SyncService s = SyncService(db, hs);
SyncService s = SyncService(db);
final List<Asset> toUpsert = [
makeAsset(checksum: "a", remoteId: "0-1"), // changed
makeAsset(checksum: "f", remoteId: "0-2"), // new
+6 -7
View File
@@ -3,14 +3,13 @@ import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/models/album.model.dart';
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
import 'package:immich_mobile/modules/backup/models/duplicated_asset.model.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/android_device_asset.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/device_asset.dart';
import 'package:immich_mobile/shared/models/etag.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/models/ios_device_asset.dart';
import 'package:immich_mobile/shared/models/logger_message.model.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
@@ -41,14 +40,14 @@ final class TestUtils {
StoreValueSchema,
ExifInfoSchema,
AssetSchema,
AlbumSchema,
UserSchema,
BackupAlbumSchema,
LocalAlbumSchema,
RemoteAlbumSchema,
UserSchema,
DuplicatedAssetSchema,
LoggerMessageSchema,
ETagSchema,
AndroidDeviceAssetSchema,
IOSDeviceAssetSchema,
DeviceAssetSchema,
],
maxSizeMiB: 256,
directory: "test/",