AnpherosAnpheros PlatformDevelopers

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

Come creare un'app sanitaria con Flutter

Tutorial: un'app Flutter che si connette al record FHIR R4 di una persona con consenso OAuth e PKCE, memorizza token in modo sicuro, legge e aggiunge letture di pressione sanguigna con il Dart SDK e gestisce l'accesso revocato.

Questo tutorial crea un'app Flutter che si connette al record medico HL7 FHIR R4 di una persona con il suo consenso, mostra le sue letture della pressione sanguigna e ne aggiunge di nuove, utilizzando l'SDK Dart anpheros_sdk. L'app comunica direttamente con Anpheros tramite un token OAuth concesso dalla persona, non con una chiave API, quindi non necessita di un proprio backend per i dati medici. Funziona su iOS e Android; lo stesso codice funziona su macOS e sul web con la configurazione della piattaforma del pacchetto di autenticazione.

Come i pezzi si incastrano

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

Un'app mobile non deve mai contenere una chiave API: chiunque può estrarla e una chiave raggiunge ogni paziente del tuo progetto. Con OAuth, il token nell'app raggiunge un record, solo ciò che la persona ha approvato, finché non lo revoca. Il flusso è il lancio standalone standard SMART on FHIR descritto in Come implementare il consenso del paziente con SMART on FHIR; l'SDK si occupa del lavoro di protocollo.

Prima di iniziare

Durante la beta privata, le connessioni OAuth utilizzano record sandbox, quindi puoi testare l'intero flusso senza dati reali.

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

Android: aggiungi l'activity di callback di flutter_web_auth_2 all'interno di <application> in android/app/src/main/AndroidManifest.xml, con il tuo 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 non richiede configurazioni aggiuntive per uno schema personalizzato.

Passaggio 1: connettersi al record

OAuthFlow costruisce l'indirizzo di autorizzazione con una sfida PKCE; flutter_web_auth_2 lo apre in una scheda del browser sicura e restituisce quando Anpheros reindirizza a 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()));
}

Nella schermata del consenso, la persona accede, vede il nome della tua app, lo scopo e esattamente cosa richiede, sceglie il record (il proprio, uno a cui tiene o uno nuovo per la tua app) e per quanto tempo dura l'accesso. tokens.patient è l'id di quel record per la tua app.

I token di aggiornamento ruotano: ogni aggiornamento restituisce uno nuovo e onTokens lo memorizza subito. Riutilizzare un vecchio token di aggiornamento revoca la concessione, quindi non saltare onTokens.

Passaggio 2: leggere le letture

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

Le letture sono memorizzate come osservazioni FHIR standard, LOINC 85354-9 con componenti sistolica e diastolica, in modo che il medico della persona, un sistema di clinica o un'altra app a cui si connettono le legga senza alcuna conoscenza della tua app. authorType: patient registra che la persona ha inserito la lettura. L'SDK invia una chiave di idempotenza con ogni creazione, quindi un nuovo tentativo in caso di connessione non riuscita non aggiunge la lettura due volte.

Passaggio 3: lo schermo

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

Eseguilo con flutter run, tocca Connetti la mia cartella clinica, accedi alla schermata di consenso, consenti l'accesso, quindi aggiungi una lettura.

Cosa fanno gli scope

L'app ha richiesto patient/Observation.rs?category=vital-signs e patient/Observation.c. Ciò significa che può leggere i segni vitali e aggiungere osservazioni, e nient'altro. Una richiesta per i risultati di laboratorio, le diagnosi o i documenti della persona risponde 403, e qualsiasi altro paziente non esiste per questo token (404). Se la persona revoca l'accesso in Anpheros, la chiamata successiva risponde 401, e la schermata sopra torna a Connect.

Richiedi di più solo quando una funzionalità lo necessita, ad esempio patient/MedicationStatement.rs per un elenco di farmaci, e spiega il motivo nella tua app prima di aprire la schermata di consenso.

Approfondimenti

Domande frequenti

Esiste un SDK FHIR per Flutter?

Sì. anpheros_sdk su pub.dev è un client Dart per le API Anpheros FHIR R4 e REST, con OAuth 2.1 / SMART on FHIR, PKCE, token refresh, scritture idempotenti e modelli tipizzati. Funziona in Flutter su iOS, Android, macOS e web.

Dove dovrebbe un'app sanitaria Flutter memorizzare i token?

Nello storage sicuro della piattaforma (Keychain su iOS, storage basato su Keystore su Android) tramite un pacchetto come flutter_secure_storage, mai nelle shared preferences o in file di testo semplice.

L'app necessita di un proprio backend?

Non per i dati medici: l'app legge e scrive il record della persona direttamente con il suo consenso. È necessario un backend per qualsiasi altra cosa faccia il tuo prodotto e per conservare le API key se crei record per conto delle persone.

Posso testare senza pazienti reali?

Sì. Durante la beta privata, le connessioni OAuth utilizzano record sandbox e la sandbox ha 30 pazienti sintetici con sei mesi di cronologia.

Correlati

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