Week 11: Camera, Media & the File System

This week is where the app starts touching the device directly — a real camera feed, the photo library, and a file system that outlives any single screen. Three packages cover almost every media need a Flutter app has.

Phase 6 of 8 Week 11 of 20 ~4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Request camera permission and capture a photo with the camera package
  • Let a user pick an existing photo with image_picker
  • Read, write and locate files with path_provider and dart:io
  • Upload a captured file to a backend as multipart form data

1. Capturing Photos

terminal
flutter pub add camera permission_handler
requesting permission, then initializing the camera
final status = await Permission.camera.request();
if (!status.isGranted) {
  // show a rationale and stop here
  return;
}

final cameras = await availableCameras();
final controller = CameraController(cameras.first, ResolutionPreset.medium);
await controller.initialize();
taking a picture
final XFile photo = await controller.takePicture();
print(photo.path); // a local file path

CameraController holds real native resources — it must be disposed in the widget's dispose() method, or the camera hardware can be left locked even after the screen is gone.

2. Picking Existing Media

terminal
flutter pub add image_picker
picking a photo from the library
final picker = ImagePicker();
final XFile? image = await picker.pickImage(source: ImageSource.gallery);

if (image != null) {
  // same XFile shape as the camera capture above — same downstream handling
  print(image.path);
}

Both the camera capture and the gallery pick resolve to the same XFile type — a screen that accepts "a photo from anywhere" can offer both a "Take Photo" and "Choose from Gallery" option feeding identical downstream code, the same pattern this site's React Native course uses for the equivalent Expo APIs.

3. Files with path_provider

terminal
flutter pub add path_provider
saving a captured photo permanently
import 'package:path_provider/path_provider.dart';
import 'dart:io';

Future<String> saveToAppStorage(String tempPath) async {
  final appDir = await getApplicationDocumentsDirectory();
  final filename = tempPath.split('/').last;
  final destination = '${appDir.path}/$filename';

  await File(tempPath).copy(destination);
  return destination; // safe to store this path long-term
}

Like a camera-captured file's original path, getTemporaryDirectory()'s contents can be cleared by the OS at any time — copying into getApplicationDocumentsDirectory() is what makes a file genuinely persistent for the app's own lifetime.

4. Uploading Media

uploading a photo as multipart form data
Future<void> uploadPhoto(String filePath) async {
  final dio = Dio();
  final formData = FormData.fromMap({
    'photo': await MultipartFile.fromFile(filePath, filename: 'photo.jpg'),
  });

  final response = await dio.post('https://api.example.com/upload', data: formData);
  if (response.statusCode != 200) {
    throw Exception('Upload failed');
  }
}

dio's FormData/MultipartFile handle the multipart encoding automatically — the same wire format a browser's <input type="file"> would send, which is what almost every REST backend's file-upload endpoint expects.

5. Hands-on Exercise

Hands-on

Build a photo journal screen

Capture or pick a photo, keep it permanently, and upload it to a mock endpoint.

Requirements:

  1. A screen with two options — "Take Photo" (camera) and "Choose from Gallery" (image_picker) — both requesting the right permission first.
  2. After either action, copy the resulting file into the app's documents directory and add it to an in-memory list.
  3. Render saved photos in a grid using their permanent paths, not the original temp/cache paths.
  4. An "Upload" button per photo sending it as multipart form data to a mock endpoint (e.g. httpbin.org/post), showing a success/failure state.
  5. Properly dispose the CameraController when the camera screen is popped, confirmed by capturing a photo, backing out, and reopening the camera screen without errors.
Hint

If reopening the camera screen throws an error about the camera already being in use, that's almost always a missed controller.dispose() — every CameraController that gets initialized needs a matching disposal in the owning widget's dispose() method, or the native camera resource stays locked from the previous screen.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why must a CameraController be explicitly disposed, where most Dart objects don't need this?

It holds real native camera hardware resources, not just Dart memory the garbage collector can reclaim — leaving it undisposed can keep the physical camera locked even after the widget using it is gone, which is why dispose() has to explicitly release it as part of the owning widget's own dispose() lifecycle method.

Q2

Why can the same downstream code (save, display, upload) handle both a camera capture and a gallery pick without any branching on which source was used?

Both camera's takePicture() and image_picker's pickImage() resolve to the same XFile type with the same .path property — code written against "an XFile" doesn't need to know or care which package or source produced it.

Q3

Why isn't a camera-captured photo's original file path safe to store long-term, and what fixes that?

The original path typically lives in a temporary or cache directory the OS can clear under storage pressure, independent of the app's own lifecycle. Copying the file into getApplicationDocumentsDirectory() moves it into storage the app fully owns and that persists for the app's own lifetime, which is what makes the resulting path safe to keep around.

Q4

What does dio's FormData/MultipartFile handle automatically that you'd otherwise have to implement by hand?

The multipart/form-data wire encoding itself — correctly formatting the request body with boundaries, content-type headers per part, and the binary file data — which is the same format a browser file upload uses and what most REST upload endpoints expect. Building that encoding manually is fiddly and easy to get subtly wrong; dio's helpers handle it correctly by default.