chore: add sync indicator and better album state management (#20004)

* album list rerendering

* sync indicator

* sync indicator

* fix: lint
This commit is contained in:
Alex 2025-07-18 08:39:28 -05:00 committed by GitHub
parent 137f0d48c0
commit 2e63b9d951
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 226 additions and 158 deletions

View File

@ -4,13 +4,24 @@ import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
import 'package:immich_mobile/utils/isolate.dart'; import 'package:immich_mobile/utils/isolate.dart';
import 'package:worker_manager/worker_manager.dart'; import 'package:worker_manager/worker_manager.dart';
typedef SyncCallback = void Function();
typedef SyncErrorCallback = void Function(String error);
class BackgroundSyncManager { class BackgroundSyncManager {
final SyncCallback? onRemoteSyncStart;
final SyncCallback? onRemoteSyncComplete;
final SyncErrorCallback? onRemoteSyncError;
Cancelable<void>? _syncTask; Cancelable<void>? _syncTask;
Cancelable<void>? _syncWebsocketTask; Cancelable<void>? _syncWebsocketTask;
Cancelable<void>? _deviceAlbumSyncTask; Cancelable<void>? _deviceAlbumSyncTask;
Cancelable<void>? _hashTask; Cancelable<void>? _hashTask;
BackgroundSyncManager(); BackgroundSyncManager({
this.onRemoteSyncStart,
this.onRemoteSyncComplete,
this.onRemoteSyncError,
});
Future<void> cancel() { Future<void> cancel() {
final futures = <Future>[]; final futures = <Future>[];
@ -72,10 +83,16 @@ class BackgroundSyncManager {
return _syncTask!.future; return _syncTask!.future;
} }
onRemoteSyncStart?.call();
_syncTask = runInIsolateGentle( _syncTask = runInIsolateGentle(
computation: (ref) => ref.read(syncStreamServiceProvider).sync(), computation: (ref) => ref.read(syncStreamServiceProvider).sync(),
); );
return _syncTask!.whenComplete(() { return _syncTask!.whenComplete(() {
onRemoteSyncComplete?.call();
_syncTask = null;
}).catchError((error) {
onRemoteSyncError?.call(error.toString());
_syncTask = null; _syncTask = null;
}); });
} }

View File

@ -35,42 +35,15 @@ class _TabShellPageState extends ConsumerState<TabShellPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isScreenLandscape = context.orientation == Orientation.landscape; 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<Color>(
context.primaryColor,
),
),
),
),
],
);
}
final navigationDestinations = [ final navigationDestinations = [
NavigationDestination( NavigationDestination(
label: 'photos'.tr(), label: 'photos'.tr(),
icon: const Icon( icon: const Icon(
Icons.photo_library_outlined, Icons.photo_library_outlined,
), ),
selectedIcon: buildIcon( selectedIcon: Icon(
isProcessing: false, Icons.photo_library,
icon: Icon( color: context.primaryColor,
Icons.photo_library,
color: context.primaryColor,
),
), ),
), ),
NavigationDestination( NavigationDestination(
@ -88,12 +61,9 @@ class _TabShellPageState extends ConsumerState<TabShellPage> {
icon: const Icon( icon: const Icon(
Icons.photo_album_outlined, Icons.photo_album_outlined,
), ),
selectedIcon: buildIcon( selectedIcon: Icon(
isProcessing: false, Icons.photo_album_rounded,
icon: Icon( color: context.primaryColor,
Icons.photo_album_rounded,
color: context.primaryColor,
),
), ),
), ),
NavigationDestination( NavigationDestination(
@ -101,12 +71,9 @@ class _TabShellPageState extends ConsumerState<TabShellPage> {
icon: const Icon( icon: const Icon(
Icons.space_dashboard_outlined, Icons.space_dashboard_outlined,
), ),
selectedIcon: buildIcon( selectedIcon: Icon(
isProcessing: false, Icons.space_dashboard_rounded,
icon: Icon( color: context.primaryColor,
Icons.space_dashboard_rounded,
color: context.primaryColor,
),
), ),
), ),
]; ];
@ -183,7 +150,7 @@ void _onNavigationSelected(TabsRouter router, int index, WidgetRef ref) {
// Album page // Album page
if (index == 2) { if (index == 2) {
ref.read(remoteAlbumProvider.notifier).getAll(); ref.read(remoteAlbumProvider.notifier).refresh();
} }
ref.read(hapticFeedbackProvider.notifier).selectionClick(); ref.read(hapticFeedbackProvider.notifier).selectionClick();

View File

@ -40,7 +40,7 @@ class _DriftAlbumsPageState extends ConsumerState<DriftAlbumsPage> {
// Load albums when component mounts // Load albums when component mounts
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(remoteAlbumProvider.notifier).getAll(); ref.read(remoteAlbumProvider.notifier).refresh();
}); });
searchController.addListener(() { searchController.addListener(() {
@ -88,10 +88,9 @@ class _DriftAlbumsPageState extends ConsumerState<DriftAlbumsPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final albumState = ref.watch(remoteAlbumProvider); final albums =
final albums = albumState.filteredAlbums; ref.watch(remoteAlbumProvider.select((s) => s.filteredAlbums));
final isLoading = albumState.isLoading;
final error = albumState.error;
final userId = ref.watch(currentUserProvider)?.id; final userId = ref.watch(currentUserProvider)?.id;
return RefreshIndicator( return RefreshIndicator(
@ -133,14 +132,10 @@ class _DriftAlbumsPageState extends ConsumerState<DriftAlbumsPage> {
? _AlbumGrid( ? _AlbumGrid(
albums: albums, albums: albums,
userId: userId, userId: userId,
isLoading: isLoading,
error: error,
) )
: _AlbumList( : _AlbumList(
albums: albums, albums: albums,
userId: userId, userId: userId,
isLoading: isLoading,
error: error,
), ),
], ],
), ),
@ -481,46 +476,15 @@ class _QuickSortAndViewMode extends StatelessWidget {
class _AlbumList extends ConsumerWidget { class _AlbumList extends ConsumerWidget {
const _AlbumList({ const _AlbumList({
required this.isLoading,
required this.error,
required this.albums, required this.albums,
required this.userId, required this.userId,
}); });
final bool isLoading;
final String? error;
final List<RemoteAlbum> albums; final List<RemoteAlbum> albums;
final String? userId; final String? userId;
@override @override
Widget build(BuildContext context, WidgetRef ref) { 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) { if (albums.isEmpty) {
return const SliverToBoxAdapter( return const SliverToBoxAdapter(
child: Center( child: Center(
@ -623,44 +587,13 @@ class _AlbumGrid extends StatelessWidget {
const _AlbumGrid({ const _AlbumGrid({
required this.albums, required this.albums,
required this.userId, required this.userId,
required this.isLoading,
required this.error,
}); });
final List<RemoteAlbum> albums; final List<RemoteAlbum> albums;
final String? userId; final String? userId;
final bool isLoading;
final String? error;
@override @override
Widget build(BuildContext context) { 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) { if (albums.isEmpty) {
return const SliverToBoxAdapter( return const SliverToBoxAdapter(
child: Center( child: Center(

View File

@ -1,8 +1,14 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/utils/background_sync.dart'; import 'package:immich_mobile/domain/utils/background_sync.dart';
import 'package:immich_mobile/providers/sync_status.provider.dart';
final backgroundSyncProvider = Provider<BackgroundSyncManager>((ref) { final backgroundSyncProvider = Provider<BackgroundSyncManager>((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); ref.onDispose(manager.cancel);
return manager; return manager;
}); });

View File

@ -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/domain/services/remote_album.service.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/utils/remote_album.utils.dart'; import 'package:immich_mobile/utils/remote_album.utils.dart';
import 'package:logging/logging.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'album.provider.dart'; import 'album.provider.dart';
@ -13,33 +14,25 @@ import 'album.provider.dart';
class RemoteAlbumState { class RemoteAlbumState {
final List<RemoteAlbum> albums; final List<RemoteAlbum> albums;
final List<RemoteAlbum> filteredAlbums; final List<RemoteAlbum> filteredAlbums;
final bool isLoading;
final String? error;
const RemoteAlbumState({ const RemoteAlbumState({
required this.albums, required this.albums,
List<RemoteAlbum>? filteredAlbums, List<RemoteAlbum>? filteredAlbums,
this.isLoading = false,
this.error,
}) : filteredAlbums = filteredAlbums ?? albums; }) : filteredAlbums = filteredAlbums ?? albums;
RemoteAlbumState copyWith({ RemoteAlbumState copyWith({
List<RemoteAlbum>? albums, List<RemoteAlbum>? albums,
List<RemoteAlbum>? filteredAlbums, List<RemoteAlbum>? filteredAlbums,
bool? isLoading,
String? error,
}) { }) {
return RemoteAlbumState( return RemoteAlbumState(
albums: albums ?? this.albums, albums: albums ?? this.albums,
filteredAlbums: filteredAlbums ?? this.filteredAlbums, filteredAlbums: filteredAlbums ?? this.filteredAlbums,
isLoading: isLoading ?? this.isLoading,
error: error ?? this.error,
); );
} }
@override @override
String toString() => String toString() =>
'RemoteAlbumState(albums: ${albums.length}, filteredAlbums: ${filteredAlbums.length}, isLoading: $isLoading, error: $error)'; 'RemoteAlbumState(albums: ${albums.length}, filteredAlbums: ${filteredAlbums.length})';
@override @override
bool operator ==(covariant RemoteAlbumState other) { bool operator ==(covariant RemoteAlbumState other) {
@ -47,47 +40,38 @@ class RemoteAlbumState {
final listEquals = const DeepCollectionEquality().equals; final listEquals = const DeepCollectionEquality().equals;
return listEquals(other.albums, albums) && return listEquals(other.albums, albums) &&
listEquals(other.filteredAlbums, filteredAlbums) && listEquals(other.filteredAlbums, filteredAlbums);
other.isLoading == isLoading &&
other.error == error;
} }
@override @override
int get hashCode => int get hashCode => albums.hashCode ^ filteredAlbums.hashCode;
albums.hashCode ^
filteredAlbums.hashCode ^
isLoading.hashCode ^
error.hashCode;
} }
class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> { class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
late RemoteAlbumService _remoteAlbumService; late RemoteAlbumService _remoteAlbumService;
final _logger = Logger('RemoteAlbumNotifier');
@override @override
RemoteAlbumState build() { RemoteAlbumState build() {
_remoteAlbumService = ref.read(remoteAlbumServiceProvider); _remoteAlbumService = ref.read(remoteAlbumServiceProvider);
return const RemoteAlbumState(albums: [], filteredAlbums: []); return const RemoteAlbumState(albums: [], filteredAlbums: []);
} }
Future<List<RemoteAlbum>> getAll() async { Future<List<RemoteAlbum>> _getAll() async {
state = state.copyWith(isLoading: true, error: null);
try { try {
final albums = await _remoteAlbumService.getAll(); final albums = await _remoteAlbumService.getAll();
state = state.copyWith( state = state.copyWith(
albums: albums, albums: albums,
filteredAlbums: albums, filteredAlbums: albums,
isLoading: false,
); );
return albums; return albums;
} catch (e) { } catch (error, stack) {
state = state.copyWith(isLoading: false, error: e.toString()); _logger.severe('Failed to fetch albums', error, stack);
rethrow; rethrow;
} }
} }
Future<void> refresh() async { Future<void> refresh() async {
await getAll(); await _getAll();
} }
void searchAlbums( void searchAlbums(
@ -127,8 +111,6 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
String? description, String? description,
List<String> assetIds = const [], List<String> assetIds = const [],
}) async { }) async {
state = state.copyWith(isLoading: true, error: null);
try { try {
final album = await _remoteAlbumService.createAlbum( final album = await _remoteAlbumService.createAlbum(
title: title, title: title,
@ -141,10 +123,9 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
filteredAlbums: [...state.filteredAlbums, album], filteredAlbums: [...state.filteredAlbums, album],
); );
state = state.copyWith(isLoading: false);
return album; return album;
} catch (e) { } catch (error, stack) {
state = state.copyWith(isLoading: false, error: e.toString()); _logger.severe('Failed to create album', error, stack);
rethrow; rethrow;
} }
} }
@ -157,8 +138,6 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
bool? isActivityEnabled, bool? isActivityEnabled,
AlbumAssetOrder? order, AlbumAssetOrder? order,
}) async { }) async {
state = state.copyWith(isLoading: true, error: null);
try { try {
final updatedAlbum = await _remoteAlbumService.updateAlbum( final updatedAlbum = await _remoteAlbumService.updateAlbum(
albumId, albumId,
@ -180,12 +159,11 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
state = state.copyWith( state = state.copyWith(
albums: updatedAlbums, albums: updatedAlbums,
filteredAlbums: updatedFilteredAlbums, filteredAlbums: updatedFilteredAlbums,
isLoading: false,
); );
return updatedAlbum; return updatedAlbum;
} catch (e) { } catch (error, stack) {
state = state.copyWith(isLoading: false, error: e.toString()); _logger.severe('Failed to update album', error, stack);
rethrow; rethrow;
} }
} }

View File

@ -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<SyncStatusState> {
@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, SyncStatusState>(
SyncStatusNotifier.new,
);

View File

@ -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/backup/backup.provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/server_info.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/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
@ -59,13 +60,6 @@ class ImmichSliverAppBar extends ConsumerWidget {
centerTitle: false, centerTitle: false,
title: title ?? const _ImmichLogoWithText(), title: title ?? const _ImmichLogoWithText(),
actions: [ actions: [
if (actions != null)
...actions!.map(
(action) => Padding(
padding: const EdgeInsets.only(right: 16),
child: action,
),
),
if (isCasting) if (isCasting)
Padding( Padding(
padding: const EdgeInsets.only(right: 12), 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) if (showUploadButton)
const Padding( const Padding(
padding: EdgeInsets.only(right: 20), padding: EdgeInsets.only(right: 20),
@ -273,3 +275,100 @@ class _BackupIndicator extends ConsumerWidget {
return null; 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<double> _rotationAnimation;
late Animation<double> _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<double>(
begin: 0.0,
end: 1.0,
).animate(_rotationController);
_dismissalAnimation = Tween<double>(
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,
),
),
),
),
);
},
);
}
}