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_providerStep 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:
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.
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,totalis-1. Show an indeterminateLinearProgressIndicator(value: null)instead of a percentage. - Cancelling: pass a
CancelTokenand callcancel(). Dio throws aDioExceptionthatCancelToken.isCancelrecognises, 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
DioExceptionand 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.

