|
|
|
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);
|
|
|
|
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 [],
|
|
...
|
...
|
@@ -56,6 +87,229 @@ class AppleHealthUploadTool { |
|
|
|
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 {
|
|
...
|
...
|
@@ -63,11 +317,17 @@ class 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;
|
...
|
...
|
|