mirror of https://github.com/immich-app/immich.git
feat(mobile): new upload (#18726)
parent
f929dc0816
commit
fafb88d31c
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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';
|
|
||||||
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue