mirror of
https://github.com/immich-app/immich.git
synced 2025-01-28 06:32:44 +01:00
feat(mobile): preserve mobile album info on upload (#11965)
* curating assets with albums to upload * sorting for background backup * background upload works * transform fields string array to javascript array * send json array * generate sql * refactor upload callback * remove albums info from upload payload * mechanism to create album on album selection * album creation * Sync to upload album * Remove unused service * unify name changes * Add mechanism to sync uploaded assets to albums * Put add to album operation after updating the UI state * clean up * background album sync * add to album in background context * remove add to album in callback * refactor * refactor * refactor * fix: make sure all selected albums are selected for building upload candidate * clean up * add manual sync button * lint * revert server changes * pr feedback * revert time filtering * const * sync album on manual upload * linting * pr feedback and proper time filtering * wording
This commit is contained in:
parent
f4371578f5
commit
6b6d2a6621
19 changed files with 657 additions and 233 deletions
mobile
assets/i18n
lib
entities
models/backup
pages/backup
providers
services
album.service.dartapp_settings.service.dartasset.service.dartbackground.service.dartbackup.service.dart
widgets
|
@ -573,5 +573,9 @@
|
||||||
"version_announcement_overlay_text_2": "please take your time to visit the ",
|
"version_announcement_overlay_text_2": "please take your time to visit the ",
|
||||||
"version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.",
|
"version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.",
|
||||||
"version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89",
|
"version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89",
|
||||||
"viewer_unstack": "Un-Stack"
|
"viewer_unstack": "Un-Stack",
|
||||||
|
"sync_albums": "Sync albums",
|
||||||
|
"sync_upload_album_setting_subtitle": "Create and upload your photos and videos to the selected albums on Immich",
|
||||||
|
"sync_albums_manual_subtitle": "Sync all uploaded videos and photos to the selected backup albums",
|
||||||
|
"sync": "Sync"
|
||||||
}
|
}
|
||||||
|
|
|
@ -234,6 +234,8 @@ enum StoreKey<T> {
|
||||||
primaryColor<String>(128, type: String),
|
primaryColor<String>(128, type: String),
|
||||||
dynamicTheme<bool>(129, type: bool),
|
dynamicTheme<bool>(129, type: bool),
|
||||||
colorfulInterface<bool>(130, type: bool),
|
colorfulInterface<bool>(130, type: bool),
|
||||||
|
|
||||||
|
syncAlbums<bool>(131, type: bool),
|
||||||
;
|
;
|
||||||
|
|
||||||
const StoreKey(
|
const StoreKey(
|
||||||
|
|
19
mobile/lib/models/backup/backup_candidate.model.dart
Normal file
19
mobile/lib/models/backup/backup_candidate.model.dart
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
|
class BackupCandidate {
|
||||||
|
BackupCandidate({required this.asset, required this.albumNames});
|
||||||
|
|
||||||
|
AssetEntity asset;
|
||||||
|
List<String> albumNames;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => asset.hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (other is! BackupCandidate) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return asset == other.asset;
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import 'package:cancellation_token_http/http.dart';
|
import 'package:cancellation_token_http/http.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
|
||||||
|
|
||||||
import 'package:immich_mobile/models/backup/available_album.model.dart';
|
import 'package:immich_mobile/models/backup/available_album.model.dart';
|
||||||
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
|
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
|
||||||
|
@ -41,7 +41,7 @@ class BackUpState {
|
||||||
final Set<AvailableAlbum> excludedBackupAlbums;
|
final Set<AvailableAlbum> excludedBackupAlbums;
|
||||||
|
|
||||||
/// Assets that are not overlapping in selected backup albums and excluded backup albums
|
/// Assets that are not overlapping in selected backup albums and excluded backup albums
|
||||||
final Set<AssetEntity> allUniqueAssets;
|
final Set<BackupCandidate> allUniqueAssets;
|
||||||
|
|
||||||
/// All assets from the selected albums that have been backup
|
/// All assets from the selected albums that have been backup
|
||||||
final Set<String> selectedAlbumsBackupAssetsIds;
|
final Set<String> selectedAlbumsBackupAssetsIds;
|
||||||
|
@ -94,7 +94,7 @@ class BackUpState {
|
||||||
List<AvailableAlbum>? availableAlbums,
|
List<AvailableAlbum>? availableAlbums,
|
||||||
Set<AvailableAlbum>? selectedBackupAlbums,
|
Set<AvailableAlbum>? selectedBackupAlbums,
|
||||||
Set<AvailableAlbum>? excludedBackupAlbums,
|
Set<AvailableAlbum>? excludedBackupAlbums,
|
||||||
Set<AssetEntity>? allUniqueAssets,
|
Set<BackupCandidate>? allUniqueAssets,
|
||||||
Set<String>? selectedAlbumsBackupAssetsIds,
|
Set<String>? selectedAlbumsBackupAssetsIds,
|
||||||
CurrentUploadAsset? currentUploadAsset,
|
CurrentUploadAsset? currentUploadAsset,
|
||||||
}) {
|
}) {
|
||||||
|
|
42
mobile/lib/models/backup/success_upload_asset.model.dart
Normal file
42
mobile/lib/models/backup/success_upload_asset.model.dart
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
|
||||||
|
|
||||||
|
class SuccessUploadAsset {
|
||||||
|
final BackupCandidate candidate;
|
||||||
|
final String remoteAssetId;
|
||||||
|
final bool isDuplicate;
|
||||||
|
|
||||||
|
SuccessUploadAsset({
|
||||||
|
required this.candidate,
|
||||||
|
required this.remoteAssetId,
|
||||||
|
required this.isDuplicate,
|
||||||
|
});
|
||||||
|
|
||||||
|
SuccessUploadAsset copyWith({
|
||||||
|
BackupCandidate? candidate,
|
||||||
|
String? remoteAssetId,
|
||||||
|
bool? isDuplicate,
|
||||||
|
}) {
|
||||||
|
return SuccessUploadAsset(
|
||||||
|
candidate: candidate ?? this.candidate,
|
||||||
|
remoteAssetId: remoteAssetId ?? this.remoteAssetId,
|
||||||
|
isDuplicate: isDuplicate ?? this.isDuplicate,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'SuccessUploadAsset(asset: $candidate, remoteAssetId: $remoteAssetId, isDuplicate: $isDuplicate)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(covariant SuccessUploadAsset other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
|
return other.candidate == candidate &&
|
||||||
|
other.remoteAssetId == remoteAssetId &&
|
||||||
|
other.isDuplicate == isDuplicate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
candidate.hashCode ^ remoteAssetId.hashCode ^ isDuplicate.hashCode;
|
||||||
|
}
|
|
@ -4,19 +4,24 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.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.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/album_info_card.dart';
|
import 'package:immich_mobile/widgets/backup/album_info_card.dart';
|
||||||
import 'package:immich_mobile/widgets/backup/album_info_list_tile.dart';
|
import 'package:immich_mobile/widgets/backup/album_info_list_tile.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
|
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
|
||||||
|
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
|
||||||
|
|
||||||
@RoutePage()
|
@RoutePage()
|
||||||
class BackupAlbumSelectionPage extends HookConsumerWidget {
|
class BackupAlbumSelectionPage extends HookConsumerWidget {
|
||||||
const BackupAlbumSelectionPage({super.key});
|
const BackupAlbumSelectionPage({super.key});
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
// final availableAlbums = ref.watch(backupProvider).availableAlbums;
|
|
||||||
final selectedBackupAlbums = ref.watch(backupProvider).selectedBackupAlbums;
|
final selectedBackupAlbums = ref.watch(backupProvider).selectedBackupAlbums;
|
||||||
final excludedBackupAlbums = ref.watch(backupProvider).excludedBackupAlbums;
|
final excludedBackupAlbums = ref.watch(backupProvider).excludedBackupAlbums;
|
||||||
|
final enableSyncUploadAlbum =
|
||||||
|
useAppSettingsState(AppSettingsEnum.syncAlbums);
|
||||||
final isDarkTheme = context.isDarkTheme;
|
final isDarkTheme = context.isDarkTheme;
|
||||||
final albums = ref.watch(backupProvider).availableAlbums;
|
final albums = ref.watch(backupProvider).availableAlbums;
|
||||||
|
|
||||||
|
@ -144,47 +149,14 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
||||||
}).toSet();
|
}).toSet();
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildSearchBar() {
|
handleSyncAlbumToggle(bool isEnable) async {
|
||||||
// return Padding(
|
if (isEnable) {
|
||||||
// padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 8.0),
|
await ref.read(albumProvider.notifier).getAllAlbums();
|
||||||
// child: TextFormField(
|
for (final album in selectedBackupAlbums) {
|
||||||
// onChanged: (searchValue) {
|
await ref.read(albumProvider.notifier).createSyncAlbum(album.name);
|
||||||
// // if (searchValue.isEmpty) {
|
}
|
||||||
// // albums = availableAlbums;
|
}
|
||||||
// // } else {
|
}
|
||||||
// // albums.value = availableAlbums
|
|
||||||
// // .where(
|
|
||||||
// // (album) => album.name
|
|
||||||
// // .toLowerCase()
|
|
||||||
// // .contains(searchValue.toLowerCase()),
|
|
||||||
// // )
|
|
||||||
// // .toList();
|
|
||||||
// // }
|
|
||||||
// },
|
|
||||||
// decoration: InputDecoration(
|
|
||||||
// contentPadding: const EdgeInsets.symmetric(
|
|
||||||
// horizontal: 8.0,
|
|
||||||
// vertical: 8.0,
|
|
||||||
// ),
|
|
||||||
// hintText: "Search",
|
|
||||||
// hintStyle: TextStyle(
|
|
||||||
// color: isDarkTheme ? Colors.white : Colors.grey,
|
|
||||||
// fontSize: 14.0,
|
|
||||||
// ),
|
|
||||||
// prefixIcon: const Icon(
|
|
||||||
// Icons.search,
|
|
||||||
// color: Colors.grey,
|
|
||||||
// ),
|
|
||||||
// border: OutlineInputBorder(
|
|
||||||
// borderRadius: BorderRadius.circular(10),
|
|
||||||
// borderSide: BorderSide.none,
|
|
||||||
// ),
|
|
||||||
// filled: true,
|
|
||||||
// fillColor: isDarkTheme ? Colors.white30 : Colors.grey[200],
|
|
||||||
// ),
|
|
||||||
// ),
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
|
@ -226,6 +198,20 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
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(
|
ListTile(
|
||||||
title: Text(
|
title: Text(
|
||||||
"backup_album_selection_page_albums_device".tr(
|
"backup_album_selection_page_albums_device".tr(
|
||||||
|
|
|
@ -23,6 +23,7 @@ class AlbumNotifier extends StateNotifier<List<Album>> {
|
||||||
});
|
});
|
||||||
_streamSub = query.watch().listen((data) => state = data);
|
_streamSub = query.watch().listen((data) => state = data);
|
||||||
}
|
}
|
||||||
|
|
||||||
final AlbumService _albumService;
|
final AlbumService _albumService;
|
||||||
late final StreamSubscription<List<Album>> _streamSub;
|
late final StreamSubscription<List<Album>> _streamSub;
|
||||||
|
|
||||||
|
@ -41,6 +42,23 @@ class AlbumNotifier extends StateNotifier<List<Album>> {
|
||||||
) =>
|
) =>
|
||||||
_albumService.createAlbum(albumTitle, assets, []);
|
_albumService.createAlbum(albumTitle, assets, []);
|
||||||
|
|
||||||
|
Future<Album?> getAlbumByName(String albumName, {bool remoteOnly = false}) =>
|
||||||
|
_albumService.getAlbumByName(albumName, remoteOnly);
|
||||||
|
|
||||||
|
/// Create an album on the server with the same name as the selected album for backup
|
||||||
|
/// First this will check if the album already exists on the server with name
|
||||||
|
/// If it does not exist, it will create the album on the server
|
||||||
|
Future<void> createSyncAlbum(
|
||||||
|
String albumName,
|
||||||
|
) async {
|
||||||
|
final album = await getAlbumByName(albumName, remoteOnly: true);
|
||||||
|
if (album != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await createAlbum(albumName, {});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_streamSub.cancel();
|
_streamSub.cancel();
|
||||||
|
|
|
@ -2,13 +2,16 @@ import 'dart:io';
|
||||||
|
|
||||||
import 'package:cancellation_token_http/http.dart';
|
import 'package:cancellation_token_http/http.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/models/backup/available_album.model.dart';
|
import 'package:immich_mobile/models/backup/available_album.model.dart';
|
||||||
import 'package:immich_mobile/entities/backup_album.entity.dart';
|
import 'package:immich_mobile/entities/backup_album.entity.dart';
|
||||||
|
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
|
||||||
import 'package:immich_mobile/models/backup/backup_state.model.dart';
|
import 'package:immich_mobile/models/backup/backup_state.model.dart';
|
||||||
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
|
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
|
||||||
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
|
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/models/backup/success_upload_asset.model.dart';
|
||||||
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
|
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
|
||||||
import 'package:immich_mobile/services/background.service.dart';
|
import 'package:immich_mobile/services/background.service.dart';
|
||||||
import 'package:immich_mobile/services/backup.service.dart';
|
import 'package:immich_mobile/services/backup.service.dart';
|
||||||
|
@ -290,8 +293,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||||
///
|
///
|
||||||
Future<void> _updateBackupAssetCount() async {
|
Future<void> _updateBackupAssetCount() async {
|
||||||
final duplicatedAssetIds = await _backupService.getDuplicatedAssetIds();
|
final duplicatedAssetIds = await _backupService.getDuplicatedAssetIds();
|
||||||
final Set<AssetEntity> assetsFromSelectedAlbums = {};
|
final Set<BackupCandidate> assetsFromSelectedAlbums = {};
|
||||||
final Set<AssetEntity> assetsFromExcludedAlbums = {};
|
final Set<BackupCandidate> assetsFromExcludedAlbums = {};
|
||||||
|
|
||||||
for (final album in state.selectedBackupAlbums) {
|
for (final album in state.selectedBackupAlbums) {
|
||||||
final assetCount = await album.albumEntity.assetCountAsync;
|
final assetCount = await album.albumEntity.assetCountAsync;
|
||||||
|
@ -304,7 +307,27 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||||
start: 0,
|
start: 0,
|
||||||
end: assetCount,
|
end: assetCount,
|
||||||
);
|
);
|
||||||
assetsFromSelectedAlbums.addAll(assets);
|
|
||||||
|
// Add album's name to the asset info
|
||||||
|
for (final asset in assets) {
|
||||||
|
List<String> albumNames = [album.name];
|
||||||
|
|
||||||
|
final existingAsset = assetsFromSelectedAlbums.firstWhereOrNull(
|
||||||
|
(a) => a.asset.id == asset.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingAsset != null) {
|
||||||
|
albumNames.addAll(existingAsset.albumNames);
|
||||||
|
assetsFromSelectedAlbums.remove(existingAsset);
|
||||||
|
}
|
||||||
|
|
||||||
|
assetsFromSelectedAlbums.add(
|
||||||
|
BackupCandidate(
|
||||||
|
asset: asset,
|
||||||
|
albumNames: albumNames,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (final album in state.excludedBackupAlbums) {
|
for (final album in state.excludedBackupAlbums) {
|
||||||
|
@ -318,11 +341,17 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||||
start: 0,
|
start: 0,
|
||||||
end: assetCount,
|
end: assetCount,
|
||||||
);
|
);
|
||||||
assetsFromExcludedAlbums.addAll(assets);
|
|
||||||
|
for (final asset in assets) {
|
||||||
|
assetsFromExcludedAlbums.add(
|
||||||
|
BackupCandidate(asset: asset, albumNames: [album.name]),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final Set<AssetEntity> allUniqueAssets =
|
final Set<BackupCandidate> allUniqueAssets =
|
||||||
assetsFromSelectedAlbums.difference(assetsFromExcludedAlbums);
|
assetsFromSelectedAlbums.difference(assetsFromExcludedAlbums);
|
||||||
|
|
||||||
final allAssetsInDatabase = await _backupService.getDeviceBackupAsset();
|
final allAssetsInDatabase = await _backupService.getDeviceBackupAsset();
|
||||||
|
|
||||||
if (allAssetsInDatabase == null) {
|
if (allAssetsInDatabase == null) {
|
||||||
|
@ -331,14 +360,14 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||||
|
|
||||||
// Find asset that were backup from selected albums
|
// Find asset that were backup from selected albums
|
||||||
final Set<String> selectedAlbumsBackupAssets =
|
final Set<String> selectedAlbumsBackupAssets =
|
||||||
Set.from(allUniqueAssets.map((e) => e.id));
|
Set.from(allUniqueAssets.map((e) => e.asset.id));
|
||||||
|
|
||||||
selectedAlbumsBackupAssets
|
selectedAlbumsBackupAssets
|
||||||
.removeWhere((assetId) => !allAssetsInDatabase.contains(assetId));
|
.removeWhere((assetId) => !allAssetsInDatabase.contains(assetId));
|
||||||
|
|
||||||
// Remove duplicated asset from all unique assets
|
// Remove duplicated asset from all unique assets
|
||||||
allUniqueAssets.removeWhere(
|
allUniqueAssets.removeWhere(
|
||||||
(asset) => duplicatedAssetIds.contains(asset.id),
|
(candidate) => duplicatedAssetIds.contains(candidate.asset.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (allUniqueAssets.isEmpty) {
|
if (allUniqueAssets.isEmpty) {
|
||||||
|
@ -433,10 +462,10 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Set<AssetEntity> assetsWillBeBackup = Set.from(state.allUniqueAssets);
|
Set<BackupCandidate> assetsWillBeBackup = Set.from(state.allUniqueAssets);
|
||||||
// Remove item that has already been backed up
|
// Remove item that has already been backed up
|
||||||
for (final assetId in state.allAssetsInDatabase) {
|
for (final assetId in state.allAssetsInDatabase) {
|
||||||
assetsWillBeBackup.removeWhere((e) => e.id == assetId);
|
assetsWillBeBackup.removeWhere((e) => e.asset.id == assetId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (assetsWillBeBackup.isEmpty) {
|
if (assetsWillBeBackup.isEmpty) {
|
||||||
|
@ -456,11 +485,11 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||||
await _backupService.backupAsset(
|
await _backupService.backupAsset(
|
||||||
assetsWillBeBackup,
|
assetsWillBeBackup,
|
||||||
state.cancelToken,
|
state.cancelToken,
|
||||||
pmProgressHandler,
|
pmProgressHandler: pmProgressHandler,
|
||||||
_onAssetUploaded,
|
onSuccess: _onAssetUploaded,
|
||||||
_onUploadProgress,
|
onProgress: _onUploadProgress,
|
||||||
_onSetCurrentBackupAsset,
|
onCurrentAsset: _onSetCurrentBackupAsset,
|
||||||
_onBackupError,
|
onError: _onBackupError,
|
||||||
);
|
);
|
||||||
await notifyBackgroundServiceCanRun();
|
await notifyBackgroundServiceCanRun();
|
||||||
} else {
|
} else {
|
||||||
|
@ -497,34 +526,36 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onAssetUploaded(
|
void _onAssetUploaded(SuccessUploadAsset result) async {
|
||||||
String deviceAssetId,
|
if (result.isDuplicate) {
|
||||||
String deviceId,
|
|
||||||
bool isDuplicated,
|
|
||||||
) {
|
|
||||||
if (isDuplicated) {
|
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
allUniqueAssets: state.allUniqueAssets
|
allUniqueAssets: state.allUniqueAssets
|
||||||
.where((asset) => asset.id != deviceAssetId)
|
.where(
|
||||||
|
(candidate) => candidate.asset.id != result.candidate.asset.id,
|
||||||
|
)
|
||||||
.toSet(),
|
.toSet(),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
selectedAlbumsBackupAssetsIds: {
|
selectedAlbumsBackupAssetsIds: {
|
||||||
...state.selectedAlbumsBackupAssetsIds,
|
...state.selectedAlbumsBackupAssetsIds,
|
||||||
deviceAssetId,
|
result.candidate.asset.id,
|
||||||
},
|
},
|
||||||
allAssetsInDatabase: [...state.allAssetsInDatabase, deviceAssetId],
|
allAssetsInDatabase: [
|
||||||
|
...state.allAssetsInDatabase,
|
||||||
|
result.candidate.asset.id,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.allUniqueAssets.length -
|
if (state.allUniqueAssets.length -
|
||||||
state.selectedAlbumsBackupAssetsIds.length ==
|
state.selectedAlbumsBackupAssetsIds.length ==
|
||||||
0) {
|
0) {
|
||||||
final latestAssetBackup =
|
final latestAssetBackup = state.allUniqueAssets
|
||||||
state.allUniqueAssets.map((e) => e.modifiedDateTime).reduce(
|
.map((candidate) => candidate.asset.modifiedDateTime)
|
||||||
(v, e) => e.isAfter(v) ? e : v,
|
.reduce(
|
||||||
);
|
(v, e) => e.isAfter(v) ? e : v,
|
||||||
|
);
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
selectedBackupAlbums: state.selectedBackupAlbums
|
selectedBackupAlbums: state.selectedBackupAlbums
|
||||||
.map((e) => e.copyWith(lastBackup: latestAssetBackup))
|
.map((e) => e.copyWith(lastBackup: latestAssetBackup))
|
||||||
|
|
|
@ -6,6 +6,8 @@ import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:fluttertoast/fluttertoast.dart';
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
|
||||||
|
import 'package:immich_mobile/models/backup/success_upload_asset.model.dart';
|
||||||
import 'package:immich_mobile/services/background.service.dart';
|
import 'package:immich_mobile/services/background.service.dart';
|
||||||
import 'package:immich_mobile/models/backup/backup_state.model.dart';
|
import 'package:immich_mobile/models/backup/backup_state.model.dart';
|
||||||
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
|
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
|
||||||
|
@ -22,6 +24,7 @@ import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
|
||||||
import 'package:immich_mobile/services/local_notification.service.dart';
|
import 'package:immich_mobile/services/local_notification.service.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||||
import 'package:immich_mobile/utils/backup_progress.dart';
|
import 'package:immich_mobile/utils/backup_progress.dart';
|
||||||
|
import 'package:isar/isar.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
@ -31,6 +34,7 @@ final manualUploadProvider =
|
||||||
return ManualUploadNotifier(
|
return ManualUploadNotifier(
|
||||||
ref.watch(localNotificationService),
|
ref.watch(localNotificationService),
|
||||||
ref.watch(backupProvider.notifier),
|
ref.watch(backupProvider.notifier),
|
||||||
|
ref.watch(backupServiceProvider),
|
||||||
ref,
|
ref,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -39,11 +43,13 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
|
||||||
final Logger _log = Logger("ManualUploadNotifier");
|
final Logger _log = Logger("ManualUploadNotifier");
|
||||||
final LocalNotificationService _localNotificationService;
|
final LocalNotificationService _localNotificationService;
|
||||||
final BackupNotifier _backupProvider;
|
final BackupNotifier _backupProvider;
|
||||||
|
final BackupService _backupService;
|
||||||
final Ref ref;
|
final Ref ref;
|
||||||
|
|
||||||
ManualUploadNotifier(
|
ManualUploadNotifier(
|
||||||
this._localNotificationService,
|
this._localNotificationService,
|
||||||
this._backupProvider,
|
this._backupProvider,
|
||||||
|
this._backupService,
|
||||||
this.ref,
|
this.ref,
|
||||||
) : super(
|
) : super(
|
||||||
ManualUploadState(
|
ManualUploadState(
|
||||||
|
@ -115,11 +121,7 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onAssetUploaded(
|
void _onAssetUploaded(SuccessUploadAsset result) {
|
||||||
String deviceAssetId,
|
|
||||||
String deviceId,
|
|
||||||
bool isDuplicated,
|
|
||||||
) {
|
|
||||||
state = state.copyWith(successfulUploads: state.successfulUploads + 1);
|
state = state.copyWith(successfulUploads: state.successfulUploads + 1);
|
||||||
_backupProvider.updateDiskInfo();
|
_backupProvider.updateDiskInfo();
|
||||||
}
|
}
|
||||||
|
@ -209,9 +211,23 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Set<AssetEntity> allUploadAssets = allAssetsFromDevice.nonNulls.toSet();
|
final selectedBackupAlbums =
|
||||||
|
_backupService.selectedAlbumsQuery().findAllSync();
|
||||||
|
final excludedBackupAlbums =
|
||||||
|
_backupService.excludedAlbumsQuery().findAllSync();
|
||||||
|
|
||||||
if (allUploadAssets.isEmpty) {
|
// Get candidates from selected albums and excluded albums
|
||||||
|
Set<BackupCandidate> candidates =
|
||||||
|
await _backupService.buildUploadCandidates(
|
||||||
|
selectedBackupAlbums,
|
||||||
|
excludedBackupAlbums,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extrack candidate from allAssetsFromDevice.nonNulls
|
||||||
|
final uploadAssets = candidates
|
||||||
|
.where((e) => allAssetsFromDevice.nonNulls.contains(e.asset));
|
||||||
|
|
||||||
|
if (uploadAssets.isEmpty) {
|
||||||
debugPrint("[_startUpload] No Assets to upload - Abort Process");
|
debugPrint("[_startUpload] No Assets to upload - Abort Process");
|
||||||
_backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
|
_backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
|
||||||
return false;
|
return false;
|
||||||
|
@ -221,7 +237,7 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
|
||||||
progressInPercentage: 0,
|
progressInPercentage: 0,
|
||||||
progressInFileSize: "0 B / 0 B",
|
progressInFileSize: "0 B / 0 B",
|
||||||
progressInFileSpeed: 0,
|
progressInFileSpeed: 0,
|
||||||
totalAssetsToUpload: allUploadAssets.length,
|
totalAssetsToUpload: uploadAssets.length,
|
||||||
successfulUploads: 0,
|
successfulUploads: 0,
|
||||||
currentAssetIndex: 0,
|
currentAssetIndex: 0,
|
||||||
currentUploadAsset: CurrentUploadAsset(
|
currentUploadAsset: CurrentUploadAsset(
|
||||||
|
@ -250,13 +266,13 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
|
||||||
final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null;
|
final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null;
|
||||||
|
|
||||||
final bool ok = await ref.read(backupServiceProvider).backupAsset(
|
final bool ok = await ref.read(backupServiceProvider).backupAsset(
|
||||||
allUploadAssets,
|
uploadAssets,
|
||||||
state.cancelToken,
|
state.cancelToken,
|
||||||
pmProgressHandler,
|
pmProgressHandler: pmProgressHandler,
|
||||||
_onAssetUploaded,
|
onSuccess: _onAssetUploaded,
|
||||||
_onProgress,
|
onProgress: _onProgress,
|
||||||
_onSetCurrentBackupAsset,
|
onCurrentAsset: _onSetCurrentBackupAsset,
|
||||||
_onAssetUploadError,
|
onError: _onAssetUploadError,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Close detailed notification
|
// Close detailed notification
|
||||||
|
|
|
@ -7,7 +7,6 @@ import 'package:flutter/foundation.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/models/albums/album_add_asset_response.model.dart';
|
import 'package:immich_mobile/models/albums/album_add_asset_response.model.dart';
|
||||||
import 'package:immich_mobile/entities/backup_album.entity.dart';
|
import 'package:immich_mobile/entities/backup_album.entity.dart';
|
||||||
import 'package:immich_mobile/services/backup.service.dart';
|
|
||||||
import 'package:immich_mobile/entities/album.entity.dart';
|
import 'package:immich_mobile/entities/album.entity.dart';
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
|
@ -28,7 +27,6 @@ final albumServiceProvider = Provider(
|
||||||
ref.watch(userServiceProvider),
|
ref.watch(userServiceProvider),
|
||||||
ref.watch(syncServiceProvider),
|
ref.watch(syncServiceProvider),
|
||||||
ref.watch(dbProvider),
|
ref.watch(dbProvider),
|
||||||
ref.watch(backupServiceProvider),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -37,7 +35,6 @@ class AlbumService {
|
||||||
final UserService _userService;
|
final UserService _userService;
|
||||||
final SyncService _syncService;
|
final SyncService _syncService;
|
||||||
final Isar _db;
|
final Isar _db;
|
||||||
final BackupService _backupService;
|
|
||||||
final Logger _log = Logger('AlbumService');
|
final Logger _log = Logger('AlbumService');
|
||||||
Completer<bool> _localCompleter = Completer()..complete(false);
|
Completer<bool> _localCompleter = Completer()..complete(false);
|
||||||
Completer<bool> _remoteCompleter = Completer()..complete(false);
|
Completer<bool> _remoteCompleter = Completer()..complete(false);
|
||||||
|
@ -47,9 +44,15 @@ class AlbumService {
|
||||||
this._userService,
|
this._userService,
|
||||||
this._syncService,
|
this._syncService,
|
||||||
this._db,
|
this._db,
|
||||||
this._backupService,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
|
||||||
|
selectedAlbumsQuery() =>
|
||||||
|
_db.backupAlbums.filter().selectionEqualTo(BackupSelection.select);
|
||||||
|
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
|
||||||
|
excludedAlbumsQuery() =>
|
||||||
|
_db.backupAlbums.filter().selectionEqualTo(BackupSelection.exclude);
|
||||||
|
|
||||||
/// Checks all selected device albums for changes of albums and their assets
|
/// Checks all selected device albums for changes of albums and their assets
|
||||||
/// Updates the local database and returns `true` if there were any changes
|
/// Updates the local database and returns `true` if there were any changes
|
||||||
Future<bool> refreshDeviceAlbums() async {
|
Future<bool> refreshDeviceAlbums() async {
|
||||||
|
@ -63,9 +66,9 @@ class AlbumService {
|
||||||
bool changes = false;
|
bool changes = false;
|
||||||
try {
|
try {
|
||||||
final List<String> excludedIds =
|
final List<String> excludedIds =
|
||||||
await _backupService.excludedAlbumsQuery().idProperty().findAll();
|
await excludedAlbumsQuery().idProperty().findAll();
|
||||||
final List<String> selectedIds =
|
final List<String> selectedIds =
|
||||||
await _backupService.selectedAlbumsQuery().idProperty().findAll();
|
await selectedAlbumsQuery().idProperty().findAll();
|
||||||
if (selectedIds.isEmpty) {
|
if (selectedIds.isEmpty) {
|
||||||
final numLocal = await _db.albums.where().localIdIsNotNull().count();
|
final numLocal = await _db.albums.where().localIdIsNotNull().count();
|
||||||
if (numLocal > 0) {
|
if (numLocal > 0) {
|
||||||
|
@ -441,4 +444,33 @@ class AlbumService {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Album?> getAlbumByName(String name, bool remoteOnly) async {
|
||||||
|
return _db.albums
|
||||||
|
.filter()
|
||||||
|
.optional(remoteOnly, (q) => q.localIdIsNull())
|
||||||
|
.nameEqualTo(name)
|
||||||
|
.sharedEqualTo(false)
|
||||||
|
.findFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
///
|
||||||
|
/// Add the uploaded asset to the selected albums
|
||||||
|
///
|
||||||
|
Future<void> syncUploadAlbums(
|
||||||
|
List<String> albumNames,
|
||||||
|
List<String> assetIds,
|
||||||
|
) async {
|
||||||
|
for (final albumName in albumNames) {
|
||||||
|
Album? album = await getAlbumByName(albumName, true);
|
||||||
|
album ??= await createAlbum(albumName, []);
|
||||||
|
|
||||||
|
if (album != null && album.remoteId != null) {
|
||||||
|
await _apiService.albumsApi.addAssetsToAlbum(
|
||||||
|
album.remoteId!,
|
||||||
|
BulkIdsDto(ids: assetIds),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -76,6 +76,7 @@ enum AppSettingsEnum<T> {
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
enableHapticFeedback<bool>(StoreKey.enableHapticFeedback, null, true),
|
enableHapticFeedback<bool>(StoreKey.enableHapticFeedback, null, true),
|
||||||
|
syncAlbums<bool>(StoreKey.syncAlbums, null, false),
|
||||||
;
|
;
|
||||||
|
|
||||||
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);
|
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);
|
||||||
|
|
|
@ -2,15 +2,20 @@
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
import 'package:immich_mobile/entities/etag.entity.dart';
|
import 'package:immich_mobile/entities/etag.entity.dart';
|
||||||
import 'package:immich_mobile/entities/exif_info.entity.dart';
|
import 'package:immich_mobile/entities/exif_info.entity.dart';
|
||||||
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/entities/user.entity.dart';
|
import 'package:immich_mobile/entities/user.entity.dart';
|
||||||
|
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
|
||||||
import 'package:immich_mobile/providers/api.provider.dart';
|
import 'package:immich_mobile/providers/api.provider.dart';
|
||||||
import 'package:immich_mobile/providers/db.provider.dart';
|
import 'package:immich_mobile/providers/db.provider.dart';
|
||||||
|
import 'package:immich_mobile/services/album.service.dart';
|
||||||
import 'package:immich_mobile/services/api.service.dart';
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
|
import 'package:immich_mobile/services/backup.service.dart';
|
||||||
import 'package:immich_mobile/services/sync.service.dart';
|
import 'package:immich_mobile/services/sync.service.dart';
|
||||||
import 'package:immich_mobile/services/user.service.dart';
|
import 'package:immich_mobile/services/user.service.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
|
@ -23,6 +28,8 @@ final assetServiceProvider = Provider(
|
||||||
ref.watch(apiServiceProvider),
|
ref.watch(apiServiceProvider),
|
||||||
ref.watch(syncServiceProvider),
|
ref.watch(syncServiceProvider),
|
||||||
ref.watch(userServiceProvider),
|
ref.watch(userServiceProvider),
|
||||||
|
ref.watch(backupServiceProvider),
|
||||||
|
ref.watch(albumServiceProvider),
|
||||||
ref.watch(dbProvider),
|
ref.watch(dbProvider),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -31,6 +38,8 @@ class AssetService {
|
||||||
final ApiService _apiService;
|
final ApiService _apiService;
|
||||||
final SyncService _syncService;
|
final SyncService _syncService;
|
||||||
final UserService _userService;
|
final UserService _userService;
|
||||||
|
final BackupService _backupService;
|
||||||
|
final AlbumService _albumService;
|
||||||
final log = Logger('AssetService');
|
final log = Logger('AssetService');
|
||||||
final Isar _db;
|
final Isar _db;
|
||||||
|
|
||||||
|
@ -38,6 +47,8 @@ class AssetService {
|
||||||
this._apiService,
|
this._apiService,
|
||||||
this._syncService,
|
this._syncService,
|
||||||
this._userService,
|
this._userService,
|
||||||
|
this._backupService,
|
||||||
|
this._albumService,
|
||||||
this._db,
|
this._db,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -284,4 +295,64 @@ class AssetService {
|
||||||
return Future.value(null);
|
return Future.value(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> syncUploadedAssetToAlbums() async {
|
||||||
|
try {
|
||||||
|
final [selectedAlbums, excludedAlbums] = await Future.wait([
|
||||||
|
_backupService.selectedAlbumsQuery().findAll(),
|
||||||
|
_backupService.excludedAlbumsQuery().findAll(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
final candidates = await _backupService.buildUploadCandidates(
|
||||||
|
selectedAlbums,
|
||||||
|
excludedAlbums,
|
||||||
|
useTimeFilter: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
final duplicates = await _apiService.assetsApi.checkExistingAssets(
|
||||||
|
CheckExistingAssetsDto(
|
||||||
|
deviceAssetIds: candidates.map((c) => c.asset.id).toList(),
|
||||||
|
deviceId: Store.get(StoreKey.deviceId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (duplicates != null) {
|
||||||
|
candidates
|
||||||
|
.removeWhere((c) => !duplicates.existingIds.contains(c.asset.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshRemoteAssets();
|
||||||
|
final remoteAssets = await _db.assets
|
||||||
|
.where()
|
||||||
|
.localIdIsNotNull()
|
||||||
|
.filter()
|
||||||
|
.remoteIdIsNotNull()
|
||||||
|
.findAll();
|
||||||
|
|
||||||
|
/// Map<AlbumName, [AssetId]>
|
||||||
|
Map<String, List<String>> assetToAlbums = {};
|
||||||
|
|
||||||
|
for (BackupCandidate candidate in candidates) {
|
||||||
|
final asset = remoteAssets.firstWhereOrNull(
|
||||||
|
(a) => a.localId == candidate.asset.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (asset != null) {
|
||||||
|
for (final albumName in candidate.albumNames) {
|
||||||
|
assetToAlbums.putIfAbsent(albumName, () => []).add(asset.remoteId!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload assets to albums
|
||||||
|
for (final entry in assetToAlbums.entries) {
|
||||||
|
final albumName = entry.key;
|
||||||
|
final assetIds = entry.value;
|
||||||
|
|
||||||
|
await _albumService.syncUploadAlbums([albumName], assetIds);
|
||||||
|
}
|
||||||
|
} catch (error, stack) {
|
||||||
|
log.severe("Error while syncing uploaded asset to albums", error, stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,10 @@ import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/main.dart';
|
import 'package:immich_mobile/main.dart';
|
||||||
|
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
|
||||||
|
import 'package:immich_mobile/models/backup/success_upload_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/services/album.service.dart';
|
||||||
|
import 'package:immich_mobile/services/hash.service.dart';
|
||||||
import 'package:immich_mobile/services/localization.service.dart';
|
import 'package:immich_mobile/services/localization.service.dart';
|
||||||
import 'package:immich_mobile/entities/backup_album.entity.dart';
|
import 'package:immich_mobile/entities/backup_album.entity.dart';
|
||||||
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
|
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
|
||||||
|
@ -18,6 +22,9 @@ import 'package:immich_mobile/services/backup.service.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/services/api.service.dart';
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
|
import 'package:immich_mobile/services/partner.service.dart';
|
||||||
|
import 'package:immich_mobile/services/sync.service.dart';
|
||||||
|
import 'package:immich_mobile/services/user.service.dart';
|
||||||
import 'package:immich_mobile/utils/backup_progress.dart';
|
import 'package:immich_mobile/utils/backup_progress.dart';
|
||||||
import 'package:immich_mobile/utils/diff.dart';
|
import 'package:immich_mobile/utils/diff.dart';
|
||||||
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
|
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
|
||||||
|
@ -345,8 +352,16 @@ class BackgroundService {
|
||||||
ApiService apiService = ApiService();
|
ApiService apiService = ApiService();
|
||||||
apiService.setAccessToken(Store.get(StoreKey.accessToken));
|
apiService.setAccessToken(Store.get(StoreKey.accessToken));
|
||||||
AppSettingsService settingService = AppSettingsService();
|
AppSettingsService settingService = AppSettingsService();
|
||||||
BackupService backupService = BackupService(apiService, db, settingService);
|
|
||||||
AppSettingsService settingsService = AppSettingsService();
|
AppSettingsService settingsService = AppSettingsService();
|
||||||
|
PartnerService partnerService = PartnerService(apiService, db);
|
||||||
|
HashService hashService = HashService(db, this);
|
||||||
|
SyncService syncSerive = SyncService(db, hashService);
|
||||||
|
UserService userService =
|
||||||
|
UserService(apiService, db, syncSerive, partnerService);
|
||||||
|
AlbumService albumService =
|
||||||
|
AlbumService(apiService, userService, syncSerive, db);
|
||||||
|
BackupService backupService =
|
||||||
|
BackupService(apiService, db, settingService, albumService);
|
||||||
|
|
||||||
final selectedAlbums = backupService.selectedAlbumsQuery().findAllSync();
|
final selectedAlbums = backupService.selectedAlbumsQuery().findAllSync();
|
||||||
final excludedAlbums = backupService.excludedAlbumsQuery().findAllSync();
|
final excludedAlbums = backupService.excludedAlbumsQuery().findAllSync();
|
||||||
|
@ -416,7 +431,7 @@ class BackgroundService {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<AssetEntity> toUpload = await backupService.buildUploadCandidates(
|
Set<BackupCandidate> toUpload = await backupService.buildUploadCandidates(
|
||||||
selectedAlbums,
|
selectedAlbums,
|
||||||
excludedAlbums,
|
excludedAlbums,
|
||||||
);
|
);
|
||||||
|
@ -460,29 +475,47 @@ class BackgroundService {
|
||||||
final bool ok = await backupService.backupAsset(
|
final bool ok = await backupService.backupAsset(
|
||||||
toUpload,
|
toUpload,
|
||||||
_cancellationToken!,
|
_cancellationToken!,
|
||||||
pmProgressHandler,
|
pmProgressHandler: pmProgressHandler,
|
||||||
notifyTotalProgress ? _onAssetUploaded : (assetId, deviceId, isDup) {},
|
onSuccess: (result) => _onAssetUploaded(
|
||||||
notifySingleProgress ? _onProgress : (sent, total) {},
|
result: result,
|
||||||
notifySingleProgress ? _onSetCurrentBackupAsset : (asset) {},
|
shouldNotify: notifyTotalProgress,
|
||||||
_onBackupError,
|
),
|
||||||
sortAssets: true,
|
onProgress: (bytes, totalBytes) =>
|
||||||
|
_onProgress(bytes, totalBytes, shouldNotify: notifySingleProgress),
|
||||||
|
onCurrentAsset: (asset) =>
|
||||||
|
_onSetCurrentBackupAsset(asset, shouldNotify: notifySingleProgress),
|
||||||
|
onError: _onBackupError,
|
||||||
|
isBackground: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!ok && !_cancellationToken!.isCancelled) {
|
if (!ok && !_cancellationToken!.isCancelled) {
|
||||||
_showErrorNotification(
|
_showErrorNotification(
|
||||||
title: "backup_background_service_error_title".tr(),
|
title: "backup_background_service_error_title".tr(),
|
||||||
content: "backup_background_service_backup_failed_message".tr(),
|
content: "backup_background_service_backup_failed_message".tr(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onAssetUploaded(String deviceAssetId, String deviceId, bool isDup) {
|
void _onAssetUploaded({
|
||||||
|
required SuccessUploadAsset result,
|
||||||
|
bool shouldNotify = false,
|
||||||
|
}) async {
|
||||||
|
if (!shouldNotify) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_uploadedAssetsCount++;
|
_uploadedAssetsCount++;
|
||||||
_throttledNotifiy();
|
_throttledNotifiy();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onProgress(int sent, int total) {
|
void _onProgress(int bytes, int totalBytes, {bool shouldNotify = false}) {
|
||||||
_throttledDetailNotify(progress: sent, total: total);
|
if (!shouldNotify) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_throttledDetailNotify(progress: bytes, total: totalBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _updateDetailProgress(String? title, int progress, int total) {
|
void _updateDetailProgress(String? title, int progress, int total) {
|
||||||
|
@ -522,7 +555,14 @@ class BackgroundService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) {
|
void _onSetCurrentBackupAsset(
|
||||||
|
CurrentUploadAsset currentUploadAsset, {
|
||||||
|
bool shouldNotify = false,
|
||||||
|
}) {
|
||||||
|
if (!shouldNotify) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_throttledDetailNotify.title =
|
_throttledDetailNotify.title =
|
||||||
"backup_background_service_current_upload_notification"
|
"backup_background_service_current_upload_notification"
|
||||||
.tr(args: [currentUploadAsset.fileName]);
|
.tr(args: [currentUploadAsset.fileName]);
|
||||||
|
|
|
@ -9,11 +9,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/entities/backup_album.entity.dart';
|
import 'package:immich_mobile/entities/backup_album.entity.dart';
|
||||||
import 'package:immich_mobile/entities/duplicated_asset.entity.dart';
|
import 'package:immich_mobile/entities/duplicated_asset.entity.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
|
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
|
||||||
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
|
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
|
||||||
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
|
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/models/backup/success_upload_asset.model.dart';
|
||||||
import 'package:immich_mobile/providers/api.provider.dart';
|
import 'package:immich_mobile/providers/api.provider.dart';
|
||||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||||
import 'package:immich_mobile/providers/db.provider.dart';
|
import 'package:immich_mobile/providers/db.provider.dart';
|
||||||
|
import 'package:immich_mobile/services/album.service.dart';
|
||||||
import 'package:immich_mobile/services/api.service.dart';
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
|
@ -28,6 +31,7 @@ final backupServiceProvider = Provider(
|
||||||
ref.watch(apiServiceProvider),
|
ref.watch(apiServiceProvider),
|
||||||
ref.watch(dbProvider),
|
ref.watch(dbProvider),
|
||||||
ref.watch(appSettingsServiceProvider),
|
ref.watch(appSettingsServiceProvider),
|
||||||
|
ref.watch(albumServiceProvider),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -37,8 +41,14 @@ class BackupService {
|
||||||
final Isar _db;
|
final Isar _db;
|
||||||
final Logger _log = Logger("BackupService");
|
final Logger _log = Logger("BackupService");
|
||||||
final AppSettingsService _appSetting;
|
final AppSettingsService _appSetting;
|
||||||
|
final AlbumService _albumService;
|
||||||
|
|
||||||
BackupService(this._apiService, this._db, this._appSetting);
|
BackupService(
|
||||||
|
this._apiService,
|
||||||
|
this._db,
|
||||||
|
this._appSetting,
|
||||||
|
this._albumService,
|
||||||
|
);
|
||||||
|
|
||||||
Future<List<String>?> getDeviceBackupAsset() async {
|
Future<List<String>?> getDeviceBackupAsset() async {
|
||||||
final String deviceId = Store.get(StoreKey.deviceId);
|
final String deviceId = Store.get(StoreKey.deviceId);
|
||||||
|
@ -70,10 +80,12 @@ class BackupService {
|
||||||
_db.backupAlbums.filter().selectionEqualTo(BackupSelection.exclude);
|
_db.backupAlbums.filter().selectionEqualTo(BackupSelection.exclude);
|
||||||
|
|
||||||
/// Returns all assets newer than the last successful backup per album
|
/// Returns all assets newer than the last successful backup per album
|
||||||
Future<List<AssetEntity>> buildUploadCandidates(
|
/// if `useTimeFilter` is set to true, all assets will be returned
|
||||||
|
Future<Set<BackupCandidate>> buildUploadCandidates(
|
||||||
List<BackupAlbum> selectedBackupAlbums,
|
List<BackupAlbum> selectedBackupAlbums,
|
||||||
List<BackupAlbum> excludedBackupAlbums,
|
List<BackupAlbum> excludedBackupAlbums, {
|
||||||
) async {
|
bool useTimeFilter = true,
|
||||||
|
}) async {
|
||||||
final filter = FilterOptionGroup(
|
final filter = FilterOptionGroup(
|
||||||
containsPathModified: true,
|
containsPathModified: true,
|
||||||
orders: [const OrderOption(type: OrderOptionType.updateDate)],
|
orders: [const OrderOption(type: OrderOptionType.updateDate)],
|
||||||
|
@ -82,105 +94,156 @@ class BackupService {
|
||||||
videoOption: const FilterOption(needTitle: true),
|
videoOption: const FilterOption(needTitle: true),
|
||||||
);
|
);
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
|
|
||||||
final List<AssetPathEntity?> selectedAlbums =
|
final List<AssetPathEntity?> selectedAlbums =
|
||||||
await _loadAlbumsWithTimeFilter(selectedBackupAlbums, filter, now);
|
await _loadAlbumsWithTimeFilter(
|
||||||
|
selectedBackupAlbums,
|
||||||
|
filter,
|
||||||
|
now,
|
||||||
|
useTimeFilter: useTimeFilter,
|
||||||
|
);
|
||||||
|
|
||||||
if (selectedAlbums.every((e) => e == null)) {
|
if (selectedAlbums.every((e) => e == null)) {
|
||||||
return [];
|
return {};
|
||||||
}
|
|
||||||
final int allIdx = selectedAlbums.indexWhere((e) => e != null && e.isAll);
|
|
||||||
if (allIdx != -1) {
|
|
||||||
final List<AssetPathEntity?> excludedAlbums =
|
|
||||||
await _loadAlbumsWithTimeFilter(excludedBackupAlbums, filter, now);
|
|
||||||
final List<AssetEntity> toAdd = await _fetchAssetsAndUpdateLastBackup(
|
|
||||||
selectedAlbums.slice(allIdx, allIdx + 1),
|
|
||||||
selectedBackupAlbums.slice(allIdx, allIdx + 1),
|
|
||||||
now,
|
|
||||||
);
|
|
||||||
final List<AssetEntity> toRemove = await _fetchAssetsAndUpdateLastBackup(
|
|
||||||
excludedAlbums,
|
|
||||||
excludedBackupAlbums,
|
|
||||||
now,
|
|
||||||
);
|
|
||||||
return toAdd.toSet().difference(toRemove.toSet()).toList();
|
|
||||||
} else {
|
|
||||||
return await _fetchAssetsAndUpdateLastBackup(
|
|
||||||
selectedAlbums,
|
|
||||||
selectedBackupAlbums,
|
|
||||||
now,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final List<AssetPathEntity?> excludedAlbums =
|
||||||
|
await _loadAlbumsWithTimeFilter(
|
||||||
|
excludedBackupAlbums,
|
||||||
|
filter,
|
||||||
|
now,
|
||||||
|
useTimeFilter: useTimeFilter,
|
||||||
|
);
|
||||||
|
|
||||||
|
final Set<BackupCandidate> toAdd = await _fetchAssetsAndUpdateLastBackup(
|
||||||
|
selectedAlbums,
|
||||||
|
selectedBackupAlbums,
|
||||||
|
now,
|
||||||
|
useTimeFilter: useTimeFilter,
|
||||||
|
);
|
||||||
|
|
||||||
|
final Set<BackupCandidate> toRemove = await _fetchAssetsAndUpdateLastBackup(
|
||||||
|
excludedAlbums,
|
||||||
|
excludedBackupAlbums,
|
||||||
|
now,
|
||||||
|
useTimeFilter: useTimeFilter,
|
||||||
|
);
|
||||||
|
|
||||||
|
return toAdd.difference(toRemove);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<AssetPathEntity?>> _loadAlbumsWithTimeFilter(
|
Future<List<AssetPathEntity?>> _loadAlbumsWithTimeFilter(
|
||||||
List<BackupAlbum> albums,
|
List<BackupAlbum> albums,
|
||||||
FilterOptionGroup filter,
|
FilterOptionGroup filter,
|
||||||
DateTime now,
|
DateTime now, {
|
||||||
) async {
|
bool useTimeFilter = true,
|
||||||
|
}) async {
|
||||||
List<AssetPathEntity?> result = [];
|
List<AssetPathEntity?> result = [];
|
||||||
for (BackupAlbum a in albums) {
|
for (BackupAlbum backupAlbum in albums) {
|
||||||
try {
|
try {
|
||||||
|
final optionGroup = useTimeFilter
|
||||||
|
? filter.copyWith(
|
||||||
|
updateTimeCond: DateTimeCond(
|
||||||
|
// subtract 2 seconds to prevent missing assets due to rounding issues
|
||||||
|
min: backupAlbum.lastBackup
|
||||||
|
.subtract(const Duration(seconds: 2)),
|
||||||
|
max: now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: filter;
|
||||||
|
|
||||||
final AssetPathEntity album =
|
final AssetPathEntity album =
|
||||||
await AssetPathEntity.obtainPathFromProperties(
|
await AssetPathEntity.obtainPathFromProperties(
|
||||||
id: a.id,
|
id: backupAlbum.id,
|
||||||
optionGroup: filter.copyWith(
|
optionGroup: optionGroup,
|
||||||
updateTimeCond: DateTimeCond(
|
|
||||||
// subtract 2 seconds to prevent missing assets due to rounding issues
|
|
||||||
min: a.lastBackup.subtract(const Duration(seconds: 2)),
|
|
||||||
max: now,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
maxDateTimeToNow: false,
|
maxDateTimeToNow: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
result.add(album);
|
result.add(album);
|
||||||
} on StateError {
|
} on StateError {
|
||||||
// either there are no assets matching the filter criteria OR the album no longer exists
|
// either there are no assets matching the filter criteria OR the album no longer exists
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<AssetEntity>> _fetchAssetsAndUpdateLastBackup(
|
Future<Set<BackupCandidate>> _fetchAssetsAndUpdateLastBackup(
|
||||||
List<AssetPathEntity?> albums,
|
List<AssetPathEntity?> localAlbums,
|
||||||
List<BackupAlbum> backupAlbums,
|
List<BackupAlbum> backupAlbums,
|
||||||
DateTime now,
|
DateTime now, {
|
||||||
) async {
|
bool useTimeFilter = true,
|
||||||
List<AssetEntity> result = [];
|
}) async {
|
||||||
for (int i = 0; i < albums.length; i++) {
|
Set<BackupCandidate> candidate = {};
|
||||||
final AssetPathEntity? a = albums[i];
|
|
||||||
if (a != null &&
|
for (int i = 0; i < localAlbums.length; i++) {
|
||||||
a.lastModified?.isBefore(backupAlbums[i].lastBackup) != true) {
|
final localAlbum = localAlbums[i];
|
||||||
result.addAll(
|
if (localAlbum == null) {
|
||||||
await a.getAssetListRange(start: 0, end: await a.assetCountAsync),
|
continue;
|
||||||
);
|
|
||||||
backupAlbums[i].lastBackup = now;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (useTimeFilter &&
|
||||||
|
localAlbum.lastModified?.isBefore(backupAlbums[i].lastBackup) ==
|
||||||
|
true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final assets = await localAlbum.getAssetListRange(
|
||||||
|
start: 0,
|
||||||
|
end: await localAlbum.assetCountAsync,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add album's name to the asset info
|
||||||
|
for (final asset in assets) {
|
||||||
|
List<String> albumNames = [localAlbum.name];
|
||||||
|
|
||||||
|
final existingAsset = candidate.firstWhereOrNull(
|
||||||
|
(a) => a.asset.id == asset.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingAsset != null) {
|
||||||
|
albumNames.addAll(existingAsset.albumNames);
|
||||||
|
candidate.remove(existingAsset);
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate.add(
|
||||||
|
BackupCandidate(
|
||||||
|
asset: asset,
|
||||||
|
albumNames: albumNames,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
backupAlbums[i].lastBackup = now;
|
||||||
}
|
}
|
||||||
return result;
|
|
||||||
|
return candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a new list of assets not yet uploaded
|
/// Returns a new list of assets not yet uploaded
|
||||||
Future<List<AssetEntity>> removeAlreadyUploadedAssets(
|
Future<Set<BackupCandidate>> removeAlreadyUploadedAssets(
|
||||||
List<AssetEntity> candidates,
|
Set<BackupCandidate> candidates,
|
||||||
) async {
|
) async {
|
||||||
if (candidates.isEmpty) {
|
if (candidates.isEmpty) {
|
||||||
return candidates;
|
return candidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
final Set<String> duplicatedAssetIds = await getDuplicatedAssetIds();
|
final Set<String> duplicatedAssetIds = await getDuplicatedAssetIds();
|
||||||
candidates = duplicatedAssetIds.isEmpty
|
candidates.removeWhere(
|
||||||
? candidates
|
(candidate) => duplicatedAssetIds.contains(candidate.asset.id),
|
||||||
: candidates
|
);
|
||||||
.whereNot((asset) => duplicatedAssetIds.contains(asset.id))
|
|
||||||
.toList();
|
|
||||||
if (candidates.isEmpty) {
|
if (candidates.isEmpty) {
|
||||||
return candidates;
|
return candidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
final Set<String> existing = {};
|
final Set<String> existing = {};
|
||||||
try {
|
try {
|
||||||
final String deviceId = Store.get(StoreKey.deviceId);
|
final String deviceId = Store.get(StoreKey.deviceId);
|
||||||
final CheckExistingAssetsResponseDto? duplicates =
|
final CheckExistingAssetsResponseDto? duplicates =
|
||||||
await _apiService.assetsApi.checkExistingAssets(
|
await _apiService.assetsApi.checkExistingAssets(
|
||||||
CheckExistingAssetsDto(
|
CheckExistingAssetsDto(
|
||||||
deviceAssetIds: candidates.map((e) => e.id).toList(),
|
deviceAssetIds: candidates.map((c) => c.asset.id).toList(),
|
||||||
deviceId: deviceId,
|
deviceId: deviceId,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -194,55 +257,75 @@ class BackupService {
|
||||||
existing.addAll(allAssetsInDatabase);
|
existing.addAll(allAssetsInDatabase);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return existing.isEmpty
|
|
||||||
? candidates
|
if (existing.isNotEmpty) {
|
||||||
: candidates.whereNot((e) => existing.contains(e.id)).toList();
|
candidates.removeWhere((c) => existing.contains(c.asset.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> backupAsset(
|
Future<bool> _checkPermissions() async {
|
||||||
Iterable<AssetEntity> assetList,
|
|
||||||
http.CancellationToken cancelToken,
|
|
||||||
PMProgressHandler? pmProgressHandler,
|
|
||||||
Function(String, String, bool) uploadSuccessCb,
|
|
||||||
Function(int, int) uploadProgressCb,
|
|
||||||
Function(CurrentUploadAsset) setCurrentUploadAssetCb,
|
|
||||||
Function(ErrorUploadAsset) errorCb, {
|
|
||||||
bool sortAssets = false,
|
|
||||||
}) async {
|
|
||||||
final bool isIgnoreIcloudAssets =
|
|
||||||
_appSetting.getSetting(AppSettingsEnum.ignoreIcloudAssets);
|
|
||||||
|
|
||||||
if (Platform.isAndroid &&
|
if (Platform.isAndroid &&
|
||||||
!(await pm.Permission.accessMediaLocation.status).isGranted) {
|
!(await pm.Permission.accessMediaLocation.status).isGranted) {
|
||||||
// double check that permission is granted here, to guard against
|
// double check that permission is granted here, to guard against
|
||||||
// uploading corrupt assets without EXIF information
|
// uploading corrupt assets without EXIF information
|
||||||
_log.warning("Media location permission is not granted. "
|
_log.warning("Media location permission is not granted. "
|
||||||
"Cannot access original assets for backup.");
|
"Cannot access original assets for backup.");
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
final String deviceId = Store.get(StoreKey.deviceId);
|
|
||||||
final String savedEndpoint = Store.get(StoreKey.serverEndpoint);
|
|
||||||
bool anyErrors = false;
|
|
||||||
final List<String> duplicatedAssetIds = [];
|
|
||||||
|
|
||||||
// DON'T KNOW WHY BUT THIS HELPS BACKGROUND BACKUP TO WORK ON IOS
|
// DON'T KNOW WHY BUT THIS HELPS BACKGROUND BACKUP TO WORK ON IOS
|
||||||
if (Platform.isIOS) {
|
if (Platform.isIOS) {
|
||||||
await PhotoManager.requestPermissionExtend();
|
await PhotoManager.requestPermissionExtend();
|
||||||
}
|
}
|
||||||
|
|
||||||
List<AssetEntity> assetsToUpload = sortAssets
|
return true;
|
||||||
// Upload images before video assets
|
}
|
||||||
// these are further sorted by using their creation date
|
|
||||||
? assetList.sorted(
|
|
||||||
(a, b) {
|
|
||||||
final cmp = a.typeInt - b.typeInt;
|
|
||||||
if (cmp != 0) return cmp;
|
|
||||||
return a.createDateTime.compareTo(b.createDateTime);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: assetList.toList();
|
|
||||||
|
|
||||||
for (var entity in assetsToUpload) {
|
/// Upload images before video assets for background tasks
|
||||||
|
/// these are further sorted by using their creation date
|
||||||
|
List<BackupCandidate> _sortPhotosFirst(List<BackupCandidate> candidates) {
|
||||||
|
return candidates.sorted(
|
||||||
|
(a, b) {
|
||||||
|
final cmp = a.asset.typeInt - b.asset.typeInt;
|
||||||
|
if (cmp != 0) return cmp;
|
||||||
|
return a.asset.createDateTime.compareTo(b.asset.createDateTime);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> backupAsset(
|
||||||
|
Iterable<BackupCandidate> assets,
|
||||||
|
http.CancellationToken cancelToken, {
|
||||||
|
bool isBackground = false,
|
||||||
|
PMProgressHandler? pmProgressHandler,
|
||||||
|
required void Function(SuccessUploadAsset result) onSuccess,
|
||||||
|
required void Function(int bytes, int totalBytes) onProgress,
|
||||||
|
required void Function(CurrentUploadAsset asset) onCurrentAsset,
|
||||||
|
required void Function(ErrorUploadAsset error) onError,
|
||||||
|
}) async {
|
||||||
|
final bool isIgnoreIcloudAssets =
|
||||||
|
_appSetting.getSetting(AppSettingsEnum.ignoreIcloudAssets);
|
||||||
|
final shouldSyncAlbums = _appSetting.getSetting(AppSettingsEnum.syncAlbums);
|
||||||
|
final String deviceId = Store.get(StoreKey.deviceId);
|
||||||
|
final String savedEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||||
|
final List<String> duplicatedAssetIds = [];
|
||||||
|
bool anyErrors = false;
|
||||||
|
|
||||||
|
final hasPermission = await _checkPermissions();
|
||||||
|
if (!hasPermission) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<BackupCandidate> candidates = assets.toList();
|
||||||
|
if (isBackground) {
|
||||||
|
candidates = _sortPhotosFirst(candidates);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final candidate in candidates) {
|
||||||
|
final AssetEntity entity = candidate.asset;
|
||||||
File? file;
|
File? file;
|
||||||
File? livePhotoFile;
|
File? livePhotoFile;
|
||||||
|
|
||||||
|
@ -257,7 +340,7 @@ class BackupService {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrentUploadAssetCb(
|
onCurrentAsset(
|
||||||
CurrentUploadAsset(
|
CurrentUploadAsset(
|
||||||
id: entity.id,
|
id: entity.id,
|
||||||
fileCreatedAt: entity.createDateTime.year == 1970
|
fileCreatedAt: entity.createDateTime.year == 1970
|
||||||
|
@ -299,23 +382,22 @@ class BackupService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var fileStream = file.openRead();
|
final fileStream = file.openRead();
|
||||||
var assetRawUploadData = http.MultipartFile(
|
final assetRawUploadData = http.MultipartFile(
|
||||||
"assetData",
|
"assetData",
|
||||||
fileStream,
|
fileStream,
|
||||||
file.lengthSync(),
|
file.lengthSync(),
|
||||||
filename: originalFileName,
|
filename: originalFileName,
|
||||||
);
|
);
|
||||||
|
|
||||||
var baseRequest = MultipartRequest(
|
final baseRequest = MultipartRequest(
|
||||||
'POST',
|
'POST',
|
||||||
Uri.parse('$savedEndpoint/assets'),
|
Uri.parse('$savedEndpoint/assets'),
|
||||||
onProgress: ((bytes, totalBytes) =>
|
onProgress: ((bytes, totalBytes) => onProgress(bytes, totalBytes)),
|
||||||
uploadProgressCb(bytes, totalBytes)),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
baseRequest.headers.addAll(ApiService.getRequestHeaders());
|
baseRequest.headers.addAll(ApiService.getRequestHeaders());
|
||||||
baseRequest.headers["Transfer-Encoding"] = "chunked";
|
baseRequest.headers["Transfer-Encoding"] = "chunked";
|
||||||
|
|
||||||
baseRequest.fields['deviceAssetId'] = entity.id;
|
baseRequest.fields['deviceAssetId'] = entity.id;
|
||||||
baseRequest.fields['deviceId'] = deviceId;
|
baseRequest.fields['deviceId'] = deviceId;
|
||||||
baseRequest.fields['fileCreatedAt'] =
|
baseRequest.fields['fileCreatedAt'] =
|
||||||
|
@ -324,12 +406,9 @@ class BackupService {
|
||||||
entity.modifiedDateTime.toUtc().toIso8601String();
|
entity.modifiedDateTime.toUtc().toIso8601String();
|
||||||
baseRequest.fields['isFavorite'] = entity.isFavorite.toString();
|
baseRequest.fields['isFavorite'] = entity.isFavorite.toString();
|
||||||
baseRequest.fields['duration'] = entity.videoDuration.toString();
|
baseRequest.fields['duration'] = entity.videoDuration.toString();
|
||||||
|
|
||||||
baseRequest.files.add(assetRawUploadData);
|
baseRequest.files.add(assetRawUploadData);
|
||||||
|
|
||||||
var fileSize = file.lengthSync();
|
onCurrentAsset(
|
||||||
|
|
||||||
setCurrentUploadAssetCb(
|
|
||||||
CurrentUploadAsset(
|
CurrentUploadAsset(
|
||||||
id: entity.id,
|
id: entity.id,
|
||||||
fileCreatedAt: entity.createDateTime.year == 1970
|
fileCreatedAt: entity.createDateTime.year == 1970
|
||||||
|
@ -337,7 +416,7 @@ class BackupService {
|
||||||
: entity.createDateTime,
|
: entity.createDateTime,
|
||||||
fileName: originalFileName,
|
fileName: originalFileName,
|
||||||
fileType: _getAssetType(entity.type),
|
fileType: _getAssetType(entity.type),
|
||||||
fileSize: fileSize,
|
fileSize: file.lengthSync(),
|
||||||
iCloudAsset: false,
|
iCloudAsset: false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -356,22 +435,23 @@ class BackupService {
|
||||||
baseRequest.fields['livePhotoVideoId'] = livePhotoVideoId;
|
baseRequest.fields['livePhotoVideoId'] = livePhotoVideoId;
|
||||||
}
|
}
|
||||||
|
|
||||||
var response = await httpClient.send(
|
final response = await httpClient.send(
|
||||||
baseRequest,
|
baseRequest,
|
||||||
cancellationToken: cancelToken,
|
cancellationToken: cancelToken,
|
||||||
);
|
);
|
||||||
|
|
||||||
var responseBody = jsonDecode(await response.stream.bytesToString());
|
final responseBody =
|
||||||
|
jsonDecode(await response.stream.bytesToString());
|
||||||
|
|
||||||
if (![200, 201].contains(response.statusCode)) {
|
if (![200, 201].contains(response.statusCode)) {
|
||||||
var error = responseBody;
|
final error = responseBody;
|
||||||
var errorMessage = error['message'] ?? error['error'];
|
final errorMessage = error['message'] ?? error['error'];
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
"Error(${error['statusCode']}) uploading ${entity.id} | $originalFileName | Created on ${entity.createDateTime} | ${error['error']}",
|
"Error(${error['statusCode']}) uploading ${entity.id} | $originalFileName | Created on ${entity.createDateTime} | ${error['error']}",
|
||||||
);
|
);
|
||||||
|
|
||||||
errorCb(
|
onError(
|
||||||
ErrorUploadAsset(
|
ErrorUploadAsset(
|
||||||
asset: entity,
|
asset: entity,
|
||||||
id: entity.id,
|
id: entity.id,
|
||||||
|
@ -386,23 +466,37 @@ class BackupService {
|
||||||
anyErrors = true;
|
anyErrors = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var isDuplicate = false;
|
bool isDuplicate = false;
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
isDuplicate = true;
|
isDuplicate = true;
|
||||||
duplicatedAssetIds.add(entity.id);
|
duplicatedAssetIds.add(entity.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
uploadSuccessCb(entity.id, deviceId, isDuplicate);
|
onSuccess(
|
||||||
|
SuccessUploadAsset(
|
||||||
|
candidate: candidate,
|
||||||
|
remoteAssetId: responseBody['id'] as String,
|
||||||
|
isDuplicate: isDuplicate,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shouldSyncAlbums && !isDuplicate) {
|
||||||
|
await _albumService.syncUploadAlbums(
|
||||||
|
candidate.albumNames,
|
||||||
|
[responseBody['id'] as String],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} on http.CancelledException {
|
} on http.CancelledException {
|
||||||
debugPrint("Backup was cancelled by the user");
|
debugPrint("Backup was cancelled by the user");
|
||||||
anyErrors = true;
|
anyErrors = true;
|
||||||
break;
|
break;
|
||||||
} catch (e) {
|
} catch (error, stackTrace) {
|
||||||
debugPrint("ERROR backupAsset: ${e.toString()}");
|
debugPrint("Error backup asset: ${error.toString()}: $stackTrace");
|
||||||
anyErrors = true;
|
anyErrors = true;
|
||||||
continue;
|
continue;
|
||||||
} finally {
|
} finally {
|
||||||
|
@ -416,9 +510,11 @@ class BackupService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (duplicatedAssetIds.isNotEmpty) {
|
if (duplicatedAssetIds.isNotEmpty) {
|
||||||
await _saveDuplicatedAssetIds(duplicatedAssetIds);
|
await _saveDuplicatedAssetIds(duplicatedAssetIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
return !anyErrors;
|
return !anyErrors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -5,15 +5,21 @@ import 'package:fluttertoast/fluttertoast.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/models/backup/available_album.model.dart';
|
import 'package:immich_mobile/models/backup/available_album.model.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.provider.dart';
|
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||||
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||||
|
|
||||||
class AlbumInfoCard extends HookConsumerWidget {
|
class AlbumInfoCard extends HookConsumerWidget {
|
||||||
final AvailableAlbum album;
|
final AvailableAlbum album;
|
||||||
|
|
||||||
const AlbumInfoCard({super.key, required this.album});
|
const AlbumInfoCard({
|
||||||
|
super.key,
|
||||||
|
required this.album,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
@ -21,6 +27,9 @@ class AlbumInfoCard extends HookConsumerWidget {
|
||||||
ref.watch(backupProvider).selectedBackupAlbums.contains(album);
|
ref.watch(backupProvider).selectedBackupAlbums.contains(album);
|
||||||
final bool isExcluded =
|
final bool isExcluded =
|
||||||
ref.watch(backupProvider).excludedBackupAlbums.contains(album);
|
ref.watch(backupProvider).excludedBackupAlbums.contains(album);
|
||||||
|
final syncAlbum = ref
|
||||||
|
.watch(appSettingsServiceProvider)
|
||||||
|
.getSetting(AppSettingsEnum.syncAlbums);
|
||||||
|
|
||||||
final isDarkTheme = context.isDarkTheme;
|
final isDarkTheme = context.isDarkTheme;
|
||||||
|
|
||||||
|
@ -85,6 +94,9 @@ class AlbumInfoCard extends HookConsumerWidget {
|
||||||
ref.read(backupProvider.notifier).removeAlbumForBackup(album);
|
ref.read(backupProvider.notifier).removeAlbumForBackup(album);
|
||||||
} else {
|
} else {
|
||||||
ref.read(backupProvider.notifier).addAlbumForBackup(album);
|
ref.read(backupProvider.notifier).addAlbumForBackup(album);
|
||||||
|
if (syncAlbum) {
|
||||||
|
ref.read(albumProvider.notifier).createSyncAlbum(album.name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDoubleTap: () {
|
onDoubleTap: () {
|
||||||
|
|
|
@ -5,9 +5,12 @@ import 'package:fluttertoast/fluttertoast.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/models/backup/available_album.model.dart';
|
import 'package:immich_mobile/models/backup/available_album.model.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.provider.dart';
|
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||||
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||||
|
|
||||||
class AlbumInfoListTile extends HookConsumerWidget {
|
class AlbumInfoListTile extends HookConsumerWidget {
|
||||||
|
@ -21,7 +24,10 @@ class AlbumInfoListTile extends HookConsumerWidget {
|
||||||
ref.watch(backupProvider).selectedBackupAlbums.contains(album);
|
ref.watch(backupProvider).selectedBackupAlbums.contains(album);
|
||||||
final bool isExcluded =
|
final bool isExcluded =
|
||||||
ref.watch(backupProvider).excludedBackupAlbums.contains(album);
|
ref.watch(backupProvider).excludedBackupAlbums.contains(album);
|
||||||
var assetCount = useState(0);
|
final assetCount = useState(0);
|
||||||
|
final syncAlbum = ref
|
||||||
|
.watch(appSettingsServiceProvider)
|
||||||
|
.getSetting(AppSettingsEnum.syncAlbums);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() {
|
() {
|
||||||
|
@ -98,6 +104,9 @@ class AlbumInfoListTile extends HookConsumerWidget {
|
||||||
ref.read(backupProvider.notifier).removeAlbumForBackup(album);
|
ref.read(backupProvider.notifier).removeAlbumForBackup(album);
|
||||||
} else {
|
} else {
|
||||||
ref.read(backupProvider.notifier).addAlbumForBackup(album);
|
ref.read(backupProvider.notifier).addAlbumForBackup(album);
|
||||||
|
if (syncAlbum) {
|
||||||
|
ref.read(albumProvider.notifier).createSyncAlbum(album.name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
leading: buildIcon(),
|
leading: buildIcon(),
|
||||||
|
|
|
@ -1,9 +1,12 @@
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/providers/backup/backup_verification.provider.dart';
|
import 'package:immich_mobile/providers/backup/backup_verification.provider.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
|
import 'package:immich_mobile/services/asset.service.dart';
|
||||||
import 'package:immich_mobile/widgets/settings/backup_settings/background_settings.dart';
|
import 'package:immich_mobile/widgets/settings/backup_settings/background_settings.dart';
|
||||||
import 'package:immich_mobile/widgets/settings/backup_settings/foreground_settings.dart';
|
import 'package:immich_mobile/widgets/settings/backup_settings/foreground_settings.dart';
|
||||||
import 'package:immich_mobile/widgets/settings/settings_button_list_tile.dart';
|
import 'package:immich_mobile/widgets/settings/settings_button_list_tile.dart';
|
||||||
|
@ -23,7 +26,21 @@ class BackupSettings extends HookConsumerWidget {
|
||||||
useAppSettingsState(AppSettingsEnum.ignoreIcloudAssets);
|
useAppSettingsState(AppSettingsEnum.ignoreIcloudAssets);
|
||||||
final isAdvancedTroubleshooting =
|
final isAdvancedTroubleshooting =
|
||||||
useAppSettingsState(AppSettingsEnum.advancedTroubleshooting);
|
useAppSettingsState(AppSettingsEnum.advancedTroubleshooting);
|
||||||
|
final albumSync = useAppSettingsState(AppSettingsEnum.syncAlbums);
|
||||||
final isCorruptCheckInProgress = ref.watch(backupVerificationProvider);
|
final isCorruptCheckInProgress = ref.watch(backupVerificationProvider);
|
||||||
|
final isAlbumSyncInProgress = useState(false);
|
||||||
|
|
||||||
|
syncAlbums() async {
|
||||||
|
isAlbumSyncInProgress.value = true;
|
||||||
|
try {
|
||||||
|
await ref.read(assetServiceProvider).syncUploadedAssetToAlbums();
|
||||||
|
} catch (_) {
|
||||||
|
} finally {
|
||||||
|
Future.delayed(const Duration(seconds: 1), () {
|
||||||
|
isAlbumSyncInProgress.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final backupSettings = [
|
final backupSettings = [
|
||||||
const ForegroundBackupSettings(),
|
const ForegroundBackupSettings(),
|
||||||
|
@ -58,6 +75,23 @@ class BackupSettings extends HookConsumerWidget {
|
||||||
.performBackupCheck(context)
|
.performBackupCheck(context)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
|
if (albumSync.value)
|
||||||
|
SettingsButtonListTile(
|
||||||
|
icon: Icons.photo_album_outlined,
|
||||||
|
title: 'sync_albums'.tr(),
|
||||||
|
subtitle: Text(
|
||||||
|
"sync_albums_manual_subtitle".tr(),
|
||||||
|
),
|
||||||
|
buttonText: 'sync_albums'.tr(),
|
||||||
|
child: isAlbumSyncInProgress.value
|
||||||
|
? const CircularProgressIndicator.adaptive(
|
||||||
|
strokeWidth: 2,
|
||||||
|
)
|
||||||
|
: ElevatedButton(
|
||||||
|
onPressed: syncAlbums,
|
||||||
|
child: Text('sync'.tr()),
|
||||||
|
),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
return SettingsSubPageScaffold(
|
return SettingsSubPageScaffold(
|
||||||
|
|
|
@ -9,6 +9,7 @@ class SettingsButtonListTile extends StatelessWidget {
|
||||||
final Widget? subtitle;
|
final Widget? subtitle;
|
||||||
final String? subtileText;
|
final String? subtileText;
|
||||||
final String buttonText;
|
final String buttonText;
|
||||||
|
final Widget? child;
|
||||||
final void Function()? onButtonTap;
|
final void Function()? onButtonTap;
|
||||||
|
|
||||||
const SettingsButtonListTile({
|
const SettingsButtonListTile({
|
||||||
|
@ -18,6 +19,7 @@ class SettingsButtonListTile extends StatelessWidget {
|
||||||
this.subtileText,
|
this.subtileText,
|
||||||
this.subtitle,
|
this.subtitle,
|
||||||
required this.buttonText,
|
required this.buttonText,
|
||||||
|
this.child,
|
||||||
this.onButtonTap,
|
this.onButtonTap,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
@ -48,7 +50,8 @@ class SettingsButtonListTile extends StatelessWidget {
|
||||||
),
|
),
|
||||||
if (subtitle != null) subtitle!,
|
if (subtitle != null) subtitle!,
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
ElevatedButton(onPressed: onButtonTap, child: Text(buttonText)),
|
child ??
|
||||||
|
ElevatedButton(onPressed: onButtonTap, child: Text(buttonText)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
@ -9,6 +9,9 @@ class SettingsSwitchListTile extends StatelessWidget {
|
||||||
final String? subtitle;
|
final String? subtitle;
|
||||||
final IconData? icon;
|
final IconData? icon;
|
||||||
final Function(bool)? onChanged;
|
final Function(bool)? onChanged;
|
||||||
|
final EdgeInsets? contentPadding;
|
||||||
|
final TextStyle? titleStyle;
|
||||||
|
final TextStyle? subtitleStyle;
|
||||||
|
|
||||||
const SettingsSwitchListTile({
|
const SettingsSwitchListTile({
|
||||||
required this.valueNotifier,
|
required this.valueNotifier,
|
||||||
|
@ -17,6 +20,9 @@ class SettingsSwitchListTile extends StatelessWidget {
|
||||||
this.icon,
|
this.icon,
|
||||||
this.enabled = true,
|
this.enabled = true,
|
||||||
this.onChanged,
|
this.onChanged,
|
||||||
|
this.contentPadding = const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
this.titleStyle,
|
||||||
|
this.subtitleStyle,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -30,7 +36,7 @@ class SettingsSwitchListTile extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
return SwitchListTile.adaptive(
|
return SwitchListTile.adaptive(
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
|
contentPadding: contentPadding,
|
||||||
selectedTileColor: enabled ? null : context.themeData.disabledColor,
|
selectedTileColor: enabled ? null : context.themeData.disabledColor,
|
||||||
value: valueNotifier.value,
|
value: valueNotifier.value,
|
||||||
onChanged: onSwitchChanged,
|
onChanged: onSwitchChanged,
|
||||||
|
@ -45,20 +51,22 @@ class SettingsSwitchListTile extends StatelessWidget {
|
||||||
: null,
|
: null,
|
||||||
title: Text(
|
title: Text(
|
||||||
title,
|
title,
|
||||||
style: context.textTheme.bodyLarge?.copyWith(
|
style: titleStyle ??
|
||||||
fontWeight: FontWeight.w500,
|
context.textTheme.bodyLarge?.copyWith(
|
||||||
color: enabled ? null : context.themeData.disabledColor,
|
fontWeight: FontWeight.w500,
|
||||||
height: 1.5,
|
color: enabled ? null : context.themeData.disabledColor,
|
||||||
),
|
height: 1.5,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
subtitle: subtitle != null
|
subtitle: subtitle != null
|
||||||
? Text(
|
? Text(
|
||||||
subtitle!,
|
subtitle!,
|
||||||
style: context.textTheme.bodyMedium?.copyWith(
|
style: subtitleStyle ??
|
||||||
color: enabled
|
context.textTheme.bodyMedium?.copyWith(
|
||||||
? context.colorScheme.onSurfaceSecondary
|
color: enabled
|
||||||
: context.themeData.disabledColor,
|
? context.colorScheme.onSurfaceSecondary
|
||||||
),
|
: context.themeData.disabledColor,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
|
|
Loading…
Reference in a new issue