Compare commits

...

20 Commits

Author SHA1 Message Date
shenlong-tanwen b560249eed chore: pull latest 2024-03-03 05:21:22 +05:30
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 3f93fda4e4 fix: timeline line reload and backup state 2024-02-16 01:11:11 +05:30
shenlong-tanwen 108e10f175 refactor: backup album service 2024-02-15 22:27:51 +05:30
shenlong-tanwen 7023143bce refactor: move backup related count to device asset provider 2024-02-15 08:22:22 +05:30
shenlong-tanwen e4a8e77c67 chore: rebase changes 2024-02-15 08:13:26 +05:30
shenlong-tanwen 8e5ce7f684 refactor: start using checksums for upload 2024-02-15 07:49:07 +05:30
shenlong-tanwen f86f073d50 refactor: move assets count to backup albums state 2024-02-15 07:48:17 +05:30
shenlong-tanwen d7580a3413 refactor: use server disk info from server info provider 2024-02-15 07:44:47 +05:30
shenlong-tanwen 324890e182 refactor: move backup related settings to new provider 2024-02-15 07:44:47 +05:30
shenlong-tanwen 7cccd216c2 refactor: move backup selection to device asset 2024-02-15 07:28:58 +05:30
shenlong-tanwen c98a8e1bb1 remove album selection chips 2024-02-15 07:28:18 +05:30
shenlong-tanwen 989a406721 feat(mobile): select which local assets to display in timeline 2024-02-15 07:28:18 +05:30
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
109 changed files with 6790 additions and 6285 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 -13
View File
@@ -9,20 +9,18 @@ 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 +71,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 +93,13 @@ Future<Isar> loadDb() async {
StoreValueSchema,
ExifInfoSchema,
AssetSchema,
AlbumSchema,
UserSchema,
BackupAlbumSchema,
DuplicatedAssetSchema,
LocalAlbumSchema,
RemoteAlbumSchema,
UserSchema,
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,30 @@
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/modules/backup/providers/backup_album.provider.dart';
import 'package:immich_mobile/modules/backup/providers/device_assets.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() async {
final hasChanges =
await ref.read(localAlbumServiceProvider).refreshDeviceAlbums();
if (hasChanges) {
ref.read(backupAlbumsProvider.notifier).refreshAlbumAssetsState();
ref.invalidate(deviceAssetsProvider);
}
}
}
@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'local_album.provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$localAlbumsHash() => r'15274162ef40aa87f498a5cf6f8f8a5e0d4c69a8';
/// 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,17 @@
import 'package:immich_mobile/modules/album/services/local_album.service.dart';
import 'package:immich_mobile/modules/backup/services/backup_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.read(backupAlbumServiceProvider),
);
@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'local_album_service.provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$localAlbumServiceHash() => r'362ac6700cf340ace2d6de4c691e1e900d3a28c8';
/// 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,373 @@
import 'dart:async';
import 'dart:io';
import 'package:collection/collection.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/services/backup_album.service.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 HashService _hashService;
final SyncService _syncService;
final BackupAlbumService _backupAlbumService;
LocalAlbumService(
this._db,
this._hashService,
this._syncService,
this._backupAlbumService,
);
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,
orders: [const OrderOption(type: OrderOptionType.updateDate)],
// title is needed to create Assets
imageOption: const FilterOption(needTitle: true),
videoOption: const FilterOption(needTitle: 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 _backupAlbumService.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,22 +4,26 @@ 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';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/main.dart';
import 'package:immich_mobile/modules/album/services/local_album.service.dart';
import 'package:immich_mobile/modules/backup/background_service/localization.dart';
import 'package:immich_mobile/modules/backup/models/backup_album.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/services/backup.service.dart';
import 'package:immich_mobile/modules/backup/services/backup_album.service.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/device_asset.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/shared/services/hash.service.dart';
import 'package:immich_mobile/shared/services/sync.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';
@@ -346,48 +350,43 @@ class BackgroundService {
AppSettingsService settingService = AppSettingsService();
BackupService backupService = BackupService(apiService, db, settingService);
AppSettingsService settingsService = AppSettingsService();
final selectedAlbums = backupService.selectedAlbumsQuery().findAllSync();
final excludedAlbums = backupService.excludedAlbumsQuery().findAllSync();
if (selectedAlbums.isEmpty) {
return true;
}
HashService hashService = HashService(db, this);
SyncService syncService = SyncService(db);
BackupAlbumService backupAlbumService = BackupAlbumService(db);
LocalAlbumService localAlbumService =
LocalAlbumService(db, hashService, syncService, backupAlbumService);
await PhotoManager.setIgnorePermissionCheck(true);
do {
await localAlbumService.refreshDeviceAlbums();
final idsToBackup = await db.deviceAssets
.filter()
.backupSelectionEqualTo(BackupSelection.select)
.idProperty()
.findAll();
final localAssetsToBackup = await db.assets
.where()
.remoteIdIsNull()
.filter()
.anyOf(idsToBackup, (q, id) => q.localIdEqualTo(id))
.findAll();
final toUpload =
await backupService.remoteAlreadyUploaded(localAssetsToBackup);
if (toUpload.isEmpty) {
debugPrint("No Asset On Device - Abort Backup Process");
return false;
}
final bool backupOk = await _runBackup(
backupService,
settingsService,
selectedAlbums,
excludedAlbums,
toUpload.toList(),
);
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);
});
} else if (Store.tryGet(StoreKey.backupFailedSince) == null) {
Store.put(StoreKey.backupFailedSince, DateTime.now());
return false;
@@ -402,8 +401,7 @@ class BackgroundService {
Future<bool> _runBackup(
BackupService backupService,
AppSettingsService settingsService,
List<BackupAlbum> selectedAlbums,
List<BackupAlbum> excludedAlbums,
List<Asset> toUpload,
) async {
_errorGracePeriodExceeded = _isErrorGracePeriodExceeded(settingsService);
final bool notifyTotalProgress = settingsService
@@ -411,32 +409,10 @@ class BackgroundService {
final bool notifySingleProgress = settingsService
.getSetting<bool>(AppSettingsEnum.backgroundBackupSingleProgress);
if (_canceledBySystem) {
if (_canceledBySystem || toUpload.isEmpty) {
return false;
}
List<AssetEntity> toUpload = await backupService.buildUploadCandidates(
selectedAlbums,
excludedAlbums,
);
try {
toUpload = await backupService.removeAlreadyUploadedAssets(toUpload);
} catch (e) {
_showErrorNotification(
title: "backup_background_service_error_title".tr(),
content: "backup_background_service_connection_failed_message".tr(),
);
return false;
}
if (_canceledBySystem) {
return false;
}
if (toUpload.isEmpty) {
return true;
}
_assetsToUploadCount = toUpload.length;
_uploadedAssetsCount = 0;
_updateNotification(
@@ -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,50 @@
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;
@enumerated
BackupSelection selection;
static const albumLinkId = 'album';
final album = IsarLink<LocalAlbum>();
BackupAlbum({
required this.id,
this.selection = BackupSelection.none,
});
BackupAlbum copyWith({
String? id,
BackupSelection? selection,
}) {
return BackupAlbum(
id: id ?? this.id,
selection: selection ?? this.selection,
);
}
@override
String toString() => 'BackupAlbum(id: $id, selection: $selection)';
@override
bool operator ==(covariant BackupAlbum other) {
if (identical(this, other)) return true;
return other.id == id && other.selection == selection;
}
@override
int get hashCode => id.hashCode ^ selection.hashCode;
}
+129 -108
View File
@@ -17,16 +17,16 @@ 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,
name: r'lastBackup',
type: IsarType.dateTime,
),
r'selection': PropertySchema(
id: 2,
name: r'selection',
@@ -40,7 +40,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,8 +71,8 @@ void _backupAlbumSerialize(
List<int> offsets,
Map<Type, List<int>> allOffsets,
) {
writer.writeString(offsets[0], object.id);
writer.writeDateTime(offsets[1], object.lastBackup);
writer.writeLong(offsets[0], object.hashCode);
writer.writeString(offsets[1], object.id);
writer.writeByte(offsets[2], object.selection.index);
}
@@ -76,10 +83,10 @@ 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]),
selection:
_BackupAlbumselectionValueEnumMap[reader.readByteOrNull(offsets[2])] ??
BackupSelection.none,
);
return object;
}
@@ -92,9 +99,9 @@ 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 (_BackupAlbumselectionValueEnumMap[
reader.readByteOrNull(offset)] ??
@@ -120,11 +127,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 +218,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,
@@ -393,62 +457,6 @@ extension BackupAlbumQueryFilter
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
lastBackupEqualTo(DateTime value) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'lastBackup',
value: value,
));
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
lastBackupGreaterThan(
DateTime value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'lastBackup',
value: value,
));
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
lastBackupLessThan(
DateTime value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'lastBackup',
value: value,
));
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
lastBackupBetween(
DateTime lower,
DateTime upper, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'lastBackup',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
));
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
selectionEqualTo(BackupSelection value) {
return QueryBuilder.apply(this, (query) {
@@ -510,10 +518,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);
@@ -526,18 +559,6 @@ extension BackupAlbumQuerySortBy
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> sortByLastBackup() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'lastBackup', Sort.asc);
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> sortByLastBackupDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'lastBackup', Sort.desc);
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> sortBySelection() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'selection', Sort.asc);
@@ -553,6 +574,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);
@@ -577,18 +610,6 @@ extension BackupAlbumQuerySortThenBy
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenByLastBackup() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'lastBackup', Sort.asc);
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenByLastBackupDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'lastBackup', Sort.desc);
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenBySelection() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'selection', Sort.asc);
@@ -604,6 +625,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) {
@@ -611,12 +638,6 @@ extension BackupAlbumQueryWhereDistinct
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QDistinct> distinctByLastBackup() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'lastBackup');
});
}
QueryBuilder<BackupAlbum, BackupAlbum, QDistinct> distinctBySelection() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'selection');
@@ -632,15 +653,15 @@ extension BackupAlbumQueryProperty
});
}
QueryBuilder<BackupAlbum, String, QQueryOperations> idProperty() {
QueryBuilder<BackupAlbum, int, QQueryOperations> hashCodeProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'id');
return query.addPropertyName(r'hashCode');
});
}
QueryBuilder<BackupAlbum, DateTime, QQueryOperations> lastBackupProperty() {
QueryBuilder<BackupAlbum, String, QQueryOperations> idProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'lastBackup');
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;
}
@@ -0,0 +1,57 @@
class BackupSetting {
final bool autoBackup;
final bool backgroundBackup;
final bool backupRequireWifi;
final bool backupRequireCharging;
final int backupTriggerDelay;
const BackupSetting({
required this.autoBackup,
required this.backgroundBackup,
required this.backupRequireWifi,
required this.backupRequireCharging,
required this.backupTriggerDelay,
});
BackupSetting copyWith({
bool? autoBackup,
bool? backgroundBackup,
bool? backupRequireWifi,
bool? backupRequireCharging,
int? backupTriggerDelay,
}) {
return BackupSetting(
autoBackup: autoBackup ?? this.autoBackup,
backgroundBackup: backgroundBackup ?? this.backgroundBackup,
backupRequireWifi: backupRequireWifi ?? this.backupRequireWifi,
backupRequireCharging:
backupRequireCharging ?? this.backupRequireCharging,
backupTriggerDelay: backupTriggerDelay ?? this.backupTriggerDelay,
);
}
@override
String toString() {
return 'BackupSettings(autoBackup: $autoBackup, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, backupTriggerDelay: $backupTriggerDelay)';
}
@override
bool operator ==(covariant BackupSetting other) {
if (identical(this, other)) return true;
return other.autoBackup == autoBackup &&
other.backgroundBackup == backgroundBackup &&
other.backupRequireWifi == backupRequireWifi &&
other.backupRequireCharging == backupRequireCharging &&
other.backupTriggerDelay == backupTriggerDelay;
}
@override
int get hashCode {
return autoBackup.hashCode ^
backgroundBackup.hashCode ^
backupRequireWifi.hashCode ^
backupRequireCharging.hashCode ^
backupTriggerDelay.hashCode;
}
}
@@ -1,12 +1,5 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart';
import 'package:photo_manager/photo_manager.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';
enum BackUpProgressEnum {
idle,
@@ -19,144 +12,60 @@ enum BackUpProgressEnum {
class BackUpState {
// enum
final BackUpProgressEnum backupProgress;
final List<String> allAssetsInDatabase;
final double progressInPercentage;
final double iCloudDownloadProgress;
final CancellationToken cancelToken;
final ServerDiskInfo serverInfo;
final bool autoBackup;
final bool backgroundBackup;
final bool backupRequireWifi;
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;
/// All assets from the selected albums that have been backup
final Set<String> selectedAlbumsBackupAssetsIds;
// Current Backup Asset
final CurrentUploadAsset currentUploadAsset;
const BackUpState({
required this.backupProgress,
required this.allAssetsInDatabase,
required this.progressInPercentage,
required this.iCloudDownloadProgress,
required this.cancelToken,
required this.serverInfo,
required this.autoBackup,
required this.backgroundBackup,
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.currentUploadAsset,
});
BackUpState copyWith({
BackUpProgressEnum? backupProgress,
List<String>? allAssetsInDatabase,
double? progressInPercentage,
double? iCloudDownloadProgress,
CancellationToken? cancelToken,
ServerDiskInfo? serverInfo,
bool? autoBackup,
bool? backgroundBackup,
bool? backupRequireWifi,
bool? backupRequireCharging,
int? backupTriggerDelay,
List<AvailableAlbum>? availableAlbums,
Set<AvailableAlbum>? selectedBackupAlbums,
Set<AvailableAlbum>? excludedBackupAlbums,
Set<AssetEntity>? allUniqueAssets,
Set<String>? selectedAlbumsBackupAssetsIds,
CurrentUploadAsset? currentUploadAsset,
}) {
return BackUpState(
backupProgress: backupProgress ?? this.backupProgress,
allAssetsInDatabase: allAssetsInDatabase ?? this.allAssetsInDatabase,
progressInPercentage: progressInPercentage ?? this.progressInPercentage,
iCloudDownloadProgress:
iCloudDownloadProgress ?? this.iCloudDownloadProgress,
cancelToken: cancelToken ?? this.cancelToken,
serverInfo: serverInfo ?? this.serverInfo,
autoBackup: autoBackup ?? this.autoBackup,
backgroundBackup: backgroundBackup ?? this.backgroundBackup,
backupRequireWifi: backupRequireWifi ?? this.backupRequireWifi,
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,
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, progressInPercentage: $progressInPercentage, iCloudDownloadProgress: $iCloudDownloadProgress, cancelToken: $cancelToken, currentUploadAsset: $currentUploadAsset)';
}
@override
bool operator ==(covariant BackUpState other) {
if (identical(this, other)) return true;
final collectionEquals = const DeepCollectionEquality().equals;
return other.backupProgress == backupProgress &&
collectionEquals(other.allAssetsInDatabase, allAssetsInDatabase) &&
other.progressInPercentage == progressInPercentage &&
other.iCloudDownloadProgress == iCloudDownloadProgress &&
other.cancelToken == cancelToken &&
other.serverInfo == serverInfo &&
other.autoBackup == autoBackup &&
other.backgroundBackup == backgroundBackup &&
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.currentUploadAsset == currentUploadAsset;
}
@override
int get hashCode {
return backupProgress.hashCode ^
allAssetsInDatabase.hashCode ^
progressInPercentage.hashCode ^
iCloudDownloadProgress.hashCode ^
cancelToken.hashCode ^
serverInfo.hashCode ^
autoBackup.hashCode ^
backgroundBackup.hashCode ^
backupRequireWifi.hashCode ^
backupRequireCharging.hashCode ^
backupTriggerDelay.hashCode ^
availableAlbums.hashCode ^
selectedBackupAlbums.hashCode ^
excludedBackupAlbums.hashCode ^
allUniqueAssets.hashCode ^
selectedAlbumsBackupAssetsIds.hashCode ^
currentUploadAsset.hashCode;
}
}
@@ -0,0 +1,36 @@
class DeviceAssetState {
final int uniqueAssetsToBackup;
final int backedUpAssets;
const DeviceAssetState({
required this.uniqueAssetsToBackup,
required this.backedUpAssets,
});
int get assetsRemaining => uniqueAssetsToBackup - backedUpAssets;
DeviceAssetState copyWith({
int? uniqueAssetsToBackup,
int? backedUpAssets,
}) {
return DeviceAssetState(
uniqueAssetsToBackup: uniqueAssetsToBackup ?? this.uniqueAssetsToBackup,
backedUpAssets: backedUpAssets ?? this.backedUpAssets,
);
}
@override
String toString() =>
'DeviceAssetState(uniqueAssetsToBackup: $uniqueAssetsToBackup, backedUpAssets: $backedUpAssets)';
@override
bool operator ==(covariant DeviceAssetState other) {
if (identical(this, other)) return true;
return other.uniqueAssetsToBackup == uniqueAssetsToBackup &&
other.backedUpAssets == backedUpAssets;
}
@override
int get hashCode => uniqueAssetsToBackup.hashCode ^ backedUpAssets.hashCode;
}
@@ -1,11 +0,0 @@
import 'package:immich_mobile/utils/hash.dart';
import 'package:isar/isar.dart';
part 'duplicated_asset.model.g.dart';
@Collection(inheritance: false)
class DuplicatedAsset {
String id;
DuplicatedAsset(this.id);
Id get isarId => fastHash(id);
}
@@ -1,443 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'duplicated_asset.model.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 GetDuplicatedAssetCollection on Isar {
IsarCollection<DuplicatedAsset> get duplicatedAssets => this.collection();
}
const DuplicatedAssetSchema = CollectionSchema(
name: r'DuplicatedAsset',
id: -2679334728174694496,
properties: {
r'id': PropertySchema(
id: 0,
name: r'id',
type: IsarType.string,
)
},
estimateSize: _duplicatedAssetEstimateSize,
serialize: _duplicatedAssetSerialize,
deserialize: _duplicatedAssetDeserialize,
deserializeProp: _duplicatedAssetDeserializeProp,
idName: r'isarId',
indexes: {},
links: {},
embeddedSchemas: {},
getId: _duplicatedAssetGetId,
getLinks: _duplicatedAssetGetLinks,
attach: _duplicatedAssetAttach,
version: '3.1.0+1',
);
int _duplicatedAssetEstimateSize(
DuplicatedAsset object,
List<int> offsets,
Map<Type, List<int>> allOffsets,
) {
var bytesCount = offsets.last;
bytesCount += 3 + object.id.length * 3;
return bytesCount;
}
void _duplicatedAssetSerialize(
DuplicatedAsset object,
IsarWriter writer,
List<int> offsets,
Map<Type, List<int>> allOffsets,
) {
writer.writeString(offsets[0], object.id);
}
DuplicatedAsset _duplicatedAssetDeserialize(
Id id,
IsarReader reader,
List<int> offsets,
Map<Type, List<int>> allOffsets,
) {
final object = DuplicatedAsset(
reader.readString(offsets[0]),
);
return object;
}
P _duplicatedAssetDeserializeProp<P>(
IsarReader reader,
int propertyId,
int offset,
Map<Type, List<int>> allOffsets,
) {
switch (propertyId) {
case 0:
return (reader.readString(offset)) as P;
default:
throw IsarError('Unknown property with id $propertyId');
}
}
Id _duplicatedAssetGetId(DuplicatedAsset object) {
return object.isarId;
}
List<IsarLinkBase<dynamic>> _duplicatedAssetGetLinks(DuplicatedAsset object) {
return [];
}
void _duplicatedAssetAttach(
IsarCollection<dynamic> col, Id id, DuplicatedAsset object) {}
extension DuplicatedAssetQueryWhereSort
on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QWhere> {
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterWhere> anyIsarId() {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(const IdWhereClause.any());
});
}
}
extension DuplicatedAssetQueryWhere
on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QWhereClause> {
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterWhereClause>
isarIdEqualTo(Id isarId) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IdWhereClause.between(
lower: isarId,
upper: isarId,
));
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterWhereClause>
isarIdNotEqualTo(Id isarId) {
return QueryBuilder.apply(this, (query) {
if (query.whereSort == Sort.asc) {
return query
.addWhereClause(
IdWhereClause.lessThan(upper: isarId, includeUpper: false),
)
.addWhereClause(
IdWhereClause.greaterThan(lower: isarId, includeLower: false),
);
} else {
return query
.addWhereClause(
IdWhereClause.greaterThan(lower: isarId, includeLower: false),
)
.addWhereClause(
IdWhereClause.lessThan(upper: isarId, includeUpper: false),
);
}
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterWhereClause>
isarIdGreaterThan(Id isarId, {bool include = false}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(
IdWhereClause.greaterThan(lower: isarId, includeLower: include),
);
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterWhereClause>
isarIdLessThan(Id isarId, {bool include = false}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(
IdWhereClause.lessThan(upper: isarId, includeUpper: include),
);
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterWhereClause>
isarIdBetween(
Id lowerIsarId,
Id upperIsarId, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IdWhereClause.between(
lower: lowerIsarId,
includeLower: includeLower,
upper: upperIsarId,
includeUpper: includeUpper,
));
});
}
}
extension DuplicatedAssetQueryFilter
on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QFilterCondition> {
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
idEqualTo(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'id',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
idGreaterThan(
String value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'id',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
idLessThan(
String value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'id',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
idBetween(
String lower,
String upper, {
bool includeLower = true,
bool includeUpper = true,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'id',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
idStartsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.startsWith(
property: r'id',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
idEndsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.endsWith(
property: r'id',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
idContains(String value, {bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.contains(
property: r'id',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
idMatches(String pattern, {bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.matches(
property: r'id',
wildcard: pattern,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
idIsEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'id',
value: '',
));
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
idIsNotEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
property: r'id',
value: '',
));
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
isarIdEqualTo(Id value) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'isarId',
value: value,
));
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
isarIdGreaterThan(
Id value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'isarId',
value: value,
));
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
isarIdLessThan(
Id value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'isarId',
value: value,
));
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
isarIdBetween(
Id lower,
Id upper, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'isarId',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
));
});
}
}
extension DuplicatedAssetQueryObject
on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QFilterCondition> {}
extension DuplicatedAssetQueryLinks
on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QFilterCondition> {}
extension DuplicatedAssetQuerySortBy
on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QSortBy> {
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterSortBy> sortById() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'id', Sort.asc);
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterSortBy> sortByIdDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'id', Sort.desc);
});
}
}
extension DuplicatedAssetQuerySortThenBy
on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QSortThenBy> {
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterSortBy> thenById() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'id', Sort.asc);
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterSortBy> thenByIdDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'id', Sort.desc);
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterSortBy> thenByIsarId() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'isarId', Sort.asc);
});
}
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterSortBy>
thenByIsarIdDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'isarId', Sort.desc);
});
}
}
extension DuplicatedAssetQueryWhereDistinct
on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QDistinct> {
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QDistinct> distinctById(
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'id', caseSensitive: caseSensitive);
});
}
}
extension DuplicatedAssetQueryProperty
on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QQueryProperty> {
QueryBuilder<DuplicatedAsset, int, QQueryOperations> isarIdProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'isarId');
});
}
QueryBuilder<DuplicatedAsset, String, QQueryOperations> idProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'id');
});
}
}
@@ -0,0 +1,55 @@
import 'package:collection/collection.dart';
import 'package:openapi/api.dart';
class BulkUploadCheckResponse {
final List<String> toBeUploaded;
final List<String> duplicates;
const BulkUploadCheckResponse({
required this.toBeUploaded,
required this.duplicates,
});
BulkUploadCheckResponse copyWith({
List<String>? toBeUploaded,
List<String>? duplicates,
}) {
return BulkUploadCheckResponse(
toBeUploaded: toBeUploaded ?? this.toBeUploaded,
duplicates: duplicates ?? this.duplicates,
);
}
static BulkUploadCheckResponse fromDto(AssetBulkUploadCheckResponseDto dto) {
final duplicates = <String>[];
final toBeUploaded = <String>[];
for (final result in dto.results) {
if (result.action == AssetBulkUploadCheckResultActionEnum.accept) {
toBeUploaded.add(result.id);
} else if (result.action == AssetBulkUploadCheckResultActionEnum.reject &&
result.reason == AssetBulkUploadCheckResultReasonEnum.duplicate) {
duplicates.add(result.id);
}
}
return BulkUploadCheckResponse(
toBeUploaded: toBeUploaded.toList(),
duplicates: duplicates.toList(),
);
}
@override
String toString() =>
'BulkUploadCheckResponse(toBeUploaded: $toBeUploaded, duplicates: $duplicates)';
@override
bool operator ==(covariant BulkUploadCheckResponse other) {
if (identical(this, other)) return true;
final listEquals = const DeepCollectionEquality().equals;
return listEquals(other.toBeUploaded, toBeUploaded) &&
listEquals(other.duplicates, duplicates);
}
@override
int get hashCode => toBeUploaded.hashCode ^ duplicates.hashCode;
}
@@ -1,26 +1,26 @@
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_settings.provider.dart';
import 'package:immich_mobile/modules/backup/providers/device_assets.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/server_info/server_disk_info.model.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/device_asset.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:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:permission_handler/permission_handler.dart';
@@ -29,7 +29,7 @@ import 'package:photo_manager/photo_manager.dart';
class BackupNotifier extends StateNotifier<BackUpState> {
BackupNotifier(
this._backupService,
this._serverInfoService,
this._serverInfoNotifier,
this._authState,
this._backgroundService,
this._galleryPermissionNotifier,
@@ -38,26 +38,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
) : super(
BackUpState(
backupProgress: BackUpProgressEnum.idle,
allAssetsInDatabase: const [],
progressInPercentage: 0,
cancelToken: CancellationToken(),
autoBackup: Store.get(StoreKey.autoBackup, false),
backgroundBackup: Store.get(StoreKey.backgroundBackup, false),
backupRequireWifi: Store.get(StoreKey.backupRequireWifi, true),
backupRequireCharging:
Store.get(StoreKey.backupRequireCharging, false),
backupTriggerDelay: Store.get(StoreKey.backupTriggerDelay, 5000),
serverInfo: const ServerDiskInfo(
diskAvailable: "0",
diskSize: "0",
diskUse: "0",
diskUsagePercentage: 0,
),
availableAlbums: const [],
selectedBackupAlbums: const {},
excludedBackupAlbums: const {},
allUniqueAssets: const {},
selectedAlbumsBackupAssetsIds: const {},
currentUploadAsset: CurrentUploadAsset(
id: '...',
fileCreatedAt: DateTime.parse('2020-10-04'),
@@ -71,412 +53,76 @@ class BackupNotifier extends StateNotifier<BackUpState> {
final log = Logger('BackupNotifier');
final BackupService _backupService;
final ServerInfoService _serverInfoService;
final ServerInfoNotifier _serverInfoNotifier;
final AuthenticationState _authState;
final BackgroundService _backgroundService;
final GalleryPermissionNotifier _galleryPermissionNotifier;
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);
}
void configureBackgroundBackup({
bool? enabled,
bool? requireWifi,
bool? requireCharging,
int? triggerDelay,
required void Function(String msg) onError,
required void Function() onBatteryInfo,
}) async {
assert(
enabled != null ||
requireWifi != null ||
requireCharging != null ||
triggerDelay != null,
);
final bool wasEnabled = state.backgroundBackup;
final bool wasWifi = state.backupRequireWifi;
final bool wasCharging = state.backupRequireCharging;
final int oldTriggerDelay = state.backupTriggerDelay;
state = state.copyWith(
backgroundBackup: enabled,
backupRequireWifi: requireWifi,
backupRequireCharging: requireCharging,
backupTriggerDelay: triggerDelay,
);
if (state.backgroundBackup) {
bool success = true;
if (!wasEnabled) {
if (!await _backgroundService.isIgnoringBatteryOptimizations()) {
onBatteryInfo();
}
success &= await _backgroundService.enableService(immediate: true);
}
success &= success &&
await _backgroundService.configureService(
requireUnmetered: state.backupRequireWifi,
requireCharging: state.backupRequireCharging,
triggerUpdateDelay: state.backupTriggerDelay,
triggerMaxDelay: state.backupTriggerDelay * 10,
);
if (success) {
await Store.put(StoreKey.backupRequireWifi, state.backupRequireWifi);
await Store.put(
StoreKey.backupRequireCharging,
state.backupRequireCharging,
);
await Store.put(StoreKey.backupTriggerDelay, state.backupTriggerDelay);
await Store.put(StoreKey.backgroundBackup, state.backgroundBackup);
} else {
state = state.copyWith(
backgroundBackup: wasEnabled,
backupRequireWifi: wasWifi,
backupRequireCharging: wasCharging,
backupTriggerDelay: oldTriggerDelay,
);
onError("backup_controller_page_background_configure_error");
}
} else {
final bool success = await _backgroundService.disableService();
if (!success) {
state = state.copyWith(backgroundBackup: wasEnabled);
onError("backup_controller_page_background_configure_error");
}
}
}
///
/// 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
/// Those assets are unique and are used as the total assets
///
Future<void> _updateBackupAssetCount() async {
final duplicatedAssetIds = await _backupService.getDuplicatedAssetIds();
final Set<AssetEntity> assetsFromSelectedAlbums = {};
final Set<AssetEntity> 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 album in state.excludedBackupAlbums) {
final assets = await album.albumEntity.getAssetListRange(
start: 0,
end: await album.albumEntity.assetCountAsync,
);
assetsFromExcludedAlbums.addAll(assets);
}
final Set<AssetEntity> allUniqueAssets =
assetsFromSelectedAlbums.difference(assetsFromExcludedAlbums);
final allAssetsInDatabase = await _backupService.getDeviceBackupAsset();
if (allAssetsInDatabase == null) {
return;
}
// Find asset that were backup from selected albums
final Set<String> selectedAlbumsBackupAssets =
Set.from(allUniqueAssets.map((e) => e.id));
selectedAlbumsBackupAssets
.removeWhere((assetId) => !allAssetsInDatabase.contains(assetId));
// Remove duplicated asset from all unique assets
allUniqueAssets.removeWhere(
(asset) => duplicatedAssetIds.contains(asset.id),
);
if (allUniqueAssets.isEmpty) {
log.info("No assets are selected for back up");
state = state.copyWith(
backupProgress: BackUpProgressEnum.idle,
allAssetsInDatabase: allAssetsInDatabase,
allUniqueAssets: {},
selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets,
);
} else {
state = state.copyWith(
allAssetsInDatabase: allAssetsInDatabase,
allUniqueAssets: allUniqueAssets,
selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets,
);
}
// Save to persistent storage
await _updatePersistentAlbumsSelection();
}
/// Get all necessary information for calculating the available albums,
/// which albums are selected or excluded
/// and then update the UI according to those information
Future<void> getBackupInfo() async {
final isEnabled = await _backgroundService.isBackgroundBackupEnabled();
state = state.copyWith(backgroundBackup: isEnabled);
if (isEnabled != Store.get(StoreKey.backgroundBackup, !isEnabled)) {
Store.put(StoreKey.backgroundBackup, isEnabled);
}
if (state.backupProgress != BackUpProgressEnum.inBackground) {
await _getBackupAlbumsInfo();
await updateServerInfo();
await _updateBackupAssetCount();
} else {
log.warning("cannot get backup info - background backup is in progress!");
}
}
/// 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");
assert(state.backupProgress == BackUpProgressEnum.idle);
state = state.copyWith(backupProgress: BackUpProgressEnum.inProgress);
await getBackupInfo();
await _serverInfoNotifier.getServerDiskInfo();
final hasPermission = _galleryPermissionNotifier.hasPermission;
if (hasPermission) {
await PhotoManager.clearFileCache();
if (state.allUniqueAssets.isEmpty) {
log.info("No Asset On Device - Abort Backup Process");
state = state.copyWith(backupProgress: BackUpProgressEnum.idle);
return;
}
Set<AssetEntity> assetsWillBeBackup = Set.from(state.allUniqueAssets);
// Remove item that has already been backed up
for (final assetId in state.allAssetsInDatabase) {
assetsWillBeBackup.removeWhere((e) => e.id == assetId);
}
if (assetsWillBeBackup.isEmpty) {
state = state.copyWith(backupProgress: BackUpProgressEnum.idle);
}
// Perform Backup
state = state.copyWith(cancelToken: CancellationToken());
final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null;
pmProgressHandler?.stream.listen((event) {
final double progress = event.progress;
state = state.copyWith(iCloudDownloadProgress: progress);
});
await _backupService.backupAsset(
assetsWillBeBackup,
state.cancelToken,
pmProgressHandler,
_onAssetUploaded,
_onUploadProgress,
_onSetCurrentBackupAsset,
_onBackupError,
);
await notifyBackgroundServiceCanRun();
} else {
if (!hasPermission) {
openAppSettings();
return;
}
}
void setAvailableAlbums(availableAlbums) {
state = state.copyWith(
availableAlbums: availableAlbums,
await PhotoManager.clearFileCache();
final idsForBackup = await _db.deviceAssets
.filter()
.backupSelectionEqualTo(BackupSelection.select)
.idProperty()
.findAll();
final localAssetsToBackup = await _db.assets
.where()
.remoteIdIsNull()
.filter()
.anyOf(idsForBackup, (q, id) => q.localIdEqualTo(id))
.findAll();
final assetsToBackup =
await _backupService.remoteAlreadyUploaded(localAssetsToBackup);
if (assetsToBackup.isEmpty) {
log.info("No Asset On Device - Abort Backup Process");
state = state.copyWith(backupProgress: BackUpProgressEnum.idle);
return;
}
// Perform Backup
state = state.copyWith(cancelToken: CancellationToken());
final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null;
pmProgressHandler?.stream.listen((event) {
final double progress = event.progress;
state = state.copyWith(iCloudDownloadProgress: progress);
});
await _backupService.backupAsset(
assetsToBackup,
state.cancelToken,
pmProgressHandler,
_onAssetUploaded,
_onUploadProgress,
_onSetCurrentBackupAsset,
_onBackupError,
);
state.cancelToken.cancel();
state = state.copyWith(
backupProgress: BackUpProgressEnum.idle,
progressInPercentage: 0.0,
);
ref.invalidate(deviceAssetsProvider);
await notifyBackgroundServiceCanRun();
}
void _onBackupError(ErrorUploadAsset errorAssetInfo) {
@@ -503,43 +149,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
String deviceId,
bool isDuplicated,
) {
if (isDuplicated) {
state = state.copyWith(
allUniqueAssets: state.allUniqueAssets
.where((asset) => asset.id != deviceAssetId)
.toSet(),
);
} else {
state = state.copyWith(
selectedAlbumsBackupAssetsIds: {
...state.selectedAlbumsBackupAssetsIds,
deviceAssetId,
},
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();
_serverInfoNotifier.getServerDiskInfo();
}
void _onUploadProgress(int sent, int total) {
@@ -548,17 +158,6 @@ class BackupNotifier extends StateNotifier<BackUpState> {
);
}
Future<void> updateServerInfo() async {
final serverInfo = await _serverInfoService.getServerInfo();
// Update server info
if (serverInfo != null) {
state = state.copyWith(
serverInfo: serverInfo,
);
}
}
Future<void> _resumeBackup() async {
// Check if user is login
final accessKey = Store.tryGet(StoreKey.accessToken);
@@ -570,7 +169,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
}
// Check if this device is enable backup by the user
if (state.autoBackup) {
if (ref.read(backupSettingsProvider).autoBackup) {
// check if backup is already in process - then return
if (state.backupProgress == BackUpProgressEnum.inProgress) {
log.info("[_resumeBackup] Auto Backup is already in progress - abort");
@@ -595,35 +194,9 @@ 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);
// 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 +206,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,
@@ -674,7 +227,7 @@ final backupProvider =
StateNotifierProvider<BackupNotifier, BackUpState>((ref) {
return BackupNotifier(
ref.watch(backupServiceProvider),
ref.watch(serverInfoServiceProvider),
ref.watch(serverInfoProvider.notifier),
ref.watch(authenticationProvider),
ref.watch(backgroundServiceProvider),
ref.watch(galleryPermissionNotifier.notifier),
@@ -0,0 +1,84 @@
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/modules/backup/services/backup_album.service.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:isar/isar.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'backup_album.provider.g.dart';
@riverpod
class BackupAlbums extends _$BackupAlbums {
@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> _reloadProviders(
LocalAlbum album,
BackupSelection selection,
) async {
final shouldReload = await ref
.read(backupAlbumServiceProvider)
.updateAlbumAssetsState(album, selection);
if (shouldReload) {
ref.invalidate(deviceAssetsProvider);
}
ref.invalidateSelf();
}
Future<void> addBackupAlbum(
LocalAlbum album,
BackupSelection selection,
) async {
await ref.read(backupAlbumServiceProvider).addBackupAlbum(album, selection);
_reloadProviders(album, selection);
}
Future<void> syncWithLocalAlbum(LocalAlbum album) async {
await ref.read(backupAlbumServiceProvider).syncWithLocalAlbum(album);
final albumInDB = await ref
.read(dbProvider)
.backupAlbums
.filter()
.idEqualTo(album.id)
.findFirst();
if (albumInDB?.selection != null) {
_reloadProviders(album, albumInDB!.selection);
}
}
Future<void> _updateAlbumSelection(
LocalAlbum localAlbum,
BackupSelection selection,
) async {
await ref
.read(backupAlbumServiceProvider)
.updateAlbumSelection(localAlbum, selection);
_reloadProviders(localAlbum, selection);
}
Future<void> refreshAlbumAssetsState() async =>
ref.read(backupAlbumServiceProvider).refreshAlbumAssetsState();
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);
}
@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'backup_album.provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$backupAlbumsHash() => r'f37d088af2a837d61040fc7663bed61703196efd';
/// 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,93 @@
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
import 'package:immich_mobile/modules/backup/models/backup_setting.model.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'backup_settings.provider.g.dart';
@riverpod
class BackupSettings extends _$BackupSettings {
@override
BackupSetting build() {
return BackupSetting(
autoBackup: Store.get(StoreKey.autoBackup, false),
backgroundBackup: Store.get(StoreKey.backgroundBackup, false),
backupRequireWifi: Store.get(StoreKey.backupRequireWifi, true),
backupRequireCharging: Store.get(StoreKey.backupRequireCharging, false),
backupTriggerDelay: Store.get(StoreKey.backupTriggerDelay, 5000),
);
}
void setAutoBackup(bool enabled) {
Store.put(StoreKey.autoBackup, enabled);
state = state.copyWith(autoBackup: enabled);
}
void configureBackgroundBackup({
bool? enabled,
bool? requireWifi,
bool? requireCharging,
int? triggerDelay,
required void Function(String msg) onError,
required void Function() onBatteryInfo,
}) async {
assert(
enabled != null ||
requireWifi != null ||
requireCharging != null ||
triggerDelay != null,
);
final backgroundService = ref.read(backgroundServiceProvider);
final bool wasEnabled = state.backgroundBackup;
final bool wasWifi = state.backupRequireWifi;
final bool wasCharging = state.backupRequireCharging;
final int oldTriggerDelay = state.backupTriggerDelay;
state = state.copyWith(
backgroundBackup: enabled,
backupRequireWifi: requireWifi,
backupRequireCharging: requireCharging,
backupTriggerDelay: triggerDelay,
);
if (state.backgroundBackup) {
bool success = true;
if (!wasEnabled) {
if (!await backgroundService.isIgnoringBatteryOptimizations()) {
onBatteryInfo();
}
success &= await backgroundService.enableService(immediate: true);
}
success &= success &&
await backgroundService.configureService(
requireUnmetered: state.backupRequireWifi,
requireCharging: state.backupRequireCharging,
triggerUpdateDelay: state.backupTriggerDelay,
triggerMaxDelay: state.backupTriggerDelay * 10,
);
if (success) {
await Store.put(StoreKey.backupRequireWifi, state.backupRequireWifi);
await Store.put(
StoreKey.backupRequireCharging,
state.backupRequireCharging,
);
await Store.put(StoreKey.backupTriggerDelay, state.backupTriggerDelay);
await Store.put(StoreKey.backgroundBackup, state.backgroundBackup);
} else {
state = state.copyWith(
backgroundBackup: wasEnabled,
backupRequireWifi: wasWifi,
backupRequireCharging: wasCharging,
backupTriggerDelay: oldTriggerDelay,
);
onError("backup_controller_page_background_configure_error");
}
} else {
final bool success = await backgroundService.disableService();
if (!success) {
state = state.copyWith(backgroundBackup: wasEnabled);
onError("backup_controller_page_background_configure_error");
}
}
}
}
@@ -0,0 +1,26 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'backup_settings.provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$backupSettingsHash() => r'839ad0c6c4b3bd61b9af75c38f8580a8af1d2e0a';
/// See also [BackupSettings].
@ProviderFor(BackupSettings)
final backupSettingsProvider =
AutoDisposeNotifierProvider<BackupSettings, BackupSetting>.internal(
BackupSettings.new,
name: r'backupSettingsProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$backupSettingsHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$BackupSettings = AutoDisposeNotifier<BackupSetting>;
// 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,35 @@
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/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: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);
final idsToBackup = await db.deviceAssets
.filter()
.backupSelectionEqualTo(BackupSelection.select)
.idProperty()
.findAll();
return DeviceAssetState(
uniqueAssetsToBackup: await db.assets
.filter()
.anyOf(idsToBackup, (q, id) => q.localIdEqualTo(id))
.count(),
backedUpAssets: await db.assets
.where()
.remoteIdIsNotNull()
.filter()
.anyOf(idsToBackup, (q, id) => q.localIdEqualTo(id))
.count(),
);
}
}
@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'device_assets.provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$deviceAssetsHash() => r'0fd5eb2d0f02d125df780e5676a6dcee2234de2f';
/// 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
@@ -18,6 +18,7 @@ import 'package:immich_mobile/modules/settings/providers/app_settings.provider.d
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/app_state.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/services/local_notification.service.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/utils/backup_progress.dart';
@@ -115,7 +116,7 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
bool isDuplicated,
) {
state = state.copyWith(successfulUploads: state.successfulUploads + 1);
_backupProvider.updateServerInfo();
ref.read(serverInfoProvider.notifier).getServerDiskInfo();
}
void _onAssetUploadError(ErrorUploadAsset errorAssetInfo) {
@@ -158,25 +159,7 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
if (ref.read(galleryPermissionNotifier.notifier).hasPermission) {
await PhotoManager.clearFileCache();
// We do not have 1:1 mapping of all AssetEntity fields to Asset. This results in cases
// where platform specific fields such as `subtype` used to detect platform specific assets such as
// LivePhoto in iOS is lost when we directly fetch the local asset from Asset using Asset.local
List<AssetEntity?> allAssetsFromDevice = await Future.wait(
allManualUploads
// Filter local only assets
.where((e) => e.isLocal && !e.isRemote)
.map((e) => e.local!.obtainForNewProperties()),
);
if (allAssetsFromDevice.length != allManualUploads.length) {
_log.warning(
'[_startUpload] Refreshed upload list -> ${allManualUploads.length - allAssetsFromDevice.length} asset will not be uploaded',
);
}
Set<AssetEntity> allUploadAssets = allAssetsFromDevice.nonNulls.toSet();
if (allUploadAssets.isEmpty) {
if (allManualUploads.isEmpty) {
debugPrint("[_startUpload] No Assets to upload - Abort Process");
_backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
return false;
@@ -184,7 +167,7 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
state = state.copyWith(
progressInPercentage: 0,
totalAssetsToUpload: allUploadAssets.length,
totalAssetsToUpload: allManualUploads.length,
successfulUploads: 0,
currentAssetIndex: 0,
currentUploadAsset: CurrentUploadAsset(
@@ -213,7 +196,7 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null;
final bool ok = await ref.read(backupServiceProvider).backupAsset(
allUploadAssets,
allManualUploads,
state.cancelToken,
pmProgressHandler,
_onAssetUploaded,
@@ -7,10 +7,11 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/duplicated_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/upload_check_response.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/asset.dart' hide AssetType;
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
@@ -40,26 +41,38 @@ class BackupService {
BackupService(this._apiService, this._db, this._appSetting);
Future<List<String>?> getDeviceBackupAsset() async {
final String deviceId = Store.get(StoreKey.deviceId);
Future<BulkUploadCheckResponse?> _bulkUploadCheck(List<Asset> assets) async {
try {
return await _apiService.assetApi.getAllUserAssetsByDeviceId(deviceId);
final dto = await _apiService.assetApi.checkBulkUpload(
AssetBulkUploadCheckDto(
assets: assets
.where((a) => a.localId != null)
.map(
(e) => AssetBulkUploadCheckItem(
checksum: e.checksum,
id: e.localId!,
),
)
.toList(),
),
);
return dto != null ? BulkUploadCheckResponse.fromDto(dto) : null;
} catch (e) {
debugPrint('Error [getDeviceBackupAsset] ${e.toString()}');
debugPrint('Error [bulkUploadCheck] ${e.toString()}');
return null;
}
}
Future<void> _saveDuplicatedAssetIds(List<String> deviceAssetIds) {
final duplicates = deviceAssetIds.map((id) => DuplicatedAsset(id)).toList();
return _db.writeTxn(() => _db.duplicatedAssets.putAll(duplicates));
}
Future<Iterable<Asset>> remoteAlreadyUploaded(List<Asset> assets) async {
final uploadCheck = await _bulkUploadCheck(assets);
if (uploadCheck == null) {
_log.warning("Cannot determine duplicates. Skipping backup for now");
return [];
}
/// Get duplicated asset id from database
Future<Set<String>> getDuplicatedAssetIds() async {
final duplicates = await _db.duplicatedAssets.where().findAll();
return duplicates.map((e) => e.id).toSet();
return uploadCheck.toBeUploaded
.map((c) => assets.firstWhere((a) => a.localId == c));
}
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
@@ -69,138 +82,8 @@ class BackupService {
excludedAlbumsQuery() =>
_db.backupAlbums.filter().selectionEqualTo(BackupSelection.exclude);
/// Returns all assets newer than the last successful backup per album
Future<List<AssetEntity>> buildUploadCandidates(
List<BackupAlbum> selectedBackupAlbums,
List<BackupAlbum> excludedBackupAlbums,
) async {
final filter = FilterOptionGroup(
containsPathModified: true,
orders: [const OrderOption(type: OrderOptionType.updateDate)],
// title is needed to create Assets
imageOption: const FilterOption(needTitle: true),
videoOption: const FilterOption(needTitle: true),
);
final now = DateTime.now();
final List<AssetPathEntity?> selectedAlbums =
await _loadAlbumsWithTimeFilter(selectedBackupAlbums, filter, now);
if (selectedAlbums.every((e) => e == null)) {
return [];
}
final int allIdx = selectedAlbums.indexWhere((e) => e != null && e.isAll);
if (allIdx != -1) {
final List<AssetPathEntity?> excludedAlbums =
await _loadAlbumsWithTimeFilter(excludedBackupAlbums, filter, now);
final List<AssetEntity> toAdd = await _fetchAssetsAndUpdateLastBackup(
selectedAlbums.slice(allIdx, allIdx + 1),
selectedBackupAlbums.slice(allIdx, allIdx + 1),
now,
);
final List<AssetEntity> toRemove = await _fetchAssetsAndUpdateLastBackup(
excludedAlbums,
excludedBackupAlbums,
now,
);
return toAdd.toSet().difference(toRemove.toSet()).toList();
} else {
return await _fetchAssetsAndUpdateLastBackup(
selectedAlbums,
selectedBackupAlbums,
now,
);
}
}
Future<List<AssetPathEntity?>> _loadAlbumsWithTimeFilter(
List<BackupAlbum> albums,
FilterOptionGroup filter,
DateTime now,
) async {
List<AssetPathEntity?> result = [];
for (BackupAlbum a in albums) {
try {
final AssetPathEntity album =
await AssetPathEntity.obtainPathFromProperties(
id: a.id,
optionGroup: filter.copyWith(
updateTimeCond: DateTimeCond(
// subtract 2 seconds to prevent missing assets due to rounding issues
min: a.lastBackup.subtract(const Duration(seconds: 2)),
max: now,
),
),
maxDateTimeToNow: false,
);
result.add(album);
} on StateError {
// either there are no assets matching the filter criteria OR the album no longer exists
}
}
return result;
}
Future<List<AssetEntity>> _fetchAssetsAndUpdateLastBackup(
List<AssetPathEntity?> albums,
List<BackupAlbum> backupAlbums,
DateTime now,
) async {
List<AssetEntity> result = [];
for (int i = 0; i < albums.length; i++) {
final AssetPathEntity? a = albums[i];
if (a != null &&
a.lastModified?.isBefore(backupAlbums[i].lastBackup) != true) {
result.addAll(
await a.getAssetListRange(start: 0, end: await a.assetCountAsync),
);
backupAlbums[i].lastBackup = now;
}
}
return result;
}
/// Returns a new list of assets not yet uploaded
Future<List<AssetEntity>> removeAlreadyUploadedAssets(
List<AssetEntity> candidates,
) async {
if (candidates.isEmpty) {
return candidates;
}
final Set<String> duplicatedAssetIds = await getDuplicatedAssetIds();
candidates = duplicatedAssetIds.isEmpty
? candidates
: candidates
.whereNot((asset) => duplicatedAssetIds.contains(asset.id))
.toList();
if (candidates.isEmpty) {
return candidates;
}
final Set<String> existing = {};
try {
final String deviceId = Store.get(StoreKey.deviceId);
final CheckExistingAssetsResponseDto? duplicates =
await _apiService.assetApi.checkExistingAssets(
CheckExistingAssetsDto(
deviceAssetIds: candidates.map((e) => e.id).toList(),
deviceId: deviceId,
),
);
if (duplicates != null) {
existing.addAll(duplicates.existingIds);
}
} on ApiException {
// workaround for older server versions or when checking for too many assets at once
final List<String>? allAssetsInDatabase = await getDeviceBackupAsset();
if (allAssetsInDatabase != null) {
existing.addAll(allAssetsInDatabase);
}
}
return existing.isEmpty
? candidates
: candidates.whereNot((e) => existing.contains(e.id)).toList();
}
Future<bool> backupAsset(
Iterable<AssetEntity> assetList,
Iterable<Asset> assetList,
http.CancellationToken cancelToken,
PMProgressHandler? pmProgressHandler,
Function(String, String, bool) uploadSuccessCb,
@@ -223,26 +106,36 @@ class BackupService {
final String deviceId = Store.get(StoreKey.deviceId);
final String savedEndpoint = Store.get(StoreKey.serverEndpoint);
bool anyErrors = false;
final List<String> duplicatedAssetIds = [];
// DON'T KNOW WHY BUT THIS HELPS BACKGROUND BACKUP TO WORK ON IOS
if (Platform.isIOS) {
await PhotoManager.requestPermissionExtend();
}
List<AssetEntity> assetsToUpload = sortAssets
List<Asset> assetsToUpload = sortAssets
// Upload images before video assets
// these are further sorted by using their creation date
? assetList.sorted(
(a, b) {
final cmp = a.typeInt - b.typeInt;
final cmp = a.type.index - b.type.index;
if (cmp != 0) return cmp;
return a.createDateTime.compareTo(b.createDateTime);
return a.fileCreatedAt.compareTo(b.fileCreatedAt);
},
)
: assetList.toList();
for (var entity in assetsToUpload) {
for (final asset in assetsToUpload) {
if (!asset.isLocal) {
_log.severe("Asset - ${asset.id} is not a local asset");
continue;
}
final entity = (await AssetEntity.fromId(asset.localId!));
if (entity == null) {
_log.severe("Cannot fetch asset entity for - ${asset.localId}");
continue;
}
File? file;
File? livePhotoFile;
@@ -353,8 +246,6 @@ class BackupService {
await httpClient.send(req, cancellationToken: cancelToken);
if (response.statusCode == 200) {
// asset is a duplicate (already exists on the server)
duplicatedAssetIds.add(entity.id);
uploadSuccessCb(entity.id, deviceId, true);
} else if (response.statusCode == 201) {
// stored a new asset on the server
@@ -405,9 +296,6 @@ class BackupService {
}
}
}
if (duplicatedAssetIds.isNotEmpty) {
await _saveDuplicatedAssetIds(duplicatedAssetIds);
}
return !anyErrors;
}
@@ -0,0 +1,183 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
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/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';
final backupAlbumServiceProvider = Provider(
(ref) => BackupAlbumService(
ref.watch(dbProvider),
),
);
class BackupAlbumService {
final Isar _db;
final Logger _log = Logger("BackupAlbumService");
BackupAlbumService(this._db);
Future<void> addBackupAlbum(
LocalAlbum album,
BackupSelection selection,
) async {
final albumInDB =
await _db.backupAlbums.filter().idEqualTo(album.id).findFirst();
final backupAlbum = albumInDB ??
BackupAlbum(
id: album.id,
selection: selection,
);
backupAlbum.selection = selection;
backupAlbum.album.value = album;
await _db.writeTxn(() async {
await _db.backupAlbums.store(backupAlbum);
});
}
Future<void> syncWithLocalAlbum(LocalAlbum album) async {
final albumInDB =
await _db.backupAlbums.filter().idEqualTo(album.id).findFirst();
if (albumInDB == null) {
_log.fine("No backup album for local album - ${album.name}");
return;
}
albumInDB.album.value = album;
await _db.writeTxn(() async {
await _db.backupAlbums.store(albumInDB);
});
}
Future<void> refreshAlbumAssetsState() async {
final backupAlbums = await _db.backupAlbums.where().findAll();
for (final album in backupAlbums) {
final localAlbum = album.album.value;
if (localAlbum != null) {
await updateAlbumAssetsState(localAlbum, album.selection);
}
}
}
Future<void> updateAlbumSelection(
LocalAlbum localAlbum,
BackupSelection selection,
) async {
await localAlbum.backup.load();
final backupAlbum = localAlbum.backup.value;
if (backupAlbum == null) {
return addBackupAlbum(localAlbum, selection);
}
backupAlbum.selection = selection;
await _db.writeTxn(() async {
await _db.backupAlbums.store(backupAlbum);
});
}
Future<List<LocalAlbum>> _getAllLocalAlbumWithAsset(Asset asset) async {
return await _db.localAlbums
.filter()
.assets((q) => q.idEqualTo(asset.id))
.findAll();
}
Future<bool> updateAlbumAssetsState(
LocalAlbum album,
BackupSelection selection,
) async {
final assets = await _updateDeviceAssetsToSelection(album, selection);
if (assets.isEmpty) {
return false;
}
await _db.writeTxn(() async {
await _db.deviceAssets.putAll(assets);
});
return true;
}
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) {
_log.warning("Local id not available for asset ID - ${asset.id}");
continue;
}
final deviceAsset =
await _db.deviceAssets.where().idEqualTo(asset.localId!).findFirst();
if (deviceAsset == null) {
_log.warning(
"Device asset not available for local asset ID - ${asset.id}",
);
continue;
}
if (deviceAsset.backupSelection == selection) {
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);
}
return updatedAssets;
}
}
@@ -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,11 @@ 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/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/album/models/album.model.dart';
import 'package:immich_mobile/modules/album/providers/local_album.provider.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 +15,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();
ref.read(localAlbumsProvider.notifier).getDeviceAlbums();
return null;
},
[],
);
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 +35,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 +62,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),
);
}),
),
);
}
}
@@ -1,5 +1,4 @@
import 'dart:io';
import 'dart:math';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -7,7 +6,10 @@ 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/device_assets.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';
@@ -15,7 +17,6 @@ import 'package:immich_mobile/modules/backup/ui/current_backup_asset_info_box.da
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:immich_mobile/modules/backup/ui/backup_info_card.dart';
@RoutePage()
@@ -24,61 +25,71 @@ class BackupControllerPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
BackUpState backupState = ref.watch(backupProvider);
final hasAnyAlbum = backupState.selectedBackupAlbums.isNotEmpty;
final backupState = ref.watch(backupProvider);
final backupAlbums = ref.watch(backupAlbumsProvider);
final deviceAssetState = ref.watch(deviceAssetsProvider);
final hasAnyAlbum =
backupAlbums.valueOrNull?.selectedBackupAlbums.isNotEmpty ?? false;
bool hasExclusiveAccess =
backupState.backupProgress != BackUpProgressEnum.inBackground;
bool shouldBackup = backupState.allUniqueAssets.length -
backupState.selectedAlbumsBackupAssetsIds.length ==
0 ||
bool shouldBackup = deviceAssetState.valueOrNull?.assetsRemaining == 0 ||
!hasExclusiveAccess
? false
: true;
useEffect(
() {
if (backupState.backupProgress != BackUpProgressEnum.inProgress &&
backupState.backupProgress != BackUpProgressEnum.manualInProgress) {
ref.watch(backupProvider.notifier).getBackupInfo();
}
// Update the background settings information just to make sure we
// have the latest, since the platform channel will not update
// automatically
if (Platform.isIOS) {
ref.watch(iOSBackgroundSettingsProvider.notifier).refresh();
}
ref
.watch(websocketProvider.notifier)
.stopListenToEvent('on_upload_success');
ref.read(backupAlbumsProvider.notifier).refreshAlbumAssetsState();
ref.invalidate(deviceAssetsProvider);
return null;
},
[],
);
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 +104,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 +148,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 +179,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(
@@ -242,10 +262,7 @@ class BackupControllerPage extends HookConsumerWidget {
"backup_controller_page_backup",
).tr(),
leading: IconButton(
onPressed: () {
ref.watch(websocketProvider.notifier).listenUploadEvent();
context.popRoute(true);
},
onPressed: () => context.popRoute(true),
splashRadius: 24,
icon: const Icon(
Icons.arrow_back_ios_rounded,
@@ -274,23 +291,32 @@ class BackupControllerPage extends HookConsumerWidget {
BackupInfoCard(
title: "backup_controller_page_total".tr(),
subtitle: "backup_controller_page_total_sub".tr(),
info: ref.watch(backupProvider).availableAlbums.isEmpty
info: ref
.watch(localAlbumsProvider)
.valueOrNull
.isNullOrEmpty
? "..."
: "${backupState.allUniqueAssets.length}",
: "${deviceAssetState.valueOrNull?.uniqueAssetsToBackup ?? 0}",
),
BackupInfoCard(
title: "backup_controller_page_backup".tr(),
subtitle: "backup_controller_page_backup_sub".tr(),
info: ref.watch(backupProvider).availableAlbums.isEmpty
info: ref
.watch(localAlbumsProvider)
.valueOrNull
.isNullOrEmpty
? "..."
: "${backupState.selectedAlbumsBackupAssetsIds.length}",
: "${deviceAssetState.valueOrNull?.backedUpAssets ?? 0}",
),
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)}",
: "${deviceAssetState.valueOrNull?.assetsRemaining ?? 0}",
),
const Divider(),
const CurrentUploadingAssetInfoBox(),
@@ -8,8 +8,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/backup/background_service/background.service.dart';
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/modules/backup/providers/backup_settings.provider.dart';
import 'package:immich_mobile/modules/backup/providers/device_assets.provider.dart';
import 'package:immich_mobile/modules/backup/providers/ios_background_settings.provider.dart';
import 'package:immich_mobile/modules/backup/services/backup_verification.service.dart';
import 'package:immich_mobile/modules/backup/ui/ios_debug_info_tile.dart';
@@ -28,7 +28,8 @@ class BackupOptionsPage extends HookConsumerWidget {
const BackupOptionsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
BackUpState backupState = ref.watch(backupProvider);
final deviceAssetState = ref.watch(deviceAssetsProvider);
final backupSettings = ref.watch(backupSettingsProvider);
final settings = ref.watch(iOSBackgroundSettingsProvider.notifier).settings;
final settingsService = ref.watch(appSettingsServiceProvider);
final showBackupFix = Platform.isAndroid &&
@@ -64,8 +65,7 @@ class BackupOptionsPage extends HookConsumerWidget {
void performBackupCheck() async {
try {
checkInProgress.value = true;
if (backupState.allUniqueAssets.length >
backupState.selectedAlbumsBackupAssetsIds.length) {
if (deviceAssetState.valueOrNull?.assetsRemaining != 0) {
ImmichToast.show(
context: context,
msg: "Backup all assets before starting this check!",
@@ -194,9 +194,9 @@ class BackupOptionsPage extends HookConsumerWidget {
}
Widget buildBackgroundBackupController() {
final bool isBackgroundEnabled = backupState.backgroundBackup;
final bool isWifiRequired = backupState.backupRequireWifi;
final bool isChargingRequired = backupState.backupRequireCharging;
final bool isBackgroundEnabled = backupSettings.backgroundBackup;
final bool isWifiRequired = backupSettings.backupRequireWifi;
final bool isChargingRequired = backupSettings.backupRequireCharging;
final Color activeColor = context.primaryColor;
String formatBackupDelaySliderValue(double v) {
@@ -236,7 +236,7 @@ class BackupOptionsPage extends HookConsumerWidget {
}
final triggerDelay =
useState(backupDelayToSliderValue(backupState.backupTriggerDelay));
useState(backupDelayToSliderValue(backupSettings.backupTriggerDelay));
return Column(
children: [
@@ -276,7 +276,7 @@ class BackupOptionsPage extends HookConsumerWidget {
activeColor: activeColor,
value: isWifiRequired,
onChanged: (isChecked) => ref
.read(backupProvider.notifier)
.read(backupSettingsProvider.notifier)
.configureBackgroundBackup(
requireWifi: isChecked,
onError: showErrorToUser,
@@ -296,7 +296,7 @@ class BackupOptionsPage extends HookConsumerWidget {
activeColor: activeColor,
value: isChargingRequired,
onChanged: (isChecked) => ref
.read(backupProvider.notifier)
.read(backupSettingsProvider.notifier)
.configureBackgroundBackup(
requireCharging: isChecked,
onError: showErrorToUser,
@@ -319,7 +319,7 @@ class BackupOptionsPage extends HookConsumerWidget {
value: triggerDelay.value,
onChanged: (double v) => triggerDelay.value = v,
onChangeEnd: (double v) => ref
.read(backupProvider.notifier)
.read(backupSettingsProvider.notifier)
.configureBackgroundBackup(
triggerDelay: backupDelayToMilliseconds(v),
onError: showErrorToUser,
@@ -333,7 +333,7 @@ class BackupOptionsPage extends HookConsumerWidget {
),
ElevatedButton(
onPressed: () => ref
.read(backupProvider.notifier)
.read(backupSettingsProvider.notifier)
.configureBackgroundBackup(
enabled: !isBackgroundEnabled,
onError: showErrorToUser,
@@ -411,7 +411,7 @@ class BackupOptionsPage extends HookConsumerWidget {
}
ListTile buildAutoBackupController() {
final isAutoBackup = backupState.autoBackup;
final isAutoBackup = backupSettings.autoBackup;
final backUpOption = isAutoBackup
? "backup_controller_page_status_on".tr()
: "backup_controller_page_status_off".tr();
@@ -443,7 +443,7 @@ class BackupOptionsPage extends HookConsumerWidget {
padding: const EdgeInsets.only(top: 8.0),
child: ElevatedButton(
onPressed: () => ref
.read(backupProvider.notifier)
.read(backupSettingsProvider.notifier)
.setAutoBackup(!isAutoBackup),
child: Text(
backupBtnText,
@@ -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(
+1 -1
View File
@@ -6,7 +6,7 @@ part of 'map_state.provider.dart';
// RiverpodGenerator
// **************************************************************************
String _$mapStateNotifierHash() => r'3b509b57b7400b09817e9caee9debf899172cd52';
String _$mapStateNotifierHash() => r'6408d616ec9fc0d1ff26e25692417c43504ff754';
/// See also [MapStateNotifier].
@ProviderFor(MapStateNotifier)
@@ -1,61 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' show useEffect, useState;
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/backup/models/duplicated_asset.model.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
class LocalStorageSettings extends HookConsumerWidget {
const LocalStorageSettings({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isarDb = ref.watch(dbProvider);
final cacheItemCount = useState(0);
useEffect(
() {
cacheItemCount.value = isarDb.duplicatedAssets.countSync();
return null;
},
[],
);
void clearCache() {
isarDb.writeTxnSync(() => isarDb.duplicatedAssets.clearSync());
cacheItemCount.value = isarDb.duplicatedAssets.countSync();
}
return ExpansionTile(
textColor: context.primaryColor,
title: Text(
"cache_settings_tile_title",
style: context.textTheme.titleMedium,
).tr(),
subtitle: const Text(
"cache_settings_tile_subtitle",
).tr(),
children: [
ListTile(
title: Text(
"cache_settings_duplicated_assets_title",
style: context.textTheme.titleSmall,
).tr(args: ["${cacheItemCount.value}"]),
subtitle: const Text(
"cache_settings_duplicated_assets_subtitle",
).tr(),
trailing: TextButton(
onPressed: cacheItemCount.value > 0 ? clearCache : null,
child: Text(
"cache_settings_duplicated_assets_clear_button",
style: TextStyle(
fontSize: 12,
color: cacheItemCount.value > 0 ? Colors.red : Colors.grey,
fontWeight: FontWeight.bold,
),
).tr(),
),
),
],
);
}
}
@@ -5,7 +5,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/settings/ui/advanced_settings/advanced_settings.dart';
import 'package:immich_mobile/modules/settings/ui/asset_list_settings/asset_list_settings.dart';
import 'package:immich_mobile/modules/settings/ui/local_storage_settings/local_storage_settings.dart';
import 'package:immich_mobile/modules/settings/ui/image_viewer_quality_setting/image_viewer_quality_setting.dart';
import 'package:immich_mobile/modules/settings/ui/notification_setting/notification_setting.dart';
import 'package:immich_mobile/modules/settings/ui/theme_setting/theme_setting.dart';
@@ -40,7 +39,6 @@ class SettingsPage extends HookConsumerWidget {
const AssetListSettings(),
const NotificationSetting(),
// const ExperimentalSettings(),
const LocalStorageSettings(),
const AdvancedSettings(),
],
),
+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(
+108 -102
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() {
@@ -1393,7 +1399,7 @@ class VideoViewerRoute extends PageRouteInfo<VideoViewerRouteArgs> {
void Function()? onPaused,
Widget? placeholder,
bool showControls = true,
Duration hideControlsTimer = const Duration(milliseconds: 1500),
Duration hideControlsTimer = const Duration(seconds: 5),
bool showDownloadingIndicator = true,
List<PageRouteInfo>? children,
}) : super(
@@ -1429,7 +1435,7 @@ class VideoViewerRouteArgs {
this.onPaused,
this.placeholder,
this.showControls = true,
this.hideControlsTimer = const Duration(milliseconds: 1500),
this.hideControlsTimer = const Duration(seconds: 5),
this.showDownloadingIndicator = true,
});
@@ -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();
}
}
+68 -13
View File
@@ -1,6 +1,9 @@
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/models/backup_album.model.dart';
import 'package:immich_mobile/shared/models/device_asset.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 +19,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 +53,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 +82,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 +324,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 +346,57 @@ final assetWatcher =
return db.assets.watchObject(asset.id, fireImmediately: true);
});
final assetsProvider = StreamProvider.family<RenderList, int?>((ref, userId) {
Stream<RenderList> _assetsProvider(
AutoDisposeStreamProviderRef<RenderList> 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> assets(AssetsRef ref, int? userId) {
final listener = ref
.read(dbProvider)
.deviceAssets
.filter()
.backupSelectionEqualTo(BackupSelection.select)
.watchLazy()
.listen((_) => ref.invalidateSelf());
ref.onDispose(listener.cancel);
return _assetsProvider(ref, userId);
}
Stream<RenderList> _multiUserAssets(
AutoDisposeStreamProviderRef<RenderList> 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);
}
@riverpod
Stream<RenderList> multiUserAssets(MultiUserAssetsRef ref, List<int> userIds) {
final listener = ref
.read(dbProvider)
.deviceAssets
.filter()
.backupSelectionEqualTo(BackupSelection.select)
.watchLazy()
.listen((_) => ref.invalidateSelf());
ref.onDispose(listener.cancel);
return _multiUserAssets(ref, userIds);
}
QueryBuilder<Asset, Asset, QAfterSortBy>? getRemoteAssetQuery(WidgetRef ref) {
final userId = ref.watch(currentUserProvider)?.isarId;
@@ -375,16 +415,31 @@ 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
.read(dbProvider)
.deviceAssets
.filter()
.backupSelectionEqualTo(BackupSelection.select)
.idProperty()
.findAllSync();
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'89d151eead499747703a2361efd178ba89e87be8';
/// 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'63a9506e21d34e66af2af93946930016d51b0b43';
/// 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
@@ -52,6 +52,7 @@ class ServerInfoNotifier extends StateNotifier<ServerInfo> {
await getServerVersion();
await getServerFeatures();
await getServerConfig();
await getServerDiskInfo();
}
getServerVersion() async {
@@ -152,6 +153,14 @@ class ServerInfoNotifier extends StateNotifier<ServerInfo> {
state = state.copyWith(serverConfig: serverConfig);
}
getServerDiskInfo() async {
final serverInfo = await _serverInfoService.getServerInfo();
if (serverInfo == null) {
return;
}
state = state.copyWith(serverDiskInfo: serverInfo);
}
Map<String, int> _getDetailVersion(String version) {
List<String> detail = version.split(".");
@@ -4,6 +4,7 @@ import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/backup/providers/device_assets.provider.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/server_info/server_version.model.dart';
@@ -185,16 +186,6 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
}
}
void stopListenToEvent(String eventName) {
debugPrint("Stop listening to event $eventName");
state.socket?.off(eventName);
}
void listenUploadEvent() {
debugPrint("Start listening to event on_upload_success");
state.socket?.on('on_upload_success', _handleOnUploadSuccess);
}
void addPendingChange(PendingAction action, dynamic value) {
final now = DateTime.now();
state = state.copyWith(
@@ -241,6 +232,9 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
.whereNot((c) => uploadedChanges.contains(c))
.toList(),
);
// Reload backup state
_ref.invalidate(deviceAssetsProvider);
}
}
+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!));
}
@@ -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/backup/models/backup_state.model.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:immich_mobile/shared/ui/app_bar_dialog/app_bar_profile_info.dart';
@@ -23,7 +23,8 @@ class ImmichAppBarDialog extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
BackUpState backupState = ref.watch(backupProvider);
final serverInfo =
ref.watch(serverInfoProvider.select((value) => value.serverDiskInfo));
final theme = context.themeData;
bool isHorizontal = !context.isMobile;
final horizontalPadding = isHorizontal ? 100.0 : 20.0;
@@ -31,7 +32,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
useEffect(
() {
ref.read(backupProvider.notifier).updateServerInfo();
ref.read(serverInfoProvider.notifier).getServerDiskInfo();
ref.read(currentUserProvider.notifier).refresh();
return null;
},
@@ -134,9 +135,9 @@ class ImmichAppBarDialog extends HookConsumerWidget {
}
Widget buildStorageInformation() {
var percentage = backupState.serverInfo.diskUsagePercentage / 100;
var usedDiskSpace = backupState.serverInfo.diskUse;
var totalDiskSpace = backupState.serverInfo.diskSize;
var percentage = serverInfo.diskUsagePercentage / 100;
var usedDiskSpace = serverInfo.diskUse;
var totalDiskSpace = serverInfo.diskSize;
if (user != null && user.hasQuota) {
usedDiskSpace = formatBytes(user.quotaUsageInBytes);
@@ -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;
+4 -2
View File
@@ -2,6 +2,7 @@ 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/backup/providers/backup_settings.provider.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/ui/app_bar_dialog/app_bar_dialog.dart';
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
@@ -21,9 +22,10 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final BackUpState backupState = ref.watch(backupProvider);
final backupState = ref.watch(backupProvider);
final backupSettings = ref.watch(backupSettingsProvider);
final bool isEnableAutoBackup =
backupState.backgroundBackup || backupState.autoBackup;
backupSettings.backgroundBackup || backupSettings.autoBackup;
final ServerInfo serverInfoState = ref.watch(serverInfoProvider);
final user = Store.tryGet(StoreKey.currentUser);
final isDarkTheme = context.isDarkTheme;
+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,
);
}

Some files were not shown because too many files have changed in this diff Show More