feat: new upload (cont) (#20029)

* new upload button

* wip

* pr feedback

* fix: updateAll override album selection value

* feat: status box

* feat: handle upload resume

* re-enable websocket event

* fix: update state condition and upload status

* Better backup detail page

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
Alex
2025-07-21 15:30:51 -05:00
committed by GitHub
parent 1dc62fce5f
commit 4d27f187ea
26 changed files with 1558 additions and 413 deletions
@@ -6,10 +6,12 @@ import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/models/backup/backup_state.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/asset.provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/backup/ios_background_settings.provider.dart';
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
@@ -18,6 +20,7 @@ import 'package:immich_mobile/providers/notification_permission.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/background.service.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
@@ -69,25 +72,18 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
}
await _ref.read(serverInfoProvider.notifier).getServerVersion();
// TODO: Need to decide on how we want to handle uploads once the app is resumed
// await FileDownloader().start();
}
if (!Store.isBetaTimelineEnabled) {
switch (_ref.read(tabProvider)) {
case TabEnum.home:
await _ref.read(assetProvider.notifier).getAllAsset();
break;
case TabEnum.search:
// nothing to do
break;
case TabEnum.albums:
await _ref.read(albumProvider.notifier).refreshRemoteAlbums();
break;
case TabEnum.library:
// nothing to do
case TabEnum.search:
break;
}
} else {
@@ -108,7 +104,15 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
},
),
backgroundManager.syncRemote(),
]);
]).then((_) async {
final isEnableBackup = _ref
.read(appSettingsServiceProvider)
.getSetting(AppSettingsEnum.enableBackup);
if (isEnableBackup) {
await _ref.read(driftBackupProvider.notifier).handleBackupResume();
}
});
} catch (e, stackTrace) {
Logger("AppLifeCycleNotifier").severe(
"Error during background sync",
@@ -1,6 +1,7 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/services/local_album.service.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
final backupAlbumProvider =
@@ -18,7 +19,8 @@ class BackupAlbumNotifier extends StateNotifier<List<LocalAlbum>> {
final LocalAlbumService _localAlbumService;
Future<void> getAll() async {
state = await _localAlbumService.getAll();
state =
await _localAlbumService.getAll(sortBy: {SortLocalAlbumsBy.assetCount});
}
Future<void> selectAlbum(LocalAlbum album) async {
@@ -1,39 +1,75 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:async';
import 'dart:convert';
import 'package:background_downloader/background_downloader.dart';
import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/services/drift_backup.service.dart';
import 'package:immich_mobile/services/upload.service.dart';
class EnqueueStatus {
final int enqueueCount;
final int totalCount;
const EnqueueStatus({
required this.enqueueCount,
required this.totalCount,
});
EnqueueStatus copyWith({
int? enqueueCount,
int? totalCount,
}) {
return EnqueueStatus(
enqueueCount: enqueueCount ?? this.enqueueCount,
totalCount: totalCount ?? this.totalCount,
);
}
@override
String toString() =>
'EnqueueStatus(enqueueCount: $enqueueCount, totalCount: $totalCount)';
}
class DriftUploadStatus {
final String taskId;
final String filename;
final double progress;
final int fileSize;
final String networkSpeedAsString;
const DriftUploadStatus({
required this.taskId,
required this.filename,
required this.progress,
required this.fileSize,
required this.networkSpeedAsString,
});
DriftUploadStatus copyWith({
String? taskId,
String? filename,
double? progress,
int? fileSize,
String? networkSpeedAsString,
}) {
return DriftUploadStatus(
taskId: taskId ?? this.taskId,
filename: filename ?? this.filename,
progress: progress ?? this.progress,
fileSize: fileSize ?? this.fileSize,
networkSpeedAsString: networkSpeedAsString ?? this.networkSpeedAsString,
);
}
@override
String toString() =>
'ExpUploadStatus(taskId: $taskId, filename: $filename, progress: $progress)';
String toString() {
return 'DriftUploadStatus(taskId: $taskId, filename: $filename, progress: $progress, fileSize: $fileSize, networkSpeedAsString: $networkSpeedAsString)';
}
@override
bool operator ==(covariant DriftUploadStatus other) {
@@ -41,23 +77,65 @@ class DriftUploadStatus {
return other.taskId == taskId &&
other.filename == filename &&
other.progress == progress;
other.progress == progress &&
other.fileSize == fileSize &&
other.networkSpeedAsString == networkSpeedAsString;
}
@override
int get hashCode => taskId.hashCode ^ filename.hashCode ^ progress.hashCode;
int get hashCode {
return taskId.hashCode ^
filename.hashCode ^
progress.hashCode ^
fileSize.hashCode ^
networkSpeedAsString.hashCode;
}
Map<String, dynamic> toMap() {
return <String, dynamic>{
'taskId': taskId,
'filename': filename,
'progress': progress,
'fileSize': fileSize,
'networkSpeedAsString': networkSpeedAsString,
};
}
factory DriftUploadStatus.fromMap(Map<String, dynamic> map) {
return DriftUploadStatus(
taskId: map['taskId'] as String,
filename: map['filename'] as String,
progress: map['progress'] as double,
fileSize: map['fileSize'] as int,
networkSpeedAsString: map['networkSpeedAsString'] as String,
);
}
String toJson() => json.encode(toMap());
factory DriftUploadStatus.fromJson(String source) =>
DriftUploadStatus.fromMap(json.decode(source) as Map<String, dynamic>);
}
class DriftBackupState {
final int totalCount;
final int backupCount;
final int remainderCount;
final int enqueueCount;
final int enqueueTotalCount;
final bool isCanceling;
final Map<String, DriftUploadStatus> uploadItems;
const DriftBackupState({
required this.totalCount,
required this.backupCount,
required this.remainderCount,
required this.enqueueCount,
required this.enqueueTotalCount,
required this.isCanceling,
required this.uploadItems,
});
@@ -65,19 +143,25 @@ class DriftBackupState {
int? totalCount,
int? backupCount,
int? remainderCount,
int? enqueueCount,
int? enqueueTotalCount,
bool? isCanceling,
Map<String, DriftUploadStatus>? uploadItems,
}) {
return DriftBackupState(
totalCount: totalCount ?? this.totalCount,
backupCount: backupCount ?? this.backupCount,
remainderCount: remainderCount ?? this.remainderCount,
enqueueCount: enqueueCount ?? this.enqueueCount,
enqueueTotalCount: enqueueTotalCount ?? this.enqueueTotalCount,
isCanceling: isCanceling ?? this.isCanceling,
uploadItems: uploadItems ?? this.uploadItems,
);
}
@override
String toString() {
return 'ExpBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, uploadItems: $uploadItems)';
return 'DriftBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, enqueueCount: $enqueueCount, enqueueTotalCount: $enqueueTotalCount, isCanceling: $isCanceling, uploadItems: $uploadItems)';
}
@override
@@ -88,6 +172,9 @@ class DriftBackupState {
return other.totalCount == totalCount &&
other.backupCount == backupCount &&
other.remainderCount == remainderCount &&
other.enqueueCount == enqueueCount &&
other.enqueueTotalCount == enqueueTotalCount &&
other.isCanceling == isCanceling &&
mapEquals(other.uploadItems, uploadItems);
}
@@ -96,6 +183,9 @@ class DriftBackupState {
return totalCount.hashCode ^
backupCount.hashCode ^
remainderCount.hashCode ^
enqueueCount.hashCode ^
enqueueTotalCount.hashCode ^
isCanceling.hashCode ^
uploadItems.hashCode;
}
}
@@ -117,6 +207,9 @@ class ExpBackupNotifier extends StateNotifier<DriftBackupState> {
totalCount: 0,
backupCount: 0,
remainderCount: 0,
enqueueCount: 0,
enqueueTotalCount: 0,
isCanceling: false,
uploadItems: {},
),
) {
@@ -131,13 +224,39 @@ class ExpBackupNotifier extends StateNotifier<DriftBackupState> {
StreamSubscription<TaskStatusUpdate>? _statusSubscription;
StreamSubscription<TaskProgressUpdate>? _progressSubscription;
/// Remove upload item from state
void _removeUploadItem(String taskId) {
if (state.uploadItems.containsKey(taskId)) {
final updatedItems =
Map<String, DriftUploadStatus>.from(state.uploadItems);
updatedItems.remove(taskId);
state = state.copyWith(uploadItems: updatedItems);
}
}
void _handleTaskStatusUpdate(TaskStatusUpdate update) {
switch (update.status) {
case TaskStatus.complete:
state = state.copyWith(
backupCount: state.backupCount + 1,
remainderCount: state.remainderCount - 1,
);
if (update.task.group == kBackupGroup) {
state = state.copyWith(
backupCount: state.backupCount + 1,
remainderCount: state.remainderCount - 1,
);
}
// Remove the completed task from the upload items
final taskId = update.task.taskId;
if (state.uploadItems.containsKey(taskId)) {
Future.delayed(const Duration(milliseconds: 500), () {
_removeUploadItem(taskId);
});
}
case TaskStatus.failed:
break;
case TaskStatus.canceled:
_removeUploadItem(update.task.taskId);
break;
default:
@@ -145,7 +264,48 @@ class ExpBackupNotifier extends StateNotifier<DriftBackupState> {
}
}
void _handleTaskProgressUpdate(TaskProgressUpdate update) {}
void _handleTaskProgressUpdate(TaskProgressUpdate update) {
final taskId = update.task.taskId;
final filename = update.task.displayName;
final progress = update.progress;
final currentItem = state.uploadItems[taskId];
if (currentItem != null) {
if (progress == kUploadStatusCanceled) {
_removeUploadItem(update.task.taskId);
return;
}
state = state.copyWith(
uploadItems: {
...state.uploadItems,
taskId: update.hasExpectedFileSize
? currentItem.copyWith(
progress: progress,
fileSize: update.expectedFileSize,
networkSpeedAsString: update.networkSpeedAsString,
)
: currentItem.copyWith(
progress: progress,
),
},
);
return;
}
state = state.copyWith(
uploadItems: {
...state.uploadItems,
taskId: DriftUploadStatus(
taskId: taskId,
filename: filename,
progress: progress,
fileSize: update.expectedFileSize,
networkSpeedAsString: update.networkSpeedAsString,
),
},
);
}
Future<void> getBackupStatus() async {
final [totalCount, backupCount, remainderCount] = await Future.wait([
@@ -162,27 +322,50 @@ class ExpBackupNotifier extends StateNotifier<DriftBackupState> {
}
Future<void> backup() {
return _backupService.backup();
return _backupService.backup(_updateEnqueueCount);
}
void _updateEnqueueCount(EnqueueStatus status) {
state = state.copyWith(
enqueueCount: status.enqueueCount,
enqueueTotalCount: status.totalCount,
);
}
Future<void> cancel() async {
state = state.copyWith(
enqueueCount: 0,
enqueueTotalCount: 0,
isCanceling: true,
);
await _backupService.cancel();
await getDataInfo();
// Check if there are any tasks left in the queue
final tasks = await FileDownloader().allTasks(group: kBackupGroup);
debugPrint("Tasks left to cancel: ${tasks.length}");
if (tasks.isNotEmpty) {
await cancel();
} else {
// Clear all upload items when cancellation is complete
state = state.copyWith(
isCanceling: false,
uploadItems: {},
);
}
}
Future<void> getDataInfo() async {
final a = await FileDownloader().database.allRecordsWithStatus(
TaskStatus.enqueued,
group: kBackupGroup,
);
Future<void> handleBackupResume() async {
final tasks = await FileDownloader().allTasks(group: kBackupGroup);
if (tasks.isEmpty) {
// Start a new backup queue
await backup();
}
final b = await FileDownloader().allTasks(
group: kBackupGroup,
);
debugPrint(
"Enqueued tasks: ${a.length}, All tasks: ${b.length}",
);
debugPrint("Tasks to resume: ${tasks.length}");
await FileDownloader().start();
}
@override
+9 -3
View File
@@ -12,6 +12,7 @@ import 'package:immich_mobile/models/server_info/server_version.model.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
// import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
@@ -177,9 +178,15 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
});
if (!Store.isBetaTimelineEnabled) {
startListeningToOldEvents();
socket.on('on_upload_success', _handleOnUploadSuccess);
socket.on('on_asset_delete', _handleOnAssetDelete);
socket.on('on_asset_trash', _handleOnAssetTrash);
socket.on('on_asset_restore', _handleServerUpdates);
socket.on('on_asset_update', _handleServerUpdates);
socket.on('on_asset_stack_update', _handleServerUpdates);
socket.on('on_asset_hidden', _handleOnAssetHidden);
} else {
startListeningToBetaEvents();
socket.on('AssetUploadReadyV1', _handleSyncAssetUploadReady);
}
socket.on('on_config_update', _handleOnConfigUpdate);
@@ -207,7 +214,6 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
}
void stopListenToEvent(String eventName) {
debugPrint("Stop listening to event $eventName");
state.socket?.off(eventName);
}