AnpherosAnpheros PlatformDevelopers

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

Flutter로 의료 앱 구축 방법

튜토리얼: OAuth 동의 및 PKCE로 개인의 FHIR R4 레코드에 연결하고 토큰을 안전하게 저장하며 Dart SDK로 혈압 측정을 읽고 추가하며 액세스 거부 처리를 하는 Flutter 앱.

이 튜토리얼에서는 개인의 동의를 얻어 HL7 FHIR R4 의료 기록에 연결하고, 혈압 판독값을 표시하며 새로운 판독값을 추가하는 Flutter 앱을 Dart SDK anpheros_sdk를 사용하여 구축합니다. 이 앱은 API 키가 아닌 개인이 부여한 OAuth 토큰을 사용하여 Anpheros와 직접 통신하므로 의료 데이터를 위한 자체 백엔드가 필요하지 않습니다. iOS 및 Android에서 실행되며, auth 패키지의 플랫폼 설정을 통해 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 키가 포함되어서는 안 됩니다. API 키는 누구나 추출할 수 있으며, 키 하나가 프로젝트의 모든 환자에게 도달합니다. OAuth를 사용하면 앱의 토큰은 한 번에 하나의 레코드에만 도달하며, 사용자가 승인한 내용만 접근 가능하고, 사용자가 취소할 때까지 유효합니다. 이 흐름은 SMART on FHIR을 사용한 환자 동의 구현 방법에 설명된 표준 SMART on FHIR 독립 실행형 시작 흐름이며, SDK가 프로토콜 작업을 처리합니다.

시작하기 전에

비공개 베타 기간 동안 OAuth 연결은 샌드박스 레코드를 사용하므로 실제 데이터 없이 전체 흐름을 테스트할 수 있습니다.

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

Android: android/app/src/main/AndroidManifest.xml의 <application> 안에 flutter_web_auth_2의 콜백 활동을 스킴과 함께 추가하세요:

<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은 해당 레코드의 앱 ID입니다.

리프레시 토큰은 순환됩니다. 모든 새로고침 시 새 토큰이 반환되며, 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 Observation으로 저장되며, 수축기 및 이완기 구성 요소가 있는 LOINC 85354-9를 사용하므로, 해당 환자의 의사, 클리닉 시스템 또는 연결된 다른 앱은 귀하의 앱에 대한 지식 없이도 측정값을 읽을 수 있습니다. authorType: patient은 환자가 측정값을 입력했음을 기록합니다. SDK는 각 생성 시마다 멱등성 키(idempotency key)를 전송하므로, 연결 오류 시 재시도해도 측정값이 두 번 추가되지 않습니다.

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으로 실행하고, 내 건강 기록 연결을 탭한 다음, 동의 화면에서 로그인하고, 액세스를 허용한 후, 판독값을 추가하세요.

스코프의 기능

앱은 patient/Observation.rs?category=vital-signs 및 patient/Observation.c을 요청했습니다. 이는 활력 징후를 읽고 관찰을 추가할 수 있으며 그 외에는 아무것도 할 수 없음을 의미합니다. 환자의 검사 결과, 진단 또는 문서를 요청하는 것은 403에 응답하며, 이 토큰에 대해 다른 환자는 존재하지 않습니다(404). Anpheros에서 환자가 액세스를 취소하면 다음 호출은 401에 응답하고 위의 화면은 Connect로 돌아갑니다.

기능에 필요한 경우에만 더 많은 스코프를 요청하십시오. 예를 들어, 약물 목록의 경우 patient/MedicationStatement.rs을 요청하고 동의 화면을 열기 전에 앱에서 그 이유를 설명하십시오.

추가 정보

자주 묻는 질문

Flutter용 FHIR SDK가 있나요?

네. pub.dev의 anpheros_sdk는 Anpheros FHIR R4 및 REST API용 Dart 클라이언트로, OAuth 2.1 / SMART on FHIR, PKCE, 토큰 새로고침, 멱등성 쓰기 및 타입화된 모델을 지원합니다. Flutter에서 iOS, Android, macOS 및 웹에서 작동합니다.

Flutter 건강 앱은 토큰을 어디에 저장해야 하나요?

iOS의 Keychain, Android의 Keystore-backed storage와 같은 플랫폼의 보안 저장소에 flutter_secure_storage과 같은 패키지를 통해 저장해야 하며, 공유 기본 설정이나 일반 파일에는 절대 저장해서는 안 됩니다.

앱에 자체 백엔드가 필요한가요?

의료 데이터의 경우 필요하지 않습니다. 앱은 사용자의 동의 하에 개인의 기록을 직접 읽고 씁니다. 제품의 다른 기능이나 사용자를 대신하여 기록을 생성할 때 API 키를 보관하는 데는 백엔드가 필요합니다.

실제 환자 없이 테스트할 수 있나요?

네. 비공개 베타 기간 동안 OAuth 연결은 샌드박스 기록을 사용하며, 샌드박스에는 6개월간의 기록을 가진 30명의 합성 환자가 있습니다.

관련 자료

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