# Flutter로 의료 앱 구축하기

> 튜토리얼: OAuth 동의 및 PKCE를 사용하여 개인의 FHIR R4 기록에 연결하고, 토큰을 안전하게 저장하며, 혈압 판독값을 읽고 추가하는 Flutter 앱을 Dart SDK를 사용하여 구축합니다.

Source: https://developers.anpheros.com/ko/guides/healthcare-app-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을 사용한 환자 동의 구현 방법](https://developers.anpheros.com/ko/guides/smart-on-fhir-backend)에 설명된 표준 SMART on FHIR 독립 실행형 시작 흐름이며, SDK가 프로토콜 작업을 처리합니다.

## 시작하기 전에

- Flutter 3.22 이상 버전.
- Anpheros 프로젝트. [platform.anpheros.com/dashboard](https://platform.anpheros.com/dashboard/)에서 Google로 로그인하고 샌드박스 키를 받으세요. 프로젝트에 포함되어 있습니다.
- 대시보드에서 리디렉션 URI `bpapp://callback`을 가진 **public** 유형의 애플리케이션을 등록하세요. `client_id`를 기록해 두세요.

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

```bash
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`의 콜백 활동을 스킴과 함께 추가하세요:

```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`로 리디렉션될 때 반환됩니다.

```dart
// 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단계: 측정값 읽기

```dart
// 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단계: 화면

```dart
// 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`을 요청하고 동의 화면을 열기 전에 앱에서 그 이유를 설명하십시오.

## 추가 정보

- **자체 백엔드 구축.** 제품에 서버 기능(알림, 의료진 대시보드 등)이 있는 경우, [TypeScript로 의료 API 구축 방법](https://developers.anpheros.com/ko/guides/healthcare-api-typescript)을 참조하세요. API 키는 서버에, OAuth 토큰은 기기에 보관하세요.
- **차트 및 AI 요약.** `anpheros.context.build`은 모델에 대한 소스 레이블이 지정된 컨텍스트를 반환합니다: [FHIR 백엔드를 사용하여 AI 의료 앱 구축 방법](https://developers.anpheros.com/ko/guides/build-ai-healthcare-app).
- **원시 FHIR.** `anpheros.fhir`는 동일한 레코드에 대한 읽기, 검색, 생성, 트랜잭션, `$everything` 및 `$summary` 기능을 제공합니다 ([FHIR R4를 사용하여 의료 기록 저장 방법](https://developers.anpheros.com/ko/guides/store-medical-records-fhir)).

## 자주 묻는 질문

### 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명의 합성 환자가 있습니다.

## 관련 자료

- [SMART on FHIR을 사용한 환자 동의 구현 방법](https://developers.anpheros.com/ko/guides/smart-on-fhir-backend)
- [헬스케어 앱 구축](https://developers.anpheros.com/guides/build-a-healthcare-app): API 측면에서 동일한 빌딩 블록
- [인증 및 OAuth](https://developers.anpheros.com/ko/guides/authentication)
- [의료 데이터 API](https://developers.anpheros.com/ko/guides/medical-data-api)
- [무료 FHIR 데이터베이스 및 샌드박스](https://developers.anpheros.com/ko/guides/free-fhir-database)
