Try it free in the sandbox · first FHIR call in 5 minutesFree · 30 synthetic patients · no card
Как создать медицинское приложение на Flutter
Руководство: приложение Flutter, которое подключается к FHIR R4 записи человека с помощью OAuth-согласия и PKCE, безопасно хранит токены, считывает и добавляет показания артериального давления с помощью Dart SDK и обрабатывает отозванный доступ.
В этом учебном пособии мы создадим приложение Flutter, которое подключается к медицинской записи человека в формате HL7 FHIR R4 с его согласия, отображает его показания артериального давления и добавляет новые, используя SDK Dart anpheros_sdk. Приложение напрямую взаимодействует с Anpheros с помощью OAuth-токена, предоставленного человеком, а не с помощью API-ключа, поэтому ему не требуется собственный бэкенд для медицинских данных. Оно работает на iOS и Android; один и тот же код работает на macOS и в вебе благодаря настройке платформы пакета аутентификации.
Как все это работает вместе
┌──────────────────────┐ 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 │
└──────────────────────┘ ─────────────────────────────────────► └───────────────────────────┘
Мобильное приложение никогда не должно содержать API-ключ: его может извлечь кто угодно, а ключ предоставляет доступ ко всем пациентам вашего проекта. С помощью OAuth токен в приложении предоставляет доступ к одной записи, только к тем данным, которые одобрил пользователь, до тех пор, пока он не отзовет его. Этот процесс соответствует стандартному запуску SMART on FHIR в автономном режиме, описанному в Как реализовать согласие пациента с помощью SMART on FHIR; SDK выполняет работу по протоколу.
Перед началом работы
- Flutter 3.22 или новее.
- Проект Anpheros. Войдите на platform.anpheros.com/dashboard с помощью Google и получите sandbox-ключ; проект поставляется с ним.
- На панели управления зарегистрируйте приложение типа public с URI перенаправления
bpapp://callback. Запишите егоclient_id.
Во время закрытого бета-тестирования OAuth-соединения используют sandbox-записи, поэтому вы можете протестировать весь процесс без реальных данных.
flutter create bp_app && cd bp_app
flutter pub add anpheros_sdk flutter_web_auth_2 flutter_secure_storage
Android: добавьте callback-активность flutter_web_auth_2 внутри <application> в android/app/src/main/AndroidManifest.xml, указав вашу схему:
<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 не требует дополнительной настройки для пользовательской схемы.
Шаг 1: подключение к записи
OAuthFlow формирует адрес авторизации с PKCE-вызовом; flutter_web_auth_2 открывает его в безопасной вкладке браузера и возвращается, когда Anpheros перенаправляет на 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()));
}
На экране согласия пользователь входит в систему, видит название вашего приложения, его назначение и то, что именно оно запрашивает, выбирает запись (свою собственную, запись того, о ком он заботится, или новую для вашего приложения) и срок действия доступа. tokens.patient — это идентификатор этой записи для вашего приложения.
Токены обновления ротируются: каждое обновление возвращает новый токен, и onTokens немедленно сохраняет его. Повторное использование старого токена обновления отзывает разрешение, поэтому не пропускайте onTokens.
Шаг 2: чтение показаний
// 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',
);
Показания хранятся как стандартные FHIR Observations, LOINC 85354-9 с компонентами систолического и диастолического давления, поэтому врач пациента, клиническая система или другое приложение, к которому он подключается, считывает их без каких-либо знаний о вашем приложении. authorType: patient записывает, что пациент ввел показание. SDK отправляет идемпотентный ключ с каждым созданием, поэтому повторная попытка при плохом соединении не добавляет показание дважды.
Шаг 3: Экран
// 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),
),
);
}
}
Запустите его с помощью flutter run, нажмите Connect my health record, войдите на экран согласия, разрешите доступ, а затем добавьте показание.
Что делают области доступа
Приложение запросило patient/Observation.rs?category=vital-signs и patient/Observation.c. Это означает, что оно может читать жизненно важные показатели и добавлять наблюдения, и ничего более. Запрос результатов лабораторных исследований, диагнозов или документов человека отвечает 403, и любой другой пациент не существует для этого токена (404). Если человек отзывает доступ в Anpheros, следующий вызов отвечает 401, и экран выше возвращается к Connect.
Запрашивайте больше только тогда, когда функция этого требует, например patient/MedicationStatement.rs для списка лекарств, и объясните причину в своем приложении перед открытием экрана согласия.
Дальнейшие шаги
- Собственный бэкенд. Если ваш продукт также имеет серверные функции (напоминания, панели управления для врачей), см. Как создать медицинский API с помощью TypeScript. Ключи API храните на сервере, а токены OAuth — на устройстве.
- Диаграммы и сводки ИИ.
anpheros.context.buildвозвращает контекст с метками источников для модели: Как создать медицинское приложение с ИИ и бэкендом FHIR. - Необработанный FHIR.
anpheros.fhirпредоставляет операции чтения, поиска, создания, транзакции,$everythingи$summaryдля одной и той же записи (Как хранить медицинские записи с использованием FHIR R4).
Часто задаваемые вопросы
Существует ли SDK FHIR для Flutter?
Да. anpheros_sdk на pub.dev представляет собой клиент Dart для API Anpheros FHIR R4 и REST API с поддержкой OAuth 2.1 / SMART on FHIR, PKCE, обновления токенов, идемпотентных записей и типизированных моделей. Он работает во Flutter на iOS, Android, macOS и в вебе.
Где мобильное приложение для здоровья на Flutter должно хранить токены?
В безопасном хранилище платформы (Keychain на iOS, хранилище на основе Keystore на Android) с помощью пакета, такого как flutter_secure_storage, никогда не в общих настройках (shared preferences) или обычных файлах.
Нужно ли приложению собственное серверное приложение (backend)?
Не для медицинских данных: приложение с согласия пользователя считывает и записывает запись человека напрямую. Вам потребуется серверное приложение для всего остального, что делает ваш продукт, и для хранения ключей API, если вы создаете записи от имени людей.
Могу ли я тестировать без реальных пациентов?
Да. Во время закрытого бета-тестирования OAuth-соединения используют записи из песочницы (sandbox), а в песочнице есть 30 синтетических пациентов с шестимесячной историей.
Связанные материалы
- Как реализовать согласие пациента с помощью SMART on FHIR
- Создание медицинского приложения: те же строительные блоки, но со стороны API
- Аутентификация и OAuth
- API медицинских данных
- Бесплатная FHIR база данных и песочница
Try it free in the sandbox · first FHIR call in 5 minutesFree · 30 synthetic patients · no card