File & Image Uploads with Multipart Requests
File & Image Uploads with Multipart Requests
Most production apps need to let users upload profile photos, documents, or media to a server. HTTP multipart form-data is the standard encoding for binary uploads because it packages one or more files alongside metadata fields in a single request body. In Flutter, the Dio package makes this straightforward with its FormData and MultipartFile APIs, and the image_picker plugin handles the OS-level file-selection dialog.
Dependencies
Add the following to pubspec.yaml:
dio: ^5.4.0— HTTP client with multipart support and progress callbacksimage_picker: ^1.0.7— Camera and gallery access on iOS and Android
On iOS, add the following keys to Info.plist:
NSPhotoLibraryUsageDescription— explains gallery accessNSCameraUsageDescription— explains camera access
On Android (API 33+), READ_MEDIA_IMAGES is declared by the plugin automatically.
Picking an Image with image_picker
ImagePicker.pickImage() returns an XFile? — a cross-platform file abstraction that exposes a path string on mobile. You choose the source (gallery or camera) and an optional imageQuality (0–100) to reduce upload size before the file even leaves the device.
Picking a File from the Gallery
import 'package:image_picker/image_picker.dart';
final ImagePicker _picker = ImagePicker();
Future<XFile?> pickImage() async {
final XFile? file = await _picker.pickImage(
source: ImageSource.gallery,
imageQuality: 80, // compress to 80 % before uploading
maxWidth: 1280,
maxHeight: 1280,
);
return file;
}
Building a Multipart Request with Dio
Once you have the file path, wrap it in a MultipartFile and attach it to a FormData object. FormData accepts both plain string fields (captions, user IDs, etc.) and file entries side by side.
Uploading with FormData and onSendProgress
import 'package:dio/dio.dart';
final Dio _dio = Dio();
Future<void> uploadProfilePhoto({
required String filePath,
required String userId,
required void Function(int sent, int total) onProgress,
}) async {
final formData = FormData.fromMap({
'user_id': userId,
'avatar': await MultipartFile.fromFile(
filePath,
filename: 'avatar.jpg', // server-visible filename
contentType: DioMediaType('image', 'jpeg'),
),
});
final response = await _dio.post(
'https://api.example.com/upload/avatar',
data: formData,
onSendProgress: (int sent, int total) {
// total may be -1 if Content-Length is unknown
if (total != -1) {
onProgress(sent, total);
}
},
options: Options(
headers: {'Authorization': 'Bearer \$_token'},
receiveTimeout: const Duration(seconds: 60),
sendTimeout: const Duration(seconds: 120),
),
);
if (response.statusCode != 200) {
throw Exception('Upload failed: \${response.statusCode}');
}
}
MultipartFile.fromFile() is asynchronous because it opens the file descriptor. Always await it inside the FormData.fromMap() call. Forgetting the await is a common source of empty uploads.Tracking Progress in the UI
The onSendProgress callback fires repeatedly as chunks are sent. Store the ratio sent / total in a double state variable and feed it to a LinearProgressIndicator. Always call setState() inside the callback so the widget tree rebuilds with each update.
Progress Indicator Widget
class UploadScreen extends StatefulWidget {
const UploadScreen({super.key});
@override
State<UploadScreen> createState() => _UploadScreenState();
}
class _UploadScreenState extends State<UploadScreen> {
XFile? _pickedFile;
double _uploadProgress = 0.0; // 0.0 to 1.0
bool _isUploading = false;
String? _errorMessage;
Future<void> _pickAndUpload() async {
final file = await pickImage();
if (file == null) return;
setState(() {
_pickedFile = file;
_isUploading = true;
_uploadProgress = 0.0;
_errorMessage = null;
});
try {
await uploadProfilePhoto(
filePath: file.path,
userId: 'user_42',
onProgress: (sent, total) {
setState(() {
_uploadProgress = sent / total;
});
},
);
setState(() => _isUploading = false);
} catch (e) {
setState(() {
_isUploading = false;
_errorMessage = e.toString();
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Upload Photo')),
body: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (_pickedFile != null)
Image.network(_pickedFile!.path, height: 200),
const SizedBox(height: 16),
if (_isUploading) ...[
LinearProgressIndicator(value: _uploadProgress),
const SizedBox(height: 8),
Text('${(_uploadProgress * 100).toStringAsFixed(1)} %'),
],
if (_errorMessage != null)
Text(_errorMessage!, style: const TextStyle(color: Colors.red)),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: _isUploading ? null : _pickAndUpload,
icon: const Icon(Icons.upload_file),
label: const Text('Pick & Upload'),
),
],
),
),
);
}
}
onPressed: null) while an upload is in progress to prevent double-submission. Re-enable it in both the success and error branches of your try/catch.sendTimeout and receiveTimeout in Dio Options, and always wrap the call in a try/catch that handles DioException.Uploading Multiple Files
Pass a List<MultipartFile> under the same key to upload a batch in one request. The server receives them as a multi-value field array:
- Use
MultipartFile.fromFileSync()for synchronous construction when all paths are already resolved. - Name each file descriptively (
photo_0.jpg,photo_1.jpg, …) so the server can distinguish them in the multipart boundary.
Summary
To upload files in Flutter: (1) add image_picker and dio to your project; (2) call ImagePicker.pickImage() to obtain an XFile; (3) wrap the file in MultipartFile.fromFile() and attach it to FormData; (4) pass the onSendProgress callback to Dio.post(); and (5) update a LinearProgressIndicator in the UI by calling setState() inside the callback. Proper timeout configuration and error handling are non-negotiable in production.