diff --git a/mobile/lib/domain/utils/background_sync.dart b/mobile/lib/domain/utils/background_sync.dart index c71f1a8315..7ab2989354 100644 --- a/mobile/lib/domain/utils/background_sync.dart +++ b/mobile/lib/domain/utils/background_sync.dart @@ -4,13 +4,24 @@ import 'package:immich_mobile/providers/infrastructure/sync.provider.dart'; import 'package:immich_mobile/utils/isolate.dart'; import 'package:worker_manager/worker_manager.dart'; +typedef SyncCallback = void Function(); +typedef SyncErrorCallback = void Function(String error); + class BackgroundSyncManager { + final SyncCallback? onRemoteSyncStart; + final SyncCallback? onRemoteSyncComplete; + final SyncErrorCallback? onRemoteSyncError; + Cancelable? _syncTask; Cancelable? _syncWebsocketTask; Cancelable? _deviceAlbumSyncTask; Cancelable? _hashTask; - BackgroundSyncManager(); + BackgroundSyncManager({ + this.onRemoteSyncStart, + this.onRemoteSyncComplete, + this.onRemoteSyncError, + }); Future cancel() { final futures = []; @@ -72,10 +83,16 @@ class BackgroundSyncManager { return _syncTask!.future; } + onRemoteSyncStart?.call(); + _syncTask = runInIsolateGentle( computation: (ref) => ref.read(syncStreamServiceProvider).sync(), ); return _syncTask!.whenComplete(() { + onRemoteSyncComplete?.call(); + _syncTask = null; + }).catchError((error) { + onRemoteSyncError?.call(error.toString()); _syncTask = null; }); } diff --git a/mobile/lib/pages/common/tab_shell.page.dart b/mobile/lib/pages/common/tab_shell.page.dart index 24efff143f..671c1a6156 100644 --- a/mobile/lib/pages/common/tab_shell.page.dart +++ b/mobile/lib/pages/common/tab_shell.page.dart @@ -35,42 +35,15 @@ class _TabShellPageState extends ConsumerState { Widget build(BuildContext context) { final isScreenLandscape = context.orientation == Orientation.landscape; - Widget buildIcon({required Widget icon, required bool isProcessing}) { - if (!isProcessing) return icon; - return Stack( - alignment: Alignment.center, - clipBehavior: Clip.none, - children: [ - icon, - Positioned( - right: -18, - child: SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation( - context.primaryColor, - ), - ), - ), - ), - ], - ); - } - final navigationDestinations = [ NavigationDestination( label: 'photos'.tr(), icon: const Icon( Icons.photo_library_outlined, ), - selectedIcon: buildIcon( - isProcessing: false, - icon: Icon( - Icons.photo_library, - color: context.primaryColor, - ), + selectedIcon: Icon( + Icons.photo_library, + color: context.primaryColor, ), ), NavigationDestination( @@ -88,12 +61,9 @@ class _TabShellPageState extends ConsumerState { icon: const Icon( Icons.photo_album_outlined, ), - selectedIcon: buildIcon( - isProcessing: false, - icon: Icon( - Icons.photo_album_rounded, - color: context.primaryColor, - ), + selectedIcon: Icon( + Icons.photo_album_rounded, + color: context.primaryColor, ), ), NavigationDestination( @@ -101,12 +71,9 @@ class _TabShellPageState extends ConsumerState { icon: const Icon( Icons.space_dashboard_outlined, ), - selectedIcon: buildIcon( - isProcessing: false, - icon: Icon( - Icons.space_dashboard_rounded, - color: context.primaryColor, - ), + selectedIcon: Icon( + Icons.space_dashboard_rounded, + color: context.primaryColor, ), ), ]; @@ -183,7 +150,7 @@ void _onNavigationSelected(TabsRouter router, int index, WidgetRef ref) { // Album page if (index == 2) { - ref.read(remoteAlbumProvider.notifier).getAll(); + ref.read(remoteAlbumProvider.notifier).refresh(); } ref.read(hapticFeedbackProvider.notifier).selectionClick(); diff --git a/mobile/lib/presentation/pages/drift_album.page.dart b/mobile/lib/presentation/pages/drift_album.page.dart index d59f734c79..fbdf1ef116 100644 --- a/mobile/lib/presentation/pages/drift_album.page.dart +++ b/mobile/lib/presentation/pages/drift_album.page.dart @@ -40,7 +40,7 @@ class _DriftAlbumsPageState extends ConsumerState { // Load albums when component mounts WidgetsBinding.instance.addPostFrameCallback((_) { - ref.read(remoteAlbumProvider.notifier).getAll(); + ref.read(remoteAlbumProvider.notifier).refresh(); }); searchController.addListener(() { @@ -88,10 +88,9 @@ class _DriftAlbumsPageState extends ConsumerState { @override Widget build(BuildContext context) { - final albumState = ref.watch(remoteAlbumProvider); - final albums = albumState.filteredAlbums; - final isLoading = albumState.isLoading; - final error = albumState.error; + final albums = + ref.watch(remoteAlbumProvider.select((s) => s.filteredAlbums)); + final userId = ref.watch(currentUserProvider)?.id; return RefreshIndicator( @@ -133,14 +132,10 @@ class _DriftAlbumsPageState extends ConsumerState { ? _AlbumGrid( albums: albums, userId: userId, - isLoading: isLoading, - error: error, ) : _AlbumList( albums: albums, userId: userId, - isLoading: isLoading, - error: error, ), ], ), @@ -481,46 +476,15 @@ class _QuickSortAndViewMode extends StatelessWidget { class _AlbumList extends ConsumerWidget { const _AlbumList({ - required this.isLoading, - required this.error, required this.albums, required this.userId, }); - final bool isLoading; - final String? error; final List albums; final String? userId; @override Widget build(BuildContext context, WidgetRef ref) { - if (isLoading) { - return const SliverToBoxAdapter( - child: Center( - child: Padding( - padding: EdgeInsets.all(20.0), - child: CircularProgressIndicator(), - ), - ), - ); - } - - if (error != null) { - return SliverToBoxAdapter( - child: Center( - child: Padding( - padding: const EdgeInsets.all(20.0), - child: Text( - 'Error loading albums: $error', - style: TextStyle( - color: context.colorScheme.error, - ), - ), - ), - ), - ); - } - if (albums.isEmpty) { return const SliverToBoxAdapter( child: Center( @@ -623,44 +587,13 @@ class _AlbumGrid extends StatelessWidget { const _AlbumGrid({ required this.albums, required this.userId, - required this.isLoading, - required this.error, }); final List albums; final String? userId; - final bool isLoading; - final String? error; @override Widget build(BuildContext context) { - if (isLoading) { - return const SliverToBoxAdapter( - child: Center( - child: Padding( - padding: EdgeInsets.all(20.0), - child: CircularProgressIndicator(), - ), - ), - ); - } - - if (error != null) { - return SliverToBoxAdapter( - child: Center( - child: Padding( - padding: const EdgeInsets.all(20.0), - child: Text( - 'Error loading albums: $error', - style: TextStyle( - color: context.colorScheme.error, - ), - ), - ), - ), - ); - } - if (albums.isEmpty) { return const SliverToBoxAdapter( child: Center( diff --git a/mobile/lib/providers/background_sync.provider.dart b/mobile/lib/providers/background_sync.provider.dart index 83d103bb3b..dc9cc0d59f 100644 --- a/mobile/lib/providers/background_sync.provider.dart +++ b/mobile/lib/providers/background_sync.provider.dart @@ -1,8 +1,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/utils/background_sync.dart'; +import 'package:immich_mobile/providers/sync_status.provider.dart'; final backgroundSyncProvider = Provider((ref) { - final manager = BackgroundSyncManager(); + final syncStatusNotifier = ref.read(syncStatusProvider.notifier); + final manager = BackgroundSyncManager( + onRemoteSyncStart: syncStatusNotifier.startRemoteSync, + onRemoteSyncComplete: syncStatusNotifier.completeRemoteSync, + onRemoteSyncError: syncStatusNotifier.errorRemoteSync, + ); ref.onDispose(manager.cancel); return manager; }); diff --git a/mobile/lib/providers/infrastructure/remote_album.provider.dart b/mobile/lib/providers/infrastructure/remote_album.provider.dart index 14badd58ed..2ce10d7cbd 100644 --- a/mobile/lib/providers/infrastructure/remote_album.provider.dart +++ b/mobile/lib/providers/infrastructure/remote_album.provider.dart @@ -6,6 +6,7 @@ import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/domain/services/remote_album.service.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/utils/remote_album.utils.dart'; +import 'package:logging/logging.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'album.provider.dart'; @@ -13,33 +14,25 @@ import 'album.provider.dart'; class RemoteAlbumState { final List albums; final List filteredAlbums; - final bool isLoading; - final String? error; const RemoteAlbumState({ required this.albums, List? filteredAlbums, - this.isLoading = false, - this.error, }) : filteredAlbums = filteredAlbums ?? albums; RemoteAlbumState copyWith({ List? albums, List? filteredAlbums, - bool? isLoading, - String? error, }) { return RemoteAlbumState( albums: albums ?? this.albums, filteredAlbums: filteredAlbums ?? this.filteredAlbums, - isLoading: isLoading ?? this.isLoading, - error: error ?? this.error, ); } @override String toString() => - 'RemoteAlbumState(albums: ${albums.length}, filteredAlbums: ${filteredAlbums.length}, isLoading: $isLoading, error: $error)'; + 'RemoteAlbumState(albums: ${albums.length}, filteredAlbums: ${filteredAlbums.length})'; @override bool operator ==(covariant RemoteAlbumState other) { @@ -47,47 +40,38 @@ class RemoteAlbumState { final listEquals = const DeepCollectionEquality().equals; return listEquals(other.albums, albums) && - listEquals(other.filteredAlbums, filteredAlbums) && - other.isLoading == isLoading && - other.error == error; + listEquals(other.filteredAlbums, filteredAlbums); } @override - int get hashCode => - albums.hashCode ^ - filteredAlbums.hashCode ^ - isLoading.hashCode ^ - error.hashCode; + int get hashCode => albums.hashCode ^ filteredAlbums.hashCode; } class RemoteAlbumNotifier extends Notifier { late RemoteAlbumService _remoteAlbumService; - + final _logger = Logger('RemoteAlbumNotifier'); @override RemoteAlbumState build() { _remoteAlbumService = ref.read(remoteAlbumServiceProvider); return const RemoteAlbumState(albums: [], filteredAlbums: []); } - Future> getAll() async { - state = state.copyWith(isLoading: true, error: null); - + Future> _getAll() async { try { final albums = await _remoteAlbumService.getAll(); state = state.copyWith( albums: albums, filteredAlbums: albums, - isLoading: false, ); return albums; - } catch (e) { - state = state.copyWith(isLoading: false, error: e.toString()); + } catch (error, stack) { + _logger.severe('Failed to fetch albums', error, stack); rethrow; } } Future refresh() async { - await getAll(); + await _getAll(); } void searchAlbums( @@ -127,8 +111,6 @@ class RemoteAlbumNotifier extends Notifier { String? description, List assetIds = const [], }) async { - state = state.copyWith(isLoading: true, error: null); - try { final album = await _remoteAlbumService.createAlbum( title: title, @@ -141,10 +123,9 @@ class RemoteAlbumNotifier extends Notifier { filteredAlbums: [...state.filteredAlbums, album], ); - state = state.copyWith(isLoading: false); return album; - } catch (e) { - state = state.copyWith(isLoading: false, error: e.toString()); + } catch (error, stack) { + _logger.severe('Failed to create album', error, stack); rethrow; } } @@ -157,8 +138,6 @@ class RemoteAlbumNotifier extends Notifier { bool? isActivityEnabled, AlbumAssetOrder? order, }) async { - state = state.copyWith(isLoading: true, error: null); - try { final updatedAlbum = await _remoteAlbumService.updateAlbum( albumId, @@ -180,12 +159,11 @@ class RemoteAlbumNotifier extends Notifier { state = state.copyWith( albums: updatedAlbums, filteredAlbums: updatedFilteredAlbums, - isLoading: false, ); return updatedAlbum; - } catch (e) { - state = state.copyWith(isLoading: false, error: e.toString()); + } catch (error, stack) { + _logger.severe('Failed to update album', error, stack); rethrow; } } diff --git a/mobile/lib/providers/sync_status.provider.dart b/mobile/lib/providers/sync_status.provider.dart new file mode 100644 index 0000000000..18d851aa19 --- /dev/null +++ b/mobile/lib/providers/sync_status.provider.dart @@ -0,0 +1,68 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +enum SyncStatus { + idle, + syncing, + success, + error, +} + +class SyncStatusState { + final SyncStatus remoteSyncStatus; + final String? errorMessage; + + const SyncStatusState({ + this.remoteSyncStatus = SyncStatus.idle, + this.errorMessage, + }); + + SyncStatusState copyWith({ + SyncStatus? remoteSyncStatus, + String? errorMessage, + }) { + return SyncStatusState( + remoteSyncStatus: remoteSyncStatus ?? this.remoteSyncStatus, + errorMessage: errorMessage ?? this.errorMessage, + ); + } + + bool get isRemoteSyncing => remoteSyncStatus == SyncStatus.syncing; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is SyncStatusState && + other.remoteSyncStatus == remoteSyncStatus && + other.errorMessage == errorMessage; + } + + @override + int get hashCode => Object.hash(remoteSyncStatus, errorMessage); +} + +class SyncStatusNotifier extends Notifier { + @override + SyncStatusState build() { + return const SyncStatusState( + errorMessage: null, + remoteSyncStatus: SyncStatus.idle, + ); + } + + void setRemoteSyncStatus(SyncStatus status, [String? errorMessage]) { + state = state.copyWith( + remoteSyncStatus: status, + errorMessage: status == SyncStatus.error ? errorMessage : null, + ); + } + + void startRemoteSync() => setRemoteSyncStatus(SyncStatus.syncing); + void completeRemoteSync() => setRemoteSyncStatus(SyncStatus.success); + void errorRemoteSync(String error) => + setRemoteSyncStatus(SyncStatus.error, error); +} + +final syncStatusProvider = + NotifierProvider( + SyncStatusNotifier.new, +); diff --git a/mobile/lib/widgets/common/immich_sliver_app_bar.dart b/mobile/lib/widgets/common/immich_sliver_app_bar.dart index 5ab60c913c..b58a1ad6f9 100644 --- a/mobile/lib/widgets/common/immich_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/immich_sliver_app_bar.dart @@ -9,6 +9,7 @@ import 'package:immich_mobile/models/server_info/server_info.model.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; +import 'package:immich_mobile/providers/sync_status.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -59,13 +60,6 @@ class ImmichSliverAppBar extends ConsumerWidget { centerTitle: false, title: title ?? const _ImmichLogoWithText(), actions: [ - if (actions != null) - ...actions!.map( - (action) => Padding( - padding: const EdgeInsets.only(right: 16), - child: action, - ), - ), if (isCasting) Padding( padding: const EdgeInsets.only(right: 12), @@ -81,6 +75,14 @@ class ImmichSliverAppBar extends ConsumerWidget { ), ), ), + const _SyncStatusIndicator(), + if (actions != null) + ...actions!.map( + (action) => Padding( + padding: const EdgeInsets.only(right: 16), + child: action, + ), + ), if (showUploadButton) const Padding( padding: EdgeInsets.only(right: 20), @@ -273,3 +275,100 @@ class _BackupIndicator extends ConsumerWidget { return null; } } + +class _SyncStatusIndicator extends ConsumerStatefulWidget { + const _SyncStatusIndicator(); + + @override + ConsumerState<_SyncStatusIndicator> createState() => + _SyncStatusIndicatorState(); +} + +class _SyncStatusIndicatorState extends ConsumerState<_SyncStatusIndicator> + with TickerProviderStateMixin { + late AnimationController _rotationController; + late AnimationController _dismissalController; + late Animation _rotationAnimation; + late Animation _dismissalAnimation; + + @override + void initState() { + super.initState(); + _rotationController = AnimationController( + duration: const Duration(seconds: 2), + vsync: this, + ); + _dismissalController = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + _rotationAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(_rotationController); + _dismissalAnimation = Tween( + begin: 1.0, + end: 0.0, + ).animate( + CurvedAnimation( + parent: _dismissalController, + curve: Curves.easeOutQuart, + ), + ); + } + + @override + void dispose() { + _rotationController.dispose(); + _dismissalController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final syncStatus = ref.watch(syncStatusProvider); + final isSyncing = syncStatus.isRemoteSyncing; + + // Control animations based on sync status + if (isSyncing) { + if (!_rotationController.isAnimating) { + _rotationController.repeat(); + } + _dismissalController.reset(); + } else { + _rotationController.stop(); + if (_dismissalController.status == AnimationStatus.dismissed) { + _dismissalController.forward(); + } + } + + // Don't show anything if not syncing and dismissal animation is complete + if (!isSyncing && + _dismissalController.status == AnimationStatus.completed) { + return const SizedBox.shrink(); + } + + return AnimatedBuilder( + animation: Listenable.merge([_rotationAnimation, _dismissalAnimation]), + builder: (context, child) { + return Padding( + padding: EdgeInsets.only(right: isSyncing ? 16 : 0), + child: Transform.scale( + scale: isSyncing ? 1.0 : _dismissalAnimation.value, + child: Opacity( + opacity: isSyncing ? 1.0 : _dismissalAnimation.value, + child: Transform.rotate( + angle: _rotationAnimation.value * 2 * 3.14159, + child: Icon( + Icons.sync, + size: 24, + color: context.primaryColor, + ), + ), + ), + ), + ); + }, + ); + } +}