mirror of
https://github.com/immich-app/immich.git
synced 2025-07-31 15:08:44 -04: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