1. Capturing Photos
flutter pub add camera permission_handler
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();
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
flutter pub add image_picker
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
flutter pub add path_provider
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
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
Build a photo journal screen
Capture or pick a photo, keep it permanently, and upload it to a mock endpoint.
Requirements:
- A screen with two options — "Take Photo" (camera) and "Choose from Gallery" (image_picker) — both requesting the right permission first.
- After either action, copy the resulting file into the app's documents directory and add it to an in-memory list.
- Render saved photos in a grid using their permanent paths, not the original temp/cache paths.
- 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.
- Properly dispose the
CameraControllerwhen the camera screen is popped, confirmed by capturing a photo, backing out, and reopening the camera screen without errors.
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?
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?
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?
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?
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.