Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Write support on Google Fit and HealthKit #430

Merged
merged 8 commits into from
Nov 14, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import android.content.Intent
import android.os.Handler
import android.util.Log
import androidx.annotation.NonNull
import androidx.annotation.Nullable
import io.flutter.plugin.common.PluginRegistry.ActivityResultListener
import java.util.concurrent.TimeUnit
import kotlin.concurrent.thread
Expand All @@ -29,6 +28,7 @@ import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding

const val GOOGLE_FIT_PERMISSIONS_REQUEST_CODE = 1111
const val CHANNEL_NAME = "flutter_health"
const val MMOLL_2_MGDL = 18.0 // 1 mmoll= 18 mgdl

class HealthPlugin(private var channel: MethodChannel? = null) : MethodCallHandler, ActivityResultListener, Result, ActivityAware, FlutterPlugin {
private var result: Result? = null
Expand All @@ -53,7 +53,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : MethodCallHandl
private var SLEEP_AWAKE = "SLEEP_AWAKE"

override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
channel = MethodChannel(flutterPluginBinding.binaryMessenger, CHANNEL_NAME);
channel = MethodChannel(flutterPluginBinding.binaryMessenger, CHANNEL_NAME)
channel?.setMethodCallHandler(this)
}

Expand Down Expand Up @@ -127,7 +127,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : MethodCallHandl
mResult?.success(true)
} else if (resultCode == Activity.RESULT_CANCELED) {
Log.d("FLUTTER_HEALTH", "Access Denied!")
mResult?.success(false);
mResult?.success(false)
}
}
return false
Expand Down Expand Up @@ -157,7 +157,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : MethodCallHandl
}
}

private fun getUnit(type: String): Field {
private fun getField(type: String): Field {
return when (type) {
BODY_FAT_PERCENTAGE -> Field.FIELD_PERCENTAGE
HEIGHT -> Field.FIELD_HEIGHT
Expand All @@ -179,20 +179,90 @@ class HealthPlugin(private var channel: MethodChannel? = null) : MethodCallHandl
}
}

private fun isIntField(dataSource: DataSource, unit: Field): Boolean {
val dataPoint = DataPoint.builder(dataSource).build()
val value = dataPoint.getValue(unit)
return value.format == Field.FORMAT_INT32
}

/// Extracts the (numeric) value from a Health Data Point
private fun getHealthDataValue(dataPoint: DataPoint, unit: Field): Any {
return try {
dataPoint.getValue(unit).asFloat()
} catch (e1: Exception) {
try {
dataPoint.getValue(unit).asInt()
} catch (e2: Exception) {
try {
dataPoint.getValue(unit).asString()
} catch (e3: Exception) {
Log.e("FLUTTER_HEALTH::ERROR", e3.toString())
}
}
private fun getHealthDataValue(dataPoint: DataPoint, field: Field): Any {
val value = dataPoint.getValue(field)
// Conversion is needed because glucose is stored as mmoll in Google Fit;
// while mgdl is used for glucose in this plugin.
val isGlucose = field == HealthFields.FIELD_BLOOD_GLUCOSE_LEVEL
return when (value.format) {
Field.FORMAT_FLOAT -> if (!isGlucose) value.asFloat() else value.asFloat() * MMOLL_2_MGDL
Field.FORMAT_INT32 -> value.asInt()
Field.FORMAT_STRING -> value.asString()
else -> Log.e("Unsupported format:", value.format.toString())
}
}

private fun writeData(call: MethodCall, result: Result) {

if (activity == null) {
result.success(false)
return
}

val type = call.argument<String>("dataTypeKey")!!
val startTime = call.argument<Long>("startTime")!!
val endTime = call.argument<Long>("endTime")!!
val value = call.argument<Float>( "value")!!

// Look up data type and unit for the type key
val dataType = keyToHealthDataType(type)
val field = getField(type)

val typesBuilder = FitnessOptions.builder()
typesBuilder.addDataType(dataType, FitnessOptions.ACCESS_WRITE)

val dataSource = DataSource.Builder()
.setDataType(dataType)
.setType(DataSource.TYPE_RAW)
.setDevice(Device.getLocalDevice(activity!!.applicationContext))
.setAppPackageName(activity!!.applicationContext)
.build()

val builder = if (startTime == endTime)
DataPoint.builder(dataSource)
.setTimestamp(startTime, TimeUnit.MILLISECONDS)
else
DataPoint.builder(dataSource)
.setTimeInterval(startTime, endTime, TimeUnit.MILLISECONDS)

// Conversion is needed because glucose is stored as mmoll in Google Fit;
// while mgdl is used for glucose in this plugin.
val isGlucose = field == HealthFields.FIELD_BLOOD_GLUCOSE_LEVEL
val dataPoint = if (!isIntField(dataSource, field))
builder.setField(field, if (!isGlucose) value else (value/ MMOLL_2_MGDL).toFloat()).build() else
builder.setField(field, value.toInt()).build()

val dataSet = DataSet.builder(dataSource)
.add(dataPoint)
.build()

if (dataType == DataType.TYPE_SLEEP_SEGMENT) {
typesBuilder.accessSleepSessions(FitnessOptions.ACCESS_READ)
}
val fitnessOptions = typesBuilder.build()


try {
val googleSignInAccount = GoogleSignIn.getAccountForExtension(activity!!.applicationContext, fitnessOptions)
Fitness.getHistoryClient(activity!!.applicationContext, googleSignInAccount)
.insertData(dataSet)
.addOnSuccessListener {
Log.i("FLUTTER_HEALTH::SUCCESS", "DataSet added successfully!")
result.success(true)
}
.addOnFailureListener { e ->
Log.w("FLUTTER_HEALTH::ERROR", "There was an error adding the DataSet", e)
result.success(false)
}
} catch (e3: Exception) {
result.success(false)
}
}

Expand All @@ -208,7 +278,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : MethodCallHandl

// Look up data type and unit for the type key
val dataType = keyToHealthDataType(type)
val unit = getUnit(type)
val field = getField(type)

/// Start a new thread for doing a GoogleFit data lookup
thread {
Expand All @@ -234,12 +304,11 @@ class HealthPlugin(private var channel: MethodChannel? = null) : MethodCallHandl
/// For each data point, extract the contents and send them to Flutter, along with date and unit.
val healthData = dataPoints.dataPoints.mapIndexed { _, dataPoint ->
return@mapIndexed hashMapOf(
"value" to getHealthDataValue(dataPoint, unit),
"value" to getHealthDataValue(dataPoint, field),
"date_from" to dataPoint.getStartTime(TimeUnit.MILLISECONDS),
"date_to" to dataPoint.getEndTime(TimeUnit.MILLISECONDS),
"unit" to unit.toString(),
"source_name" to (dataPoint.getOriginalDataSource().appPackageName ?: (dataPoint.originalDataSource?.getDevice()?.model ?: "" )),
"source_id" to dataPoint.getOriginalDataSource().getStreamIdentifier()
"source_name" to (dataPoint.originalDataSource.appPackageName ?: (dataPoint.originalDataSource.device?.model ?: "" )),
"source_id" to dataPoint.originalDataSource.streamIdentifier
)
}

Expand Down Expand Up @@ -316,7 +385,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : MethodCallHandl
val types = args["types"] as ArrayList<*>
for (typeKey in types) {
if (typeKey !is String) continue
typesBuilder.addDataType(keyToHealthDataType(typeKey), FitnessOptions.ACCESS_READ)
typesBuilder.addDataType(keyToHealthDataType(typeKey), FitnessOptions.ACCESS_WRITE)
if (typeKey == SLEEP_ASLEEP || typeKey == SLEEP_AWAKE) {
typesBuilder.accessSleepSessions(FitnessOptions.ACCESS_READ)
}
Expand Down Expand Up @@ -355,6 +424,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : MethodCallHandl
when (call.method) {
"requestAuthorization" -> requestAuthorization(call, result)
"getData" -> getData(call, result)
"writeData" -> writeData(call, result)
else -> result.notImplemented()
}
}
Expand Down
60 changes: 55 additions & 5 deletions packages/health/example/lib/main.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:math';

import 'package:flutter/material.dart';
import 'package:health/health.dart';
Expand All @@ -15,19 +16,44 @@ enum AppState {
FETCHING_DATA,
DATA_READY,
NO_DATA,
AUTH_NOT_GRANTED
AUTH_NOT_GRANTED,
DATA_ADDED,
DATA_NOT_ADDED,
}

class _MyAppState extends State<MyApp> {
List<HealthDataPoint> _healthDataList = [];
AppState _state = AppState.DATA_NOT_FETCHED;
int _nofSteps = 10;
double _mgdl = 10.0;

@override
void initState() {
super.initState();
}

/// Fetch data from the health plugin and print it
Future addData() async {
HealthFactory health = HealthFactory();

final time = DateTime.now();
final ago = time.add(Duration(minutes: -5));

_nofSteps = Random().nextInt(10);
_mgdl = Random().nextInt(10) * 1.0;
bool success = await health.writeHealthData(
_nofSteps.toDouble(), HealthDataType.STEPS, ago, time);

if (success) {
success = await health.writeHealthData(
_mgdl, HealthDataType.BLOOD_GLUCOSE, time, time);
}

setState(() {
_state = success ? AppState.DATA_ADDED : AppState.DATA_NOT_ADDED;
});
}

/// Fetch data from the healt plugin and print it
Future fetchData() async {
// get everything from midnight until now
DateTime startDate = DateTime(2020, 11, 07, 0, 0, 0);
Expand All @@ -41,7 +67,8 @@ class _MyAppState extends State<MyApp> {
HealthDataType.WEIGHT,
HealthDataType.HEIGHT,
HealthDataType.BLOOD_GLUCOSE,
HealthDataType.DISTANCE_WALKING_RUNNING,
// Uncomment this line on iOS. This type is supported ONLY on Android!
// HealthDataType.DISTANCE_WALKING_RUNNING,
];

setState(() => _state = AppState.FETCHING_DATA);
Expand All @@ -53,6 +80,7 @@ class _MyAppState extends State<MyApp> {

if (accessWasGranted) {
try {

// fetch new data
List<HealthDataPoint> healthData =
await health.getHealthDataFromTypes(startDate, endDate, types);
Expand Down Expand Up @@ -117,7 +145,13 @@ class _MyAppState extends State<MyApp> {
}

Widget _contentNotFetched() {
return Text('Press the download button to fetch data');
return Column(
children: [
Text('Press the download button to fetch data.'),
Text('Press the plus button to insert some random data.')
],
mainAxisAlignment: MainAxisAlignment.center,
);
}

Widget _authorizationNotGranted() {
Expand All @@ -126,6 +160,14 @@ class _MyAppState extends State<MyApp> {
For iOS check your permissions in Apple Health.''');
}

Widget _dataAdded() {
return Text('$_nofSteps steps and $_mgdl mgdl are inserted successfully!');
}

Widget _dataNotAdded() {
return Text('Failed to add data');
}

Widget _content() {
if (_state == AppState.DATA_READY)
return _contentDataReady();
Expand All @@ -135,6 +177,9 @@ class _MyAppState extends State<MyApp> {
return _contentFetchingData();
else if (_state == AppState.AUTH_NOT_GRANTED)
return _authorizationNotGranted();
else if (_state == AppState.DATA_ADDED)
return _dataAdded();
else if (_state == AppState.DATA_NOT_ADDED) return _dataNotAdded();

return _contentNotFetched();
}
Expand All @@ -151,7 +196,12 @@ class _MyAppState extends State<MyApp> {
onPressed: () {
fetchData();
},
)
),
IconButton(
onPressed: () {
addData();
},
icon: Icon(Icons.add))
],
),
body: Center(
Expand Down
Loading