# Flutterでヘルスケアアプリを構築する方法

> チュートリアル：OAuthの同意とPKCEを使用して個人のFHIR R4レコードに接続し、トークンを安全に保存し、血圧測定値を読み取って追加し、アクセス取り消しを処理するFlutterアプリ。

Source: https://developers.anpheros.com/ja/guides/healthcare-app-flutter

**このチュートリアルでは、個人のHL7 FHIR R4医療記録に同意を得て接続し、血圧測定値を表示して新しい測定値を追加するFlutterアプリを、Dart SDK `anpheros_sdk` を使用して構築します。** このアプリは、個人が許可したOAuthトークンを使用してAnpherosと直接通信し、APIキーは使用しないため、医療データ用の独自のバックエンドは必要ありません。iOSおよびAndroidで実行でき、認証パッケージのプラットフォーム設定により、macOSおよびWebでも同じコードが機能します。

## 各コンポーネントの連携方法

```
┌──────────────────────┐ 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を使用すると、アプリ内のトークンは特定の患者レコードにのみ有効であり、その人が承認した範囲に限定されます。また、その人がトークンを無効化するまで有効です。このフローは、[How to implement patient consent with SMART on FHIR](https://developers.anpheros.com/ja/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は、作成ごとに冪等性キーを送信するため、接続不良によるリトライでも測定値が二重に追加されることはありません。

## ステップ 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` で実行し、**Connect my health record** をタップし、同意画面でサインインしてアクセスを許可してから、読み込みを追加します。

## スコープの機能

アプリは`patient/Observation.rs?category=vital-signs`と`patient/Observation.c`を要求しました。これは、バイタルサインを読み取り、観察を追加できることを意味し、それ以外の操作はできません。個人の検査結果、診断、またはドキュメントを要求する回答は`403`となり、このトークンでは他の患者は存在しません（`404`）。Anpherosでアクセスが取り消された場合、次の呼び出しは`401`を返し、上記の画面は**Connect**に戻ります。

機能が必要な場合にのみ、より多くのスコープを要求してください。例えば、投薬リストには`patient/MedicationStatement.rs`を要求し、同意画面を開く前にアプリでその理由を説明してください。

## さらに進む

- **独自のバックエンド。** 製品にサーバー機能（リマインダー、臨床医ダッシュボードなど）もある場合は、[TypeScriptでヘルスケアAPIを構築する方法](https://developers.anpheros.com/ja/guides/healthcare-api-typescript)を参照してください。APIキーはサーバーに、OAuthトークンはデバイスに保管してください。
- **チャートとAI要約。** `anpheros.context.build`は、モデルのソースラベル付きコンテキストを返します。[FHIRバックエンドでAIヘルスケアアプリを構築する方法](https://developers.anpheros.com/ja/guides/build-ai-healthcare-app)を参照してください。
- **生のFHIR。** `anpheros.fhir`は、同じレコードに対して、読み取り、検索、作成、トランザクション、`$everything`、および`$summary`を提供します（[FHIR R4を使用して医療記録を保存する方法](https://developers.anpheros.com/ja/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、Webで動作します。

### Flutterヘルスケアアプリはどこにトークンを保存すべきですか？
プラットフォームのセキュアストレージ（iOSではKeychain、AndroidではKeystoreバックストレージ）に、`flutter_secure_storage`のようなパッケージを介して保存してください。共有設定やプレーンファイルには絶対に保存しないでください。

### アプリは独自のバックエンドを必要としますか？
医療データについては不要です。アプリは、ユーザーの同意を得て、個人の記録を直接読み書きします。製品が実行するその他の機能や、ユーザーの代わりにレコードを作成する場合のAPIキーを保持するためにバックエンドが必要です。

### 実際の患者なしでテストできますか？
はい。プライベートベータ期間中は、OAuth接続はサンドボックスレコードを使用し、サンドボックスには6ヶ月分の履歴を持つ30人の合成患者がいます。

## 関連情報

- [SMART on FHIR を使用した患者同意の実装方法](https://developers.anpheros.com/ja/guides/smart-on-fhir-backend)
- [ヘルスケアアプリの構築](https://developers.anpheros.com/ja/guides/build-a-healthcare-app): API 側の同じビルディングブロック
- [認証と OAuth](https://developers.anpheros.com/ja/guides/authentication)
- [医療データ API](https://developers.anpheros.com/ja/guides/medical-data-api)
- [無料 FHIR データベースとサンドボックス](https://developers.anpheros.com/ja/guides/free-fhir-database)
