fix: improve sync backup error indicator (#22527)

* fix: improve sync indicator error

* prefer backup disabled icon before error

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
shenlong 2025-10-03 19:06:44 +05:30 committed by GitHub
parent 3c5a125762
commit 212649edf9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 81 additions and 58 deletions

View file

@ -6,11 +6,12 @@ import 'package:immich_mobile/utils/isolate.dart';
import 'package:worker_manager/worker_manager.dart';
typedef SyncCallback = void Function();
typedef SyncCallbackWithResult<T> = void Function(T result);
typedef SyncErrorCallback = void Function(String error);
class BackgroundSyncManager {
final SyncCallback? onRemoteSyncStart;
final SyncCallback? onRemoteSyncComplete;
final SyncCallbackWithResult<bool?>? onRemoteSyncComplete;
final SyncErrorCallback? onRemoteSyncError;
final SyncCallback? onLocalSyncStart;
@ -156,15 +157,18 @@ class BackgroundSyncManager {
debugLabel: 'remote-sync',
);
return _syncTask!
.then((result) => result ?? false)
.whenComplete(() {
onRemoteSyncComplete?.call();
_syncTask = null;
.then((result) {
final success = result ?? false;
onRemoteSyncComplete?.call(success);
return success;
})
.catchError((error) {
onRemoteSyncError?.call(error.toString());
_syncTask = null;
return false;
})
.whenComplete(() {
_syncTask = null;
});
}

View file

@ -49,9 +49,6 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
ref.read(driftBackupProvider.notifier).updateSyncing(true);
syncSuccess = await ref.read(backgroundSyncProvider).syncRemote();
ref
.read(driftBackupProvider.notifier)
.updateError(syncSuccess == true ? BackupError.none : BackupError.syncFailed);
ref.read(driftBackupProvider.notifier).updateSyncing(false);
if (mounted) {
@ -94,7 +91,6 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
if (syncSuccess == false) {
Logger("DriftBackupPage").warning("Remote sync did not complete successfully, skipping backup");
backupNotifier.updateError(BackupError.syncFailed);
return;
}
await backupNotifier.startBackup(currentUser.id);

View file

@ -120,7 +120,6 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
return backupNotifier.startBackup(user.id);
} else {
Logger('DriftBackupAlbumSelectionPage').warning('Background sync failed, not starting backup');
backupNotifier.updateError(BackupError.syncFailed);
}
}),
),

View file

@ -66,7 +66,6 @@ class DriftBackupOptionsPage extends ConsumerWidget {
return backupNotifier.startBackup(currentUser.id);
} else {
Logger('DriftBackupOptionsPage').warning('Background sync failed, not starting backup');
backupNotifier.updateError(BackupError.syncFailed);
}
}),
),

View file

@ -76,7 +76,6 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
_resumeBackup(backupProvider),
]);
} else {
backupProvider.updateError(BackupError.syncFailed);
await backgroundManager.hashAssets();
}

View file

@ -161,7 +161,6 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
_resumeBackup(),
]);
} else {
_ref.read(driftBackupProvider.notifier).updateError(BackupError.syncFailed);
await _safeRun(backgroundManager.hashAssets(), "hashAssets");
}

View file

@ -1,12 +1,21 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/utils/background_sync.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/sync_status.provider.dart';
final backgroundSyncProvider = Provider<BackgroundSyncManager>((ref) {
final syncStatusNotifier = ref.read(syncStatusProvider.notifier);
final backupProvider = ref.read(driftBackupProvider.notifier);
final manager = BackgroundSyncManager(
onRemoteSyncStart: syncStatusNotifier.startRemoteSync,
onRemoteSyncComplete: syncStatusNotifier.completeRemoteSync,
onRemoteSyncStart: () {
syncStatusNotifier.startRemoteSync();
backupProvider.updateError(BackupError.none);
},
onRemoteSyncComplete: (isSuccess) {
syncStatusNotifier.completeRemoteSync();
backupProvider.updateError(isSuccess == true ? BackupError.none : BackupError.syncFailed);
},
onRemoteSyncError: syncStatusNotifier.errorRemoteSync,
onLocalSyncStart: syncStatusNotifier.startLocalSync,
onLocalSyncComplete: syncStatusNotifier.completeLocalSync,

View file

@ -162,48 +162,32 @@ class _ProfileIndicator extends ConsumerWidget {
}
}
const double _kBadgeWidgetSize = 30.0;
class _BackupIndicator extends ConsumerWidget {
const _BackupIndicator();
@override
Widget build(BuildContext context, WidgetRef ref) {
const widgetSize = 30.0;
final hasError = ref.watch(driftBackupProvider.select((state) => state.error != BackupError.none));
final indicatorIcon = hasError
? Icon(
Icons.warning_rounded,
size: 12,
color: context.colorScheme.error,
semanticLabel: 'backup_controller_page_backup'.tr(),
)
: _getBackupBadgeIcon(context, ref);
final badgeBackground = hasError ? context.colorScheme.errorContainer : context.colorScheme.surfaceContainer;
final indicatorIcon = _getBackupBadgeIcon(context, ref);
return InkWell(
onTap: () => context.pushRoute(const DriftBackupRoute()),
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: Badge(
label: Container(
width: widgetSize / 2,
height: widgetSize / 2,
decoration: BoxDecoration(
color: badgeBackground,
border: Border.all(color: context.colorScheme.outline.withValues(alpha: .3)),
borderRadius: BorderRadius.circular(widgetSize / 2),
),
child: indicatorIcon,
),
label: indicatorIcon,
backgroundColor: Colors.transparent,
alignment: Alignment.bottomRight,
isLabelVisible: indicatorIcon != null,
offset: const Offset(-2, -12),
child: Icon(Icons.backup_rounded, size: widgetSize, color: context.primaryColor),
child: Icon(Icons.backup_rounded, size: _kBadgeWidgetSize, color: context.primaryColor),
),
);
}
Widget? _getBackupBadgeIcon(BuildContext context, WidgetRef ref) {
final backupStateStream = ref.watch(settingsProvider).watch(Setting.enableBackup);
final hasError = ref.watch(driftBackupProvider.select((state) => state.error != BackupError.none));
final isDarkTheme = context.isDarkTheme;
final iconColor = isDarkTheme ? Colors.white : Colors.black;
final isUploading = ref.watch(driftBackupProvider.select((state) => state.uploadItems.isNotEmpty));
@ -215,42 +199,76 @@ class _BackupIndicator extends ConsumerWidget {
final backupEnabled = snapshot.data ?? false;
if (!backupEnabled) {
return Icon(
Icons.cloud_off_rounded,
size: 9,
color: iconColor,
semanticLabel: 'backup_controller_page_backup'.tr(),
return _BadgeLabel(
Icon(
Icons.cloud_off_rounded,
size: 9,
color: iconColor,
semanticLabel: 'backup_controller_page_backup'.tr(),
),
);
}
if (hasError) {
return _BadgeLabel(
Icon(
Icons.warning_rounded,
size: 12,
color: context.colorScheme.error,
semanticLabel: 'backup_controller_page_backup'.tr(),
),
backgroundColor: context.colorScheme.errorContainer,
);
}
if (isUploading) {
return Container(
padding: const EdgeInsets.all(3.5),
child: Theme(
data: context.themeData.copyWith(
progressIndicatorTheme: context.themeData.progressIndicatorTheme.copyWith(year2023: true),
),
child: CircularProgressIndicator(
strokeWidth: 2,
strokeCap: StrokeCap.round,
valueColor: AlwaysStoppedAnimation<Color>(iconColor),
semanticsLabel: 'backup_controller_page_backup'.tr(),
return _BadgeLabel(
Container(
padding: const EdgeInsets.all(3.5),
child: Theme(
data: context.themeData.copyWith(
progressIndicatorTheme: context.themeData.progressIndicatorTheme.copyWith(year2023: true),
),
child: CircularProgressIndicator(
strokeWidth: 2,
strokeCap: StrokeCap.round,
valueColor: AlwaysStoppedAnimation<Color>(iconColor),
semanticsLabel: 'backup_controller_page_backup'.tr(),
),
),
),
);
}
return Icon(
Icons.check_outlined,
size: 9,
color: iconColor,
semanticLabel: 'backup_controller_page_backup'.tr(),
return _BadgeLabel(
Icon(Icons.check_outlined, size: 9, color: iconColor, semanticLabel: 'backup_controller_page_backup'.tr()),
);
},
);
}
}
class _BadgeLabel extends StatelessWidget {
final Widget indicator;
final Color? backgroundColor;
const _BadgeLabel(this.indicator, {this.backgroundColor});
@override
Widget build(BuildContext context) {
return Container(
width: _kBadgeWidgetSize / 2,
height: _kBadgeWidgetSize / 2,
decoration: BoxDecoration(
color: backgroundColor ?? context.colorScheme.surfaceContainer,
border: Border.all(color: context.colorScheme.outline.withValues(alpha: .3)),
borderRadius: BorderRadius.circular(_kBadgeWidgetSize / 2),
),
child: indicator,
);
}
}
class _SyncStatusIndicator extends ConsumerStatefulWidget {
const _SyncStatusIndicator();