Skip to content
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
53 changes: 53 additions & 0 deletions packages/health/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,22 @@ flutter: Health Plugin Error:
flutter: PlatformException(FlutterHealth, Results are null, Optional(Error Domain=com.apple.healthkit Code=6 "Protected health data is inaccessible" UserInfo={NSLocalizedDescription=Protected health data is inaccessible}))
```

### Fetch single health data by UUID

In order to retrieve a single record, it is required to provide `String uuid` and `HealthDataType type`.

Please see example below:
```dart
HealthDataPoint? healthPoint = await health.getHealthDataByUUID(
uuid: 'random-uuid-string',
type: HealthDataType.STEPS,
);
```
```
I/FLUTTER_HEALTH( 9161): Success: {uuid=random-uuid-string, value=12, date_from=1742259061009, date_to=1742259092888, source_id=, source_name=com.google.android.apps.fitness, recording_method=0}
```
> Assuming that the `uuid` and `type` are coming from your database.

### Filtering by recording method

Google Health Connect and Apple HealthKit both provide ways to distinguish samples collected "automatically" and manually entered data by the user.
Expand Down Expand Up @@ -322,6 +338,43 @@ Furthermore, the plugin now exposes three new functions to help you check and re
2. `isHealthDataInBackgroundAuthorized()`: Checks the current status of the Health Data in Background permission
3. `requestHealthDataInBackgroundAuthorization()`: Requests the Health Data in Background permission.

### Fetch single health data by UUID

In order to retrieve a single record, it is required to provide `String uuid` and `HealthDataType type`.

Please see example below:
```dart
HealthDataPoint? healthPoint = await health.getHealthDataByUUID(
uuid: 'E9F2EEAD-8FC5-4CE5-9FF5-7C4E535FB8B8',
type: HealthDataType.WORKOUT,
);
```
```
data by UUID: HealthDataPoint -
uuid: E9F2EEAD-8FC5-4CE5-9FF5-7C4E535FB8B8,
value: WorkoutHealthValue - workoutActivityType: RUNNING,
totalEnergyBurned: null,
totalEnergyBurnedUnit: KILOCALORIE,
totalDistance: 2400,
totalDistanceUnit: METER
totalSteps: null,
totalStepsUnit: null,
unit: NO_UNIT,
dateFrom: 2025-05-02 07:31:00.000,
dateTo: 2025-05-02 08:25:00.000,
dataType: WORKOUT,
platform: HealthPlatformType.appleHealth,
deviceId: unknown,
sourceId: com.apple.Health,
sourceName: Health
recordingMethod: RecordingMethod.manual
workoutSummary: WorkoutSummary - workoutType: runningtotalDistance: 2400, totalEnergyBurned: 0, totalSteps: 0
metadata: null
deviceModel: null
```
> Assuming that the `uuid` and `type` are coming from your database.


## Data Types

The plugin supports the following [`HealthDataType`](https://pub.dev/documentation/health/latest/health/HealthDataType.html).
Expand Down
13 changes: 9 additions & 4 deletions packages/health/example/ios/Runner.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
66EFA595EFC367CCF62B5486 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
7CF05C63DD5841073CB4E39B /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
Expand Down Expand Up @@ -326,6 +327,7 @@
};
6FEBDBC7D4FF675303904EA3 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
Expand Down Expand Up @@ -371,7 +373,6 @@
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
showEnvVarsInLog = 0;
};
F39DD7443B254A13543A9E9D /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
Expand Down Expand Up @@ -505,7 +506,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 59TCTNUBMQ;
DEVELOPMENT_TEAM = 4A2HNSB52U;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
Expand Down Expand Up @@ -689,7 +690,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 59TCTNUBMQ;
DEVELOPMENT_TEAM = 4A2HNSB52U;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
Expand All @@ -713,7 +714,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 59TCTNUBMQ;
DEVELOPMENT_TEAM = 4A2HNSB52U;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
Expand Down Expand Up @@ -764,6 +765,10 @@
/* End XCConfigurationList section */

/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
ABB05D852D6BB16700FA4740 /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
Expand Down
197 changes: 157 additions & 40 deletions packages/health/example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,28 @@ class HealthAppState extends State<HealthApp> {
});
}

/// Fetch single data point by UUID and type.
Future<void> fetchDataByUUID(
BuildContext context, {
required String uuid,
required HealthDataType type,
}) async {
try {
// fetch health data
HealthDataPoint? healthPoint = await health.getHealthDataByUUID(
uuid: uuid,
type: type,
);

if (healthPoint != null) {
// save all the new data points (only the first 100)
if (context.mounted) openDetailBottomSheet(context, healthPoint);
}
} catch (error) {
debugPrint("Exception in getHealthDataByUUID: $error");
}
}

/// Add some random health data.
/// Note that you should ensure that you have permissions to add the
/// following data types.
Expand Down Expand Up @@ -579,6 +601,23 @@ class HealthAppState extends State<HealthApp> {
});
}

/// Display bottom sheet dialog of selected HealthDataPoint
void openDetailBottomSheet(
BuildContext context,
HealthDataPoint? healthPoint,
) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (BuildContext context) => _detailedBottomSheet(
healthPoint: healthPoint,
),
);
}

// UI building below

@override
Expand Down Expand Up @@ -783,46 +822,79 @@ class HealthAppState extends State<HealthApp> {
],
);

Widget get _contentDataReady => ListView.builder(
itemCount: _healthDataList.length,
itemBuilder: (_, index) {
// filter out manual entires if not wanted
if (recordingMethodsToFilter
.contains(_healthDataList[index].recordingMethod)) {
return Container();
}

HealthDataPoint p = _healthDataList[index];
if (p.value is AudiogramHealthValue) {
return ListTile(
title: Text("${p.typeString}: ${p.value}"),
trailing: Text(p.unitString),
subtitle: Text('${p.dateFrom} - ${p.dateTo}\n${p.recordingMethod}'),
);
}
if (p.value is WorkoutHealthValue) {
return ListTile(
title: Text(
"${p.typeString}: ${(p.value as WorkoutHealthValue).totalEnergyBurned} ${(p.value as WorkoutHealthValue).totalEnergyBurnedUnit?.name}"),
trailing:
Text((p.value as WorkoutHealthValue).workoutActivityType.name),
subtitle: Text('${p.dateFrom} - ${p.dateTo}\n${p.recordingMethod}'),
);
}
if (p.value is NutritionHealthValue) {
return ListTile(
title: Text(
"${p.typeString} ${(p.value as NutritionHealthValue).mealType}: ${(p.value as NutritionHealthValue).name}"),
trailing:
Text('${(p.value as NutritionHealthValue).calories} kcal'),
subtitle: Text('${p.dateFrom} - ${p.dateTo}\n${p.recordingMethod}'),
);
}
return ListTile(
title: Text("${p.typeString}: ${p.value}"),
trailing: Text(p.unitString),
subtitle: Text('${p.dateFrom} - ${p.dateTo}\n${p.recordingMethod}'),
);
Widget get _contentDataReady => Builder(builder: (context) {
return ListView.builder(
itemCount: _healthDataList.length,
itemBuilder: (_, index) {
// filter out manual entires if not wanted
if (recordingMethodsToFilter
.contains(_healthDataList[index].recordingMethod)) {
return Container();
}

HealthDataPoint p = _healthDataList[index];
if (p.value is AudiogramHealthValue) {
return ListTile(
title: Text("${p.typeString}: ${p.value}"),
trailing: Text(p.unitString),
subtitle: Text('${p.dateFrom} - ${p.dateTo}\n${p.recordingMethod}'),
onTap: () {
fetchDataByUUID(
context,
uuid: p.uuid,
type: p.type,
);
},
);
}
if (p.value is WorkoutHealthValue) {
return ListTile(
title: Text(
"${p.typeString}: ${(p.value as WorkoutHealthValue).totalEnergyBurned} ${(p.value as WorkoutHealthValue).totalEnergyBurnedUnit?.name}"),
trailing: Text(
(p.value as WorkoutHealthValue).workoutActivityType.name),
subtitle:
Text('${p.dateFrom} - ${p.dateTo}\n${p.recordingMethod}'),
onTap: () {
fetchDataByUUID(
context,
uuid: p.uuid,
type: p.type,
);
},
);
}
if (p.value is NutritionHealthValue) {
return ListTile(
title: Text(
"${p.typeString} ${(p.value as NutritionHealthValue).mealType}: ${(p.value as NutritionHealthValue).name}"),
trailing: Text(
'${(p.value as NutritionHealthValue).calories} kcal'),
subtitle:
Text('${p.dateFrom} - ${p.dateTo}\n${p.recordingMethod}'),
onTap: () {
fetchDataByUUID(
context,
uuid: p.uuid,
type: p.type,
);
},
);
}
return ListTile(
title: Text("${p.typeString}: ${p.value}"),
trailing: Text(p.unitString),
subtitle:
Text('${p.dateFrom} - ${p.dateTo}\n${p.recordingMethod}'),
onTap: () {
fetchDataByUUID(
context,
uuid: p.uuid,
type: p.type,
);
},
);
});
});

final Widget _contentNoData = const Text('No Data to show');
Expand Down Expand Up @@ -878,4 +950,49 @@ class HealthAppState extends State<HealthApp> {
AppState.PERMISSIONS_REVOKED => _permissionsRevoked,
AppState.PERMISSIONS_NOT_REVOKED => _permissionsNotRevoked,
};

Widget _detailedBottomSheet({HealthDataPoint? healthPoint}) {
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.5,
minChildSize: 0.3,
maxChildSize: 0.9,
builder: (BuildContext listContext, scrollController) {
return Container(
padding: const EdgeInsets.all(16),
child: Column(
children: [
const Text(
"Health Data Details",
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 10),
healthPoint == null
? const Text('UUID Not Found!')
: Expanded(
child: ListView.builder(
controller: scrollController,
itemCount: healthPoint.toJson().entries.length,
itemBuilder: (context, index) {
String key =
healthPoint.toJson().keys.elementAt(index);
var value = healthPoint.toJson()[key];

return ListTile(
title: Text(
key.replaceAll('_', ' ').toUpperCase(),
style:
const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(value.toString()),
);
},
),
),
],
),
);
},
);
}
}
Loading