Networking & REST API Integration

File & Image Uploads with Multipart Requests

16 min Lesson 10 of 13

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 callbacks
  • image_picker: ^1.0.7 — Camera and gallery access on iOS and Android

On iOS, add the following keys to Info.plist:

  • NSPhotoLibraryUsageDescription — explains gallery access
  • NSCameraUsageDescription — 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}');
  }
}
Note: 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'),
            ),
          ],
        ),
      ),
    );
  }
}
Tip: Disable the upload button (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.
Warning: Never upload files on the main isolate without at least a send timeout. A stalled upload will freeze the UI indefinitely. Set both 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.