health 13.3.1 copy "health: ^13.3.1" to clipboard
health: ^13.3.1 copied to clipboard

Wrapper for Apple's HealthKit on iOS and Google's Health Connect on Android.

example/lib/main.dart

import 'dart:async';
import 'dart:io';

import 'package:flutter/material.dart';
import 'package:health/health.dart';
import 'package:health_example/util.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:carp_serializable/carp_serializable.dart';

class _ChangesSummary {
  final int beforeCount;
  final int afterCount;
  final int inserted;
  final int updated;
  final int removed;
  final int appliedUpserts;
  final int appliedDeletes;

  const _ChangesSummary({
    required this.beforeCount,
    required this.afterCount,
    required this.inserted,
    required this.updated,
    required this.removed,
    required this.appliedUpserts,
    required this.appliedDeletes,
  });
}

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

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

class HealthApp extends StatefulWidget {
  const HealthApp({super.key});

  @override
  HealthAppState createState() => HealthAppState();
}

enum AppState {
  DATA_NOT_FETCHED,
  FETCHING_DATA,
  DATA_READY,
  NO_DATA,
  AUTHORIZED,
  AUTH_NOT_GRANTED,
  DATA_ADDED,
  DATA_DELETED,
  DATA_NOT_ADDED,
  DATA_NOT_DELETED,
  STEPS_READY,
  SKIN_TEMPERATURE_FEATURE_STATUS,
  HEALTH_CONNECT_STATUS,
  PERMISSIONS_REVOKING,
  PERMISSIONS_REVOKED,
  PERMISSIONS_NOT_REVOKED,
  CHANGES_READY,
  CHANGES_NOT_READY,
}

class HealthAppState extends State<HealthApp> {
  List<HealthDataPoint> _healthDataList = [];
  AppState _state = AppState.DATA_NOT_FETCHED;
  int _nofSteps = 0;
  List<RecordingMethod> recordingMethodsToFilter = [];
  String? _changesToken;
  String? _previousChangesToken;
  bool _changesTokenExpired = false;
  bool _changesHasMore = false;
  final List<HealthChange> _changes = [];
  final List<HealthDataType> _changeTypes = const [
    HealthDataType.STEPS,
    HealthDataType.WORKOUT,
  ];
  final Map<String, HealthDataPoint> _syncedDataById = {};
  int _lastBeforeCount = 0;
  int _lastAfterCount = 0;
  int _lastInserted = 0;
  int _lastUpdated = 0;
  int _lastRemoved = 0;
  int _lastAppliedUpserts = 0;
  int _lastAppliedDeletes = 0;
  DateTime? _lastChangesAt;

  // All types available depending on platform (iOS ot Android).
  List<HealthDataType> get types {
    if (Platform.isAndroid) return dataTypesAndroid;
    if (!Platform.isIOS) return [];

    final iosTypes = List<HealthDataType>.from(dataTypesIOS);
    if (!_isIOS16OrNewer) {
      iosTypes.removeWhere(
        (type) => const {
          HealthDataType.WATER_TEMPERATURE,
          HealthDataType.UNDERWATER_DEPTH,
          HealthDataType.UV_INDEX,
          HealthDataType.SLEEP_WRIST_TEMPERATURE,
        }.contains(type),
      );
    }

    return iosTypes;
  }

  // // Or specify specific types
  // static final types = [
  //   HealthDataType.WEIGHT,
  //   HealthDataType.STEPS,
  //   HealthDataType.HEIGHT,
  //   HealthDataType.BLOOD_GLUCOSE,
  //   HealthDataType.WORKOUT,
  //   HealthDataType.BLOOD_PRESSURE_DIASTOLIC,
  //   HealthDataType.BLOOD_PRESSURE_SYSTOLIC,
  //   // Uncomment this line on iOS - only available on iOS
  //   // HealthDataType.AUDIOGRAM
  // ];

  // Set up corresponding permissions

  // READ only
  // List<HealthDataAccess> get permissions =>
  //     types.map((e) => HealthDataAccess.READ).toList();

  // Or both READ and WRITE
  List<HealthDataAccess> get permissions => types
      .map(
        (type) =>
            // can only request READ permissions to the following list of types on iOS
            [
              HealthDataType.GENDER,
              HealthDataType.BLOOD_TYPE,
              HealthDataType.BIRTH_DATE,
              HealthDataType.APPLE_MOVE_TIME,
              HealthDataType.APPLE_STAND_HOUR,
              HealthDataType.APPLE_STAND_TIME,
              HealthDataType.WALKING_HEART_RATE,
              HealthDataType.ELECTROCARDIOGRAM,
              HealthDataType.HIGH_HEART_RATE_EVENT,
              HealthDataType.LOW_HEART_RATE_EVENT,
              HealthDataType.IRREGULAR_HEART_RATE_EVENT,
              HealthDataType.EXERCISE_TIME,
              HealthDataType.SLEEP_WRIST_TEMPERATURE,
            ].contains(type)
            ? HealthDataAccess.READ
            : HealthDataAccess.READ_WRITE,
      )
      .toList();

  @override
  void initState() {
    // configure the health plugin before use and check the Health Connect status
    health.configure();
    health.getHealthConnectSdkStatus();

    super.initState();
  }

  bool get _isIOS16OrNewer {
    if (!Platform.isIOS) return false;
    final match = RegExp(r'(\d+)').firstMatch(Platform.operatingSystemVersion);
    final majorVersion = int.tryParse(match?.group(1) ?? '');
    return (majorVersion ?? 0) >= 16;
  }

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

  /// Authorize, i.e. get permissions to access relevant health data.
  Future<void> authorize() async {
    // If we are trying to read Step Count, Workout, Sleep or other data that requires
    // the ACTIVITY_RECOGNITION permission, we need to request the permission first.
    // This requires a special request authorization call.
    //
    // The location permission is requested for Workouts using the Distance information.
    await Permission.activityRecognition.request();
    await Permission.location.request();

    // Check if we have health permissions
    bool? hasPermissions = 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.
    hasPermissions = false;

    bool authorized = false;
    if (!hasPermissions) {
      // requesting access to the data types before reading them
      try {
        authorized = await health.requestAuthorization(
          types,
          permissions: permissions,
        );

        // request access to read historic data
        await health.requestHealthDataHistoryAuthorization();

        // request access in background
        await health.requestHealthDataInBackgroundAuthorization();
      } catch (error) {
        debugPrint("Exception in authorize: $error");
      }
    }

    setState(
      () => _state = (authorized)
          ? AppState.AUTHORIZED
          : AppState.AUTH_NOT_GRANTED,
    );
  }

  /// Gets the Health Connect status on Android.
  Future<void> getHealthConnectSdkStatus() async {
    assert(Platform.isAndroid, "This is only available on Android");

    final status = await health.getHealthConnectSdkStatus();

    setState(() {
      _contentHealthConnectStatus = Text(
        'Health Connect Status: ${status?.name.toUpperCase()}',
      );
      _state = AppState.HEALTH_CONNECT_STATUS;
    });
  }

  /// Gets the Skin Temperature feature availability on Android.
  Future<void> getSkinTemperatureFeatureStatus() async {
    if (!Platform.isAndroid) {
      setState(() {
        _contentSkinTemperatureFeatureStatus = const Text(
          'Skin Temperature feature is Android only.',
        );
        _state = AppState.SKIN_TEMPERATURE_FEATURE_STATUS;
      });
      return;
    }

    final available = await health.isSkinTemperatureAvailable();
    setState(() {
      _contentSkinTemperatureFeatureStatus = Text(
        'Skin Temperature Feature: ${available ? "AVAILABLE" : "NOT AVAILABLE"}',
      );
      _state = AppState.SKIN_TEMPERATURE_FEATURE_STATUS;
    });
  }

  /// Create a change token for Health Connect incremental sync.
  Future<void> createChangesToken() async {
    if (!Platform.isAndroid) {
      setState(() => _state = AppState.CHANGES_NOT_READY);
      return;
    }

    try {
      final token = await health.getChangesToken(types: _changeTypes);
      setState(() {
        _changesToken = token;
        _changesTokenExpired = false;
        _changesHasMore = false;
        _changes.clear();
        _state = token == null ? AppState.CHANGES_NOT_READY : AppState.CHANGES_READY;
      });
    } catch (error) {
      debugPrint("Exception in createChangesToken: $error");
      setState(() => _state = AppState.CHANGES_NOT_READY);
    }
  }

  /// Fetch the next page of changes using the current token.
  Future<void> fetchChanges() async {
    if (!Platform.isAndroid) {
      setState(() => _state = AppState.CHANGES_NOT_READY);
      return;
    }

    final token = _changesToken;
    if (token == null || token.isEmpty) {
      setState(() => _state = AppState.CHANGES_NOT_READY);
      return;
    }

    try {
      final response = await health.getChanges(changesToken: token);
      if (response == null) {
        setState(() => _state = AppState.CHANGES_NOT_READY);
        return;
      }

      final summary = _applyChanges(response.changes);

      setState(() {
        _previousChangesToken = token;
        _changesToken = response.nextChangesToken;
        _changesTokenExpired = response.changesTokenExpired;
        _changesHasMore = response.hasMore;
        _changes.addAll(response.changes);
        _lastBeforeCount = summary.beforeCount;
        _lastAfterCount = summary.afterCount;
        _lastInserted = summary.inserted;
        _lastUpdated = summary.updated;
        _lastRemoved = summary.removed;
        _lastAppliedUpserts = summary.appliedUpserts;
        _lastAppliedDeletes = summary.appliedDeletes;
        _lastChangesAt = DateTime.now();
        _state = AppState.CHANGES_READY;
      });
    } catch (error) {
      debugPrint("Exception in fetchChanges: $error");
      setState(() => _state = AppState.CHANGES_NOT_READY);
    }
  }

  _ChangesSummary _applyChanges(List<HealthChange> changes) {
    final beforeCount = _syncedDataById.length;
    int inserted = 0;
    int updated = 0;
    int removed = 0;
    int appliedUpserts = 0;
    int appliedDeletes = 0;

    for (final change in changes) {
      if (change.type == HealthChangeType.delete) {
        appliedDeletes += 1;
        final recordId = change.recordId;
        if (recordId != null && _syncedDataById.remove(recordId) != null) {
          removed += 1;
        }
        continue;
      }

      appliedUpserts += 1;
      final dataPoint = change.dataPoint;
      if (dataPoint == null || dataPoint.uuid.isEmpty) {
        continue;
      }

      final existing = _syncedDataById[dataPoint.uuid];
      _syncedDataById[dataPoint.uuid] = dataPoint;
      if (existing == null) {
        inserted += 1;
      } else if (existing != dataPoint) {
        updated += 1;
      }
    }

    return _ChangesSummary(
      beforeCount: beforeCount,
      afterCount: _syncedDataById.length,
      inserted: inserted,
      updated: updated,
      removed: removed,
      appliedUpserts: appliedUpserts,
      appliedDeletes: appliedDeletes,
    );
  }

  /// Fetch data points from the health plugin and show them in the app.
  Future<void> fetchData() async {
    setState(() => _state = AppState.FETCHING_DATA);

    // get data within the last 24 hours
    final now = DateTime.now();
    final yesterday = now.subtract(const Duration(hours: 24));

    // Clear old data points
    _healthDataList.clear();

    try {
      // fetch health data
      List<HealthDataPoint> healthData = await health.getHealthDataFromTypes(
        types: types,
        startTime: yesterday,
        endTime: now,
        recordingMethodsToFilter: recordingMethodsToFilter,
      );

      debugPrint(
        'Total number of data points: ${healthData.length}. '
        '${healthData.length > 100 ? 'Only showing the first 100.' : ''}',
      );

      // sort the data points by date
      healthData.sort((a, b) => b.dateTo.compareTo(a.dateTo));

      // save all the new data points (only the first 100)
      _healthDataList.addAll(
        (healthData.length < 100) ? healthData : healthData.sublist(0, 100),
      );
    } catch (error) {
      debugPrint("Exception in getHealthDataFromTypes: $error");
    }

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

    for (var data in _healthDataList) {
      debugPrint(data.toJson().toString());
    }

    // update the UI to display the results
    setState(() {
      _state = _healthDataList.isEmpty ? AppState.NO_DATA : AppState.DATA_READY;
    });
  }

  /// 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.
  Future<void> addData() async {
    final now = DateTime.now();
    final earlier = now.subtract(const Duration(minutes: 20));

    // Add data for supported types
    // NOTE: These are only the ones supported on Androids new API Health Connect.
    // Both Android's Health Connect and iOS' HealthKit have more types that we support in the enum list [HealthDataType]
    // Add more - like AUDIOGRAM, HEADACHE_SEVERE etc. to try them.
    bool success = true;

    // misc. health data examples using the writeHealthData() method
    success &= await health.writeHealthData(
      value: 1.925,
      type: HealthDataType.HEIGHT,
      startTime: earlier,
      endTime: now,
      recordingMethod: RecordingMethod.manual,
    );
    success &= await health.writeHealthData(
      value: 90,
      type: HealthDataType.WEIGHT,
      startTime: now,
      recordingMethod: RecordingMethod.manual,
    );
    success &= await health.writeHealthData(
      value: 90,
      type: HealthDataType.HEART_RATE,
      startTime: earlier,
      endTime: now,
      recordingMethod: RecordingMethod.manual,
    );
    success &= await health.writeHealthData(
      value: 90,
      type: HealthDataType.STEPS,
      startTime: earlier,
      endTime: now,
      recordingMethod: RecordingMethod.manual,
    );
    success &= await health.writeHealthData(
      value: 200,
      type: HealthDataType.ACTIVE_ENERGY_BURNED,
      startTime: earlier,
      endTime: now,
      clientRecordId: "uniqueID1234",
      clientRecordVersion: 1,
    );
    success &= await health.writeHealthData(
      value: 70,
      type: HealthDataType.HEART_RATE,
      startTime: earlier,
      endTime: now,
    );
    success &= await health.writeHealthData(
      value: 37,
      type: HealthDataType.BODY_TEMPERATURE,
      startTime: earlier,
      endTime: now,
    );
    success &= await health.writeHealthData(
      value: 105,
      type: HealthDataType.BLOOD_GLUCOSE,
      startTime: earlier,
      endTime: now,
    );
    success &= await health.writeHealthData(
      value: 1.8,
      type: HealthDataType.WATER,
      startTime: earlier,
      endTime: now,
    );

    // different types of sleep
    success &= await health.writeHealthData(
      value: 0.0,
      type: HealthDataType.SLEEP_ASLEEP,
      startTime: earlier,
      endTime: now,
    );
    success &= await health.writeHealthData(
      value: 0.0,
      type: HealthDataType.SLEEP_AWAKE,
      startTime: earlier,
      endTime: now,
    );
    if (_isIOS16OrNewer || Platform.isAndroid) {
      success &= await health.writeHealthData(
        value: 0.0,
        type: HealthDataType.SLEEP_REM,
        startTime: earlier,
        endTime: now,
      );
      success &= await health.writeHealthData(
        value: 0.0,
        type: HealthDataType.SLEEP_DEEP,
        startTime: earlier,
        endTime: now,
      );
    } else if (Platform.isIOS) {
      debugPrint('Skipping SLEEP_REM and SLEEP_DEEP writes on iOS < 16.');
    }
    success &= await health.writeHealthData(
      value: 22,
      type: HealthDataType.LEAN_BODY_MASS,
      startTime: earlier,
      endTime: now,
    );

    if (Platform.isAndroid) {
      success &= await health.writeActivityIntensity(
        intensityLevel: ActivityIntensityLevel.moderate,
        startTime: earlier,
        endTime: now,
        recordingMethod: RecordingMethod.manual,
      );
    }

    // specialized write methods
    success &= await health.writeBloodOxygen(
      saturation: 98,
      startTime: earlier,
      endTime: now,
    );
    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(
      systolic: 90,
      diastolic: 80,
      startTime: now,
      clientRecordId: "uniqueID1234",
      clientRecordVersion: 2,
    );
    success &= await health.writeMeal(
      mealType: MealType.SNACK,
      clientRecordId: "uniqueID1234",
      clientRecordVersion: 1.4,
      startTime: earlier,
      endTime: now,
      caloriesConsumed: 1000,
      carbohydrates: 50,
      protein: 25,
      fatTotal: 50,
      name: "Banana",
      caffeine: 0.002,
      vitaminA: 0.001,
      vitaminC: 0.002,
      vitaminD: 0.003,
      vitaminE: 0.004,
      vitaminK: 0.005,
      b1Thiamin: 0.006,
      b2Riboflavin: 0.007,
      b3Niacin: 0.008,
      b5PantothenicAcid: 0.009,
      b6Pyridoxine: 0.010,
      b7Biotin: 0.011,
      b9Folate: 0.012,
      b12Cobalamin: 0.013,
      calcium: 0.015,
      copper: 0.016,
      iodine: 0.017,
      iron: 0.018,
      magnesium: 0.019,
      manganese: 0.020,
      phosphorus: 0.021,
      potassium: 0.022,
      selenium: 0.023,
      sodium: 0.024,
      zinc: 0.025,
      water: 0.026,
      molybdenum: 0.027,
      chloride: 0.028,
      chromium: 0.029,
      cholesterol: 0.030,
      fiber: 0.031,
      fatMonounsaturated: 0.032,
      fatPolyunsaturated: 0.033,
      fatUnsaturated: 0.065,
      fatTransMonoenoic: 0.65,
      fatSaturated: 066,
      sugar: 0.067,
      recordingMethod: RecordingMethod.manual,
    );

    // Store an Audiogram - only available on iOS
    // 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(
    //   frequencies,
    //   leftEarSensitivities,
    //   rightEarSensitivities,
    //   now,
    //   now,
    //   metadata: {
    //     "HKExternalUUID": "uniqueID",
    //     "HKDeviceName": "bluetooth headphone",
    //   },
    // );

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

    writeSkinTemperatureSample();

    if (Platform.isIOS) {
      success &= await health.writeInsulinDelivery(
        5,
        InsulinDeliveryReason.BOLUS,
        earlier,
        now,
      );
      success &= await health.writeHealthData(
        value: 30,
        type: HealthDataType.HEART_RATE_VARIABILITY_SDNN,
        startTime: earlier,
        endTime: now,
      );
      success &= await health.writeHealthData(
        value: 1.5, // 1.5 m/s (typical walking speed)
        type: HealthDataType.WALKING_SPEED,
        startTime: earlier,
        endTime: now,
        recordingMethod: RecordingMethod.manual,
      );
    } else {
      success &= await health.writeHealthData(
        value: 2.0, // 2.0 m/s (typical jogging speed)
        type: HealthDataType.SPEED,
        startTime: earlier,
        endTime: now,
        recordingMethod: RecordingMethod.manual,
      );
      success &= await health.writeHealthData(
        value: 30,
        type: HealthDataType.HEART_RATE_VARIABILITY_RMSSD,
        startTime: earlier,
        endTime: now,
      );
    }

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

      success &= await health.writeHealthData(
        value: 55,
        type: HealthDataType.UNDERWATER_DEPTH,
        startTime: earlier,
        endTime: now,
        recordingMethod: RecordingMethod.manual,
      );
      success &= await health.writeHealthData(
        value: 4.3,
        type: HealthDataType.UV_INDEX,
        startTime: earlier,
        endTime: now,
        recordingMethod: RecordingMethod.manual,
      );
    } else if (Platform.isIOS) {
      debugPrint(
        'Skipping WATER_TEMPERATURE, UNDERWATER_DEPTH, and UV_INDEX writes on iOS < 16.',
      );
    }

    if (Platform.isIOS) {
      // Mindfulness value should be counted based on start and end time
      success &= await health.writeHealthData(
        value: 10,
        type: HealthDataType.MINDFULNESS,
        startTime: earlier,
        endTime: now,
        recordingMethod: RecordingMethod.automatic,
      );
    }

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

  Future<bool> _ensureSkinTemperaturePermissions({
    required HealthDataAccess access,
  }) async {
    if (!Platform.isAndroid) return false;

    final available = await health.isSkinTemperatureAvailable();
    if (!available) {
      setState(() {
        _contentSkinTemperatureFeatureStatus = const Text(
          'Skin Temperature Feature: NOT AVAILABLE',
        );
        _state = AppState.SKIN_TEMPERATURE_FEATURE_STATUS;
      });
      return false;
    }

    bool? hasPermissions = await health.hasPermissions(
      [HealthDataType.SKIN_TEMPERATURE],
      permissions: [access],
    );

    if (hasPermissions != true) {
      hasPermissions = await health.requestAuthorization(
        [HealthDataType.SKIN_TEMPERATURE],
        permissions: [access],
      );
    }

    return hasPermissions == true;
  }

  /// Write a sample skin temperature record (Android only).
  Future<void> writeSkinTemperatureSample() async {
    if (!Platform.isAndroid) {
      setState(() => _state = AppState.DATA_NOT_ADDED);
      return;
    }

    final authorized = await _ensureSkinTemperaturePermissions(
      access: HealthDataAccess.READ_WRITE,
    );
    if (!authorized) {
      setState(() => _state = AppState.DATA_NOT_ADDED);
      return;
    }

    final now = DateTime.now();
    final earlier = now.subtract(const Duration(minutes: 20));
    await health.writeHealthData(
      value: 33.6,
      type: HealthDataType.SKIN_TEMPERATURE,
      startTime: earlier,
      endTime: now,
      recordingMethod: RecordingMethod.manual,
    );

  }

  /// Writes a sample workout route and associates it with a workout.
  Future<void> writeWorkoutRoute() async {
    if (!(Platform.isIOS || Platform.isAndroid)) {
      debugPrint('Workout routes are only supported on iOS or Android.');
      return;
    }

    if (Platform.isAndroid &&
        health.healthConnectSdkStatus != HealthConnectSdkStatus.sdkAvailable) {
      debugPrint(
        'Workout routes on Android require Health Connect to be available.',
      );
      return;
    }

    String? builderId;
    try {
      builderId = await health.startWorkoutRoute();

      List<HealthDataPoint> workouts = await health.getHealthDataFromTypes(
        types: const [HealthDataType.WORKOUT],
        startTime: DateTime.now().subtract(const Duration(hours: 3)),
        endTime: DateTime.now().add(const Duration(hours: 3)),
      );

      if (workouts.isEmpty) {
        final now = DateTime.now();
        final fallbackStart = now.subtract(const Duration(minutes: 30));
        final fallbackEnd = now;

        await health.writeWorkoutData(
          activityType: HealthWorkoutActivityType.WALKING,
          start: fallbackStart,
          end: fallbackEnd,
          totalDistance: 1500,
          totalEnergyBurned: 150,
        );

        // Add a small delay to allow the workout to be written before querying
        await Future.delayed(const Duration(milliseconds: 500));

        workouts = await health.getHealthDataFromTypes(
          types: const [HealthDataType.WORKOUT],
          startTime: fallbackStart.subtract(const Duration(minutes: 1)),
          endTime: fallbackEnd.add(const Duration(minutes: 1)),
        );

        // Retry once more if still empty after a longer delay
        if (workouts.isEmpty) {
          await Future.delayed(const Duration(seconds: 1));
          workouts = await health.getHealthDataFromTypes(
            types: const [HealthDataType.WORKOUT],
            startTime: fallbackStart.subtract(const Duration(minutes: 1)),
            endTime: fallbackEnd.add(const Duration(minutes: 1)),
          );
        }
      }

      workouts.sort((a, b) => b.dateTo.compareTo(a.dateTo));
      if (workouts.isEmpty) {
        throw StateError(
          'Unable to find a workout to attach the route. The created workout may not be immediately available. Try running a workout manually first, or wait a moment and try again.',
        );
      }

      final workoutPoint = workouts.first;
      final routeLocations = _buildSampleWorkoutRoute(
        workoutPoint.dateFrom,
        workoutPoint.dateTo,
      );

      await health.insertWorkoutRouteData(
        builderId: builderId,
        locations: routeLocations,
      );

      final routeUuid = await health.finishWorkoutRoute(
        builderId: builderId,
        workoutUuid: workoutPoint.uuid,
        metadata: const {'sample_source': 'health_example_app'},
      );

      debugPrint(
        'Created workout route $routeUuid for workout ${workoutPoint.uuid}.',
      );

      await fetchData();
    } catch (error) {
      debugPrint('Exception in writeWorkoutRoute: $error');
      if (builderId != null) {
        try {
          await health.discardWorkoutRoute(builderId);
        } catch (discardError) {
          debugPrint('Failed to discard workout route builder: $discardError');
        }
      }
      if (mounted) {
        setState(() => _state = AppState.DATA_NOT_ADDED);
      }
    }
  }

  List<WorkoutRouteLocation> _buildSampleWorkoutRoute(
    DateTime start,
    DateTime end,
  ) {
    final clampedEnd = end.subtract(const Duration(seconds: 1));
    if (!clampedEnd.isAfter(start)) {
      throw ArgumentError('Workout end time must be after start time.');
    }

    final coordinates = <List<double>>[
      [37.334900, -122.009020],
      [37.335020, -122.008720],
      [37.335280, -122.008430],
      [37.335540, -122.008250],
      [37.335780, -122.008000],
    ];

    final intervalMillis =
        (clampedEnd.millisecondsSinceEpoch - start.millisecondsSinceEpoch) ~/
        (coordinates.length + 1);
    final interval = Duration(milliseconds: intervalMillis.clamp(1, 600000));

    return List<WorkoutRouteLocation>.generate(coordinates.length, (index) {
      final pointTime = start.add(interval * (index + 1));
      final timestamp = pointTime.isBefore(clampedEnd) ? pointTime : clampedEnd;
      final latitude = coordinates[index][0];
      final longitude = coordinates[index][1];

      return WorkoutRouteLocation(
        latitude: latitude,
        longitude: longitude,
        timestamp: timestamp,
        altitude: 10 + index * 1.5,
        horizontalAccuracy: 5.0,
        verticalAccuracy: 8.0,
        speed: 1.4 + index * 0.1,
        course: ((index * 45) % 360).toDouble(),
      );
    });
  }

  /// Delete some random health data.
  Future<void> deleteData() async {
    final now = DateTime.now();
    final earlier = now.subtract(const Duration(hours: 24));

    bool success = true;
    for (HealthDataType type in types) {
      success &= await health.delete(
        type: type,
        startTime: earlier,
        endTime: now,
      );
    }

    // To delete a record by UUID - call the `health.deleteByUUID` method:
    /**
      List<HealthDataPoint> healthData = await health.getHealthDataFromTypes(
        types: [HealthDataType.STEPS],
        startTime: startDate,
        endTime: endDate,
      );
      
      if (healthData.isNotEmpty) {
        print("DELETING: ${healthData.first.toJson()}");
        String uuid = healthData.first.uuid;
        
        success &= await health.deleteByUUID(
          type: HealthDataType.STEPS,
          uuid: uuid,
        );
        
      }
     */

    setState(() {
      _state = success ? AppState.DATA_DELETED : AppState.DATA_NOT_DELETED;
    });
  }

  /// Fetch steps from the health plugin and show them in the app.
  Future<void> fetchStepData() async {
    int? steps;

    // get steps for today (i.e., since midnight)
    final now = DateTime.now();
    final midnight = DateTime(now.year, now.month, now.day);

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

    if (stepsPermission) {
      try {
        steps = await health.getTotalStepsInInterval(
          midnight,
          now,
          includeManualEntry: !recordingMethodsToFilter.contains(
            RecordingMethod.manual,
          ),
        );
      } catch (error) {
        debugPrint("Exception in getTotalStepsInInterval: $error");
      }

      debugPrint('Total number of steps: $steps');

      setState(() {
        _nofSteps = (steps == null) ? 0 : steps;
        _state = (steps == null) ? AppState.NO_DATA : AppState.STEPS_READY;
      });
    } else {
      debugPrint("Authorization not granted - error in authorization");
      setState(() => _state = AppState.DATA_NOT_FETCHED);
    }
  }

  /// Revoke access to health data. Note, this only has an effect on Android.
  Future<void> revokeAccess() async {
    setState(() => _state = AppState.PERMISSIONS_REVOKING);

    bool success = false;

    try {
      await health.revokePermissions();
      success = true;
    } catch (error) {
      debugPrint("Exception in revokeAccess: $error");
    }

    setState(() {
      _state = success
          ? AppState.PERMISSIONS_REVOKED
          : AppState.PERMISSIONS_NOT_REVOKED;
    });
  }

  Future<void> getIntervalBasedData() async {
    final startDate = DateTime.now().subtract(const Duration(days: 7));
    final endDate = DateTime.now();

    List<HealthDataPoint> healthDataResponse = await health
        .getHealthIntervalDataFromTypes(
          startDate: startDate,
          endDate: endDate,
          types: [HealthDataType.BLOOD_OXYGEN, HealthDataType.STEPS],
          interval: 86400, // 86400 seconds = 1 day
          // recordingMethodsToFilter: recordingMethodsToFilter,
        );
    debugPrint(
      'Total number of interval data points: ${healthDataResponse.length}. '
      '${healthDataResponse.length > 100 ? 'Only showing the first 100.' : ''}',
    );

    debugPrint("Interval data points: ");
    for (var data in healthDataResponse) {
      debugPrint(toJsonString(data));
    }
    healthDataResponse.sort((a, b) => b.dateTo.compareTo(a.dateTo));

    _healthDataList.clear();
    _healthDataList.addAll(
      (healthDataResponse.length < 100)
          ? healthDataResponse
          : healthDataResponse.sublist(0, 100),
    );

    for (var data in _healthDataList) {
      debugPrint(toJsonString(data));
    }

    setState(() {
      _state = _healthDataList.isEmpty ? AppState.NO_DATA : AppState.DATA_READY;
    });
  }

  /// 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
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('Health Example')),
        body: Column(
          children: [
            Wrap(
              spacing: 10,
              children: [
                if (Platform.isAndroid)
                  TextButton(
                    onPressed: getHealthConnectSdkStatus,
                    style: const ButtonStyle(
                      backgroundColor: WidgetStatePropertyAll(Colors.blue),
                    ),
                    child: const Text(
                      "Check Health Connect Status",
                      style: TextStyle(color: Colors.white),
                    ),
                  ),
                if (Platform.isAndroid &&
                    health.healthConnectSdkStatus !=
                        HealthConnectSdkStatus.sdkAvailable)
                  TextButton(
                    onPressed: installHealthConnect,
                    style: const ButtonStyle(
                      backgroundColor: WidgetStatePropertyAll(Colors.blue),
                    ),
                    child: const Text(
                      "Install Health Connect",
                      style: TextStyle(color: Colors.white),
                    ),
                  ),
                if (Platform.isIOS ||
                    Platform.isAndroid &&
                        health.healthConnectSdkStatus ==
                            HealthConnectSdkStatus.sdkAvailable)
                  Wrap(
                    spacing: 10,
                    children: [
                      TextButton(
                        onPressed: authorize,
                        style: const ButtonStyle(
                          backgroundColor: WidgetStatePropertyAll(Colors.blue),
                        ),
                        child: const Text(
                          "Authenticate",
                          style: TextStyle(color: Colors.white),
                        ),
                      ),
                      if (Platform.isAndroid)
                        TextButton(
                          onPressed: getSkinTemperatureFeatureStatus,
                          style: const ButtonStyle(
                            backgroundColor: WidgetStatePropertyAll(Colors.blue),
                          ),
                          child: const Text(
                            "Check Skin Temp Feature",
                            style: TextStyle(color: Colors.white),
                          ),
                        ),
                      TextButton(
                        onPressed: fetchData,
                        style: const ButtonStyle(
                          backgroundColor: WidgetStatePropertyAll(Colors.blue),
                        ),
                        child: const Text(
                          "Fetch Data",
                          style: TextStyle(color: Colors.white),
                        ),
                      ),
                      if (Platform.isAndroid)
                        TextButton(
                          onPressed: createChangesToken,
                          style: const ButtonStyle(
                            backgroundColor: WidgetStatePropertyAll(
                              Colors.blue,
                            ),
                          ),
                          child: const Text(
                            "Create Changes Token",
                            style: TextStyle(color: Colors.white),
                          ),
                        ),
                      if (Platform.isAndroid)
                        TextButton(
                          onPressed: fetchChanges,
                          style: const ButtonStyle(
                            backgroundColor: WidgetStatePropertyAll(
                              Colors.blue,
                            ),
                          ),
                          child: const Text(
                            "Get Changes",
                            style: TextStyle(color: Colors.white),
                          ),
                        ),
                      TextButton(
                        onPressed: addData,
                        style: const ButtonStyle(
                          backgroundColor: WidgetStatePropertyAll(Colors.blue),
                        ),
                        child: const Text(
                          "Add Data",
                          style: TextStyle(color: Colors.white),
                        ),
                      ),
                      if (Platform.isIOS || Platform.isAndroid)
                        TextButton(
                          onPressed: writeWorkoutRoute,
                          style: const ButtonStyle(
                            backgroundColor: WidgetStatePropertyAll(
                              Colors.blue,
                            ),
                          ),
                          child: const Text(
                            "Write Workout Route",
                            style: TextStyle(color: Colors.white),
                          ),
                        ),
                      TextButton(
                        onPressed: deleteData,
                        style: const ButtonStyle(
                          backgroundColor: WidgetStatePropertyAll(Colors.blue),
                        ),
                        child: const Text(
                          "Delete Data",
                          style: TextStyle(color: Colors.white),
                        ),
                      ),
                      TextButton(
                        onPressed: fetchStepData,
                        style: const ButtonStyle(
                          backgroundColor: WidgetStatePropertyAll(Colors.blue),
                        ),
                        child: const Text(
                          "Fetch Step Data",
                          style: TextStyle(color: Colors.white),
                        ),
                      ),
                      TextButton(
                        onPressed: revokeAccess,
                        style: const ButtonStyle(
                          backgroundColor: WidgetStatePropertyAll(Colors.blue),
                        ),
                        child: const Text(
                          "Revoke Access",
                          style: TextStyle(color: Colors.white),
                        ),
                      ),
                      TextButton(
                        onPressed: getIntervalBasedData,
                        style: const ButtonStyle(
                          backgroundColor: WidgetStatePropertyAll(Colors.blue),
                        ),
                        child: const Text(
                          'Get Interval Data (7 days)',
                          style: TextStyle(color: Colors.white),
                        ),
                      ),
                    ],
                  ),
              ],
            ),
            const Divider(thickness: 3),
            if (_state == AppState.DATA_READY) _dataFiltration,
            if (_state == AppState.STEPS_READY) _stepsFiltration,
            Expanded(child: Center(child: _content)),
          ],
        ),
      ),
    );
  }

  Widget get _dataFiltration => Column(
    children: [
      Wrap(
        children: [
          for (final method
              in Platform.isAndroid
                  ? [
                      RecordingMethod.manual,
                      RecordingMethod.automatic,
                      RecordingMethod.active,
                      RecordingMethod.unknown,
                    ]
                  : [RecordingMethod.automatic, RecordingMethod.manual])
            SizedBox(
              width: 150,
              child: CheckboxListTile(
                title: Text(
                  '${method.name[0].toUpperCase()}${method.name.substring(1)} entries',
                ),
                value: !recordingMethodsToFilter.contains(method),
                onChanged: (value) {
                  setState(() {
                    if (value!) {
                      recordingMethodsToFilter.remove(method);
                    } else {
                      recordingMethodsToFilter.add(method);
                    }
                    fetchData();
                  });
                },
                controlAffinity: ListTileControlAffinity.leading,
                contentPadding: EdgeInsets.zero,
                dense: true,
              ),
            ),
          // Add other entries here if needed
        ],
      ),
      const Divider(thickness: 3),
    ],
  );

  Widget get _stepsFiltration => Column(
    children: [
      Wrap(
        children: [
          for (final method in [RecordingMethod.manual])
            SizedBox(
              width: 150,
              child: CheckboxListTile(
                title: Text(
                  '${method.name[0].toUpperCase()}${method.name.substring(1)} entries',
                ),
                value: !recordingMethodsToFilter.contains(method),
                onChanged: (value) {
                  setState(() {
                    if (value!) {
                      recordingMethodsToFilter.remove(method);
                    } else {
                      recordingMethodsToFilter.add(method);
                    }
                    fetchStepData();
                  });
                },
                controlAffinity: ListTileControlAffinity.leading,
                contentPadding: EdgeInsets.zero,
                dense: true,
              ),
            ),
          // Add other entries here if needed
        ],
      ),
      const Divider(thickness: 3),
    ],
  );

  Widget get _permissionsRevoking => Column(
    mainAxisAlignment: MainAxisAlignment.center,
    children: <Widget>[
      Container(
        padding: const EdgeInsets.all(20),
        child: const CircularProgressIndicator(strokeWidth: 10),
      ),
      const Text('Revoking permissions...'),
    ],
  );

  Widget get _permissionsRevoked => const Text('Permissions revoked.');

  Widget get _permissionsNotRevoked =>
      const Text('Failed to revoke permissions');

  Widget get _contentFetchingData => Column(
    mainAxisAlignment: MainAxisAlignment.center,
    children: <Widget>[
      Container(
        padding: const EdgeInsets.all(20),
        child: const CircularProgressIndicator(strokeWidth: 10),
      ),
      const Text('Fetching data...'),
    ],
  );

  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');

  final Widget _contentNotFetched = const Column(
    mainAxisAlignment: MainAxisAlignment.center,
    children: [
      Text("Press 'Auth' to get permissions to access health data."),
      Text("Press 'Fetch Data' to get health data."),
      Text("Press 'Add Data' to add some random health data."),
      Text("Press 'Delete Data' to remove some random health data."),
    ],
  );

  final Widget _authorized = const Text('Authorization granted!');

  final Widget _authorizationNotGranted = const Column(
    mainAxisAlignment: MainAxisAlignment.center,
    children: [
      Text('Authorization not given.'),
      Text(
        'For Google Health Connect please check if you have added the right permissions and services to the manifest file.',
      ),
      Text('For Apple Health check your permissions in Apple Health.'),
    ],
  );

  Widget _contentHealthConnectStatus = const Text(
    'No status, click getHealthConnectSdkStatus to get the status.',
  );

  Widget _contentSkinTemperatureFeatureStatus = const Text(
    'No status, click "Check Skin Temp Feature" to get the status.',
  );

  final Widget _dataAdded = const Text('Data points inserted successfully.');

  final Widget _dataDeleted = const Text('Data points deleted successfully.');

  Widget get _stepsFetched => Text('Total number of steps: $_nofSteps.');

  Widget get _contentChangesReady {
    final token = _changesToken;
    final tokenPreview = token == null
        ? 'none'
        : (token.length > 12 ? '${token.substring(0, 12)}...' : token);
    final previousToken = _previousChangesToken;
    final previousPreview = previousToken == null
        ? 'none'
        : (previousToken.length > 12
            ? '${previousToken.substring(0, 12)}...'
            : previousToken);
    final lastChangesAt = _lastChangesAt;

    return ListView.builder(
      itemCount: _changes.length + 1,
      itemBuilder: (_, index) {
        if (index == 0) {
          return ListTile(
            title: const Text('Health Connect Changes'),
            subtitle: Text(
              'prevToken: $previousPreview\n'
              'nextToken: $tokenPreview\n'
              'hasMore: $_changesHasMore | tokenExpired: $_changesTokenExpired\n'
              'applied upserts: $_lastAppliedUpserts | applied deletes: $_lastAppliedDeletes\n'
              'inserted: $_lastInserted | updated: $_lastUpdated | removed: $_lastRemoved\n'
              'store count: $_lastBeforeCount -> $_lastAfterCount\n'
              'last pull: ${lastChangesAt ?? "never"}',
            ),
          );
        }

        final change = _changes[index - 1];
        if (change.type == HealthChangeType.delete) {
          return ListTile(
            title: const Text('Delete'),
            subtitle: Text('recordId: ${change.recordId ?? "unknown"}'),
          );
        }

        final dataPoint = change.dataPoint;
        if (dataPoint == null) {
          return ListTile(
            title: Text('Upsert ${change.dataType?.name ?? "UNKNOWN"}'),
            subtitle: const Text('No data point decoded.'),
          );
        }

        return ListTile(
          title: Text('${dataPoint.typeString}: ${dataPoint.value}'),
          trailing: Text(dataPoint.unitString),
          subtitle: Text('${dataPoint.dateFrom} - ${dataPoint.dateTo}'),
        );
      },
    );
  }

  final Widget _contentChangesNotReady = const Text(
    'No change token yet. Tap "Create Changes Token" after authorization.',
  );

  final Widget _dataNotAdded = const Text(
    'Failed to add data.\nDo you have permissions to add data?',
  );

  final Widget _dataNotDeleted = const Text('Failed to delete data');

  Widget get _content => switch (_state) {
    AppState.DATA_READY => _contentDataReady,
    AppState.DATA_NOT_FETCHED => _contentNotFetched,
    AppState.FETCHING_DATA => _contentFetchingData,
    AppState.NO_DATA => _contentNoData,
    AppState.AUTHORIZED => _authorized,
    AppState.AUTH_NOT_GRANTED => _authorizationNotGranted,
    AppState.DATA_ADDED => _dataAdded,
    AppState.DATA_DELETED => _dataDeleted,
    AppState.DATA_NOT_ADDED => _dataNotAdded,
    AppState.DATA_NOT_DELETED => _dataNotDeleted,
    AppState.STEPS_READY => _stepsFetched,
    AppState.HEALTH_CONNECT_STATUS => _contentHealthConnectStatus,
    AppState.SKIN_TEMPERATURE_FEATURE_STATUS =>
      _contentSkinTemperatureFeatureStatus,
    AppState.PERMISSIONS_REVOKING => _permissionsRevoking,
    AppState.PERMISSIONS_REVOKED => _permissionsRevoked,
    AppState.PERMISSIONS_NOT_REVOKED => _permissionsNotRevoked,
    AppState.CHANGES_READY => _contentChangesReady,
    AppState.CHANGES_NOT_READY => _contentChangesNotReady,
  };

  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()),
                          );
                        },
                      ),
                    ),
            ],
          ),
        );
      },
    );
  }
}
652
likes
160
points
59.4k
downloads

Publisher

verified publishercachet.dk

Weekly Downloads

Wrapper for Apple's HealthKit on iOS and Google's Health Connect on Android.

Repository (GitHub)
View/report issues

Documentation

API reference

License

MIT (license)

Dependencies

carp_serializable, device_info_plus, flutter, intl, json_annotation

More

Packages that depend on health

Packages that implement health