flutter_permission_wizard 0.1.2
flutter_permission_wizard: ^0.1.2 copied to clipboard
Declarative, state-machine-driven permission flow wizard for Flutter. Builds on permission_handler to provide a complete, themeable UX for rationale, denial, restricted, and Settings-return flows.
flutter_permission_wizard #
[flutter_permission_wizard — rationale → OS prompt → Settings round-trip]
A declarative, state-machine-driven permission flow for Flutter that fills the
gap between permission_handler (great OS API, zero UX) and what every
production app actually ships: a polished pre-prompt rationale, a clear
denied-state recovery flow, a Settings-app round-trip, and exhaustive
result handling.
Everything is fully themeable, fully testable, and ships as a pure Dart package — no extra native code, no plugin registration steps.
Why this exists #
permission_handler answers one question: what does the OS say about this
permission right now? Every team then rebuilds the same things on top:
- A rationale dialog explaining why you need the permission.
- A "you denied it; here's how to fix it" screen.
- A round-trip to the Settings app with a status re-check on resume.
- Platform-specific quirks: iOS-first-denial-is-permanent, Android- shouldShowRequestPermissionRationale, Android-13 split storage perms, iOS Photos limited grants, …
flutter_permission_wizard ships all of this as a single, themed
state-machine you configure declaratively and hand control to:
final result = await PermissionWizard.request(
context: context,
request: PermissionRequest(
permission: Permission.camera,
rationale: PermissionRationale(
iconData: Icons.camera_alt_rounded,
title: 'Camera Access',
description: 'Used to scan QR codes and take profile photos.',
allowButtonText: 'Allow Camera',
denyButtonText: 'Not Now',
),
deniedConfig: PermissionDeniedConfig(
title: 'Camera is off',
description: 'Turn on camera access in settings to continue.',
openSettingsText: 'Open Settings',
skipText: 'Skip for now',
),
),
);
switch (result) {
case GrantedResult(): launchCamera();
case LimitedResult(): launchCamera(); // iOS Photos
case DeniedResult(): showDegradedMode();
case RestrictedResult(): showRestrictedMessage();
case CancelledResult(): break;
}
Installation #
dependencies:
flutter_permission_wizard: ^0.1.0
The package brings in permission_handler and app_settings transitively;
follow each plugin's platform-setup guide (Info.plist usage descriptions,
Android manifest entries) per the permission you intend to request.
The state machine #
Every wizard run drives this finite-state machine:
IDLE
└─▶ CHECKING_STATUS
├─▶ GRANTED (already granted → call onGranted, done)
├─▶ RESTRICTED (MDM/parental → show restricted screen, done)
└─▶ NEEDS_REQUEST
└─▶ SHOWING_RATIONALE (if rationale configured)
├─▶ CANCELLED (user tapped "Not Now")
└─▶ REQUESTING_OS
└─▶ REQUESTING_OS (skip rationale if not configured)
├─▶ GRANTED (OS granted → done)
├─▶ DENIED_SOFT (Android first-time denial)
│ ├─▶ SHOWING_RATIONALE (user tapped "Try Again")
│ └─▶ CANCELLED (user tapped "Skip")
└─▶ DENIED_PERMANENT
├─▶ OPENING_SETTINGS
│ └─▶ AWAITING_RESUME
│ └─▶ CHECKING_STATUS (on app foreground)
└─▶ CANCELLED (user tapped "Skip")
You can introspect or unit-test the FSM directly through
PermissionStateMachine.
Three ways to use it #
1. Static imperative API #
For one-shot requests at a specific moment in code (button tap, navigation event, etc).
final result = await PermissionWizard.request(
context: context,
request: PermissionRequest(
permission: Permission.microphone,
rationale: PermissionRationale(
title: 'Microphone access',
description: 'So we can record your voice messages.',
allowButtonText: 'Allow',
denyButtonText: 'Not Now',
style: RationaleStyle.bottomSheet,
),
),
);
2. Reactive builder widget #
For screens where the rendered UI depends on the current permission state and you want it to rebuild automatically.
PermissionWizardBuilder(
request: PermissionRequest(
permission: Permission.locationWhenInUse,
rationale: PermissionRationale(
title: 'Location for Nearby Results',
description: 'We use your location to show restaurants near you.',
),
),
builder: (context, status, requestPermission) {
return switch (status) {
WizardStatus.granted => const MapWidget(),
WizardStatus.denied => TextButton(
onPressed: requestPermission,
child: const Text('Enable Location'),
),
WizardStatus.restricted => const RestrictedPlaceholder(),
_ => const SizedBox.shrink(),
};
},
)
3. Controller-driven flow #
When you need to trigger a wizard from outside the widget tree (BLoC, ViewModel, etc.) or coordinate multiple permissions yourself.
final controller = PermissionWizardController(
request: PermissionRequest(permission: Permission.microphone, /* ... */),
);
controller.stream.listen((status) => debugPrint('mic status = $status'));
await controller.requestPermission(context);
if (controller.isGranted) { /* … */ }
Batch requests #
Request several permissions in one flow. Two strategies:
BatchStrategy.combined— show one shared rationale, then run OS prompts sequentially. Best for "video call needs camera and microphone".BatchStrategy.sequential— run a full wizard per permission. Best when permissions are optional and unrelated.
final result = await PermissionWizard.requestBatch(
context: context,
request: BatchPermissionRequest(
strategy: BatchStrategy.combined,
batchRationale: PermissionRationale(
title: 'Video Calling Needs Two Things',
description: 'Camera for video, microphone for audio.',
bullets: [
PermissionBullet(icon: Icons.camera_alt, label: 'Camera'),
PermissionBullet(icon: Icons.mic, label: 'Microphone'),
],
allowButtonText: 'Allow Both',
),
permissions: [
PermissionRequest(permission: Permission.camera),
PermissionRequest(permission: Permission.microphone),
],
),
);
if (result.allGranted) startVideoCall();
BatchPermissionWizardResult exposes per-permission results plus
allGranted, anyGranted, grantedPermissions, deniedPermissions.
Theming #
Every UI surface listens to the ambient ThemeData (light/dark/M3) so it
"just works" out of the box. Override anything via WizardTheme:
PermissionRequest(
permission: Permission.camera,
theme: WizardTheme(
primaryColor: Colors.deepPurple,
iconBackgroundColor: Colors.deepPurple.shade50,
primaryButtonStyle: FilledButton.styleFrom(
backgroundColor: Colors.deepPurple,
shape: const StadiumBorder(),
),
),
...
);
Dynamic dialog customisation #
Customisation is layered — pick the lightest touch that gets the job done:
| Level | API | Use when |
|---|---|---|
| 1. Text & icons | PermissionRationale(title: …, iconData: …) |
You just need to change copy or swap an icon. |
2. WizardTheme |
colors, paddings, button heights, action layout, bottom-sheet sizing, animations | You want a different look package-wide. |
| 3. Per-slot builders | iconBuilder, titleBuilder, descriptionBuilder, bulletsBuilder, actionsBuilder |
You want to replace one section but keep the rest of the default layout. |
| 4. Header / footer slots | headerBuilder, footerBuilder |
You need to inject extra content (badges, fine print) without rewriting anything. |
| 5. Full takeover | customBuilder |
You want to render any widget tree you like. |
Levels 2–4 are new in 0.1.2 and compose freely. Example:
PermissionRequest(
permission: Permission.photos,
theme: WizardTheme.expressive().copyWith(
primaryColor: const Color(0xFFE94560),
iconBackgroundColor: const Color(0xFFFFEFF3),
actionsLayout: WizardActionsLayout.horizontal,
),
rationale: PermissionRationale(
iconData: Icons.photo_library_rounded,
title: 'Spice up your profile',
description: 'Pick from your library to choose an avatar.',
headerBuilder: (ctx, r) => const _BadgeChip(text: 'NEW'),
footerBuilder: (ctx, r) => Text(
'You can revoke access any time from Settings.',
style: Theme.of(ctx).textTheme.bodySmall,
textAlign: TextAlign.center,
),
),
);
Theme presets
Use a ready-made WizardTheme as a starting point and copyWith over
it:
WizardTheme.compact() // tighter spacing, smaller icons
WizardTheme.expressive() // more breathing room, larger icons, 28-dp corners
WizardTheme.minimal() // stock AlertDialog vibe — no icon container fill
Reusable default slots
If you're writing a custom builder but want to render the wizard's own icon / title / actions, the shared widgets are exported:
PermissionRationale(
title: 'T',
description: 'D',
actionsBuilder: (ctx, r, onAllow, onDeny) => Column(
children: [
DefaultRationaleActions(
rationale: r,
onAllow: onAllow,
onDeny: onDeny,
),
const SizedBox(height: 8),
const Text('Tap "Allow" to continue.'),
],
),
);
Presentation styles #
PermissionRationale.style and PermissionDeniedConfig.style switch
between three layouts without any other code changes:
| Style | Use it when |
|---|---|
RationaleStyle.dialog |
Default — centered modal AlertDialog. |
RationaleStyle.bottomSheet |
You want a slide-up sheet, e.g. on a content-heavy screen. |
RationaleStyle.fullScreen |
Onboarding-style flows where the rationale gets a screen. |
DeniedStyle.dialog |
Quick recovery from soft denial. |
DeniedStyle.bottomSheet |
Less interruptive recovery prompt. |
DeniedStyle.fullScreen |
When the permission gates a primary feature. |
Platform differences (handled for you) #
The package abstracts the platform via PlatformPermissionChecker. Two
concrete implementations ship out of the box, picked automatically.
iOS #
- First denial is permanent — there is no soft-denial concept. The
package stores a per-permission "has been asked" flag in memory (use a
custom
WizardPreferencesStorageif you need persistence across cold starts) and surfaces denials as permanent for any second-or-later attempt. PermissionStatus.limited(iOS 14+ Photos) becomesLimitedResultso you can offer a "Select more photos" action without branching on the rawPermissionStatusenum.PermissionStatus.provisional(iOS 12+ Notifications) is treated as granted.
Android #
shouldShowRequestPermissionRationaledisambiguates soft denial from "don't ask again" so the package can show the correct UI.- Android 13+ image/audio split —
Permission.photos,Permission.videos,Permission.audio— is not auto-translated. Pass the permission your app'stargetSdkVersionexpects. - Android 12+
Permission.locationAlwaysrequires showinglocationWhenInUsefirst;permission_handlerchains this transparently.
Edge cases (all handled) #
| Scenario | Behaviour |
|---|---|
| Permission already granted | Returns GrantedResult immediately, no UI shown. |
Concurrent PermissionWizard.request() calls |
Queued internally — at most one wizard visible at a time. |
| App backgrounded mid-flow | Current dialog is dismissed, returns CancelledResult(app_backgrounded). |
| User returns from Settings unchanged | Re-shows the denied screen with the Open Settings button suppressed (only Skip remains). |
| Max-retry budget exhausted | Returns CancelledResult(max_retries_exceeded). |
| iOS notification permission | Routed through permission_handler's notification API. The wizard treats provisional as granted. |
| Dark mode | Every component inherits from Theme.of(context). |
Disposed BuildContext between async gaps |
Wizard short-circuits with CancelledResult(app_backgrounded). |
Observing the flow #
All side-effect-free hooks live on PermissionWizardCallbacks. None of
them affect the result.
PermissionRequest(
/* ... */
callbacks: PermissionWizardCallbacks(
onRationaleShown: () => analytics.track('rationale_shown'),
onRationaleAccepted: () => analytics.track('rationale_accepted'),
onRationaleDismissed: () => analytics.track('rationale_dismissed'),
onOSDialogPresented: () => analytics.track('os_dialog'),
onGranted: () => analytics.track('granted'),
onDenied: (isPermanent) =>
analytics.track(isPermanent ? 'denied_permanent' : 'denied_soft'),
onSettingsOpened: () => analytics.track('settings_opened'),
onReturnedFromSettings: (status) =>
analytics.track('returned_from_settings', {'status': status.name}),
onCancelled: (reason) =>
analytics.track('cancelled', {'reason': reason}),
),
);
Cancel reasons (WizardCancelReason):
rationale_dismissed, soft_denied_skipped, permanent_denied_skipped,
max_retries_exceeded, app_backgrounded, restricted_dismissed.
Testing #
The package is fully testable without a real device:
- The state machine (
PermissionStateMachine) is pure Dart — feed itWizardEvents and assert on the resultingWizardPhase. - Inject a custom
PlatformPermissionCheckerviaPermissionWizard.debugConfigure(checker: …)to script any sequence ofPermissionStatusandRequestOutcomevalues. - A
FakeSettingsLauncheris provided for simulating the Settings round-trip. AppLifecycleObserver.emit(...)(visible-for-testing) lets you simulate background/resume events.
Example:
PermissionWizard.debugConfigure(
checker: FakeChecker(
statusScript: [PermissionStatus.denied],
requestScript: [RequestOutcome.granted],
),
settingsLauncher: FakeSettingsLauncher(),
);
See test/ for ~70 unit, widget, and golden tests covering every
documented edge case.
License #
MIT. See LICENSE.