chore: handle requeue upload when target albums changed (#20089)

* chore: handle requeue upload when target albums changed

* chore: remove debug
This commit is contained in:
Alex 2025-07-22 17:11:06 -05:00 committed by GitHub
parent f1cac122ed
commit 1011cdb376
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 237 additions and 182 deletions

View File

@ -1330,6 +1330,7 @@
"no_results": "No results", "no_results": "No results",
"no_results_description": "Try a synonym or more general keyword", "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_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_in_any_album": "Not in any album",
"not_selected": "Not selected", "not_selected": "Not selected",
"note_apply_storage_label_to_previously_uploaded assets": "Note: To apply the Storage Label to previously uploaded assets, run the", "note_apply_storage_label_to_previously_uploaded assets": "Note: To apply the Storage Label to previously uploaded assets, run the",

View File

@ -44,9 +44,6 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
(album) => album.backupSelection == BackupSelection.selected, (album) => album.backupSelection == BackupSelection.selected,
) )
.toList(); .toList();
final uploadItems = ref.watch(
driftBackupProvider.select((state) => state.uploadItems),
);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@ -85,14 +82,13 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
onStart: () async => await startBackup(), onStart: () async => await startBackup(),
onStop: () async => await stopBackup(), onStop: () async => await stopBackup(),
), ),
if (uploadItems.isNotEmpty) TextButton.icon(
TextButton.icon( icon: const Icon(Icons.info_outline_rounded),
icon: const Icon(Icons.info_outline_rounded), onPressed: () => context.pushRoute(
onPressed: () => context.pushRoute( const DriftUploadDetailRoute(),
const DriftUploadDetailRoute(),
),
label: Text("view_details".t(context: context)),
), ),
label: Text("view_details".t(context: context)),
),
], ],
], ],
), ),

View File

@ -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/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/album/album.provider.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/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/services/app_settings.service.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/widgets/backup/drift_album_info_list_tile.dart'; import 'package:immich_mobile/widgets/backup/drift_album_info_list_tile.dart';
@ -28,6 +29,8 @@ class _DriftBackupAlbumSelectionPageState
extends ConsumerState<DriftBackupAlbumSelectionPage> { extends ConsumerState<DriftBackupAlbumSelectionPage> {
String _searchQuery = ''; String _searchQuery = '';
bool _isSearchMode = false; bool _isSearchMode = false;
int _initialTotalAssetCount = 0;
bool _hasPopped = false;
late ValueNotifier<bool> _enableSyncUploadAlbum; late ValueNotifier<bool> _enableSyncUploadAlbum;
late TextEditingController _searchController; late TextEditingController _searchController;
late FocusNode _searchFocusNode; late FocusNode _searchFocusNode;
@ -43,6 +46,9 @@ class _DriftBackupAlbumSelectionPageState
.read(appSettingsServiceProvider) .read(appSettingsServiceProvider)
.getSetting(AppSettingsEnum.syncAlbums); .getSetting(AppSettingsEnum.syncAlbums);
ref.read(backupAlbumProvider.notifier).getAll(); ref.read(backupAlbumProvider.notifier).getAll();
_initialTotalAssetCount =
ref.read(driftBackupProvider.select((p) => p.totalCount));
} }
@override @override
@ -79,179 +85,207 @@ class _DriftBackupAlbumSelectionPageState
} }
} }
return Scaffold( return PopScope(
appBar: AppBar( onPopInvokedWithResult: (didPop, result) async {
leading: IconButton( // There is an issue with Flutter where the pop event
onPressed: () => context.maybePop(), // can be triggered multiple times, so we guard it with _hasPopped
icon: const Icon(Icons.arrow_back_ios_rounded), if (didPop && !_hasPopped) {
), _hasPopped = true;
title: _isSearchMode
? SearchField( await ref.read(driftBackupProvider.notifier).getBackupStatus();
hintText: 'search_albums'.t(context: context), final currentTotalAssetCount =
autofocus: true, ref.read(driftBackupProvider.select((p) => p.totalCount));
controller: _searchController,
focusNode: _searchFocusNode, if (currentTotalAssetCount != _initialTotalAssetCount) {
onChanged: (value) => final isBackupEnabled = ref
setState(() => _searchQuery = value.trim()), .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( else
"backup_album_selection_page_select_albums", IconButton(
).t(context: context), icon: const Icon(Icons.close),
actions: [ onPressed: () => setState(() {
if (!_isSearchMode) _isSearchMode = false;
IconButton( _searchQuery = '';
icon: const Icon(Icons.search), _searchController.clear();
onPressed: () => setState(() { }),
_isSearchMode = true; ),
_searchQuery = ''; ],
}), elevation: 0,
) ),
else body: CustomScrollView(
IconButton( physics: const ClampingScrollPhysics(),
icon: const Icon(Icons.close), slivers: [
onPressed: () => setState(() { SliverToBoxAdapter(
_isSearchMode = false; child: Column(
_searchQuery = ''; crossAxisAlignment: CrossAxisAlignment.start,
_searchController.clear(); children: [
}), Padding(
), padding: const EdgeInsets.symmetric(
], vertical: 8.0,
elevation: 0, horizontal: 16.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()},
), ),
style: context.textTheme.titleSmall,
),
subtitle: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text( child: Text(
"backup_album_selection_page_albums_tap", "backup_album_selection_page_selection_info",
style: context.textTheme.labelLarge?.copyWith( style: context.textTheme.titleSmall,
color: context.primaryColor,
),
).t(context: context), ).t(context: context),
), ),
trailing: IconButton( // Selected Album Chips
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) Padding(
_SelectAllButton( padding: const EdgeInsets.symmetric(horizontal: 16.0),
filteredAlbums: filteredAlbums, child: Wrap(
selectedBackupAlbums: selectedBackupAlbums, 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(
SliverLayoutBuilder( builder: (context, constraints) {
builder: (context, constraints) { if (constraints.crossAxisExtent > 600) {
if (constraints.crossAxisExtent > 600) { return _AlbumSelectionGrid(
return _AlbumSelectionGrid( filteredAlbums: filteredAlbums,
filteredAlbums: filteredAlbums, searchQuery: _searchQuery,
searchQuery: _searchQuery, );
); } else {
} else { return _AlbumSelectionList(
return _AlbumSelectionList( filteredAlbums: filteredAlbums,
filteredAlbums: filteredAlbums, searchQuery: _searchQuery,
searchQuery: _searchQuery, );
); }
} },
}, ),
), ],
], ),
), ),
); );
} }

View File

@ -39,7 +39,7 @@ class DriftUploadDetailPage extends ConsumerWidget {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Icon(
Icons.cloud_upload_outlined, Icons.cloud_off_rounded,
size: 80, size: 80,
color: context.colorScheme.onSurface.withValues(alpha: 0.3), color: context.colorScheme.onSurface.withValues(alpha: 0.3),
), ),
@ -79,7 +79,9 @@ class DriftUploadDetailPage extends ConsumerWidget {
return Card( return Card(
elevation: 0, elevation: 0,
color: context.colorScheme.surfaceContainer, color: item.isFailed != null
? context.colorScheme.errorContainer
: context.colorScheme.surfaceContainer,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all( borderRadius: const BorderRadius.all(
Radius.circular(16), Radius.circular(16),

View File

@ -41,6 +41,7 @@ class DriftUploadStatus {
final double progress; final double progress;
final int fileSize; final int fileSize;
final String networkSpeedAsString; final String networkSpeedAsString;
final bool? isFailed;
const DriftUploadStatus({ const DriftUploadStatus({
required this.taskId, required this.taskId,
@ -48,6 +49,7 @@ class DriftUploadStatus {
required this.progress, required this.progress,
required this.fileSize, required this.fileSize,
required this.networkSpeedAsString, required this.networkSpeedAsString,
this.isFailed,
}); });
DriftUploadStatus copyWith({ DriftUploadStatus copyWith({
@ -56,6 +58,7 @@ class DriftUploadStatus {
double? progress, double? progress,
int? fileSize, int? fileSize,
String? networkSpeedAsString, String? networkSpeedAsString,
bool? isFailed,
}) { }) {
return DriftUploadStatus( return DriftUploadStatus(
taskId: taskId ?? this.taskId, taskId: taskId ?? this.taskId,
@ -63,12 +66,13 @@ class DriftUploadStatus {
progress: progress ?? this.progress, progress: progress ?? this.progress,
fileSize: fileSize ?? this.fileSize, fileSize: fileSize ?? this.fileSize,
networkSpeedAsString: networkSpeedAsString ?? this.networkSpeedAsString, networkSpeedAsString: networkSpeedAsString ?? this.networkSpeedAsString,
isFailed: isFailed ?? this.isFailed,
); );
} }
@override @override
String toString() { 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 @override
@ -79,7 +83,8 @@ class DriftUploadStatus {
other.filename == filename && other.filename == filename &&
other.progress == progress && other.progress == progress &&
other.fileSize == fileSize && other.fileSize == fileSize &&
other.networkSpeedAsString == networkSpeedAsString; other.networkSpeedAsString == networkSpeedAsString &&
other.isFailed == isFailed;
} }
@override @override
@ -88,7 +93,8 @@ class DriftUploadStatus {
filename.hashCode ^ filename.hashCode ^
progress.hashCode ^ progress.hashCode ^
fileSize.hashCode ^ fileSize.hashCode ^
networkSpeedAsString.hashCode; networkSpeedAsString.hashCode ^
isFailed.hashCode;
} }
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
@ -98,6 +104,7 @@ class DriftUploadStatus {
'progress': progress, 'progress': progress,
'fileSize': fileSize, 'fileSize': fileSize,
'networkSpeedAsString': networkSpeedAsString, 'networkSpeedAsString': networkSpeedAsString,
'isFailed': isFailed,
}; };
} }
@ -108,6 +115,7 @@ class DriftUploadStatus {
progress: map['progress'] as double, progress: map['progress'] as double,
fileSize: map['fileSize'] as int, fileSize: map['fileSize'] as int,
networkSpeedAsString: map['networkSpeedAsString'] as String, networkSpeedAsString: map['networkSpeedAsString'] as String,
isFailed: map['isFailed'] != null ? map['isFailed'] as bool : null,
); );
} }
@ -235,6 +243,8 @@ class ExpBackupNotifier extends StateNotifier<DriftBackupState> {
} }
void _handleTaskStatusUpdate(TaskStatusUpdate update) { void _handleTaskStatusUpdate(TaskStatusUpdate update) {
final taskId = update.task.taskId;
switch (update.status) { switch (update.status) {
case TaskStatus.complete: case TaskStatus.complete:
if (update.task.group == kBackupGroup) { if (update.task.group == kBackupGroup) {
@ -245,14 +255,26 @@ class ExpBackupNotifier extends StateNotifier<DriftBackupState> {
} }
// Remove the completed task from the upload items // Remove the completed task from the upload items
final taskId = update.task.taskId;
if (state.uploadItems.containsKey(taskId)) { if (state.uploadItems.containsKey(taskId)) {
Future.delayed(const Duration(milliseconds: 500), () { Future.delayed(const Duration(milliseconds: 1000), () {
_removeUploadItem(taskId); _removeUploadItem(taskId);
}); });
} }
case TaskStatus.failed: case TaskStatus.failed:
final currentItem = state.uploadItems[taskId];
if (currentItem == null) {
return;
}
state = state.copyWith(
uploadItems: {
...state.uploadItems,
taskId: currentItem.copyWith(
isFailed: true,
),
},
);
break; break;
case TaskStatus.canceled: case TaskStatus.canceled: