AnpherosAnpheros PlatformDevelopers

How to build a healthcare app with Flutter

Tutorial: a Flutter app that connects to a person's FHIR R4 record with OAuth consent and PKCE, stores tokens securely, reads and adds blood-pressure readings with the Dart SDK, and handles revoked access.

This tutorial builds a Flutter app that connects to a person's HL7 FHIR R4 medical record with their consent, shows their blood-pressure readings and adds new ones, using the Dart SDK anpheros_sdk. The app talks to Anpheros directly with an OAuth token the person granted, not with an API key, so it needs no backend of its own for the medical data. It runs on iOS and Android; the same code works on macOS and the web with the platform setup of the auth package.

How the pieces fit

┌──────────────────────┐ 1. "Connect" opens the consent screen  ┌───────────────────────────┐
│ Flutter app          │ ─────────────────────────────────────► │ Anpheros                  │
│ anpheros_sdk         │ ◄───────────────────────────────────── │ sign-in + consent screen  │
│ flutter_web_auth_2   │ 2. bpapp://callback?code=…             │                           │
│ flutter_secure_storage│ 3. code + PKCE verifier → tokens      │ OAuth 2.1 / SMART on FHIR │
│                      │ 4. read / write within the scopes      │ FHIR R4 record, audit     │
└──────────────────────┘ ─────────────────────────────────────► └───────────────────────────┘

A mobile app must never contain an API key: anyone can extract it, and a key reaches every patient of your project. With OAuth, the token in the app reaches one record, only what the person approved, until they revoke it. The flow is the standard SMART on FHIR standalone launch described in How to implement patient consent with SMART on FHIR; the SDK does the protocol work.

Before you start

During the private beta, OAuth connections use sandbox records, so you can test the whole flow without real data.

flutter create bp_app && cd bp_app
flutter pub add anpheros_sdk flutter_web_auth_2 flutter_secure_storage

Android: add the callback activity of flutter_web_auth_2 inside <application> in android/app/src/main/AndroidManifest.xml, with your scheme:

<activity android:name="com.linusu.flutter_web_auth_2.CallbackActivity" android:exported="true" android:taskAffinity="">
  <intent-filter android:label="flutter_web_auth_2">
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="bpapp" />
  </intent-filter>
</activity>

iOS needs no extra setup for a custom scheme.

Step 1: connect to the record

OAuthFlow builds the authorisation address with a PKCE challenge; flutter_web_auth_2 opens it in a secure browser tab and returns when Anpheros redirects to bpapp://callback.

// lib/record_connection.dart
import 'dart:convert';
import 'dart:math';

import 'package:anpheros_sdk/anpheros_sdk.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';

const _base = 'https://platform.anpheros.com';
const _clientId = 'client_…'; // from the dashboard
const _scopes = [
  'patient/Observation.rs?category=vital-signs', // read vital signs only
  'patient/Observation.c', // add readings
  'offline_access', // stay connected without asking again every 30 minutes
];

class RecordConnection {
  final _flow = OAuthFlow(baseUrl: _base, clientId: _clientId, redirectUri: 'bpapp://callback');
  final _storage = const FlutterSecureStorage();

  /// Opens the consent screen and returns the stored tokens, or throws if the person declines.
  Future<OAuthTokens> connect() async {
    final pkce = Pkce.generate();
    final state = base64UrlEncode(List<int>.generate(16, (_) => Random.secure().nextInt(256)));
    final url = _flow.authorizeUrl(scopes: _scopes, state: state, pkce: pkce, purpose: 'self-care', lang: 'en');

    final result = Uri.parse(await FlutterWebAuth2.authenticate(url: url.toString(), callbackUrlScheme: 'bpapp'));
    if (result.queryParameters['state'] != state) throw StateError('state mismatch');
    final code = result.queryParameters['code'];
    if (code == null) throw StateError(result.queryParameters['error'] ?? 'no code');

    final tokens = await _flow.exchange(code: code, pkce: pkce);
    await _save(tokens);
    return tokens;
  }

  /// The client for the connected record, refreshing and re-saving tokens as needed.
  Future<(Anpheros, String)?> open() async {
    final saved = await _storage.read(key: 'anpheros_tokens');
    if (saved == null) return null;
    final tokens = OAuthTokens.fromJson(jsonDecode(saved) as Map<String, dynamic>);
    final client = Anpheros(
      baseUrl: _base,
      auth: AnpherosAuth.oauth(
        accessToken: tokens.accessToken,
        refreshToken: tokens.refreshToken,
        expiresAt: tokens.expiresAt,
        onRefresh: _flow.refresh,
        onTokens: _save,
      ),
    );
    return (client, tokens.patient!);
  }

  Future<void> disconnect() async {
    final saved = await _storage.read(key: 'anpheros_tokens');
    if (saved != null) {
      final tokens = OAuthTokens.fromJson(jsonDecode(saved) as Map<String, dynamic>);
      await _flow.revoke(tokens.refreshToken ?? tokens.accessToken);
    }
    await _storage.delete(key: 'anpheros_tokens');
  }

  Future<void> _save(OAuthTokens tokens) =>
      _storage.write(key: 'anpheros_tokens', value: jsonEncode(tokens.toJson()));
}

On the consent screen the person signs in, sees your app's name, purpose and exactly what it asks for, picks the record (their own, one they care for, or a new one for your app) and how long the access lasts. tokens.patient is that record's id for your app.

Refresh tokens rotate: every refresh returns a new one, and onTokens stores it right away. Reusing an old refresh token revokes the grant, so do not skip onTokens.

Step 2: read the readings

// lib/readings.dart
import 'package:anpheros_sdk/anpheros_sdk.dart';

class Reading {
  Reading(this.measuredAt, this.systolic, this.diastolic);
  final DateTime measuredAt;
  final double systolic;
  final double diastolic;
}

const _bloodPressure = '85354-9', _systolic = '8480-6', _diastolic = '8462-4';

Future<List<Reading>> loadReadings(Anpheros anpheros, String patientId) async {
  final page = await anpheros.observations.list(patientId, code: _bloodPressure, limit: 50);
  return [
    for (final Observation o in page.data)
      if (o.effectiveDateTime != null)
        Reading(
          o.effectiveDateTime!,
          o.components.firstWhere((c) => c.code == _systolic).value,
          o.components.firstWhere((c) => c.code == _diastolic).value,
        ),
  ];
}

Future<void> addReading(Anpheros anpheros, String patientId, double systolic, double diastolic) =>
    anpheros.observations.createObservation(
      patientId,
      ObservationInput(
        code: _bloodPressure,
        category: 'vital-signs',
        effectiveAt: DateTime.now(),
        components: [
          Component(code: _systolic, value: systolic, unit: 'mm[Hg]'),
          Component(code: _diastolic, value: diastolic, unit: 'mm[Hg]'),
        ],
        authorType: AuthorType.patient,
      ),
      sourceSystem: 'bp-app',
    );

Readings are stored as standard FHIR Observations, LOINC 85354-9 with systolic and diastolic components, so the person's doctor, a clinic system or another app they connect reads them without any knowledge of your app. authorType: patient records that the person entered the reading. The SDK sends an idempotency key with each create, so a retry on a bad connection does not add the reading twice.

Step 3: the screen

// lib/main.dart
import 'package:anpheros_sdk/anpheros_sdk.dart';
import 'package:flutter/material.dart';

import 'readings.dart';
import 'record_connection.dart';

void main() => runApp(const MaterialApp(home: ReadingsScreen()));

class ReadingsScreen extends StatefulWidget {
  const ReadingsScreen({super.key});
  @override
  State<ReadingsScreen> createState() => _ReadingsScreenState();
}

class _ReadingsScreenState extends State<ReadingsScreen> {
  final _connection = RecordConnection();
  Anpheros? _client;
  String? _patient;
  List<Reading> _readings = [];
  String? _error;

  @override
  void initState() {
    super.initState();
    _open();
  }

  Future<void> _open() async {
    final opened = await _connection.open();
    if (opened == null) return;
    setState(() {
      _client = opened.$1;
      _patient = opened.$2;
    });
    await _refresh();
  }

  Future<void> _refresh() async {
    try {
      final readings = await loadReadings(_client!, _patient!);
      setState(() {
        _readings = readings;
        _error = null;
      });
    } on AnpherosException catch (e) {
      if (e.status == 401) {
        await _connection.disconnect(); // access was revoked or has expired
        setState(() {
          _client = null;
          _patient = null;
          _readings = [];
        });
      } else {
        setState(() => _error = e.message);
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    if (_client == null) {
      return Scaffold(
        body: Center(
          child: FilledButton(
            onPressed: () async {
              await _connection.connect();
              await _open();
            },
            child: const Text('Connect my health record'),
          ),
        ),
      );
    }
    return Scaffold(
      appBar: AppBar(title: const Text('Blood pressure')),
      body: RefreshIndicator(
        onRefresh: _refresh,
        child: ListView(children: [
          if (_error != null) ListTile(title: Text(_error!)),
          for (final r in _readings)
            ListTile(
              title: Text('${r.systolic.round()}/${r.diastolic.round()} mmHg'),
              subtitle: Text(r.measuredAt.toLocal().toString()),
            ),
        ]),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () async {
          await addReading(_client!, _patient!, 128, 82); // replace with a form
          await _refresh();
        },
        child: const Icon(Icons.add),
      ),
    );
  }
}

Run it with flutter run, tap Connect my health record, sign in on the consent screen, allow access, then add a reading.

What the scopes do

The app asked for patient/Observation.rs?category=vital-signs and patient/Observation.c. That means it can read vital signs and add observations, and nothing else. A request for the person's lab results, diagnoses or documents answers 403, and any other patient does not exist for this token (404). If the person revokes access in Anpheros, the next call answers 401, and the screen above goes back to Connect.

Ask for more only when a feature needs it, for example patient/MedicationStatement.rs for a medication list, and say why in your app before opening the consent screen.

Going further

Frequently asked questions

Is there a FHIR SDK for Flutter?

Yes. anpheros_sdk on pub.dev is a Dart client for the Anpheros FHIR R4 and REST APIs, with OAuth 2.1 / SMART on FHIR, PKCE, token refresh, idempotent writes and typed models. It works in Flutter on iOS, Android, macOS and the web.

Where should a Flutter health app store tokens?

In the platform's secure storage (Keychain on iOS, Keystore-backed storage on Android) through a package such as flutter_secure_storage, never in shared preferences or plain files.

Does the app need its own backend?

Not for the medical data: the app reads and writes the person's record directly with their consent. You need a backend for anything else your product does, and to hold API keys if you create records on people's behalf.

Can I test without real patients?

Yes. During the private beta, OAuth connections use sandbox records, and the sandbox has 30 synthetic patients with six months of history.

Related