mirror of
https://github.com/immich-app/immich.git
synced 2025-07-31 15:08:44 -04:00
chore: add sync indicator and better album state management (#20004)
* album list rerendering * sync indicator * sync indicator * fix: lint
This commit is contained in:
parent
137f0d48c0
commit
2e63b9d951
@ -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<void>? _syncTask;
|
||||
Cancelable<void>? _syncWebsocketTask;
|
||||
Cancelable<void>? _deviceAlbumSyncTask;
|
||||
Cancelable<void>? _hashTask;
|
||||
|
||||
BackgroundSyncManager();
|
||||
BackgroundSyncManager({
|
||||
this.onRemoteSyncStart,
|
||||
this.onRemoteSyncComplete,
|
||||
this.onRemoteSyncError,
|
||||
});
|
||||
|
||||
Future<void> cancel() {
|
||||
final futures = <Future>[];
|
||||
@ -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;
|
||||
});
|
||||
}
|
||||
|
@ -35,44 +35,17 @@ class _TabShellPageState extends ConsumerState<TabShellPage> {
|
||||
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<Color>(
|
||||
context.primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final navigationDestinations = [
|
||||
NavigationDestination(
|
||||
label: 'photos'.tr(),
|
||||
icon: const Icon(
|
||||
Icons.photo_library_outlined,
|
||||
),
|
||||
selectedIcon: buildIcon(
|
||||
isProcessing: false,
|
||||
icon: Icon(
|
||||
selectedIcon: Icon(
|
||||
Icons.photo_library,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
NavigationDestination(
|
||||
label: 'search'.tr(),
|
||||
icon: const Icon(
|
||||
@ -88,27 +61,21 @@ class _TabShellPageState extends ConsumerState<TabShellPage> {
|
||||
icon: const Icon(
|
||||
Icons.photo_album_outlined,
|
||||
),
|
||||
selectedIcon: buildIcon(
|
||||
isProcessing: false,
|
||||
icon: Icon(
|
||||
selectedIcon: Icon(
|
||||
Icons.photo_album_rounded,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
NavigationDestination(
|
||||
label: 'library'.tr(),
|
||||
icon: const Icon(
|
||||
Icons.space_dashboard_outlined,
|
||||
),
|
||||
selectedIcon: buildIcon(
|
||||
isProcessing: false,
|
||||
icon: Icon(
|
||||
selectedIcon: Icon(
|
||||
Icons.space_dashboard_rounded,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
Widget navigationRail(TabsRouter tabsRouter) {
|
||||
@ -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();
|
||||
|
@ -40,7 +40,7 @@ class _DriftAlbumsPageState extends ConsumerState<DriftAlbumsPage> {
|
||||
|
||||
// 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<DriftAlbumsPage> {
|
||||
|
||||
@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<DriftAlbumsPage> {
|
||||
? _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<RemoteAlbum> 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<RemoteAlbum> 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(
|
||||
|
@ -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<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);
|
||||
return manager;
|
||||
});
|
||||
|
@ -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<RemoteAlbum> albums;
|
||||
final List<RemoteAlbum> filteredAlbums;
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
|
||||
const RemoteAlbumState({
|
||||
required this.albums,
|
||||
List<RemoteAlbum>? filteredAlbums,
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
}) : filteredAlbums = filteredAlbums ?? albums;
|
||||
|
||||
RemoteAlbumState copyWith({
|
||||
List<RemoteAlbum>? albums,
|
||||
List<RemoteAlbum>? 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<RemoteAlbumState> {
|
||||
late RemoteAlbumService _remoteAlbumService;
|
||||
|
||||
final _logger = Logger('RemoteAlbumNotifier');
|
||||
@override
|
||||
RemoteAlbumState build() {
|
||||
_remoteAlbumService = ref.read(remoteAlbumServiceProvider);
|
||||
return const RemoteAlbumState(albums: [], filteredAlbums: []);
|
||||
}
|
||||
|
||||
Future<List<RemoteAlbum>> getAll() async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
Future<List<RemoteAlbum>> _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<void> refresh() async {
|
||||
await getAll();
|
||||
await _getAll();
|
||||
}
|
||||
|
||||
void searchAlbums(
|
||||
@ -127,8 +111,6 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
|
||||
String? description,
|
||||
List<String> 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<RemoteAlbumState> {
|
||||
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<RemoteAlbumState> {
|
||||
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<RemoteAlbumState> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
68
mobile/lib/providers/sync_status.provider.dart
Normal file
68
mobile/lib/providers/sync_status.provider.dart
Normal 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,
|
||||
);
|
@ -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<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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user