apple_health_upload_tool.dart 11 KB
import 'package:doublefeel_flutter/core/result/app_result.dart';
import 'package:doublefeel_flutter/core/logging/app_logger.dart';
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
import 'package:doublefeel_flutter/pigeon/health_kit_api.g.dart';

import 'apple_health_upload_api.dart';
import 'apple_health_upload_local_recorder.dart';
import 'health_kit_upload_mapper.dart';
import 'models/apple_health_upload_record.dart';
import 'models/apple_health_upload_request.dart';
import 'models/apple_health_upload_sample.dart';
import 'models/health_upload_data_type.dart';
import 'models/upload_activity_target.dart';
import 'models/upload_sleep.dart';

class AppleHealthUploadTool {
  AppleHealthUploadTool(
    this._api,
    this._localRecorder,
    this._userPreferences, {
    HealthKitHostApi? hostApi,
  }) : _hostApi = hostApi ?? HealthKitHostApi();

  final AppleHealthUploadApi _api;
  final AppleHealthUploadLocalRecorder _localRecorder;
  final UserPreferencesStorage _userPreferences;
  final HealthKitHostApi _hostApi;

  static const _historyWindow = Duration(days: 365 * 2);
  static const _commonTypes = [
    HealthUploadDataType.hrv,
    HealthUploadDataType.heartRate,
    HealthUploadDataType.walkingHeartRate,
    HealthUploadDataType.restingHeartRate,
    HealthUploadDataType.sleepingHeartRate,
    HealthUploadDataType.oxygenSaturation,
    HealthUploadDataType.activeEnergy,
    HealthUploadDataType.exercise,
    HealthUploadDataType.stand,
    HealthUploadDataType.steps,
    HealthUploadDataType.sleepingWristTemperature,
    HealthUploadDataType.respiratoryRate,
    HealthUploadDataType.irregularHeartRhythm,
  ];

  Future<AppleHealthUploadResult> upload({
    List<AppleHealthUploadSample> commonData = const [],
    List<HealthSleepUploadData> sleepData = const [],
    HealthActivityTargetUploadData? activityTarget,
  }) async {
    AppResult<void>? commonResult;
    AppResult<void>? sleepResult;
    AppResult<void>? activityTargetResult;

    if (commonData.isNotEmpty) {
      commonResult = await _api.uploadCommonData(
        AppleHealthCommonUploadRequest(dataList: commonData),
      );
    }

    if (sleepData.isNotEmpty) {
      sleepResult = await _api.uploadSleepData(
        AppleHealthSleepUploadRequest(dataList: sleepData),
      );
    }

    if (activityTarget != null) {
      activityTargetResult = await _api.uploadActivityTarget(activityTarget);
    }

    return AppleHealthUploadResult(
      commonUploadSuccess:
          commonResult == null || commonResult is AppSuccess<void>,
      sleepUploadSuccess:
          sleepResult == null || sleepResult is AppSuccess<void>,
      activityTargetUploadSuccess: activityTargetResult == null ||
          activityTargetResult is AppSuccess<void>,
    );
  }

  Future<AppResult<AppleHealthLatestUploadRecordList>>
      getLatestCommonUploadRecordList() {
    return _api.getLatestCommonUploadRecordList();
  }

  Future<AppResult<AppleHealthLatestSleepUploadRecord>>
      getLatestSleepUploadRecord() {
    return _api.getLatestSleepUploadRecord();
  }

  Future<AppleHealthUploadResult> uploadAllNewDataFromHealthKit() async {
    final userId = _userPreferences.preferences.value.meUserInfo?.id ?? 0;
    if (userId <= 0) {
      return const AppleHealthUploadResult(
        commonUploadSuccess: false,
        sleepUploadSuccess: false,
        activityTargetUploadSuccess: false,
      );
    }

    final endTime = DateTime.now().millisecondsSinceEpoch ~/ 1000;
    final startTimes = await _resolveStartTimes(userId);

    final commonDataByType =
        <HealthUploadDataType, List<AppleHealthUploadSample>>{};
    for (final type in _commonTypes) {
      final points = await _safeFetchCommonDataPointList(
        type: type,
        startTime: startTimes[type] ?? _fallbackStartTime(),
        endTime: endTime,
      );
      final models = points
          .map((point) => point.toAppleHealthUploadSample())
          .whereType<AppleHealthUploadSample>()
          .toList();
      commonDataByType[type] = models;
    }

    final sleepPoints = await _safeFetchSleepDataPointList(
      startTimes[HealthUploadDataType.sleep] ?? _fallbackStartTime(),
      endTime,
    );
    final sleepData =
        sleepPoints.map((item) => item.toHealthSleepUploadData()).toList();

    final activityTarget = await _fetchActivityTarget(startTimes, endTime);

    final commonData = commonDataByType.values
        .expand((items) => items)
        .toList(growable: false);

    final result = await upload(
      commonData: commonData,
      sleepData: sleepData,
      activityTarget: activityTarget,
    );

    if (result.commonUploadSuccess) {
      final uploadedCommonTypes = <HealthUploadDataType, int>{};
      for (final entry in commonDataByType.entries) {
        if (entry.value.isNotEmpty) {
          uploadedCommonTypes[entry.key] = endTime;
        }
      }
      await _localRecorder.saveLatestDataTimes(
        userId: userId,
        latestDataTimes: uploadedCommonTypes,
      );
    }

    if (result.sleepUploadSuccess && sleepData.isNotEmpty) {
      await _localRecorder.saveLatestDataTime(
        userId: userId,
        dataType: HealthUploadDataType.sleep,
        latestDataTime: endTime,
      );
    }

    return AppleHealthUploadResult(
      commonUploadSuccess: result.commonUploadSuccess,
      sleepUploadSuccess: result.sleepUploadSuccess,
      activityTargetUploadSuccess: result.activityTargetUploadSuccess,
      commonCount: commonData.length,
      sleepCount: sleepData.length,
      activityTargetCount: activityTarget == null ? 0 : 1,
    );
  }

  Future<Map<HealthUploadDataType, int>> _resolveStartTimes(int userId) async {
    final localRecords = _localRecorder.load(userId);
    final serverCommonRecords = await _serverCommonRecordMap();
    final serverSleepRecord = await _serverSleepLatestTime();

    final result = <HealthUploadDataType, int>{};
    for (final type in [..._commonTypes, HealthUploadDataType.sleep]) {
      final serverTime = type == HealthUploadDataType.sleep
          ? serverSleepRecord
          : serverCommonRecords[type];
      result[type] = _resolveStartTime(
        localTime: localRecords.latestDataTime(type),
        serverTime: serverTime,
      );
    }
    return result;
  }

  Future<Map<HealthUploadDataType, int>> _serverCommonRecordMap() async {
    final result = await _api.getLatestCommonUploadRecordList(
      errorHandlingPolicy: null,
    );
    if (result
        case AppSuccess<AppleHealthLatestUploadRecordList>(data: final data)) {
      final records = data.latestDataTimeList ?? const [];
      return {
        for (final record in records)
          if (record.dataType != null && record.latestDataTime != null)
            record.dataType!: record.latestDataTime!,
      };
    }
    return {};
  }

  Future<int?> _serverSleepLatestTime() async {
    final result = await _api.getLatestSleepUploadRecord(
      errorHandlingPolicy: null,
    );
    if (result
        case AppSuccess<AppleHealthLatestSleepUploadRecord>(data: final data)) {
      return data.latestDataTime;
    }
    return null;
  }

  int _resolveStartTime({int? localTime, int? serverTime}) {
    final fallback = _fallbackStartTime();
    if (localTime != null && localTime > fallback) return localTime;
    if (serverTime != null && serverTime > fallback) return serverTime;
    return fallback;
  }

  int _fallbackStartTime() {
    final start = DateTime.now().subtract(_historyWindow);
    return DateTime(start.year, start.month, start.day)
            .millisecondsSinceEpoch ~/
        1000;
  }

  Future<List<HealthUploadDataPoint>> _safeFetchCommonDataPointList({
    required HealthUploadDataType type,
    required int startTime,
    required int endTime,
  }) async {
    try {
      return await _fetchCommonDataPointList(type, startTime, endTime);
    } catch (error, stackTrace) {
      AppLogger.w(
        'Fetch Apple Health data failed: $type',
        error,
        stackTrace,
      );
      return const [];
    }
  }

  Future<List<HealthSleepUploadDataPoint>> _safeFetchSleepDataPointList(
    int startTime,
    int endTime,
  ) async {
    try {
      return await _hostApi.fetchSleepData(startTime, endTime);
    } catch (error, stackTrace) {
      AppLogger.w('Fetch Apple Health sleep data failed', error, stackTrace);
      return const [];
    }
  }

  Future<List<HealthUploadDataPoint>> _fetchCommonDataPointList(
    HealthUploadDataType type,
    int startTime,
    int endTime,
  ) {
    return switch (type) {
      HealthUploadDataType.hrv => _hostApi.fetchHrvData(startTime, endTime),
      HealthUploadDataType.heartRate =>
        _hostApi.fetchHeartRateData(startTime, endTime),
      HealthUploadDataType.walkingHeartRate =>
        _hostApi.fetchWalkingHeartRateData(startTime, endTime),
      HealthUploadDataType.restingHeartRate =>
        _hostApi.fetchRestingHeartRateData(startTime, endTime),
      HealthUploadDataType.sleepingHeartRate =>
        _hostApi.fetchSleepingHeartRateData(startTime, endTime),
      HealthUploadDataType.oxygenSaturation =>
        _hostApi.fetchOxygenSaturationData(startTime, endTime),
      HealthUploadDataType.activeEnergy =>
        _hostApi.fetchActiveEnergyData(startTime, endTime),
      HealthUploadDataType.exercise =>
        _hostApi.fetchExerciseData(startTime, endTime),
      HealthUploadDataType.stand => _hostApi.fetchStandData(startTime, endTime),
      HealthUploadDataType.steps =>
        _hostApi.fetchStepCountData(startTime, endTime),
      HealthUploadDataType.sleepingWristTemperature =>
        _hostApi.fetchSleepingWristTemperatureData(startTime, endTime),
      HealthUploadDataType.respiratoryRate =>
        _hostApi.fetchRespiratoryRateData(startTime, endTime),
      HealthUploadDataType.irregularHeartRhythm =>
        _hostApi.fetchIrregularHeartRhythmData(startTime, endTime),
      HealthUploadDataType.unknown ||
      HealthUploadDataType.sleep =>
        Future.value(const []),
    };
  }

  Future<HealthActivityTargetUploadData?> _fetchActivityTarget(
    Map<HealthUploadDataType, int> startTimes,
    int endTime,
  ) async {
    final startTime =
        startTimes[HealthUploadDataType.activeEnergy] ?? _fallbackStartTime();
    final HealthActivityTargetData? target;
    try {
      target = await _hostApi.fetchActivityTargetData(startTime, endTime);
    } catch (error, stackTrace) {
      AppLogger.w(
          'Fetch Apple Health activity target failed', error, stackTrace);
      return null;
    }
    if (target == null) return null;
    return HealthActivityTargetUploadData(
      move: target.move,
      stand: target.stand,
    );
  }
}

class AppleHealthUploadResult {
  const AppleHealthUploadResult({
    required this.commonUploadSuccess,
    required this.sleepUploadSuccess,
    required this.activityTargetUploadSuccess,
    this.commonCount = 0,
    this.sleepCount = 0,
    this.activityTargetCount = 0,
  });

  final bool commonUploadSuccess;
  final bool sleepUploadSuccess;
  final bool activityTargetUploadSuccess;
  final int commonCount;
  final int sleepCount;
  final int activityTargetCount;

  bool get isSuccess =>
      commonUploadSuccess && sleepUploadSuccess && activityTargetUploadSuccess;
}