flutter_permission_wizard 0.1.4
flutter_permission_wizard: ^0.1.4 copied to clipboard
Declarative permission wizard for Flutter: themeable rationale, denial, restricted, and settings flows built on permission_handler.
import 'package:flutter/material.dart';
import 'package:flutter_permission_wizard/flutter_permission_wizard.dart';
void main() => runApp(const DemoApp());
class DemoApp extends StatelessWidget {
const DemoApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Permission Wizard Demo',
themeMode: ThemeMode.system,
theme: ThemeData(
useMaterial3: true,
colorSchemeSeed: const Color(0xFF6750A4),
),
darkTheme: ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorSchemeSeed: const Color(0xFF6750A4),
),
home: const DemoHome(),
);
}
}
class DemoHome extends StatefulWidget {
const DemoHome({super.key});
@override
State<DemoHome> createState() => _DemoHomeState();
}
class _DemoHomeState extends State<DemoHome> {
String _lastResult = '—';
Future<void> _requestCamera() async {
final result = await PermissionWizard.request(
context: context,
request: const PermissionRequest(
permission: Permission.camera,
rationale: PermissionRationale(
iconData: Icons.camera_alt_rounded,
title: 'Camera Access',
description:
'We use the camera to let you scan QR codes and take profile photos.',
allowButtonText: 'Allow Camera',
denyButtonText: 'Not Now',
),
deniedConfig: PermissionDeniedConfig(
iconData: Icons.camera_alt_outlined,
title: 'Camera is off',
description:
'To use this feature, turn on camera access in your settings.',
retryText: 'Try Again',
skipText: 'Skip for now',
),
permanentlyDeniedConfig: PermissionDeniedConfig(
iconData: Icons.camera_alt_outlined,
title: 'Camera is blocked',
description:
'Camera access was permanently denied. Open settings to enable it.',
openSettingsText: 'Open Settings',
skipText: 'Skip for now',
),
),
);
if (!mounted) return;
setState(() => _lastResult = 'Camera → ${_describe(result)}');
}
Future<void> _requestLocationBuilder() async {
if (!mounted) return;
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const LocationDemoPage()),
);
}
Future<void> _requestMicWithController() async {
final controller = PermissionWizardController(
request: const PermissionRequest(
permission: Permission.microphone,
rationale: PermissionRationale(
iconData: Icons.mic,
title: 'Microphone',
description: 'We need your microphone for voice messages.',
allowButtonText: 'Allow Microphone',
denyButtonText: 'Not Now',
style: RationaleStyle.bottomSheet,
),
),
);
controller.stream.listen((status) {
debugPrint('Mic permission status → $status');
});
final result = await controller.requestPermission(context);
controller.dispose();
if (!mounted) return;
setState(() => _lastResult = 'Microphone → ${_describe(result)}');
}
Future<void> _requestThemedPhotos() async {
final result = await PermissionWizard.request(
context: context,
request: PermissionRequest(
permission: Permission.photos,
theme: WizardTheme.expressive().copyWith(
primaryColor: const Color(0xFFE94560),
iconBackgroundColor: const Color(0xFFFFEFF3),
iconColor: const Color(0xFFE94560),
containerShape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(28)),
),
actionsLayout: WizardActionsLayout.horizontal,
),
rationale: PermissionRationale(
iconData: Icons.photo_library_rounded,
title: 'Spice up your profile',
description:
'Pick from your library to choose an avatar — we never upload '
'photos without your tap.',
allowButtonText: 'Pick a photo',
denyButtonText: 'Maybe later',
headerBuilder: (ctx, r) => Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFFE94560).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(99),
),
child: const Text(
'NEW',
style: TextStyle(
color: Color(0xFFE94560),
fontWeight: FontWeight.bold,
fontSize: 11,
letterSpacing: 1.2,
),
),
),
footerBuilder: (ctx, r) => Text(
'You can revoke access any time from Settings → Privacy.',
style: Theme.of(ctx).textTheme.bodySmall?.copyWith(
color:
Theme.of(ctx).colorScheme.onSurface.withValues(alpha: 0.6),
),
textAlign: TextAlign.center,
),
),
),
);
if (!mounted) return;
setState(() => _lastResult = 'Themed photos → ${_describe(result)}');
}
Future<void> _requestBatchVideoCall() async {
final result = await PermissionWizard.requestBatch(
context: context,
request: const BatchPermissionRequest(
strategy: BatchStrategy.combined,
batchRationale: PermissionRationale(
iconData: Icons.video_call,
title: 'Video Calling Needs Two Things',
description:
'To make video calls, the app needs both your camera and microphone.',
allowButtonText: 'Allow Both',
denyButtonText: 'Not Now',
bullets: [
PermissionBullet(
icon: Icons.camera_alt,
label: 'Camera',
sublabel: 'For video',
),
PermissionBullet(
icon: Icons.mic,
label: 'Microphone',
sublabel: 'For audio',
),
],
),
permissions: [
PermissionRequest(permission: Permission.camera),
PermissionRequest(permission: Permission.microphone),
],
),
);
if (!mounted) return;
setState(() {
_lastResult = 'Batch → ${result.allGranted ? 'all granted' : 'partial: '
'${result.grantedPermissions.length}/${result.results.length}'}';
});
}
String _describe(PermissionWizardResult r) => switch (r) {
GrantedResult() => 'granted',
LimitedResult() => 'limited (iOS Photos)',
DeniedResult(:final isPermanent) =>
isPermanent ? 'denied (permanent)' : 'denied (soft)',
RestrictedResult() => 'restricted',
CancelledResult(:final reason) => 'cancelled ($reason)',
};
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Permission Wizard Demo')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FilledButton(
onPressed: _requestCamera,
child: const Text('Static API → Camera (dialog rationale)'),
),
const SizedBox(height: 12),
FilledButton(
onPressed: _requestLocationBuilder,
child: const Text('Builder widget → Location'),
),
const SizedBox(height: 12),
FilledButton(
onPressed: _requestMicWithController,
child: const Text('Controller → Microphone (sheet rationale)'),
),
const SizedBox(height: 12),
FilledButton(
onPressed: _requestBatchVideoCall,
child: const Text('Batch combined → Camera + Microphone'),
),
const SizedBox(height: 12),
FilledButton(
onPressed: _requestThemedPhotos,
child: const Text(
'Custom theme + header/footer slots → Photos',
),
),
const Spacer(),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'Last result: $_lastResult',
style: Theme.of(context).textTheme.titleMedium,
),
),
],
),
),
);
}
}
class LocationDemoPage extends StatelessWidget {
const LocationDemoPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Location Builder Demo')),
body: PermissionWizardBuilder(
request: const PermissionRequest(
permission: Permission.locationWhenInUse,
rationale: PermissionRationale(
iconData: Icons.place,
title: 'Location for Nearby Results',
description: 'We use your location to show restaurants near you.',
allowButtonText: 'Allow Location',
denyButtonText: 'Skip',
style: RationaleStyle.fullScreen,
),
permanentlyDeniedConfig: PermissionDeniedConfig(
iconData: Icons.location_off,
title: 'Location off',
description: 'Open settings to enable location.',
openSettingsText: 'Open Settings',
skipText: 'Skip',
style: DeniedStyle.fullScreen,
),
),
builder: (context, status, requestPermission) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_iconForStatus(status),
size: 64,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 16),
Text(
'Status: ${status.name}',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 24),
if (status != WizardStatus.granted &&
status != WizardStatus.limited)
FilledButton(
onPressed: () => requestPermission(),
child: const Text('Enable Location'),
),
],
),
),
);
},
),
);
}
IconData _iconForStatus(WizardStatus status) => switch (status) {
WizardStatus.granted || WizardStatus.limited => Icons.place,
WizardStatus.denied => Icons.location_off,
WizardStatus.restricted => Icons.lock,
_ => Icons.help_outline,
};
}