A camera button should not crash, stall, or repeatedly nag someone who has already refused access. In this tutorial, you will build a permission gate that checks the current camera status, requests access at the point of use, and presents a settings route when the operating system will no longer show a prompt.
The implementation distinguishes granted, denied, permanently denied, and restricted states. It also covers requesting camera and microphone access together, Android manifest declarations, iOS usage strings, and the Podfile macros required by permission_handler.
Add the package with flutter pub add permission_handler, then rebuild the native application rather than relying on hot reload. Permission behaviour must be checked on a device or emulator with resettable app permissions; a desktop or web run does not exercise the same Android and iOS flows.

The Complete Flutter Guide: Build Android, iOS and Web apps
Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.
Enrol nowDeclare camera access before writing the Flutter gate
permission_handler exposes a common Dart API, but it cannot add native entitlements or usage descriptions for you. On Android, place the camera permission directly inside the root <manifest> element of android/app/src/main/AndroidManifest.xml:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA" />
<application
android:label="permission_example"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
</manifest>
Declaring CAMERA does not grant it. Android still presents a runtime prompt when request() is called. If recording video with sound is part of the same feature, also declare android.permission.RECORD_AUDIO; do not request microphone access merely because a camera preview is visible.
Give iOS a precise reason and enable its native handler
iOS terminates an app that attempts to access protected data without the corresponding usage-description key. Add a sentence to ios/Runner/Info.plist that describes the feature rather than using a vague phrase such as “Camera required”:
<key>NSCameraUsageDescription</key>
<string>Take a profile photograph to display on your account.</string>
The permission_handler package also uses preprocessor macros to include selected iOS permission implementations. In ios/Podfile, retain the existing Flutter post-install call and add PERMISSION_CAMERA=1 to each build configuration:
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
target.build_configurations.each do |config|
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [
'$(inherited)',
'PERMISSION_CAMERA=1',
]
end
end
end
After changing the Podfile, run flutter clean, fetch packages, and rebuild the iOS app. If the feature will request Permission.microphone too, add NSMicrophoneUsageDescription and PERMISSION_MICROPHONE=1. A macro without a matching usage string can still leave the native configuration invalid.
Check Permission.camera.status before prompting
A status check is useful when deciding which interface to display. It does not show the operating-system prompt, so the app can reveal a short explanation before asking. The following complete app checks on launch but requests only after the user presses the button:
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
void main() {
runApp(const MaterialApp(home: CameraPermissionPage()));
}
class CameraPermissionPage extends StatefulWidget {
const CameraPermissionPage({super.key});
@override
State<CameraPermissionPage> createState() =>
_CameraPermissionPageState();
}
class _CameraPermissionPageState extends State<CameraPermissionPage>
with WidgetsBindingObserver {
PermissionStatus? _status;
bool _checking = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_refreshStatus();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_refreshStatus();
}
}
Future<void> _refreshStatus() async {
setState(() => _checking = true);
final status = await Permission.camera.status;
if (!mounted) return;
setState(() {
_status = status;
_checking = false;
});
}
Future<void> _requestCamera() async {
setState(() => _checking = true);
final status = await Permission.camera.request();
if (!mounted) return;
setState(() {
_status = status;
_checking = false;
});
}
Future<void> _openSettings() async {
final opened = await openAppSettings();
if (!opened && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not open app settings.')),
);
}
}
@override
Widget build(BuildContext context) {
final status = _status;
return Scaffold(
appBar: AppBar(title: const Text('Camera access')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Center(
child: _checking || status == null
? const CircularProgressIndicator()
: PermissionPanel(
status: status,
onRequest: _requestCamera,
onOpenSettings: _openSettings,
),
),
),
);
}
}
class PermissionPanel extends StatelessWidget {
const PermissionPanel({
required this.status,
required this.onRequest,
required this.onOpenSettings,
super.key,
});
final PermissionStatus status;
final VoidCallback onRequest;
final VoidCallback onOpenSettings;
@override
Widget build(BuildContext context) {
if (status.isGranted) {
return const Text('Camera access is ready.');
}
if (status.isPermanentlyDenied) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Camera access is blocked. Enable it in the app settings.',
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
FilledButton(
onPressed: onOpenSettings,
child: const Text('Open settings'),
),
],
);
}
if (status.isRestricted) {
return const Text(
'Camera access is restricted by this device or account.',
textAlign: TextAlign.center,
);
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Allow camera access to take your profile photograph.',
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
FilledButton(
onPressed: onRequest,
child: const Text('Allow camera'),
),
],
);
}
}
The asynchronous methods check mounted because the page may be removed while the native prompt or settings app is visible. The loading flag also prevents several overlapping requests from rapid taps.

The Complete Flutter Guide: Build Android, iOS and Web apps
Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.
Enrol nowTreat PermissionStatus as a state machine
PermissionStatus communicates more than a yes-or-no result. Each relevant state needs a deliberate product response:
- granted: the protected feature may proceed. Check again when the feature is opened later because access can be revoked in system settings.
- denied: access is unavailable now, but a request may still be possible. Keep the feature usable enough to explain its value and offer another appropriate action.
- permanentlyDenied: the operating system will not display another permission prompt for this app. Present a settings button instead of calling
request()repeatedly. - restricted: policy controls prevent the user from granting access, commonly through iOS parental or device-management restrictions. Opening settings may not let the user change it, so explain the limitation without promising a fix.
The package also defines statuses for permission types with platform-specific behaviour, including limited and provisional access. Do not collapse every unknown status into “denied” if you later adapt the gate for photos or notifications.
Handle permanently denied access without trapping the user
A permanent denial is not an exceptional crash condition. Keep the rest of the app available, explain exactly where the permission is used, and make opening settings an explicit user choice. openAppSettings() returns a Future<bool>; the Boolean reports whether the package could launch the settings page, not whether the user enabled anything.
The lifecycle observer in the example re-runs Permission.camera.status when the app resumes. That matters because control returns to Flutter after the user visits settings, and the original status object does not update itself. Never assume access was granted simply because settings opened successfully.
A permanently denied result can differ across operating-system versions and user journeys. Branch on the result returned by request() rather than maintaining your own denial counter.
Request camera and microphone together for video capture
When one user action genuinely requires several capabilities, permission_handler accepts a list and returns a map keyed by each requested permission. The permissions may produce different outcomes, so checking only one map entry creates a partial-authorisation bug:
import 'package:permission_handler/permission_handler.dart';
Future<bool> requestVideoCapturePermissions() async {
final statuses = await [
Permission.camera,
Permission.microphone,
].request();
final cameraGranted =
statuses[Permission.camera] == PermissionStatus.granted;
final microphoneGranted =
statuses[Permission.microphone] == PermissionStatus.granted;
return cameraGranted && microphoneGranted;
}
Before using this function, declare both permissions on Android and configure both iOS usage strings and Podfile macros. If silent video is a valid fallback, evaluate the entries separately and let the user continue with camera access alone. Avoid requesting unrelated permissions in one launch-time bundle: people make better decisions when the request follows a clear feature action.
Separate permission approval from resource availability
A granted permission does not prove that a camera exists, is functioning, or is free to use. The camera package can still report no available cameras, an initialisation failure, or a device-level error. Permission handling should therefore guard entry to the feature, while camera discovery and capture code retain their own error paths.
The reverse distinction matters as well: declaring camera hardware as required can exclude devices from an Android store listing. If the wider app remains useful without a camera, describe hardware as optional in the manifest and disable the capture control when discovery returns no cameras.
<uses-feature
android:name="android.hardware.camera.any"
android:required="false" />
This declaration concerns hardware filtering; it does not replace android.permission.CAMERA.
Avoid the permission loops that frustrate users
Several common mistakes make a technically functional request flow unreliable:
- Requesting every permission during startup, before the person has seen the feature or its explanation.
- Calling
request()again afterisPermanentlyDeniedinstead of offeringopenAppSettings(). - Adding Dart code but omitting
AndroidManifest.xml,Info.plist, or the relevant iOS Podfile macro. - Continuing into camera code for any status other than granted.
- Assuming a previously granted status remains valid for the lifetime of the installation.
- Using one generic explanation for camera, microphone, photos, and location access.
- Ignoring the Boolean returned by
openAppSettings()or treating it as proof of authorisation.
Keep permission requests close to the action they enable. That makes the operating-system prompt understandable and gives a denial a clear, limited consequence.
Verify every branch, not only the first prompt
Test on both supported platforms because simulator behaviour and status transitions are not identical. Start with a clean installation, confirm that the explanatory screen appears before the system prompt, then grant access and verify that the camera feature opens. Reset the app permission, deny it, and confirm that the app remains stable.
Next, reach the permanently denied path where the platform supports it. Check that the button opens the app’s settings, change the permission there, return to the app, and confirm that the resumed lifecycle refreshes the screen. On iOS, also test a restricted device or managed test configuration when that audience is relevant.
Finally, inspect a release build. Native permission configuration is compiled into the application, so a debug session that was not fully rebuilt can conceal Podfile or manifest mistakes. The expected result is a feature that proceeds only for granted, offers settings for permanentlyDenied, explains restricted, and leaves a recoverable route after an ordinary denial.
Further reads
Keep going with these related tutorials from this site.
- Flutter: The Complete Guide — follow the full Flutter learning path from widgets to production apps
- Flutter Material Widgets Catalogue — build the buttons, progress indicators, and messages around a permission request
- Dart Language Guide for Flutter — strengthen the async, null-safety, and collection syntax used by permission flows
- Flutter Interview Questions and Answers — review platform channels, lifecycle handling, and other practical Flutter concepts