resumable_downloader 0.1.0-dev.5 copy "resumable_downloader: ^0.1.0-dev.5" to clipboard
resumable_downloader: ^0.1.0-dev.5 copied to clipboard

Reliable Flutter downloads with multipart ranges, durable resume, scheduling, integrity checks, and authenticated restoration.

example/lib/main.dart

import 'dart:async';

import 'package:flutter/foundation.dart'
    show debugPrint, debugPrintStack, kDebugMode;
import 'package:flutter/material.dart';
import 'package:resumable_downloader/resumable_downloader.dart';

import 'widgets/adaptive_progress_bar.dart';

void main() {
  runApp(const MainApp());
}

class MainApp extends StatelessWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
        useMaterial3: true,
      ),
      home: const DownloadLabPage(),
    );
  }
}

class DownloadLabPage extends StatefulWidget {
  const DownloadLabPage({super.key});

  @override
  State<DownloadLabPage> createState() => _DownloadLabPageState();
}

class _DownloadLabPageState extends State<DownloadLabPage> {
  DownloadConfiguration _configuration = DownloadConfiguration(
    maxConcurrentDownloads: 3,
    maxConcurrentConnections: 6,
    maxConnectionsPerDownload: 4,
    minimumBytesPerPart: 8 * 1024 * 1024,
    maxRetries: 2,
  );
  final List<_TransferEntry> _entries = <_TransferEntry>[];
  late DownloadManager _manager;
  var _selectedIndex = 0;

  @override
  void initState() {
    super.initState();
    _manager = _createManager();
    unawaited(_restorePending());
  }

  @override
  void dispose() {
    unawaited(_manager.dispose());
    super.dispose();
  }

  void _startOperation(
    List<DownloadRequest> requests, {
    DownloadPriority priority = DownloadPriority.normal,
  }) {
    final operation = _manager.startOperation(requests, priority: priority);
    for (var index = 0; index < operation.tasks.length; index++) {
      _trackTask(
        requests[index],
        operation.tasks[index],
        operationId: operation.id,
        priority: priority,
      );
    }
  }

  void _startRequest(
    DownloadRequest request, {
    DownloadPriority priority = DownloadPriority.normal,
    _TransferEntry? restarting,
  }) {
    final operation = _manager.startOperation(<DownloadRequest>[
      request,
    ], priority: priority);
    _trackTask(
      request,
      operation.tasks.single,
      restarting: restarting,
      operationId: operation.id,
      priority: priority,
    );
  }

  void _trackTask(
    DownloadRequest request,
    DownloadTask task, {
    _TransferEntry? restarting,
    String? operationId,
    DownloadPriority priority = DownloadPriority.normal,
  }) {
    final entry =
        restarting ??
        _TransferEntry(
          request: request,
          task: task,
          operationId: operationId,
          priority: priority,
        );
    setState(() {
      if (restarting == null) {
        _entries.insert(0, entry);
      } else {
        entry.request = request;
        entry.task = task;
        entry.update = null;
        entry.operationId = operationId;
        entry.priority = priority;
        entry.resetTelemetry();
      }
    });
    task.updates.listen((update) {
      if (!mounted) {
        return;
      }
      setState(() {
        entry.record(update);
        entry.update = update;
      });
    });
    unawaited(
      task.result.then<void>(
        (_) {},
        onError: (Object error, StackTrace stackTrace) {
          if (kDebugMode) {
            debugPrint('Download failed for ${request.url}: $error');
            debugPrintStack(stackTrace: stackTrace);
          }
        },
      ),
    );
  }

  Future<void> _restorePending() async {
    final restoredRequests = <DownloadRequest>[];
    try {
      final tasks = await _manager.restorePending((pending) async {
        final request = DownloadRequest(
          url: pending.sourceUri,
          fileName: pending.fileName,
          restorationId: pending.restorationId,
          expectedSha256: pending.expectedSha256,
        );
        restoredRequests.add(request);
        return request;
      });
      if (!mounted) {
        return;
      }
      for (var index = 0; index < tasks.length; index++) {
        _trackTask(restoredRequests[index], tasks[index]);
      }
    } on Object catch (error, stackTrace) {
      if (kDebugMode) {
        debugPrint('Could not restore pending downloads: $error');
        debugPrintStack(stackTrace: stackTrace);
      }
    }
  }

  void _restart(_TransferEntry entry) {
    final request = entry.request;
    _startRequest(
      DownloadRequest(
        url: request.url,
        fileName: request.fileName,
        subdirectory: request.subdirectory,
        headers: request.headers,
        expectedSha256: request.expectedSha256,
        existingFilePolicy: ExistingFilePolicy.replace,
      ),
      restarting: entry,
    );
  }

  DownloadManager _createManager() => DownloadManager(
    subdirectory: 'transfer_lab',
    configuration: _configuration,
  );

  Future<void> _deleteArtifacts(_TransferEntry entry) async {
    try {
      await _manager.deleteArtifacts(
        entry.request,
        cancelActive: !entry.task.isCompleted,
      );
      if (mounted) {
        setState(() => _entries.remove(entry));
      }
    } on Object catch (error) {
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('Could not delete artifacts: $error')),
        );
      }
    }
  }

  Future<bool> _applyConfiguration(DownloadConfiguration configuration) async {
    if (_entries.any((entry) => !entry.task.isCompleted)) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(
          content: Text('Finish or cancel active transfers first.'),
        ),
      );
      return false;
    }
    await _manager.dispose();
    if (!mounted) {
      return false;
    }
    setState(() {
      _configuration = configuration;
      _manager = _createManager();
    });
    return true;
  }

  Future<void> _showConfigurationSheet() => showModalBottomSheet<void>(
    context: context,
    isScrollControlled: true,
    builder:
        (_) => _ConfigurationSheet(
          configuration: _configuration,
          onApply: _applyConfiguration,
        ),
  );

  Future<void> _showAddSheet() {
    return showModalBottomSheet<void>(
      context: context,
      isScrollControlled: true,
      builder:
          (_) => _AddDownloadSheet(
            onQueue:
                (request, priority) =>
                    _startRequest(request, priority: priority),
          ),
    );
  }

  @override
  Widget build(BuildContext context) {
    final titles = <String>['Transfers', 'Presets', 'Configuration'];
    return Scaffold(
      appBar: AppBar(
        title: Text(titles[_selectedIndex]),
        actions: [
          if (_selectedIndex == 0)
            IconButton(
              tooltip: 'Add download',
              onPressed: _showAddSheet,
              icon: const Icon(Icons.add),
            ),
        ],
      ),
      body: switch (_selectedIndex) {
        0 => _TransfersView(
          entries: _entries,
          onRestart: _restart,
          onDelete: _deleteArtifacts,
        ),
        1 => _PresetsView(
          onStart:
              (request) =>
                  _startRequest(request, priority: DownloadPriority.foreground),
          onStartOperation: _startOperation,
        ),
        _ => _ConfigurationView(
          configuration: _configuration,
          onEdit: _showConfigurationSheet,
        ),
      },
      floatingActionButton:
          _selectedIndex == 0
              ? FloatingActionButton.extended(
                onPressed: _showAddSheet,
                icon: const Icon(Icons.add),
                label: const Text('Add URL'),
              )
              : null,
      bottomNavigationBar: NavigationBar(
        selectedIndex: _selectedIndex,
        onDestinationSelected:
            (index) => setState(() => _selectedIndex = index),
        destinations: const [
          NavigationDestination(
            icon: Icon(Icons.download_outlined),
            selectedIcon: Icon(Icons.download),
            label: 'Transfers',
          ),
          NavigationDestination(
            icon: Icon(Icons.science_outlined),
            selectedIcon: Icon(Icons.science),
            label: 'Presets',
          ),
          NavigationDestination(
            icon: Icon(Icons.tune_outlined),
            selectedIcon: Icon(Icons.tune),
            label: 'Configuration',
          ),
        ],
      ),
    );
  }
}

class _AddDownloadSheet extends StatefulWidget {
  const _AddDownloadSheet({required this.onQueue});

  final void Function(DownloadRequest request, DownloadPriority priority)
  onQueue;

  @override
  State<_AddDownloadSheet> createState() => _AddDownloadSheetState();
}

class _AddDownloadSheetState extends State<_AddDownloadSheet> {
  final _urlController = TextEditingController();
  final _fileNameController = TextEditingController();
  final _checksumController = TextEditingController();
  String? _urlError;
  var _existingFilePolicy = ExistingFilePolicy.resume;
  var _priority = DownloadPriority.normal;

  @override
  void dispose() {
    _urlController.dispose();
    _fileNameController.dispose();
    _checksumController.dispose();
    super.dispose();
  }

  void _queue() {
    final url = Uri.tryParse(_urlController.text.trim());
    if (url == null || !(url.isScheme('http') || url.isScheme('https'))) {
      setState(() => _urlError = 'Enter a valid HTTP or HTTPS URL.');
      return;
    }
    try {
      widget.onQueue(
        DownloadRequest(
          url: url,
          fileName:
              _fileNameController.text.trim().isEmpty
                  ? null
                  : _fileNameController.text.trim(),
          expectedSha256:
              _checksumController.text.trim().isEmpty
                  ? null
                  : _checksumController.text.trim(),
          existingFilePolicy: _existingFilePolicy,
        ),
        _priority,
      );
    } on ArgumentError catch (error) {
      setState(() => _urlError = error.message?.toString() ?? error.toString());
      return;
    }
    Navigator.pop(context);
  }

  @override
  Widget build(BuildContext context) {
    return SafeArea(
      top: false,
      child: SingleChildScrollView(
        padding: EdgeInsets.fromLTRB(
          20,
          20,
          20,
          20 + MediaQuery.viewInsetsOf(context).bottom,
        ),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            Text('Add download', style: Theme.of(context).textTheme.titleLarge),
            const SizedBox(height: 16),
            TextField(
              controller: _urlController,
              keyboardType: TextInputType.url,
              onChanged: (_) {
                if (_urlError != null) {
                  setState(() => _urlError = null);
                }
              },
              decoration: InputDecoration(
                labelText: 'Download URL',
                errorText: _urlError,
                border: const OutlineInputBorder(),
              ),
            ),
            const SizedBox(height: 12),
            TextField(
              controller: _fileNameController,
              decoration: const InputDecoration(
                labelText: 'File name',
                border: OutlineInputBorder(),
              ),
            ),
            const SizedBox(height: 12),
            DropdownButtonFormField<ExistingFilePolicy>(
              initialValue: _existingFilePolicy,
              decoration: const InputDecoration(
                labelText: 'Existing output',
                border: OutlineInputBorder(),
              ),
              items: ExistingFilePolicy.values
                  .map(
                    (policy) => DropdownMenuItem(
                      value: policy,
                      child: Text(policy.name),
                    ),
                  )
                  .toList(growable: false),
              onChanged: (policy) {
                if (policy != null) {
                  setState(() => _existingFilePolicy = policy);
                }
              },
            ),
            const SizedBox(height: 12),
            SegmentedButton<DownloadPriority>(
              segments: const <ButtonSegment<DownloadPriority>>[
                ButtonSegment(
                  value: DownloadPriority.normal,
                  label: Text('normal'),
                ),
                ButtonSegment(
                  value: DownloadPriority.foreground,
                  label: Text('foreground'),
                ),
              ],
              selected: <DownloadPriority>{_priority},
              onSelectionChanged: (selection) {
                setState(() => _priority = selection.single);
              },
            ),
            const SizedBox(height: 12),
            TextField(
              controller: _checksumController,
              autocorrect: false,
              decoration: const InputDecoration(
                labelText: 'Expected SHA-256 (optional)',
                border: OutlineInputBorder(),
              ),
            ),
            const SizedBox(height: 16),
            FilledButton(
              onPressed: _queue,
              child: const Text('Queue download'),
            ),
          ],
        ),
      ),
    );
  }
}

class _TransfersView extends StatelessWidget {
  const _TransfersView({
    required this.entries,
    required this.onRestart,
    required this.onDelete,
  });

  final List<_TransferEntry> entries;
  final void Function(_TransferEntry entry) onRestart;
  final Future<void> Function(_TransferEntry entry) onDelete;

  @override
  Widget build(BuildContext context) {
    if (entries.isEmpty) {
      return const Center(
        child: Text(
          'Add a URL or start a preset to inspect transfer behavior.',
        ),
      );
    }
    return ListView.separated(
      padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
      itemCount: entries.length,
      separatorBuilder: (_, _) => const SizedBox(height: 10),
      itemBuilder:
          (context, index) => _TransferTile(
            entry: entries[index],
            onRestart: onRestart,
            onDelete: onDelete,
          ),
    );
  }
}

class _TransferTile extends StatelessWidget {
  const _TransferTile({
    required this.entry,
    required this.onRestart,
    required this.onDelete,
  });

  final _TransferEntry entry;
  final void Function(_TransferEntry entry) onRestart;
  final Future<void> Function(_TransferEntry entry) onDelete;

  @override
  Widget build(BuildContext context) {
    final update = entry.update;
    final state = update?.status ?? DownloadStatus.queued;
    final pathSegments = entry.request.url.pathSegments;
    final fileName =
        entry.request.fileName ??
        (pathSegments.isEmpty ? entry.request.url.host : pathSegments.last);
    final ranges = update?.ranges ?? const <DownloadRangeUpdate>[];
    final canRestart =
        state == DownloadStatus.failed || state == DownloadStatus.cancelled;
    return Card(
      margin: EdgeInsets.zero,
      child: Padding(
        padding: const EdgeInsets.all(14),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              children: [
                Expanded(
                  child: Text(
                    fileName,
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis,
                    style: Theme.of(context).textTheme.titleSmall,
                  ),
                ),
                _StatusLabel(status: state),
              ],
            ),
            const SizedBox(height: 8),
            Text(
              entry.operationId == null
                  ? 'restored · ${entry.priority.name}'
                  : '${entry.operationId} · ${entry.priority.name}',
              maxLines: 1,
              overflow: TextOverflow.ellipsis,
              style: Theme.of(context).textTheme.labelSmall,
            ),
            const SizedBox(height: 8),
            AdaptiveProgressBar(ranges: ranges),
            const SizedBox(height: 8),
            if (state == DownloadStatus.failed && update?.error != null) ...[
              Text(
                update!.error.toString(),
                maxLines: 2,
                overflow: TextOverflow.ellipsis,
                style: Theme.of(context).textTheme.bodySmall?.copyWith(
                  color: Theme.of(context).colorScheme.error,
                ),
              ),
              const SizedBox(height: 8),
            ],
            Row(
              children: [
                Expanded(
                  child: Text(
                    '${_formatBytes(update?.receivedBytes ?? 0)}'
                    '${update?.totalBytes == null ? '' : ' / ${_formatBytes(update!.totalBytes!)}'}'
                    '${entry.bytesPerSecond > 0 ? ' · ${_formatSpeed(entry.bytesPerSecond)}' : ''}',
                    style: Theme.of(context).textTheme.bodySmall,
                  ),
                ),
                if (state == DownloadStatus.downloading ||
                    state == DownloadStatus.preparing ||
                    state == DownloadStatus.retrying)
                  IconButton(
                    tooltip: 'Pause transfer',
                    onPressed: () => unawaited(entry.task.pause()),
                    icon: const Icon(Icons.pause_outlined),
                  )
                else if (state == DownloadStatus.paused)
                  IconButton(
                    tooltip: 'Resume transfer',
                    onPressed: () => unawaited(entry.task.resume()),
                    icon: const Icon(Icons.play_arrow_outlined),
                  ),
                if (state == DownloadStatus.downloading ||
                    state == DownloadStatus.preparing ||
                    state == DownloadStatus.retrying ||
                    state == DownloadStatus.paused ||
                    state == DownloadStatus.validating)
                  IconButton(
                    tooltip: 'Cancel transfer',
                    onPressed: () => unawaited(entry.task.cancel()),
                    icon: const Icon(Icons.close),
                  )
                else if (canRestart || state == DownloadStatus.completed)
                  IconButton(
                    tooltip: 'Run again and replace output',
                    onPressed: () => onRestart(entry),
                    icon: const Icon(Icons.refresh),
                  ),
                IconButton(
                  tooltip:
                      entry.task.isCompleted
                          ? 'Delete output and staged data'
                          : 'Cancel and delete transfer data',
                  onPressed: () => unawaited(onDelete(entry)),
                  icon: const Icon(Icons.delete_outline),
                ),
              ],
            ),
            if (ranges.isNotEmpty) ...[
              const SizedBox(height: 4),
              Text(
                '${update?.activeRanges ?? 0} active · '
                '${update?.completedRanges ?? 0}/${ranges.length} parts',
                style: Theme.of(context).textTheme.labelSmall,
              ),
            ],
          ],
        ),
      ),
    );
  }
}

class _StatusLabel extends StatelessWidget {
  const _StatusLabel({required this.status});

  final DownloadStatus status;

  @override
  Widget build(BuildContext context) {
    final color = switch (status) {
      DownloadStatus.completed => Colors.green,
      DownloadStatus.failed => Theme.of(context).colorScheme.error,
      DownloadStatus.retrying => Colors.orange,
      DownloadStatus.cancelled => Colors.grey,
      _ => Theme.of(context).colorScheme.primary,
    };
    return Text(
      status.name,
      style: Theme.of(context).textTheme.labelSmall?.copyWith(color: color),
    );
  }
}

class _PresetsView extends StatelessWidget {
  const _PresetsView({required this.onStart, required this.onStartOperation});

  final void Function(DownloadRequest request) onStart;
  final void Function(List<DownloadRequest> requests) onStartOperation;

  static final List<_Preset> _presets = <_Preset>[
    _Preset(
      name: 'Multipart fixture',
      description:
          'Use a large Range-enabled file to inspect segmented progress.',
      url: Uri.parse('https://proof.ovh.net/files/100Mb.dat'),
      fileName: 'multipart-fixture.bin',
    ),
    _Preset(
      name: 'Single-stream fixture',
      description: 'Use a small file or a server without Range support.',
      url: Uri.parse('https://proof.ovh.net/files/1Mb.dat'),
      fileName: 'single-fixture.bin',
    ),
  ];

  @override
  Widget build(BuildContext context) {
    return ListView.separated(
      padding: const EdgeInsets.all(16),
      itemCount: _presets.length + 1,
      separatorBuilder: (_, _) => const SizedBox(height: 10),
      itemBuilder: (context, index) {
        if (index == 0) {
          return FilledButton.icon(
            onPressed:
                () => onStartOperation(<DownloadRequest>[
                  for (final preset in _presets)
                    DownloadRequest(url: preset.url, fileName: preset.fileName),
                ]),
            icon: const Icon(Icons.playlist_add),
            label: const Text('Start scoped batch'),
          );
        }
        final preset = _presets[index - 1];
        return ListTile(
          tileColor: Theme.of(context).colorScheme.surfaceContainerLow,
          title: Text(preset.name),
          subtitle: Text(preset.description),
          trailing: FilledButton(
            onPressed:
                () => onStart(
                  DownloadRequest(url: preset.url, fileName: preset.fileName),
                ),
            child: const Text('Start'),
          ),
        );
      },
    );
  }
}

class _ConfigurationView extends StatelessWidget {
  const _ConfigurationView({required this.configuration, required this.onEdit});

  final DownloadConfiguration configuration;
  final VoidCallback onEdit;

  @override
  Widget build(BuildContext context) {
    return ListView(
      padding: const EdgeInsets.all(16),
      children: [
        Text('Manager limits', style: Theme.of(context).textTheme.titleMedium),
        const SizedBox(height: 8),
        ListTile(
          title: const Text('Active files'),
          trailing: Text('${configuration.maxConcurrentDownloads}'),
        ),
        ListTile(
          title: const Text('Global connections'),
          trailing: Text('${configuration.maxConcurrentConnections}'),
        ),
        ListTile(
          title: const Text('Connections per file'),
          trailing: Text('${configuration.maxConnectionsPerDownload}'),
        ),
        ListTile(
          title: const Text('Minimum part size'),
          trailing: Text(
            '${configuration.minimumBytesPerPart ~/ (1024 * 1024)} MB',
          ),
        ),
        ListTile(
          title: const Text('Checkpoint interval'),
          trailing: Text('${configuration.checkpointBytes ~/ 1024} KB'),
        ),
        ListTile(
          title: const Text('Retry attempts'),
          trailing: Text('${configuration.maxRetries}'),
        ),
        const SizedBox(height: 16),
        FilledButton.icon(
          onPressed: onEdit,
          icon: const Icon(Icons.tune),
          label: const Text('Edit session settings'),
        ),
      ],
    );
  }
}

class _ConfigurationSheet extends StatefulWidget {
  const _ConfigurationSheet({
    required this.configuration,
    required this.onApply,
  });

  final DownloadConfiguration configuration;
  final Future<bool> Function(DownloadConfiguration configuration) onApply;

  @override
  State<_ConfigurationSheet> createState() => _ConfigurationSheetState();
}

class _ConfigurationSheetState extends State<_ConfigurationSheet> {
  late final TextEditingController _downloadsController;
  late final TextEditingController _connectionsController;
  late final TextEditingController _perFileController;
  late final TextEditingController _partSizeController;
  late final TextEditingController _checkpointController;
  late final TextEditingController _retriesController;
  String? _error;

  @override
  void initState() {
    super.initState();
    final config = widget.configuration;
    _downloadsController = TextEditingController(
      text: '${config.maxConcurrentDownloads}',
    );
    _connectionsController = TextEditingController(
      text: '${config.maxConcurrentConnections}',
    );
    _perFileController = TextEditingController(
      text: '${config.maxConnectionsPerDownload}',
    );
    _partSizeController = TextEditingController(
      text: '${config.minimumBytesPerPart ~/ (1024 * 1024)}',
    );
    _checkpointController = TextEditingController(
      text: '${config.checkpointBytes ~/ 1024}',
    );
    _retriesController = TextEditingController(text: '${config.maxRetries}');
  }

  @override
  void dispose() {
    _downloadsController.dispose();
    _connectionsController.dispose();
    _perFileController.dispose();
    _partSizeController.dispose();
    _checkpointController.dispose();
    _retriesController.dispose();
    super.dispose();
  }

  Future<void> _apply() async {
    try {
      final configuration = DownloadConfiguration(
        maxConcurrentDownloads: _parsePositive(_downloadsController, 'files'),
        maxConcurrentConnections: _parsePositive(
          _connectionsController,
          'connections',
        ),
        maxConnectionsPerDownload: _parsePositive(
          _perFileController,
          'per file',
        ),
        minimumBytesPerPart:
            _parsePositive(_partSizeController, 'part size') * 1024 * 1024,
        checkpointBytes:
            _parsePositive(_checkpointController, 'checkpoint') * 1024,
        maxRetries: _parseNonNegative(_retriesController, 'retries'),
        retryDelay: widget.configuration.retryDelay,
      );
      final applied = await widget.onApply(configuration);
      if (mounted && applied) {
        Navigator.pop(context);
      }
    } on ArgumentError catch (error) {
      setState(() => _error = error.message?.toString() ?? error.toString());
    }
  }

  int _parsePositive(TextEditingController controller, String field) {
    final value = int.tryParse(controller.text.trim());
    if (value == null || value <= 0) {
      throw ArgumentError('$field must be greater than zero');
    }
    return value;
  }

  int _parseNonNegative(TextEditingController controller, String field) {
    final value = int.tryParse(controller.text.trim());
    if (value == null || value < 0) {
      throw ArgumentError('$field must not be negative');
    }
    return value;
  }

  @override
  Widget build(BuildContext context) {
    return SafeArea(
      top: false,
      child: SingleChildScrollView(
        padding: EdgeInsets.fromLTRB(
          20,
          20,
          20,
          20 + MediaQuery.viewInsetsOf(context).bottom,
        ),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            Text(
              'Session settings',
              style: Theme.of(context).textTheme.titleLarge,
            ),
            const SizedBox(height: 16),
            _NumberField(
              controller: _downloadsController,
              label: 'Concurrent files',
            ),
            _NumberField(
              controller: _connectionsController,
              label: 'Global connections',
            ),
            _NumberField(
              controller: _perFileController,
              label: 'Connections per file',
            ),
            _NumberField(
              controller: _partSizeController,
              label: 'Minimum part size (MB)',
            ),
            _NumberField(
              controller: _checkpointController,
              label: 'Checkpoint interval (KB)',
            ),
            _NumberField(
              controller: _retriesController,
              label: 'Retry attempts',
            ),
            if (_error != null) ...[
              const SizedBox(height: 8),
              Text(
                _error!,
                style: TextStyle(color: Theme.of(context).colorScheme.error),
              ),
            ],
            const SizedBox(height: 16),
            FilledButton(
              onPressed: _apply,
              child: const Text('Apply settings'),
            ),
          ],
        ),
      ),
    );
  }
}

class _NumberField extends StatelessWidget {
  const _NumberField({required this.controller, required this.label});

  final TextEditingController controller;
  final String label;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 12),
      child: TextField(
        controller: controller,
        keyboardType: TextInputType.number,
        decoration: InputDecoration(
          labelText: label,
          border: const OutlineInputBorder(),
        ),
      ),
    );
  }
}

class _TransferEntry {
  _TransferEntry({
    required this.request,
    required this.task,
    required this.operationId,
    required this.priority,
  });

  DownloadRequest request;
  DownloadTask task;
  String? operationId;
  DownloadPriority priority;
  DownloadUpdate? update;
  DateTime? _sampleAt;
  int _sampleBytes = 0;
  double bytesPerSecond = 0;

  void record(DownloadUpdate next) {
    final now = DateTime.now();
    final previousAt = _sampleAt;
    if (previousAt != null && next.receivedBytes >= _sampleBytes) {
      final elapsedMicroseconds = now.difference(previousAt).inMicroseconds;
      if (elapsedMicroseconds > 0) {
        final instant =
            (next.receivedBytes - _sampleBytes) *
            Duration.microsecondsPerSecond /
            elapsedMicroseconds;
        bytesPerSecond =
            bytesPerSecond == 0
                ? instant
                : (bytesPerSecond * 0.7) + (instant * 0.3);
      }
    }
    if (next.status == DownloadStatus.paused ||
        next.status == DownloadStatus.validating ||
        next.status == DownloadStatus.completed ||
        next.status == DownloadStatus.failed ||
        next.status == DownloadStatus.cancelled) {
      bytesPerSecond = 0;
    }
    _sampleAt = now;
    _sampleBytes = next.receivedBytes;
  }

  void resetTelemetry() {
    _sampleAt = null;
    _sampleBytes = 0;
    bytesPerSecond = 0;
  }
}

String _formatBytes(int bytes) {
  if (bytes < 1024) {
    return '$bytes B';
  }
  if (bytes < 1024 * 1024) {
    return '${(bytes / 1024).toStringAsFixed(1)} KB';
  }
  if (bytes < 1024 * 1024 * 1024) {
    return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
  }
  return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
}

String _formatSpeed(double bytesPerSecond) =>
    '${_formatBytes(bytesPerSecond.round())}/s';

class _Preset {
  const _Preset({
    required this.name,
    required this.description,
    required this.url,
    required this.fileName,
  });

  final String name;
  final String description;
  final Uri url;
  final String fileName;
}
9
likes
0
points
769
downloads

Publisher

verified publishershakyapurna.com.np

Weekly Downloads

Reliable Flutter downloads with multipart ranges, durable resume, scheduling, integrity checks, and authenticated restoration.

Repository (GitHub)
View/report issues

Topics

#downloader #resumable-download #dio #flutter #concurrency

License

unknown (license)

Dependencies

collection, crypto, dio, flutter, path_provider

More

Packages that depend on resumable_downloader