はじめに
前回、こちらの記事で、flutterで「Appleヘルスケアとの連携」ボタンを設置し、AppleのHealthKit APIにデータアクセスの許可を実装するところまでを記載した。
今回は、Flutterアプリからサーバーにヘルスデータを送信し、Gemini APIを通じてアドバイスのテキスト(例:昨日はよく眠れたみたいですね。この調子!)を生成する部分を実装したい。
ヘルスデータの取得
Flutterアプリで、ユーザーからの許可を得た後、指定した期間(例えば、前日)のヘルスデータを取得する。前回で、既にHealthFactory
を使用して許可を取得する部分を実装しているので、次にデータ取得のロジックを追加する。
特定の期間(この場合は前日の始まりから今日の始まりまで)にわたってユーザーの健康データを取得し、それをコンソールに出力するFlutterの非同期関数fetchData
を追加する。
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
メソッドを呼び出してデータをフェッチする。取得したデータポイントはコンソールに出力された。
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日の総歩数と総運動時間を出力するように計算ロジックを追加。
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日の合計値が出力されるようになった。
flutter: 平均睡眠時間:663.0分
flutter: 総歩数: 11426.0 歩
flutter: 総運動時間: 14.0 分
ヘルスデータをサーバーに送信
次に、取得した健康データをサーバーに送信する機能を実装する。
アプリ側(Flutter)
ApiClientクラスへのPOSTメソッド追加
取得した健康データをJSON形式に変換し、HTTP POSTリクエストを使用してサーバーに送信する。
ApiClient
クラスに健康データを送信するためのメソッド(sendHealthData
)を追加する。
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へのリクエスト送信時に再利用可能なクライアントを使用できる。
// /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
インスタンスを介して健康データをサーバー側に送信する。
// /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リクエストを介してユーザーからのヘルスデータを受け取る。
// 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
クラスを定義。このハンドラーは、クライアントから送られてくる健康データを受け取り、そのデータを基にアドバイスを生成する。
// 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で送られてきた健康データ(歩数・運動時間・睡眠時間)に対して、アドバイス文を生成する関数を作成して、読み込む。
// 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をコールしてアドバイス文(今回の場合は「目標達成おめでとう!引き続き順調にウォーキング続けていこうぜ」を生成することができた。
コメント