A download is not complete because an HTTP request reached 100 percent. The file must survive an app restart, remain connected to useful metadata, handle interruption, and be deleted cleanly.
My early download code wrote bytes to a file and returned the path. That left difficult questions unanswered:
- What if the app closes halfway through?
- How does the downloads screen know what the file represents?
- What happens when the database says downloaded but the file is missing?
- Can two taps download the same item twice?
Reliable offline support needs a small state machine and two durable stores: the file system and metadata storage.
Model the lifecycle#
enum DownloadStatus {
queued,
downloading,
completed,
failed,
}
class DownloadRecord {
const DownloadRecord({
required this.contentId,
required this.status,
required this.progress,
this.localPath,
this.errorMessage,
});
final String contentId;
final DownloadStatus status;
final double progress;
final String? localPath;
final String? errorMessage;
}
The UI can now render queued, active, completed, and retry states without guessing from a nullable path.
Store metadata separately#
A local database such as sqflite can store:
- stable content ID;
- title, artwork, and media type;
- local path;
- expected byte size;
- completion status;
- downloaded timestamp;
- content version if replacement is possible.
Do not store media bytes in preferences or the database. Store files in an application directory obtained with path_provider and persist only metadata.
Download into a temporary file#
Never expose a partial file as completed:
final finalPath = path.join(directory.path, fileName);
final temporaryPath = finalPath + '.download';
await dio.download(
remoteUrl,
temporaryPath,
onReceiveProgress: (received, total) {
if (total > 0) {
progress.add(received / total);
}
},
);
await File(temporaryPath).rename(finalPath);
Only after the rename succeeds should metadata become completed. Renaming within the same file system is usually atomic, which prevents a player from reading half a file.
On startup, remove abandoned temporary files or mark their records as failed.
Prevent duplicate jobs#
Track active downloads by content ID:
final active = <String, Future<DownloadRecord>>{};
Future<DownloadRecord> download(ContentItem item) {
return active.putIfAbsent(item.id, () async {
try {
return await performDownload(item);
} finally {
active.remove(item.id);
}
});
}
Two taps now share one operation. The UI can disable the button, but the repository still protects the invariant.
Reconcile files and records#
Storage can change outside your code. The operating system may clear files, a user may restore database data without media files, or an earlier version may have crashed between file and database writes.
At startup or when opening Downloads:
- Load completed records.
- Check that each file exists and is readable.
- Remove or repair invalid records.
- Find orphaned temporary files.
- Recalculate storage totals if needed.
Treat the database as metadata, not proof that the file exists.
Plan authentication and expiry#
Protected media URLs may expire. Resolve a fresh URL or token when the download begins rather than persisting a short-lived remote URL as the permanent source.
A completed local file should play without authentication. Its metadata still needs an access policy: logout may clear protected downloads, or the next session may need to revalidate entitlement.
Delete as one operation#
Delete both the file and record:
Future<void> deleteDownload(DownloadRecord record) async {
final localPath = record.localPath;
if (localPath != null) {
final file = File(localPath);
if (await file.exists()) {
await file.delete();
}
}
await database.delete(
'downloads',
where: 'content_id = ?',
whereArgs: [record.contentId],
);
}
Choose the failure policy deliberately. If file deletion fails, keeping the record may allow retry. If the file is already missing, removing stale metadata is reasonable.
Test interruption#
Cover:
- app restart during download;
- network loss and retry;
- insufficient storage;
- two requests for the same content;
- missing completed file;
- changed content version;
- logout cleanup;
- deletion during playback;
- local playback with no network.
Offline downloads are a consistency problem between network, file system, database, and UI. Once those responsibilities are explicit, progress indicators become the easy part.