mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-03 19:17:11 -05:00 
			
		
		
		
	feat(mobile): new upload (#18726)
This commit is contained in:
		
							parent
							
								
									f929dc0816
								
							
						
					
					
						commit
						fafb88d31c
					
				@ -16,6 +16,11 @@ const int kBatchHashSizeLimit = 1024 * 1024 * 1024; // 1GB
 | 
				
			|||||||
// Secure storage keys
 | 
					// Secure storage keys
 | 
				
			||||||
const String kSecuredPinCode = "secured_pin_code";
 | 
					const String kSecuredPinCode = "secured_pin_code";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// background_downloader task groups
 | 
				
			||||||
 | 
					const String kManualUploadGroup = 'manual_upload_group';
 | 
				
			||||||
 | 
					const String kBackupGroup = 'backup_group';
 | 
				
			||||||
 | 
					const String kBackupLivePhotoGroup = 'backup_live_photo_group';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Timeline constants
 | 
					// Timeline constants
 | 
				
			||||||
const int kTimelineNoneSegmentSize = 120;
 | 
					const int kTimelineNoneSegmentSize = 120;
 | 
				
			||||||
const int kTimelineAssetLoadBatchSize = 256;
 | 
					const int kTimelineAssetLoadBatchSize = 256;
 | 
				
			||||||
 | 
				
			|||||||
@ -14,4 +14,8 @@ class LocalAlbumService {
 | 
				
			|||||||
  Future<LocalAsset?> getThumbnail(String albumId) {
 | 
					  Future<LocalAsset?> getThumbnail(String albumId) {
 | 
				
			||||||
    return _repository.getThumbnail(albumId);
 | 
					    return _repository.getThumbnail(albumId);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> update(LocalAlbum album) {
 | 
				
			||||||
 | 
					    return _repository.upsert(album);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										154
									
								
								mobile/lib/infrastructure/repositories/backup.repository.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										154
									
								
								mobile/lib/infrastructure/repositories/backup.repository.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,154 @@
 | 
				
			|||||||
 | 
					import 'package:drift/drift.dart';
 | 
				
			||||||
 | 
					import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/domain/models/album/local_album.model.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
 | 
				
			||||||
 | 
					import "package:immich_mobile/utils/database.utils.dart";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					final backupRepositoryProvider = Provider<DriftBackupRepository>(
 | 
				
			||||||
 | 
					  (ref) => DriftBackupRepository(ref.watch(driftProvider)),
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class DriftBackupRepository extends DriftDatabaseRepository {
 | 
				
			||||||
 | 
					  final Drift _db;
 | 
				
			||||||
 | 
					  const DriftBackupRepository(this._db) : super(_db);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  _getExcludedSubquery() {
 | 
				
			||||||
 | 
					    return _db.localAlbumAssetEntity.selectOnly()
 | 
				
			||||||
 | 
					      ..addColumns([_db.localAlbumAssetEntity.assetId])
 | 
				
			||||||
 | 
					      ..join([
 | 
				
			||||||
 | 
					        innerJoin(
 | 
				
			||||||
 | 
					          _db.localAlbumEntity,
 | 
				
			||||||
 | 
					          _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
 | 
				
			||||||
 | 
					          useColumns: false,
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ])
 | 
				
			||||||
 | 
					      ..where(
 | 
				
			||||||
 | 
					        _db.localAlbumEntity.backupSelection
 | 
				
			||||||
 | 
					            .equalsValue(BackupSelection.excluded),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<int> getTotalCount() async {
 | 
				
			||||||
 | 
					    final query = _db.localAlbumAssetEntity.selectOnly(distinct: true)
 | 
				
			||||||
 | 
					      ..addColumns([_db.localAlbumAssetEntity.assetId])
 | 
				
			||||||
 | 
					      ..join([
 | 
				
			||||||
 | 
					        innerJoin(
 | 
				
			||||||
 | 
					          _db.localAlbumEntity,
 | 
				
			||||||
 | 
					          _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
 | 
				
			||||||
 | 
					          useColumns: false,
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ])
 | 
				
			||||||
 | 
					      ..where(
 | 
				
			||||||
 | 
					        _db.localAlbumEntity.backupSelection
 | 
				
			||||||
 | 
					                .equalsValue(BackupSelection.selected) &
 | 
				
			||||||
 | 
					            _db.localAlbumAssetEntity.assetId
 | 
				
			||||||
 | 
					                .isNotInQuery(_getExcludedSubquery()),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return query.get().then((rows) => rows.length);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<int> getRemainderCount() async {
 | 
				
			||||||
 | 
					    final query = _db.localAlbumAssetEntity.selectOnly(distinct: true)
 | 
				
			||||||
 | 
					      ..addColumns(
 | 
				
			||||||
 | 
					        [_db.localAlbumAssetEntity.assetId],
 | 
				
			||||||
 | 
					      )
 | 
				
			||||||
 | 
					      ..join([
 | 
				
			||||||
 | 
					        innerJoin(
 | 
				
			||||||
 | 
					          _db.localAlbumEntity,
 | 
				
			||||||
 | 
					          _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
 | 
				
			||||||
 | 
					          useColumns: false,
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        innerJoin(
 | 
				
			||||||
 | 
					          _db.localAssetEntity,
 | 
				
			||||||
 | 
					          _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id),
 | 
				
			||||||
 | 
					          useColumns: false,
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        leftOuterJoin(
 | 
				
			||||||
 | 
					          _db.remoteAssetEntity,
 | 
				
			||||||
 | 
					          _db.localAssetEntity.checksum
 | 
				
			||||||
 | 
					              .equalsExp(_db.remoteAssetEntity.checksum),
 | 
				
			||||||
 | 
					          useColumns: false,
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ])
 | 
				
			||||||
 | 
					      ..where(
 | 
				
			||||||
 | 
					        _db.localAlbumEntity.backupSelection
 | 
				
			||||||
 | 
					                .equalsValue(BackupSelection.selected) &
 | 
				
			||||||
 | 
					            _db.remoteAssetEntity.id.isNull() &
 | 
				
			||||||
 | 
					            _db.localAlbumAssetEntity.assetId
 | 
				
			||||||
 | 
					                .isNotInQuery(_getExcludedSubquery()),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return query.get().then((rows) => rows.length);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<int> getBackupCount() async {
 | 
				
			||||||
 | 
					    final query = _db.localAlbumAssetEntity.selectOnly(distinct: true)
 | 
				
			||||||
 | 
					      ..addColumns(
 | 
				
			||||||
 | 
					        [_db.localAlbumAssetEntity.assetId],
 | 
				
			||||||
 | 
					      )
 | 
				
			||||||
 | 
					      ..join([
 | 
				
			||||||
 | 
					        innerJoin(
 | 
				
			||||||
 | 
					          _db.localAlbumEntity,
 | 
				
			||||||
 | 
					          _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
 | 
				
			||||||
 | 
					          useColumns: false,
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        innerJoin(
 | 
				
			||||||
 | 
					          _db.localAssetEntity,
 | 
				
			||||||
 | 
					          _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id),
 | 
				
			||||||
 | 
					          useColumns: false,
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        innerJoin(
 | 
				
			||||||
 | 
					          _db.remoteAssetEntity,
 | 
				
			||||||
 | 
					          _db.localAssetEntity.checksum
 | 
				
			||||||
 | 
					              .equalsExp(_db.remoteAssetEntity.checksum),
 | 
				
			||||||
 | 
					          useColumns: false,
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ])
 | 
				
			||||||
 | 
					      ..where(
 | 
				
			||||||
 | 
					        _db.localAlbumEntity.backupSelection
 | 
				
			||||||
 | 
					                .equalsValue(BackupSelection.selected) &
 | 
				
			||||||
 | 
					            _db.remoteAssetEntity.id.isNotNull() &
 | 
				
			||||||
 | 
					            _db.localAlbumAssetEntity.assetId
 | 
				
			||||||
 | 
					                .isNotInQuery(_getExcludedSubquery()),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return query.get().then((rows) => rows.length);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<List<LocalAsset>> getCandidates() async {
 | 
				
			||||||
 | 
					    final selectedAlbumIds = _db.localAlbumEntity.selectOnly(distinct: true)
 | 
				
			||||||
 | 
					      ..addColumns([_db.localAlbumEntity.id])
 | 
				
			||||||
 | 
					      ..where(
 | 
				
			||||||
 | 
					        _db.localAlbumEntity.backupSelection
 | 
				
			||||||
 | 
					            .equalsValue(BackupSelection.selected),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final query = _db.localAssetEntity.select()
 | 
				
			||||||
 | 
					      ..where(
 | 
				
			||||||
 | 
					        (lae) =>
 | 
				
			||||||
 | 
					            existsQuery(
 | 
				
			||||||
 | 
					              _db.localAlbumAssetEntity.selectOnly()
 | 
				
			||||||
 | 
					                ..addColumns([_db.localAlbumAssetEntity.assetId])
 | 
				
			||||||
 | 
					                ..where(
 | 
				
			||||||
 | 
					                  _db.localAlbumAssetEntity.albumId
 | 
				
			||||||
 | 
					                          .isInQuery(selectedAlbumIds) &
 | 
				
			||||||
 | 
					                      _db.localAlbumAssetEntity.assetId.equalsExp(lae.id),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					            ) &
 | 
				
			||||||
 | 
					            notExistsQuery(
 | 
				
			||||||
 | 
					              _db.remoteAssetEntity.selectOnly()
 | 
				
			||||||
 | 
					                ..addColumns([_db.remoteAssetEntity.checksum])
 | 
				
			||||||
 | 
					                ..where(
 | 
				
			||||||
 | 
					                  _db.remoteAssetEntity.checksum.equalsExp(lae.checksum) &
 | 
				
			||||||
 | 
					                      lae.checksum.isNotNull(),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					            ) &
 | 
				
			||||||
 | 
					            lae.id.isNotInQuery(_getExcludedSubquery()),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return query.map((localAsset) => localAsset.toDto()).get();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -5,6 +5,7 @@ import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.d
 | 
				
			|||||||
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart';
 | 
					import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart';
 | 
				
			||||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
 | 
					import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
 | 
				
			||||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
 | 
					import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/utils/database.utils.dart';
 | 
				
			||||||
import 'package:platform/platform.dart';
 | 
					import 'package:platform/platform.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
enum SortLocalAlbumsBy { id, backupSelection, isIosSharedAlbum }
 | 
					enum SortLocalAlbumsBy { id, backupSelection, isIosSharedAlbum }
 | 
				
			||||||
@ -381,30 +382,3 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
 | 
				
			|||||||
    return results.isNotEmpty ? results.first : null;
 | 
					    return results.isNotEmpty ? results.first : null;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					 | 
				
			||||||
extension on LocalAlbumEntityData {
 | 
					 | 
				
			||||||
  LocalAlbum toDto({int assetCount = 0}) {
 | 
					 | 
				
			||||||
    return LocalAlbum(
 | 
					 | 
				
			||||||
      id: id,
 | 
					 | 
				
			||||||
      name: name,
 | 
					 | 
				
			||||||
      updatedAt: updatedAt,
 | 
					 | 
				
			||||||
      assetCount: assetCount,
 | 
					 | 
				
			||||||
      backupSelection: backupSelection,
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
extension on LocalAssetEntityData {
 | 
					 | 
				
			||||||
  LocalAsset toDto() {
 | 
					 | 
				
			||||||
    return LocalAsset(
 | 
					 | 
				
			||||||
      id: id,
 | 
					 | 
				
			||||||
      name: name,
 | 
					 | 
				
			||||||
      checksum: checksum,
 | 
					 | 
				
			||||||
      type: type,
 | 
					 | 
				
			||||||
      createdAt: createdAt,
 | 
					 | 
				
			||||||
      updatedAt: updatedAt,
 | 
					 | 
				
			||||||
      durationInSeconds: durationInSeconds,
 | 
					 | 
				
			||||||
      isFavorite: isFavorite,
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
				
			|||||||
@ -56,4 +56,11 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
 | 
				
			|||||||
      }
 | 
					      }
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<LocalAsset?> getById(String id) {
 | 
				
			||||||
 | 
					    final query = _db.localAssetEntity.select()
 | 
				
			||||||
 | 
					      ..where((lae) => lae.id.equals(id));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return query.map((row) => row.toDto()).getSingleOrNull();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -1,5 +1,6 @@
 | 
				
			|||||||
import 'dart:io';
 | 
					import 'dart:io';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
 | 
				
			||||||
import 'package:logging/logging.dart';
 | 
					import 'package:logging/logging.dart';
 | 
				
			||||||
import 'package:photo_manager/photo_manager.dart';
 | 
					import 'package:photo_manager/photo_manager.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -7,8 +8,9 @@ class StorageRepository {
 | 
				
			|||||||
  const StorageRepository();
 | 
					  const StorageRepository();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<File?> getFileForAsset(String assetId) async {
 | 
					  Future<File?> getFileForAsset(String assetId) async {
 | 
				
			||||||
    final log = Logger('StorageRepository');
 | 
					 | 
				
			||||||
    File? file;
 | 
					    File? file;
 | 
				
			||||||
 | 
					    final log = Logger('StorageRepository');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      final entity = await AssetEntity.fromId(assetId);
 | 
					      final entity = await AssetEntity.fromId(assetId);
 | 
				
			||||||
      file = await entity?.originFile;
 | 
					      file = await entity?.originFile;
 | 
				
			||||||
@ -20,4 +22,48 @@ class StorageRepository {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
    return file;
 | 
					    return file;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<File?> getMotionFileForAsset(LocalAsset asset) async {
 | 
				
			||||||
 | 
					    File? file;
 | 
				
			||||||
 | 
					    final log = Logger('StorageRepository');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final entity = await AssetEntity.fromId(asset.id);
 | 
				
			||||||
 | 
					      file = await entity?.originFileWithSubtype;
 | 
				
			||||||
 | 
					      if (file == null) {
 | 
				
			||||||
 | 
					        log.warning(
 | 
				
			||||||
 | 
					          "Cannot get motion file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } catch (error, stackTrace) {
 | 
				
			||||||
 | 
					      log.warning(
 | 
				
			||||||
 | 
					        "Error getting motion file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
 | 
				
			||||||
 | 
					        error,
 | 
				
			||||||
 | 
					        stackTrace,
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    return file;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<AssetEntity?> getAssetEntityForAsset(LocalAsset asset) async {
 | 
				
			||||||
 | 
					    final log = Logger('StorageRepository');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    AssetEntity? entity;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      entity = await AssetEntity.fromId(asset.id);
 | 
				
			||||||
 | 
					      if (entity == null) {
 | 
				
			||||||
 | 
					        log.warning(
 | 
				
			||||||
 | 
					          "Cannot get AssetEntity for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } catch (error, stackTrace) {
 | 
				
			||||||
 | 
					      log.warning(
 | 
				
			||||||
 | 
					        "Error getting AssetEntity for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
 | 
				
			||||||
 | 
					        error,
 | 
				
			||||||
 | 
					        stackTrace,
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    return entity;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -93,6 +93,13 @@ Future<void> initApp() async {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  initializeTimeZones();
 | 
					  initializeTimeZones();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Initialize the file downloader
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  await FileDownloader().configure(
 | 
				
			||||||
 | 
					    // maxConcurrent: 5, maxConcurrentByHost: 2, maxConcurrentByGroup: 3
 | 
				
			||||||
 | 
					    globalConfig: (Config.holdingQueue, (5, 2, 3)),
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  await FileDownloader().trackTasksInGroup(
 | 
					  await FileDownloader().trackTasksInGroup(
 | 
				
			||||||
    downloadGroupLivePhoto,
 | 
					    downloadGroupLivePhoto,
 | 
				
			||||||
    markDownloadedComplete: false,
 | 
					    markDownloadedComplete: false,
 | 
				
			||||||
@ -171,7 +178,21 @@ class ImmichAppState extends ConsumerState<ImmichApp>
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  void _configureFileDownloaderNotifications() {
 | 
					  void _configureFileDownloaderNotifications() {
 | 
				
			||||||
    FileDownloader().configureNotification(
 | 
					    FileDownloader().configureNotificationForGroup(
 | 
				
			||||||
 | 
					      downloadGroupImage,
 | 
				
			||||||
 | 
					      running: TaskNotification(
 | 
				
			||||||
 | 
					        'downloading_media'.tr(),
 | 
				
			||||||
 | 
					        '${'file_name'.tr()}: {filename}',
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      complete: TaskNotification(
 | 
				
			||||||
 | 
					        'download_finished'.tr(),
 | 
				
			||||||
 | 
					        '${'file_name'.tr()}: {filename}',
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      progressBar: true,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    FileDownloader().configureNotificationForGroup(
 | 
				
			||||||
 | 
					      downloadGroupVideo,
 | 
				
			||||||
      running: TaskNotification(
 | 
					      running: TaskNotification(
 | 
				
			||||||
        'downloading_media'.tr(),
 | 
					        'downloading_media'.tr(),
 | 
				
			||||||
        '${'file_name'.tr()}: {filename}',
 | 
					        '${'file_name'.tr()}: {filename}',
 | 
				
			||||||
 | 
				
			|||||||
@ -17,7 +17,7 @@ enum UploadStatus {
 | 
				
			|||||||
  notFound,
 | 
					  notFound,
 | 
				
			||||||
  failed,
 | 
					  failed,
 | 
				
			||||||
  canceled,
 | 
					  canceled,
 | 
				
			||||||
  waitingtoRetry,
 | 
					  waitingToRetry,
 | 
				
			||||||
  paused,
 | 
					  paused,
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										294
									
								
								mobile/lib/pages/backup/drift_backup.page.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										294
									
								
								mobile/lib/pages/backup/drift_backup.page.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,294 @@
 | 
				
			|||||||
 | 
					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/domain/models/album/local_album.model.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/extensions/build_context_extensions.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/extensions/theme_extensions.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/providers/websocket.provider.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/routing/router.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/widgets/backup/backup_info_card.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@RoutePage()
 | 
				
			||||||
 | 
					class DriftBackupPage extends HookConsumerWidget {
 | 
				
			||||||
 | 
					  const DriftBackupPage({super.key});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context, WidgetRef ref) {
 | 
				
			||||||
 | 
					    useEffect(
 | 
				
			||||||
 | 
					      () {
 | 
				
			||||||
 | 
					        ref.read(driftBackupProvider.notifier).getBackupStatus();
 | 
				
			||||||
 | 
					        return null;
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      [],
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Widget buildControlButtons() {
 | 
				
			||||||
 | 
					      return Padding(
 | 
				
			||||||
 | 
					        padding: const EdgeInsets.only(
 | 
				
			||||||
 | 
					          top: 24,
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        child: Column(
 | 
				
			||||||
 | 
					          children: [
 | 
				
			||||||
 | 
					            ElevatedButton(
 | 
				
			||||||
 | 
					              onPressed: () => ref.read(driftBackupProvider.notifier).backup(),
 | 
				
			||||||
 | 
					              child: const Text(
 | 
				
			||||||
 | 
					                "backup_controller_page_start_backup",
 | 
				
			||||||
 | 
					                style: TextStyle(
 | 
				
			||||||
 | 
					                  fontSize: 16,
 | 
				
			||||||
 | 
					                  fontWeight: FontWeight.bold,
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              ).tr(),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            OutlinedButton(
 | 
				
			||||||
 | 
					              onPressed: () => ref.read(driftBackupProvider.notifier).cancel(),
 | 
				
			||||||
 | 
					              child: const Text(
 | 
				
			||||||
 | 
					                "cancel",
 | 
				
			||||||
 | 
					                style: TextStyle(
 | 
				
			||||||
 | 
					                  fontSize: 16,
 | 
				
			||||||
 | 
					                  fontWeight: FontWeight.bold,
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              ).tr(),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            OutlinedButton(
 | 
				
			||||||
 | 
					              onPressed: () =>
 | 
				
			||||||
 | 
					                  ref.read(driftBackupProvider.notifier).getDataInfo(),
 | 
				
			||||||
 | 
					              child: const Text(
 | 
				
			||||||
 | 
					                "Get database info",
 | 
				
			||||||
 | 
					                style: TextStyle(
 | 
				
			||||||
 | 
					                  fontSize: 16,
 | 
				
			||||||
 | 
					                  fontWeight: FontWeight.bold,
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              ).tr(),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ],
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return Scaffold(
 | 
				
			||||||
 | 
					      appBar: AppBar(
 | 
				
			||||||
 | 
					        elevation: 0,
 | 
				
			||||||
 | 
					        title: const Text(
 | 
				
			||||||
 | 
					          "Backup (Experimental)",
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        leading: IconButton(
 | 
				
			||||||
 | 
					          onPressed: () {
 | 
				
			||||||
 | 
					            ref.watch(websocketProvider.notifier).listenUploadEvent();
 | 
				
			||||||
 | 
					            context.maybePop(true);
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          splashRadius: 24,
 | 
				
			||||||
 | 
					          icon: const Icon(
 | 
				
			||||||
 | 
					            Icons.arrow_back_ios_rounded,
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        actions: [
 | 
				
			||||||
 | 
					          Padding(
 | 
				
			||||||
 | 
					            padding: const EdgeInsets.only(right: 8.0),
 | 
				
			||||||
 | 
					            child: IconButton(
 | 
				
			||||||
 | 
					              onPressed: () => context.pushRoute(const BackupOptionsRoute()),
 | 
				
			||||||
 | 
					              splashRadius: 24,
 | 
				
			||||||
 | 
					              icon: const Icon(
 | 
				
			||||||
 | 
					                Icons.settings_outlined,
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      body: Stack(
 | 
				
			||||||
 | 
					        children: [
 | 
				
			||||||
 | 
					          Padding(
 | 
				
			||||||
 | 
					            padding: const EdgeInsets.only(
 | 
				
			||||||
 | 
					              left: 16.0,
 | 
				
			||||||
 | 
					              right: 16,
 | 
				
			||||||
 | 
					              bottom: 32,
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            child: ListView(
 | 
				
			||||||
 | 
					              children: [
 | 
				
			||||||
 | 
					                const SizedBox(height: 8),
 | 
				
			||||||
 | 
					                const _BackupAlbumSelectionCard(),
 | 
				
			||||||
 | 
					                const _TotalCard(),
 | 
				
			||||||
 | 
					                const _BackupCard(),
 | 
				
			||||||
 | 
					                const _RemainderCard(),
 | 
				
			||||||
 | 
					                const Divider(),
 | 
				
			||||||
 | 
					                buildControlButtons(),
 | 
				
			||||||
 | 
					              ],
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _BackupAlbumSelectionCard extends ConsumerWidget {
 | 
				
			||||||
 | 
					  const _BackupAlbumSelectionCard();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context, WidgetRef ref) {
 | 
				
			||||||
 | 
					    Widget buildSelectedAlbumName() {
 | 
				
			||||||
 | 
					      String text = "backup_controller_page_backup_selected".tr();
 | 
				
			||||||
 | 
					      final albums = ref
 | 
				
			||||||
 | 
					          .watch(backupAlbumProvider)
 | 
				
			||||||
 | 
					          .where(
 | 
				
			||||||
 | 
					            (album) => album.backupSelection == BackupSelection.selected,
 | 
				
			||||||
 | 
					          )
 | 
				
			||||||
 | 
					          .toList();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (albums.isNotEmpty) {
 | 
				
			||||||
 | 
					        for (var album in albums) {
 | 
				
			||||||
 | 
					          if (album.name == "Recent" || album.name == "Recents") {
 | 
				
			||||||
 | 
					            text += "${album.name} (${'all'.tr()}), ";
 | 
				
			||||||
 | 
					          } else {
 | 
				
			||||||
 | 
					            text += "${album.name}, ";
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        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,
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        return Padding(
 | 
				
			||||||
 | 
					          padding: const EdgeInsets.only(top: 8.0),
 | 
				
			||||||
 | 
					          child: Text(
 | 
				
			||||||
 | 
					            "backup_controller_page_none_selected".tr(),
 | 
				
			||||||
 | 
					            style: context.textTheme.labelLarge?.copyWith(
 | 
				
			||||||
 | 
					              color: context.primaryColor,
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Widget buildExcludedAlbumName() {
 | 
				
			||||||
 | 
					      String text = "backup_controller_page_excluded".tr();
 | 
				
			||||||
 | 
					      final albums = ref
 | 
				
			||||||
 | 
					          .watch(backupAlbumProvider)
 | 
				
			||||||
 | 
					          .where(
 | 
				
			||||||
 | 
					            (album) => album.backupSelection == BackupSelection.excluded,
 | 
				
			||||||
 | 
					          )
 | 
				
			||||||
 | 
					          .toList();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (albums.isNotEmpty) {
 | 
				
			||||||
 | 
					        for (var album in albums) {
 | 
				
			||||||
 | 
					          text += "${album.name}, ";
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        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],
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        return const SizedBox();
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return Card(
 | 
				
			||||||
 | 
					      shape: RoundedRectangleBorder(
 | 
				
			||||||
 | 
					        borderRadius: const BorderRadius.all(Radius.circular(20)),
 | 
				
			||||||
 | 
					        side: BorderSide(
 | 
				
			||||||
 | 
					          color: context.colorScheme.outlineVariant,
 | 
				
			||||||
 | 
					          width: 1,
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      elevation: 0,
 | 
				
			||||||
 | 
					      borderOnForeground: false,
 | 
				
			||||||
 | 
					      child: ListTile(
 | 
				
			||||||
 | 
					        minVerticalPadding: 18,
 | 
				
			||||||
 | 
					        title: Text(
 | 
				
			||||||
 | 
					          "backup_controller_page_albums",
 | 
				
			||||||
 | 
					          style: context.textTheme.titleMedium,
 | 
				
			||||||
 | 
					        ).tr(),
 | 
				
			||||||
 | 
					        subtitle: Padding(
 | 
				
			||||||
 | 
					          padding: const EdgeInsets.only(top: 8.0),
 | 
				
			||||||
 | 
					          child: Column(
 | 
				
			||||||
 | 
					            crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					            children: [
 | 
				
			||||||
 | 
					              Text(
 | 
				
			||||||
 | 
					                "backup_controller_page_to_backup",
 | 
				
			||||||
 | 
					                style: context.textTheme.bodyMedium?.copyWith(
 | 
				
			||||||
 | 
					                  color: context.colorScheme.onSurfaceSecondary,
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              ).tr(),
 | 
				
			||||||
 | 
					              buildSelectedAlbumName(),
 | 
				
			||||||
 | 
					              buildExcludedAlbumName(),
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        trailing: ElevatedButton(
 | 
				
			||||||
 | 
					          onPressed: () async {
 | 
				
			||||||
 | 
					            await context.pushRoute(const DriftBackupAlbumSelectionRoute());
 | 
				
			||||||
 | 
					            ref.read(driftBackupProvider.notifier).getBackupStatus();
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          child: const Text(
 | 
				
			||||||
 | 
					            "select",
 | 
				
			||||||
 | 
					            style: TextStyle(
 | 
				
			||||||
 | 
					              fontWeight: FontWeight.bold,
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ).tr(),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _TotalCard extends ConsumerWidget {
 | 
				
			||||||
 | 
					  const _TotalCard();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context, WidgetRef ref) {
 | 
				
			||||||
 | 
					    final totalCount =
 | 
				
			||||||
 | 
					        ref.watch(driftBackupProvider.select((p) => p.totalCount));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return BackupInfoCard(
 | 
				
			||||||
 | 
					      title: "total".tr(),
 | 
				
			||||||
 | 
					      subtitle: "backup_controller_page_total_sub".tr(),
 | 
				
			||||||
 | 
					      info: totalCount.toString(),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _BackupCard extends ConsumerWidget {
 | 
				
			||||||
 | 
					  const _BackupCard();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context, WidgetRef ref) {
 | 
				
			||||||
 | 
					    final backupCount =
 | 
				
			||||||
 | 
					        ref.watch(driftBackupProvider.select((p) => p.backupCount));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return BackupInfoCard(
 | 
				
			||||||
 | 
					      title: "backup_controller_page_backup".tr(),
 | 
				
			||||||
 | 
					      subtitle: "backup_controller_page_backup_sub".tr(),
 | 
				
			||||||
 | 
					      info: backupCount.toString(),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _RemainderCard extends ConsumerWidget {
 | 
				
			||||||
 | 
					  const _RemainderCard();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context, WidgetRef ref) {
 | 
				
			||||||
 | 
					    final remainderCount =
 | 
				
			||||||
 | 
					        ref.watch(driftBackupProvider.select((p) => p.remainderCount));
 | 
				
			||||||
 | 
					    return BackupInfoCard(
 | 
				
			||||||
 | 
					      title: "backup_controller_page_remainder".tr(),
 | 
				
			||||||
 | 
					      subtitle: "backup_controller_page_remainder_sub".tr(),
 | 
				
			||||||
 | 
					      info: remainderCount.toString(),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										307
									
								
								mobile/lib/pages/backup/drift_backup_album_selection.page.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										307
									
								
								mobile/lib/pages/backup/drift_backup_album_selection.page.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,307 @@
 | 
				
			|||||||
 | 
					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/domain/models/album/local_album.model.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import 'package:immich_mobile/extensions/build_context_extensions.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/providers/album/album.provider.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/providers/backup/backup.provider.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/services/app_settings.service.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/widgets/backup/drift_album_info_list_tile.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@RoutePage()
 | 
				
			||||||
 | 
					class DriftBackupAlbumSelectionPage extends HookConsumerWidget {
 | 
				
			||||||
 | 
					  const DriftBackupAlbumSelectionPage({super.key});
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context, WidgetRef ref) {
 | 
				
			||||||
 | 
					    final albums = ref.watch(backupAlbumProvider);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final selectedBackupAlbums = albums
 | 
				
			||||||
 | 
					        .where((album) => album.backupSelection == BackupSelection.selected)
 | 
				
			||||||
 | 
					        .toList();
 | 
				
			||||||
 | 
					    final excludedBackupAlbums = albums
 | 
				
			||||||
 | 
					        .where((album) => album.backupSelection == BackupSelection.excluded)
 | 
				
			||||||
 | 
					        .toList();
 | 
				
			||||||
 | 
					    final enableSyncUploadAlbum =
 | 
				
			||||||
 | 
					        useAppSettingsState(AppSettingsEnum.syncAlbums);
 | 
				
			||||||
 | 
					    final isDarkTheme = context.isDarkTheme;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    useEffect(
 | 
				
			||||||
 | 
					      () {
 | 
				
			||||||
 | 
					        ref.watch(backupProvider.notifier).getBackupInfo();
 | 
				
			||||||
 | 
					        ref.watch(backupAlbumProvider.notifier).getAll();
 | 
				
			||||||
 | 
					        return null;
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      [],
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    buildAlbumSelectionList() {
 | 
				
			||||||
 | 
					      if (albums.isEmpty) {
 | 
				
			||||||
 | 
					        return const SliverToBoxAdapter(
 | 
				
			||||||
 | 
					          child: Center(
 | 
				
			||||||
 | 
					            child: CircularProgressIndicator(),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      return SliverPadding(
 | 
				
			||||||
 | 
					        padding: const EdgeInsets.symmetric(vertical: 12.0),
 | 
				
			||||||
 | 
					        sliver: SliverList(
 | 
				
			||||||
 | 
					          delegate: SliverChildBuilderDelegate(
 | 
				
			||||||
 | 
					            ((context, index) {
 | 
				
			||||||
 | 
					              return DriftAlbumInfoListTile(
 | 
				
			||||||
 | 
					                album: albums[index],
 | 
				
			||||||
 | 
					              );
 | 
				
			||||||
 | 
					            }),
 | 
				
			||||||
 | 
					            childCount: albums.length,
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    buildAlbumSelectionGrid() {
 | 
				
			||||||
 | 
					      if (albums.isEmpty) {
 | 
				
			||||||
 | 
					        return const SliverToBoxAdapter(
 | 
				
			||||||
 | 
					          child: Center(
 | 
				
			||||||
 | 
					            child: CircularProgressIndicator(),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      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) {
 | 
				
			||||||
 | 
					            return DriftAlbumInfoListTile(
 | 
				
			||||||
 | 
					              album: albums[index],
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					          }),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    buildSelectedAlbumNameChip() {
 | 
				
			||||||
 | 
					      return selectedBackupAlbums.map((album) {
 | 
				
			||||||
 | 
					        void removeSelection() {
 | 
				
			||||||
 | 
					          ref.read(backupAlbumProvider.notifier).deselectAlbum(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.read(backupAlbumProvider.notifier).deselectAlbum(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: context.scaffoldBackgroundColor,
 | 
				
			||||||
 | 
					                  fontWeight: FontWeight.bold,
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					              backgroundColor: Colors.red[300],
 | 
				
			||||||
 | 
					              deleteIconColor: context.scaffoldBackgroundColor,
 | 
				
			||||||
 | 
					              deleteIcon: const Icon(
 | 
				
			||||||
 | 
					                Icons.cancel_rounded,
 | 
				
			||||||
 | 
					                size: 15,
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					              onDeleted: removeSelection,
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      }).toSet();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    handleSyncAlbumToggle(bool isEnable) async {
 | 
				
			||||||
 | 
					      if (isEnable) {
 | 
				
			||||||
 | 
					        await ref.read(albumProvider.notifier).refreshRemoteAlbums();
 | 
				
			||||||
 | 
					        for (final album in selectedBackupAlbums) {
 | 
				
			||||||
 | 
					          await ref.read(albumProvider.notifier).createSyncAlbum(album.name);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return Scaffold(
 | 
				
			||||||
 | 
					      appBar: AppBar(
 | 
				
			||||||
 | 
					        leading: IconButton(
 | 
				
			||||||
 | 
					          onPressed: () => context.maybePop(),
 | 
				
			||||||
 | 
					          icon: const Icon(Icons.arrow_back_ios_rounded),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        title: const Text(
 | 
				
			||||||
 | 
					          "backup_album_selection_page_select_albums",
 | 
				
			||||||
 | 
					        ).tr(),
 | 
				
			||||||
 | 
					        elevation: 0,
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      body: SafeArea(
 | 
				
			||||||
 | 
					        child: CustomScrollView(
 | 
				
			||||||
 | 
					          physics: const ClampingScrollPhysics(),
 | 
				
			||||||
 | 
					          slivers: [
 | 
				
			||||||
 | 
					            SliverToBoxAdapter(
 | 
				
			||||||
 | 
					              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(),
 | 
				
			||||||
 | 
					                      ],
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                  SettingsSwitchListTile(
 | 
				
			||||||
 | 
					                    valueNotifier: enableSyncUploadAlbum,
 | 
				
			||||||
 | 
					                    title: "sync_albums".tr(),
 | 
				
			||||||
 | 
					                    subtitle: "sync_upload_album_setting_subtitle".tr(),
 | 
				
			||||||
 | 
					                    contentPadding: const EdgeInsets.symmetric(horizontal: 16),
 | 
				
			||||||
 | 
					                    titleStyle: context.textTheme.bodyLarge?.copyWith(
 | 
				
			||||||
 | 
					                      fontWeight: FontWeight.bold,
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                    subtitleStyle: context.textTheme.labelLarge?.copyWith(
 | 
				
			||||||
 | 
					                      color: context.colorScheme.primary,
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                    onChanged: handleSyncAlbumToggle,
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                  ListTile(
 | 
				
			||||||
 | 
					                    title: Text(
 | 
				
			||||||
 | 
					                      "backup_album_selection_page_albums_device".tr(
 | 
				
			||||||
 | 
					                        namedArgs: {
 | 
				
			||||||
 | 
					                          'count': 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: 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(),
 | 
				
			||||||
 | 
					                                  ],
 | 
				
			||||||
 | 
					                                ),
 | 
				
			||||||
 | 
					                              ),
 | 
				
			||||||
 | 
					                            );
 | 
				
			||||||
 | 
					                          },
 | 
				
			||||||
 | 
					                        );
 | 
				
			||||||
 | 
					                      },
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                  // buildSearchBar(),
 | 
				
			||||||
 | 
					                ],
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            SliverLayoutBuilder(
 | 
				
			||||||
 | 
					              builder: (context, constraints) {
 | 
				
			||||||
 | 
					                if (constraints.crossAxisExtent > 600) {
 | 
				
			||||||
 | 
					                  return buildAlbumSelectionGrid();
 | 
				
			||||||
 | 
					                } else {
 | 
				
			||||||
 | 
					                  return buildAlbumSelectionList();
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					              },
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ],
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -8,6 +8,7 @@ import 'package:immich_mobile/providers/asset.provider.dart';
 | 
				
			|||||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
 | 
					import 'package:immich_mobile/providers/background_sync.provider.dart';
 | 
				
			||||||
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
 | 
					import 'package:immich_mobile/providers/gallery_permission.provider.dart';
 | 
				
			||||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
 | 
					import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/providers/websocket.provider.dart';
 | 
				
			||||||
import 'package:immich_mobile/routing/router.dart';
 | 
					import 'package:immich_mobile/routing/router.dart';
 | 
				
			||||||
import 'package:immich_mobile/utils/migration.dart';
 | 
					import 'package:immich_mobile/utils/migration.dart';
 | 
				
			||||||
import 'package:permission_handler/permission_handler.dart';
 | 
					import 'package:permission_handler/permission_handler.dart';
 | 
				
			||||||
@ -42,6 +43,9 @@ class _ChangeExperiencePageState extends ConsumerState<ChangeExperiencePage> {
 | 
				
			|||||||
        albumNotifier.dispose();
 | 
					        albumNotifier.dispose();
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      ref.read(websocketProvider.notifier).stopListenToOldEvents();
 | 
				
			||||||
 | 
					      ref.read(websocketProvider.notifier).startListeningToBetaEvents();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      final permission = await ref
 | 
					      final permission = await ref
 | 
				
			||||||
          .read(galleryPermissionNotifier.notifier)
 | 
					          .read(galleryPermissionNotifier.notifier)
 | 
				
			||||||
          .requestGalleryPermission();
 | 
					          .requestGalleryPermission();
 | 
				
			||||||
@ -55,6 +59,8 @@ class _ChangeExperiencePageState extends ConsumerState<ChangeExperiencePage> {
 | 
				
			|||||||
      }
 | 
					      }
 | 
				
			||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
      await ref.read(backgroundSyncProvider).cancel();
 | 
					      await ref.read(backgroundSyncProvider).cancel();
 | 
				
			||||||
 | 
					      ref.read(websocketProvider.notifier).stopListeningToBetaEvents();
 | 
				
			||||||
 | 
					      ref.read(websocketProvider.notifier).startListeningToOldEvents();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (mounted) {
 | 
					    if (mounted) {
 | 
				
			||||||
 | 
				
			|||||||
@ -268,7 +268,7 @@ class UploadStatusIcon extends StatelessWidget {
 | 
				
			|||||||
          color: Colors.red,
 | 
					          color: Colors.red,
 | 
				
			||||||
          semanticLabel: 'canceled'.tr(),
 | 
					          semanticLabel: 'canceled'.tr(),
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
      UploadStatus.waitingtoRetry || UploadStatus.paused => Icon(
 | 
					      UploadStatus.waitingToRetry || UploadStatus.paused => Icon(
 | 
				
			||||||
          Icons.pause_circle_rounded,
 | 
					          Icons.pause_circle_rounded,
 | 
				
			||||||
          color: context.primaryColor,
 | 
					          color: context.primaryColor,
 | 
				
			||||||
          semanticLabel: 'paused'.tr(),
 | 
					          semanticLabel: 'paused'.tr(),
 | 
				
			||||||
 | 
				
			|||||||
@ -91,6 +91,10 @@ final _features = [
 | 
				
			|||||||
  ),
 | 
					  ),
 | 
				
			||||||
  _Feature(
 | 
					  _Feature(
 | 
				
			||||||
    name: 'Clear Local Data',
 | 
					    name: 'Clear Local Data',
 | 
				
			||||||
 | 
					    style: const TextStyle(
 | 
				
			||||||
 | 
					      color: Colors.orange,
 | 
				
			||||||
 | 
					      fontWeight: FontWeight.bold,
 | 
				
			||||||
 | 
					    ),
 | 
				
			||||||
    icon: Icons.delete_forever_rounded,
 | 
					    icon: Icons.delete_forever_rounded,
 | 
				
			||||||
    onTap: (_, ref) async {
 | 
					    onTap: (_, ref) async {
 | 
				
			||||||
      final db = ref.read(driftProvider);
 | 
					      final db = ref.read(driftProvider);
 | 
				
			||||||
@ -101,6 +105,10 @@ final _features = [
 | 
				
			|||||||
  ),
 | 
					  ),
 | 
				
			||||||
  _Feature(
 | 
					  _Feature(
 | 
				
			||||||
    name: 'Clear Remote Data',
 | 
					    name: 'Clear Remote Data',
 | 
				
			||||||
 | 
					    style: const TextStyle(
 | 
				
			||||||
 | 
					      color: Colors.orange,
 | 
				
			||||||
 | 
					      fontWeight: FontWeight.bold,
 | 
				
			||||||
 | 
					    ),
 | 
				
			||||||
    icon: Icons.delete_sweep_rounded,
 | 
					    icon: Icons.delete_sweep_rounded,
 | 
				
			||||||
    onTap: (_, ref) async {
 | 
					    onTap: (_, ref) async {
 | 
				
			||||||
      final db = ref.read(driftProvider);
 | 
					      final db = ref.read(driftProvider);
 | 
				
			||||||
@ -117,17 +125,29 @@ final _features = [
 | 
				
			|||||||
  ),
 | 
					  ),
 | 
				
			||||||
  _Feature(
 | 
					  _Feature(
 | 
				
			||||||
    name: 'Local Media Summary',
 | 
					    name: 'Local Media Summary',
 | 
				
			||||||
 | 
					    style: const TextStyle(
 | 
				
			||||||
 | 
					      color: Colors.indigo,
 | 
				
			||||||
 | 
					      fontWeight: FontWeight.bold,
 | 
				
			||||||
 | 
					    ),
 | 
				
			||||||
    icon: Icons.table_chart_rounded,
 | 
					    icon: Icons.table_chart_rounded,
 | 
				
			||||||
    onTap: (ctx, _) => ctx.pushRoute(const LocalMediaSummaryRoute()),
 | 
					    onTap: (ctx, _) => ctx.pushRoute(const LocalMediaSummaryRoute()),
 | 
				
			||||||
  ),
 | 
					  ),
 | 
				
			||||||
  _Feature(
 | 
					  _Feature(
 | 
				
			||||||
    name: 'Remote Media Summary',
 | 
					    name: 'Remote Media Summary',
 | 
				
			||||||
 | 
					    style: const TextStyle(
 | 
				
			||||||
 | 
					      color: Colors.indigo,
 | 
				
			||||||
 | 
					      fontWeight: FontWeight.bold,
 | 
				
			||||||
 | 
					    ),
 | 
				
			||||||
    icon: Icons.summarize_rounded,
 | 
					    icon: Icons.summarize_rounded,
 | 
				
			||||||
    onTap: (ctx, _) => ctx.pushRoute(const RemoteMediaSummaryRoute()),
 | 
					    onTap: (ctx, _) => ctx.pushRoute(const RemoteMediaSummaryRoute()),
 | 
				
			||||||
  ),
 | 
					  ),
 | 
				
			||||||
  _Feature(
 | 
					  _Feature(
 | 
				
			||||||
    name: 'Reset Sqlite',
 | 
					    name: 'Reset Sqlite',
 | 
				
			||||||
    icon: Icons.table_view_rounded,
 | 
					    icon: Icons.table_view_rounded,
 | 
				
			||||||
 | 
					    style: const TextStyle(
 | 
				
			||||||
 | 
					      color: Colors.red,
 | 
				
			||||||
 | 
					      fontWeight: FontWeight.bold,
 | 
				
			||||||
 | 
					    ),
 | 
				
			||||||
    onTap: (_, ref) async {
 | 
					    onTap: (_, ref) async {
 | 
				
			||||||
      final drift = ref.read(driftProvider);
 | 
					      final drift = ref.read(driftProvider);
 | 
				
			||||||
      // ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member
 | 
					      // ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member
 | 
				
			||||||
@ -160,7 +180,10 @@ class FeatInDevPage extends StatelessWidget {
 | 
				
			|||||||
                final feat = _features[index];
 | 
					                final feat = _features[index];
 | 
				
			||||||
                return Consumer(
 | 
					                return Consumer(
 | 
				
			||||||
                  builder: (ctx, ref, _) => ListTile(
 | 
					                  builder: (ctx, ref, _) => ListTile(
 | 
				
			||||||
                    title: Text(feat.name),
 | 
					                    title: Text(
 | 
				
			||||||
 | 
					                      feat.name,
 | 
				
			||||||
 | 
					                      style: feat.style,
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
                    trailing: Icon(feat.icon),
 | 
					                    trailing: Icon(feat.icon),
 | 
				
			||||||
                    visualDensity: VisualDensity.compact,
 | 
					                    visualDensity: VisualDensity.compact,
 | 
				
			||||||
                    onTap: () => unawaited(feat.onTap(ctx, ref)),
 | 
					                    onTap: () => unawaited(feat.onTap(ctx, ref)),
 | 
				
			||||||
@ -183,10 +206,12 @@ class _Feature {
 | 
				
			|||||||
    required this.name,
 | 
					    required this.name,
 | 
				
			||||||
    required this.icon,
 | 
					    required this.icon,
 | 
				
			||||||
    required this.onTap,
 | 
					    required this.onTap,
 | 
				
			||||||
 | 
					    this.style,
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  final String name;
 | 
					  final String name;
 | 
				
			||||||
  final IconData icon;
 | 
					  final IconData icon;
 | 
				
			||||||
 | 
					  final TextStyle? style;
 | 
				
			||||||
  final Future<void> Function(BuildContext, WidgetRef _) onTap;
 | 
					  final Future<void> Function(BuildContext, WidgetRef _) onTap;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -69,6 +69,9 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
 | 
				
			|||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      await _ref.read(serverInfoProvider.notifier).getServerVersion();
 | 
					      await _ref.read(serverInfoProvider.notifier).getServerVersion();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // TODO: Need to decide on how we want to handle uploads once the app is resumed
 | 
				
			||||||
 | 
					      // await FileDownloader().start();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (!Store.isBetaTimelineEnabled) {
 | 
					    if (!Store.isBetaTimelineEnabled) {
 | 
				
			||||||
 | 
				
			|||||||
@ -1,7 +1,6 @@
 | 
				
			|||||||
import 'dart:io';
 | 
					import 'dart:io';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import 'package:background_downloader/background_downloader.dart';
 | 
					import 'package:background_downloader/background_downloader.dart';
 | 
				
			||||||
import 'package:flutter/foundation.dart';
 | 
					 | 
				
			||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
					import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
				
			||||||
import 'package:immich_mobile/constants/constants.dart';
 | 
					import 'package:immich_mobile/constants/constants.dart';
 | 
				
			||||||
import 'package:immich_mobile/extensions/string_extensions.dart';
 | 
					import 'package:immich_mobile/extensions/string_extensions.dart';
 | 
				
			||||||
@ -30,7 +29,7 @@ class ShareIntentUploadStateNotifier
 | 
				
			|||||||
    this._uploadService,
 | 
					    this._uploadService,
 | 
				
			||||||
    this._shareIntentService,
 | 
					    this._shareIntentService,
 | 
				
			||||||
  ) : super([]) {
 | 
					  ) : super([]) {
 | 
				
			||||||
    _uploadService.onUploadStatus = _uploadStatusCallback;
 | 
					    _uploadService.onUploadStatus = _updateUploadStatus;
 | 
				
			||||||
    _uploadService.onTaskProgress = _taskProgressCallback;
 | 
					    _uploadService.onTaskProgress = _taskProgressCallback;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -69,8 +68,8 @@ class ShareIntentUploadStateNotifier
 | 
				
			|||||||
    state = [];
 | 
					    state = [];
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  void _updateUploadStatus(TaskStatusUpdate task, TaskStatus status) async {
 | 
					  void _updateUploadStatus(TaskStatusUpdate task) async {
 | 
				
			||||||
    if (status == TaskStatus.canceled) {
 | 
					    if (task.status == TaskStatus.canceled) {
 | 
				
			||||||
      return;
 | 
					      return;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -83,7 +82,7 @@ class ShareIntentUploadStateNotifier
 | 
				
			|||||||
      TaskStatus.running => UploadStatus.running,
 | 
					      TaskStatus.running => UploadStatus.running,
 | 
				
			||||||
      TaskStatus.paused => UploadStatus.paused,
 | 
					      TaskStatus.paused => UploadStatus.paused,
 | 
				
			||||||
      TaskStatus.notFound => UploadStatus.notFound,
 | 
					      TaskStatus.notFound => UploadStatus.notFound,
 | 
				
			||||||
      TaskStatus.waitingToRetry => UploadStatus.waitingtoRetry
 | 
					      TaskStatus.waitingToRetry => UploadStatus.waitingToRetry
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    state = [
 | 
					    state = [
 | 
				
			||||||
@ -95,27 +94,6 @@ class ShareIntentUploadStateNotifier
 | 
				
			|||||||
    ];
 | 
					    ];
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  void _uploadStatusCallback(TaskStatusUpdate update) {
 | 
					 | 
				
			||||||
    _updateUploadStatus(update, update.status);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    switch (update.status) {
 | 
					 | 
				
			||||||
      case TaskStatus.complete:
 | 
					 | 
				
			||||||
        if (update.responseStatusCode == 200) {
 | 
					 | 
				
			||||||
          if (kDebugMode) {
 | 
					 | 
				
			||||||
            debugPrint("[COMPLETE] ${update.task.taskId} - DUPLICATE");
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
        } else {
 | 
					 | 
				
			||||||
          if (kDebugMode) {
 | 
					 | 
				
			||||||
            debugPrint("[COMPLETE] ${update.task.taskId}");
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        break;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      default:
 | 
					 | 
				
			||||||
        break;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  void _taskProgressCallback(TaskProgressUpdate update) {
 | 
					  void _taskProgressCallback(TaskProgressUpdate update) {
 | 
				
			||||||
    // Ignore if the task is canceled or completed
 | 
					    // Ignore if the task is canceled or completed
 | 
				
			||||||
    if (update.progress == downloadFailed ||
 | 
					    if (update.progress == downloadFailed ||
 | 
				
			||||||
@ -134,10 +112,6 @@ class ShareIntentUploadStateNotifier
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<void> upload(File file) {
 | 
					  Future<void> upload(File file) {
 | 
				
			||||||
    return _uploadService.upload(file);
 | 
					    return _uploadService.buildUploadTask(file, group: kManualUploadGroup);
 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  Future<bool> cancelUpload(String id) {
 | 
					 | 
				
			||||||
    return _uploadService.cancelUpload(id);
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										62
									
								
								mobile/lib/providers/backup/backup_album.provider.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								mobile/lib/providers/backup/backup_album.provider.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,62 @@
 | 
				
			|||||||
 | 
					import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/domain/models/album/local_album.model.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/domain/services/local_album.service.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					final backupAlbumProvider =
 | 
				
			||||||
 | 
					    StateNotifierProvider<BackupAlbumNotifier, List<LocalAlbum>>(
 | 
				
			||||||
 | 
					  (ref) => BackupAlbumNotifier(
 | 
				
			||||||
 | 
					    ref.watch(localAlbumServiceProvider),
 | 
				
			||||||
 | 
					  ),
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class BackupAlbumNotifier extends StateNotifier<List<LocalAlbum>> {
 | 
				
			||||||
 | 
					  BackupAlbumNotifier(this._localAlbumService) : super([]) {
 | 
				
			||||||
 | 
					    getAll();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  final LocalAlbumService _localAlbumService;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> getAll() async {
 | 
				
			||||||
 | 
					    state = await _localAlbumService.getAll();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> selectAlbum(LocalAlbum album) async {
 | 
				
			||||||
 | 
					    album = album.copyWith(backupSelection: BackupSelection.selected);
 | 
				
			||||||
 | 
					    await _localAlbumService.update(album);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    state = state
 | 
				
			||||||
 | 
					        .map(
 | 
				
			||||||
 | 
					          (currentAlbum) => currentAlbum.id == album.id
 | 
				
			||||||
 | 
					              ? currentAlbum.copyWith(backupSelection: BackupSelection.selected)
 | 
				
			||||||
 | 
					              : currentAlbum,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        .toList();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> deselectAlbum(LocalAlbum album) async {
 | 
				
			||||||
 | 
					    album = album.copyWith(backupSelection: BackupSelection.none);
 | 
				
			||||||
 | 
					    await _localAlbumService.update(album);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    state = state
 | 
				
			||||||
 | 
					        .map(
 | 
				
			||||||
 | 
					          (currentAlbum) => currentAlbum.id == album.id
 | 
				
			||||||
 | 
					              ? currentAlbum.copyWith(backupSelection: BackupSelection.none)
 | 
				
			||||||
 | 
					              : currentAlbum,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        .toList();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> excludeAlbum(LocalAlbum album) async {
 | 
				
			||||||
 | 
					    album = album.copyWith(backupSelection: BackupSelection.excluded);
 | 
				
			||||||
 | 
					    await _localAlbumService.update(album);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    state = state
 | 
				
			||||||
 | 
					        .map(
 | 
				
			||||||
 | 
					          (currentAlbum) => currentAlbum.id == album.id
 | 
				
			||||||
 | 
					              ? currentAlbum.copyWith(backupSelection: BackupSelection.excluded)
 | 
				
			||||||
 | 
					              : currentAlbum,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        .toList();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										194
									
								
								mobile/lib/providers/backup/drift_backup.provider.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										194
									
								
								mobile/lib/providers/backup/drift_backup.provider.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,194 @@
 | 
				
			|||||||
 | 
					import 'dart:async';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import 'package:background_downloader/background_downloader.dart';
 | 
				
			||||||
 | 
					import 'package:collection/collection.dart';
 | 
				
			||||||
 | 
					import 'package:flutter/widgets.dart';
 | 
				
			||||||
 | 
					import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/constants/constants.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/services/drift_backup.service.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/services/upload.service.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class DriftUploadStatus {
 | 
				
			||||||
 | 
					  final String taskId;
 | 
				
			||||||
 | 
					  final String filename;
 | 
				
			||||||
 | 
					  final double progress;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const DriftUploadStatus({
 | 
				
			||||||
 | 
					    required this.taskId,
 | 
				
			||||||
 | 
					    required this.filename,
 | 
				
			||||||
 | 
					    required this.progress,
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  DriftUploadStatus copyWith({
 | 
				
			||||||
 | 
					    String? taskId,
 | 
				
			||||||
 | 
					    String? filename,
 | 
				
			||||||
 | 
					    double? progress,
 | 
				
			||||||
 | 
					  }) {
 | 
				
			||||||
 | 
					    return DriftUploadStatus(
 | 
				
			||||||
 | 
					      taskId: taskId ?? this.taskId,
 | 
				
			||||||
 | 
					      filename: filename ?? this.filename,
 | 
				
			||||||
 | 
					      progress: progress ?? this.progress,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  String toString() =>
 | 
				
			||||||
 | 
					      'ExpUploadStatus(taskId: $taskId, filename: $filename, progress: $progress)';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  bool operator ==(covariant DriftUploadStatus other) {
 | 
				
			||||||
 | 
					    if (identical(this, other)) return true;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return other.taskId == taskId &&
 | 
				
			||||||
 | 
					        other.filename == filename &&
 | 
				
			||||||
 | 
					        other.progress == progress;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  int get hashCode => taskId.hashCode ^ filename.hashCode ^ progress.hashCode;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class DriftBackupState {
 | 
				
			||||||
 | 
					  final int totalCount;
 | 
				
			||||||
 | 
					  final int backupCount;
 | 
				
			||||||
 | 
					  final int remainderCount;
 | 
				
			||||||
 | 
					  final Map<String, DriftUploadStatus> uploadItems;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const DriftBackupState({
 | 
				
			||||||
 | 
					    required this.totalCount,
 | 
				
			||||||
 | 
					    required this.backupCount,
 | 
				
			||||||
 | 
					    required this.remainderCount,
 | 
				
			||||||
 | 
					    required this.uploadItems,
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  DriftBackupState copyWith({
 | 
				
			||||||
 | 
					    int? totalCount,
 | 
				
			||||||
 | 
					    int? backupCount,
 | 
				
			||||||
 | 
					    int? remainderCount,
 | 
				
			||||||
 | 
					    Map<String, DriftUploadStatus>? uploadItems,
 | 
				
			||||||
 | 
					  }) {
 | 
				
			||||||
 | 
					    return DriftBackupState(
 | 
				
			||||||
 | 
					      totalCount: totalCount ?? this.totalCount,
 | 
				
			||||||
 | 
					      backupCount: backupCount ?? this.backupCount,
 | 
				
			||||||
 | 
					      remainderCount: remainderCount ?? this.remainderCount,
 | 
				
			||||||
 | 
					      uploadItems: uploadItems ?? this.uploadItems,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  String toString() {
 | 
				
			||||||
 | 
					    return 'ExpBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, uploadItems: $uploadItems)';
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  bool operator ==(covariant DriftBackupState other) {
 | 
				
			||||||
 | 
					    if (identical(this, other)) return true;
 | 
				
			||||||
 | 
					    final mapEquals = const DeepCollectionEquality().equals;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return other.totalCount == totalCount &&
 | 
				
			||||||
 | 
					        other.backupCount == backupCount &&
 | 
				
			||||||
 | 
					        other.remainderCount == remainderCount &&
 | 
				
			||||||
 | 
					        mapEquals(other.uploadItems, uploadItems);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  int get hashCode {
 | 
				
			||||||
 | 
					    return totalCount.hashCode ^
 | 
				
			||||||
 | 
					        backupCount.hashCode ^
 | 
				
			||||||
 | 
					        remainderCount.hashCode ^
 | 
				
			||||||
 | 
					        uploadItems.hashCode;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					final driftBackupProvider =
 | 
				
			||||||
 | 
					    StateNotifierProvider<ExpBackupNotifier, DriftBackupState>((ref) {
 | 
				
			||||||
 | 
					  return ExpBackupNotifier(
 | 
				
			||||||
 | 
					    ref.watch(driftBackupServiceProvider),
 | 
				
			||||||
 | 
					    ref.watch(uploadServiceProvider),
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ExpBackupNotifier extends StateNotifier<DriftBackupState> {
 | 
				
			||||||
 | 
					  ExpBackupNotifier(
 | 
				
			||||||
 | 
					    this._backupService,
 | 
				
			||||||
 | 
					    this._uploadService,
 | 
				
			||||||
 | 
					  ) : super(
 | 
				
			||||||
 | 
					          const DriftBackupState(
 | 
				
			||||||
 | 
					            totalCount: 0,
 | 
				
			||||||
 | 
					            backupCount: 0,
 | 
				
			||||||
 | 
					            remainderCount: 0,
 | 
				
			||||||
 | 
					            uploadItems: {},
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ) {
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					      _uploadService.taskStatusStream.listen(_handleTaskStatusUpdate);
 | 
				
			||||||
 | 
					      _uploadService.taskProgressStream.listen(_handleTaskProgressUpdate);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  final DriftBackupService _backupService;
 | 
				
			||||||
 | 
					  final UploadService _uploadService;
 | 
				
			||||||
 | 
					  StreamSubscription<TaskStatusUpdate>? _statusSubscription;
 | 
				
			||||||
 | 
					  StreamSubscription<TaskProgressUpdate>? _progressSubscription;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _handleTaskStatusUpdate(TaskStatusUpdate update) {
 | 
				
			||||||
 | 
					    switch (update.status) {
 | 
				
			||||||
 | 
					      case TaskStatus.complete:
 | 
				
			||||||
 | 
					        state = state.copyWith(
 | 
				
			||||||
 | 
					          backupCount: state.backupCount + 1,
 | 
				
			||||||
 | 
					          remainderCount: state.remainderCount - 1,
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      default:
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _handleTaskProgressUpdate(TaskProgressUpdate update) {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> getBackupStatus() async {
 | 
				
			||||||
 | 
					    final [totalCount, backupCount, remainderCount] = await Future.wait([
 | 
				
			||||||
 | 
					      _backupService.getTotalCount(),
 | 
				
			||||||
 | 
					      _backupService.getBackupCount(),
 | 
				
			||||||
 | 
					      _backupService.getRemainderCount(),
 | 
				
			||||||
 | 
					    ]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    state = state.copyWith(
 | 
				
			||||||
 | 
					      totalCount: totalCount,
 | 
				
			||||||
 | 
					      backupCount: backupCount,
 | 
				
			||||||
 | 
					      remainderCount: remainderCount,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> backup() {
 | 
				
			||||||
 | 
					    return _backupService.backup();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> cancel() async {
 | 
				
			||||||
 | 
					    await _backupService.cancel();
 | 
				
			||||||
 | 
					    await getDataInfo();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> getDataInfo() async {
 | 
				
			||||||
 | 
					    final a = await FileDownloader().database.allRecordsWithStatus(
 | 
				
			||||||
 | 
					          TaskStatus.enqueued,
 | 
				
			||||||
 | 
					          group: kBackupGroup,
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final b = await FileDownloader().allTasks(
 | 
				
			||||||
 | 
					      group: kBackupGroup,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    debugPrint(
 | 
				
			||||||
 | 
					      "Enqueued tasks: ${a.length}, All tasks: ${b.length}",
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void dispose() {
 | 
				
			||||||
 | 
					    _statusSubscription?.cancel();
 | 
				
			||||||
 | 
					    _progressSubscription?.cancel();
 | 
				
			||||||
 | 
					    super.dispose();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -176,16 +176,14 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
 | 
				
			|||||||
          );
 | 
					          );
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        socket.on('on_upload_success', _handleOnUploadSuccess);
 | 
					        if (!Store.isBetaTimelineEnabled) {
 | 
				
			||||||
 | 
					          startListeningToOldEvents();
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					          startListeningToBetaEvents();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        socket.on('on_config_update', _handleOnConfigUpdate);
 | 
					        socket.on('on_config_update', _handleOnConfigUpdate);
 | 
				
			||||||
        socket.on('on_asset_delete', _handleOnAssetDelete);
 | 
					 | 
				
			||||||
        socket.on('on_asset_trash', _handleOnAssetTrash);
 | 
					 | 
				
			||||||
        socket.on('on_asset_restore', _handleServerUpdates);
 | 
					 | 
				
			||||||
        socket.on('on_asset_update', _handleServerUpdates);
 | 
					 | 
				
			||||||
        socket.on('on_asset_stack_update', _handleServerUpdates);
 | 
					 | 
				
			||||||
        socket.on('on_asset_hidden', _handleOnAssetHidden);
 | 
					 | 
				
			||||||
        socket.on('on_new_release', _handleReleaseUpdates);
 | 
					        socket.on('on_new_release', _handleReleaseUpdates);
 | 
				
			||||||
        socket.on('AssetUploadReadyV1', _handleSyncAssetUploadReady);
 | 
					 | 
				
			||||||
      } catch (e) {
 | 
					      } catch (e) {
 | 
				
			||||||
        debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
 | 
					        debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
@ -213,6 +211,34 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
 | 
				
			|||||||
    state.socket?.off(eventName);
 | 
					    state.socket?.off(eventName);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void stopListenToOldEvents() {
 | 
				
			||||||
 | 
					    state.socket?.off('on_upload_success');
 | 
				
			||||||
 | 
					    state.socket?.off('on_asset_delete');
 | 
				
			||||||
 | 
					    state.socket?.off('on_asset_trash');
 | 
				
			||||||
 | 
					    state.socket?.off('on_asset_restore');
 | 
				
			||||||
 | 
					    state.socket?.off('on_asset_update');
 | 
				
			||||||
 | 
					    state.socket?.off('on_asset_stack_update');
 | 
				
			||||||
 | 
					    state.socket?.off('on_asset_hidden');
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void startListeningToOldEvents() {
 | 
				
			||||||
 | 
					    state.socket?.on('on_upload_success', _handleOnUploadSuccess);
 | 
				
			||||||
 | 
					    state.socket?.on('on_asset_delete', _handleOnAssetDelete);
 | 
				
			||||||
 | 
					    state.socket?.on('on_asset_trash', _handleOnAssetTrash);
 | 
				
			||||||
 | 
					    state.socket?.on('on_asset_restore', _handleServerUpdates);
 | 
				
			||||||
 | 
					    state.socket?.on('on_asset_update', _handleServerUpdates);
 | 
				
			||||||
 | 
					    state.socket?.on('on_asset_stack_update', _handleServerUpdates);
 | 
				
			||||||
 | 
					    state.socket?.on('on_asset_hidden', _handleOnAssetHidden);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void stopListeningToBetaEvents() {
 | 
				
			||||||
 | 
					    state.socket?.off('AssetUploadReadyV1');
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void startListeningToBetaEvents() {
 | 
				
			||||||
 | 
					    state.socket?.on('AssetUploadReadyV1', _handleSyncAssetUploadReady);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  void listenUploadEvent() {
 | 
					  void listenUploadEvent() {
 | 
				
			||||||
    debugPrint("Start listening to event on_upload_success");
 | 
					    debugPrint("Start listening to event on_upload_success");
 | 
				
			||||||
    state.socket?.on('on_upload_success', _handleOnUploadSuccess);
 | 
					    state.socket?.on('on_upload_success', _handleOnUploadSuccess);
 | 
				
			||||||
 | 
				
			|||||||
@ -1,6 +1,6 @@
 | 
				
			|||||||
import 'package:background_downloader/background_downloader.dart';
 | 
					import 'package:background_downloader/background_downloader.dart';
 | 
				
			||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
					import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
				
			||||||
import 'package:immich_mobile/utils/upload.dart';
 | 
					import 'package:immich_mobile/constants/constants.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
final uploadRepositoryProvider = Provider((ref) => UploadRepository());
 | 
					final uploadRepositoryProvider = Provider((ref) => UploadRepository());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -11,25 +11,30 @@ class UploadRepository {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  UploadRepository() {
 | 
					  UploadRepository() {
 | 
				
			||||||
    FileDownloader().registerCallbacks(
 | 
					    FileDownloader().registerCallbacks(
 | 
				
			||||||
      group: uploadGroup,
 | 
					      group: kBackupGroup,
 | 
				
			||||||
 | 
					      taskStatusCallback: (update) => onUploadStatus?.call(update),
 | 
				
			||||||
 | 
					      taskProgressCallback: (update) => onTaskProgress?.call(update),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    FileDownloader().registerCallbacks(
 | 
				
			||||||
 | 
					      group: kBackupLivePhotoGroup,
 | 
				
			||||||
      taskStatusCallback: (update) => onUploadStatus?.call(update),
 | 
					      taskStatusCallback: (update) => onUploadStatus?.call(update),
 | 
				
			||||||
      taskProgressCallback: (update) => onTaskProgress?.call(update),
 | 
					      taskProgressCallback: (update) => onTaskProgress?.call(update),
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<bool> upload(UploadTask task) {
 | 
					  void enqueueAll(List<UploadTask> tasks) {
 | 
				
			||||||
    return FileDownloader().enqueue(task);
 | 
					    FileDownloader().enqueueAll(tasks);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<void> deleteAllTrackingRecords() {
 | 
					  Future<void> deleteAllTrackingRecords(String group) {
 | 
				
			||||||
    return FileDownloader().database.deleteAllRecords();
 | 
					    return FileDownloader().database.deleteAllRecords(group: group);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<bool> cancel(String id) {
 | 
					  Future<bool> cancelAll(String group) {
 | 
				
			||||||
    return FileDownloader().cancelTaskWithId(id);
 | 
					    return FileDownloader().cancelAll(group: group);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<void> deleteRecordsWithIds(List<String> ids) {
 | 
					  Future<int> reset(String group) {
 | 
				
			||||||
    return FileDownloader().database.deleteRecordsWithIds(ids);
 | 
					    return FileDownloader().reset(group: group);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -22,6 +22,8 @@ import 'package:immich_mobile/pages/album/album_shared_user_selection.page.dart'
 | 
				
			|||||||
import 'package:immich_mobile/pages/album/album_viewer.page.dart';
 | 
					import 'package:immich_mobile/pages/album/album_viewer.page.dart';
 | 
				
			||||||
import 'package:immich_mobile/pages/albums/albums.page.dart';
 | 
					import 'package:immich_mobile/pages/albums/albums.page.dart';
 | 
				
			||||||
import 'package:immich_mobile/pages/backup/album_preview.page.dart';
 | 
					import 'package:immich_mobile/pages/backup/album_preview.page.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/pages/backup/drift_backup_album_selection.page.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/pages/backup/drift_backup.page.dart';
 | 
				
			||||||
import 'package:immich_mobile/pages/backup/backup_album_selection.page.dart';
 | 
					import 'package:immich_mobile/pages/backup/backup_album_selection.page.dart';
 | 
				
			||||||
import 'package:immich_mobile/pages/backup/backup_controller.page.dart';
 | 
					import 'package:immich_mobile/pages/backup/backup_controller.page.dart';
 | 
				
			||||||
import 'package:immich_mobile/pages/backup/backup_options.page.dart';
 | 
					import 'package:immich_mobile/pages/backup/backup_options.page.dart';
 | 
				
			||||||
@ -385,6 +387,14 @@ class AppRouter extends RootStackRouter {
 | 
				
			|||||||
      page: RemoteMediaSummaryRoute.page,
 | 
					      page: RemoteMediaSummaryRoute.page,
 | 
				
			||||||
      guards: [_authGuard, _duplicateGuard],
 | 
					      guards: [_authGuard, _duplicateGuard],
 | 
				
			||||||
    ),
 | 
					    ),
 | 
				
			||||||
 | 
					    AutoRoute(
 | 
				
			||||||
 | 
					      page: DriftBackupRoute.page,
 | 
				
			||||||
 | 
					      guards: [_authGuard, _duplicateGuard],
 | 
				
			||||||
 | 
					    ),
 | 
				
			||||||
 | 
					    AutoRoute(
 | 
				
			||||||
 | 
					      page: DriftBackupAlbumSelectionRoute.page,
 | 
				
			||||||
 | 
					      guards: [_authGuard, _duplicateGuard],
 | 
				
			||||||
 | 
					    ),
 | 
				
			||||||
    AutoRoute(
 | 
					    AutoRoute(
 | 
				
			||||||
      page: LocalTimelineRoute.page,
 | 
					      page: LocalTimelineRoute.page,
 | 
				
			||||||
      guards: [_authGuard, _duplicateGuard],
 | 
					      guards: [_authGuard, _duplicateGuard],
 | 
				
			||||||
 | 
				
			|||||||
@ -726,6 +726,38 @@ class DriftAssetSelectionTimelineRouteArgs {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// generated route for
 | 
				
			||||||
 | 
					/// [DriftBackupAlbumSelectionPage]
 | 
				
			||||||
 | 
					class DriftBackupAlbumSelectionRoute extends PageRouteInfo<void> {
 | 
				
			||||||
 | 
					  const DriftBackupAlbumSelectionRoute({List<PageRouteInfo>? children})
 | 
				
			||||||
 | 
					      : super(DriftBackupAlbumSelectionRoute.name, initialChildren: children);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static const String name = 'DriftBackupAlbumSelectionRoute';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static PageInfo page = PageInfo(
 | 
				
			||||||
 | 
					    name,
 | 
				
			||||||
 | 
					    builder: (data) {
 | 
				
			||||||
 | 
					      return const DriftBackupAlbumSelectionPage();
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// generated route for
 | 
				
			||||||
 | 
					/// [DriftBackupPage]
 | 
				
			||||||
 | 
					class DriftBackupRoute extends PageRouteInfo<void> {
 | 
				
			||||||
 | 
					  const DriftBackupRoute({List<PageRouteInfo>? children})
 | 
				
			||||||
 | 
					      : super(DriftBackupRoute.name, initialChildren: children);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static const String name = 'DriftBackupRoute';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static PageInfo page = PageInfo(
 | 
				
			||||||
 | 
					    name,
 | 
				
			||||||
 | 
					    builder: (data) {
 | 
				
			||||||
 | 
					      return const DriftBackupPage();
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/// generated route for
 | 
					/// generated route for
 | 
				
			||||||
/// [DriftCreateAlbumPage]
 | 
					/// [DriftCreateAlbumPage]
 | 
				
			||||||
class DriftCreateAlbumRoute extends PageRouteInfo<void> {
 | 
					class DriftCreateAlbumRoute extends PageRouteInfo<void> {
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										286
									
								
								mobile/lib/services/drift_backup.service.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										286
									
								
								mobile/lib/services/drift_backup.service.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,286 @@
 | 
				
			|||||||
 | 
					import 'dart:async';
 | 
				
			||||||
 | 
					import 'dart:convert';
 | 
				
			||||||
 | 
					import 'dart:io';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import 'package:background_downloader/background_downloader.dart';
 | 
				
			||||||
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/constants/constants.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/services/upload.service.dart';
 | 
				
			||||||
 | 
					import 'package:logging/logging.dart';
 | 
				
			||||||
 | 
					import 'package:path/path.dart' as p;
 | 
				
			||||||
 | 
					import 'package:riverpod_annotation/riverpod_annotation.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					final driftBackupServiceProvider = Provider<DriftBackupService>(
 | 
				
			||||||
 | 
					  (ref) => DriftBackupService(
 | 
				
			||||||
 | 
					    ref.watch(backupRepositoryProvider),
 | 
				
			||||||
 | 
					    ref.watch(storageRepositoryProvider),
 | 
				
			||||||
 | 
					    ref.watch(uploadServiceProvider),
 | 
				
			||||||
 | 
					    ref.watch(localAssetRepository),
 | 
				
			||||||
 | 
					  ),
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class DriftBackupService {
 | 
				
			||||||
 | 
					  DriftBackupService(
 | 
				
			||||||
 | 
					    this._backupRepository,
 | 
				
			||||||
 | 
					    this._storageRepository,
 | 
				
			||||||
 | 
					    this._uploadService,
 | 
				
			||||||
 | 
					    this._localAssetRepository,
 | 
				
			||||||
 | 
					  ) {
 | 
				
			||||||
 | 
					    _uploadService.taskStatusStream.listen(_handleTaskStatusUpdate);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  final DriftBackupRepository _backupRepository;
 | 
				
			||||||
 | 
					  final StorageRepository _storageRepository;
 | 
				
			||||||
 | 
					  final DriftLocalAssetRepository _localAssetRepository;
 | 
				
			||||||
 | 
					  final UploadService _uploadService;
 | 
				
			||||||
 | 
					  final _log = Logger("DriftBackupService");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  bool shouldCancel = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<int> getTotalCount() {
 | 
				
			||||||
 | 
					    return _backupRepository.getTotalCount();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<int> getRemainderCount() {
 | 
				
			||||||
 | 
					    return _backupRepository.getRemainderCount();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<int> getBackupCount() {
 | 
				
			||||||
 | 
					    return _backupRepository.getBackupCount();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> backup() async {
 | 
				
			||||||
 | 
					    shouldCancel = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final candidates = await _backupRepository.getCandidates();
 | 
				
			||||||
 | 
					    if (candidates.isEmpty) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const batchSize = 100;
 | 
				
			||||||
 | 
					    int count = 0;
 | 
				
			||||||
 | 
					    for (int i = 0; i < candidates.length; i += batchSize) {
 | 
				
			||||||
 | 
					      if (shouldCancel) {
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      final batch = candidates.skip(i).take(batchSize).toList();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      List<UploadTask> tasks = [];
 | 
				
			||||||
 | 
					      for (final asset in batch) {
 | 
				
			||||||
 | 
					        final task = await _getUploadTask(asset);
 | 
				
			||||||
 | 
					        if (task != null) {
 | 
				
			||||||
 | 
					          tasks.add(task);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (tasks.isNotEmpty && !shouldCancel) {
 | 
				
			||||||
 | 
					        count += tasks.length;
 | 
				
			||||||
 | 
					        _uploadService.enqueueTasks(tasks);
 | 
				
			||||||
 | 
					        debugPrint(
 | 
				
			||||||
 | 
					          "Enqueued $count/${candidates.length} tasks for backup",
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _handleTaskStatusUpdate(TaskStatusUpdate update) {
 | 
				
			||||||
 | 
					    switch (update.status) {
 | 
				
			||||||
 | 
					      case TaskStatus.complete:
 | 
				
			||||||
 | 
					        _handleLivePhoto(update);
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      default:
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _handleLivePhoto(TaskStatusUpdate update) async {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      if (update.task.metaData.isEmpty || update.task.metaData == '') {
 | 
				
			||||||
 | 
					        return;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      final metadata = UploadTaskMetadata.fromJson(update.task.metaData);
 | 
				
			||||||
 | 
					      if (!metadata.isLivePhotos) {
 | 
				
			||||||
 | 
					        return;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (update.responseBody == null || update.responseBody!.isEmpty) {
 | 
				
			||||||
 | 
					        return;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      final response = jsonDecode(update.responseBody!);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      final localAsset =
 | 
				
			||||||
 | 
					          await _localAssetRepository.getById(metadata.localAssetId);
 | 
				
			||||||
 | 
					      if (localAsset == null) {
 | 
				
			||||||
 | 
					        return;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      final uploadTask = await _getLivePhotoUploadTask(
 | 
				
			||||||
 | 
					        localAsset,
 | 
				
			||||||
 | 
					        response['id'] as String,
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (uploadTask == null) {
 | 
				
			||||||
 | 
					        return;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      _uploadService.enqueueTasks([uploadTask]);
 | 
				
			||||||
 | 
					    } catch (error, stackTrace) {
 | 
				
			||||||
 | 
					      _log.severe("Error handling live photo upload task", error, stackTrace);
 | 
				
			||||||
 | 
					      debugPrint("Error handling live photo upload task: $error $stackTrace");
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<UploadTask?> _getUploadTask(LocalAsset asset) async {
 | 
				
			||||||
 | 
					    final entity = await _storageRepository.getAssetEntityForAsset(asset);
 | 
				
			||||||
 | 
					    if (entity == null) {
 | 
				
			||||||
 | 
					      return null;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    File? file;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /// iOS LivePhoto has two files: a photo and a video.
 | 
				
			||||||
 | 
					    /// They are uploaded separately, with video file being upload first, then returned with the assetId
 | 
				
			||||||
 | 
					    /// The assetId is then used as a metadata for the photo file upload task.
 | 
				
			||||||
 | 
					    ///
 | 
				
			||||||
 | 
					    /// We implement two separate upload groups for this, the normal one for the video file
 | 
				
			||||||
 | 
					    /// and the higher priority group for the photo file because the video file is already uploaded.
 | 
				
			||||||
 | 
					    ///
 | 
				
			||||||
 | 
					    /// The cancel operation will only cancel the video group (normal group), the photo group will not
 | 
				
			||||||
 | 
					    /// be touched, as the video file is already uploaded.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (entity.isLivePhoto) {
 | 
				
			||||||
 | 
					      file = await _storageRepository.getMotionFileForAsset(asset);
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      file = await _storageRepository.getFileForAsset(asset.id);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (file == null) {
 | 
				
			||||||
 | 
					      return null;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final originalFileName = entity.isLivePhoto
 | 
				
			||||||
 | 
					        ? p.setExtension(
 | 
				
			||||||
 | 
					            asset.name,
 | 
				
			||||||
 | 
					            p.extension(file.path),
 | 
				
			||||||
 | 
					          )
 | 
				
			||||||
 | 
					        : asset.name;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    String metadata = UploadTaskMetadata(
 | 
				
			||||||
 | 
					      localAssetId: asset.id,
 | 
				
			||||||
 | 
					      isLivePhotos: entity.isLivePhoto,
 | 
				
			||||||
 | 
					      livePhotoVideoId: '',
 | 
				
			||||||
 | 
					    ).toJson();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return _uploadService.buildUploadTask(
 | 
				
			||||||
 | 
					      file,
 | 
				
			||||||
 | 
					      originalFileName: originalFileName,
 | 
				
			||||||
 | 
					      deviceAssetId: asset.id,
 | 
				
			||||||
 | 
					      metadata: metadata,
 | 
				
			||||||
 | 
					      group: kBackupGroup,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<UploadTask?> _getLivePhotoUploadTask(
 | 
				
			||||||
 | 
					    LocalAsset asset,
 | 
				
			||||||
 | 
					    String livePhotoVideoId,
 | 
				
			||||||
 | 
					  ) async {
 | 
				
			||||||
 | 
					    final entity = await _storageRepository.getAssetEntityForAsset(asset);
 | 
				
			||||||
 | 
					    if (entity == null) {
 | 
				
			||||||
 | 
					      return null;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final file = await _storageRepository.getFileForAsset(asset.id);
 | 
				
			||||||
 | 
					    if (file == null) {
 | 
				
			||||||
 | 
					      return null;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final fields = {
 | 
				
			||||||
 | 
					      'livePhotoVideoId': livePhotoVideoId,
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return _uploadService.buildUploadTask(
 | 
				
			||||||
 | 
					      file,
 | 
				
			||||||
 | 
					      originalFileName: asset.name,
 | 
				
			||||||
 | 
					      deviceAssetId: asset.id,
 | 
				
			||||||
 | 
					      fields: fields,
 | 
				
			||||||
 | 
					      group: kBackupLivePhotoGroup,
 | 
				
			||||||
 | 
					      priority: 0,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> cancel() async {
 | 
				
			||||||
 | 
					    shouldCancel = true;
 | 
				
			||||||
 | 
					    await _uploadService.cancelAllForGroup(kBackupGroup);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class UploadTaskMetadata {
 | 
				
			||||||
 | 
					  final String localAssetId;
 | 
				
			||||||
 | 
					  final bool isLivePhotos;
 | 
				
			||||||
 | 
					  final String livePhotoVideoId;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const UploadTaskMetadata({
 | 
				
			||||||
 | 
					    required this.localAssetId,
 | 
				
			||||||
 | 
					    required this.isLivePhotos,
 | 
				
			||||||
 | 
					    required this.livePhotoVideoId,
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  UploadTaskMetadata copyWith({
 | 
				
			||||||
 | 
					    String? localAssetId,
 | 
				
			||||||
 | 
					    bool? isLivePhotos,
 | 
				
			||||||
 | 
					    String? livePhotoVideoId,
 | 
				
			||||||
 | 
					  }) {
 | 
				
			||||||
 | 
					    return UploadTaskMetadata(
 | 
				
			||||||
 | 
					      localAssetId: localAssetId ?? this.localAssetId,
 | 
				
			||||||
 | 
					      isLivePhotos: isLivePhotos ?? this.isLivePhotos,
 | 
				
			||||||
 | 
					      livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Map<String, dynamic> toMap() {
 | 
				
			||||||
 | 
					    return <String, dynamic>{
 | 
				
			||||||
 | 
					      'localAssetId': localAssetId,
 | 
				
			||||||
 | 
					      'isLivePhotos': isLivePhotos,
 | 
				
			||||||
 | 
					      'livePhotoVideoId': livePhotoVideoId,
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  factory UploadTaskMetadata.fromMap(Map<String, dynamic> map) {
 | 
				
			||||||
 | 
					    return UploadTaskMetadata(
 | 
				
			||||||
 | 
					      localAssetId: map['localAssetId'] as String,
 | 
				
			||||||
 | 
					      isLivePhotos: map['isLivePhotos'] as bool,
 | 
				
			||||||
 | 
					      livePhotoVideoId: map['livePhotoVideoId'] as String,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  String toJson() => json.encode(toMap());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  factory UploadTaskMetadata.fromJson(String source) =>
 | 
				
			||||||
 | 
					      UploadTaskMetadata.fromMap(json.decode(source) as Map<String, dynamic>);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  String toString() =>
 | 
				
			||||||
 | 
					      'UploadTaskMetadata(localAssetId: $localAssetId, isLivePhotos: $isLivePhotos, livePhotoVideoId: $livePhotoVideoId)';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  bool operator ==(covariant UploadTaskMetadata other) {
 | 
				
			||||||
 | 
					    if (identical(this, other)) return true;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return other.localAssetId == localAssetId &&
 | 
				
			||||||
 | 
					        other.isLivePhotos == isLivePhotos &&
 | 
				
			||||||
 | 
					        other.livePhotoVideoId == livePhotoVideoId;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  int get hashCode =>
 | 
				
			||||||
 | 
					      localAssetId.hashCode ^ isLivePhotos.hashCode ^ livePhotoVideoId.hashCode;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -1,3 +1,4 @@
 | 
				
			|||||||
 | 
					import 'dart:async';
 | 
				
			||||||
import 'dart:io';
 | 
					import 'dart:io';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import 'package:background_downloader/background_downloader.dart';
 | 
					import 'package:background_downloader/background_downloader.dart';
 | 
				
			||||||
@ -6,22 +7,28 @@ import 'package:immich_mobile/domain/models/store.model.dart';
 | 
				
			|||||||
import 'package:immich_mobile/entities/store.entity.dart';
 | 
					import 'package:immich_mobile/entities/store.entity.dart';
 | 
				
			||||||
import 'package:immich_mobile/repositories/upload.repository.dart';
 | 
					import 'package:immich_mobile/repositories/upload.repository.dart';
 | 
				
			||||||
import 'package:immich_mobile/services/api.service.dart';
 | 
					import 'package:immich_mobile/services/api.service.dart';
 | 
				
			||||||
import 'package:immich_mobile/utils/upload.dart';
 | 
					 | 
				
			||||||
import 'package:path/path.dart';
 | 
					import 'package:path/path.dart';
 | 
				
			||||||
// import 'package:logging/logging.dart';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
final uploadServiceProvider = Provider(
 | 
					final uploadServiceProvider = Provider((ref) {
 | 
				
			||||||
  (ref) => UploadService(
 | 
					  final service = UploadService(ref.watch(uploadRepositoryProvider));
 | 
				
			||||||
    ref.watch(uploadRepositoryProvider),
 | 
					  ref.onDispose(service.dispose);
 | 
				
			||||||
  ),
 | 
					  return service;
 | 
				
			||||||
);
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class UploadService {
 | 
					class UploadService {
 | 
				
			||||||
  final UploadRepository _uploadRepository;
 | 
					  final UploadRepository _uploadRepository;
 | 
				
			||||||
  // final Logger _log = Logger("UploadService");
 | 
					 | 
				
			||||||
  void Function(TaskStatusUpdate)? onUploadStatus;
 | 
					  void Function(TaskStatusUpdate)? onUploadStatus;
 | 
				
			||||||
  void Function(TaskProgressUpdate)? onTaskProgress;
 | 
					  void Function(TaskProgressUpdate)? onTaskProgress;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  final StreamController<TaskStatusUpdate> _taskStatusController =
 | 
				
			||||||
 | 
					      StreamController<TaskStatusUpdate>.broadcast();
 | 
				
			||||||
 | 
					  final StreamController<TaskProgressUpdate> _taskProgressController =
 | 
				
			||||||
 | 
					      StreamController<TaskProgressUpdate>.broadcast();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Stream<TaskStatusUpdate> get taskStatusStream => _taskStatusController.stream;
 | 
				
			||||||
 | 
					  Stream<TaskProgressUpdate> get taskProgressStream =>
 | 
				
			||||||
 | 
					      _taskProgressController.stream;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  UploadService(
 | 
					  UploadService(
 | 
				
			||||||
    this._uploadRepository,
 | 
					    this._uploadRepository,
 | 
				
			||||||
  ) {
 | 
					  ) {
 | 
				
			||||||
@ -31,29 +38,65 @@ class UploadService {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  void _onTaskProgressCallback(TaskProgressUpdate update) {
 | 
					  void _onTaskProgressCallback(TaskProgressUpdate update) {
 | 
				
			||||||
    onTaskProgress?.call(update);
 | 
					    onTaskProgress?.call(update);
 | 
				
			||||||
 | 
					    if (!_taskProgressController.isClosed) {
 | 
				
			||||||
 | 
					      _taskProgressController.add(update);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  void _onUploadCallback(TaskStatusUpdate update) {
 | 
					  void _onUploadCallback(TaskStatusUpdate update) {
 | 
				
			||||||
    onUploadStatus?.call(update);
 | 
					    onUploadStatus?.call(update);
 | 
				
			||||||
 | 
					    if (!_taskStatusController.isClosed) {
 | 
				
			||||||
 | 
					      _taskStatusController.add(update);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void dispose() {
 | 
				
			||||||
 | 
					    _taskStatusController.close();
 | 
				
			||||||
 | 
					    _taskProgressController.close();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<bool> cancelUpload(String id) {
 | 
					  Future<bool> cancelUpload(String id) {
 | 
				
			||||||
    return FileDownloader().cancelTaskWithId(id);
 | 
					    return FileDownloader().cancelTaskWithId(id);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<void> upload(File file) async {
 | 
					  Future<void> cancelAllForGroup(String group) async {
 | 
				
			||||||
    final task = await _buildUploadTask(
 | 
					    await _uploadRepository.cancelAll(group);
 | 
				
			||||||
      hash(file.path).toString(),
 | 
					    await _uploadRepository.reset(group);
 | 
				
			||||||
      file,
 | 
					    await _uploadRepository.deleteAllTrackingRecords(group);
 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    await _uploadRepository.upload(task);
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<UploadTask> _buildUploadTask(
 | 
					  void enqueueTasks(List<UploadTask> tasks) {
 | 
				
			||||||
 | 
					    _uploadRepository.enqueueAll(tasks);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<UploadTask> buildUploadTask(
 | 
				
			||||||
 | 
					    File file, {
 | 
				
			||||||
 | 
					    required String group,
 | 
				
			||||||
 | 
					    Map<String, String>? fields,
 | 
				
			||||||
 | 
					    String? originalFileName,
 | 
				
			||||||
 | 
					    String? deviceAssetId,
 | 
				
			||||||
 | 
					    String? metadata,
 | 
				
			||||||
 | 
					    int? priority,
 | 
				
			||||||
 | 
					  }) async {
 | 
				
			||||||
 | 
					    return _buildTask(
 | 
				
			||||||
 | 
					      deviceAssetId ?? hash(file.path).toString(),
 | 
				
			||||||
 | 
					      file,
 | 
				
			||||||
 | 
					      fields: fields,
 | 
				
			||||||
 | 
					      originalFileName: originalFileName,
 | 
				
			||||||
 | 
					      metadata: metadata,
 | 
				
			||||||
 | 
					      group: group,
 | 
				
			||||||
 | 
					      priority: priority,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<UploadTask> _buildTask(
 | 
				
			||||||
    String id,
 | 
					    String id,
 | 
				
			||||||
    File file, {
 | 
					    File file, {
 | 
				
			||||||
 | 
					    required String group,
 | 
				
			||||||
    Map<String, String>? fields,
 | 
					    Map<String, String>? fields,
 | 
				
			||||||
 | 
					    String? originalFileName,
 | 
				
			||||||
 | 
					    String? metadata,
 | 
				
			||||||
 | 
					    int? priority,
 | 
				
			||||||
  }) async {
 | 
					  }) async {
 | 
				
			||||||
    final serverEndpoint = Store.get(StoreKey.serverEndpoint);
 | 
					    final serverEndpoint = Store.get(StoreKey.serverEndpoint);
 | 
				
			||||||
    final url = Uri.parse('$serverEndpoint/assets').toString();
 | 
					    final url = Uri.parse('$serverEndpoint/assets').toString();
 | 
				
			||||||
@ -65,9 +108,8 @@ class UploadService {
 | 
				
			|||||||
    final stats = await file.stat();
 | 
					    final stats = await file.stat();
 | 
				
			||||||
    final fileCreatedAt = stats.changed;
 | 
					    final fileCreatedAt = stats.changed;
 | 
				
			||||||
    final fileModifiedAt = stats.modified;
 | 
					    final fileModifiedAt = stats.modified;
 | 
				
			||||||
 | 
					 | 
				
			||||||
    final fieldsMap = {
 | 
					    final fieldsMap = {
 | 
				
			||||||
      'filename': filename,
 | 
					      'filename': originalFileName ?? filename,
 | 
				
			||||||
      'deviceAssetId': id,
 | 
					      'deviceAssetId': id,
 | 
				
			||||||
      'deviceId': deviceId,
 | 
					      'deviceId': deviceId,
 | 
				
			||||||
      'fileCreatedAt': fileCreatedAt.toUtc().toIso8601String(),
 | 
					      'fileCreatedAt': fileCreatedAt.toUtc().toIso8601String(),
 | 
				
			||||||
@ -79,6 +121,7 @@ class UploadService {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    return UploadTask(
 | 
					    return UploadTask(
 | 
				
			||||||
      taskId: id,
 | 
					      taskId: id,
 | 
				
			||||||
 | 
					      displayName: originalFileName ?? filename,
 | 
				
			||||||
      httpRequestMethod: 'POST',
 | 
					      httpRequestMethod: 'POST',
 | 
				
			||||||
      url: url,
 | 
					      url: url,
 | 
				
			||||||
      headers: headers,
 | 
					      headers: headers,
 | 
				
			||||||
@ -87,7 +130,9 @@ class UploadService {
 | 
				
			|||||||
      baseDirectory: baseDirectory,
 | 
					      baseDirectory: baseDirectory,
 | 
				
			||||||
      directory: directory,
 | 
					      directory: directory,
 | 
				
			||||||
      fileField: 'assetData',
 | 
					      fileField: 'assetData',
 | 
				
			||||||
      group: uploadGroup,
 | 
					      metaData: metadata ?? '',
 | 
				
			||||||
 | 
					      group: group,
 | 
				
			||||||
 | 
					      priority: priority ?? 5,
 | 
				
			||||||
      updates: Updates.statusAndProgress,
 | 
					      updates: Updates.statusAndProgress,
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										31
									
								
								mobile/lib/utils/database.utils.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								mobile/lib/utils/database.utils.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,31 @@
 | 
				
			|||||||
 | 
					import 'package:immich_mobile/domain/models/album/local_album.model.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					extension LocalAlbumEntityDataHelper on LocalAlbumEntityData {
 | 
				
			||||||
 | 
					  LocalAlbum toDto({int assetCount = 0}) {
 | 
				
			||||||
 | 
					    return LocalAlbum(
 | 
				
			||||||
 | 
					      id: id,
 | 
				
			||||||
 | 
					      name: name,
 | 
				
			||||||
 | 
					      updatedAt: updatedAt,
 | 
				
			||||||
 | 
					      assetCount: assetCount,
 | 
				
			||||||
 | 
					      backupSelection: backupSelection,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					extension LocalAssetEntityDataHelper on LocalAssetEntityData {
 | 
				
			||||||
 | 
					  LocalAsset toDto() {
 | 
				
			||||||
 | 
					    return LocalAsset(
 | 
				
			||||||
 | 
					      id: id,
 | 
				
			||||||
 | 
					      name: name,
 | 
				
			||||||
 | 
					      checksum: checksum,
 | 
				
			||||||
 | 
					      type: type,
 | 
				
			||||||
 | 
					      createdAt: createdAt,
 | 
				
			||||||
 | 
					      updatedAt: updatedAt,
 | 
				
			||||||
 | 
					      durationInSeconds: durationInSeconds,
 | 
				
			||||||
 | 
					      isFavorite: isFavorite,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -1 +0,0 @@
 | 
				
			|||||||
const uploadGroup = 'upload_group';
 | 
					 | 
				
			||||||
							
								
								
									
										121
									
								
								mobile/lib/widgets/backup/drift_album_info_list_tile.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										121
									
								
								mobile/lib/widgets/backup/drift_album_info_list_tile.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,121 @@
 | 
				
			|||||||
 | 
					import 'package:auto_route/auto_route.dart';
 | 
				
			||||||
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					import 'package:fluttertoast/fluttertoast.dart';
 | 
				
			||||||
 | 
					import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/domain/models/album/local_album.model.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/extensions/build_context_extensions.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/providers/album/album.provider.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/providers/app_settings.provider.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/routing/router.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/services/app_settings.service.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/widgets/common/immich_toast.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class DriftAlbumInfoListTile extends HookConsumerWidget {
 | 
				
			||||||
 | 
					  final LocalAlbum album;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const DriftAlbumInfoListTile({super.key, required this.album});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context, WidgetRef ref) {
 | 
				
			||||||
 | 
					    final bool isSelected = album.backupSelection == BackupSelection.selected;
 | 
				
			||||||
 | 
					    final bool isExcluded = album.backupSelection == BackupSelection.excluded;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final syncAlbum = ref
 | 
				
			||||||
 | 
					        .watch(appSettingsServiceProvider)
 | 
				
			||||||
 | 
					        .getSetting(AppSettingsEnum.syncAlbums);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    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;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    buildIcon() {
 | 
				
			||||||
 | 
					      if (isSelected) {
 | 
				
			||||||
 | 
					        return Icon(
 | 
				
			||||||
 | 
					          Icons.check_circle_rounded,
 | 
				
			||||||
 | 
					          color: context.colorScheme.primary,
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (isExcluded) {
 | 
				
			||||||
 | 
					        return Icon(
 | 
				
			||||||
 | 
					          Icons.remove_circle_rounded,
 | 
				
			||||||
 | 
					          color: context.colorScheme.error,
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      return Icon(
 | 
				
			||||||
 | 
					        Icons.circle,
 | 
				
			||||||
 | 
					        color: context.colorScheme.surfaceContainerHighest,
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return GestureDetector(
 | 
				
			||||||
 | 
					      onDoubleTap: () {
 | 
				
			||||||
 | 
					        ref.watch(hapticFeedbackProvider.notifier).selectionClick();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (isExcluded) {
 | 
				
			||||||
 | 
					          ref.read(backupAlbumProvider.notifier).deselectAlbum(album);
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					          if (album.id == 'isAll' || album.name == 'Recents') {
 | 
				
			||||||
 | 
					            ImmichToast.show(
 | 
				
			||||||
 | 
					              context: context,
 | 
				
			||||||
 | 
					              msg: 'Cannot exclude album contains all assets',
 | 
				
			||||||
 | 
					              toastType: ToastType.error,
 | 
				
			||||||
 | 
					              gravity: ToastGravity.BOTTOM,
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          ref.read(backupAlbumProvider.notifier).excludeAlbum(album);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      child: ListTile(
 | 
				
			||||||
 | 
					        tileColor: buildTileColor(),
 | 
				
			||||||
 | 
					        contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
 | 
				
			||||||
 | 
					        onTap: () {
 | 
				
			||||||
 | 
					          ref.read(hapticFeedbackProvider.notifier).selectionClick();
 | 
				
			||||||
 | 
					          if (isSelected) {
 | 
				
			||||||
 | 
					            ref.read(backupAlbumProvider.notifier).deselectAlbum(album);
 | 
				
			||||||
 | 
					          } else {
 | 
				
			||||||
 | 
					            ref.read(backupAlbumProvider.notifier).selectAlbum(album);
 | 
				
			||||||
 | 
					            if (syncAlbum) {
 | 
				
			||||||
 | 
					              ref.read(albumProvider.notifier).createSyncAlbum(album.name);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        leading: buildIcon(),
 | 
				
			||||||
 | 
					        title: Text(
 | 
				
			||||||
 | 
					          album.name,
 | 
				
			||||||
 | 
					          style: const TextStyle(
 | 
				
			||||||
 | 
					            fontSize: 14,
 | 
				
			||||||
 | 
					            fontWeight: FontWeight.bold,
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        subtitle: Text(album.assetCount.toString()),
 | 
				
			||||||
 | 
					        trailing: IconButton(
 | 
				
			||||||
 | 
					          onPressed: () {
 | 
				
			||||||
 | 
					            context.pushRoute(LocalTimelineRoute(album: album));
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          icon: Icon(
 | 
				
			||||||
 | 
					            Icons.image_outlined,
 | 
				
			||||||
 | 
					            color: context.primaryColor,
 | 
				
			||||||
 | 
					            size: 24,
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          splashRadius: 25,
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -205,7 +205,7 @@ class _BackupIndicator extends ConsumerWidget {
 | 
				
			|||||||
    final badgeBackground = context.colorScheme.surfaceContainer;
 | 
					    final badgeBackground = context.colorScheme.surfaceContainer;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return InkWell(
 | 
					    return InkWell(
 | 
				
			||||||
      onTap: () => context.pushRoute(const BackupControllerRoute()),
 | 
					      onTap: () => context.pushRoute(const DriftBackupRoute()),
 | 
				
			||||||
      borderRadius: const BorderRadius.all(Radius.circular(12)),
 | 
					      borderRadius: const BorderRadius.all(Radius.circular(12)),
 | 
				
			||||||
      child: Badge(
 | 
					      child: Badge(
 | 
				
			||||||
        label: Container(
 | 
					        label: Container(
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user