Flutter iOS 音声メモアプリ開発(5)HealthKitの組込み

HealthKitのリポジトリに血圧データを登録するパッケージ(health_kit_reporter)の使い方について、前回記事化しました。

Flutter iOS 音声メモアプリ開発(4)HealthKitで血圧登録

Apple社は、HealthKitという健康とフィットネスデータのリポジトリを提供しています。iOSでは「ヘルスケア」というアプリからアクセスできます。今回は、音声メモアプリか…

本記事では、音声メモアプリに血圧登録のためのhealth_kit_reporterモジュールを組み込んでいきます。

ちなみに、今回ちょっとした文字列操作のコードをChatGPTで書かせてみました。最後におまけとして記述しています。ちょっと面倒なロジックを書くときなどは、自分より正確かもしれません。

血圧登録操作

まず、想定される操作を考えてみます。

1)音声メモを入力

2)音声メモをタップすると、編集画面に遷移

3)音声メモの内容が、血圧登録に関するものだったら、登録用のWidgetを表示

4)登録ボタンを押して、HealthKitリポジトリに登録

画面遷移

1)音声メモを入力 

2)音声メモをタップすると、編集画面に遷移します。アプリをインストール後の最初のこの操作で、HealthKitへのアクセス許可をユーザに求めます。この画面は一度しか表示させることができません。 もし、ユーザから許可されなかった場合は、アプリを再インストールするか、iOSの設定画面からユーザに許可の操作をしてもらう必要があります。

3)音声メモに「血圧」という単語と、2つの数字が含まれている場合には、登録用のwidgetsを表示します。日時を変更するためにflutter_datetime_pickerパッケージを使っています。

4)「ヘルスケアアプリに登録」ボタンを押すと登録を実行し、結果を表示させます。

登録済みである印(✅)をSentimentの顔絵文字の代わりに設定します。

サンプルコード

元のコードは、main.dartとcard.dartの2つでした。今回は、card.dartを改変し、health_reporter.dartを追加します。

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:health_kit_reporter/health_kit_reporter.dart';
import 'package:health_kit_reporter/model/payload/correlation.dart';
import 'package:health_kit_reporter/model/payload/device.dart';
import 'package:health_kit_reporter/model/payload/quantity.dart';
import 'package:health_kit_reporter/model/payload/source.dart';
import 'package:health_kit_reporter/model/payload/source_revision.dart';
import 'package:health_kit_reporter/model/predicate.dart';
import 'package:health_kit_reporter/model/type/correlation_type.dart';
import 'package:health_kit_reporter/model/type/quantity_type.dart';

mixin HealthKitReporterMixin {
 // 省略 前記事参照  デバイス情報等
}

class MyHealth extends State with HealthKitReporterMixin {
  final String bd = '';
  final String bs = '';
  static bool saved = false;

  Future<void> authorize() async {
   // 省略 前記事参照  ユーザに血圧データへのアクセスを求める
  }

  Future<bool> saveBloodPressureCorrelation(
      int bs, int bd, DateTime bDate) async {
    try {
      //final now = DateTime.now();
      //final minuteAgo = now.add(const Duration(minutes: -1));
      final sys = Quantity(
          'testSysUUID234',
          QuantityType.bloodPressureSystolic.identifier,
          bDate.millisecondsSinceEpoch,
          bDate.millisecondsSinceEpoch,
          device,
          sourceRevision,
          QuantityHarmonized(bs, 'mmHg', null));
      final dia = Quantity(
          'testDiaUUID456',
          QuantityType.bloodPressureDiastolic.identifier,
          bDate.millisecondsSinceEpoch,
          bDate.millisecondsSinceEpoch,
          device,
          sourceRevision,
          QuantityHarmonized(bd, 'mmHg', null));
      final correlationJarmonized = CorrelationHarmonized([sys, dia], [], null);
      final correlation = Correlation(
          'test',
          CorrelationType.bloodPressure.identifier,
          bDate.millisecondsSinceEpoch,
          bDate.millisecondsSinceEpoch,
          device,
          sourceRevision,
          correlationJarmonized);
      saved = await HealthKitReporter.save(correlation);
      print('BloodPressureCorrelationSaved: $saved');

      return saved;
    } catch (e) {
      return false;

      print(e);
    }
  }

  @override
  Widget build(BuildContext context) {
    // TODO: implement build
    throw UnimplementedError();
  }
}

前回の記事(パッケージのサンプルコード)からの変更点はsaveBloodPressureCorrelation()で、引数を受け取れるようにして、登録できたかの結果をbool値で返すようにしています。

デバイス情報は、今回はサンプルコードの固定のままにしていますが、デバイス情報を取得するパッケージdevice_info_plusを使えば取得可能なようです。

import 'package:flutter/material.dart';
import 'package:flutter_datetime_picker/flutter_datetime_picker.dart';
import 'package:intl/intl.dart';
import 'package:intl/date_symbol_data_local.dart';

import 'main.dart';
import 'health_reporter.dart';

class CardPage extends StatefulWidget {
  const CardPage({Key? key, required this.card_item}) : super(key: key);
  final Todo card_item;

  @override
  CardPageState createState() => CardPageState();
}

class CardPageState extends State<CardPage> {
  late Todo item;
  final TextEditingController _textController = TextEditingController();

  bool resTextValue = false;
  int sbp = 0; //  最高血圧 収縮期血圧
  int dbp = 0; //  最低血圧 拡張期血圧

  DateTime _myDate = DateTime.now();

  final MyHealth _myHealth = MyHealth();

  void _textValue(String input) {
    sbp = 0;
    dbp = 0;

    try {
      bool bloodP = input.contains('血圧'); // 血圧という文字列を含む

      RegExp exp = RegExp(r"(\d{2,3}).*?(\d{2,3})"); // 2つの数字を含む
      Match match = exp.firstMatch(input) as Match;

      if (match != null) {
        String? firstNumber = match.group(1);
        String? secondNumber = match.group(2);

        int blood1 = int.parse(firstNumber!);
        int blood2 = int.parse(secondNumber!);

        if (blood1 > blood2) {   // 大きい方を最大血圧にする
          sbp = blood1;
          dbp = blood2;
        } else {
          sbp = blood2;
          dbp = blood1;
        }

        if (bloodP && blood1 != 0 && blood2 != 0) {
          resTextValue = true;    // 血圧登録データですよ
        } else {
          resTextValue = false;
        }
      }
      setState(() {});
    } catch (e) {
      resTextValue = false;
    }
  }

  @override
  void initState() {
    super.initState();
    item = widget.card_item;
    _textValue(item.title);   // メモが血圧登録データか調べる
    initializeDateFormatting('ja_JP');
    _myDate = item.time;        // DatePickerの初期値は、メモの登録日時とする

    _myHealth.authorize();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(item.time.toIso8601String())),
      body: Container(
        padding: const EdgeInsets.all(10),
        child: Column(
          children: [
            Card(
              child: ListTile(
                onTap: () async {
                  var ans = await _showEditDialog();
                  if (_textController.text != '' && ans == 'save') {
                    _editDataText(_textController.text);
                    _textValue(item.title);
                    setState(() {});
                  }
                  if (ans == 'cancel') {
                    _textController.text = '';
                  }
                },
                title: Text(item.title),
                //subtitle: Text(item.time.toString()),
                trailing: Text(item.sentiment,
                    style: Theme.of(context).textTheme.headlineSmall),
              ),
            ),
            const SizedBox(height: 10),
            if (resTextValue)  // 血圧登録用のメモだった場合にCardを表示
              Card(
                  child: Column(children: [
                TextButton(
                  style: ButtonStyle(
                    foregroundColor: MaterialStateProperty.all<Color>(
                        Colors.blue.withOpacity(0.7)),
                  ),
                  onPressed: () {
                    DatePicker.showDateTimePicker(context,
                        showTitleActions: true, onConfirm: (date) {
                      initializeDateFormatting('ja_JP');
                      setState(() {
                        _myDate = date;
                      });
                    }, currentTime: _myDate, locale: LocaleType.jp);
                  },
                  child: Text(
                    DateFormat.yMMMEd('ja_JP').add_jm().format(_myDate),
                    style: TextStyle(fontSize: 20),
                  ),
                ),
                const SizedBox(height: 20),
                Text("最高血圧:$sbp", style: const TextStyle(fontSize: 20)),
                Text("最低血圧:$dbp", style: const TextStyle(fontSize: 20)),
                const SizedBox(height: 20),
                ElevatedButton(
                  onPressed: () async {
                    _textValue(item.title);
                    if (resTextValue) {
                      bool saved = await _myHealth.saveBloodPressureCorrelation(
                          sbp, dbp, _myDate);
                      if (saved) {
                        var ans = await _showSavedDialog(
                            '血圧を登録しました。\nヘルスケアアプリから確認できます。');
                        setState(() {
                          item.sentiment = '✅';
                        });
                      } else {
                        var ans = await _showSavedDialog(
                            '血圧を登録できませんでした。\niOSの設定からヘルスケアを選択し、アプリからのデータアクセスを許可してください');
                      }
                    }
                  },
                  child: const Text("ヘルスケアアプリに登録"),
                  //icon: const Icon(Icons.health_and_safety))
                )
              ]))
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
       // 省略 前記事参照  編集用ダイアログの表示
    );
  }

  Future<void> _editDataText(String inputText) async {
    if (inputText != '') {
      item.title = inputText;
      item.save();
    }
  }

  Future<String> _showEditDialog() async {
    // 省略 前記事参照  編集用ダイアログ
  }

  Future<String> _showSavedDialog(String message) async {
    var result = await showDialog(
      context: context,
      builder: (_) {
        return AlertDialog(
          insetPadding: const EdgeInsets.all(8),
          title: const Text('ヘルスケアの登録'),
          content: Text(message),
          actions: [
            TextButton(
              child: const Text('close'),
              onPressed: () {
                Navigator.pop(context, 'close');
              },
            ),
          ],
        );
      },
    );
    return result;
  }
}

おまけ)ChatGPTのコード生成

card.dartの変更点は_textValue()を追加した以外は、ほぼUIの変更点です。DatePickerの使い方は少し難しかったので、後日記事化したいと思います。

 _textValue()はメモの内容が、血圧登録用のメモか否かを判断して、血圧登録用のメモであった場合に、最高血圧と最低血圧をsbp,dbpにセットするものです。

こちらは、正規表現を使っていますが、あまり得意ではないので、ChatGPTにコードを書いてもらいました。

質問:flutterで文字列から二つの数字を取り出す方法は

回答:Flutterでは、Dart言語の正規表現を使用して、文字列から二つの数字を取り出すことができます。以下は例です。

void main() {
  String str = "The numbers are 123 and 456";
  
  RegExp exp = RegExp(r"(\d+).*?(\d+)");
  Match match = exp.firstMatch(str);
  
  if (match != null) {
    String firstNumber = match.group(1);
    String secondNumber = match.group(2);
    
    print("First number: $firstNumber");  // Output: First number: 123
    print("Second number: $secondNumber");  // Output: Second number: 456
  }
}

この例では、RegExpクラスを使用して正規表現パターンを定義し、firstMatchメソッドを使用して、文字列中で最初にマッチするパターンを取得します。Matchオブジェクトが返され、groupメソッドを使用して、パターンのグループ化された部分を取得します。group(1)は、正規表現パターンの最初のグループにマッチする文字列を返し、group(2)は2番目のグループにマッチする文字列を返します。

正規表現パターン (\d+).*?(\d+) は、上記の例の正規表現パターンに相当し、同様に、数字をグループ化するために丸括弧を使用します。.+?は、最短一致を表す非貪欲マッチングで、2番目の数字を取得するために使用されます。

ほぼそのまま使えるコードでした。なかなか便利なものです。