AnpherosAnpheros PlatformDevelopers

Try it free in the sandbox · first FHIR call in 5 minutesFree · 30 synthetic patients · no card

Eine Gesundheits-App mit Flutter entwickeln

Tutorial: Eine Flutter-App, die sich mit der FHIR R4 Akte einer Person über OAuth Consent und PKCE verbindet, Tokens sicher speichert, Blutdruckwerte mit dem Dart SDK liest und hinzufügt und widerrufene Zugriffe handhabt.

Dieses Tutorial erstellt eine Flutter-App, die sich mit dem HL7 FHIR R4-Medizindatensatz einer Person mit deren Zustimmung verbindet, deren Blutdruckwerte anzeigt und neue hinzufügt, unter Verwendung des Dart SDK anpheros_sdk. Die App kommuniziert direkt mit Anpheros über ein vom Benutzer erteiltes OAuth-Token, nicht mit einem API-Schlüssel, sodass sie kein eigenes Backend für die medizinischen Daten benötigt. Sie läuft unter iOS und Android; derselbe Code funktioniert unter macOS und im Web mit der Plattformkonfiguration des Auth-Pakets.

Wie die Teile zusammenpassen

┌──────────────────────┐ 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     │
└──────────────────────┘ ─────────────────────────────────────► └───────────────────────────┘

Eine mobile App darf niemals einen API-Schlüssel enthalten: Jeder kann ihn extrahieren, und ein Schlüssel erreicht jeden Patienten Ihres Projekts. Mit OAuth erreicht der Token in der App einen Datensatz, nur das, was die Person genehmigt hat, bis sie ihn widerruft. Der Ablauf ist der Standard-SMART-on-FHIR-Standalone-Launch, wie in How to implement patient consent with SMART on FHIR beschrieben; das SDK erledigt die Protokollarbeit.

Bevor Sie beginnen

Während der privaten Beta verwenden OAuth-Verbindungen Sandbox-Datensätze, sodass Sie den gesamten Ablauf ohne reale Daten testen können.

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

Android: Fügen Sie die Callback-Aktivität von flutter_web_auth_2 in <application> in android/app/src/main/AndroidManifest.xml ein, mit Ihrem Schema:

<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 benötigt keine zusätzliche Einrichtung für ein benutzerdefiniertes Schema.

Schritt 1: Verbindung zum Datensatz herstellen

OAuthFlow erstellt die Autorisierungsadresse mit einer PKCE-Herausforderung; flutter_web_auth_2 öffnet diese in einem sicheren Browser-Tab und kehrt zurück, wenn Anpheros zu bpapp://callback weiterleitet.

// 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()));
}

Auf dem Zustimmungsbildschirm meldet sich die Person an, sieht den Namen Ihrer App, den Zweck und genau das, wonach sie fragt, wählt den Datensatz aus (ihren eigenen, einen, der ihr wichtig ist, oder einen neuen für Ihre App) und legt fest, wie lange der Zugriff dauern soll. tokens.patient ist die ID dieses Datensatzes für Ihre App.

Refresh-Tokens rotieren: Jede Aktualisierung gibt ein neues Token zurück, und onTokens speichert es sofort. Die Wiederverwendung eines alten Refresh-Tokens widerruft die Berechtigung, überspringen Sie daher onTokens nicht.

Schritt 2: Messwerte lesen

// 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',
    );

Messwerte werden als Standard-FHIR-Observations gespeichert, LOINC 85354-9 mit systolischen und diastolischen Komponenten, sodass der Arzt der Person, ein Kliniksystem oder eine andere App, mit der sie sich verbindet, diese ohne Kenntnis Ihrer App lesen kann. authorType: patient erfasst, dass die Person den Messwert eingegeben hat. Das SDK sendet bei jeder Erstellung einen Idempotenzschlüssel mit, sodass ein erneuter Versuch bei einer schlechten Verbindung den Messwert nicht zweimal hinzufügt.

Schritt 3: Der Bildschirm

// 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),
      ),
    );
  }
}

Führen Sie es mit flutter run aus, tippen Sie auf Meine Gesundheitsakte verbinden, melden Sie sich auf dem Zustimmungsbildschirm an, erlauben Sie den Zugriff und fügen Sie dann einen Messwert hinzu.

Was die Scopes bewirken

Die App hat patient/Observation.rs?category=vital-signs und patient/Observation.c angefordert. Das bedeutet, sie kann Vitalparameter lesen und Beobachtungen hinzufügen, und nichts weiter. Eine Anfrage nach den Laborergebnissen, Diagnosen oder Dokumenten der Person beantwortet 403, und jeder andere Patient existiert für dieses Token nicht (404). Wenn die Person den Zugriff in Anpheros widerruft, beantwortet der nächste Aufruf 401, und der obige Bildschirm kehrt zu Connect zurück.

Fordern Sie nur dann mehr an, wenn eine Funktion dies erfordert, z. B. patient/MedicationStatement.rs für eine Medikamentenliste, und begründen Sie dies in Ihrer App, bevor Sie den Zustimmungsbildschirm öffnen.

Weiterführende Informationen

Häufig gestellte Fragen

Gibt es ein FHIR SDK für Flutter?

Ja. anpheros_sdk auf pub.dev ist ein Dart-Client für die Anpheros FHIR R4 und REST APIs, mit OAuth 2.1 / SMART on FHIR, PKCE, Token-Aktualisierung, idempotenten Schreibvorgängen und typisierten Modellen. Es funktioniert in Flutter unter iOS, Android, macOS und im Web.

Wo sollte eine Flutter-Gesundheits-App Tokens speichern?

Im sicheren Speicher der Plattform (Keychain unter iOS, Keystore-gestützter Speicher unter Android) über ein Paket wie flutter_secure_storage, niemals in Shared Preferences oder einfachen Dateien.

Benötigt die App ein eigenes Backend?

Nicht für die medizinischen Daten: Die App liest und schreibt den Datensatz der Person direkt mit deren Zustimmung. Sie benötigen ein Backend für alles andere, was Ihr Produkt tut, und um API-Schlüssel zu speichern, wenn Sie Datensätze im Namen von Personen erstellen.

Kann ich ohne echte Patienten testen?

Ja. Während der privaten Beta-Phase verwenden OAuth-Verbindungen Sandbox-Datensätze, und die Sandbox verfügt über 30 synthetische Patienten mit sechs Monaten Historie.

Verwandte Themen

Try it free in the sandbox · first FHIR call in 5 minutesFree · 30 synthetic patients · no card

Product updates by email

New features, guides and the end of the beta. One email a month at most.

Send me occasional Anpheros Platform updates by email (new features, API changes, guides). I'll confirm by email and can unsubscribe in one click from any email. Privacy