From 1011cdb37699a53ee61d876067d3cfc435e2eafb Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 22 Jul 2025 17:11:06 -0500 Subject: [PATCH] chore: handle requeue upload when target albums changed (#20089) * chore: handle requeue upload when target albums changed * chore: remove debug --- i18n/en.json | 1 + .../lib/pages/backup/drift_backup.page.dart | 16 +- .../drift_backup_album_selection.page.dart | 364 ++++++++++-------- .../backup/drift_upload_detail.page.dart | 6 +- .../backup/drift_backup.provider.dart | 32 +- 5 files changed, 237 insertions(+), 182 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 4dc72b4c42..54c7ca6f1b 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1330,6 +1330,7 @@ "no_results": "No results", "no_results_description": "Try a synonym or more general keyword", "no_shared_albums_message": "Create an album to share photos and videos with people in your network", + "no_uploads_in_progress": "No uploads in progress", "not_in_any_album": "Not in any album", "not_selected": "Not selected", "note_apply_storage_label_to_previously_uploaded assets": "Note: To apply the Storage Label to previously uploaded assets, run the", diff --git a/mobile/lib/pages/backup/drift_backup.page.dart b/mobile/lib/pages/backup/drift_backup.page.dart index 1b9ec8ad07..7780413399 100644 --- a/mobile/lib/pages/backup/drift_backup.page.dart +++ b/mobile/lib/pages/backup/drift_backup.page.dart @@ -44,9 +44,6 @@ class _DriftBackupPageState extends ConsumerState { (album) => album.backupSelection == BackupSelection.selected, ) .toList(); - final uploadItems = ref.watch( - driftBackupProvider.select((state) => state.uploadItems), - ); return Scaffold( appBar: AppBar( @@ -85,14 +82,13 @@ class _DriftBackupPageState extends ConsumerState { onStart: () async => await startBackup(), onStop: () async => await stopBackup(), ), - if (uploadItems.isNotEmpty) - TextButton.icon( - icon: const Icon(Icons.info_outline_rounded), - onPressed: () => context.pushRoute( - const DriftUploadDetailRoute(), - ), - label: Text("view_details".t(context: context)), + TextButton.icon( + icon: const Icon(Icons.info_outline_rounded), + onPressed: () => context.pushRoute( + const DriftUploadDetailRoute(), ), + label: Text("view_details".t(context: context)), + ), ], ], ), diff --git a/mobile/lib/pages/backup/drift_backup_album_selection.page.dart b/mobile/lib/pages/backup/drift_backup_album_selection.page.dart index fd39f0a579..18d3ee1156 100644 --- a/mobile/lib/pages/backup/drift_backup_album_selection.page.dart +++ b/mobile/lib/pages/backup/drift_backup_album_selection.page.dart @@ -9,6 +9,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/providers/album/album.provider.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/services/app_settings.service.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/widgets/backup/drift_album_info_list_tile.dart'; @@ -28,6 +29,8 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState { String _searchQuery = ''; bool _isSearchMode = false; + int _initialTotalAssetCount = 0; + bool _hasPopped = false; late ValueNotifier _enableSyncUploadAlbum; late TextEditingController _searchController; late FocusNode _searchFocusNode; @@ -43,6 +46,9 @@ class _DriftBackupAlbumSelectionPageState .read(appSettingsServiceProvider) .getSetting(AppSettingsEnum.syncAlbums); ref.read(backupAlbumProvider.notifier).getAll(); + + _initialTotalAssetCount = + ref.read(driftBackupProvider.select((p) => p.totalCount)); } @override @@ -79,179 +85,207 @@ class _DriftBackupAlbumSelectionPageState } } - return Scaffold( - appBar: AppBar( - leading: IconButton( - onPressed: () => context.maybePop(), - icon: const Icon(Icons.arrow_back_ios_rounded), - ), - title: _isSearchMode - ? SearchField( - hintText: 'search_albums'.t(context: context), - autofocus: true, - controller: _searchController, - focusNode: _searchFocusNode, - onChanged: (value) => - setState(() => _searchQuery = value.trim()), + return PopScope( + onPopInvokedWithResult: (didPop, result) async { + // There is an issue with Flutter where the pop event + // can be triggered multiple times, so we guard it with _hasPopped + if (didPop && !_hasPopped) { + _hasPopped = true; + + await ref.read(driftBackupProvider.notifier).getBackupStatus(); + final currentTotalAssetCount = + ref.read(driftBackupProvider.select((p) => p.totalCount)); + + if (currentTotalAssetCount != _initialTotalAssetCount) { + final isBackupEnabled = ref + .read(appSettingsServiceProvider) + .getSetting(AppSettingsEnum.enableBackup); + + if (!isBackupEnabled) { + return; + } + final backupNotifier = ref.read(driftBackupProvider.notifier); + + backupNotifier.cancel().then((_) { + backupNotifier.backup(); + }); + } + } + }, + child: Scaffold( + appBar: AppBar( + leading: IconButton( + onPressed: () async => await context.maybePop(), + icon: const Icon(Icons.arrow_back_ios_rounded), + ), + title: _isSearchMode + ? SearchField( + hintText: 'search_albums'.t(context: context), + autofocus: true, + controller: _searchController, + focusNode: _searchFocusNode, + onChanged: (value) => + setState(() => _searchQuery = value.trim()), + ) + : const Text( + "backup_album_selection_page_select_albums", + ).t(context: context), + actions: [ + if (!_isSearchMode) + IconButton( + icon: const Icon(Icons.search), + onPressed: () => setState(() { + _isSearchMode = true; + _searchQuery = ''; + }), ) - : const Text( - "backup_album_selection_page_select_albums", - ).t(context: context), - actions: [ - if (!_isSearchMode) - IconButton( - icon: const Icon(Icons.search), - onPressed: () => setState(() { - _isSearchMode = true; - _searchQuery = ''; - }), - ) - else - IconButton( - icon: const Icon(Icons.close), - onPressed: () => setState(() { - _isSearchMode = false; - _searchQuery = ''; - _searchController.clear(); - }), - ), - ], - elevation: 0, - ), - body: 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, - ).t(context: context), - ), - // Selected Album Chips - - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Wrap( - children: [ - _SelectedAlbumNameChips( - selectedBackupAlbums: selectedBackupAlbums, - ), - _ExcludedAlbumNameChips( - excludedBackupAlbums: excludedBackupAlbums, - ), - ], - ), - ), - - SettingsSwitchListTile( - valueNotifier: _enableSyncUploadAlbum, - title: "sync_albums".t(context: context), - subtitle: - "sync_upload_album_setting_subtitle".t(context: context), - 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( - "albums_on_device_count".t( - context: context, - args: {'count': albumCount.toString()}, + else + IconButton( + icon: const Icon(Icons.close), + onPressed: () => setState(() { + _isSearchMode = false; + _searchQuery = ''; + _searchController.clear(); + }), + ), + ], + elevation: 0, + ), + body: CustomScrollView( + physics: const ClampingScrollPhysics(), + slivers: [ + SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + vertical: 8.0, + horizontal: 16.0, ), - 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, - ), + "backup_album_selection_page_selection_info", + style: context.textTheme.titleSmall, ).t(context: context), ), - 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, - ), - ).t(context: context), - content: SingleChildScrollView( - child: ListBody( - children: [ - const Text( - 'backup_album_selection_page_assets_scatter', - style: TextStyle( - fontSize: 14, - ), - ).t(context: context), - ], - ), - ), - ); - }, - ); - }, - ), - ), + // Selected Album Chips - if (Platform.isAndroid) - _SelectAllButton( - filteredAlbums: filteredAlbums, - selectedBackupAlbums: selectedBackupAlbums, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Wrap( + children: [ + _SelectedAlbumNameChips( + selectedBackupAlbums: selectedBackupAlbums, + ), + _ExcludedAlbumNameChips( + excludedBackupAlbums: excludedBackupAlbums, + ), + ], + ), ), - ], + + SettingsSwitchListTile( + valueNotifier: _enableSyncUploadAlbum, + title: "sync_albums".t(context: context), + subtitle: "sync_upload_album_setting_subtitle" + .t(context: context), + 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( + "albums_on_device_count".t( + context: context, + args: {'count': albumCount.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, + ), + ).t(context: context), + ), + 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, + ), + ).t(context: context), + content: SingleChildScrollView( + child: ListBody( + children: [ + const Text( + 'backup_album_selection_page_assets_scatter', + style: TextStyle( + fontSize: 14, + ), + ).t(context: context), + ], + ), + ), + ); + }, + ); + }, + ), + ), + + if (Platform.isAndroid) + _SelectAllButton( + filteredAlbums: filteredAlbums, + selectedBackupAlbums: selectedBackupAlbums, + ), + ], + ), ), - ), - SliverLayoutBuilder( - builder: (context, constraints) { - if (constraints.crossAxisExtent > 600) { - return _AlbumSelectionGrid( - filteredAlbums: filteredAlbums, - searchQuery: _searchQuery, - ); - } else { - return _AlbumSelectionList( - filteredAlbums: filteredAlbums, - searchQuery: _searchQuery, - ); - } - }, - ), - ], + SliverLayoutBuilder( + builder: (context, constraints) { + if (constraints.crossAxisExtent > 600) { + return _AlbumSelectionGrid( + filteredAlbums: filteredAlbums, + searchQuery: _searchQuery, + ); + } else { + return _AlbumSelectionList( + filteredAlbums: filteredAlbums, + searchQuery: _searchQuery, + ); + } + }, + ), + ], + ), ), ); } diff --git a/mobile/lib/pages/backup/drift_upload_detail.page.dart b/mobile/lib/pages/backup/drift_upload_detail.page.dart index 66803265e6..058bfa1aaf 100644 --- a/mobile/lib/pages/backup/drift_upload_detail.page.dart +++ b/mobile/lib/pages/backup/drift_upload_detail.page.dart @@ -39,7 +39,7 @@ class DriftUploadDetailPage extends ConsumerWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( - Icons.cloud_upload_outlined, + Icons.cloud_off_rounded, size: 80, color: context.colorScheme.onSurface.withValues(alpha: 0.3), ), @@ -79,7 +79,9 @@ class DriftUploadDetailPage extends ConsumerWidget { return Card( elevation: 0, - color: context.colorScheme.surfaceContainer, + color: item.isFailed != null + ? context.colorScheme.errorContainer + : context.colorScheme.surfaceContainer, shape: RoundedRectangleBorder( borderRadius: const BorderRadius.all( Radius.circular(16), diff --git a/mobile/lib/providers/backup/drift_backup.provider.dart b/mobile/lib/providers/backup/drift_backup.provider.dart index c51c40775e..3a2c7fd9ce 100644 --- a/mobile/lib/providers/backup/drift_backup.provider.dart +++ b/mobile/lib/providers/backup/drift_backup.provider.dart @@ -41,6 +41,7 @@ class DriftUploadStatus { final double progress; final int fileSize; final String networkSpeedAsString; + final bool? isFailed; const DriftUploadStatus({ required this.taskId, @@ -48,6 +49,7 @@ class DriftUploadStatus { required this.progress, required this.fileSize, required this.networkSpeedAsString, + this.isFailed, }); DriftUploadStatus copyWith({ @@ -56,6 +58,7 @@ class DriftUploadStatus { double? progress, int? fileSize, String? networkSpeedAsString, + bool? isFailed, }) { return DriftUploadStatus( taskId: taskId ?? this.taskId, @@ -63,12 +66,13 @@ class DriftUploadStatus { progress: progress ?? this.progress, fileSize: fileSize ?? this.fileSize, networkSpeedAsString: networkSpeedAsString ?? this.networkSpeedAsString, + isFailed: isFailed ?? this.isFailed, ); } @override String toString() { - return 'DriftUploadStatus(taskId: $taskId, filename: $filename, progress: $progress, fileSize: $fileSize, networkSpeedAsString: $networkSpeedAsString)'; + return 'DriftUploadStatus(taskId: $taskId, filename: $filename, progress: $progress, fileSize: $fileSize, networkSpeedAsString: $networkSpeedAsString, isFailed: $isFailed)'; } @override @@ -79,7 +83,8 @@ class DriftUploadStatus { other.filename == filename && other.progress == progress && other.fileSize == fileSize && - other.networkSpeedAsString == networkSpeedAsString; + other.networkSpeedAsString == networkSpeedAsString && + other.isFailed == isFailed; } @override @@ -88,7 +93,8 @@ class DriftUploadStatus { filename.hashCode ^ progress.hashCode ^ fileSize.hashCode ^ - networkSpeedAsString.hashCode; + networkSpeedAsString.hashCode ^ + isFailed.hashCode; } Map toMap() { @@ -98,6 +104,7 @@ class DriftUploadStatus { 'progress': progress, 'fileSize': fileSize, 'networkSpeedAsString': networkSpeedAsString, + 'isFailed': isFailed, }; } @@ -108,6 +115,7 @@ class DriftUploadStatus { progress: map['progress'] as double, fileSize: map['fileSize'] as int, networkSpeedAsString: map['networkSpeedAsString'] as String, + isFailed: map['isFailed'] != null ? map['isFailed'] as bool : null, ); } @@ -235,6 +243,8 @@ class ExpBackupNotifier extends StateNotifier { } void _handleTaskStatusUpdate(TaskStatusUpdate update) { + final taskId = update.task.taskId; + switch (update.status) { case TaskStatus.complete: if (update.task.group == kBackupGroup) { @@ -245,14 +255,26 @@ class ExpBackupNotifier extends StateNotifier { } // Remove the completed task from the upload items - final taskId = update.task.taskId; if (state.uploadItems.containsKey(taskId)) { - Future.delayed(const Duration(milliseconds: 500), () { + Future.delayed(const Duration(milliseconds: 1000), () { _removeUploadItem(taskId); }); } case TaskStatus.failed: + final currentItem = state.uploadItems[taskId]; + if (currentItem == null) { + return; + } + + state = state.copyWith( + uploadItems: { + ...state.uploadItems, + taskId: currentItem.copyWith( + isFailed: true, + ), + }, + ); break; case TaskStatus.canceled: