AnpherosAnpheros PlatformDevelopers

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

Comment créer une application de santé avec Flutter

Tutoriel : une application Flutter qui se connecte au dossier FHIR R4 d'une personne avec consentement OAuth et PKCE, stocke les jetons en toute sécurité, lit et ajoute des relevés de tension artérielle avec le SDK Dart, et gère l'accès révoqué.

Ce tutoriel crée une application Flutter qui se connecte au dossier médical HL7 FHIR R4 d'une personne avec son consentement, affiche ses mesures de tension artérielle et en ajoute de nouvelles, en utilisant le SDK Dart anpheros_sdk. L'application communique directement avec Anpheros avec un jeton OAuth que la personne a accordé, et non avec une clé API, elle n'a donc pas besoin de son propre backend pour les données médicales. Elle s'exécute sur iOS et Android ; le même code fonctionne sur macOS et le web avec la configuration de plateforme du package d'authentification.

Comment les pièces s'assemblent

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

Une application mobile ne doit jamais contenir de clé d'API : n'importe qui peut l'extraire, et une clé atteint tous les patients de votre projet. Avec OAuth, le jeton dans l'application atteint un enregistrement, uniquement ce que la personne a approuvé, jusqu'à ce qu'elle le révoque. Le flux est le lancement autonome standard SMART on FHIR décrit dans Comment implémenter le consentement du patient avec SMART on FHIR ; le SDK s'occupe du travail de protocole.

Avant de commencer

Pendant la bêta privée, les connexions OAuth utilisent des enregistrements sandbox, vous pouvez donc tester l'intégralité du flux sans données réelles.

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

Android : ajoutez l'activité de rappel de flutter_web_auth_2 à l'intérieur de <application> dans android/app/src/main/AndroidManifest.xml, avec votre schéma :

<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 ne nécessite aucune configuration supplémentaire pour un schéma personnalisé.

Étape 1 : se connecter au dossier

OAuthFlow construit l'adresse d'autorisation avec un défi PKCE ; flutter_web_auth_2 l'ouvre dans un onglet de navigateur sécurisé et revient lorsque Anpheros redirige vers 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()));
}

Sur l'écran de consentement, la personne se connecte, voit le nom de votre application, son objectif et exactement ce qu'elle demande, choisit le dossier (le sien, celui dont elle s'occupe, ou un nouveau pour votre application) et la durée de l'accès. tokens.patient est l'identifiant de ce dossier pour votre application.

Les jetons de rafraîchissement tournent : chaque rafraîchissement renvoie un nouveau jeton, et onTokens le stocke immédiatement. La réutilisation d'un ancien jeton de rafraîchissement révoque l'autorisation, alors ne sautez pas onTokens.

Étape 2 : lire les relevés

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

Les relevés sont stockés sous forme d'Observations FHIR standard, LOINC 85354-9 avec des composantes systolique et diastolique, de sorte que le médecin de la personne, un système de clinique ou une autre application à laquelle elle se connecte les lit sans aucune connaissance de votre application. authorType: patient enregistre que la personne a saisi le relevé. Le SDK envoie une clé d'idempotence avec chaque création, de sorte qu'une nouvelle tentative en cas de mauvaise connexion n'ajoute pas le relevé deux fois.

Étape 3 : l'écran

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

Exécutez-le avec flutter run, appuyez sur Connect my health record, connectez-vous sur l'écran de consentement, autorisez l'accès, puis ajoutez une lecture.

Ce que font les scopes

L'application a demandé patient/Observation.rs?category=vital-signs et patient/Observation.c. Cela signifie qu'elle peut lire les signes vitaux et ajouter des observations, et rien d'autre. Une requête pour les résultats de laboratoire, les diagnostics ou les documents de la personne répond à 403, et tout autre patient n'existe pas pour ce token (404). Si la personne révoque l'accès dans Anpheros, le prochain appel répondra 401, et l'écran ci-dessus reviendra à Connect.

Demandez-en plus uniquement lorsqu'une fonctionnalité en a besoin, par exemple patient/MedicationStatement.rs pour une liste de médicaments, et expliquez pourquoi dans votre application avant d'ouvrir l'écran de consentement.

Aller plus loin

Questions fréquemment posées

Existe-t-il un SDK FHIR pour Flutter ?

Oui. anpheros_sdk sur pub.dev est un client Dart pour les API Anpheros FHIR R4 et REST, avec OAuth 2.1 / SMART on FHIR, PKCE, rafraîchissement de jeton, écritures idempotentes et modèles typés. Il fonctionne dans Flutter sur iOS, Android, macOS et le web.

Où une application de santé Flutter doit-elle stocker les jetons ?

Dans le stockage sécurisé de la plateforme (Keychain sur iOS, stockage basé sur Keystore sur Android) via un package tel que flutter_secure_storage, jamais dans les préférences partagées ou les fichiers en clair.

L'application a-t-elle besoin de son propre backend ?

Pas pour les données médicales : l'application lit et écrit directement le dossier de la personne avec son consentement. Vous avez besoin d'un backend pour tout ce que votre produit fait d'autre, et pour conserver les clés API si vous créez des dossiers au nom de personnes.

Puis-je tester sans vrais patients ?

Oui. Pendant la bêta privée, les connexions OAuth utilisent des enregistrements sandbox, et la sandbox contient 30 patients synthétiques avec six mois d'historique.

Liens Utiles

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