How to Download Any File from a URL in Flutter (with Progress)

Download PDFs, images, and other files from a URL in Flutter using Dio and path_provider, with a live progress bar and proper error handling.

T

Teammity Team

· 2 min read

Downloading a file sounds simple until you need a progress bar, a safe place to save it, and graceful handling when the network drops. In this tutorial we build a complete file download in Flutter that does all three.

What we will use

  • dio: an HTTP client with built-in download progress callbacks.
  • path_provider: finds the correct folder to save files on each platform.

Add both to your project:

flutter pub add dio path_provider

Step 1: Choose where to save the file

Each platform has its own rules about where apps may write files. path_provider hides those differences:

import 'package:path_provider/path_provider.dart';
 
Future<String> savePathFor(String fileName) async {
  final dir = await getApplicationDocumentsDirectory();
  return '${dir.path}/$fileName';
}

Step 2: Download with progress

Dio's download method streams the file straight to disk and reports progress as bytes arrive:

lib/services/file_downloader.dart
import 'package:dio/dio.dart';
 
class FileDownloader {
  final Dio _dio = Dio();
 
  Future<String> download({
    required String url,
    required String savePath,
    required void Function(double progress) onProgress,
    CancelToken? cancelToken,
  }) async {
    await _dio.download(
      url,
      savePath,
      cancelToken: cancelToken,
      onReceiveProgress: (received, total) {
        // total is -1 when the server does not send a Content-Length header.
        if (total > 0) onProgress(received / total);
      },
    );
    return savePath;
  }
}

Step 3: Show progress in the UI

A small StatefulWidget ties it together: a button to start, a progress bar while downloading, and a message when done.

lib/screens/download_screen.dart
class DownloadScreen extends StatefulWidget {
  const DownloadScreen({super.key});
 
  @override
  State<DownloadScreen> createState() => _DownloadScreenState();
}
 
class _DownloadScreenState extends State<DownloadScreen> {
  final _downloader = FileDownloader();
  CancelToken? _cancelToken;
  double? _progress;
  String? _message;
 
  Future<void> _startDownload() async {
    const url = 'https://example.com/sample.pdf';
    _cancelToken = CancelToken();
    setState(() {
      _progress = 0;
      _message = null;
    });
 
    try {
      final path = await _downloader.download(
        url: url,
        savePath: await savePathFor('sample.pdf'),
        cancelToken: _cancelToken,
        onProgress: (p) => setState(() => _progress = p),
      );
      setState(() => _message = 'Saved to $path');
    } on DioException catch (e) {
      setState(() => _message = CancelToken.isCancel(e)
          ? 'Download cancelled'
          : 'Download failed: ${e.message}');
    } finally {
      setState(() => _progress = null);
    }
  }
 
  @override
  void dispose() {
    _cancelToken?.cancel();
    super.dispose();
  }
 
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Download a file')),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          children: [
            if (_progress != null) ...[
              LinearProgressIndicator(value: _progress),
              const SizedBox(height: 8),
              Text('${(_progress! * 100).toStringAsFixed(0)}%'),
              TextButton(onPressed: () => _cancelToken?.cancel(), child: const Text('Cancel')),
            ] else
              ElevatedButton(onPressed: _startDownload, child: const Text('Download')),
            if (_message != null) Padding(
              padding: const EdgeInsets.only(top: 16),
              child: Text(_message!),
            ),
          ],
        ),
      ),
    );
  }
}

Handling the edge cases

  • Unknown file size: if the server does not send Content-Length, total is -1. Show an indeterminate LinearProgressIndicator(value: null) instead of a percentage.
  • Cancelling: pass a CancelToken and call cancel(). Dio throws a DioException that CancelToken.isCancel recognises, so you can show "cancelled" rather than an error.
  • Leaving the screen: cancel in dispose() so a download does not keep running for a screen that no longer exists.
  • Bad URLs and offline devices: catch DioException and show a friendly message. Never let the exception crash the app.

Wrapping up

With dio and path_provider you get reliable downloads, accurate progress, and cancellation in under 100 lines. Watch the video at the top of this post to see it running live, and browse more Flutter tutorials.

Keep reading