This commit is contained in:
Alex 2025-06-09 14:35:33 -05:00
parent 429d339c6d
commit f87ae08cd1
No known key found for this signature in database
GPG Key ID: 53CD082B3A5E1082
7 changed files with 403 additions and 177 deletions

View File

@ -0,0 +1,14 @@
import 'package:immich_mobile/domain/interfaces/db.interface.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/local_album.model.dart';
abstract interface class IBackupRepository implements IDatabaseRepository {
Future<List<LocalAsset>> getAssets(String albumId);
Future<List<String>> getAssetIds(String albumId);
/// Returns the total number of assets that are selected for backup.
Future<int> getTotalCount(BackupSelection selection);
Future<int> getBackupCount();
}

View File

@ -0,0 +1,114 @@
import 'package:drift/drift.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/interfaces/backup.interface.dart';
import 'package:immich_mobile/domain/interfaces/local_album.interface.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/local_album.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.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/repositories/db.repository.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:platform/platform.dart';
final backupRepositoryProvider = Provider<IBackupRepository>(
(ref) => DriftBackupRepository(ref.watch(driftProvider)),
);
class DriftBackupRepository extends DriftDatabaseRepository
implements IBackupRepository {
final Drift _db;
final Platform _platform;
const DriftBackupRepository(this._db, {Platform? platform})
: _platform = platform ?? const LocalPlatform(),
super(_db);
@override
Future<List<LocalAsset>> getAssets(String albumId) {
final query = _db.localAlbumAssetEntity.select().join(
[
innerJoin(
_db.localAssetEntity,
_db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id),
),
],
)
..where(_db.localAlbumAssetEntity.albumId.equals(albumId))
..orderBy([OrderingTerm.asc(_db.localAssetEntity.id)]);
return query
.map((row) => row.readTable(_db.localAssetEntity).toDto())
.get();
}
@override
Future<List<String>> getAssetIds(String albumId) {
final query = _db.localAlbumAssetEntity.selectOnly()
..addColumns([_db.localAlbumAssetEntity.assetId])
..where(_db.localAlbumAssetEntity.albumId.equals(albumId));
return query
.map((row) => row.read(_db.localAlbumAssetEntity.assetId)!)
.get();
}
@override
Future<int> getTotalCount(BackupSelection selection) {
final query = _db.localAlbumAssetEntity.selectOnly(distinct: true)
..addColumns([_db.localAlbumAssetEntity.assetId])
..join([
innerJoin(
_db.localAlbumEntity,
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
),
])
..where(
_db.localAlbumEntity.backupSelection.equals(selection.index),
);
return query.get().then((rows) => rows.length);
}
@override
Future<int> getBackupCount() {
final query = _db.localAlbumEntity.select().join(
[
innerJoin(
_db.localAlbumAssetEntity,
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
),
],
)..where(
_db.localAlbumEntity.backupSelection.equals(
BackupSelection.selected.index,
),
);
return query.get().then((rows) => rows.length);
}
}
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,
);
}
}

View File

@ -10,15 +10,14 @@ import 'package:immich_mobile/domain/models/local_album.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/models/backup/backup_state.model.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.provider.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart'; import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart'; import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
import 'package:immich_mobile/providers/backup/exp_backup.provider.dart';
import 'package:immich_mobile/providers/backup/ios_background_settings.provider.dart'; import 'package:immich_mobile/providers/backup/ios_background_settings.provider.dart';
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
import 'package:immich_mobile/providers/websocket.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/services/upload.service.dart';
import 'package:immich_mobile/widgets/backup/backup_info_card.dart'; import 'package:immich_mobile/widgets/backup/backup_info_card.dart';
import 'package:immich_mobile/widgets/backup/current_backup_asset_info_box.dart'; import 'package:immich_mobile/widgets/backup/current_backup_asset_info_box.dart';
import 'package:immich_mobile/widgets/backup/exp_upload_option_toggle.dart'; import 'package:immich_mobile/widgets/backup/exp_upload_option_toggle.dart';
@ -43,6 +42,14 @@ class ExpBackupPage extends HookConsumerWidget {
? false ? false
: true; : true;
useEffect(
() {
ref.read(expBackupProvider.notifier).getBackupStatus();
return null;
},
[],
);
useEffect( useEffect(
() { () {
// Update the background settings information just to make sure we // Update the background settings information just to make sure we
@ -88,131 +95,6 @@ class ExpBackupPage extends HookConsumerWidget {
[backupState.backupProgress], [backupState.backupProgress],
); );
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();
}
}
buildFolderSelectionTile() {
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.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 ExpBackupAlbumSelectionRoute());
// waited until returning from selection
await ref
.read(backupProvider.notifier)
.backupAlbumSelectionDone();
// waited until backup albums are stored in DB
ref.read(albumProvider.notifier).refreshDeviceAlbums();
},
child: const Text(
"select",
style: TextStyle(
fontWeight: FontWeight.bold,
),
).tr(),
),
),
),
);
}
void startBackup() { void startBackup() {
ref.watch(errorBackupListProvider.notifier).empty(); ref.watch(errorBackupListProvider.notifier).empty();
if (ref.watch(backupProvider).backupProgress != if (ref.watch(backupProvider).backupProgress !=
@ -329,61 +211,17 @@ class ExpBackupPage extends HookConsumerWidget {
onToggle: () => onToggle: () =>
context.replaceRoute(const BackupControllerRoute()), context.replaceRoute(const BackupControllerRoute()),
), ),
buildFolderSelectionTile(), const SizedBox(height: 8),
BackupInfoCard( const BackupAlbumSelectionCard(),
title: "total".tr(), const TotalCard(),
subtitle: "backup_controller_page_total_sub".tr(), const RemainderCard(),
info: ref.watch(backupProvider).availableAlbums.isEmpty
? "..."
: "${backupState.allUniqueAssets.length}",
),
BackupInfoCard(
title: "backup_controller_page_backup".tr(),
subtitle: "backup_controller_page_backup_sub".tr(),
info: ref.watch(backupProvider).availableAlbums.isEmpty
? "..."
: "${backupState.selectedAlbumsBackupAssetsIds.length}",
),
BackupInfoCard(
title: "backup_controller_page_remainder".tr(),
subtitle: "backup_controller_page_remainder_sub".tr(),
info: ref.watch(backupProvider).availableAlbums.isEmpty
? "..."
: "${max(0, backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length)}",
),
const Divider(), const Divider(),
const CurrentUploadingAssetInfoBox(), const CurrentUploadingAssetInfoBox(),
if (!hasExclusiveAccess) buildBackgroundBackupInfo(), if (!hasExclusiveAccess) buildBackgroundBackupInfo(),
buildBackupButton(), buildBackupButton(),
ElevatedButton(
onPressed: () {
ref.watch(uploadServiceProvider).getRecords();
},
child: const Text(
"get record",
),
),
ElevatedButton(
onPressed: () {
ref
.watch(uploadServiceProvider)
.deleteAllUploadTasks();
},
child: const Text(
"clear records",
),
),
ElevatedButton(
onPressed: () {
ref.watch(uploadServiceProvider).cancelAllUpload();
},
child: const Text(
"cancel all uploads",
),
),
] ]
: [ : [
buildFolderSelectionTile(), const BackupAlbumSelectionCard(),
if (!didGetBackupInfo.value) buildLoadingIndicator(), if (!didGetBackupInfo.value) buildLoadingIndicator(),
], ],
), ),
@ -393,3 +231,156 @@ class ExpBackupPage extends HookConsumerWidget {
); );
} }
} }
class BackupAlbumSelectionCard extends ConsumerWidget {
const BackupAlbumSelectionCard({super.key});
@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: BorderRadius.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 ExpBackupAlbumSelectionRoute());
ref.read(expBackupProvider.notifier).getBackupStatus();
},
child: const Text(
"select",
style: TextStyle(
fontWeight: FontWeight.bold,
),
).tr(),
),
),
);
}
}
class TotalCard extends ConsumerWidget {
const TotalCard({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final totalCount = ref.watch(expBackupProvider.select((p) => p.totalCount));
return BackupInfoCard(
title: "total".tr(),
subtitle: "backup_controller_page_total_sub".tr(),
info: totalCount.toString(),
);
}
}
class RemainderCard extends ConsumerWidget {
const RemainderCard({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final backupState = ref.watch(backupProvider);
return BackupInfoCard(
title: "backup_controller_page_remainder".tr(),
subtitle: "backup_controller_page_remainder_sub".tr(),
info: backupState.availableAlbums.isEmpty
? "..."
: "${max(0, backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length)}",
);
}
}

View File

@ -19,7 +19,6 @@ class BackupAlbumNotifier extends StateNotifier<List<LocalAlbum>> {
Future<void> getAll() async { Future<void> getAll() async {
state = await _localAlbumService.getAll(); state = await _localAlbumService.getAll();
print("Backup albums loaded: ${state.length}");
} }
Future<void> selectAlbum(LocalAlbum album) async { Future<void> selectAlbum(LocalAlbum album) async {

View File

@ -0,0 +1,73 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:convert';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/services/exp_backup.service.dart';
class ExpBackupState {
final int totalCount;
ExpBackupState({
required this.totalCount,
});
ExpBackupState copyWith({
int? totalCount,
}) {
return ExpBackupState(
totalCount: totalCount ?? this.totalCount,
);
}
Map<String, dynamic> toMap() {
return <String, dynamic>{
'totalCount': totalCount,
};
}
factory ExpBackupState.fromMap(Map<String, dynamic> map) {
return ExpBackupState(
totalCount: map['totalCount'] as int,
);
}
String toJson() => json.encode(toMap());
factory ExpBackupState.fromJson(String source) =>
ExpBackupState.fromMap(json.decode(source) as Map<String, dynamic>);
@override
String toString() => 'ExpBackupState(totalCount: $totalCount)';
@override
bool operator ==(covariant ExpBackupState other) {
if (identical(this, other)) return true;
return other.totalCount == totalCount;
}
@override
int get hashCode => totalCount.hashCode;
}
final expBackupProvider =
StateNotifierProvider<ExpBackupNotifier, ExpBackupState>((ref) {
return ExpBackupNotifier(ref.watch(expBackupServiceProvider));
});
class ExpBackupNotifier extends StateNotifier<ExpBackupState> {
ExpBackupNotifier(this._backupService)
: super(
ExpBackupState(
totalCount: 0,
),
);
final ExpBackupService _backupService;
Future<void> getBackupStatus() async {
final totalCount = await _backupService.getTotalCount();
state = state.copyWith(totalCount: totalCount);
}
}

View File

@ -336,5 +336,9 @@ class AppRouter extends RootStackRouter {
page: ExpBackupRoute.page, page: ExpBackupRoute.page,
guards: [_authGuard, _duplicateGuard], guards: [_authGuard, _duplicateGuard],
), ),
AutoRoute(
page: ExpBackupAlbumSelectionRoute.page,
guards: [_authGuard, _duplicateGuard],
),
]; ];
} }

View File

@ -0,0 +1,31 @@
import 'package:immich_mobile/domain/interfaces/backup.interface.dart';
import 'package:immich_mobile/domain/interfaces/local_album.interface.dart';
import 'package:immich_mobile/domain/models/local_album.model.dart';
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
final expBackupServiceProvider = Provider<ExpBackupService>(
(ref) => ExpBackupService(
ref.watch(backupRepositoryProvider),
),
);
class ExpBackupService {
ExpBackupService(this._backupRepository);
final IBackupRepository _backupRepository;
Future<int> getTotalCount() async {
final [selectedCount, excludedCount] = await Future.wait([
_backupRepository.getTotalCount(BackupSelection.selected),
_backupRepository.getTotalCount(BackupSelection.excluded),
]);
return selectedCount - excludedCount;
}
Future<int> getBackupCount() {
return _backupRepository.getBackupCount();
}
}