【Flutter】Apple HealthKit API から行動ログを取得し、Gemini APIを元にアドバイス文を生成:実装編

この記事は約28分で読めます。

はじめに

前回、こちらの記事で、flutterで「Appleヘルスケアとの連携」ボタンを設置し、AppleのHealthKit APIにデータアクセスの許可を実装するところまでを記載した。

今回は、Flutterアプリからサーバーにヘルスデータを送信し、Gemini APIを通じてアドバイスのテキスト(例:昨日はよく眠れたみたいですね。この調子!)を生成する部分を実装したい。

ヘルスデータの取得

Flutterアプリで、ユーザーからの許可を得た後、指定した期間(例えば、前日)のヘルスデータを取得する。前回で、既にHealthFactoryを使用して許可を取得する部分を実装しているので、次にデータ取得のロジックを追加する。

特定の期間(この場合は前日の始まりから今日の始まりまで)にわたってユーザーの健康データを取得し、それをコンソールに出力するFlutterの非同期関数fetchDataを追加する。

Dart
import 'package:flutter/material.dart';
import 'package:health/health.dart';

class HealthCareAppIntegrationScreen extends StatefulWidget {
  const HealthCareAppIntegrationScreen({Key? key}) : super(key: key);

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

class _HealthCareAppIntegrationScreenState
    extends State<HealthCareAppIntegrationScreen> {
  // ここで連携したいデータタイプを定義
  static final types = [
    HealthDataType.STEPS,
    HealthDataType.WEIGHT,
    HealthDataType.WORKOUT,
    HealthDataType.EXERCISE_TIME,
    HealthDataType.MINDFULNESS,
    HealthDataType.SLEEP_IN_BED,
    HealthDataType.SLEEP_ASLEEP,
    HealthDataType.SLEEP_AWAKE,
    HealthDataType.SLEEP_DEEP,
    HealthDataType.SLEEP_REM,
  ];
  // READ only
  final permissions = types.map((e) => HealthDataAccess.READ).toList();

  // create a HealthFactory for use in the app
  HealthFactory health = HealthFactory(useHealthConnectIfAvailable: true);

  Future<void> requestPermissionsAndFetchData() async {
    final permissions = types.map((e) => HealthDataAccess.READ).toList();
    bool requestResult =
        await health.requestAuthorization(types, permissions: permissions);

    if (requestResult) {
      print("Authorization granted");
      fetchData();
    } else {
      print("Authorization denied");
    }
  }

  Future<void> fetchData() async {
    DateTime now = DateTime.now();
    DateTime startOfDay = DateTime(now.year, now.month, now.day - 1);
    DateTime endOfDay = DateTime(now.year, now.month, now.day);

    List<HealthDataPoint> healthDataPoints =
        await health.getHealthDataFromTypes(startOfDay, endOfDay, types);
    healthDataPoints = HealthFactory.removeDuplicates(healthDataPoints);

    for (var dataPoint in healthDataPoints) {
      print(
          "${dataPoint.typeString}: ${dataPoint.value} ${dataPoint.unitString}");
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('ヘルスケアアプリとの連携'),
      ),
      body: Center(
        child: ElevatedButton(
          onPressed: () => requestPermissionsAndFetchData(),
          child: const Text('連携する'),
        ),
      ),
    );
  }
}

ユーザーが「連携する」ボタンをクリックすると、requestPermissionsAndFetchDataメソッドが呼び出される。

このメソッドはまずHealthKitまたはGoogle Fitからのデータアクセス許可をリクエストし、ユーザーが許可した場合にのみfetchDataメソッドを呼び出してデータをフェッチする。取得したデータポイントはコンソールに出力された。

Dart
flutter: >> isAuthorized: true
flutter: Authorization granted
flutter: STEPS: 142.0 COUNT
flutter: STEPS: 55.0 COUNT
flutter: STEPS: 10.0 COUNT
flutter: STEPS: 20.0 COUNT
flutter: STEPS: 84.0 COUNT
flutter: STEPS: 92.0 COUNT
flutter: STEPS: 39.0 COUNT
flutter: STEPS: 31.0 COUNT
flutter: STEPS: 19.0 COUNT
flutter: STEPS: 67.0 COUNT
flutter: STEPS: 13.0 COUNT
flutter: STEPS: 724.0 COUNT
flutter: STEPS: 624.0 COUNT
flutter: STEPS: 836.0 COUNT
flutter: STEPS: 775.0 COUNT
flutter: STEPS: 231.0 COUNT
flutter: STEPS: 297.0 COUNT
flutter: STEPS: 264.0 COUNT
flutter: STEPS: 351.0 COUNT
flutter: STEPS: 205.0 COUNT
flutter: STEPS: 94.0 COUNT
flutter: STEPS: 88.0 COUNT
flutter: STEPS: 642.0 COUNT
flutter: STEPS: 556.0 COUNT
flutter: STEPS: 833.0 COUNT
flutter: STEPS: 836.0 COUNT
flutter: STEPS: 271.0 COUNT
flutter: STEPS: 277.0 COUNT
flutter: STEPS: 553.0 COUNT
flutter: STEPS: 611.0 COUNT
flutter: STEPS: 52.0 COUNT
flutter: STEPS: 33.0 COUNT
flutter: STEPS: 109.0 COUNT
flutter: STEPS: 479.0 COUNT
flutter: STEPS: 400.0 COUNT
flutter: STEPS: 68.0 COUNT
flutter: STEPS: 80.0 COUNT
flutter: STEPS: 41.0 COUNT
flutter: STEPS: 72.0 COUNT
flutter: STEPS: 68.0 COUNT
flutter: STEPS: 26.0 COUNT
flutter: STEPS: 10.0 COUNT
flutter: STEPS: 179.0 COUNT
flutter: STEPS: 41.0 COUNT
flutter: STEPS: 11.0 COUNT
flutter: STEPS: 68.0 COUNT
flutter: STEPS: 12.0 COUNT
flutter: STEPS: 37.0 COUNT
flutter: EXERCISE_TIME: 1.0 MINUTE
flutter: EXERCISE_TIME: 1.0 MINUTE
flutter: EXERCISE_TIME: 1.0 MINUTE
flutter: EXERCISE_TIME: 1.0 MINUTE
flutter: EXERCISE_TIME: 1.0 MINUTE
flutter: EXERCISE_TIME: 1.0 MINUTE
flutter: EXERCISE_TIME: 1.0 MINUTE
flutter: EXERCISE_TIME: 1.0 MINUTE
flutter: EXERCISE_TIME: 1.0 MINUTE
flutter: EXERCISE_TIME: 1.0 MINUTE
flutter: EXERCISE_TIME: 1.0 MINUTE
flutter: EXERCISE_TIME: 1.0 MINUTE
flutter: EXERCISE_TIME: 1.0 MINUTE
flutter: EXERCISE_TIME: 1.0 MINUTE
flutter: SLEEP_IN_BED: 663.0 MINUTE

あれ、睡眠時間以外は、項目が多いなと。1日分のデータなので、歩数データは、1日の間に歩いた総歩数が出ると思ったのだが。調べてみると下記が理由みたい。

歩数データの多さ

歩数データが多く表示されている理由の一つは、HealthKitが1日の間に歩いた総歩数ではなく、特定の時間帯やアクティビティごとに歩数を記録しているため。

例えば、ユーザーが1日の中で複数回に分けて歩いた場合、それぞれのセッションが個別のデータポイントとして記録される。これにより、1日の総歩数を得るためには、取得した全ての歩数データを合計する必要がある。

運動時間データの複数回記録

運動時間(EXERCISE_TIME)が1分のデータポイントで複数回記録されているのは、短い運動セッションが複数回にわたって行われたことを示している。HealthKitは、ユーザーがアクティビティを行った正確な時間と期間を追跡し、それぞれのセッションを個別のデータポイントとして記録することがある。

1日の総歩数と総運動時間を出力するように変更

1日の総歩数と総運動時間を出力するように計算ロジックを追加。

Dart
Future<void> fetchData() async {
    DateTime now = DateTime.now();
    DateTime startOfDay = DateTime(now.year, now.month, now.day - 1);
    DateTime endOfDay = DateTime(now.year, now.month, now.day);

    List<HealthDataPoint> healthDataPoints =
        await health.getHealthDataFromTypes(startOfDay, endOfDay, types);
    healthDataPoints = HealthFactory.removeDuplicates(healthDataPoints);

    // 歩数と運動時間の合計を計算
    double totalSteps = 0;
    double totalExerciseMinutes = 0;
    double totalSleepMinutes = 0;

    for (var dataPoint in healthDataPoints) {
      if (dataPoint.type == HealthDataType.STEPS &&
          dataPoint.value is NumericHealthValue) {
        totalSteps += (dataPoint.value as NumericHealthValue).numericValue;
      } else if (dataPoint.type == HealthDataType.EXERCISE_TIME &&
          dataPoint.value is NumericHealthValue) {
        totalExerciseMinutes +=
            (dataPoint.value as NumericHealthValue).numericValue;
      } else if (dataPoint.type == HealthDataType.SLEEP_IN_BED) {
        totalSleepMinutes +=
            (dataPoint.value as NumericHealthValue).numericValue;
      }
    }

    // コンソールに出力
    print("平均睡眠時間:$totalSleepMinutes分");
    print("総歩数: $totalSteps 歩");
    print("総運動時間: $totalExerciseMinutes 分");
  }

下記のように、無事1日の合計値が出力されるようになった。

Dart
flutter: 平均睡眠時間:663.0分
flutter: 総歩数: 11426.0
flutter: 総運動時間: 14.0

ヘルスデータをサーバーに送信

次に、取得した健康データをサーバーに送信する機能を実装する。

アプリ側(Flutter)

ApiClientクラスへのPOSTメソッド追加

取得した健康データをJSON形式に変換し、HTTP POSTリクエストを使用してサーバーに送信する。

ApiClientクラスに健康データを送信するためのメソッド(sendHealthData )を追加する。

Dart
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;

class ApiClient {
  final String apiUrl;
  ApiClient(this.apiUrl);

  Future<Map<String, String>> apiHeaders() async {
    final idToken = await FirebaseAuth.instance.currentUser?.getIdToken();
    return {
      'Content-Type': 'application/json',
      'Accept': 'application/json',
      'Authorization': 'Bearer $idToken',
    };
  }

  // 健康データをサーバーに送信するメソッド
  Future<void> sendHealthData(Map<String, dynamic> healthData) async {
    final url = Uri.parse('$apiUrl/app/healthdata');
    final headers = await apiHeaders();
    final response = await http.post(
      url,
      headers: headers,
      body: jsonEncode(healthData),
    );

    if (response.statusCode != 200) {
      throw Exception('Failed to send health data');
    }
  }
}

HealthServiceクラスとProviderの作成

次に、外部APIとの通信を担当するHealthServiceクラスを作成し、このクラスを通じて健康データを送信する機能を提供する。このクラスはApiClientのインスタンスを必要とし、コンストラクタを通じて依存性注入を行う。これにより、APIへのリクエスト送信時に再利用可能なクライアントを使用できる。

Dart
// /lib/services/health_service.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/api_client.dart';
import '../api/api_client_provider.dart';

final healthServiceProvider = Provider<HealthService>((ref) {
  final apiClient = ref.watch(apiClientProvider);
  return HealthService(apiClient: apiClient);
});

class HealthService {
  final ApiClient apiClient;

  HealthService({required this.apiClient});

  Future<void> sendHealthData(Map<String, dynamic> healthData) async {
    try {
      await apiClient.sendHealthData(healthData);
      print('Health data sent successfully');
    } catch (e) {
      print('Failed to send health data: $e');
    }
  }
}

Flutterの、ヘルスケアアプリとの連携画面では、HealthServiceインスタンスを介して健康データをサーバー側に送信する。

Dart
// /lib/screens/home/health_care_app_integration_screen.dart

import 'package:clocky_app/services/health_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:health/health.dart';

class HealthCareAppIntegrationScreen extends ConsumerStatefulWidget {
  const HealthCareAppIntegrationScreen({Key? key}) : super(key: key);

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

class _HealthCareAppIntegrationScreenState
    extends ConsumerState<HealthCareAppIntegrationScreen> {
  // ここで連携したいデータタイプを定義
  static final types = [
    HealthDataType.STEPS,
    HealthDataType.WEIGHT,
    HealthDataType.WORKOUT,
    HealthDataType.EXERCISE_TIME,
    HealthDataType.MINDFULNESS,
    HealthDataType.SLEEP_IN_BED,
    HealthDataType.SLEEP_ASLEEP,
    HealthDataType.SLEEP_AWAKE,
    HealthDataType.SLEEP_DEEP,
    HealthDataType.SLEEP_REM,
  ];
  // READ only
  final permissions = types.map((e) => HealthDataAccess.READ).toList();

  // create a HealthFactory for use in the app
  HealthFactory health = HealthFactory(useHealthConnectIfAvailable: true);

  Future<void> requestPermissionsAndFetchData() async {
    final permissions = types.map((e) => HealthDataAccess.READ).toList();
    bool requestResult =
        await health.requestAuthorization(types, permissions: permissions);

    if (requestResult) {
      print("Authorization granted");
      fetchData();
    } else {
      print("Authorization denied");
    }
  }

  Future<void> fetchData() async {
    DateTime now = DateTime.now();
    DateTime startOfDay = DateTime(now.year, now.month, now.day - 1);
    DateTime endOfDay = DateTime(now.year, now.month, now.day);

    List<HealthDataPoint> healthDataPoints =
        await health.getHealthDataFromTypes(startOfDay, endOfDay, types);
    healthDataPoints = HealthFactory.removeDuplicates(healthDataPoints);

    // 歩数と運動時間の合計を計算
    int totalSteps = 0;
    double totalExerciseMinutes = 0;
    double totalSleepMinutes = 0;

    for (var dataPoint in healthDataPoints) {
      if (dataPoint.type == HealthDataType.STEPS &&
          dataPoint.value is NumericHealthValue) {
        totalSteps +=
            (dataPoint.value as NumericHealthValue).numericValue.round();
      } else if (dataPoint.type == HealthDataType.EXERCISE_TIME &&
          dataPoint.value is NumericHealthValue) {
        totalExerciseMinutes +=
            (dataPoint.value as NumericHealthValue).numericValue;
      } else if (dataPoint.type == HealthDataType.SLEEP_IN_BED) {
        totalSleepMinutes +=
            (dataPoint.value as NumericHealthValue).numericValue;
      }
    }

    // コンソールに出力
    print("平均睡眠時間:$totalSleepMinutes分");
    print("総歩数: $totalSteps 歩");
    print("総運動時間: $totalExerciseMinutes 分");

    // HealthServiceを使用してデータ送信
    final healthService = ref.read(healthServiceProvider);
    await healthService.sendHealthData({
      'steps': totalSteps,
      'exerciseMinutes': totalExerciseMinutes,
      'sleepMinutes': totalSleepMinutes,
    });

    print("データ送信完了");
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('ヘルスケアアプリとの連携'),
      ),
      body: Center(
        child: ElevatedButton(
          onPressed: () => requestPermissionsAndFetchData(),
          child: const Text('連携する'),
        ),
      ),
    );
  }
}

サーバー側 (Go)

ヘルスデータを受け取るエンドポイントを設定

サーバー側(Go言語)で、Flutterアプリから送信されたヘルスデータを受け取るエンドポイントを設定する。受け取ったデータに対して、Gemini APIを使用してアドバイスのテキストを生成する。

NewHealthHandler関数を使用して、ヘルスデータを処理するためのハンドラー(HealthHandlerインスタンス)を生成し、/healthdataエンドポイントに関連付ける。このエンドポイントは、POSTリクエストを介してユーザーからのヘルスデータを受け取る。

Go
// server.go

package clocky_be

import (
	"github.com/EarEEG-dev/clocky_be/pubsub"
	"github.com/google/uuid"
	"github.com/jmoiron/sqlx"
	"github.com/labstack/echo/v4"
	echoMiddleware "github.com/labstack/echo/v4/middleware"
	"github.com/labstack/gommon/log"
	echoSwagger "github.com/swaggo/echo-swagger"
)

type Server struct {
	e *echo.Echo
	c *Config
}

func NewServer(config *Config, db *sqlx.DB) *Server {
	e := echo.New()

	hh := NewHealthHandler(db, config)

	// アプリ用のエンドポイント
	appGroup := e.Group("/app")
	appGroup.POST("/healthdata", hh.HandlePostHealthData)
	return &Server{e: e, c: config}
}

func (s *Server) Start() {
	s.e.Start("0.0.0.0:" + s.c.Port)
}

handler_health.goファイルで、健康データ(歩数、運動時間、睡眠時間)を処理するサーバーサイドのHealthHandlerクラスを定義。このハンドラーは、クライアントから送られてくる健康データを受け取り、そのデータを基にアドバイスを生成する。

Go
// handler_health.go
package clocky_be

import (
	"fmt"
	"net/http"

	"github.com/jmoiron/sqlx"
	"github.com/labstack/echo/v4"
)

type HealthHandler struct {
    db *sqlx.DB
	Config *Config
}

type HealthData struct {
    Steps          int     `json:"steps"`
    ExerciseMinutes float64 `json:"exerciseMinutes"`
    SleepMinutes    float64 `json:"sleepMinutes"`
}

func NewHealthHandler(db *sqlx.DB, config *Config) *HealthHandler {
    return &HealthHandler{
		db: db,
		Config: config,
	}
}

// HandlePostHealthData はユーザーからのヘルスデータを受け取り、サーバーのコンソールに出力するAPIエンドポイントです。
func (hh *HealthHandler) HandlePostHealthData(c echo.Context) error {
    uid := c.Get("uid").(string)
    if uid == "" {
        return c.JSON(http.StatusUnauthorized, map[string]string{"error": "User not authenticated"})
    }

    var healthData HealthData
    if err := c.Bind(&healthData); err != nil {
        return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid health data format"})
    }

    // 受け取ったヘルスデータをコンソールに出力
    fmt.Printf("Received health data: %+vn", healthData)

		// ヘルスデータからアドバイスを生成
    healthAdvice := GenerateHealthAdvice(hh.Config, healthData)

		// 生成されたアドバイスをコンソールに出力
		fmt.Printf("Generated health advice: %sn", healthAdvice)
    
    // アドバイスをクライアントに返す
    return c.JSON(http.StatusOK, map[string]string{"message": "Health data received successfully", "advice": healthAdvice})
}

Gemini APIを使って、アプリからPostで送られてきた健康データ(歩数・運動時間・睡眠時間)に対して、アドバイス文を生成する関数を作成して、読み込む。

Go
// gemini_api_handler.go

// GenerateHealthAdvice は健康データに基づいてアドバイスを生成する関数
func GenerateHealthAdvice(config *Config, healthData HealthData) string {
	healthInfo := fmt.Sprintf("今日の歩数は%d歩、運動時間は%.1f分、睡眠時間は%.1f分です。", healthData.Steps, healthData.ExerciseMinutes, healthData.SleepMinutes)
	prompt := "健康データの情報をもとに、ユーザーに有益なアドバイスを提供してください。n" +
		"例えば、歩数が目標に達していない場合は、『少し歩きましょう』、運動時間が十分でない場合は『短い時間でも体を動かすと良いですよ』などです。n" +
		"それでは、下記の入力文に基づいて、有益なアドバイスを20文字以上50文字以内で作成してください。n" +
		"丁寧語ではなく友達に呼びかける感じの口調でお願いします。生意気な表現は使わないでください。n#n" +
		healthInfo

	healthAdvice, err := GenerateText(config, prompt)
	if err != nil {
		log.Fatalf("Error generating health advice: %v", err)
	}

	return healthAdvice
}

検証

アプリで、Health Kitとの連携画面で「連携する」ボタンを押したところ、サーバー側のコンソールで、下記のように前日の合計歩数・運動時間・睡眠時間を取得できた。

さらに取得した健康データをもとにGemini APIをコールしてアドバイス文(今回の場合は「目標達成おめでとう!引き続き順調にウォーキング続けていこうぜ」を生成することができた。

コメント

タイトルとURLをコピーしました