From 96568f720a30a93af00b17c016fbc30babb98d4b Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:19:47 +0700 Subject: [PATCH] refactor(library): move queue download labels, dialogs and sliver grid out of queue_tab.dart Pure moves: download label helpers and the download dialogs become parts of the queue_tab library, and the animated sliver grid becomes a public widget in lib/widgets since it uses no private state. --- lib/screens/queue_tab.dart | 357 +----------------- lib/screens/queue_tab_download_dialogs.dart | 176 +++++++++ lib/screens/queue_tab_download_labels.dart | 105 ++++++ lib/screens/queue_tab_filter_widgets.dart | 8 +- lib/widgets/animated_library_sliver_grid.dart | 79 ++++ 5 files changed, 367 insertions(+), 358 deletions(-) create mode 100644 lib/screens/queue_tab_download_dialogs.dart create mode 100644 lib/screens/queue_tab_download_labels.dart create mode 100644 lib/widgets/animated_library_sliver_grid.dart diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 0a7d573d..5f132c12 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -84,6 +84,7 @@ import 'package:spotiflac_android/screens/queue_library_refresh_policy.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; +import 'package:spotiflac_android/widgets/animated_library_sliver_grid.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; import 'package:spotiflac_android/widgets/smoothed_progress.dart'; @@ -99,110 +100,8 @@ part 'queue_tab_filter_widgets.dart'; part 'queue_tab_sections.dart'; part 'queue_tab_batch_actions.dart'; part 'queue_tab_item_widgets.dart'; - -String _formatDownloadSizeMB(num bytes) => '${formatMegabytes(bytes)} MB'; - -String _formatDownloadProgressLabel( - BuildContext context, - DownloadItem item, { - double? visualProgress, -}) { - final progress = (visualProgress ?? item.progress).clamp(0.0, 1.0); - final speedSuffix = item.speedMBps > 0 - ? ' • ${item.speedMBps.toStringAsFixed(1)} MB/s' - : ''; - - if (item.bytesTotal > 0) { - final received = visualProgress != null - ? item.bytesTotal * progress - : item.bytesReceived > 0 - ? item.bytesReceived - : item.bytesTotal * progress; - final percent = (progress * 100).toStringAsFixed(0); - return '${_formatDownloadSizeMB(received)} / ${_formatDownloadSizeMB(item.bytesTotal)} • $percent%$speedSuffix'; - } - - if (item.bytesReceived > 0) { - final canEstimateTotal = progress > 0.01 && progress < 0.995; - if (canEstimateTotal) { - final estimatedTotal = item.bytesReceived / progress; - if (estimatedTotal > item.bytesReceived) { - return '${_formatDownloadSizeMB(item.bytesReceived)} / ~${_formatDownloadSizeMB(estimatedTotal)}$speedSuffix'; - } - } - return '${_formatDownloadSizeMB(item.bytesReceived)}$speedSuffix'; - } - - if (progress > 0) { - final percent = (progress * 100).toStringAsFixed(0); - return '$percent%$speedSuffix'; - } - - if (item.speedMBps > 0) { - return context.l10n.queueDownloadSpeedStatus( - item.speedMBps.toStringAsFixed(1), - ); - } - - if (item.error == 'Waiting for verification') { - return context.l10n.queueWaitingForVerification; - } - switch (item.preparationStage) { - case 'checking_session': - return context.l10n.queueCheckingDownloadSession; - case 'resolving_metadata': - return context.l10n.queueResolvingDownloadMetadata; - case 'resolving_stream': - return context.l10n.queueResolvingDownloadStream; - } - if (item.error == 'Retrying after verification') { - return context.l10n.queueResumingAfterVerification; - } - - return context.l10n.queueDownloadStarting; -} - -String _formatDownloadStatusLine( - BuildContext context, - DownloadItem item, { - double? visualProgress, -}) { - final base = _formatDownloadProgressLabel( - context, - item, - visualProgress: visualProgress, - ); - final eta = _formatDownloadEta(item, visualProgress: visualProgress); - return eta == null ? base : '$base • $eta'; -} - -String? _formatDownloadEta(DownloadItem item, {double? visualProgress}) { - if (item.speedMBps <= 0 || item.bytesTotal <= 0) return null; - final progress = (visualProgress ?? item.progress).clamp(0.0, 1.0); - final received = visualProgress != null - ? (item.bytesTotal * progress).round() - : item.bytesReceived > 0 - ? item.bytesReceived - : (item.bytesTotal * progress).round(); - final remaining = item.bytesTotal - received; - if (remaining <= 0) return null; - final seconds = remaining / (item.speedMBps * 1024 * 1024); - if (!seconds.isFinite || seconds > 3600) return null; - if (seconds < 60) return '~${seconds.round()}s'; - final minutes = (seconds / 60).floor(); - final secs = (seconds % 60).round(); - return '~${minutes}m${secs.toString().padLeft(2, '0')}s'; -} - -bool _shouldAnimateDownloadProgress(BuildContext context, DownloadItem item) { - final progress = item.progress.clamp(0.0, 1.0); - final animationsDisabled = - MediaQuery.maybeDisableAnimationsOf(context) ?? false; - return item.status == DownloadStatus.downloading && - progress > 0 && - progress < 1 && - !animationsDisabled; -} +part 'queue_tab_download_labels.dart'; +part 'queue_tab_download_dialogs.dart'; DownloadHistoryItem? _historyItemForCompletionBridge( DownloadItem item, @@ -1848,254 +1747,4 @@ class _QueueTabState extends ConsumerState { }, ); } - - Future _confirmCancelDownload( - BuildContext context, - DownloadItem item, - ) async { - final confirmed = await showAppDialog( - context: context, - builder: (ctx) => AppAlertDialog( - title: Text(context.l10n.cancelDownloadTitle), - content: Text(context.l10n.cancelDownloadContent(item.track.name)), - actions: [ - AppDialogAction( - onPressed: () => Navigator.of(ctx).pop(false), - child: Text(context.l10n.cancelDownloadKeep), - ), - AppDialogAction( - isDestructive: true, - onPressed: () => Navigator.of(ctx).pop(true), - child: Text(context.l10n.dialogCancel), - ), - ], - ), - ); - if (confirmed == true) { - // cancelItem (not dismissItem): the item stays in the queue as - // cancelled so it can be retried without re-searching the track. - ref.read(downloadQueueProvider.notifier).cancelItem(item.id); - } - } - - Future _showDownloadErrorDialog( - BuildContext context, - DownloadItem item, - ) async { - final colorScheme = Theme.of(context).colorScheme; - final isRateLimit = item.errorType == DownloadErrorType.rateLimit; - final isCancelled = item.status == DownloadStatus.skipped; - final isFolderAccessLost = - item.errorMessage == safPermissionLostErrorMessage || - item.errorMessage == downloadFolderAccessLostErrorMessage; - final title = isCancelled - ? context.l10n.queueCancelledTitle - : isRateLimit - ? context.l10n.queueRateLimitTitle - : context.l10n.updateDownloadFailed; - final message = isCancelled - ? context.l10n.queueCancelledMessage - : isRateLimit - ? context.l10n.queueRateLimitMessage - : (item.errorMessage.trim().isNotEmpty - ? _localizedDownloadError(context, item.errorMessage) - : context.l10n.updateDownloadFailed); - final action = await showAppDialog( - context: context, - builder: (ctx) => AppAlertDialog( - title: Text(title), - content: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - item.track.name, - style: Theme.of( - ctx, - ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), - ), - const SizedBox(height: 10), - SelectableText( - message, - style: Theme.of(ctx).textTheme.bodyMedium, - ), - ], - ), - ), - actions: [ - AppDialogAction( - isDestructive: true, - onPressed: () => Navigator.of(ctx).pop('remove'), - style: TextButton.styleFrom(foregroundColor: colorScheme.error), - child: Text(context.l10n.dialogRemove), - ), - AppDialogAction( - onPressed: () => Navigator.of(ctx).pop(), - child: Text(context.l10n.dialogCancel), - ), - if (isFolderAccessLost) - AppDialogAction( - filled: true, - onPressed: () => Navigator.of(ctx).pop('reselect'), - child: Text(context.l10n.downloadFolderReselect), - ) - else - AppDialogAction( - filled: true, - onPressed: () => Navigator.of(ctx).pop('retry'), - child: Text(context.l10n.dialogRetry), - ), - ], - ), - ); - if (!mounted) return; - if (action == 'retry') { - ref.read(downloadQueueProvider.notifier).retryItem(item.id); - } else if (action == 'remove') { - ref.read(downloadQueueProvider.notifier).removeItem(item.id); - } else if (action == 'reselect') { - final reselected = await _reselectDownloadFolder(); - if (reselected && mounted) { - ref.read(downloadQueueProvider.notifier).retryItem(item.id); - } - } - } - - /// Reopen the platform folder picker to restore download folder access, - /// then persist the new location. Returns true when a folder was saved. - Future _reselectDownloadFolder() async { - if (Platform.isAndroid) { - final result = await PlatformBridge.pickSafTree(); - if (result == null) return false; - final treeUri = result['tree_uri'] as String? ?? ''; - final displayName = result['display_name'] as String? ?? ''; - if (treeUri.isEmpty) return false; - final notifier = ref.read(settingsProvider.notifier); - notifier.setStorageMode('saf'); - notifier.setDownloadTreeUri( - treeUri, - displayName: displayName.isNotEmpty ? displayName : treeUri, - ); - return true; - } - if (Platform.isIOS) { - IosPickedDirectory? picked; - try { - picked = await PlatformBridge.pickIosDirectory(); - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.snackbarFolderPickerFailed( - context.friendlyError(e), - ), - ), - ), - ); - } - return false; - } - if (picked == null || !mounted) return false; - final validation = validateIosPath( - picked.path, - bookmark: picked.bookmark, - ); - if (!validation.isValid) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - validation.errorReason ?? context.l10n.setupIcloudNotSupported, - ), - ), - ); - } - return false; - } - ref - .read(settingsProvider.notifier) - .setDownloadDirectory(picked.path, iosBookmark: picked.bookmark); - return true; - } - return false; - } -} - -class _AnimatedLibrarySliverGrid extends StatefulWidget { - final double maxCrossAxisExtent; - final double mainAxisSpacing; - final double crossAxisSpacing; - final double childAspectRatio; - final SliverChildDelegate delegate; - - const _AnimatedLibrarySliverGrid({ - required this.maxCrossAxisExtent, - required this.mainAxisSpacing, - required this.crossAxisSpacing, - required this.childAspectRatio, - required this.delegate, - }); - - @override - State<_AnimatedLibrarySliverGrid> createState() => - _AnimatedLibrarySliverGridState(); -} - -class _AnimatedLibrarySliverGridState extends State<_AnimatedLibrarySliverGrid> - with SingleTickerProviderStateMixin { - late final AnimationController _controller; - late final CurvedAnimation _curve; - late double _beginExtent; - late double _endExtent; - - @override - void initState() { - super.initState(); - _beginExtent = widget.maxCrossAxisExtent; - _endExtent = widget.maxCrossAxisExtent; - _controller = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 130), - )..value = 1; - _curve = CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic); - } - - @override - void didUpdateWidget(covariant _AnimatedLibrarySliverGrid oldWidget) { - super.didUpdateWidget(oldWidget); - if ((widget.maxCrossAxisExtent - _endExtent).abs() < 0.1) return; - _beginExtent = _currentExtent; - _endExtent = widget.maxCrossAxisExtent; - _controller.forward(from: 0); - } - - double get _currentExtent => - _beginExtent + ((_endExtent - _beginExtent) * _curve.value); - - @override - void dispose() { - _curve.dispose(); - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: _controller, - builder: (context, child) { - return SliverGrid( - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: _currentExtent, - mainAxisSpacing: widget.mainAxisSpacing, - crossAxisSpacing: widget.crossAxisSpacing, - childAspectRatio: widget.childAspectRatio, - ), - delegate: widget.delegate, - ); - }, - ); - } } diff --git a/lib/screens/queue_tab_download_dialogs.dart b/lib/screens/queue_tab_download_dialogs.dart new file mode 100644 index 00000000..c6fad9d1 --- /dev/null +++ b/lib/screens/queue_tab_download_dialogs.dart @@ -0,0 +1,176 @@ +part of 'queue_tab.dart'; + +extension _QueueTabDownloadDialogs on _QueueTabState { + Future _confirmCancelDownload( + BuildContext context, + DownloadItem item, + ) async { + final confirmed = await showAppDialog( + context: context, + builder: (ctx) => AppAlertDialog( + title: Text(context.l10n.cancelDownloadTitle), + content: Text(context.l10n.cancelDownloadContent(item.track.name)), + actions: [ + AppDialogAction( + onPressed: () => Navigator.of(ctx).pop(false), + child: Text(context.l10n.cancelDownloadKeep), + ), + AppDialogAction( + isDestructive: true, + onPressed: () => Navigator.of(ctx).pop(true), + child: Text(context.l10n.dialogCancel), + ), + ], + ), + ); + if (confirmed == true) { + // cancelItem (not dismissItem): the item stays in the queue as + // cancelled so it can be retried without re-searching the track. + ref.read(downloadQueueProvider.notifier).cancelItem(item.id); + } + } + + Future _showDownloadErrorDialog( + BuildContext context, + DownloadItem item, + ) async { + final colorScheme = Theme.of(context).colorScheme; + final isRateLimit = item.errorType == DownloadErrorType.rateLimit; + final isCancelled = item.status == DownloadStatus.skipped; + final isFolderAccessLost = + item.errorMessage == safPermissionLostErrorMessage || + item.errorMessage == downloadFolderAccessLostErrorMessage; + final title = isCancelled + ? context.l10n.queueCancelledTitle + : isRateLimit + ? context.l10n.queueRateLimitTitle + : context.l10n.updateDownloadFailed; + final message = isCancelled + ? context.l10n.queueCancelledMessage + : isRateLimit + ? context.l10n.queueRateLimitMessage + : (item.errorMessage.trim().isNotEmpty + ? _localizedDownloadError(context, item.errorMessage) + : context.l10n.updateDownloadFailed); + final action = await showAppDialog( + context: context, + builder: (ctx) => AppAlertDialog( + title: Text(title), + content: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + item.track.name, + style: Theme.of( + ctx, + ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 10), + SelectableText( + message, + style: Theme.of(ctx).textTheme.bodyMedium, + ), + ], + ), + ), + actions: [ + AppDialogAction( + isDestructive: true, + onPressed: () => Navigator.of(ctx).pop('remove'), + style: TextButton.styleFrom(foregroundColor: colorScheme.error), + child: Text(context.l10n.dialogRemove), + ), + AppDialogAction( + onPressed: () => Navigator.of(ctx).pop(), + child: Text(context.l10n.dialogCancel), + ), + if (isFolderAccessLost) + AppDialogAction( + filled: true, + onPressed: () => Navigator.of(ctx).pop('reselect'), + child: Text(context.l10n.downloadFolderReselect), + ) + else + AppDialogAction( + filled: true, + onPressed: () => Navigator.of(ctx).pop('retry'), + child: Text(context.l10n.dialogRetry), + ), + ], + ), + ); + if (!mounted) return; + if (action == 'retry') { + ref.read(downloadQueueProvider.notifier).retryItem(item.id); + } else if (action == 'remove') { + ref.read(downloadQueueProvider.notifier).removeItem(item.id); + } else if (action == 'reselect') { + final reselected = await _reselectDownloadFolder(); + if (reselected && mounted) { + ref.read(downloadQueueProvider.notifier).retryItem(item.id); + } + } + } + + /// Reopen the platform folder picker to restore download folder access, + /// then persist the new location. Returns true when a folder was saved. + Future _reselectDownloadFolder() async { + if (Platform.isAndroid) { + final result = await PlatformBridge.pickSafTree(); + if (result == null) return false; + final treeUri = result['tree_uri'] as String? ?? ''; + final displayName = result['display_name'] as String? ?? ''; + if (treeUri.isEmpty) return false; + final notifier = ref.read(settingsProvider.notifier); + notifier.setStorageMode('saf'); + notifier.setDownloadTreeUri( + treeUri, + displayName: displayName.isNotEmpty ? displayName : treeUri, + ); + return true; + } + if (Platform.isIOS) { + IosPickedDirectory? picked; + try { + picked = await PlatformBridge.pickIosDirectory(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.snackbarFolderPickerFailed( + context.friendlyError(e), + ), + ), + ), + ); + } + return false; + } + if (picked == null || !mounted) return false; + final validation = validateIosPath( + picked.path, + bookmark: picked.bookmark, + ); + if (!validation.isValid) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + validation.errorReason ?? context.l10n.setupIcloudNotSupported, + ), + ), + ); + } + return false; + } + ref + .read(settingsProvider.notifier) + .setDownloadDirectory(picked.path, iosBookmark: picked.bookmark); + return true; + } + return false; + } +} diff --git a/lib/screens/queue_tab_download_labels.dart b/lib/screens/queue_tab_download_labels.dart new file mode 100644 index 00000000..88daf2a2 --- /dev/null +++ b/lib/screens/queue_tab_download_labels.dart @@ -0,0 +1,105 @@ +part of 'queue_tab.dart'; + +String _formatDownloadSizeMB(num bytes) => '${formatMegabytes(bytes)} MB'; + +String _formatDownloadProgressLabel( + BuildContext context, + DownloadItem item, { + double? visualProgress, +}) { + final progress = (visualProgress ?? item.progress).clamp(0.0, 1.0); + final speedSuffix = item.speedMBps > 0 + ? ' • ${item.speedMBps.toStringAsFixed(1)} MB/s' + : ''; + + if (item.bytesTotal > 0) { + final received = visualProgress != null + ? item.bytesTotal * progress + : item.bytesReceived > 0 + ? item.bytesReceived + : item.bytesTotal * progress; + final percent = (progress * 100).toStringAsFixed(0); + return '${_formatDownloadSizeMB(received)} / ${_formatDownloadSizeMB(item.bytesTotal)} • $percent%$speedSuffix'; + } + + if (item.bytesReceived > 0) { + final canEstimateTotal = progress > 0.01 && progress < 0.995; + if (canEstimateTotal) { + final estimatedTotal = item.bytesReceived / progress; + if (estimatedTotal > item.bytesReceived) { + return '${_formatDownloadSizeMB(item.bytesReceived)} / ~${_formatDownloadSizeMB(estimatedTotal)}$speedSuffix'; + } + } + return '${_formatDownloadSizeMB(item.bytesReceived)}$speedSuffix'; + } + + if (progress > 0) { + final percent = (progress * 100).toStringAsFixed(0); + return '$percent%$speedSuffix'; + } + + if (item.speedMBps > 0) { + return context.l10n.queueDownloadSpeedStatus( + item.speedMBps.toStringAsFixed(1), + ); + } + + if (item.error == 'Waiting for verification') { + return context.l10n.queueWaitingForVerification; + } + switch (item.preparationStage) { + case 'checking_session': + return context.l10n.queueCheckingDownloadSession; + case 'resolving_metadata': + return context.l10n.queueResolvingDownloadMetadata; + case 'resolving_stream': + return context.l10n.queueResolvingDownloadStream; + } + if (item.error == 'Retrying after verification') { + return context.l10n.queueResumingAfterVerification; + } + + return context.l10n.queueDownloadStarting; +} + +String _formatDownloadStatusLine( + BuildContext context, + DownloadItem item, { + double? visualProgress, +}) { + final base = _formatDownloadProgressLabel( + context, + item, + visualProgress: visualProgress, + ); + final eta = _formatDownloadEta(item, visualProgress: visualProgress); + return eta == null ? base : '$base • $eta'; +} + +String? _formatDownloadEta(DownloadItem item, {double? visualProgress}) { + if (item.speedMBps <= 0 || item.bytesTotal <= 0) return null; + final progress = (visualProgress ?? item.progress).clamp(0.0, 1.0); + final received = visualProgress != null + ? (item.bytesTotal * progress).round() + : item.bytesReceived > 0 + ? item.bytesReceived + : (item.bytesTotal * progress).round(); + final remaining = item.bytesTotal - received; + if (remaining <= 0) return null; + final seconds = remaining / (item.speedMBps * 1024 * 1024); + if (!seconds.isFinite || seconds > 3600) return null; + if (seconds < 60) return '~${seconds.round()}s'; + final minutes = (seconds / 60).floor(); + final secs = (seconds % 60).round(); + return '~${minutes}m${secs.toString().padLeft(2, '0')}s'; +} + +bool _shouldAnimateDownloadProgress(BuildContext context, DownloadItem item) { + final progress = item.progress.clamp(0.0, 1.0); + final animationsDisabled = + MediaQuery.maybeDisableAnimationsOf(context) ?? false; + return item.status == DownloadStatus.downloading && + progress > 0 && + progress < 1 && + !animationsDisabled; +} diff --git a/lib/screens/queue_tab_filter_widgets.dart b/lib/screens/queue_tab_filter_widgets.dart index b9c4ad31..fd5a2990 100644 --- a/lib/screens/queue_tab_filter_widgets.dart +++ b/lib/screens/queue_tab_filter_widgets.dart @@ -466,7 +466,7 @@ extension _QueueTabFilterWidgets on _QueueTabState { filteredGroupedLocalAlbums.isNotEmpty)) SliverPadding( padding: EdgeInsets.symmetric(horizontal: edge), - sliver: _AnimatedLibrarySliverGrid( + sliver: AnimatedLibrarySliverGrid( maxCrossAxisExtent: _libraryAlbumGridExtent, mainAxisSpacing: 12, crossAxisSpacing: 12, @@ -521,7 +521,7 @@ extension _QueueTabFilterWidgets on _QueueTabState { ), SliverPadding( padding: EdgeInsets.symmetric(horizontal: edge), - sliver: _AnimatedLibrarySliverGrid( + sliver: AnimatedLibrarySliverGrid( maxCrossAxisExtent: _libraryAlbumGridExtent, mainAxisSpacing: 12, crossAxisSpacing: 12, @@ -562,7 +562,7 @@ extension _QueueTabFilterWidgets on _QueueTabState { if (historyViewMode == 'grid') SliverPadding( padding: EdgeInsets.symmetric(horizontal: edge), - sliver: _AnimatedLibrarySliverGrid( + sliver: AnimatedLibrarySliverGrid( maxCrossAxisExtent: _libraryGridExtent, mainAxisSpacing: 8, crossAxisSpacing: 8, @@ -746,7 +746,7 @@ extension _QueueTabFilterWidgets on _QueueTabState { historyViewMode == 'grid' ? SliverPadding( padding: EdgeInsets.symmetric(horizontal: edge), - sliver: _AnimatedLibrarySliverGrid( + sliver: AnimatedLibrarySliverGrid( maxCrossAxisExtent: _libraryGridExtent, mainAxisSpacing: 8, crossAxisSpacing: 8, diff --git a/lib/widgets/animated_library_sliver_grid.dart b/lib/widgets/animated_library_sliver_grid.dart new file mode 100644 index 00000000..037dbcab --- /dev/null +++ b/lib/widgets/animated_library_sliver_grid.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; + +class AnimatedLibrarySliverGrid extends StatefulWidget { + final double maxCrossAxisExtent; + final double mainAxisSpacing; + final double crossAxisSpacing; + final double childAspectRatio; + final SliverChildDelegate delegate; + + const AnimatedLibrarySliverGrid({ + super.key, + required this.maxCrossAxisExtent, + required this.mainAxisSpacing, + required this.crossAxisSpacing, + required this.childAspectRatio, + required this.delegate, + }); + + @override + State createState() => + _AnimatedLibrarySliverGridState(); +} + +class _AnimatedLibrarySliverGridState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final CurvedAnimation _curve; + late double _beginExtent; + late double _endExtent; + + @override + void initState() { + super.initState(); + _beginExtent = widget.maxCrossAxisExtent; + _endExtent = widget.maxCrossAxisExtent; + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 130), + )..value = 1; + _curve = CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic); + } + + @override + void didUpdateWidget(covariant AnimatedLibrarySliverGrid oldWidget) { + super.didUpdateWidget(oldWidget); + if ((widget.maxCrossAxisExtent - _endExtent).abs() < 0.1) return; + _beginExtent = _currentExtent; + _endExtent = widget.maxCrossAxisExtent; + _controller.forward(from: 0); + } + + double get _currentExtent => + _beginExtent + ((_endExtent - _beginExtent) * _curve.value); + + @override + void dispose() { + _curve.dispose(); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return SliverGrid( + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: _currentExtent, + mainAxisSpacing: widget.mainAxisSpacing, + crossAxisSpacing: widget.crossAxisSpacing, + childAspectRatio: widget.childAspectRatio, + ), + delegate: widget.delegate, + ); + }, + ); + } +}