Mobile Development7 min read
Flutter Camera: Capture Photos Like a Pro
Build a custom camera in Flutter with the camera plugin: runtime permissions, CameraPreview, a lifecycle-safe controller, capture, and saving photos on device.
- #flutter
- #camera
- #plugins
- #android
- #ios

Every app eventually needs a camera. Profile photos, KYC document scans, receipt capture, inspection checklists, social posts: sooner or later the default image picker is not enough, and you need a camera view that lives inside your app, wrapped in your own UI. That is the moment the system camera UI becomes a liability and you reach for a real camera integration.
The camera package is the official Flutter plugin for exactly this. It gives you a live preview, capture control, flash and zoom, and image streams for on-device machine learning. But it is also one of the easiest packages to misuse: the controller holds a real hardware resource, both platforms demand declarations and runtime permission, and the photo it hands you lives in a temporary directory that the operating system may clean. This guide walks the full path: setup on Android and iOS, asking permission in context, a lifecycle-safe controller, capture, and saving the photo permanently, with the errors you will hit along the way and how to fix them.
camera vs image_picker: which one to use
If you only need a photo and nothing else, use image_picker: one call, the system camera UI, done. Reach for the camera package when you need a live preview inside your own screen, a custom shutter UI with overlays, flash and zoom control, burst capture, or continuous image streams for barcode scanning or ML. The rest of this guide assumes you made that choice.
Step 1 — Add the packages
flutter pub add camera path_provider permission_handler
The camera plugin does not request runtime permission itself on Android, which is why permission_handler is in the list. path_provider gives you a permanent home for the captured photo.
Step 2 — Configure Android and iOS
On Android, declare the camera permission in android/app/src/main/AndroidManifest.xml. Save photos to your app-private directory and you do not need any storage permission on modern Android, thanks to scoped storage. Also confirm minSdkVersion is 21 or higher; the camera plugin requires it.
<uses-permission android:name="android.permission.CAMERA" />
On iOS, add NSCameraUsageDescription to ios/Runner/Info.plist. Without it, iOS kills your app the moment you touch the camera. Add NSMicrophoneUsageDescription only if you record video with audio; this guide passes enableAudio: false, so the microphone key is unnecessary.
<key>NSCameraUsageDescription</key>
<string>This app needs camera access to take photos.</string>
Step 3 — Ask for permission in context
Request permission when the user opens the camera screen, not at first launch. The runtime permission pattern is the same one Android 13 expects for notifications: in context, with an explanation first. If the status is permanently denied, explain the situation and offer to open app settings.
import 'package:permission_handler/permission_handler.dart';
Future<bool> ensureCameraPermission() async {
var status = await Permission.camera.status;
if (status.isGranted) return true;
status = await Permission.camera.request();
if (status.isGranted) return true;
if (status.isPermanentlyDenied) {
// Show a dialog explaining why, with a button that calls:
await openAppSettings();
}
return false;
}
Step 4 — Discover cameras and create the controller
availableCameras() returns every camera on the device. Pick the back one when it exists, and pass the selected description into the camera screen route. The go_router guide shows the pattern for passing parameters between routes.
import 'package:camera/camera.dart';
Future<CameraDescription> pickBackCamera() async {
final cameras = await availableCameras();
if (cameras.isEmpty) {
throw StateError('No cameras found on this device.');
}
return cameras.firstWhere(
(c) => c.lensDirection == CameraLensDirection.back,
orElse: () => cameras.first,
);
}
Create the controller in initState and wrap the preview in a FutureBuilder, so CameraPreview only builds after initialization completes.
class CameraScreen extends StatefulWidget {
const CameraScreen({super.key});
@override
State<CameraScreen> createState() => _CameraScreenState();
}
class _CameraScreenState extends State<CameraScreen> {
CameraController? _controller;
Future<void>? _initializeFuture;
@override
void initState() {
super.initState();
_initCamera();
}
Future<void> _initCamera() async {
if (!await ensureCameraPermission()) return; // permission declined
final camera = await pickBackCamera();
final controller = CameraController(
camera,
ResolutionPreset.high,
enableAudio: false,
);
_initializeFuture = controller.initialize();
setState(() => _controller = controller);
}
@override
void dispose() {
_controller?.dispose();
super.dispose();
}
}
The preview itself goes in build, behind the initialization future:
FutureBuilder<void>(
future: _initializeFuture,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done ||
_controller == null) {
return const Center(child: CircularProgressIndicator());
}
return CameraPreview(_controller!);
},
)
Step 5 — Make the controller lifecycle-safe
The controller holds a hardware resource. When the app goes to the background, dispose it; when the app returns, create it again. Without this, switching apps and coming back leaves a black preview or a crash.
class _CameraScreenState extends State<CameraScreen>
with WidgetsBindingObserver {
// ... the controller fields from the step above
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_initCamera();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
final controller = _controller;
if (controller == null || !controller.value.isInitialized) return;
if (state == AppLifecycleState.inactive) {
controller.dispose();
_controller = null;
} else if (state == AppLifecycleState.resumed) {
_initCamera();
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_controller?.dispose();
super.dispose();
}
}
Step 6 — Capture and save the photo
takePicture() returns an XFile, but the file sits in a temporary directory that the OS may clean. Copy it to the app documents directory immediately, under a unique name.
import 'dart:io';
import 'package:path_provider/path_provider.dart';
bool _isCapturing = false;
Future<void> _takePicture() async {
final controller = _controller;
if (controller == null || _isCapturing) return;
setState(() => _isCapturing = true);
try {
await _initializeFuture;
final XFile photo = await controller.takePicture();
final dir = await getApplicationDocumentsDirectory();
final name =
'photo_${DateTime.now().millisecondsSinceEpoch}.jpg';
final saved = File('${dir.path}/$name');
await photo.saveTo(saved.path);
// saved is now a permanent file: show it, upload it, or return its path.
} on CameraException catch (e) {
debugPrint('Camera error ${e.code}: ${e.description}');
} finally {
if (mounted) setState(() => _isCapturing = false);
}
}
Note the _isCapturing guard: a capture takes roughly a second on many Android devices, and calling takePicture() twice in a row throws because a capture is already in progress. Debounce the shutter button, show a capturing indicator, and disable it meanwhile.
Step 7 — Flash, zoom, and focus
Two one-line calls cover the most common extras. Guard zoom with getMaxZoomLevel(), and remember some front cameras have no flash.
await controller.setFlashMode(FlashMode.auto);
await controller.setZoomLevel(2.0);
await controller.setFocusPoint(const Offset(0.5, 0.5));
These tiny APIs are the reason many teams choose this package over image_picker: a document scanner needs torch mode, a product photo flow needs tap-to-focus, and neither exists in the system picker.
Error handling you should not skip
CameraException carries code and description; common codes include cameraNotFound and cameraAccessDenied, the latter firing when permission is revoked mid-session, which means you ask again rather than crash. availableCameras() can return an empty list on emulators and some desktop builds, so handle that case instead of indexing blindly. And emulator camera testing is unreliable in general: verify capture, flash, and the lifecycle round-trip on a physical device.
Troubleshooting
| Symptom | Most likely cause | Fix |
|---|---|---|
| App crashes on first camera open on iOS | Missing NSCameraUsageDescription | Add the key to Info.plist |
| Black preview after returning from background | Controller disposed, never re-created | Re-run initialization on resumed |
takePicture throws "capture in progress" | Shutter pressed twice | Debounce with an _isCapturing guard |
| Photo file is gone later | Used the temporary XFile path directly | Copy with saveTo to the app documents directory immediately |
| No cameras found on emulator | Emulator has no camera device | Test on a physical device |
| Permission dialog never appears on Android | CAMERA permission missing from manifest | Declare it in AndroidManifest.xml |
| Capture button does nothing on first tap | Controller not initialized yet | Await the initialization future before capturing |
Pre-launch checklist
Before shipping, confirm the chain end to end on a real device: the permission prompt appears in context, the denied and permanently-denied paths are handled, the preview initializes, the background-to-foreground round-trip restores the preview, rapid shutter presses cannot double-capture, photos persist after an app restart, and flash and zoom degrade gracefully on devices without them. Once the photo is saved locally, getting it to your backend is a short step: the companion guide Flutter Firebase Storage: Upload Images & Files picks up exactly there.
Get in touch and let's talk about your project.
Frequently asked questions
- When should I use the camera package instead of image_picker?
- Use image_picker when you only need a photo and are happy with the system camera UI. Use the camera package when you need a live preview in your own screen, custom capture UI, flash and zoom control, or continuous image streams.
- Why does my Flutter app crash when opening the camera on iOS?
- The app is missing NSCameraUsageDescription in Info.plist. iOS terminates any app that accesses the camera without this key, so add it before the first camera access.
- Why is the camera preview black after returning from the background?
- The controller was disposed when the app went inactive and never re-created. Re-run your initialization in didChangeAppLifecycleState when the state becomes resumed.
- Where does takePicture save the photo?
- Nowhere permanent: it returns an XFile in a temporary directory the operating system may clean. Copy it immediately with saveTo into the app documents directory from path_provider.


