permission_handler_package 3.0.0
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 seededPermission.bluetooth, with a now-stale// single-member groupcomment — but the group has 4 members, andcheckGroupPermissionsStatus's own implementation requires every member granted (confirmed by reading it directly:bool allGranted = truebroken tofalseon 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'spermissionGroupStatusProvidertest also only seedsPermission.bluetoothalone, but its assertion only checksbluetooth's own individual granted state inPermissionState, not the group's aggregate boolean — confirmed viapermissionGroupStatusProvider's own implementation that it writes every checked permission's individual result to state viaupdatePermissions()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) andPermissionState.areSufficient()— everyisGrantedcheck throughoutpermission_provider.dartnow usesisSufficient, so alimitediOS Photos grant or aprovisionalnotification 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 thatlimitedaccess was previously being treated as a hard denial. permissionStatusProvidernow returnsisSufficientinstead of strictisGranted, 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.PermissionBuilderredesigned: single constructor,builderrequired (now deliversisSufficient, not strictisGranted), with an additional optionalstateBuildercallback for the fullPermissionUiStatewhen a caller needs to distinguishlimited/restricted/permanentlyDeniedspecifically (e.g. a "backup every photo" feature that genuinely can't acceptlimitedaccess).- Added
PermissionType.materialIcon/PermissionGroup.materialIcon(realIconData, not emoji strings) for production UI use — the legacy emojiicongetters are retained for demos/logs. - Removed
PermissionType.accessLocalNetworkbefore shipping. Could not verify this permission exists in any released version ofpermission_handlerafter 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-availablepermission_handlerversion.pubspec.yamlremains pinned atpermission_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
PermissionTypevalues, fully wired through every switch (permission,group,displayName,icon):bluetoothScan,bluetoothConnect,bluetoothAdvertise(grouped with the existingbluetooth),nearbyWifiDevices,activityRecognition,sensorsAlways(grouped withsensors),speech,mediaLibrary,photosAddOnly,backgroundRefresh,assistant,accessMediaLocation. - Deliberately excluded
accessLocalNetwork: could not verify it exists in the pinnedpermission_handler ^12.0.1— it wasn't in the official platform-interface source checked, and would require v13. Adding a reference to a non-existentPermissionvalue would break compilation, so it was left out rather than guessed at. - Added
PermissionUiState.provisionalfor iOS provisional notification authorization (confirmed real viapermission_handler_platform_interface's ownPermissionStatus.isProvisional), plusPermissionResult.isProvisional, wired into bothuiStateForandaggregateUiStateFor's priority chains. - Fixed the real, confirmed
PermissionBuilderbinary-isGrantedlimitation: added a new, purely additivePermissionBuilder.uiState()named constructor with auiStateBuildercallback exposing the fullPermissionUiState— so callers can finally distinguishlimited/provisionalfrom a harddenied. The originalPermissionBuilder()constructor and its simpleboolcallback are completely unchanged; this is not a breaking change. Caught and fixed a real bug in this change before it shipped: a missingpermission_state.dartimport that would have broken compilation (a plain Dartimportdoesn't transitively re-export symbols, so importingpermission_provider.dartalone wasn't sufficient forPermissionUiStateto 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.backgroundRefreshdeliberately 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 forPermissionBuilder.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 baretester.pump()aftercontroller.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 (anassert(() { 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:
PermissionBuildernever automatically showsPermissionScreenon mount, regardless of permission state — confirmed by reading itsbuild()method directly. It always renders its own denied/permanently-denied card first;PermissionScreenonly appears after the user taps that card's own button (showCanonicalSettingsRequiredScreenis called from anonPressedhandler, not frombuild()). Three tests (tapping Open Settings...,tapping Cancel...,...renders Cupertino chrome...) assertedPermissionScreenwas already present immediately after the widget mounted, with no tap in between — an incorrect test expectation, not a missingpumpAndSettle(). 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'sPermissionScreen-related assertions. - The 4th failure (
externalProcessingStream drives the busy indicator...) was left unchanged after direct inspection — its structure (subscribe ininitState(), singlepump()aftercontroller.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'ssettings redirect flowgroup tap "Open Settings" (triggeringopenSettingsAndWaitForResume(), which subscribes to a realAppLifecycleStateresume 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 anAppLifecycleStatetransition; only an explicitonAppLifecycleStateChanged()call does. Without it,await future;at the end of each test waited forever, since nothing in the test process would ever complete the underlyingFuture. - Fixed both by calling
manager.onAppLifecycleStateChanged(AppLifecycleState.paused)then.resumedafter the tap, matching the pattern already correctly used everywhere else in the codebase (includingpermission_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.dartwas 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_isDisposedat its own start and returns immediately on a repeat call, rather than reachingsuper.dispose()a second time (which is what trippedstate_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
debugPrintdiagnostic 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
disposewas 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 theshowInitialScreen: falsetest).
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
ProviderScopeonce at startup and never repeats the teardown pattern that triggers this. Verified this precisely by tracing every long-lived resource in the library (the oneTimer.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 thePermissionManagersingleton'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 rawawait tester.pump(const Duration(milliseconds: 100))added in 2.12.4/2.13.0 to work around theautoInitialize()race — but that race was properly fixed at the source in 2.13.0 (excluding in-flight permissions fromautoInitialize'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 usespumpAndSettle()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/tearDownordering 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 separateChangeNotifierthat can be disposed independently ofPermissionActionNotifier) guarded only by_isDisposed— this notifier's own disposal flag, not_stateNotifier's. This is the identical class of bug already fixed forsetLoading(true)earlier in the same file; the defensive pattern just wasn't reapplied to the code added in 2.13.0. Wrapped in its owntry/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 insideautoInitialize()— 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
providerDisposedguard inpermissionActionProvider(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 viaaddTearDownspecifically for the case where a mid-flightexpect()throws and skips the test's own explicit resolution code. But in every one of the 9 tests using it, the test body also explicitlyawaits 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 thataddTearDowncallbacks run before widget-tree disposal, sotester/findshould 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 viawhenComplete()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
debugPrinttrace 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, byupdatePermissions([microphone, storage, photos, ...all 26 PermissionType values]), which cleared it right back out. That second call isautoInitialize()— triggered automatically viaaddPostFrameCallbackthe momentpermissionActionProvideris first read, checking everyPermissionType.valuesin the background. Its ownupdatePermissions()call unconditionally clearsrequesting/openingSettingsfor every permission it touches, with no awareness that some other, more specific, concurrently-running operation (e.g. a directinitializeRequiredPermissionscall 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
initializeRequiredPermissionsorrequestSinglePermissionshortly after startup — whileautoInitialize()'s own background check is still resolving — could lose this exact race, silently clearing a genuinely in-flightrequesting/openingSettingsstate. - Fixed at the source, not just in the test:
autoInitialize()now excludes any permission already present inrequestingPermissions/openingSettingsPermissionsfrom the batch it applies viaupdatePermissions(), 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 (
pumpAppAndCaptureContextnow explicitly waits outautoInitialize's async chain before returning) as defense in depth, on top of the source fix. - Removed the temporary
debugPrinttracing 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 callsautoInitialize(), and confirms the flag survives — plus confirms the fix is a targeted exclusion, not a blanket regression toautoInitialize'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.requestingis never observed for a plain, non-permanently-denied permission passed directly toinitializeRequiredPermissions— 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.requestingtiming 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 ofpumpAndSettle(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 thesettings redirect flowgroup pump a bareMaterialAppwith noScreenUtilInitwrapper at all, even though the widgets they tap buttons on (PermissionPermanentDialog) useScreenUtil's.w/.h/.sp/.rextensions throughout. WithoutScreenUtilInit, layout is computed against whateverScreenUtilstate a previous test in the same process happened to leave behind — a real, distinct cause of tap failures from the800x600-vs-375x812viewport mismatch already fixed inpermission_ui_test.dart's harness, which this file never received. Added the sameScreenUtilInitwrapper andtester.view.physicalSizecorrection 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, narrowerpaused→resumed-only transition detection — but that detection was deliberately broadened to anypreviousState != resumed && state == resumedtransition (includinginactive→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 onresumedat all (resumed→inactive) correctly never fires, and one explicitly confirminginactive→resumeddoes 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
permissionsStatusProvideritself: it wasFutureProvider.family<..., List<PermissionType>>, and a bareList<PermissionType>does not have value equality in Dart — confirmed directly against Riverpod's own documentation, which namesref.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 apumpAndSettle timed outfailure seen in testing. Fixed by introducingPermissionTypeListKey, a small wrapper class with genuine, order-independent value equality (backed by a sorted, joinedString— chosen specifically to avoid addingpackage:collectionas a new dependency for what's otherwise a one-class fix).permissionsStatusProvideris nowFutureProvider.family<Map<PermissionType, bool>, PermissionTypeListKey>. Updated the README's own example (which had the bug) and its API reference table. - Added a public
PermissionActionNotifier.isDisposedgetter. Small, additive API surface — this class's own internal methods already correctly guard themselves via the private_isDisposedfield 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 throughoutPermissionActionNotifier, and theref.readfix forpermissionActionProviderwere 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 throughPermissionWrapperinstead of callinginitializeRequiredPermissionsdirectly (as the pre-rewrite version correctly did). This added an extra, untested layer of async indirection —PermissionWrapper's ownaddPostFrameCallback— on top of the timing the test actually needed to verify, so the single 1mspump()the test relied on was no longer reliably enough to reach thesetRequesting()call. Reverted to callinginitializeRequiredPermissionsdirectly, 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-flightexpect()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 againstProviderScopedisposal 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 viaaddTearDownat 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-openPermissionScreen, since nothing else would dismiss it —ensureResolvedactively taps through any visible Cancel/Not Now first. - Audited the entire
initializeRequiredPermissionstest 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. AppliedensureResolvedto 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) thatensureResolved(Future<void> future)correctly accepts theFuture<bool?>values returned by the.then((result) => granted = result)-chained tests without a type error. permission_manager_test.dartfailures 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 thepermission_ui_test.dartcrash 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 callsPermissionManager().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.physicalSizeto matchScreenUtilInit's design size, withaddTearDownreset — 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 itsopenAppSettingsCallCountassertion, distinct fromPermissionBuilder's own Cancel test), which was added back. - Caught and fixed a real mistake made during the rewrite itself: an
str_replaceedit used to add the missing test back left a duplicatedtestWidgets(line in place, unbalancing the file by one paren. Found via a proper depth-tracking scan (checking everygroup()'s internal balance, then everytestWidgets()'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:
- Async-triggering calls (
PermissionWrapper,PermissionBuilder,initializeRequiredPermissions, etc.) with zeropump/pumpAndSettlecalls anywhere in the test — zero remaining (the one real instance was fixed in 2.11.10). - Captured
Futurevariables that are never awaited — zero found. - Bare fire-and-forget async calls with no capture and no
await— zero genuine instances (5 initial hits were all the same correctly-fixed multi-linefinal 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). Navigator.pushcalls with no corresponding dismissal action anywhere in the same test (a hang risk) — zero found.- Widget/hit-test assertions with no surface-size fix applied (
_wrap(tester,or a manualtester.view.physicalSizeoverride) — zero found; full coverage confirmed across everyMaterialApp(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.dartfresh: three tests calleddispose()on theiractionNotifier/stateNotifieras bare trailing statements after one or moreexpect()assertions, rather than viaaddTearDown. 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 aPermissionNotifier(with an active subscription to the singletonPermissionManager's broadcast stream) dangling into whatever test ran next. All three converted toaddTearDown, 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 missingdispose()/cleanup (none needed — it has no subscriptions or timers of its own, correctly relies onmountedchecks); 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 checksPermissionWrapper's loading state before callingpumpAndSettle()(settling would drive past the very state it's testing) — but it never settled the flow afterward either, soPermissionWrapper's owninitState-triggeredinitializeRequiredPermissions()call (not something the test calls directly, so none of the earlierresultFuture-capturing fixes applied to it) was still in flight when the test ended andProviderScopedisposed. 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 ofPermissionWrapper platform-adaptive statesandPermissionWrapper is driven by Riverpod statefailures. Fixed by addingawait 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 usingPermissionWrapper/PermissionBuilderwith zeropump/pumpAndSettlecalls after the initialpumpWidget— confirmed this was the only occurrence. - Two tests bypass the shared
_wrap()helper entirely (the CupertinoPageRoute/MaterialPageRoute route-verification tests, which neednavigatorObservers— a parameter_wrap()doesn't support), building their ownScreenUtilInit/MaterialApptree inline. Since 2.11.9's surface-size fix only lived inside_wrap(), these two tests never received it, causing the exact same800x600-vs-375x812hit-test-miss failure_wrap()'s callers no longer see. Applied the identicaltester.view.physicalSizefix directly to both. Confirmed via an exhaustive grep for everyMaterialApp(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, documented800x600(landscape, short) — a genuine mismatch with this suite'sScreenUtilInit(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 aColumn— 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.physicalSizetoSize(375, 812)(matching the design size) in the shared_wrap()test helper, withaddTearDowncorrectly resetting it — the standard, documented pattern for this exact problem. Sinceflutter_test'ssetUp()callbacks don't receive aWidgetTesterinstance (confirmed via research — this can only be set inside a running test), this required changing_wrap()'s signature to accepttesterand 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 viagrep, 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 havetesterin 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
isDisposedgetter toPermissionActionNotifier(unnecessary — every internal use already correctly guards via the private field directly, and no external caller needs to inspect it); and its underlyingpermissionActionProviderrewrite, which reintroducesref.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.checkPermissionsStatusreads its own internal cache (3-second TTL) before ever querying the platform, unlessbypassCache: trueis passed. SincePermissionManager()is a process-wide singleton never recreated between tests, and many tests inpermission_ui_test.dartreuse the samePermissionType(especiallycamera) within that 3-second window, a test could setfakePlatform.statuses[Permission.camera] = permanentlyDeniedand then read back a stale, cached result from an earlier test instead — meaninginitializeRequiredPermissions'shasPermanentDenialcheck would never see it, and thePermissionScreenpush (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 callsPermissionManager().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
permissionActionProviderback toref.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); addingdebugDefaultTargetPlatformOverrideto tests (checkedPermissionScreen's actual platform-detection code — it readsTheme.of(context).platform, notdefaultTargetPlatform, so the tests' existingThemeData(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
fontWeightargument (usingFontWeight.w400, matching the weight used for equivalent body text elsewhere in the same file). - Exhaustively re-scanned every other
permissionTextStyle(...)call site across the wholelib/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.dartimport frompermission_provider.dart— added defensively two rounds ago afterdefaultTargetPlatformfailed to resolve viamaterial.dartalone in a different file (permission_manager.dart); this file never actually used that specific symbol, andflutter analyzeconfirmsmaterial.dart's re-export already covers everything it does use (FlutterError,debugPrint). - Migrated all 4
containsSemanticsusages inpermission_ui_test.darttoisSemantics, 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 (onlylib/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.autoRefreshPeriodicallyproperty (mirroring thecacheTTLSecondspattern from an earlier round). The underlying issue:PermissionManager()is a process-wide singleton whose constructor starts a realTimer.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'sfake_asynczone boundary. A long-lived realTimerleft running across many tests is a documented category of cause forflutter_testfake-clock timeout/hang issues, especially in a suite where cumulativepumpAndSettle()-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 viabypassCacheand manual fake-platform status manipulation, so the periodic timer added risk without adding coverage. - Added a
timeout:override and an extra explicitpumpAndSettle()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 intoflutter_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 usedref.watch(permissionManagerProvider)andref.watch(permissionStateProvider.notifier)inside itsStateNotifierProviderbuilder. Watching a provider's.notifieraccessor 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. SincePermissionActionNotifieronly needsmanagerandstateNotifieronce, to inject into its constructor, and never needs to react to either changing,ref.watchwas never correct here — both changed toref.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'
pumpWidgetcycles — not independent bugs, and not something the earlierFuture-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
RenderFlexoverflow inPermissionScreen. Both the Material and Cupertino branches used a plainColumnwith twoSpacer()widgets; on a genuinely small viewport (confirmed via the test's actual reported constraints — 497.6 logical pixels of available height), the non-Spacercontent (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 withLayoutBuilder+SingleChildScrollView+ aminHeight-constrainedIntrinsicHeight, exactly the pattern Flutter's own overflow message recommends: on tall screens this behaves identically to before (Spacerstill 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 againststate_notifier's actual source:dispose()itself asserts the notifier is still mounted at its own start, so this error meansdispose()was called a second time on an already-disposed instance. Root cause: throughoutpermission_ui_test.dart,unawaited(actionNotifier.initializeRequiredPermissions(...))fired the flow without capturing theFuture, then the test did UI interaction and ended —pumpAndSettle()only guarantees no frames are scheduled, not that this specific danglingFuture(and its full continuation) has completed. The still-running orphaned operation would then interact with the notifier afterProviderScopeteardown 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 theFutureexplicitly 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
showInitialScreenexplanation, which would have broken code-block rendering for the rest of the document below it. - Fixed a real code bug in example 14:
_showMyGroupDialogwas 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
AppLifecycleStateresume event. Also fixed "shows the appropriate dialog" to correctly say "canonicalPermissionScreen", 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:
defaultTargetPlatformwas undefined inpermission_manager.dart.material.dartdoes not reliably carry this symbol through in every analyzer context — added an explicitimport 'package:flutter/foundation.dart';. Applied the same explicit import defensively topermission_provider.dart, which usesFlutterError/debugPrint(both alsofoundation.dartsymbols) via the same possibly-unreliable transitive path. - Fixed real compile errors in
example/lib/home_page.dart: the example was never updated afterPermissionExplanationCallback/PermissionGroupExplanationCallbackgained aBuildContextparameter several versions ago. Its closures were still single-parameter ((permission) async {...}), which Dart was silently inferring as(BuildContext) async {...}against the new typedef — meaningpermissioninside the closure was actually typed asBuildContext, causing every.displayName/.description/.iconaccess to fail. Fixed both closures to accept and use the context parameter, and had_showCustomExplanationDialogactually use the passed-in context instead of silently falling back to the widget's ownthis.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 analyzeconfirms the inference already covered it and the imports were dead weight. - Fixed two real
use_build_context_synchronouslygaps, both inpermission_provider.dart— contexts used after anawait(a canonical explanation screen push) with no.mountedre-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
containsSemanticsdeprecation warnings in the test suite.containsSemanticswas deprecated in favor ofisSemanticsafter 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 introducedisSemantics.
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 bareDialogon Material (so24.wis this package's own from-scratch styling) but wrap Flutter'sCupertinoAlertDialogon iOS (which has its own fixed, system-accurate padding as part of faithfully reproducing the realUIAlertController). Verified via Flutter's own source thatCupertinoAlertDialogexposes configurabletitlePadding/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 uses24.win 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 ontoCupertinoAlertDialogwould 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:
PermissionScreenhad 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 totruegives Flutter's defaultNavigator.maybePop()behavior, which doesn't return thefalsevalue 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
CupertinoThemeDatadocumentation: the reviewer's suggestedprimaryColorwould make the spinner match the button's own background (CupertinoButton.filledusesprimaryColoras its fill), making it invisible regardless of theme. UsedprimaryContrastingColorinstead — 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 suggestedmax(16.w, MediaQuery.of(context).size.width * 0.05)formula mixesScreenUtil-scaled and rawMediaQueryvalues inconsistently with the rest of the package, and doesn't clearly improve on what.walready does — confirmed via research thatflutter_screenutil's.wextension 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 —PermissionWrapperisn'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:
PermissionScreenbutton state transitions (both buttons now verified disabled whileonPrimaryActionis pending, re-enabled/torn-down correctly after) and accessibility semantics (verified button labels,isButtonflag, and readable title/message content viacontainsSemantics) — 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
onPermissionChangedsubscription) was already fixed in 2.10.0 — confirmed via direct inspection;_permissionChangeSubscriptionis stored and cancelled indispose()exactly as both this review and 2.10.0 describe. No further action needed. - Claim #2 (
permissionsProcessingStream's allegedonCancelrace) 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, andChangeNotifier.removeListenerdoesn't itself triggernotifyListeners(), so the specific race described has no reachable trigger path in this codebase. Not implementing the suggestedisDisposedflag. - 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 torequestPermissionGroup. Checked the other two entry points the README already claimed this warning covered (checkGroupPermissionsStatusandpermissionGroupStatusProvider) and found neither actually had it — a real gap between documented and actual behavior. Both now emit the same debug-only warning, consistent withrequestPermissionGroup. - Added tests for the empty-group behavior of
checkGroupPermissionsStatusandpermissionGroupStatusProvider(both correctly resolve tofalsewithout 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'sonPermissionChangedsubscription was never cancelled. SincePermissionManageris a process-wide singleton that outlives any individual provider instance, every timepermissionActionProviderwas disposed and recreated, the old listener closure stayed permanently registered on the manager's broadcast stream — a genuine, if low-impact, resource leak (the_isDisposedguard inside the listener prevented any actual harm from firing, but the closures still accumulated for the app's lifetime). Now stored in_permissionChangeSubscriptionand cancelled indispose(). - Re-investigated and reconfirmed: no fix needed for
permissionsProcessingStream's allegedonCancelrace. Researched further and foundChangeNotifier.removeListener's own documentation explicitly states listeners removed during anotifyListeners()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 thatStreamController.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 onPermissionType.permissions'sothercase, a README callout explaining the silent-no-op behavior, and a debug-modedebugPrintwarning (not a hard assertion, to avoid breaking legitimate generic iteration overPermissionGroup.values) insidePermissionManager.requestPermissionGroupwhen 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 forPermissionGroup.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 existingtry/catchhad actually become dead code, sinceautoInitialize()already guards its own errors internally as of 2.8.1 — replaced with a cleanunawaited()and a comment explaining why nothing needs to be caught there anymore), plus_automaticCacheRefresh()'sTimer.periodiccallback and_refreshOnResume()'s call from the lifecycle handler, both of which had the identical unmarked pattern. - Did not apply
@overridetopermissionTextStyle's parameters — verified this isn't valid Dart in this context:permissionTextStyleis a top-level function, not a class method, and@overrideonly 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 alreadyconst, 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'sshowInitialScreenparameter was accepted but completely unused — confirmed via exhaustive search (only the declaration referenced it anywhere). Now genuinely wired:falseskips the canonical explanation screen and proceeds straight to the OS request, matching the parameter's documented intent.PermissionWrappercontinues to passtrue(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 periodicTimer) — neither had any error handling, so a platform-channel failure during either would become an unhandledFutureerror, the same class of bug fixed inautoInitialize()last round. Both now catch and report viaFlutterError.reportErrorinstead 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 viaFlutterError.reportErrorbefore returning, so a disposed-_stateNotifierscenario is at least observable rather than invisible — without restructuring_stateNotifierinto a nullable field, which doesn't fit this class's constructor-injection design. - Added
PermissionManager.cacheTTLSecondsas 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 afinalfield), so this was designed independently to fit the actual singleton architecture. - Verified and explicitly did NOT implement: the suggested
onCancel-race fix forpermissionsProcessingStream— researched Dart's ownStreamController.broadcastdocumentation and confirmedadd()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 forshowInitialScreen(trueshows the screen,falseskips it — including a corrected version of thefalsetest 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 aboutaddPostFrameCallbacknot being cancellable on dispose, found the actual, more subtle bug:_stateNotifier.setLoading(true)ran unconditionally before any disposal check, and since_stateNotifieris a separateChangeNotifier(owned by a different provider) that can in principle be disposed independently, callingnotifyListeners()on it throws. BecauseautoInitialize()is called fire-and-forget (never awaited) at its real call site, a synchronous throw before the firstawaitinside anasyncfunction does not propagate to the caller'stry/catch— it becomes an unhandledFutureerror instead. Wrapped thesetLoading(true)call in its own guard. Also added aproviderDisposedflag checked inside the post-frame callback itself, as the practical equivalent of "cancel on dispose" (addPostFrameCallbackhas 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 triesgetCurrentContext()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-unmountedcontextinsideshowDialog, which would have defeated the fallback). - Fixed a real gap in
PermissionScreen'sexternalProcessingStreamsubscription: noonError/onDonehandlers, so an unexpected stream error or close could leave the busy indicator stuck. Both now fall back to local tracking and report the error viaFlutterError.reportErrorrather than failing silently. - Documented which
PermissionTypes intentionally fall through toPermissionGroup.otherand why. - Did not implement the review's suggestions to: clear all transient
requesting/openingSettingsstate on anyupdatePermissions()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 changePermissionManager's singleton fromstatic finalto nullable-with-??=(the current pattern is already the stricter, safer one — no change needed). Also declined formal@DeprecatedonwaitForNextResume— 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.
PermissionExplanationCallbackandPermissionGroupExplanationCallbacknow take aBuildContextas their first parameter (Future<bool> Function(BuildContext context, PermissionType permission)/..., PermissionGroup group))._resolveExplanationalready had a valid, pre-validated context available and simply wasn't passing it through — any existing callback registered viasetPermissionExplanationCallback/setGroupExplanationCallbackneeds a one-parameter update.- Added
showDeniedDialogparameter toPermissionManager.requestPermissionWithExplanation()andrequestPermissions(), mirroring the existingshowExplanationparameter, so canonical-flow callers can suppress the legacy post-denial popup too. Defaults totrue, so any existing direct caller of these methods sees no behavior change. - Added
showCanonicalExplanationScreen()topermission_screen.dart— thePermissionScreenMode.explanationcounterpart to the settings-required screen, using the same adaptive-route pattern. PermissionActionNotifier.requestSinglePermission()(used byPermissionBuilder) andinitializeRequiredPermissions()'s missing-permissions branch (used byPermissionWrapper) now both show the canonical explanation screen themselves, then call the manager withshowExplanation: 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 therequestingPermissionUiStateflag stuck forever on those permissions — set by the pre-existing flicker-prevention logic, never cleared on this new decline path. Fixed with the sameclearRequesting-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
CupertinoPageScaffoldtype). Added 3 new tests directly coveringPermissionBuilder'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 ofPermissionActionNotifier's private methods into two shared, public functions inpermission_screen.dart:showCanonicalSettingsRequiredScreen()andpermissionsProcessingStream().PermissionActionNotifier's own_showSettingsRequiredScreenis now a thin wrapper around the shared function — no behavior change there, just de-duplication soPermissionBuilderdoesn't need its own copy of this logic. PermissionBuilder._openSettings()rewritten to callshowCanonicalSettingsRequiredScreen(). Preserves everything from the previous fixes exactly:openSettingsAndWaitForResume()is completely untouched (still waits indefinitely for the real resume event, no timeout), theFuture<bool>Settings-outcome contract is honored (the screen only closes when the fresh,bypassCache: truecheck shows the permission genuinely granted — otherwise it stays open and the user can retry or cancel), andsetOpeningSettings/clearOpeningSettingsbracket the operation the same way every other canonical-flow call site does.PermissionPermanentDialogremains fully exported and functionally unchanged as explicit legacy/compatibility API — it is simply no longer used automatically byPermissionBuilder.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
PermissionBuildershows 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.onPrimaryActionchanged fromFuture<void> Function()?toFuture<bool> Function()?. Returntrueif the requirement is now satisfied (the screen pops); returnfalseif the operation completed but the permission is still required (the screen stays open). Omitting the callback still popstrueimmediately 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:trueonly when the fresh,bypassCache: truepost-Settings check shows every affected permission granted;falseotherwise. 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 fix —
openSettingsAndWaitForResume()is unchanged and still waits indefinitely for the realAppLifecycleStateresume 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, andpermissionGroupStatusProviderall calledPermissionManagermethods that only wrote to the manager's own internal cache — never toPermissionNotifier/PermissionState. Any permission only ever checked through these providers (which is howPermissionBuilderchecks status) would stay stuck atPermissionUiState.unknownforever, regardless of how any widget consuming them was built. All three providers now write their results through toPermissionStateas well, keeping both stores in sync — matching the patternrequestSinglePermissionalready used correctly.permissionGroupStatusProviderwas also fixed to avoid a redundant second platform call it would otherwise have made while implementing this. PermissionBuilderrewritten to remove its own_isPermanentlyDenied/_isCheckingPermanentlocal state and themanager.isPermissionPermanentlyDenied()poll that populated it (plus theinitState()that kicked it off). It now derives permanent-denial status fromref.watch(permissionStateProvider), which — thanks to the provider fix above — is genuinely populated with real data by the time it's read, not left atunknown. Two now-fully-redundant manual re-check calls (sitting alongsideref.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 populatesPermissionState(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.
PermissionWrapperno longer maintains its own_isChecking/_permissionsGranted/_anyPermanentlyDeniedlocal booleans. It now derives everything it displays fromref.watch(permissionStateProvider)via a newPermissionState.aggregateUiStateFor(List<PermissionType>)helper, which rolls several permissions' individualPermissionUiStates up into one decision (busy states win over unsettled,permanentlyDenied/restrictedwin over plaindenied, etc.). The settings-lifecycle trigger mechanism (_checkPermissions()callinginitializeRequiredPermissions()) is completely unchanged.- Found and fixed two real bugs surfaced by doing this correctly:
initializeRequiredPermissions's batch OS-request phase (_manager.requestPermissions(...)) never calledsetRequesting/clearRequestingat all, soPermissionUiStatewas wrong (stuck atunknownor stale) during the actual native-dialog phase — the single most important "busy" moment from a user's perspective. Fixed with the sametry/finallybracketing already used correctly elsewhere in the file.- Making
PermissionWrapperfully Riverpod-reactive exposed a one-frame flicker risk: the very firstupdatePermissions(results)write for a not-yet-granted permission would briefly read as plaindeniedbefore 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 permissionsrequestingimmediately, in the same synchronous stretch as that first write (noawaitbetween 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 needrequestingcleared explicitly, since cancelling the settings screen never reaches theupdatePermissionscall that would otherwise clear it implicitly.
- Added a new
README.mdsection, "Recommended API vs. legacy/compatibility API," explicitly distinguishing the Riverpod +PermissionScreencanonical flow from the popup-basedPermissionInitialDialog/PermissionDeniedDialog/PermissionPermanentDialog(kept for backward compatibility, still used byPermissionManager.requestPermission()'s single-permission path). Updated theWidgetsAPI table to includePermissionScreenand mark the three dialogs as legacy. - Corrected two independent README inaccuracies found while making these updates: a stale
google_fontsdependency listing (removed from the package's actualpubspec.yamltwo 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 upfrontrequestingmarking on the batch-request path, and the follow-onrequesting-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
MaterialPageRoutebeing used unconditionally, even on iOS.PermissionScreenalready renders full Cupertino chrome on iOS (CupertinoPageScaffold,CupertinoButton, etc.), but the route pushing it was alwaysMaterialPageRoute— so the page itself looked native while the push/pop transition and back gesture underneath were still Android-flavored._showSettingsRequiredScreennow picksCupertinoPageRouteon iOS/macOS andMaterialPageRouteelsewhere, using the same platform checkPermissionScreenalready applies to its own build. Added tests using aNavigatorObserverthat assert on the actualRoutesubtype 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 localStatefulWidgetfield (_isProcessing) tracking only its ownonPrimaryActionawait — a second, independent notion of "busy" alongside thePermissionUiState.requesting/openingSettingsstatesPermissionActionNotifieralready maintains. Added an optionalexternalProcessingStreamparameter: 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 fromPermissionNotifier(bridging the existingChangeNotifierto aStream<bool>viaaddListener/removeListener, filtered torequesting/openingSettingsfor the affected permissions), so the actual Riverpod-driven state machine is what the UI shows.PermissionScreenremains fully Riverpod-agnostic and usable standalone whenexternalProcessingStreamis 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
onPrimaryActionin flight. - Note on the
externalProcessingStreambridge: it uses a standard (non-sync) broadcastStreamController, so the initial "seed" value on subscribe is delivered on the next microtask, not literally the same frameinitStateruns. This has no practical effect on the real flow — the busy state only actually becomestrueonce the user taps "Open Settings," well after the screen has settled — but is worth knowing if you build your ownexternalProcessingStream.
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 pushesPermissionScreen(PermissionScreenMode.settingsRequired) viaNavigator.pushinstead of showingPermissionPermanentDialogviashowDialog. This is the default behavior for every existing caller ofinitializeRequiredPermissions— includingPermissionWrapper, which gets the new screen-based flow automatically with no changes of its own needed.- The entire settings operation (
openSettingsAndWaitForResume()plus thebypassCache: truere-check) is routed throughPermissionScreen.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 mechanism —
openSettingsAndWaitForResume(), the broadened resume-transition detection, and the lifecycle observer disposal are untouched, as instructed. PermissionPermanentDialogand the old dialog-based flow are preserved for backward compatibility asPermissionActionNotifier.showLegacyPermanentDenialDialog()— a public method callers can use directly if they specifically want the old popup behavior. It is no longer called byinitializeRequiredPermissionsby default.- Added end-to-end widget tests (using
WidgetTester.container(), Riverpod 3.0's built-in test helper) proving: a permanently-denied permission showsPermissionScreenand 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()andwaitForNextResume()previously usedFuture.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 diagnosticonLongWaitcallback (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 → resumedas "the user returned." Some iOS transitions can land onresumedfrominactive(Control Center, a system alert, certain app-switcher paths) without passing throughpausedfirst. Detection is now "any transition landing onresumedfrom a non-resumed state," which is strictly more permissive and introduces no false positives (sinceresumeditself only fires when the app is genuinely foreground and interactive again) — this also incidentally makes the mechanism robust todetached → resumed. Added tests forpaused→resumed,inactive→resumed,detached→resumed, and confirmed a redundantresumed→resumedcallback does not fire a spurious event. - Fixed
PermissionWrapperrunning its own second, uncoordinated settings flow._openSettings()was callingopenAppSettings()directly and then re-checking permissions itself — a fourth settings path that bypassed the canonicalopenSettingsAndWaitForResume()mechanism entirely (introduced by mistake in 2.3.0'sPermissionWrapperUI fix). It now simply re-invokes the sameinitializeRequiredPermissions()flow already used elsewhere, which already owns the full canonical settings redirect internally — there is now exactly one place in the entire package that callsopenAppSettings(). - 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, inPermissionBuilder._openSettings(). 2.3.0 only fixed the two paths inPermissionManagerandPermissionActionNotifier; this widget-level path was never audited. An exhaustivegrepacross all oflib/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 afteropenAppSettings(), meaning the stream subscription only started onceopenAppSettings()had already returned — an unrealistically fast resume could theoretically fire into the broadcast stream before anything was listening and be silently dropped (broadcastStreamControllers don't buffer for late subscribers). AddedPermissionManager.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 callingopenAppSettings()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
AppLifecycleObserverregistered viaWidgetsBinding.instance.addObserver(...)was never stored, sodispose()had no reference to pass toremoveObserver()and never removed it. The observer is now stored in alate final AppLifecycleObserver _lifecycleObserverfield and properly removed indispose(). - Removed forced
GoogleFonts.urbanisttypography from every widget in the package (44 call sites across 6 files). AddedpermissionTextStyle(context, {fontSize, fontWeight, color}), which resolves the font family fromTheme.of(context).textThemeon Material andCupertinoTheme.of(context).textThemeon Cupertino — so the host application's own typography now flows through every dialog, screen, and wrapper state, matching how color already worked.google_fontsis no longer a dependency of this package at all (theexample/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.delayedguess from the settings-return flow, in both places it existed (PermissionActionNotifier.initializeRequiredPermissionsandPermissionManager's own settings dialog). Added a realPermissionManager.onAppResumedstream, driven by the existingAppLifecycleObserver, that fires exactly once per genuinepaused → resumedtransition — andPermissionManager.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 newPermissionState.uiStateFor(permission), so UI can switch on one value instead of combining several booleans. Fully additive — every existing field and method onPermissionState/PermissionResultis unchanged.PermissionNotifiergainedsetRequesting/clearRequesting/setOpeningSettings/clearOpeningSettingsto 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 againstPermissionState). - 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 bothPermissionScreenandPermissionWrapper'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 tofalse) 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 afteropenAppSettings()returns, and that fresh result — not a stale one — is what gets returned fromrequestPermission()/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 stampsdidOpenSettings: trueonto the results it writes toPermissionState, 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.
- Added
2.1.2 #
- Fixed the remaining
use_build_context_synchronouslywarning for real this time: the previous ignore comment was placed at thectxdeclaration, which does nothing — the lint fires at each usage site after an async gap. Added the actual missing guard (ctx.mountedre-checked immediately before_resolveExplanationis called, sinceawait shouldShowRationale(...)introduced a fresh gap that theuseSmartRationalefeature added in 2.1.0). - Pinned
permission_handler_platform_interfaceto^4.3.0indev_dependencies(matching exactly whatpermission_handler: ^12.0.1itself depends on) instead of the looserany, to remove any ambiguity in resolution. - If
depend_on_referenced_packagesstill appears forpermission_handler_platform_interfaceafter unzipping this version: runflutter clean && flutter pub getin the package root (not justpub get— a stale.dart_tool/package_config.jsonfrom before the dependency was added can cause the analyzer to miss it even after a plainpub 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 omittingerrorentirely, soPermissionNotifier.clearError()silently did nothing. Fixed with a sentinel default so explicitnullnow correctly clears the error. - Implemented
setPermissionExplanationCallback/setGroupExplanationCallbackonPermissionManager(with pass-through onPermissionActionNotifier), 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)withColor.withValues(alpha: x)throughoutexample/lib/home_page.dart - Fixed
unnecessary_underscoreslint ((_, __)→(_, _)) in twoAsyncValue.whencallbacks in the example - Added a scoped, explained
// ignore: use_build_context_synchronouslyon the oneBuildContextcapture inpermission_manager.dartthat the analyzer can't verify is safe across multiple branches — every actual use is already guarded by its ownctx.mountedcheck immediately before use - Note: if
flutter analyzestill showsdepend_on_referenced_packagesforpermission_handler_platform_interfaceintest/, it's already declared indev_dependencies; runflutter pub getand 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 theenvironment:block, and cleaned up stray blank lines - Cupertino dialogs (
PermissionInitialDialog,PermissionDeniedDialog,PermissionPermanentDialog) now source colors fromCupertinoTheme.of(context)instead of the MaterialTheme.of(context), so iOS styling no longer silently depends onMaterialApp's auto-derived Cupertino theme fallback checkPermissionsStatusnow checks all uncached permissions concurrently viaFuture.waitinstead of sequentially awaiting one at a time- Added
PermissionManager.shouldShowRationale(PermissionType), wrappingpermission_handler's Android-onlyshouldShowRequestRationale - Added opt-in
useSmartRationaleparameter torequestPermission()/requestPermissionWithExplanation(): whentrue, 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 tofalseto 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-mockedPermissionManagersuite) - Corrected
example/app structure:pubspec.yamlmoved toexample/root (was previously nested underexample/lib/) and now points at the local package via apath: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.invalidateon 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.dartimports - 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