398 lines
12 KiB
Dart
398 lines
12 KiB
Dart
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<TaskUpdate> updates;
|
|
final String message;
|
|
final String collapsedMessage;
|
|
final bool showPauseButton;
|
|
final bool showCancelButton;
|
|
final double height;
|
|
final int maxExpandable;
|
|
final Color backgroundColor;
|
|
|
|
@override
|
|
State<DownloadProgressIndicator> createState() =>
|
|
_DownloadProgressIndicatorState();
|
|
}
|
|
|
|
class _DownloadProgressIndicatorState extends State<DownloadProgressIndicator> {
|
|
StreamSubscription<TaskUpdate>? downloadUpdates;
|
|
final inProgress = <Task, (double, int)>{};
|
|
final pausedTasks = <Task>{};
|
|
final totalTasks = <Task>{};
|
|
final finishedTasks = <Task>{};
|
|
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<Task> 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<Task> tasks;
|
|
final String message;
|
|
final double height;
|
|
final Color backgroundColor;
|
|
final Map<Task, (double, int)> 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 <int, TableColumnWidth>{
|
|
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(),
|
|
),
|
|
);
|
|
}
|