Skip to content

Commit

Permalink
Merge pull request #1114 from cph-cachet/health-12/test
Browse files Browse the repository at this point in the history
feat: refactor `Health` class to remove singleton pattern and enable dependency injection for `DeviceInfoPlugin`
  • Loading branch information
bardram authored Jan 15, 2025
2 parents 44aae2c + 028b74b commit 594322e
Show file tree
Hide file tree
Showing 6 changed files with 455 additions and 51 deletions.
8 changes: 8 additions & 0 deletions packages/health/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
## 12.0.0

* **BREAKING** This release introduces a significant architectural change to the `health` plugin by removing the `singleton` pattern.
* **Dependency Injection for `DeviceInfoPlugin`**:
- The `Health` class is no longer a singleton.
- The `Health()` factory constructor is removed.
- The `Health` class now accepts an (optional) `DeviceInfoPlugin` dependency through its constructor, this change was introduced to provide easy mocking of the `DeviceInfo` class during unit tests.
- This architectural change means that, for the application to work correctly, the `Health` class *MUST* be initialized correctly as a global instance.
* **Impact**:
- For most users, **no immediate code changes are required** but it is paramount to initialize the `Health` class as a global instance (i.e. do not call `Health()` every time but rather define an instance `final health = Health();`).
* **BREAKING** (Android) Remove automatic permission request of `DISTANCE_DELTA` and `TOTAL_CALORIES_BURNED` data types when requesting permission for `WORKOUT` health data type.
* For `WORKOUT`s that require above permissions, now those need to be requested manually.
* Fix [#984](https://github.com/cph-cachet/flutter-plugins/issues/984) - PR [#1055](https://github.com/cph-cachet/flutter-plugins/pull/1055)
Expand Down
85 changes: 44 additions & 41 deletions packages/health/example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import 'package:health_example/util.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:carp_serializable/carp_serializable.dart';

// Global Health instance
final health = Health();

void main() => runApp(HealthApp());

class HealthApp extends StatefulWidget {
Expand Down Expand Up @@ -85,15 +88,15 @@ class _HealthAppState extends State<HealthApp> {
@override
void initState() {
// configure the health plugin before use and check the Health Connect status
Health().configure();
Health().getHealthConnectSdkStatus();
health.configure();
health.getHealthConnectSdkStatus();

super.initState();
}

/// Install Google Health Connect on this phone.
Future<void> installHealthConnect() async =>
await Health().installHealthConnect();
await health.installHealthConnect();

/// Authorize, i.e. get permissions to access relevant health data.
Future<void> authorize() async {
Expand All @@ -107,7 +110,7 @@ class _HealthAppState extends State<HealthApp> {

// Check if we have health permissions
bool? hasPermissions =
await Health().hasPermissions(types, permissions: permissions);
await health.hasPermissions(types, permissions: permissions);

// hasPermissions = false because the hasPermission cannot disclose if WRITE access exists.
// Hence, we have to request with WRITE as well.
Expand All @@ -117,8 +120,8 @@ class _HealthAppState extends State<HealthApp> {
if (!hasPermissions) {
// requesting access to the data types before reading them
try {
authorized = await Health()
.requestAuthorization(types, permissions: permissions);
authorized =
await health.requestAuthorization(types, permissions: permissions);
} catch (error) {
debugPrint("Exception in authorize: $error");
}
Expand All @@ -132,7 +135,7 @@ class _HealthAppState extends State<HealthApp> {
Future<void> getHealthConnectSdkStatus() async {
assert(Platform.isAndroid, "This is only available on Android");

final status = await Health().getHealthConnectSdkStatus();
final status = await health.getHealthConnectSdkStatus();

setState(() {
_contentHealthConnectStatus =
Expand All @@ -154,7 +157,7 @@ class _HealthAppState extends State<HealthApp> {

try {
// fetch health data
List<HealthDataPoint> healthData = await Health().getHealthDataFromTypes(
List<HealthDataPoint> healthData = await health.getHealthDataFromTypes(
types: types,
startTime: yesterday,
endTime: now,
Expand All @@ -175,7 +178,7 @@ class _HealthAppState extends State<HealthApp> {
}

// filter out duplicates
_healthDataList = Health().removeDuplicates(_healthDataList);
_healthDataList = health.removeDuplicates(_healthDataList);

for (var data in _healthDataList) {
debugPrint(toJsonString(data));
Expand All @@ -201,117 +204,117 @@ class _HealthAppState extends State<HealthApp> {
bool success = true;

// misc. health data examples using the writeHealthData() method
success &= await Health().writeHealthData(
success &= await health.writeHealthData(
value: 1.925,
type: HealthDataType.HEIGHT,
startTime: earlier,
endTime: now,
recordingMethod: RecordingMethod.manual);
success &= await Health().writeHealthData(
success &= await health.writeHealthData(
value: 90,
type: HealthDataType.WEIGHT,
startTime: now,
recordingMethod: RecordingMethod.manual);
success &= await Health().writeHealthData(
success &= await health.writeHealthData(
value: 90,
type: HealthDataType.HEART_RATE,
startTime: earlier,
endTime: now,
recordingMethod: RecordingMethod.manual);
success &= await Health().writeHealthData(
success &= await health.writeHealthData(
value: 90,
type: HealthDataType.STEPS,
startTime: earlier,
endTime: now,
recordingMethod: RecordingMethod.manual);
success &= await Health().writeHealthData(
success &= await health.writeHealthData(
value: 200,
type: HealthDataType.ACTIVE_ENERGY_BURNED,
startTime: earlier,
endTime: now,
);
success &= await Health().writeHealthData(
success &= await health.writeHealthData(
value: 70,
type: HealthDataType.HEART_RATE,
startTime: earlier,
endTime: now);
if (Platform.isIOS) {
success &= await Health().writeHealthData(
success &= await health.writeHealthData(
value: 30,
type: HealthDataType.HEART_RATE_VARIABILITY_SDNN,
startTime: earlier,
endTime: now);
} else {
success &= await Health().writeHealthData(
success &= await health.writeHealthData(
value: 30,
type: HealthDataType.HEART_RATE_VARIABILITY_RMSSD,
startTime: earlier,
endTime: now);
}
success &= await Health().writeHealthData(
success &= await health.writeHealthData(
value: 37,
type: HealthDataType.BODY_TEMPERATURE,
startTime: earlier,
endTime: now);
success &= await Health().writeHealthData(
success &= await health.writeHealthData(
value: 105,
type: HealthDataType.BLOOD_GLUCOSE,
startTime: earlier,
endTime: now);
success &= await Health().writeHealthData(
success &= await health.writeHealthData(
value: 1.8,
type: HealthDataType.WATER,
startTime: earlier,
endTime: now);

// different types of sleep
success &= await Health().writeHealthData(
success &= await health.writeHealthData(
value: 0.0,
type: HealthDataType.SLEEP_REM,
startTime: earlier,
endTime: now);
success &= await Health().writeHealthData(
success &= await health.writeHealthData(
value: 0.0,
type: HealthDataType.SLEEP_ASLEEP,
startTime: earlier,
endTime: now);
success &= await Health().writeHealthData(
success &= await health.writeHealthData(
value: 0.0,
type: HealthDataType.SLEEP_AWAKE,
startTime: earlier,
endTime: now);
success &= await Health().writeHealthData(
success &= await health.writeHealthData(
value: 0.0,
type: HealthDataType.SLEEP_DEEP,
startTime: earlier,
endTime: now);
success &= await Health().writeHealthData(
success &= await health.writeHealthData(
value: 22,
type: HealthDataType.LEAN_BODY_MASS,
startTime: earlier,
endTime: now,
);

// specialized write methods
success &= await Health().writeBloodOxygen(
success &= await health.writeBloodOxygen(
saturation: 98,
startTime: earlier,
endTime: now,
);
success &= await Health().writeWorkoutData(
success &= await health.writeWorkoutData(
activityType: HealthWorkoutActivityType.AMERICAN_FOOTBALL,
title: "Random workout name that shows up in Health Connect",
start: now.subtract(const Duration(minutes: 15)),
end: now,
totalDistance: 2430,
totalEnergyBurned: 400,
);
success &= await Health().writeBloodPressure(
success &= await health.writeBloodPressure(
systolic: 90,
diastolic: 80,
startTime: now,
);
success &= await Health().writeMeal(
success &= await health.writeMeal(
mealType: MealType.SNACK,
startTime: earlier,
endTime: now,
Expand Down Expand Up @@ -363,7 +366,7 @@ class _HealthAppState extends State<HealthApp> {
// const frequencies = [125.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0];
// const leftEarSensitivities = [49.0, 54.0, 89.0, 52.0, 77.0, 35.0];
// const rightEarSensitivities = [76.0, 66.0, 90.0, 22.0, 85.0, 44.5];
// success &= await Health().writeAudiogram(
// success &= await health.writeAudiogram(
// frequencies,
// leftEarSensitivities,
// rightEarSensitivities,
Expand All @@ -375,24 +378,24 @@ class _HealthAppState extends State<HealthApp> {
// },
// );

success &= await Health().writeMenstruationFlow(
success &= await health.writeMenstruationFlow(
flow: MenstrualFlow.medium,
isStartOfCycle: true,
startTime: earlier,
endTime: now,
);

// Available on iOS 16.0+ only
if (Platform.isIOS) {
success &= await Health().writeHealthData(
if (Platform.isIOS) {
success &= await health.writeHealthData(
value: 22,
type: HealthDataType.WATER_TEMPERATURE,
startTime: earlier,
endTime: now,
recordingMethod: RecordingMethod.manual
);

success &= await Health().writeHealthData(
success &= await health.writeHealthData(
value: 55,
type: HealthDataType.UNDERWATER_DEPTH,
startTime: earlier,
Expand All @@ -413,7 +416,7 @@ class _HealthAppState extends State<HealthApp> {

bool success = true;
for (HealthDataType type in types) {
success &= await Health().delete(
success &= await health.delete(
type: type,
startTime: earlier,
endTime: now,
Expand All @@ -434,15 +437,15 @@ class _HealthAppState extends State<HealthApp> {
final midnight = DateTime(now.year, now.month, now.day);

bool stepsPermission =
await Health().hasPermissions([HealthDataType.STEPS]) ?? false;
await health.hasPermissions([HealthDataType.STEPS]) ?? false;
if (!stepsPermission) {
stepsPermission =
await Health().requestAuthorization([HealthDataType.STEPS]);
await health.requestAuthorization([HealthDataType.STEPS]);
}

if (stepsPermission) {
try {
steps = await Health().getTotalStepsInInterval(midnight, now,
steps = await health.getTotalStepsInInterval(midnight, now,
includeManualEntry:
!recordingMethodsToFilter.contains(RecordingMethod.manual));
} catch (error) {
Expand All @@ -468,7 +471,7 @@ class _HealthAppState extends State<HealthApp> {
bool success = false;

try {
await Health().revokePermissions();
await health.revokePermissions();
success = true;
} catch (error) {
debugPrint("Exception in revokeAccess: $error");
Expand Down Expand Up @@ -503,7 +506,7 @@ class _HealthAppState extends State<HealthApp> {
child: const Text("Check Health Connect Status",
style: TextStyle(color: Colors.white))),
if (Platform.isAndroid &&
Health().healthConnectSdkStatus !=
health.healthConnectSdkStatus !=
HealthConnectSdkStatus.sdkAvailable)
TextButton(
onPressed: installHealthConnect,
Expand All @@ -513,7 +516,7 @@ class _HealthAppState extends State<HealthApp> {
style: TextStyle(color: Colors.white))),
if (Platform.isIOS ||
Platform.isAndroid &&
Health().healthConnectSdkStatus ==
health.healthConnectSdkStatus ==
HealthConnectSdkStatus.sdkAvailable)
Wrap(spacing: 10, children: [
TextButton(
Expand Down
16 changes: 6 additions & 10 deletions packages/health/lib/src/health_plugin.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,19 +34,15 @@ part of '../health.dart';
/// or getter methods. Otherwise, the plugin will throw an exception.
class Health {
static const MethodChannel _channel = MethodChannel('flutter_health');
static final _instance = Health._();

String? _deviceId;
final _deviceInfo = DeviceInfoPlugin();
HealthConnectSdkStatus _healthConnectSdkStatus =
HealthConnectSdkStatus.sdkUnavailable;
final DeviceInfoPlugin _deviceInfo;
HealthConnectSdkStatus _healthConnectSdkStatus =
HealthConnectSdkStatus.sdkUnavailable;

Health._() {
_registerFromJsonFunctions();
}

/// The singleton [Health] instance.
factory Health() => _instance;
Health({DeviceInfoPlugin? deviceInfo}) : _deviceInfo = deviceInfo ?? DeviceInfoPlugin() {
_registerFromJsonFunctions();
}

/// The latest status on availability of Health Connect SDK on this phone.
HealthConnectSdkStatus get healthConnectSdkStatus => _healthConnectSdkStatus;
Expand Down
1 change: 1 addition & 0 deletions packages/health/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ dev_dependencies:
# dart run build_runner build --delete-conflicting-outputs
build_runner: any
json_serializable: any
mocktail: ^1.0.4

flutter:
plugin:
Expand Down
Loading

0 comments on commit 594322e

Please sign in to comment.