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