smart_permission 1.1.0
smart_permission: ^1.1.0 copied to clipboard
Flutter runtime permissions made easy: one-line requests, adaptive dialogs, and full flows on Android & iOS.
smart_permission #
Flutter runtime permissions made easy.
One-line requests, adaptive dialogs, and the complete permission flow (from first ask to "Open Settings") built on permission_handler.
Contents #
- Why smart_permission?
- Platform support
- Installation
- Platform setup
- Quick start
- How the request flow works
- Usage
- PermissionGate
- Customization
- Analytics
- Error reporting
- Testing
- Example app
- Tips and notes
- Upgrading
Why smart_permission? #
Asking for a permission is easy; handling everything that can happen next is
not. The user may deny once, deny forever, be blocked by parental controls, or
change their mind in the system settings. smart_permission handles all of
it with one call, shows the right dialog at each step, and tells you exactly
how it ended.
| Feature | What you get |
|---|---|
| 🔐 One-line requests | SmartPermission.request() for one permission, requestMultiple() for many |
| 🧭 Complete flow | Denied, permanently denied, and restricted handled automatically |
| 🎯 Rich results | SmartPermissionResult tells you why a request didn't succeed |
| 🚪 PermissionGate | A widget that shows its content only while a permission is usable |
| 🔍 Silent checks | status() and hasPermission() never prompt the user |
| ⚙️ Settings round trip | Opens Settings, waits for the user to come back, then re-checks |
| 💬 Rationale first | Optionally explain before the one-shot native prompt |
| 🎨 Adaptive dialogs | Material, Cupertino, or adaptive; light/dark and your brand color |
| 🌍 Localization | Every built-in string, title, and description can be replaced |
| 🧩 Custom dialogs | Swap in your own dialog or bottom sheet |
| 📊 Analytics hooks | requested / granted / denied / permanently denied / restricted |
| 🧪 Testable | Ships FakeSmartPermissionGateway for widget tests without platform channels |
Permission and PermissionStatus are re-exported, so one import is enough.
Platform support #
| Android | iOS | Web (incl. WASM) | Windows | macOS | Linux |
|---|---|---|---|---|---|
| ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
macOS and Linux are not supported because the underlying
permission_handler plugin has
no implementation for them.
Installation #
dependencies:
smart_permission: ^1.1.0
import 'package:smart_permission/smart_permission.dart';
Requirements: Flutter 3.27+ (Dart 3.6+).
Platform setup #
The operating system only grants permissions that your app declares. Skipping this step is the most common reason a request is denied silently.
Android #
-
Declare each permission in
android/app/src/main/AndroidManifest.xml:<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <!-- ... --> </manifest> -
Build with compileSdk 37 (required by
permission_handler13). Inandroid/app/build.gradle(.kts):android { compileSdk = 37 // ... }
iOS #
-
Add a usage description to
ios/Runner/Info.plistfor each permission. iOS shows this text in its own prompt:<key>NSCameraUsageDescription</key> <string>We need the camera to scan QR codes.</string> <key>NSMicrophoneUsageDescription</key> <string>We need the microphone for voice notes.</string> <key>NSLocationWhenInUseUsageDescription</key> <string>We use your location to show nearby stores.</string> -
CocoaPods only: enable the same permissions in
ios/Podfile(permissions are compiled out by default):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', 'PERMISSION_MICROPHONE=1', 'PERMISSION_LOCATION=1', ] end end endWith Swift Package Manager this step is not needed: permissions are enabled from the
Info.plistkeys above.
See the
permission_handler setup guide
for the full list of manifest entries, Info.plist keys, and Podfile macros.
Quick start #
Request a permission: the right dialogs are shown automatically.
final granted = await SmartPermission.request(
context,
permission: Permission.camera,
description: 'We need the camera to scan QR codes.',
);
if (granted) openScanner();
Guard a screen: the content appears once the permission is usable.
PermissionGate(
permission: Permission.camera,
description: 'We need the camera to scan QR codes.',
builder: (context) => const QrScanner(),
)
Check without asking: no prompt, no dialog.
if (await SmartPermission.hasPermission(Permission.camera)) openScanner();
How the request flow works #
| Situation | What the user sees |
|---|---|
| Already allowed | Nothing; returns immediately |
| First time | The native system prompt (optionally after your explanation) |
| Denied | An explanation dialog offering to ask again |
| Permanently denied | An "Open Settings" dialog; waits for the user to return, then re-checks |
| Restricted | An informational dialog (e.g. parental controls; Settings can't help) |
Usage #
Know why a request failed #
requestResult runs the same flow as request but returns a
SmartPermissionResult instead of a bool:
final result = await SmartPermission.requestResult(
context: context,
permission: Permission.camera,
);
if (result.canProceed) {
openCamera();
} else if (result == SmartPermissionResult.permanentlyDenied) {
showSettingsShortcut(); // e.g. a button calling SmartPermission.openSettings()
} else if (result == SmartPermissionResult.restricted) {
hideCameraFeature(); // blocked by the device; nobody can change it in-app
} else {
// denied or error: you can ask again later
}
| Result | Meaning | canProceed |
|---|---|---|
granted |
Full access | ✅ |
limited |
Partial access, e.g. selected photos only | ✅ |
provisional |
iOS provisional notifications | ✅ |
denied |
Not granted this time; you may ask again | ❌ |
permanentlyDenied |
Only the system settings can change it | ❌ |
restricted |
Blocked by the OS (e.g. parental controls) | ❌ |
error |
The platform call failed (see Error reporting) | ❌ |
Request several permissions #
final results = await SmartPermission.requestMultiple(
context,
permissions: [Permission.camera, Permission.microphone],
);
// {Permission.camera: true, Permission.microphone: false}
The native prompts run as one batch, followed by at most one combined
explanation dialog and one combined settings dialog, never a dialog per
permission. Use requestMultipleResults to get a SmartPermissionResult
per permission.
Explain before the native prompt #
On iOS you can only show the native prompt once, so explaining first is recommended:
// For one call:
await SmartPermission.request(
context,
permission: Permission.camera,
showRationaleFirst: true,
);
// For every call:
SmartPermission.config.showRationaleBeforeRequest = true;
Request without a BuildContext #
Useful from services, view models, or blocs:
final navigatorKey = GlobalKey<NavigatorState>();
MaterialApp(navigatorKey: navigatorKey, ...);
SmartPermission.config.navigatorKey = navigatorKey;
// Anywhere:
final result = await SmartPermission.requestResult(
permission: Permission.camera,
);
Without a context or navigator key, only the native prompt is shown (no dialogs).
Check without asking #
These never prompt and never show a dialog:
final usable = await SmartPermission.hasPermission(Permission.camera);
final result = await SmartPermission.status(Permission.location);
final all = await SmartPermission.statuses([
Permission.camera,
Permission.microphone,
]);
// Open this app's settings page (returns false where unsupported, e.g. web).
await SmartPermission.openSettings();
Android: the OS reveals a permanent denial only in response to a request.
status()therefore reportspermanentlyDeniedonce a request in the current app session has found it, anddeniedbefore that.
PermissionGate #
PermissionGate shows its content only while a permission is usable
(granted, limited, or provisional). Otherwise it shows a fallback with a call
to action:
- Allow runs the full request flow.
- Open Settings appears when the permission is permanently denied.
- A restricted permission gets an explanation and no button.
The gate re-checks whenever the app returns to the foreground, so changes made in the system settings show up immediately.
PermissionGate(
permission: Permission.camera,
description: 'We need the camera to scan QR codes.',
builder: (context) => const QrScanner(),
)
| Parameter | Purpose |
|---|---|
builder |
The protected content (required) |
fallbackBuilder |
Your own "not allowed" UI: (context, result, request) => ... |
loadingBuilder |
Shown during the first check (default: nothing) |
requestOnMount |
Start the request flow as soon as the gate appears |
onChanged |
Called with the new SmartPermissionResult whenever it changes |
style |
Dialog style, also used for the default fallback's button |
title, description, showRationaleFirst |
Passed to the dialogs and the default fallback |
A custom fallback receives the current result and a request callback that
does the right thing (request, or open settings when permanently denied):
PermissionGate(
permission: Permission.microphone,
fallbackBuilder: (context, result, request) {
if (result == SmartPermissionResult.restricted) {
return const Text('Voice notes are disabled on this device.');
}
return TextButton(
onPressed: request,
child: const Text('Enable voice notes'),
);
},
builder: (context) => const VoiceRecorder(),
)
Customization #
Configure once at startup through SmartPermission.config, or change values
at runtime.
| Property | Purpose |
|---|---|
defaultDialogStyle |
adaptive (default), material, or cupertino |
brightness, primaryColor |
Theme of the built-in dialogs |
titleProvider |
Per-permission titles |
descriptionProvider |
Per-permission descriptions |
strings |
Every built-in string, for localization |
showRationaleBeforeRequest |
Explain before every native prompt |
navigatorKey |
Show dialogs without passing a BuildContext |
customDialogBuilder |
Replace the built-in dialogs entirely |
analytics |
Receive flow events |
onError |
Observe platform and dialog errors |
gateway |
Platform access; replace with a fake in tests |
SmartPermission.config.resetToDefaults() restores everything (handy in
tests).
Theme #
SmartPermission.config
..defaultDialogStyle = PermissionDialogStyle.adaptive
..brightness = Brightness.dark
..primaryColor = Colors.indigo;
Texts per permission #
Built-in titles and descriptions exist for every permission. Override the
ones you need; return null to keep the built-in text:
SmartPermission.config
..titleProvider = (p) {
if (p == Permission.camera) return 'Camera access needed';
return null;
}
..descriptionProvider = (p) {
if (p == Permission.camera) return 'We need the camera to scan QR codes.';
if (p == Permission.microphone) return 'We need the microphone for voice notes.';
return null;
};
A title or description passed to a call always wins over the providers.
Localization #
Replace any built-in string; the ones you leave out keep their English default:
SmartPermission.config.strings = const SmartPermissionStrings(
allow: 'Autoriser',
notNow: 'Plus tard',
cancel: 'Annuler',
openSettings: 'Ouvrir les réglages',
blockedMessage: 'Autorisation désactivée. Activez-la dans les réglages.',
);
Combine with titleProvider and descriptionProvider (for example backed by
your AppLocalizations) to translate the per-permission texts.
Custom dialog #
Use your own UI, such as a bottom sheet. Return true for the primary
action:
SmartPermission.config.customDialogBuilder = (
context, {
required style,
required title,
required message,
required primaryText,
required secondaryText,
}) {
return showModalBottomSheet<bool>(
context: context,
builder: (ctx) => Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(title, style: Theme.of(ctx).textTheme.titleLarge),
const SizedBox(height: 12),
Text(message),
const SizedBox(height: 20),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(primaryText),
),
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(secondaryText),
),
],
),
),
);
};
Analytics #
Extend PermissionAnalyticsTracker and override only the hooks you need.
Use extends, not implements, so future hooks don't break your class:
class MyAnalytics extends PermissionAnalyticsTracker {
@override
void onRequested(Permission permission) => log('requested $permission');
@override
void onGranted(Permission permission) => log('granted $permission');
@override
void onDenied(Permission permission) => log('denied $permission');
@override
void onPermanentlyDenied(Permission permission) => log('blocked $permission');
@override
void onRestricted(Permission permission) => log('restricted $permission');
}
SmartPermission.config.analytics = MyAnalytics();
The default InMemoryPermissionAnalyticsTracker counts each event per
permission, which is handy while debugging.
Error reporting #
Platform failures are logged with debugPrint and returned as
SmartPermissionResult.error, so they never crash your app. Forward them to
your crash reporter:
SmartPermission.config.onError = (error, stack) =>
FirebaseCrashlytics.instance.recordError(error, stack);
Testing #
Test your permission UX without devices or platform channels using
FakeSmartPermissionGateway from package:smart_permission/testing.dart:
import 'package:smart_permission/testing.dart';
testWidgets('scanner unlocks after allowing the camera', (tester) async {
final fake = FakeSmartPermissionGateway()
..requestResults[Permission.camera] = [PermissionStatus.granted];
SmartPermission.config.gateway = fake;
addTearDown(SmartPermission.config.resetToDefaults);
await tester.pumpWidget(const MyApp());
await tester.tap(find.text('Allow'));
await tester.pumpAndSettle();
expect(fake.requestedPermissions, [Permission.camera]);
expect(find.byType(QrScanner), findsOneWidget);
});
| Fake member | Use it to |
|---|---|
statuses |
Set the current status per permission (default: denied) |
requestResults |
Queue what each successive request returns |
statusesAfterSettings |
Simulate what the user changed in the system settings |
canOpenSettings |
Simulate a platform that can't open settings (e.g. web) |
statusError, requestError |
Make status checks or requests throw |
androidStatusSemantics |
Mimic Android, where a permanent denial is only visible from a request |
requestedPermissions |
Assert which permissions were requested, in order |
openSettingsCalls |
Assert how often settings were opened |
For anything else, implement SmartPermissionGateway yourself.
Example app #
The example
app demonstrates:
- Single and batch requests with live results
PermissionGateguarding a camera card- Material, Cupertino, and adaptive dialog styles
- Light/dark theme and primary color switching
- Custom per-permission texts
- A custom bottom-sheet dialog
- A live analytics panel
Tips and notes #
- Some Android permissions (
manageExternalStorage,systemAlertWindow,requestInstallPackages) open a system settings page instead of a dialog. - On Android 13+, use
Permission.photos,Permission.videos, orPermission.audioinstead of the deprecated storage permissions. - Request
locationWhenInUsebeforelocationAlways. Android 11+ requires that order, and it is the recommended order on iOS. - Android 17's
Permission.accessLocalNetworkis supported with built-in texts; declareandroid.permission.ACCESS_LOCAL_NETWORKin your manifest. - On web, browsers decide when to show prompts, and settings cannot be opened from the app.
Upgrading #
To 1.1 #
Only additions: PermissionGate, status / statuses / hasPermission,
openSettings, and package:smart_permission/testing.dart. No code changes
needed.
To 1.0.2 #
- Requires Flutter 3.27+ / Dart 3.6+ (earlier versions could not compile the package anyway).
- A
titleProviderordescriptionProviderthat returnsnullnow falls back to the built-in text instead of a generic message. - On Android, repeat requests for an already blocked permission skip the explanation dialog and go straight to "Open Settings".
From 0.x to 1.0 #
request(...) and requestMultiple(...) keep their signatures and return
types. Notes:
- Android:
permission_handler13 requires building with compileSdk 37. - Analytics: if your tracker used
implements PermissionAnalyticsTracker, switch toextends(new hooks:onRequested,onGranted,onRestricted). - Behavior:
requestMultipleruns one native batch with combined dialogs instead of a full flow per permission, and the settings flow waits for the user to return before re-checking.
The full history is in the changelog.
Author #
Created by Jaimin Kavathia · LinkedIn · GitHub
Issues and pull requests are welcome on GitHub.
License #
MIT: free for personal and commercial use.
⭐ If smart_permission saves you time, give it a like on pub.dev and a star on GitHub!