permission_handler_package 3.0.0 copy "permission_handler_package: ^3.0.0" to clipboard
permission_handler_package: ^3.0.0 copied to clipboard

A professional Flutter package for handling permissions automatically with Riverpod state management, retry logic, and beautiful UI dialogs.

Changelog #

All notable changes to the permission_handler_package package will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

3.0.0 #

Fixed two real, confirmed test bugs surfaced by a real test run against v2.16.0 — both stale from the PermissionGroup.bluetooth/PermissionGroup.sensors expansion (single-member → multi-member) made in that same release, verified against the actual current group membership in permission_type.dart before fixing either.

  • permission_manager_test.dart: 'checkGroupPermissionsStatus is true when all permissions in the group are granted' only seeded Permission.bluetooth, with a now-stale // single-member group comment — but the group has 4 members, and checkGroupPermissionsStatus's own implementation requires every member granted (confirmed by reading it directly: bool allGranted = true broken to false on the first ungranted permission found). Fixed by seeding all 4 members.
  • permission_type_test.dart: 'bluetooth and sensors groups contain exactly their single member' asserted both groups had exactly one member each — both were expanded (bluetooth to 4 members, sensors to 3) in the same release. Fixed the assertions and renamed the test, since its own name was now factually false regardless of the assertion logic.
  • Checked a third occurrence and confirmed it was not actually broken: permission_ui_test.dart's permissionGroupStatusProvider test also only seeds Permission.bluetooth alone, but its assertion only checks bluetooth's own individual granted state in PermissionState, not the group's aggregate boolean — confirmed via permissionGroupStatusProvider's own implementation that it writes every checked permission's individual result to state via updatePermissions() regardless of the group's overall pass/fail, so this test genuinely still passes as written. No change made here, to avoid an unnecessary edit.

2.16.0 #

Merged in a substantial redesign of the permission-status/UI-state handling, plus one real fix applied on top before shipping.

  • Adopted PermissionResult.isSufficient (isGranted || isLimited || isProvisional) and PermissionState.areSufficient() — every isGranted check throughout permission_provider.dart now uses isSufficient, so a limited iOS Photos grant or a provisional notification authorization is correctly treated as "the app can proceed," rather than being forced into a Settings-redirect loop the user never asked for. This is the real, correct fix for the gap-analysis finding that limited access was previously being treated as a hard denial.
  • permissionStatusProvider now returns isSufficient instead of strict isGranted, under its existing name. Calling this out explicitly here because it's a genuine, meaningful behavior change to a widely-referenced public provider, not just an internal implementation detail — any existing code relying on strict granted-only semantics from this specific provider should re-check its assumptions.
  • PermissionBuilder redesigned: single constructor, builder required (now delivers isSufficient, not strict isGranted), with an additional optional stateBuilder callback for the full PermissionUiState when a caller needs to distinguish limited/restricted/permanentlyDenied specifically (e.g. a "backup every photo" feature that genuinely can't accept limited access).
  • Added PermissionType.materialIcon / PermissionGroup.materialIcon (real IconData, not emoji strings) for production UI use — the legacy emoji icon getters are retained for demos/logs.
  • Removed PermissionType.accessLocalNetwork before shipping. Could not verify this permission exists in any released version of permission_handler after two separate rounds of research (including checking the package's full changelog history, which shows no version past 12.0.1, and finding zero mentions of this permission being added) — the only relevant search result was Chromium's own, unrelated browser-level "Local Network Access" feature. Removed the enum value and all 8 references to it across every switch statement, rather than risk shipping a package that fails to compile against the actually-available permission_handler version. pubspec.yaml remains pinned at permission_handler: ^12.0.1, unchanged.

2.15.0 #

Added 12 new PermissionType values and PermissionUiState.provisional, in response to a real gap analysis. Every addition was individually verified against the actual official permission_handler source (Baseflow/flutter-permission-handler on GitHub) before being added, not taken on faith from the request that prompted this.

  • New PermissionType values, fully wired through every switch (permission, group, displayName, icon): bluetoothScan, bluetoothConnect, bluetoothAdvertise (grouped with the existing bluetooth), nearbyWifiDevices, activityRecognition, sensorsAlways (grouped with sensors), speech, mediaLibrary, photosAddOnly, backgroundRefresh, assistant, accessMediaLocation.
  • Deliberately excluded accessLocalNetwork: could not verify it exists in the pinned permission_handler ^12.0.1 — it wasn't in the official platform-interface source checked, and would require v13. Adding a reference to a non-existent Permission value would break compilation, so it was left out rather than guessed at.
  • Added PermissionUiState.provisional for iOS provisional notification authorization (confirmed real via permission_handler_platform_interface's own PermissionStatus.isProvisional), plus PermissionResult.isProvisional, wired into both uiStateFor and aggregateUiStateFor's priority chains.
  • Fixed the real, confirmed PermissionBuilder binary-isGranted limitation: added a new, purely additive PermissionBuilder.uiState() named constructor with a uiStateBuilder callback exposing the full PermissionUiState — so callers can finally distinguish limited/provisional from a hard denied. The original PermissionBuilder() constructor and its simple bool callback are completely unchanged; this is not a breaking change. Caught and fixed a real bug in this change before it shipped: a missing permission_state.dart import that would have broken compilation (a plain Dart import doesn't transitively re-export symbols, so importing permission_provider.dart alone wasn't sufficient for PermissionUiState to resolve).
  • Android manifest documentation: added the 5 missing <uses-permission> entries for the new permissions (BLUETOOTH_ADVERTISE, NEARBY_WIFI_DEVICES, ACTIVITY_RECOGNITION, ACCESS_MEDIA_LOCATION, BODY_SENSORS_BACKGROUND).
  • iOS Info.plist documentation: added the confirmed-real usage-description keys (NSSpeechRecognitionUsageDescription, NSAppleMusicUsageDescription, NSSiriUsageDescription, NSMotionUsageDescription), each verified against Apple's own developer documentation before adding. backgroundRefresh deliberately has no key documented for it, with an explanatory note — confirmed via research that it's a system-level status check (UIApplication.backgroundRefreshStatus), not a runtime permission prompt requiring a usage-description string.
  • Not yet done, flagged rather than guessed at: pubspec.yaml's placeholder GitHub URLs (yourusername) still need the real repository URL — left as-is since filling in a plausible-but-wrong URL would be worse than an obvious placeholder. Also outstanding from the original gap analysis: PermissionScreen's own binary handling of limited/provisional states, service-status exposure (PermissionWithService), and a dedicated regression test for PermissionBuilder.uiState().

2.14.3 #

+147 -1 — every fix from 2.14.2 held; only the externalProcessingStream test (predicted as a possible cascade artifact, but confirmed by this run to be a genuine, independent bug) remained.

  • Real root cause: StreamController<bool>.broadcast() (non-sync, the default) delivers events asynchronously — confirmed directly against Dart's own API documentation: "the event will always be fired at a later time, after the code adding the event has completed." A single bare tester.pump() after controller.add(true) is not reliably guaranteed to flush that delivery in every environment. Replaced with the same bounded-polling pattern (poll up to 10 times, 1ms per pump) already proven to work for this file's other genuinely-asynchronous, intermediate-state assertions, rather than trusting a single pump.
  • Clarified, not fixed: the two permission_handler_package: ...PermissionGroup.other... lines in the same log are the library's own intentional, documented debug warnings (an assert(() { debugPrint(...); return true; }()) for empty-group usage, working exactly as designed) printing during two passing tests (+110, +111, no -N — confirmed by the incrementing pass counter, not a failure marker). Nothing needed fixing here; noting it explicitly since the log's formatting could read as an error at a glance.

2.14.2 #

+144 -4 — real, substantial progress from the 2.14.1 fixes. The 4 remaining failures were a genuinely new category (PermissionScreen/widgets not found), and a separate document proposed adding pumpAndSettle() after taps as the fix. Checked that against the actual widget source before applying anything, since 3 of the 4 failing tests had no tap at all before their failing assertion — meaning the diagnosis didn't match the actual code.

  • Real root cause for 3 of the 4: PermissionBuilder never automatically shows PermissionScreen on mount, regardless of permission state — confirmed by reading its build() method directly. It always renders its own denied/permanently-denied card first; PermissionScreen only appears after the user taps that card's own button (showCanonicalSettingsRequiredScreen is called from an onPressed handler, not from build()). Three tests (tapping Open Settings..., tapping Cancel..., ...renders Cupertino chrome...) asserted PermissionScreen was already present immediately after the widget mounted, with no tap in between — an incorrect test expectation, not a missing pumpAndSettle(). No amount of settling would have made that assertion pass, since the screen genuinely never appears without the tap. Fixed by adding the missing initial tap on the card's own button (find.text('Open Settings')) before each test's PermissionScreen-related assertions.
  • The 4th failure (externalProcessingStream drives the busy indicator...) was left unchanged after direct inspection — its structure (subscribe in initState(), single pump() after controller.add()) is correct on its face, and it may simply have been a downstream artifact of the other three tests failing earlier in the same file/process rather than an independent bug. Left as-is rather than making an unverified speculative change; worth re-checking on the next real run now that the other three are fixed.

2.14.1 #

Found a real, genuine hang — completely separate from the dispose-race fixed in 2.14.0 — while investigating a report of a test run that never completed. The command reported as hanging targeted permission_ui_test.dart, but the actual last output line matched a test in permission_manager_test.dart, which was the real clue.

  • Root cause: two tests in permission_manager_test.dart's settings redirect flow group tap "Open Settings" (triggering openSettingsAndWaitForResume(), which subscribes to a real AppLifecycleState resume event and waits indefinitely for it — by design, since it's meant to wait for the genuine user action of returning from Settings) but never actually fire that event. pumpAndSettle() cannot simulate an AppLifecycleState transition; only an explicit onAppLifecycleStateChanged() call does. Without it, await future; at the end of each test waited forever, since nothing in the test process would ever complete the underlying Future.
  • Fixed both by calling manager.onAppLifecycleStateChanged(AppLifecycleState.paused) then .resumed after the tap, matching the pattern already correctly used everywhere else in the codebase (including permission_ui_test.dart's own _TestHarness.simulateSettingsRoundTrip()).
  • Audited both test files programmatically for the same pattern (tapping "Open Settings" with no lifecycle event fired anywhere in the same test body) and confirmed these were the only two instances — permission_ui_test.dart was already clean throughout.

2.14.0 — the actual root cause, confirmed by direct evidence #

The second round of debugPrint tracing (added in the previous diagnostic build, targeting initializeRequiredPermissions's entry, its checkPermissionsStatus boundary, its allGranted return path, and dispose() itself) produced conclusive, undeniable proof:

[TRACE] dispose() called — _isDisposed was false before this call
[TRACE] dispose() called — _isDisposed was true before this call

PermissionActionNotifier.dispose() is genuinely called twice by Riverpod's own provider-disposal machinery, on the same instance, for a plain StateNotifierProvider that is not .autoDispose and does not ref.watch another provider's .notifier inside its body — the two most commonly-documented causes of this exact failure in Riverpod's own issue tracker (researched several real, similar GitHub issues; none matched this precise configuration closely enough to identify the exact internal trigger).

  • Fixed properly: dispose() is now idempotent — it checks _isDisposed at its own start and returns immediately on a repeat call, rather than reaching super.dispose() a second time (which is what tripped state_notifier's own single-dispose assertion and produced the "Bad state" crash). This is the standard, correct defensive pattern for "the framework may call my cleanup more than once," and it resolves the failure regardless of which exact internal Riverpod sequence causes the double call.
  • Removed all temporary debugPrint diagnostic tracing added across the last several releases now that it's served its purpose.
  • Added a permanent regression test directly exercising the real fix: constructs a notifier, disposes it once, then disposes it again and asserts the second call returns normally rather than throwing.
  • This should resolve every remaining instance of "Bad state: Tried to use PermissionActionNotifier after dispose was called" seen throughout this debugging session, including the one that had survived multiple previous fix attempts (isolated-run and last-test-in-file execution of the showInitialScreen: false test).

2.13.4 #

Investigated whether the remaining test-teardown crash is specific to isolated execution (--plain-name) vs. running as part of the full suite, since reports showed it hanging when run alone rather than crashing cleanly. Checked and ruled out setUpAll timing and PermissionManager() first-construction ordering as the differentiator (both are identical whether the file runs one test or all of them). Could not conclusively identify the exact remaining mechanism after extensive tracing across this and the preceding several releases.

  • Added a hard 10-second timeout to this one test, converting "hangs the test run indefinitely" into "fails clearly and quickly." This is a real, honest safety improvement for CI regardless of whether the underlying cause is ever fully pinned down — it does not claim to be a root-cause fix.
  • This crash is confined to the test harness's own rapid ProviderScope teardown/recreation cycle and does not affect real app usage — a real app mounts ProviderScope once at startup and never repeats the teardown pattern that triggers this. Verified this precisely by tracing every long-lived resource in the library (the one Timer.periodic, for automatic cache refresh) and confirming it cannot accumulate or leak: it cancels any existing instance before creating a new one, and in real app usage is created exactly once, from the PermissionManager singleton's constructor.

2.13.3 #

Confirmed via a subsequent run: 2.13.2's _stateNotifier disposal guard did not resolve the crash — same exact test (showInitialScreen: false skips the explanation screen...), same crash mechanism, only the line number shifted by exactly the size of that edit. This is real, useful evidence: it rules out that specific fix as the cause and confirms the crash is precisely, reproducibly tied to this one test, which is also the last test in the entire file.

  • Removed a now-redundant, non-standard timing workaround: pumpAppAndCaptureContext() had a raw await tester.pump(const Duration(milliseconds: 100)) added in 2.12.4/2.13.0 to work around the autoInitialize() race — but that race was properly fixed at the source in 2.13.0 (excluding in-flight permissions from autoInitialize's batch update), making this test-side wait genuinely unnecessary. It was also the single most unusual, non-standard piece of timing logic anywhere in the harness — everything else uses pumpAndSettle() or precise bounded polling — making it the most plausible remaining source of an unintended interaction specifically for the one test that has crashed identically across multiple different fix attempts to its surrounding code. Removed it and updated the method's documentation to reflect that the race is now fixed at the source and no test-side workaround is needed.
  • Investigated (without a conclusive answer) whether being the last test in the file interacts differently with Riverpod/Flutter's teardown sequencing than a test with siblings after it — researched setUpAll/tearDown ordering semantics directly but found nothing that definitively confirms or rules this out.

2.13.2 #

Real, confirmed progress: PermissionUiState.requesting is set for the duration... — the test 2.13.0 and 2.13.1 targeted — no longer appears in the failure list at all. The ensureResolved() redundancy fix worked. But the crash cascade continues, now triggered by a different, previously-passing test: showInitialScreen: false skips the explanation screen..., the last test in the initializeRequiredPermissions group.

  • Found and fixed a real gap in 2.13.0's own autoInitialize() fix: the new in-flight-exclusion logic read and wrote _stateNotifier (a separate ChangeNotifier that can be disposed independently of PermissionActionNotifier) guarded only by _isDisposed — this notifier's own disposal flag, not _stateNotifier's. This is the identical class of bug already fixed for setLoading(true) earlier in the same file; the defensive pattern just wasn't reapplied to the code added in 2.13.0. Wrapped in its own try/catch, matching the established pattern.
  • Being direct about confidence here: the crash trace for this specific failure shows PermissionActionNotifier.dispose() itself throwing via a double-dispose, not a thrown exception from inside autoInitialize() — so the fix above is a real, independently-worthwhile defensive fix, but isn't confirmed as the exact mechanism for this specific crash. This test is the last one in its group (and near the end of the file); investigated whether Riverpod's teardown behaves differently for a final test with no siblings after it, but couldn't conclusively confirm or rule this out through static analysis alone.
  • Re-verified the providerDisposed guard in permissionActionProvider (added to prevent exactly this double-dispose pattern) is still correctly intact and wired after this session's edits.

2.13.1 #

2.13.0's autoInitialize() fix worked — confirmed by a subsequent run showing PermissionUiState.requesting is set for the duration... no longer failing its own assertion (no more Expected: true / Actual: false). But that same test now crashes during ProviderScope teardown instead, with the familiar "Bad state: Tried to use PermissionActionNotifier after dispose was called."

  • Found and eliminated a genuine redundancy in _TestHarness.ensureResolved(): it's a safety net registered via addTearDown specifically for the case where a mid-flight expect() throws and skips the test's own explicit resolution code. But in every one of the 9 tests using it, the test body also explicitly awaits the same future at its own end — meaning on the normal, successful path, ensureResolved's teardown callback runs a second time against an already-fully-resolved flow, on an already-popped screen, redundantly. Traced this as far as static analysis allows (confirmed via Flutter's own source and documentation that addTearDown callbacks run before widget-tree disposal, so tester/find should still be valid) without being able to conclusively prove this redundant touch is the exact mechanism causing the crash — but it's a real, unnecessary redundancy regardless, and removing it is a safe, structural improvement independent of whether it's the full explanation.
  • Fixed by having ensureResolved() track its own future's completion via whenComplete() and skip all teardown interaction entirely once the test's own body has already resolved it normally — applies automatically to all 9 call sites via the shared harness method, not patched per-test.
  • This is a lower-confidence fix than 2.13.0's. That one was verified by direct debugPrint trace evidence showing the exact mutation sequence. This one addresses a confirmed, real code smell (genuine redundancy) that is the most plausible remaining explanation given everything traced so far, but wasn't caught in the act via a trace the way the previous bug was.

2.13.0 #

The temporary debugPrint tracing added in 2.12.4 (test-only) produced the actual answer: a real, confirmed race condition, not a timing/pump-strategy problem at all.

  • Root cause, seen directly in trace output: setRequesting(microphone) fired exactly where expected — immediately followed, synchronously, by updatePermissions([microphone, storage, photos, ...all 26 PermissionType values]), which cleared it right back out. That second call is autoInitialize() — triggered automatically via addPostFrameCallback the moment permissionActionProvider is first read, checking every PermissionType.values in the background. Its own updatePermissions() call unconditionally clears requesting/openingSettings for every permission it touches, with no awareness that some other, more specific, concurrently-running operation (e.g. a direct initializeRequiredPermissions call made shortly after the widget tree mounts) might have legitimately marked one of those same permissions as in-flight moments earlier.
  • This is a real production concern, not just a test artifact: any real app calling initializeRequiredPermissions or requestSinglePermission shortly after startup — while autoInitialize()'s own background check is still resolving — could lose this exact race, silently clearing a genuinely in-flight requesting/openingSettings state.
  • Fixed at the source, not just in the test: autoInitialize() now excludes any permission already present in requestingPermissions/openingSettingsPermissions from the batch it applies via updatePermissions(), rather than blindly overwriting everything. updatePermissions() itself is unchanged — every other caller genuinely does want its existing clear-on-touch behavior, since they're the ones who just resolved that specific status themselves.
  • Also hardened the test harness itself (pumpAppAndCaptureContext now explicitly waits out autoInitialize's async chain before returning) as defense in depth, on top of the source fix.
  • Removed the temporary debugPrint tracing added in 2.12.4 now that it served its purpose.
  • Added a permanent regression test directly exercising the race: marks a permission requesting, then calls autoInitialize(), and confirms the flag survives — plus confirms the fix is a targeted exclusion, not a blanket regression to autoInitialize's normal behavior for everything else.

2.12.4 #

Found the actual reason the diagnostic output added in the previous round never appeared in any subsequent test run, despite multiple full re-runs: a genuine syntax error in permission_ui_test.dart — a leftover, duplicated string fragment ('flow completes.',) left over from an earlier edit, sitting as a stray extra positional argument after expect()'s named reason: parameter. This is invalid Dart. Removed it.

  • This explains the exact symptom seen across multiple runs: the failure message for 'PermissionUiState.requesting is set for the duration...' kept showing the old, pre-diagnostic text verbatim, truncated at the same point, no matter how many times the suite was re-run — because the file containing the new diagnostic fields never actually compiled with them intact.
  • The underlying question — why PermissionUiState.requesting is never observed for a plain, non-permanently-denied permission passed directly to initializeRequiredPermissions — is still open. This release fixes the ability to actually see the diagnostic evidence needed to answer that; it does not yet contain a fix for the underlying behavior, since that fix depends on evidence this release makes visible for the first time.

2.12.3 #

A fresh test run against v2.12.2 confirmed the permissionsStatusProvider/PermissionGroup.other fixes from that release genuinely worked (those failures no longer appear), and surfaced three more real, distinct issues.

  • Fixed my own polling-loop bug from 2.12.1: the PermissionUiState.requesting timing test's fallback fix (poll up to 20 times, 1ms per pump) was itself insufficient — 20 iterations × 1ms is only 20ms of total simulated time, nowhere near enough to get past a real route-push transition animation (typically ~300ms for the default page-route transition) before the underlying async chain could progress far enough to be observed. Increased to 50 iterations of 16ms (one frame at 60fps) — 800ms of simulated time, comfortably past a real transition, while still far short of pumpAndSettle (which would resolve the whole flow past the very state being tested).
  • Found and fixed a real, separate test-infrastructure gap in permission_manager_test.dart: three tests in the settings redirect flow group pump a bare MaterialApp with no ScreenUtilInit wrapper at all, even though the widgets they tap buttons on (PermissionPermanentDialog) use ScreenUtil's .w/.h/.sp/.r extensions throughout. Without ScreenUtilInit, layout is computed against whatever ScreenUtil state a previous test in the same process happened to leave behind — a real, distinct cause of tap failures from the 800x600-vs-375x812 viewport mismatch already fixed in permission_ui_test.dart's harness, which this file never received. Added the same ScreenUtilInit wrapper and tester.view.physicalSize correction to all three tests.
  • Found and fixed a genuinely stale test: 'onAppResumed does not fire on transitions that are not paused→resumed' was asserting the old, narrower paused→resumed-only transition detection — but that detection was deliberately broadened to any previousState != resumed && state == resumed transition (including inactive→resumed) in an earlier round, specifically because iOS can genuinely return from Settings via that path. The source code's own extensive comment explains this; the test itself was never updated to match and was asserting behavior the code no longer has. Replaced with two tests: one confirming a transition that never lands on resumed at all (resumed→inactive) correctly never fires, and one explicitly confirming inactive→resumed does fire, documenting the real intended behavior rather than leaving it only implied by a source comment.

2.12.2 — ⚠️ Breaking change to permissionsStatusProvider #

  • Fixed a real, confirmed bug in permissionsStatusProvider itself: it was FutureProvider.family<..., List<PermissionType>>, and a bare List<PermissionType> does not have value equality in Dart — confirmed directly against Riverpod's own documentation, which names ref.watch(myProvider([1, 2, 3])) as the exact incorrect pattern to avoid with .family. Every rebuild with a literal list (exactly what this package's own README example showed) registered as a brand-new provider instance instead of reusing the existing one, which is the most likely real cause of a pumpAndSettle timed out failure seen in testing. Fixed by introducing PermissionTypeListKey, a small wrapper class with genuine, order-independent value equality (backed by a sorted, joined String — chosen specifically to avoid adding package:collection as a new dependency for what's otherwise a one-class fix). permissionsStatusProvider is now FutureProvider.family<Map<PermissionType, bool>, PermissionTypeListKey>. Updated the README's own example (which had the bug) and its API reference table.
  • Added a public PermissionActionNotifier.isDisposed getter. Small, additive API surface — this class's own internal methods already correctly guard themselves via the private _isDisposed field and don't need it, but it's useful for any external caller that wants to check disposal state before interacting with the notifier.
  • Evaluated a third generic AI-generated analysis of the ongoing test failures and found it was, again, working from an outdated/generic model of the bug rather than this codebase's actual state: its suggested dispose() rewrite, disposal guards throughout PermissionActionNotifier, and the ref.read fix for permissionActionProvider were all already present and correct in the code — verified line-by-line before concluding this. Its remaining suggestions (state-machine flow changes, "don't show denied before requesting") didn't match this codebase's actual architecture and weren't applicable.

2.12.1 #

A subsequent test run against the v2.12.0 harness rewrite surfaced a real regression the rewrite itself introduced, plus a structural gap that the rewrite's design should have closed but didn't fully.

  • Root cause of the first failure, and a real design mistake in the rewrite: 'PermissionUiState.requesting is set for the duration of the batch OS-prompt phase' was rewritten to test the timing through PermissionWrapper instead of calling initializeRequiredPermissions directly (as the pre-rewrite version correctly did). This added an extra, untested layer of async indirection — PermissionWrapper's own addPostFrameCallback — on top of the timing the test actually needed to verify, so the single 1ms pump() the test relied on was no longer reliably enough to reach the setRequesting() call. Reverted to calling initializeRequiredPermissions directly, correctly isolating the timing being tested.
  • The real structural gap, found by tracing exactly why one failing expect() cascaded into 10+ subsequent crashes: any test that starts an async permission flow, asserts something about it mid-flight, and only resolves the flow afterward has a genuine risk the harness rewrite didn't account for — if the mid-flight expect() ever fails, Dart's ordinary exception propagation skips every line after it, including the cleanup that would have resolved the flow. The still-in-flight operation then races against ProviderScope disposal exactly as before, and — because it's a crash during that test's own teardown — corrupts every subsequent test in the run. Added _TestHarness.ensureResolved(future), which registers the flow's resolution via addTearDown at the moment the flow starts, before any assertion that could fail — guaranteeing cleanup runs regardless of test outcome. Designed carefully to avoid a real flaw caught before it shipped: naively awaiting the future in teardown would hang forever if the test aborted before ever tapping through a still-open PermissionScreen, since nothing else would dismiss it — ensureResolved actively taps through any visible Cancel/Not Now first.
  • Audited the entire initializeRequiredPermissions test group programmatically (not just the one test from the log) for the same "assert mid-flight, resolve after" shape and found 7 more genuine instances of the identical structural risk. Applied ensureResolved to all of them via a verified script, then manually fixed an indentation bug the script itself introduced.
  • Verified via direct research (checking Dart's actual subtyping rules for function/generic types against void) that ensureResolved(Future<void> future) correctly accepts the Future<bool?> values returned by the .then((result) => granted = result)-chained tests without a type error.
  • permission_manager_test.dart failures in the same log were not independently investigated or changed. That file was verified clean in the prior full run and wasn't touched this round; the failures reported alongside the permission_ui_test.dart crash are very plausibly the same cascade reaching across files in one test process, but this wasn't confirmed, and no change was made to that file based on an unverified guess. Re-run and check whether those clear once the actual root cause above is fixed.

2.12.0 #

test/permission_ui_test.dart rewritten from scratch (1916 → ~1430 lines, 43 → 41 tests) after a full session of incrementally patching the same five bug classes one at a time. Rather than another patch, this rebuilds the file around a single _TestHarness class that makes each of those five bug classes structurally impossible to reintroduce, instead of relying on every future test author to remember all five fixes individually:

  • Cache staleness (point 2 from earlier fixes): _TestHarness's constructor unconditionally calls PermissionManager().clearAllCache() before any test code runs — every test gets a clean cache automatically, regardless of which permissions it reuses.
  • Surface-size mismatch (point 3): the constructor also unconditionally sets tester.view.physicalSize to match ScreenUtilInit's design size, with addTearDown reset — every test gets the corrected viewport automatically.
  • Dangling in-flight operations vs. disposal (point 1): every harness method that starts an async permission flow (pumpApp, actionNotifier()) returns the real object/Future to the caller rather than firing anything internally — there's no harness-provided shortcut that starts a flow and doesn't hand back something the test must resolve.
  • Leaked periodic Timer (point 4): disabled once in the file's setUpAll, same as before.
  • Fragile teardown ordering (point 5): every manually-constructed object in the file is torn down via addTearDown.
  • Verified the rewrite preserves full test coverage from the original 43 tests: cross-referenced every original test description against the new file, confirming each is present under an unchanged or shortened name, or was a deliberate, verified consolidation (e.g. PermissionBuilder's settings-still-denied and settings-then-granted cases merged into one test that asserts both outcomes) — except one genuine gap (initializeRequiredPermissions's own dedicated Cancel-path test, with its openAppSettingsCallCount assertion, distinct from PermissionBuilder's own Cancel test), which was added back.
  • Caught and fixed a real mistake made during the rewrite itself: an str_replace edit used to add the missing test back left a duplicated testWidgets( line in place, unbalancing the file by one paren. Found via a proper depth-tracking scan (checking every group()'s internal balance, then every testWidgets()'s internal balance, rather than eyeballing sections) rather than guessing at the location, fixed, and re-verified with the same 4 automated audit checks used throughout this session (async triggers with no settle, unawaited futures, harness usage, fragile teardown) — all pass clean on the final file.
  • permission_manager_test.dart, permission_action_notifier_test.dart, and the three model/state test files were deliberately left untouched — they were verified clean in the last full test run (+139 -0) and a rewrite there would be pure risk with no benefit.

2.11.11 #

A comprehensive, scripted audit of the entire test suite (all three files, every test/testWidgets block parsed via proper paren-balance matching, not just grep) rather than continuing to trace one crash at a time from logs. Five categories of known-risky pattern were checked exhaustively across every test:

  1. Async-triggering calls (PermissionWrapper, PermissionBuilder, initializeRequiredPermissions, etc.) with zero pump/pumpAndSettle calls anywhere in the test — zero remaining (the one real instance was fixed in 2.11.10).
  2. Captured Future variables that are never awaited — zero found.
  3. Bare fire-and-forget async calls with no capture and no awaitzero genuine instances (5 initial hits were all the same correctly-fixed multi-line final resultFuture = actionNotifier\n .initializeRequiredPermissions(...) pattern, confirmed as false positives from an overly narrow first-pass regex, then re-verified with a proper multi-line-aware check).
  4. Navigator.push calls with no corresponding dismissal action anywhere in the same test (a hang risk) — zero found.
  5. Widget/hit-test assertions with no surface-size fix applied (_wrap(tester, or a manual tester.view.physicalSize override) — zero found; full coverage confirmed across every MaterialApp( construction site in the file (exactly 3 exist, all fixed).
  • Found and fixed a real, independent category of bug while manually re-reading permission_action_notifier_test.dart fresh: three tests called dispose() on their actionNotifier/stateNotifier as bare trailing statements after one or more expect() assertions, rather than via addTearDown. If any of those assertions ever failed — including the one specifically designed to catch a real dispose-time regression — the dispose calls would never run, leaving a PermissionNotifier (with an active subscription to the singleton PermissionManager's broadcast stream) dangling into whatever test ran next. All three converted to addTearDown, guaranteeing cleanup regardless of assertion outcome. A systematic sweep for the same pattern across all three test files afterward confirmed these were the only three instances.
  • Also manually cross-checked (not just scripted): PermissionWrapper's own source for any missing dispose()/cleanup (none needed — it has no subscriptions or timers of its own, correctly relies on mounted checks); the singleton's callback-registration and navigator-key-registration surfaces for cross-test leak risk (neither is used by any test, so no risk); and the lifecycle-observer test's own reasoning for deliberately not disposing the manager singleton (confirmed sound, already correctly documented).
  • Confirmed every new symbol used across this session's fixes (clearAllCache, autoRefreshPeriodically, resetPhysicalSize, resetDevicePixelRatio, addTearDown) resolves to real, correctly-used APIs with consistent usage everywhere.

2.11.10 #

A subsequent test run (with 2.11.9's viewport fix in place — confirmed by the improved pass count, +120 vs. the earlier +104) surfaced two more real, distinct gaps.

  • Root cause of the continuing dispose-race crashes: one specific test never let its internally-triggered flow settle. 'shows a loading state before permissions are checked' deliberately checks PermissionWrapper's loading state before calling pumpAndSettle() (settling would drive past the very state it's testing) — but it never settled the flow afterward either, so PermissionWrapper's own initState-triggered initializeRequiredPermissions() call (not something the test calls directly, so none of the earlier resultFuture-capturing fixes applied to it) was still in flight when the test ended and ProviderScope disposed. Since this is a cascading crash — one test's teardown corrupting every subsequent test in the same run — this single gap explains the large block of PermissionWrapper platform-adaptive states and PermissionWrapper is driven by Riverpod state failures. Fixed by adding await tester.pumpAndSettle() after the loading-state assertion, letting the flow finish cleanly before the test ends. Swept the entire file programmatically (not just visually) for any other test using PermissionWrapper/PermissionBuilder with zero pump/pumpAndSettle calls after the initial pumpWidget — confirmed this was the only occurrence.
  • Two tests bypass the shared _wrap() helper entirely (the CupertinoPageRoute/MaterialPageRoute route-verification tests, which need navigatorObservers — a parameter _wrap() doesn't support), building their own ScreenUtilInit/MaterialApp tree inline. Since 2.11.9's surface-size fix only lived inside _wrap(), these two tests never received it, causing the exact same 800x600-vs-375x812 hit-test-miss failure _wrap()'s callers no longer see. Applied the identical tester.view.physicalSize fix directly to both. Confirmed via an exhaustive grep for every MaterialApp( construction site in the file that exactly 3 exist — _wrap()'s own definition (already fixed) and these two (now also fixed) — no further bypasses remain.

2.11.9 #

A new class of failure appeared in a subsequent test run: hit-test warnings showing widgets at offsets like Offset(400.0, 621.9) landing outside the render tree's Size(800.0, 600.0) bounds, causing tap() calls to silently miss and downstream assertions to fail.

  • Root cause, confirmed via Flutter's own documentation and a long-standing GitHub issue (#12994): flutter_test's default test surface size is a fixed, documented 800x600 (landscape, short) — a genuine mismatch with this suite's ScreenUtilInit(designSize: Size(375, 812)) (portrait, tall). ScreenUtil's scaling math against that mismatched aspect ratio was producing widget positions that landed outside the actual test viewport, especially for content near the bottom of a Column — explaining both this specific hit-test failure and, very plausibly, contributing to some of the earlier "widget not found" failures attributed solely to the cache-staleness bug in 2.11.8.
  • Fixed by setting tester.view.physicalSize to Size(375, 812) (matching the design size) in the shared _wrap() test helper, with addTearDown correctly resetting it — the standard, documented pattern for this exact problem. Since flutter_test's setUp() callbacks don't receive a WidgetTester instance (confirmed via research — this can only be set inside a running test), this required changing _wrap()'s signature to accept tester and updating all 41 call sites, done via a verified script rather than manual editing to avoid missing any.
  • Caught and fixed a real bug in my own first attempt at this script: an early version corrupted the _wrap() function definition itself by matching it as a call site. Caught immediately via grep, not left in place — verified with three independent, increasingly rigorous checks (a naive line-window search, a multi-line-aware regex, and finally a full paren-balance-matched parse) before confirming all 41 real call sites correctly have tester in scope, with zero genuine gaps.
  • Evaluated a second, generic AI-generated analysis of the same failures and declined its two main suggestions: adding a public isDisposed getter to PermissionActionNotifier (unnecessary — every internal use already correctly guards via the private field directly, and no external caller needs to inspect it); and its underlying permissionActionProvider rewrite, which reintroduces ref.watch(provider.notifier) — the exact anti-pattern already identified and fixed with research-backed reasoning in 2.11.5.

2.11.8 #

A subsequent test run (after 2.11.7's compile-error fix actually let the suite build) surfaced a new, different failure: Found 0 widgets with type "PermissionScreen" on tests that previously worked, distinct from the earlier dispose-race crash.

  • Root cause: a genuine cross-test cache-staleness bug. PermissionManager.checkPermissionsStatus reads its own internal cache (3-second TTL) before ever querying the platform, unless bypassCache: true is passed. Since PermissionManager() is a process-wide singleton never recreated between tests, and many tests in permission_ui_test.dart reuse the same PermissionType (especially camera) within that 3-second window, a test could set fakePlatform.statuses[Permission.camera] = permanentlyDenied and then read back a stale, cached result from an earlier test instead — meaning initializeRequiredPermissions's hasPermanentDenial check would never see it, and the PermissionScreen push (gated entirely on that check) would never happen. No test in the file ever cleared the manager's cache.
  • Added a top-level setUp() in all three test files that calls PermissionManager().clearAllCache() before every single test, eliminating this cross-test dependency regardless of which specific permissions any given test happens to reuse.
  • Evaluated and declined several suggestions from a separate, generic AI-generated analysis of the same failures: reverting permissionActionProvider back to ref.watch(provider.notifier) (this is the exact anti-pattern already identified and fixed with research-backed reasoning in 2.11.5 — reverting it would reintroduce the original dispose-race bug); adding debugDefaultTargetPlatformOverride to tests (checked PermissionScreen's actual platform-detection code — it reads Theme.of(context).platform, not defaultTargetPlatform, so the tests' existing ThemeData(platform: ...) approach is already correct and this suggestion solves a problem that doesn't exist in this codebase's architecture).

2.11.7 #

Critical fix, found via flutter analyze: permission_builder.dart had a genuine compile error (permissionTextStyle(...) missing its required fontWeight argument in the error-state branch). This most likely explains why every previous fix in this series (2.11.4 through 2.11.6) never visibly changed the test run's failure pattern — a compile error in a file the package barrel exports is a transitive compile error for anything importing the package, which very plausibly means the entire test binary failed to build in every run since this bug was introduced, regardless of what else was correct.

  • Fixed the missing fontWeight argument (using FontWeight.w400, matching the weight used for equivalent body text elsewhere in the same file).
  • Exhaustively re-scanned every other permissionTextStyle(...) call site across the whole lib/ tree (via a paren-balance-matching script, not just grep) to confirm this was the only occurrence of the mistake.
  • Removed a now-confirmed-unnecessary explicit foundation.dart import from permission_provider.dart — added defensively two rounds ago after defaultTargetPlatform failed to resolve via material.dart alone in a different file (permission_manager.dart); this file never actually used that specific symbol, and flutter analyze confirms material.dart's re-export already covers everything it does use (FlutterError, debugPrint).
  • Migrated all 4 containsSemantics usages in permission_ui_test.dart to isSemantics, per the analyzer's own deprecation notice. Revisited the SDK-compatibility concern that held this back in an earlier round: test/ files are never shipped to package consumers (only lib/ is published), so the "breaks compilation for a consumer on an older SDK" risk that applies to library code doesn't apply here — safe to migrate immediately.

2.11.6 #

Investigated a genuine TimeoutException after 0:10:00 reported in a subsequent test run of 2.11.5 — confirmed the earlier ref.watch-on-.notifier fix (2.11.5) is working (the dispose-race crashes are gone; the failure count in the pasted log matched a pre-2.11.5 run, and the timeout is a new, different failure mode).

  • Added a real, publicly-configurable PermissionManager.autoRefreshPeriodically property (mirroring the cacheTTLSeconds pattern from an earlier round). The underlying issue: PermissionManager() is a process-wide singleton whose constructor starts a real Timer.periodic(Duration(minutes: 2), ...) for automatic cache refresh — since the singleton is never recreated between tests in a suite, this Timer is a genuine standing timer that outlives any individual test's fake_async zone boundary. A long-lived real Timer left running across many tests is a documented category of cause for flutter_test fake-clock timeout/hang issues, especially in a suite where cumulative pumpAndSettle()-advanced fake-clock time can plausibly cross the real refresh interval over the course of 100+ tests.
  • Disabled this via setUpAll() across all three test files (permission_ui_test.dart, permission_manager_test.dart, permission_action_notifier_test.dart) — periodic refresh itself is legitimate, intended production behavior and is unchanged for real app usage; this is purely a test-environment adjustment, since the suite already exercises cache freshness directly via bypassCache and manual fake-platform status manipulation, so the periodic timer added risk without adding coverage.
  • Added a timeout: override and an extra explicit pumpAndSettle() to the specific test that reported the 10-minute timeout, as defensive resilience — this is not confirmed as the sole fix (the leaked-timer theory is the primary suspect, based on real research into flutter_test's fake-async timer semantics, but wasn't independently reproducible without running the actual suite), so it's presented as a mitigation worth verifying against a fresh test run, not a guaranteed resolution.

2.11.5 #

The dangling-Future fix in 2.11.4 was a real, worthwhile improvement but did not fix the actual root cause of Bad state: Tried to use PermissionActionNotifier after 'dispose' was called — confirmed by a fresh flutter test run showing the identical crash cascade with that fix already in place. Found and fixed the real cause this time.

  • Root cause: permissionActionProvider's own definition used ref.watch(permissionManagerProvider) and ref.watch(permissionStateProvider.notifier) inside its StateNotifierProvider builder. Watching a provider's .notifier accessor is a documented Riverpod anti-pattern (see riverpod#3451) — it creates a dependency link based on the notifier instance rather than the state it holds, which produces confusing rebuild/dispose ordering. Since PermissionActionNotifier only needs manager and stateNotifier once, to inject into its constructor, and never needs to react to either changing, ref.watch was never correct here — both changed to ref.read, which is both the correct semantics and removes the spurious dependency link that was the actual trigger for the double-dispose race.
  • The stack trace's cascading "widget not found" failures across the rest of the test run were downstream symptoms of the same root cause corrupting the widget tree during subsequent tests' pumpWidget cycles — not independent bugs, and not something the earlier Future-awaiting fix could have addressed since it was never the actual mechanism.
  • The Future-capturing fix from 2.11.4 is kept — it's independently correct practice (a test should always resolve what it starts, regardless of what else might be wrong) — but this release is the one that should actually stop the crash.

2.11.4 #

Fixes two real bugs surfaced by an actual flutter test run against this package — the first time in this whole review series that real test execution (not manual code reading) caught the problems.

  • Fixed a real RenderFlex overflow in PermissionScreen. Both the Material and Cupertino branches used a plain Column with two Spacer() widgets; on a genuinely small viewport (confirmed via the test's actual reported constraints — 497.6 logical pixels of available height), the non-Spacer content (icon, title, message, two buttons) exceeds that, causing real overflow — not a test artifact, but a real, reachable failure mode on small devices, split-screen layouts, or with a large on-screen keyboard reducing available height. Fixed both branches with LayoutBuilder + SingleChildScrollView + a minHeight-constrained IntrinsicHeight, exactly the pattern Flutter's own overflow message recommends: on tall screens this behaves identically to before (Spacer still centers the content), on short screens it becomes genuinely scrollable instead of overflowing. Caught and fixed a real duplication bug in my own first attempt at this fix (a leftover, unreplaced closing-bracket block from the original code) via balance-check before it shipped.
  • Fixed the root cause of Bad state: Tried to use PermissionActionNotifier after 'dispose' was called. Traced this against state_notifier's actual source: dispose() itself asserts the notifier is still mounted at its own start, so this error means dispose() was called a second time on an already-disposed instance. Root cause: throughout permission_ui_test.dart, unawaited(actionNotifier.initializeRequiredPermissions(...)) fired the flow without capturing the Future, then the test did UI interaction and ended — pumpAndSettle() only guarantees no frames are scheduled, not that this specific dangling Future (and its full continuation) has completed. The still-running orphaned operation would then interact with the notifier after ProviderScope teardown had already begun, corrupting shared test-process state — explaining why one initial failure cascaded into dozens of subsequent failures in the same run. Fixed all 11 occurrences of this pattern: each now captures the Future explicitly and awaits it after all necessary UI interaction, before the test function returns. Two of the eleven were previously left dangling forever (the test never even tapped a dismiss button) — both now correctly resolve the flow before ending.

2.11.3 #

Doc-only fixes found while auditing every README example for accuracy before presenting the full list.

  • Fixed a real markdown bug: a stray, unmatched closing code fence after example 10's showInitialScreen explanation, which would have broken code-block rendering for the rest of the document below it.
  • Fixed a real code bug in example 14: _showMyGroupDialog was declared with one parameter (PermissionGroup group) but called with two (context, group) two lines above — would not have compiled if copy-pasted as-is.
  • Fixed stale language in example 10: "opens app settings, waits briefly, and re-checks" described the old fixed-delay behavior removed several versions ago — now accurately says it waits for the real AppLifecycleState resume event. Also fixed "shows the appropriate dialog" to correctly say "canonical PermissionScreen", matching the screen-based flow, not the legacy dialogs.

2.11.2 #

Fixes every issue from a real flutter analyze run against this package — the first time in this whole review series that actual toolchain output (not manual code reading) surfaced the problems, and it caught two things I'd gotten wrong.

  • Fixed a real compile error: defaultTargetPlatform was undefined in permission_manager.dart. material.dart does not reliably carry this symbol through in every analyzer context — added an explicit import 'package:flutter/foundation.dart';. Applied the same explicit import defensively to permission_provider.dart, which uses FlutterError/debugPrint (both also foundation.dart symbols) via the same possibly-unreliable transitive path.
  • Fixed real compile errors in example/lib/home_page.dart: the example was never updated after PermissionExplanationCallback/PermissionGroupExplanationCallback gained a BuildContext parameter several versions ago. Its closures were still single-parameter ((permission) async {...}), which Dart was silently inferring as (BuildContext) async {...} against the new typedef — meaning permission inside the closure was actually typed as BuildContext, causing every .displayName/.description/.icon access to fail. Fixed both closures to accept and use the context parameter, and had _showCustomExplanationDialog actually use the passed-in context instead of silently falling back to the widget's own this.context.
  • Removed two genuinely unused imports in permission_builder.dart (permission_state.dart, models/permission_result.dart) — these were added defensively in an earlier round on the theory that explicit imports are safer than relying on type inference; flutter analyze confirms the inference already covered it and the imports were dead weight.
  • Fixed two real use_build_context_synchronously gaps, both in permission_provider.dart — contexts used after an await (a canonical explanation screen push) with no .mounted re-check immediately before the specific usage, unlike the many similar-looking patterns elsewhere in this file that already had a guard right at the point of use. Added the missing guards, following the exact same pattern already established at every other correctly-guarded call site.
  • Investigated but did not change: the containsSemantics deprecation warnings in the test suite. containsSemantics was deprecated in favor of isSemantics after Flutter 3.40.0, but this package's minimum supported SDK is >=3.29.0 — switching now risks breaking compilation for anyone on an older pinned SDK within that stated range, for a warning (not an error) with no functional impact. Left as-is until the package's minimum SDK constraint is eventually raised past whatever version introduced isSemantics.

2.11.1 #

Response to an observation that Material dialogs use fixed 24.w padding while Cupertino dialogs use native styling. Confirmed accurate, but investigated further and concluded this is correct behavior, not a bug — documented rather than changed.

  • Confirmed the asymmetry is real in the three legacy popup dialogs (PermissionInitialDialog, PermissionDeniedDialog, PermissionPermanentDialog): all three build a bare Dialog on Material (so 24.w is this package's own from-scratch styling) but wrap Flutter's CupertinoAlertDialog on iOS (which has its own fixed, system-accurate padding as part of faithfully reproducing the real UIAlertController). Verified via Flutter's own source that CupertinoAlertDialog exposes configurable titlePadding/messagePadding — so this isn't an unreachable native black box, it's a deliberate choice to use the platform's real defaults rather than override them.
  • Confirmed PermissionScreen (the canonical, recommended UI) does not have this asymmetry — it uses 24.w in both its Material and Cupertino branches, since it's a full custom page on both platforms, not a wrapped native dialog on one of them.
  • Did not change the behavior: imposing a custom ScreenUtil-scaled padding onto CupertinoAlertDialog would make it look less like a real iOS dialog, not more consistent — the opposite of what "native Cupertino styling" is meant to achieve. Added a doc comment to all three affected dialogs (and a corresponding README section under Theming) explaining this explicitly, so a future pass doesn't "fix" this by mistake.

2.11.0 #

Response to a UI-focused review of 5 numbered issues plus a "Missing" section. Verified each against actual code/Flutter documentation; two of the suggested fixes were themselves incorrect as written and implemented differently.

  • Real bug fixed, but not as suggested: PermissionScreen had no way back via the iOS navigation bar (automaticallyImplyLeading: false, no leading widget). The suggested fix (a configurable boolean) would have been incomplete — simply flipping to true gives Flutter's default Navigator.maybePop() behavior, which doesn't return the false value every caller of this screen depends on to interpret the outcome. Instead added an explicit leading back button wired to the exact same _handleSecondaryAction() the Cancel/Not Now button already uses, so every way back produces the same, correct result.
  • Real bug fixed, but the suggested color was wrong: the Cupertino spinner's hardcoded white color. Verified against Flutter's own CupertinoThemeData documentation: the reviewer's suggested primaryColor would make the spinner match the button's own background (CupertinoButton.filled uses primaryColor as its fill), making it invisible regardless of theme. Used primaryContrastingColor instead — Flutter's own docs describe this as the property specifically meant for "a CupertinoButton's text and icons when the button's background is primaryColor."
  • Declined: responsive padding "fix" for PermissionBuilder's denied card. The suggested max(16.w, MediaQuery.of(context).size.width * 0.05) formula mixes ScreenUtil-scaled and raw MediaQuery values inconsistently with the rest of the package, and doesn't clearly improve on what .w already does — confirmed via research that flutter_screenutil's .w extension already scales proportionally to actual screen width relative to the design size, which is the exact responsiveness being asked for.
  • Declined: "loading state overlay" concern for PermissionWrapper. The described failure mode ("user navigates back during loading, sees a blank screen") doesn't apply to this widget's actual role — PermissionWrapper isn't a pushed route itself, it's content within whatever route already contains it, so it can't itself cause a blank-screen-on-back scenario.
  • Corrected an overconfident README claim about flutter_screenutil's uninitialized-use fallback behavior — research surfaced conflicting behavior across versions (some throw an assertion, some silently fall back) and couldn't be verified precisely for the pinned 5.9.3 version, so the claim was softened to a clear requirement rather than an asserted specific fallback.
  • Reassessed the "Missing UI Tests" list against actual test coverage: screen transitions, dialog dismissal, and Cupertino/Material theme switching all already had real coverage from earlier rounds (confirmed via direct search of the test suite) — the review's framing of these as entirely missing wasn't accurate. Two genuine gaps were found and closed: PermissionScreen button state transitions (both buttons now verified disabled while onPrimaryAction is pending, re-enabled/torn-down correctly after) and accessibility semantics (verified button labels, isButton flag, and readable title/message content via containsSemantics) — this second category had zero prior coverage anywhere in the suite.

2.10.1 #

Response to a follow-up review of 3 points, re-checked against the actual current code (not assumed from the review's framing).

  • Claim #1 (uncancelled onPermissionChanged subscription) was already fixed in 2.10.0 — confirmed via direct inspection; _permissionChangeSubscription is stored and cancelled in dispose() exactly as both this review and 2.10.0 describe. No further action needed.
  • Claim #2 (permissionsProcessingStream's alleged onCancel race) was already investigated and reconfirmed as a non-issue in 2.10.0. Independently re-verified the same conclusion this round: StreamController.broadcast.add() on a listener-less stream is a documented no-op, and ChangeNotifier.removeListener doesn't itself trigger notifyListeners(), so the specific race described has no reachable trigger path in this codebase. Not implementing the suggested isDisposed flag.
  • Claim #3 — real, incremental gap found and fixed: 2.10.0 added a debug-mode warning for PermissionGroup.other's empty-permissions no-op, but only to requestPermissionGroup. Checked the other two entry points the README already claimed this warning covered (checkGroupPermissionsStatus and permissionGroupStatusProvider) and found neither actually had it — a real gap between documented and actual behavior. Both now emit the same debug-only warning, consistent with requestPermissionGroup.
  • Added tests for the empty-group behavior of checkGroupPermissionsStatus and permissionGroupStatusProvider (both correctly resolve to false without crashing or hanging).

2.10.0 #

Response to a follow-up review of 3 points. Verified each against actual code; #2 was already investigated and confirmed harmless in a previous round, re-verified here with additional research to make sure that finding still holds.

  • Real bug fixed: PermissionActionNotifier's onPermissionChanged subscription was never cancelled. Since PermissionManager is a process-wide singleton that outlives any individual provider instance, every time permissionActionProvider was disposed and recreated, the old listener closure stayed permanently registered on the manager's broadcast stream — a genuine, if low-impact, resource leak (the _isDisposed guard inside the listener prevented any actual harm from firing, but the closures still accumulated for the app's lifetime). Now stored in _permissionChangeSubscription and cancelled in dispose().
  • Re-investigated and reconfirmed: no fix needed for permissionsProcessingStream's alleged onCancel race. Researched further and found ChangeNotifier.removeListener's own documentation explicitly states listeners removed during a notifyListeners() iteration "will not be visited after they are removed" — a first-class framework guarantee, not something this package needs to defend against with an extra flag. Combined with the earlier-confirmed fact that StreamController.broadcast.add() on a listener-less stream is a documented no-op, there is no actual bug here at either layer of the interaction.
  • Real documentation gap fixed: PermissionGroup.other's empty-permissions no-op was not actually documented anywhere, despite the review's belief that it was — confirmed via search, nothing in the README covered it. Added a doc comment on PermissionType.permissions's other case, a README callout explaining the silent-no-op behavior, and a debug-mode debugPrint warning (not a hard assertion, to avoid breaking legitimate generic iteration over PermissionGroup.values) inside PermissionManager.requestPermissionGroup when called with an empty group.
  • Added tests for the subscription-cancellation fix (confirming the listener genuinely delivers events while alive, and that dispose() completes normally) and for PermissionGroup.other's no-op behavior (empty result, zero platform calls).

2.9.1 #

Minor cleanup, no behavior change.

  • Applied unawaited() to every fire-and-forget async call in the package — not just the one the suggestion named. Found and fixed all three: autoInitialize()'s call site (where the existing try/catch had actually become dead code, since autoInitialize() already guards its own errors internally as of 2.8.1 — replaced with a clean unawaited() and a comment explaining why nothing needs to be caught there anymore), plus _automaticCacheRefresh()'s Timer.periodic callback and _refreshOnResume()'s call from the lifecycle handler, both of which had the identical unmarked pattern.
  • Did not apply @override to permissionTextStyle's parameters — verified this isn't valid Dart in this context: permissionTextStyle is a top-level function, not a class method, and @override only applies to member overrides. No valid interpretation of this suggestion both compiles and does what was described.
  • Confirmed, no change needed: PermissionScreen's constructor is already const, exactly as noted.

2.9.0 #

Response to a follow-up review of 8 points on the 2.8.1 fixes. Verified each against actual code and, where a claim involved real Dart async/stream semantics, against documentation/research rather than assumption.

  • Real bug fixed: initializeRequiredPermissions's showInitialScreen parameter was accepted but completely unused — confirmed via exhaustive search (only the declaration referenced it anywhere). Now genuinely wired: false skips the canonical explanation screen and proceeds straight to the OS request, matching the parameter's documented intent. PermissionWrapper continues to pass true (unchanged default behavior for existing users).
  • Real bug fixed: silent, unhandled failures in the two fire-and-forget cache-refresh paths (_refreshOnResume, called from the lifecycle observer; _automaticCacheRefresh, called from a periodic Timer) — neither had any error handling, so a platform-channel failure during either would become an unhandled Future error, the same class of bug fixed in autoInitialize() last round. Both now catch and report via FlutterError.reportError instead of crashing or silently vanishing.
  • Addressed the residual concern in autoInitialize()'s disposal guard: the exception was being caught but fully discarded with no visibility. Now reported via FlutterError.reportError before returning, so a disposed-_stateNotifier scenario is at least observable rather than invisible — without restructuring _stateNotifier into a nullable field, which doesn't fit this class's constructor-injection design.
  • Added PermissionManager.cacheTTLSeconds as a configurable public property (getter/setter, defaults to 3, asserts positive), addressing a genuine feature gap — the reviewer's own suggested implementation didn't actually compile as written (mixed up static and instance semantics on a final field), so this was designed independently to fit the actual singleton architecture.
  • Verified and explicitly did NOT implement: the suggested onCancel-race fix for permissionsProcessingStream — researched Dart's own StreamController.broadcast documentation and confirmed add() on a stream with no active listeners is a documented no-op (the event is silently dropped, not delivered late), so the "race" the review described has no actual harmful effect to fix. Also confirmed point #3 (Cupertino/Material theme-platform detection) was correctly assessed by the reviewer as "no change needed" — already intentional.
  • Added tests for cacheTTLSeconds (default, mutation, the assertion on non-positive values, and an actual behavioral test showing a lower TTL causes fresher reads) and for showInitialScreen (true shows the screen, false skips it — including a corrected version of the false test after catching that my first draft was vacuous, since it used an already-granted permission that never reached the code path being tested at all).

2.8.1 #

Response to an external code review covering 13 points. Verified each against the actual current code before acting — several were based on an outdated snapshot or misread intentional behavior as a bug, and were not implemented; four real issues were confirmed and fixed, including one deeper bug found while investigating the review's own diagnosis.

  • Fixed a real disposal-safety gap in PermissionActionNotifier.autoInitialize(). While investigating the review's claim about addPostFrameCallback not being cancellable on dispose, found the actual, more subtle bug: _stateNotifier.setLoading(true) ran unconditionally before any disposal check, and since _stateNotifier is a separate ChangeNotifier (owned by a different provider) that can in principle be disposed independently, calling notifyListeners() on it throws. Because autoInitialize() is called fire-and-forget (never awaited) at its real call site, a synchronous throw before the first await inside an async function does not propagate to the caller's try/catch — it becomes an unhandled Future error instead. Wrapped the setLoading(true) call in its own guard. Also added a providerDisposed flag checked inside the post-frame callback itself, as the practical equivalent of "cancel on dispose" (addPostFrameCallback has no cancellation token to actually cancel).
  • Fixed a real gap in PermissionManager._showPermanentDenialDialog: if the passed context was unmounted, it silently gave up and returned a stale result. Now tries getCurrentContext() as a fallback once before giving up — and while implementing this, caught a bug in the fix itself (a leftover reference to the original, possibly-unmounted context inside showDialog, which would have defeated the fallback).
  • Fixed a real gap in PermissionScreen's externalProcessingStream subscription: no onError/onDone handlers, so an unexpected stream error or close could leave the busy indicator stuck. Both now fall back to local tracking and report the error via FlutterError.reportError rather than failing silently.
  • Documented which PermissionTypes intentionally fall through to PermissionGroup.other and why.
  • Did not implement the review's suggestions to: clear all transient requesting/openingSettings state on any updatePermissions() call (this is the existing, correct behavior working as designed — the suggested change would cause a real UI flicker for any permission whose request is still in flight when an unrelated permission resolves); or change PermissionManager's singleton from static final to nullable-with-??= (the current pattern is already the stricter, safer one — no change needed). Also declined formal @Deprecated on waitForNextResume — it remains a legitimate standalone primitive, not dead code being replaced.
  • Added dedicated tests for the autoInitialize() disposal-safety fix, including confirming the fix doesn't break the normal healthy-path behavior.

2.8.0 — ⚠️ Breaking change to PermissionExplanationCallback/PermissionGroupExplanationCallback #

Extends the canonical PermissionScreen-based flow to the pre-request explanation and plain-denial steps, not just permanent denial. Previously, PermissionWrapper and PermissionBuilder both looked fully Riverpod/PermissionScreen-driven but actually fell through to PermissionManager's legacy PermissionInitialDialog/PermissionDeniedDialog popups for the initial request — only the "permanently denied → Settings" step had been fixed. This closes that gap.

  • PermissionExplanationCallback and PermissionGroupExplanationCallback now take a BuildContext as their first parameter (Future<bool> Function(BuildContext context, PermissionType permission) / ..., PermissionGroup group)). _resolveExplanation already had a valid, pre-validated context available and simply wasn't passing it through — any existing callback registered via setPermissionExplanationCallback/setGroupExplanationCallback needs a one-parameter update.
  • Added showDeniedDialog parameter to PermissionManager.requestPermissionWithExplanation() and requestPermissions(), mirroring the existing showExplanation parameter, so canonical-flow callers can suppress the legacy post-denial popup too. Defaults to true, so any existing direct caller of these methods sees no behavior change.
  • Added showCanonicalExplanationScreen() to permission_screen.dart — the PermissionScreenMode.explanation counterpart to the settings-required screen, using the same adaptive-route pattern.
  • PermissionActionNotifier.requestSinglePermission() (used by PermissionBuilder) and initializeRequiredPermissions()'s missing-permissions branch (used by PermissionWrapper) now both show the canonical explanation screen themselves, then call the manager with showExplanation: false, showDeniedDialog: false — neither legacy popup appears for these recommended-flow call paths anymore. PermissionManager.requestPermission() itself, and its dialogs, are completely unchanged for direct manager callers.
  • Found and fixed a real bug caught while doing this: declining the new explanation screen in the batch (PermissionWrapper) path would have left the requesting PermissionUiState flag stuck forever on those permissions — set by the pre-existing flicker-prevention logic, never cleared on this new decline path. Fixed with the same clearRequesting-on-decline pattern already used for the permanently-denied/settings-screen branch.
  • Fixed 3 pre-existing tests that had gone stale (they assumed the legacy dialog still appeared for the initial request, or passed for the wrong reason since the new explanation screen coincidentally uses the same CupertinoPageScaffold type). Added 3 new tests directly covering PermissionBuilder's explanation-screen behavior: shown instead of the dialog, proceeds to a real OS request and shows granted content, and declines cleanly via "Not Now" without ever requesting.

2.7.0 #

PermissionBuilder's permanently-denied state now uses the same canonical PermissionScreenMode.settingsRequired flow as PermissionWrapper/PermissionActionNotifier, instead of the legacy PermissionPermanentDialog popup — closing the last remaining inconsistency between the package's two recommended Riverpod widgets.

  • Extracted the canonical-screen logic (adaptive route selection, PermissionUiState-backed processing stream) out of PermissionActionNotifier's private methods into two shared, public functions in permission_screen.dart: showCanonicalSettingsRequiredScreen() and permissionsProcessingStream(). PermissionActionNotifier's own _showSettingsRequiredScreen is now a thin wrapper around the shared function — no behavior change there, just de-duplication so PermissionBuilder doesn't need its own copy of this logic.
  • PermissionBuilder._openSettings() rewritten to call showCanonicalSettingsRequiredScreen(). Preserves everything from the previous fixes exactly: openSettingsAndWaitForResume() is completely untouched (still waits indefinitely for the real resume event, no timeout), the Future<bool> Settings-outcome contract is honored (the screen only closes when the fresh, bypassCache: true check shows the permission genuinely granted — otherwise it stays open and the user can retry or cancel), and setOpeningSettings/clearOpeningSettings bracket the operation the same way every other canonical-flow call site does.
  • PermissionPermanentDialog remains fully exported and functionally unchanged as explicit legacy/compatibility API — it is simply no longer used automatically by PermissionBuilder. PermissionManager.requestPermission()'s own single-permission dialog flow (PermissionInitialDialog/PermissionDeniedDialog/PermissionPermanentDialog) is also unchanged, per the README's documented recommended-vs-legacy split.
  • Rewrote a test that had gone stale (it described and asserted the pre-fix legacy-dialog behavior). Added tests proving PermissionBuilder shows the canonical screen (not the dialog), stays on it when the permission remains denied after returning from Settings, closes and shows granted content when it's actually granted, renders correctly on iOS, and that Cancel neither opens Settings nor performs a check.

2.6.0 — ⚠️ Breaking change to PermissionScreen.onPrimaryAction #

Fixed a real, user-facing bug: the settings-required screen closed unconditionally after onPrimaryAction completed, regardless of whether the permission was actually granted. A user who opened Settings and changed nothing would see the screen disappear anyway, with no indication anything was still wrong.

  • PermissionScreen.onPrimaryAction changed from Future<void> Function()? to Future<bool> Function()?. Return true if the requirement is now satisfied (the screen pops); return false if the operation completed but the permission is still required (the screen stays open). Omitting the callback still pops true immediately on tap, exactly as before — this only changes behavior for callers who actually provide the callback.
  • initializeRequiredPermissions's settings-required flow now correctly reports the real outcome: true only when the fresh, bypassCache: true post-Settings check shows every affected permission granted; false otherwise. The screen now genuinely stays open on a still-denied result, and the user can retry "Open Settings" as many times as needed (verified — openSettingsAndWaitForResume() is stateless across repeated calls) or back out via "Cancel" at any point.
  • No timeout was added or considered as part of this fixopenSettingsAndWaitForResume() is unchanged and still waits indefinitely for the real AppLifecycleState resume event, however long the user takes in Settings.
  • Rewrote a test that had been asserting the buggy behavior as correct (checking the screen was gone after a still-denied outcome) — it now asserts the fixed behavior (screen stays open, user can retry or cancel). Added tests for the granted-after-retry case and a two-attempt sequence (denied, then granted).

2.5.0 #

Found and fixed the same architectural problem PermissionWrapper had (2.4.0) in PermissionBuilder — plus a deeper bug in the status providers themselves that had to be fixed first for the widget-level fix to actually work.

  • The real bug, one layer below the widget: permissionStatusProvider, permissionsStatusProvider, and permissionGroupStatusProvider all called PermissionManager methods that only wrote to the manager's own internal cache — never to PermissionNotifier/PermissionState. Any permission only ever checked through these providers (which is how PermissionBuilder checks status) would stay stuck at PermissionUiState.unknown forever, regardless of how any widget consuming them was built. All three providers now write their results through to PermissionState as well, keeping both stores in sync — matching the pattern requestSinglePermission already used correctly. permissionGroupStatusProvider was also fixed to avoid a redundant second platform call it would otherwise have made while implementing this.
  • PermissionBuilder rewritten to remove its own _isPermanentlyDenied/_isCheckingPermanent local state and the manager.isPermissionPermanentlyDenied() poll that populated it (plus the initState() that kicked it off). It now derives permanent-denial status from ref.watch(permissionStateProvider), which — thanks to the provider fix above — is genuinely populated with real data by the time it's read, not left at unknown. Two now-fully-redundant manual re-check calls (sitting alongside ref.invalidate(permissionStatusProvider(...)) calls that already do the job) were removed.
  • Added test coverage: PermissionBuilder's granted/denied/permanently-denied rendering purely from Riverpod state, a direct regression test confirming using the widget correctly populates PermissionState (this is what would have failed before the fix), and write-through tests for all three fixed status providers.

2.4.0 #

Makes PermissionWrapper a pure display/routing component over the single Riverpod-driven state machine, instead of a second, independent permission-state tracker.

  • PermissionWrapper no longer maintains its own _isChecking/_permissionsGranted/_anyPermanentlyDenied local booleans. It now derives everything it displays from ref.watch(permissionStateProvider) via a new PermissionState.aggregateUiStateFor(List<PermissionType>) helper, which rolls several permissions' individual PermissionUiStates up into one decision (busy states win over unsettled, permanentlyDenied/restricted win over plain denied, etc.). The settings-lifecycle trigger mechanism (_checkPermissions() calling initializeRequiredPermissions()) is completely unchanged.
  • Found and fixed two real bugs surfaced by doing this correctly:
    • initializeRequiredPermissions's batch OS-request phase (_manager.requestPermissions(...)) never called setRequesting/clearRequesting at all, so PermissionUiState was wrong (stuck at unknown or stale) during the actual native-dialog phase — the single most important "busy" moment from a user's perspective. Fixed with the same try/finally bracketing already used correctly elsewhere in the file.
    • Making PermissionWrapper fully Riverpod-reactive exposed a one-frame flicker risk: the very first updatePermissions(results) write for a not-yet-granted permission would briefly read as plain denied before the code had even decided whether to go down the OS-request or settings-redirect branch — long enough to flash the wrong UI for a frame. Fixed by marking not-yet-granted permissions requesting immediately, in the same synchronous stretch as that first write (no await between them, so Flutter coalesces the resulting rebuilds into one frame). Found and closed a follow-on gap this introduced: permissions that turn out to be permanently denied need requesting cleared explicitly, since cancelling the settings screen never reaches the updatePermissions call that would otherwise clear it implicitly.
  • Added a new README.md section, "Recommended API vs. legacy/compatibility API," explicitly distinguishing the Riverpod + PermissionScreen canonical flow from the popup-based PermissionInitialDialog/PermissionDeniedDialog/PermissionPermanentDialog (kept for backward compatibility, still used by PermissionManager.requestPermission()'s single-permission path). Updated the Widgets API table to include PermissionScreen and mark the three dialogs as legacy.
  • Corrected two independent README inaccuracies found while making these updates: a stale google_fonts dependency listing (removed from the package's actual pubspec.yaml two versions ago) and a stale installation version number.
  • Added tests: PermissionState.aggregateUiStateFor (all priority tiers), the flicker-prevention fix (pumping individual frames and asserting the denied UI never appears mid-flow), the upfront requesting marking on the batch-request path, and the follow-on requesting-clearing fix for the permanently-denied handoff to the settings screen.

2.3.4 #

This release fixes two real issues found in review of 2.3.3's canonical PermissionScreen flow:

  • Fixed MaterialPageRoute being used unconditionally, even on iOS. PermissionScreen already renders full Cupertino chrome on iOS (CupertinoPageScaffold, CupertinoButton, etc.), but the route pushing it was always MaterialPageRoute — so the page itself looked native while the push/pop transition and back gesture underneath were still Android-flavored. _showSettingsRequiredScreen now picks CupertinoPageRoute on iOS/macOS and MaterialPageRoute elsewhere, using the same platform check PermissionScreen already applies to its own build. Added tests using a NavigatorObserver that assert on the actual Route subtype pushed, not just the widget it builds.
  • Made PermissionScreen's processing/busy state genuinely Riverpod-driven. Previously the screen's spinner came entirely from a local StatefulWidget field (_isProcessing) tracking only its own onPrimaryAction await — a second, independent notion of "busy" alongside the PermissionUiState.requesting/openingSettings states PermissionActionNotifier already maintains. Added an optional externalProcessingStream parameter: when provided, the screen's busy indicator reflects that stream instead of (or alongside) its own local tracking. initializeRequiredPermissions's settings-required call site now builds this stream directly from PermissionNotifier (bridging the existing ChangeNotifier to a Stream<bool> via addListener/removeListener, filtered to requesting/openingSettings for the affected permissions), so the actual Riverpod-driven state machine is what the UI shows. PermissionScreen remains fully Riverpod-agnostic and usable standalone when externalProcessingStream is omitted — verified with a dedicated test confirming standalone behavior is unchanged.
  • Added tests proving the external stream drives the spinner independent of any button tap or onPrimaryAction in flight.
  • Note on the externalProcessingStream bridge: it uses a standard (non-sync) broadcast StreamController, so the initial "seed" value on subscribe is delivered on the next microtask, not literally the same frame initState runs. This has no practical effect on the real flow — the busy state only actually becomes true once the user taps "Open Settings," well after the screen has settled — but is worth knowing if you build your own externalProcessingStream.

2.3.3 #

Makes PermissionScreen the canonical settings-required UI for the Riverpod-driven flow — previously it existed but nothing in the package ever called it.

  • PermissionActionNotifier.initializeRequiredPermissions's permanent-denial branch now pushes PermissionScreen (PermissionScreenMode.settingsRequired) via Navigator.push instead of showing PermissionPermanentDialog via showDialog. This is the default behavior for every existing caller of initializeRequiredPermissions — including PermissionWrapper, which gets the new screen-based flow automatically with no changes of its own needed.
  • The entire settings operation (openSettingsAndWaitForResume() plus the bypassCache: true re-check) is routed through PermissionScreen.onPrimaryAction, so the screen's existing processing-state UI genuinely reflects the real operation in progress and does not pop until it's fully complete — a slow user in Settings does not cause the screen to disappear early.
  • Zero changes to the v2.3.2 lifecycle mechanismopenSettingsAndWaitForResume(), the broadened resume-transition detection, and the lifecycle observer disposal are untouched, as instructed.
  • PermissionPermanentDialog and the old dialog-based flow are preserved for backward compatibility as PermissionActionNotifier.showLegacyPermanentDenialDialog() — a public method callers can use directly if they specifically want the old popup behavior. It is no longer called by initializeRequiredPermissions by default.
  • Added end-to-end widget tests (using WidgetTester.container(), Riverpod 3.0's built-in test helper) proving: a permanently-denied permission shows PermissionScreen and not the legacy dialog; the full open-settings → resume → bypassCache-recheck → Riverpod-update cycle for both the granted and still-denied outcomes; the screen does not pop early while the operation is still in flight; and Cancel neither opens Settings nor performs a check.

2.3.2 #

This release fixes three more real issues found in review of 2.3.1's settings/lifecycle work:

  • Removed the unsafe 10-second timeout from the settings-resume mechanism. openSettingsAndWaitForResume() and waitForNextResume() previously used Future.timeout(...) in a way that, after 10 seconds, would proceed as if the user had returned — even if they were still inside Settings. A user genuinely spending 15+ seconds finding the right toggle would have had a permission check performed on them mid-task, reading stale state. Both methods now wait indefinitely for the real resume event; there is no timeout that fakes completion. Replaced the timeout with an optional, purely diagnostic onLongWait callback (default 30s) that can fire for logging purposes but never causes the wait to complete early — verified with a test that explicitly asserts the wait has not completed after time passes without a real event.
  • Broadened resume-transition detection. The lifecycle observer previously only recognized paused → resumed as "the user returned." Some iOS transitions can land on resumed from inactive (Control Center, a system alert, certain app-switcher paths) without passing through paused first. Detection is now "any transition landing on resumed from a non-resumed state," which is strictly more permissive and introduces no false positives (since resumed itself only fires when the app is genuinely foreground and interactive again) — this also incidentally makes the mechanism robust to detached → resumed. Added tests for paused→resumed, inactive→resumed, detached→resumed, and confirmed a redundant resumed→resumed callback does not fire a spurious event.
  • Fixed PermissionWrapper running its own second, uncoordinated settings flow. _openSettings() was calling openAppSettings() directly and then re-checking permissions itself — a fourth settings path that bypassed the canonical openSettingsAndWaitForResume() mechanism entirely (introduced by mistake in 2.3.0's PermissionWrapper UI fix). It now simply re-invokes the same initializeRequiredPermissions() flow already used elsewhere, which already owns the full canonical settings redirect internally — there is now exactly one place in the entire package that calls openAppSettings().
  • All existing tests using the removed timeout: parameter were rewritten to match the new, safer semantics rather than patched around.

2.3.1 #

This release fixes four real issues found in review of 2.3.0's lifecycle/settings work — all four confirmed against actual code before fixing, not assumed:

  • Found and fixed a third, previously-missed Future.delayed(500ms) settings workaround, in PermissionBuilder._openSettings(). 2.3.0 only fixed the two paths in PermissionManager and PermissionActionNotifier; this widget-level path was never audited. An exhaustive grep across all of lib/ now confirms zero remaining settings-related fixed delays anywhere in the package.
  • Fixed a real (if narrow) race condition in the settings-resume mechanism: waitForNextResume() was previously called after openAppSettings(), meaning the stream subscription only started once openAppSettings() had already returned — an unrealistically fast resume could theoretically fire into the broadcast stream before anything was listening and be silently dropped (broadcast StreamControllers don't buffer for late subscribers). Added PermissionManager.openSettingsAndWaitForResume(), which subscribes to the resume stream before launching Settings, closing the window entirely. All three settings-opening call sites in the package now use this single atomic method instead of separately calling openAppSettings() then waiting. waitForNextResume() is kept as a documented lower-level primitive (with an explicit warning about the pairing hazard) rather than removed.
  • Fixed a real resource leak: the AppLifecycleObserver registered via WidgetsBinding.instance.addObserver(...) was never stored, so dispose() had no reference to pass to removeObserver() and never removed it. The observer is now stored in a late final AppLifecycleObserver _lifecycleObserver field and properly removed in dispose().
  • Removed forced GoogleFonts.urbanist typography from every widget in the package (44 call sites across 6 files). Added permissionTextStyle(context, {fontSize, fontWeight, color}), which resolves the font family from Theme.of(context).textTheme on Material and CupertinoTheme.of(context).textTheme on Cupertino — so the host application's own typography now flows through every dialog, screen, and wrapper state, matching how color already worked. google_fonts is no longer a dependency of this package at all (the example/ app still uses it for its own theme, which is exactly the point — the host app chooses its font, the package respects that choice).
  • Added test coverage for openSettingsAndWaitForResume, including a test that specifically fails if the subscribe-before-launch ordering regresses back to the old, race-prone pattern.

2.3.0 #

  • Removed the last Future.delayed guess from the settings-return flow, in both places it existed (PermissionActionNotifier.initializeRequiredPermissions and PermissionManager's own settings dialog). Added a real PermissionManager.onAppResumed stream, driven by the existing AppLifecycleObserver, that fires exactly once per genuine paused → resumed transition — and PermissionManager.waitForNextResume(), which awaits that real event (with a 10s safety-net timeout only, not a guessed wait) instead of hoping half a second was enough.
  • Added PermissionUiState, a single enum (unknown, requesting, openingSettings, granted, limited, denied, permanentlyDenied, restricted, error) computed via the new PermissionState.uiStateFor(permission), so UI can switch on one value instead of combining several booleans. Fully additive — every existing field and method on PermissionState/PermissionResult is unchanged. PermissionNotifier gained setRequesting/clearRequesting/setOpeningSettings/clearOpeningSettings to drive the two new transient states, wired into the real request and settings-redirect flows (not just declared).
  • Fixed PermissionWrapper's denied UI, which was previously Material-only with no way forward (no buttons at all). It now has a real Cupertino branch matching the pattern used elsewhere in the package, and real actions: "Try Again" for a plain denial, "Open Settings" for a permanent one (correctly detected against PermissionState).
  • Added PermissionScreen, a new full-screen, platform-adaptive page for permission explanation or the Settings redirect — an alternative to the existing dialogs for callers who want a pushable route instead of a popup. The existing dialogs (PermissionInitialDialog, PermissionDeniedDialog, PermissionPermanentDialog) are unchanged and remain the default; nothing was removed or altered in their behavior.
  • Added test coverage for all of the above: PermissionUiState/uiStateFor, the transient requesting/openingSettings tracking, the lifecycle-driven resume mechanism (verifying it responds to the real event rather than a fixed delay, and times out gracefully if the event never comes), and widget tests for both PermissionScreen and PermissionWrapper's Material/Cupertino denied states.

2.2.0 #

  • Settings redirect is now honest and traceable end-to-end. Previously, when a permanently-denied permission's dialog resolved, the outcome of "did the user open Settings, and what happened after" was silently discarded — the caller always got back the stale pre-dialog result. Now:
    • Added PermissionResult.didOpenSettings (defaults to false) so callers can distinguish "declined settings," "opened settings and it's still denied," and "opened settings and it's now granted."
    • PermissionManager's internal settings dialog now always re-checks the permission's actual status (bypassing cache) immediately after openAppSettings() returns, and that fresh result — not a stale one — is what gets returned from requestPermission()/requestPermissionWithExplanation().
    • PermissionActionNotifier.initializeRequiredPermissions's own settings flow now bypasses the cache on its post-settings re-check (it was previously vulnerable to returning a still-cached "permanently denied" read even after Settings changed) and stamps didOpenSettings: true onto the results it writes to PermissionState, so anything watching state can see the redirect happened too.
    • Added widget-tree tests exercising the full dialog → Open Settings / Cancel → result flow, in addition to the existing non-widget manager tests.

2.1.2 #

  • Fixed the remaining use_build_context_synchronously warning for real this time: the previous ignore comment was placed at the ctx declaration, which does nothing — the lint fires at each usage site after an async gap. Added the actual missing guard (ctx.mounted re-checked immediately before _resolveExplanation is called, since await shouldShowRationale(...) introduced a fresh gap that the useSmartRationale feature added in 2.1.0).
  • Pinned permission_handler_platform_interface to ^4.3.0 in dev_dependencies (matching exactly what permission_handler: ^12.0.1 itself depends on) instead of the looser any, to remove any ambiguity in resolution.
  • If depend_on_referenced_packages still appears for permission_handler_platform_interface after unzipping this version: run flutter clean && flutter pub get in the package root (not just pub get — a stale .dart_tool/package_config.json from before the dependency was added can cause the analyzer to miss it even after a plain pub get), then restart your IDE's Dart/Flutter analysis server (or just restart the IDE).

2.1.1 #

  • Fixed a real bug in PermissionState.copyWith: copyWith(error: null) was indistinguishable from omitting error entirely, so PermissionNotifier.clearError() silently did nothing. Fixed with a sentinel default so explicit null now correctly clears the error.
  • Implemented setPermissionExplanationCallback / setGroupExplanationCallback on PermissionManager (with pass-through on PermissionActionNotifier), letting a consuming app replace the built-in explanation dialog with custom UI, per-permission or per-group. The example app was calling these already; they're now real, working methods instead of undefined symbols.
  • Replaced deprecated Color.withOpacity(x) with Color.withValues(alpha: x) throughout example/lib/home_page.dart
  • Fixed unnecessary_underscores lint ((_, __)(_, _)) in two AsyncValue.when callbacks in the example
  • Added a scoped, explained // ignore: use_build_context_synchronously on the one BuildContext capture in permission_manager.dart that the analyzer can't verify is safe across multiple branches — every actual use is already guarded by its own ctx.mounted check immediately before use
  • Note: if flutter analyze still shows depend_on_referenced_packages for permission_handler_platform_interface in test/, it's already declared in dev_dependencies; run flutter pub get and restart the analyzer/IDE — this lint is known to lag a pubspec edit until the analysis server restarts

2.1.0 #

  • Fixed pubspec.yaml: removed stray changelog text that had been accidentally pasted into the environment: block, and cleaned up stray blank lines
  • Cupertino dialogs (PermissionInitialDialog, PermissionDeniedDialog, PermissionPermanentDialog) now source colors from CupertinoTheme.of(context) instead of the Material Theme.of(context), so iOS styling no longer silently depends on MaterialApp's auto-derived Cupertino theme fallback
  • checkPermissionsStatus now checks all uncached permissions concurrently via Future.wait instead of sequentially awaiting one at a time
  • Added PermissionManager.shouldShowRationale(PermissionType), wrapping permission_handler's Android-only shouldShowRequestRationale
  • Added opt-in useSmartRationale parameter to requestPermission() / requestPermissionWithExplanation(): when true, the built-in explanation dialog is skipped on a true first-ever Android ask and only shown once the OS signals a rationale is warranted (i.e. after a prior denial). Defaults to false to preserve existing behavior exactly; iOS is unaffected since it has no equivalent OS signal and always explains
  • Added real unit test coverage (PermissionType, PermissionGroup, PermissionResult, PermissionState, PermissionNotifier, and a platform-mocked PermissionManager suite)
  • Corrected example/ app structure: pubspec.yaml moved to example/ root (was previously nested under example/lib/) and now points at the local package via a path: dependency instead of a stale published version

2.0.2 #

  • Most Exsisting error cleared
  • removed unused packages
  • updated the package

2.0.1 #

  • Most Exsisting error cleared
  • removed unused packages

2.0.0 #

  • Most Exsisting error cleared
  • updated to ios

1.0.9 #

  • Most Exsisting error cleared

1.0.8 #

  • updated the global errors

1.0.7 #

  • Added screen util

1.0.6 - 2024-01-16 #

🚀 Major Features & Improvements #

✨ New Features

  • Smart Permission Builder - Now automatically detects permanent denial and shows appropriate UI
    • Shows "Allow Permission" button for normal denied state
    • Shows "Open Settings" button for permanently denied state
    • Automatically refreshes state after returning from settings
  • Intelligent Retry Tracking - Proper retry count tracking (0, 1) in denial dialogs
    • First denial: "Attempt 1 of 2"
    • Second denial: "Attempt 2 of 2"
  • Permanent Denial Detection - Built-in detection with automatic settings redirection
  • Improved Permission Flow - Complete flow from initial request to permanent denial handling

🔧 Critical Fixes

  • Removed ref.onDispose(() => manager.dispose()) - PermissionManager is now correctly implemented as singleton
  • Fixed provider memory leaks - No more unnecessary disposal of singleton instance
  • Added mounted checks - Prevents calling ref.invalidate on disposed widgets in PermissionBuilder
  • Fixed retry dialog display - Now shows correct attempt number instead of always showing "1 of 2"

🎨 UI Improvements

  • Redesigned PermissionBuilder denied card - Professional, modern card design
    • Circular icon background with adaptive colors
    • Permission-specific title and description
    • Dynamic button text and color based on denial state
    • Loading state while checking permanent denial status
  • Enhanced visual feedback - Different icons for denied vs permanently denied states
  • Better error handling - Graceful fallbacks for edge cases

📦 Package Structure

  • Removed unnecessary imports - Cleaned up permission_builder.dart imports
  • Improved code organization - Better separation of concerns
  • Simplified widget API - Cleaner, more intuitive interface

🔄 Permission Flow Improvements

  • Pre-flight status check - Checks current permission status before showing dialogs
  • Permanent denial shortcut - Immediately shows settings dialog without unnecessary requests
  • Smart retry loop - While loop with proper retry counting (max 2 attempts)
  • State refresh after settings - Automatically rechecks permission status when returning from settings

📝 Example Updates #

New Smart PermissionBuilder

PermissionBuilder(
  permission: PermissionType.camera,
  builder: (context, isGranted) {
    // Your widget when permission is granted
    return CameraWidget();
  },
)
// Automatically shows:
// - Permission card with "Allow Permission" when denied
// - Settings card with "Open Settings" when permanently denied
1
likes
160
points
237
downloads

Documentation

Documentation
API reference

Publisher

unverified uploader

Weekly Downloads

A professional Flutter package for handling permissions automatically with Riverpod state management, retry logic, and beautiful UI dialogs.

Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

flutter, flutter_riverpod, flutter_screenutil, permission_handler, riverpod

More

Packages that depend on permission_handler_package