mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-29 15:28:48 +02:00
feat(ui): smooth download progress updates
This commit is contained in:
@@ -45,6 +45,7 @@ import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
import 'package:spotiflac_android/widgets/selection_action_button.dart';
|
||||
import 'package:spotiflac_android/widgets/selection_bottom_bar.dart';
|
||||
import 'package:spotiflac_android/widgets/smoothed_progress.dart';
|
||||
|
||||
part 'queue_tab_helpers.dart';
|
||||
part 'queue_tab_widgets.dart';
|
||||
@@ -57,14 +58,20 @@ part 'queue_tab_item_widgets.dart';
|
||||
|
||||
String _formatDownloadSizeMB(num bytes) => '${formatMegabytes(bytes)} MB';
|
||||
|
||||
String _formatDownloadProgressLabel(BuildContext context, DownloadItem item) {
|
||||
final progress = item.progress.clamp(0.0, 1.0);
|
||||
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 = item.bytesReceived > 0
|
||||
final received = visualProgress != null
|
||||
? item.bytesTotal * progress
|
||||
: item.bytesReceived > 0
|
||||
? item.bytesReceived
|
||||
: item.bytesTotal * progress;
|
||||
final percent = (progress * 100).toStringAsFixed(0);
|
||||
@@ -111,17 +118,28 @@ String _formatDownloadProgressLabel(BuildContext context, DownloadItem item) {
|
||||
return context.l10n.queueDownloadStarting;
|
||||
}
|
||||
|
||||
String _formatDownloadStatusLine(BuildContext context, DownloadItem item) {
|
||||
final base = _formatDownloadProgressLabel(context, item);
|
||||
final eta = _formatDownloadEta(item);
|
||||
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) {
|
||||
String? _formatDownloadEta(DownloadItem item, {double? visualProgress}) {
|
||||
if (item.speedMBps <= 0 || item.bytesTotal <= 0) return null;
|
||||
final received = item.bytesReceived > 0
|
||||
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 * item.progress).round();
|
||||
: (item.bytesTotal * progress).round();
|
||||
final remaining = item.bytesTotal - received;
|
||||
if (remaining <= 0) return null;
|
||||
final seconds = remaining / (item.speedMBps * 1024 * 1024);
|
||||
@@ -132,6 +150,16 @@ String? _formatDownloadEta(DownloadItem item) {
|
||||
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.maybeOf(context)?.disableAnimations ?? false;
|
||||
return item.status == DownloadStatus.downloading &&
|
||||
progress > 0 &&
|
||||
progress < 1 &&
|
||||
!animationsDisabled;
|
||||
}
|
||||
|
||||
DownloadHistoryItem? _historyItemForCompletionBridge(
|
||||
DownloadItem item,
|
||||
List<DownloadHistoryItem> historyItems,
|
||||
|
||||
@@ -12,7 +12,6 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState {
|
||||
final isQueued = item.status == DownloadStatus.queued;
|
||||
final isFailed = item.status == DownloadStatus.failed;
|
||||
final progress = item.progress.clamp(0.0, 1.0);
|
||||
final pct = (progress * 100).round();
|
||||
|
||||
final cover = item.track.coverUrl != null
|
||||
? CachedCoverImage(
|
||||
@@ -34,95 +33,114 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState {
|
||||
? () => ref.read(downloadQueueProvider.notifier).removeItem(item.id)
|
||||
: () => _confirmCancelDownload(context, item);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
ClipRRect(borderRadius: radius, child: cover),
|
||||
if (isDownloading || isFinalizing || isQueued)
|
||||
ClipRRect(
|
||||
borderRadius: radius,
|
||||
child: ColoredBox(
|
||||
color: Colors.black.withValues(alpha: 0.45),
|
||||
return SmoothedProgressScope(
|
||||
value: item.progress,
|
||||
animate: _shouldAnimateDownloadProgress(context, item),
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
ClipRRect(borderRadius: radius, child: cover),
|
||||
if (isDownloading || isFinalizing || isQueued)
|
||||
ClipRRect(
|
||||
borderRadius: radius,
|
||||
child: ColoredBox(
|
||||
color: Colors.black.withValues(alpha: 0.45),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isDownloading || isFinalizing || isQueued)
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 34,
|
||||
height: 34,
|
||||
child: CircularProgressIndicator(
|
||||
value: (isFinalizing || isQueued || progress <= 0)
|
||||
? null
|
||||
: progress,
|
||||
strokeWidth: 3,
|
||||
color: Colors.white,
|
||||
backgroundColor: Colors.white.withValues(
|
||||
alpha: 0.25,
|
||||
if (isDownloading || isFinalizing || isQueued)
|
||||
Center(
|
||||
child: isDownloading && progress > 0
|
||||
? SmoothedProgressBuilder(
|
||||
builder: (context, visualProgress, child) =>
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 34,
|
||||
height: 34,
|
||||
child: CircularProgressIndicator(
|
||||
value: visualProgress,
|
||||
strokeWidth: 3,
|
||||
color: Colors.white,
|
||||
backgroundColor: Colors.white
|
||||
.withValues(alpha: 0.25),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'${(visualProgress * 100).round()}%',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 34,
|
||||
height: 34,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
color: Colors.white,
|
||||
backgroundColor: Colors.white.withValues(
|
||||
alpha: 0.25,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isFailed)
|
||||
Positioned(
|
||||
right: 4,
|
||||
top: 4,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.errorContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.error_outline,
|
||||
color: colorScheme.error,
|
||||
size: 14,
|
||||
),
|
||||
if (isDownloading && progress > 0) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'$pct%',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isFailed)
|
||||
Positioned(
|
||||
right: 4,
|
||||
top: 4,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.errorContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.error_outline,
|
||||
color: colorScheme.error,
|
||||
size: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
item.track.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500),
|
||||
),
|
||||
Text(
|
||||
item.track.artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
item.track.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
],
|
||||
Text(
|
||||
item.track.artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -140,66 +140,76 @@ extension _QueueTabItemWidgets on _QueueTabState {
|
||||
item.status == DownloadStatus.downloading ||
|
||||
item.status == DownloadStatus.finalizing;
|
||||
|
||||
return Dismissible(
|
||||
key: ValueKey('dismiss_${item.id}'),
|
||||
direction: DismissDirection.endToStart,
|
||||
confirmDismiss: isActive
|
||||
? (_) async {
|
||||
return await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(context.l10n.cancelDownloadTitle),
|
||||
content: Text(
|
||||
context.l10n.cancelDownloadContent(item.track.name),
|
||||
return SmoothedProgressScope(
|
||||
value: item.progress,
|
||||
animate: _shouldAnimateDownloadProgress(context, item),
|
||||
child: Dismissible(
|
||||
key: ValueKey('dismiss_${item.id}'),
|
||||
direction: DismissDirection.endToStart,
|
||||
confirmDismiss: isActive
|
||||
? (_) async {
|
||||
return await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(context.l10n.cancelDownloadTitle),
|
||||
content: Text(
|
||||
context.l10n.cancelDownloadContent(item.track.name),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: Text(context.l10n.cancelDownloadKeep),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: Text(context.l10n.cancelDownloadKeep),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
],
|
||||
),
|
||||
) ??
|
||||
false;
|
||||
}
|
||||
: null,
|
||||
onDismissed: (_) {
|
||||
ref.read(downloadQueueProvider.notifier).dismissItem(item.id);
|
||||
},
|
||||
background: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
child: Icon(Icons.delete_outline, color: colorScheme.onErrorContainer),
|
||||
),
|
||||
child: DownloadSuccessOverlay(
|
||||
showSuccess: isCompleted,
|
||||
child: Card(
|
||||
) ??
|
||||
false;
|
||||
}
|
||||
: null,
|
||||
onDismissed: (_) {
|
||||
ref.read(downloadQueueProvider.notifier).dismissItem(item.id);
|
||||
},
|
||||
background: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: isCompleted
|
||||
? () => _navigateToMetadataScreen(item)
|
||||
: item.status == DownloadStatus.failed
|
||||
? () => _showDownloadErrorDialog(context, item)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Stack(
|
||||
children: [
|
||||
if (item.status == DownloadStatus.downloading)
|
||||
Positioned.fill(
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FractionallySizedBox(
|
||||
widthFactor: item.progress.clamp(0.0, 1.0),
|
||||
),
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
child: Icon(
|
||||
Icons.delete_outline,
|
||||
color: colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
child: DownloadSuccessOverlay(
|
||||
showSuccess: isCompleted,
|
||||
child: Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: isCompleted
|
||||
? () => _navigateToMetadataScreen(item)
|
||||
: item.status == DownloadStatus.failed
|
||||
? () => _showDownloadErrorDialog(context, item)
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Stack(
|
||||
children: [
|
||||
if (item.status == DownloadStatus.downloading)
|
||||
Positioned.fill(
|
||||
child: SmoothedProgressBuilder(
|
||||
builder: (context, progress, child) => Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FractionallySizedBox(
|
||||
widthFactor: progress,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
@@ -214,86 +224,94 @@ extension _QueueTabItemWidgets on _QueueTabState {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
isCompleted
|
||||
? Hero(
|
||||
tag: 'cover_${item.id}',
|
||||
child: _buildCoverArt(item, colorScheme),
|
||||
)
|
||||
: _buildCoverArt(item, colorScheme),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.track.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleSmall
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
ClickableArtistName(
|
||||
artistName: item.track.artistName,
|
||||
artistId: item.track.artistId,
|
||||
coverUrl: item.track.coverUrl,
|
||||
extensionId: item.track.source,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (item.status == DownloadStatus.downloading) ...[
|
||||
const SizedBox(height: 5),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.download_rounded,
|
||||
size: 12,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_formatDownloadStatusLine(context, item),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelSmall
|
||||
?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
isCompleted
|
||||
? Hero(
|
||||
tag: 'cover_${item.id}',
|
||||
child: _buildCoverArt(item, colorScheme),
|
||||
)
|
||||
: _buildCoverArt(item, colorScheme),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.track.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleSmall
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
ClickableArtistName(
|
||||
artistName: item.track.artistName,
|
||||
artistId: item.track.artistId,
|
||||
coverUrl: item.track.coverUrl,
|
||||
extensionId: item.track.source,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (item.status ==
|
||||
DownloadStatus.downloading) ...[
|
||||
const SizedBox(height: 5),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.download_rounded,
|
||||
size: 12,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: SmoothedProgressBuilder(
|
||||
builder: (context, progress, child) =>
|
||||
Text(
|
||||
_formatDownloadStatusLine(
|
||||
context,
|
||||
item,
|
||||
visualProgress: progress,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelSmall
|
||||
?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
if (item.status == DownloadStatus.failed) ...[
|
||||
const SizedBox(height: 4),
|
||||
_buildDownloadFailureMessage(
|
||||
context,
|
||||
item,
|
||||
colorScheme,
|
||||
),
|
||||
],
|
||||
],
|
||||
if (item.status == DownloadStatus.failed) ...[
|
||||
const SizedBox(height: 4),
|
||||
_buildDownloadFailureMessage(
|
||||
context,
|
||||
item,
|
||||
colorScheme,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildActionButtons(context, item, colorScheme),
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
_buildActionButtons(context, item, colorScheme),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -414,28 +432,47 @@ extension _QueueTabItemWidgets on _QueueTabState {
|
||||
borderRadius: radius,
|
||||
child: ColoredBox(color: Colors.black.withValues(alpha: 0.45)),
|
||||
),
|
||||
Center(
|
||||
child: SizedBox(
|
||||
width: coverSize * 0.6,
|
||||
height: coverSize * 0.6,
|
||||
child: CircularProgressIndicator(
|
||||
value: indeterminate ? null : progress,
|
||||
strokeWidth: 3,
|
||||
color: Colors.white,
|
||||
backgroundColor: Colors.white.withValues(alpha: 0.25),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!indeterminate)
|
||||
if (indeterminate)
|
||||
Center(
|
||||
child: Text(
|
||||
'${(progress * 100).round()}',
|
||||
style: const TextStyle(
|
||||
child: SizedBox(
|
||||
width: coverSize * 0.6,
|
||||
height: coverSize * 0.6,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
backgroundColor: Colors.white.withValues(alpha: 0.25),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SmoothedProgressBuilder(
|
||||
builder: (context, visualProgress, child) => Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Center(
|
||||
child: SizedBox(
|
||||
width: coverSize * 0.6,
|
||||
height: coverSize * 0.6,
|
||||
child: CircularProgressIndicator(
|
||||
value: visualProgress,
|
||||
strokeWidth: 3,
|
||||
color: Colors.white,
|
||||
backgroundColor: Colors.white.withValues(alpha: 0.25),
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Text(
|
||||
'${(visualProgress * 100).round()}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user