import 'dart:async'; import 'dart:math'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'file_downloader.dart'; import 'models.dart'; import 'task.dart'; import 'uri/uri_helpers.dart'; /// Progress indicator for use with the [FileDownloader] /// /// Configuration parameters: /// [message] message to show for a single download. Templates {filename} and /// {metadata} are replaced by a task's filename and metadata respectively /// [collapsedMessage] message to show when multiple file downloads are /// collapsed into a single row. The template {n} is replaced by the number /// of tasks finished, and {total} is replaced by the total number of tasks /// started in this batch. Both reset to 0 when all downloads finish. /// In collapsed mode, progress is indicated as {n}/{total} /// [showPauseButton] if true, shows a pause button if the task allows it /// [showCancelButton] if true, shows a cancel button /// [height] height of the [DownloadProgressIndicator], and of each row when /// in expanded mode /// [maxExpandable] maximum number of rows the indicator can expand to, with /// each row showing one download in progress. If set to 1 (the default) the /// indicator will not expand: if the number of downloads in progress exceeds /// 1, the indicator will switch to a 'collapsed' state showing the /// number of files finished out of the total started. It will stay in /// 'collapsed' state until all downloads have finished. To start the /// indicator in collapsed state (i.e. never show the download progress of a /// single file), set [maxExpandable] to 0 /// [backgroundColor] background color for the widget class DownloadProgressIndicator extends StatefulWidget { const DownloadProgressIndicator( this.updates, { this.message = '{filename}', this.collapsedMessage = '{n}/{total}', this.showPauseButton = false, this.showCancelButton = false, this.height = 50, this.maxExpandable = 1, this.backgroundColor = Colors.grey, super.key, }); final Stream updates; final String message; final String collapsedMessage; final bool showPauseButton; final bool showCancelButton; final double height; final int maxExpandable; final Color backgroundColor; @override State createState() => _DownloadProgressIndicatorState(); } class _DownloadProgressIndicatorState extends State { StreamSubscription? downloadUpdates; final inProgress = {}; final pausedTasks = {}; final totalTasks = {}; final finishedTasks = {}; bool isExpanded = false; bool isCollapsed = false; @override void initState() { super.initState(); isCollapsed = widget.maxExpandable == 0; downloadUpdates = widget.updates.listen((update) { if (update is TaskStatusUpdate) { switch (update.status) { case .running: totalTasks.add(update.task); pausedTasks.remove(update.task); inProgress[update.task] = ( 0, DateTime.now().millisecondsSinceEpoch, ); case .waitingToRetry: break; case .paused: pausedTasks.add(update.task); default: // task finished _finish(update.task); } } else if (update is TaskProgressUpdate) { switch (update.progress) { case >= 0 && < 1: // active, so add task to set and update progress totalTasks.add(update.task); pausedTasks.remove(update.task); final previousInProgress = inProgress[update.task]; inProgress[update.task] = ( update.progress, previousInProgress?.$2 ?? DateTime.now().millisecondsSinceEpoch, ); case progressWaitingToRetry: break; case progressPaused: pausedTasks.add(update.task); default: // task finished _finish(update.task); } if (mounted) { setState(() {}); } } }); } @override void dispose() { super.dispose(); downloadUpdates?.cancel(); downloadUpdates = null; } /// Process a finished task /// /// Remove the task from [inProgress] and [pausedTasks], add to [finishedTasks] /// If [inProgress] is empty, also empty the sets and reset /// the [isExpanded] and [isCollapsed] flags. void _finish(Task task) { inProgress.remove(task); finishedTasks.add(task); pausedTasks.remove(task); if (inProgress.isEmpty) { totalTasks.clear(); finishedTasks.clear(); isExpanded = false; isCollapsed = widget.maxExpandable == 0; } } @override Widget build(BuildContext context) { final activeTasks = inProgress.keys .where((taskId) => inProgress[taskId]!.$1 >= 0) .sorted((a, b) => inProgress[a]!.$2.compareTo(inProgress[b]!.$2)); final numActive = activeTasks.length; if (numActive > 1) { if (widget.maxExpandable > 1) { isExpanded = true; // only resets when all downloads finish } else { isCollapsed = true; // only resets when all downloads finish } } final itemsToShow = isExpanded ? min(numActive, widget.maxExpandable) : isCollapsed ? min(1, numActive) : numActive; return AnimatedSize( duration: const Duration(milliseconds: 200), alignment: Alignment.bottomCenter, child: switch (itemsToShow) { 0 => Container(height: 0), 1 => isCollapsed ? _CollapsedDownloadProgress( finishedTasks.length, totalTasks.length, widget.collapsedMessage, widget.height, widget.backgroundColor, ) : _DownloadProgressItem( activeTasks.first, inProgress[activeTasks.first]!.$1, widget.message, widget.showPauseButton, widget.showCancelButton, widget.height, widget.backgroundColor, pausedTasks, ), _ => _ExpandedDownloadProgress( activeTasks.take(widget.maxExpandable).toList(growable: false), widget.message, widget.height, widget.backgroundColor, inProgress, ), }, ); } } final _fileNameRegEx = RegExp("""{filename}""", caseSensitive: false); final _metadataRegEx = RegExp("""{metadata}""", caseSensitive: false); /// Single file download progress widget class _DownloadProgressItem extends StatelessWidget { const _DownloadProgressItem( this.task, this.progress, this.message, this.showPauseButton, this.showCancelButton, this.height, this.backgroundColor, this.pausedTasks, ); final Task task; final double progress; final String message; final bool showPauseButton; final bool showCancelButton; final double height; final Color backgroundColor; final Set pausedTasks; @override Widget build(BuildContext context) { final messageText = message .replaceAll( _fileNameRegEx, unpack(task.filename).filename ?? task.filename, ) .replaceAll(_metadataRegEx, task.metaData); return Container( height: height, decoration: BoxDecoration(color: backgroundColor), child: Padding( padding: const EdgeInsets.only(left: 8, right: 8), child: Row( children: [ Padding( padding: const EdgeInsets.only(right: 8), child: Text( messageText, style: Theme.of(context).textTheme.bodyMedium, ), ), Expanded( child: LinearProgressIndicator( value: progress == 0 ? null : progress, ), ), if (showPauseButton && task.allowPause && !pausedTasks.contains(task)) IconButton( onPressed: () => FileDownloader().pause(task as DownloadTask), icon: const Icon(Icons.pause), color: Theme.of(context).primaryColor, ), if (showPauseButton && task.allowPause && pausedTasks.contains(task)) IconButton( onPressed: () => FileDownloader().resume(task as DownloadTask), icon: const Icon(Icons.play_arrow), color: Theme.of(context).primaryColor, ), if (showCancelButton) IconButton( onPressed: () => FileDownloader().cancelTaskWithId(task.taskId), icon: const Icon(Icons.cancel), color: Theme.of(context).primaryColor, ), ], ), ), ); } } /// Collapsed file download progress widget, showing the number of files /// downloaded out of the total number of files downloaded as a progress /// indicator class _CollapsedDownloadProgress extends StatelessWidget { const _CollapsedDownloadProgress( this.numCompleted, this.numTotal, this.collapsedMessage, this.height, this.backgroundColor, ); final int numCompleted; final int numTotal; final String collapsedMessage; final double height; final Color backgroundColor; @override Widget build(BuildContext context) { final messageText = collapsedMessage .replaceAll('{n}', '$numCompleted') .replaceAll('{total}', '$numTotal'); final progress = numCompleted / numTotal; return Container( height: height, decoration: BoxDecoration(color: backgroundColor), child: Padding( padding: const EdgeInsets.only(left: 8, right: 8), child: Row( children: [ Padding( padding: const EdgeInsets.only(right: 8), child: Text( messageText, style: Theme.of(context).textTheme.bodyMedium, ), ), Expanded(child: LinearProgressIndicator(value: progress)), ], ), ), ); } } /// Expanded file download progress widget, showing multiple file download /// progress indicators (up to expandable as defined in the core widget class _ExpandedDownloadProgress extends StatelessWidget { const _ExpandedDownloadProgress( this.tasks, this.message, this.height, this.backgroundColor, this.inProgress, ); final List tasks; final String message; final double height; final Color backgroundColor; final Map inProgress; @override Widget build(BuildContext context) => DecoratedBox( decoration: BoxDecoration( color: backgroundColor, border: Border(top: BorderSide(color: Theme.of(context).dividerColor)), ), child: Table( defaultVerticalAlignment: TableCellVerticalAlignment.middle, columnWidths: const { 0: IntrinsicColumnWidth(), 1: FlexColumnWidth(), }, children: tasks .map( (task) => TableRow( decoration: BoxDecoration( border: Border( top: BorderSide(color: Theme.of(context).dividerColor), ), ), children: [ SizedBox( height: height, child: Align( alignment: Alignment.centerLeft, child: Padding( padding: const EdgeInsets.only(left: 8, right: 8), child: Text( message .replaceAll( _fileNameRegEx, unpack(task.filename).filename ?? task.filename, ) .replaceAll(_metadataRegEx, task.metaData), style: Theme.of(context).textTheme.bodyMedium, ), ), ), ), Padding( padding: const EdgeInsets.only(right: 8), child: LinearProgressIndicator(value: inProgress[task]!.$1), ), ], ), ) .toList(), ), ); }