devShakib

Reading Health Data in Flutter: The Permission Model Nobody Explains

HealthKit returns empty rather than an error when a user refuses read access. Here is why Apple designed it that way, and how to build a UI that survives it.

You wire up health data, run it on a real device, grant the permission sheet,

and get back an empty list.

No exception. No error code. No indication of whether the user refused, or

whether they genuinely have not walked anywhere this week. Just [].

This is not a bug in whichever package you are using. It is HealthKit behaving

exactly as designed, and once you understand why, a whole class of confusing

behaviour becomes predictable — including several things that look like package

bugs and are not.

Apple deliberately will not tell you if you have read access

HealthKit's authorization status has exactly three values:

HKAuthorizationStatusNotDeterminedHKAuthorizationStatusSharingDeniedHKAuthorizationStatusSharingAuthorized

Read them carefully. All three describe sharing — that is, writing. There

is no read equivalent, and its absence is not an oversight that Apple will get

round to.

The reason is a privacy argument that is genuinely clever once you see it.

Suppose there were an API that told you whether you had read access to, say,

pregnancy data or blood glucose. An app could call it for every data type and

learn the shape of the user's refusals. Refusals are not random — people deny

access to the categories that are sensitive to them. The pattern of what

someone hides is itself health information.

So Apple closes the hole at the source. Your app can ask for read access, and

the user's answer is never reported back to you. You find out by trying.

This means **any API in any package that claims to answer "do I have read

permission?" on iOS is guessing.** It can tell you what your app requested. It

cannot tell you what the user granted.

The three states, and only one is silent

Running against a real HealthKit store surfaces a distinction that matters a

great deal for your UI, and that most implementations flatten:

| State | What a read actually does |

| --- | --- |

| Never requested | Throws. You never called request. |

| Requested, granted | Returns the data |

| Requested, refused | Returns empty — indistinguishable from no data |

The first row is the useful one. If a read throws because permission was never

requested, that is your bug, and it is fixable — you forgot to call the

request method, or you called it for a different set of types than you are now

reading.

The third row is genuinely ambiguous and always will be. Empty means either "the

user said no" or "the user has no data". You cannot distinguish them on iOS, in

any package, ever.

Most health packages collapse these into one path, so a forgotten permission

request looks identical to an empty week and you debug the wrong thing for an

hour. Keeping them distinct is why vitals throws

AuthorizationNotDeterminedException for the first case rather than quietly

returning [].

Android is different — Health Connect will tell you what was granted:

switch (await vitals.readAccessOnAndroid({VitalType.steps})) {  case null:            // iOS: unknowable, attempt the read and handle empty  case final access:    // Android: an actual answer}

The method name is deliberately ugly. It says on the tin that this is an

Android-only capability, so nobody writes a cross-platform code path on top of

an answer that only exists on one side.

Designing a UI that survives the ambiguity

Since empty is unresolvable, the interface has to be built to tolerate it. Three

patterns that work:

Never say "you have no data". You do not know that. Say something that is

true in both cases: "No steps to show for this week." Then offer a way

forward — a link to the Health app's own privacy screen, where the user can

check and change what they shared. That screen is the only place the truth

lives, so send them there rather than guessing on their behalf.

Do not gate your entire onboarding on a successful read. If your flow is

"request permission → read → if empty, show an error", a user who granted

everything but genuinely walked nowhere yesterday gets treated as if they

refused. Let them into the app.

Request narrowly, and late. Ask for the types you need for the screen the

user is on, not everything at launch. A sheet asking for fifteen categories

before the app has shown its value gets refused wholesale, and once refused it

does not re-prompt — the user has to go into Settings, which almost nobody does.

The setup that crashes rather than fails

Health integration has an unusual property: get the configuration wrong and you

do not get a helpful error, you get a hard crash the moment you request access.

iOS needs three things, and none are optional.

The HealthKit capability, in Xcode or an entitlements file:

<key>com.apple.developer.healthkit</key><true/>

Both usage descriptions in Info.plist. Omitting either one crashes the app

the moment you request that kind of access — and note it is both, even if you

only ever intend to read:

<key>NSHealthShareUsageDescription</key><string>Why you read health data.</string><key>NSHealthUpdateUsageDescription</key><string>Why you write health data.</string>

And iOS 15 as a minimum. Sleep stages finer than "asleep", and workout totals,

need iOS 16 — below that they degrade rather than failing outright, which is the

better behaviour but does mean testing on the older version to see what your

users actually get.

Android needs Health Connect, which requires minSdk 26. It is built into

Android 14 and later; older devices need it installed from the Play Store. Check

availability before showing any health UI at all, because the failure mode

otherwise is a permission sheet that never appears.

Types are worth more than they look

A detail that seems pedantic until it costs you an afternoon: health data is

full of units, and most APIs hand you a double.

final weight = await vitals.read(VitalType.bodyMass, from: a, to: b);weight.first.value.pounds;   // a Mass, not a double you hope is kilograms

Getting a Mass rather than a number means the unit conversion happens in one

tested place instead of being scattered through your UI as multiplications by

2.20462. A unit mistake in health data is particularly bad because it is

silent — a weight chart in the wrong unit looks like a plausible chart, not

like an error, and it can persist for months before anyone notices the numbers

are strange.

The same applies to counts. steps.first.count being an int rather than a

double you round is a small thing that removes a small class of bugs.

Aggregate on the platform, not in Dart

A year of heart-rate samples is hundreds of thousands of points. Reading them

all across the platform channel to compute a daily average in Dart is slow,

memory-hungry, and entirely unnecessary — both platforms can reduce the data

before it crosses:

final daily = await vitals.statistics(  VitalType.steps,  from: monthAgo,  to: now,  bucket: VitalBucket.daily,);

Two design points hide in there that matter for correctness.

Each type knows how it should be reduced. Steps sum. Heart rate averages.

Weight takes the latest. Summing a weight series or averaging a step count both

produce numbers that look reasonable and mean nothing, so the default is per-type

rather than a parameter you get to pass wrongly.

A bucket with no samples reports null, never 0. This is the one to

insist on. A day where the user did not wear their watch is unknown, not

zero steps. Conflating them drags every weekly average downwards and produces

charts with phantom crashes to the floor. Any health library that returns 0

for an empty bucket is quietly corrupting your statistics.

Health flows are the hardest thing to test, and the most necessary

You cannot get a CI runner to grant a HealthKit permission sheet. So health code

tends to go untested, which is exactly backwards given how many branches it has.

The way out is an in-memory implementation that models the awkward states, not

just the happy path:

final vitals = FakeVitals()  ..seedCounts(VitalType.steps, {    DateTime(2026, 8, 24): 8210,    DateTime(2026, 8, 25): 11430,  });

And critically, the failures:

FakeVitals(available: false);               // ineligible deviceFakeVitals(permissionSheetSucceeds: false); // user dismissed the sheetFakeVitals(readsAreBlocked: true);          // the iOS silent denial

That third one is the important test. It is the state you cannot reproduce on

demand on a real device without a factory reset, it is the one your users will

hit, and it is the one that produces an empty chart with no error. Being able to

assert what your UI does in that state is most of the value of testing health

code at all.

What I have not verified, and will not claim

One thing worth saying plainly, because health data is a domain where

overclaiming does real harm.

vitals reading is verified. The iOS path is exercised against a real

HealthKit store and the Dart contract has a full unit-test suite behind it.

vitals writing is not verified on either platform. The code is complete

and compiles, but no write has yet been observed to succeed end to end: on the

iOS Simulator save returns Not authorized even after the permission sheet is

granted, and the Android round trip has not been run. It is not yet clear

whether that is a Simulator limitation or a defect in the package.

So use it for reading and aggregating. If you write, verify the values land

correctly in Health or Health Connect before trusting it — a unit-conversion

mistake would be silent, and health records are genuinely awkward to correct

after the fact. Reports from real devices are very welcome.

I would rather say that in the README and in a blog post than have someone

discover it in production with a user's weight history.

Should you use this or health?

health is the established package. It is actively maintained, it covers more

data types, and if you need breadth today you should use it. That is a

straightforward recommendation and I am not going to dress it up.

vitals exists for three things it does not do: nothing is

cast to double, permissions are modelled on what the platforms can actually

answer rather than on a convenient fiction, and the whole API runs in memory for

tests.

If those are your problems, it is on pub.dev — MIT, 160/160 pub points, iOS and

Android. If breadth is your problem, use the other one; that is the honest

answer.

The short version

and it is a good decision.

the Health app rather than guessing.

zero.