Skip to content

Commit 594a835

Browse files
authored
Merge pull request #1193 from agilst/1139-single-record-ios
Add `get` HealthData by UUID method for iOS
2 parents 72d2c9d + 316af9d commit 594a835

6 files changed

Lines changed: 530 additions & 44 deletions

File tree

packages/health/README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,22 @@ flutter: Health Plugin Error:
270270
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}))
271271
```
272272

273+
### Fetch single health data by UUID
274+
275+
In order to retrieve a single record, it is required to provide `String uuid` and `HealthDataType type`.
276+
277+
Please see example below:
278+
```dart
279+
HealthDataPoint? healthPoint = await health.getHealthDataByUUID(
280+
uuid: 'random-uuid-string',
281+
type: HealthDataType.STEPS,
282+
);
283+
```
284+
```
285+
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}
286+
```
287+
> Assuming that the `uuid` and `type` are coming from your database.
288+
273289
### Filtering by recording method
274290

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

341+
### Fetch single health data by UUID
342+
343+
In order to retrieve a single record, it is required to provide `String uuid` and `HealthDataType type`.
344+
345+
Please see example below:
346+
```dart
347+
HealthDataPoint? healthPoint = await health.getHealthDataByUUID(
348+
uuid: 'E9F2EEAD-8FC5-4CE5-9FF5-7C4E535FB8B8',
349+
type: HealthDataType.WORKOUT,
350+
);
351+
```
352+
```
353+
data by UUID: HealthDataPoint -
354+
uuid: E9F2EEAD-8FC5-4CE5-9FF5-7C4E535FB8B8,
355+
value: WorkoutHealthValue - workoutActivityType: RUNNING,
356+
totalEnergyBurned: null,
357+
totalEnergyBurnedUnit: KILOCALORIE,
358+
totalDistance: 2400,
359+
totalDistanceUnit: METER
360+
totalSteps: null,
361+
totalStepsUnit: null,
362+
unit: NO_UNIT,
363+
dateFrom: 2025-05-02 07:31:00.000,
364+
dateTo: 2025-05-02 08:25:00.000,
365+
dataType: WORKOUT,
366+
platform: HealthPlatformType.appleHealth,
367+
deviceId: unknown,
368+
sourceId: com.apple.Health,
369+
sourceName: Health
370+
recordingMethod: RecordingMethod.manual
371+
workoutSummary: WorkoutSummary - workoutType: runningtotalDistance: 2400, totalEnergyBurned: 0, totalSteps: 0
372+
metadata: null
373+
deviceModel: null
374+
```
375+
> Assuming that the `uuid` and `type` are coming from your database.
376+
377+
325378
## Data Types
326379

327380
The plugin supports the following [`HealthDataType`](https://pub.dev/documentation/health/latest/health/HealthDataType.html).

packages/health/example/ios/Runner.xcodeproj/project.pbxproj

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
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>"; };
5757
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
5858
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
59+
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
5960
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
6061
7CF05C63DD5841073CB4E39B /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
6162
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
@@ -326,6 +327,7 @@
326327
};
327328
6FEBDBC7D4FF675303904EA3 /* [CP] Embed Pods Frameworks */ = {
328329
isa = PBXShellScriptBuildPhase;
330+
alwaysOutOfDate = 1;
329331
buildActionMask = 2147483647;
330332
files = (
331333
);
@@ -371,7 +373,6 @@
371373
runOnlyForDeploymentPostprocessing = 0;
372374
shellPath = /bin/sh;
373375
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
374-
showEnvVarsInLog = 0;
375376
};
376377
F39DD7443B254A13543A9E9D /* [CP] Check Pods Manifest.lock */ = {
377378
isa = PBXShellScriptBuildPhase;
@@ -505,7 +506,7 @@
505506
CLANG_ENABLE_MODULES = YES;
506507
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
507508
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
508-
DEVELOPMENT_TEAM = 59TCTNUBMQ;
509+
DEVELOPMENT_TEAM = 4A2HNSB52U;
509510
ENABLE_BITCODE = NO;
510511
INFOPLIST_FILE = Runner/Info.plist;
511512
LD_RUNPATH_SEARCH_PATHS = (
@@ -689,7 +690,7 @@
689690
CLANG_ENABLE_MODULES = YES;
690691
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
691692
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
692-
DEVELOPMENT_TEAM = 59TCTNUBMQ;
693+
DEVELOPMENT_TEAM = 4A2HNSB52U;
693694
ENABLE_BITCODE = NO;
694695
INFOPLIST_FILE = Runner/Info.plist;
695696
LD_RUNPATH_SEARCH_PATHS = (
@@ -713,7 +714,7 @@
713714
CLANG_ENABLE_MODULES = YES;
714715
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
715716
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
716-
DEVELOPMENT_TEAM = 59TCTNUBMQ;
717+
DEVELOPMENT_TEAM = 4A2HNSB52U;
717718
ENABLE_BITCODE = NO;
718719
INFOPLIST_FILE = Runner/Info.plist;
719720
LD_RUNPATH_SEARCH_PATHS = (
@@ -764,6 +765,10 @@
764765
/* End XCConfigurationList section */
765766

766767
/* Begin XCLocalSwiftPackageReference section */
768+
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
769+
isa = XCLocalSwiftPackageReference;
770+
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
771+
};
767772
ABB05D852D6BB16700FA4740 /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
768773
isa = XCLocalSwiftPackageReference;
769774
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;

packages/health/example/lib/main.dart

Lines changed: 157 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,28 @@ class HealthAppState extends State<HealthApp> {
202202
});
203203
}
204204

205+
/// Fetch single data point by UUID and type.
206+
Future<void> fetchDataByUUID(
207+
BuildContext context, {
208+
required String uuid,
209+
required HealthDataType type,
210+
}) async {
211+
try {
212+
// fetch health data
213+
HealthDataPoint? healthPoint = await health.getHealthDataByUUID(
214+
uuid: uuid,
215+
type: type,
216+
);
217+
218+
if (healthPoint != null) {
219+
// save all the new data points (only the first 100)
220+
if (context.mounted) openDetailBottomSheet(context, healthPoint);
221+
}
222+
} catch (error) {
223+
debugPrint("Exception in getHealthDataByUUID: $error");
224+
}
225+
}
226+
205227
/// Add some random health data.
206228
/// Note that you should ensure that you have permissions to add the
207229
/// following data types.
@@ -579,6 +601,23 @@ class HealthAppState extends State<HealthApp> {
579601
});
580602
}
581603

604+
/// Display bottom sheet dialog of selected HealthDataPoint
605+
void openDetailBottomSheet(
606+
BuildContext context,
607+
HealthDataPoint? healthPoint,
608+
) {
609+
showModalBottomSheet(
610+
context: context,
611+
isScrollControlled: true,
612+
shape: const RoundedRectangleBorder(
613+
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
614+
),
615+
builder: (BuildContext context) => _detailedBottomSheet(
616+
healthPoint: healthPoint,
617+
),
618+
);
619+
}
620+
582621
// UI building below
583622

584623
@override
@@ -783,46 +822,79 @@ class HealthAppState extends State<HealthApp> {
783822
],
784823
);
785824

786-
Widget get _contentDataReady => ListView.builder(
787-
itemCount: _healthDataList.length,
788-
itemBuilder: (_, index) {
789-
// filter out manual entires if not wanted
790-
if (recordingMethodsToFilter
791-
.contains(_healthDataList[index].recordingMethod)) {
792-
return Container();
793-
}
794-
795-
HealthDataPoint p = _healthDataList[index];
796-
if (p.value is AudiogramHealthValue) {
797-
return ListTile(
798-
title: Text("${p.typeString}: ${p.value}"),
799-
trailing: Text(p.unitString),
800-
subtitle: Text('${p.dateFrom} - ${p.dateTo}\n${p.recordingMethod}'),
801-
);
802-
}
803-
if (p.value is WorkoutHealthValue) {
804-
return ListTile(
805-
title: Text(
806-
"${p.typeString}: ${(p.value as WorkoutHealthValue).totalEnergyBurned} ${(p.value as WorkoutHealthValue).totalEnergyBurnedUnit?.name}"),
807-
trailing:
808-
Text((p.value as WorkoutHealthValue).workoutActivityType.name),
809-
subtitle: Text('${p.dateFrom} - ${p.dateTo}\n${p.recordingMethod}'),
810-
);
811-
}
812-
if (p.value is NutritionHealthValue) {
813-
return ListTile(
814-
title: Text(
815-
"${p.typeString} ${(p.value as NutritionHealthValue).mealType}: ${(p.value as NutritionHealthValue).name}"),
816-
trailing:
817-
Text('${(p.value as NutritionHealthValue).calories} kcal'),
818-
subtitle: Text('${p.dateFrom} - ${p.dateTo}\n${p.recordingMethod}'),
819-
);
820-
}
821-
return ListTile(
822-
title: Text("${p.typeString}: ${p.value}"),
823-
trailing: Text(p.unitString),
824-
subtitle: Text('${p.dateFrom} - ${p.dateTo}\n${p.recordingMethod}'),
825-
);
825+
Widget get _contentDataReady => Builder(builder: (context) {
826+
return ListView.builder(
827+
itemCount: _healthDataList.length,
828+
itemBuilder: (_, index) {
829+
// filter out manual entires if not wanted
830+
if (recordingMethodsToFilter
831+
.contains(_healthDataList[index].recordingMethod)) {
832+
return Container();
833+
}
834+
835+
HealthDataPoint p = _healthDataList[index];
836+
if (p.value is AudiogramHealthValue) {
837+
return ListTile(
838+
title: Text("${p.typeString}: ${p.value}"),
839+
trailing: Text(p.unitString),
840+
subtitle: Text('${p.dateFrom} - ${p.dateTo}\n${p.recordingMethod}'),
841+
onTap: () {
842+
fetchDataByUUID(
843+
context,
844+
uuid: p.uuid,
845+
type: p.type,
846+
);
847+
},
848+
);
849+
}
850+
if (p.value is WorkoutHealthValue) {
851+
return ListTile(
852+
title: Text(
853+
"${p.typeString}: ${(p.value as WorkoutHealthValue).totalEnergyBurned} ${(p.value as WorkoutHealthValue).totalEnergyBurnedUnit?.name}"),
854+
trailing: Text(
855+
(p.value as WorkoutHealthValue).workoutActivityType.name),
856+
subtitle:
857+
Text('${p.dateFrom} - ${p.dateTo}\n${p.recordingMethod}'),
858+
onTap: () {
859+
fetchDataByUUID(
860+
context,
861+
uuid: p.uuid,
862+
type: p.type,
863+
);
864+
},
865+
);
866+
}
867+
if (p.value is NutritionHealthValue) {
868+
return ListTile(
869+
title: Text(
870+
"${p.typeString} ${(p.value as NutritionHealthValue).mealType}: ${(p.value as NutritionHealthValue).name}"),
871+
trailing: Text(
872+
'${(p.value as NutritionHealthValue).calories} kcal'),
873+
subtitle:
874+
Text('${p.dateFrom} - ${p.dateTo}\n${p.recordingMethod}'),
875+
onTap: () {
876+
fetchDataByUUID(
877+
context,
878+
uuid: p.uuid,
879+
type: p.type,
880+
);
881+
},
882+
);
883+
}
884+
return ListTile(
885+
title: Text("${p.typeString}: ${p.value}"),
886+
trailing: Text(p.unitString),
887+
subtitle:
888+
Text('${p.dateFrom} - ${p.dateTo}\n${p.recordingMethod}'),
889+
onTap: () {
890+
fetchDataByUUID(
891+
context,
892+
uuid: p.uuid,
893+
type: p.type,
894+
);
895+
},
896+
);
897+
});
826898
});
827899

828900
final Widget _contentNoData = const Text('No Data to show');
@@ -878,4 +950,49 @@ class HealthAppState extends State<HealthApp> {
878950
AppState.PERMISSIONS_REVOKED => _permissionsRevoked,
879951
AppState.PERMISSIONS_NOT_REVOKED => _permissionsNotRevoked,
880952
};
953+
954+
Widget _detailedBottomSheet({HealthDataPoint? healthPoint}) {
955+
return DraggableScrollableSheet(
956+
expand: false,
957+
initialChildSize: 0.5,
958+
minChildSize: 0.3,
959+
maxChildSize: 0.9,
960+
builder: (BuildContext listContext, scrollController) {
961+
return Container(
962+
padding: const EdgeInsets.all(16),
963+
child: Column(
964+
children: [
965+
const Text(
966+
"Health Data Details",
967+
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
968+
),
969+
const SizedBox(height: 10),
970+
healthPoint == null
971+
? const Text('UUID Not Found!')
972+
: Expanded(
973+
child: ListView.builder(
974+
controller: scrollController,
975+
itemCount: healthPoint.toJson().entries.length,
976+
itemBuilder: (context, index) {
977+
String key =
978+
healthPoint.toJson().keys.elementAt(index);
979+
var value = healthPoint.toJson()[key];
980+
981+
return ListTile(
982+
title: Text(
983+
key.replaceAll('_', ' ').toUpperCase(),
984+
style:
985+
const TextStyle(fontWeight: FontWeight.bold),
986+
),
987+
subtitle: Text(value.toString()),
988+
);
989+
},
990+
),
991+
),
992+
],
993+
),
994+
);
995+
},
996+
);
997+
}
881998
}

0 commit comments

Comments
 (0)