feat(workout): 新增锻炼历史记录功能与健康数据集成

- 新增锻炼历史页面,展示最近一个月的锻炼记录详情
- 添加锻炼汇总卡片组件,在统计页面显示当日锻炼数据
- 集成HealthKit锻炼数据获取,支持多种运动类型和详细信息
- 完善锻炼数据处理工具,包含统计分析和格式化功能
- 优化后台任务,随机选择挑战发送鼓励通知
- 版本升级至1.0.16
This commit is contained in:
2025-10-02 22:13:59 +08:00
parent 303c36025b
commit 79ddd41a49
13 changed files with 1437 additions and 34 deletions

View File

@@ -10,28 +10,28 @@
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
32476CAEFFCE691C1634B0A4 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA3641BAC6078512F41509D /* ExpoModulesProvider.swift */; };
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
646189797DBE7937221347A9 /* libPods-OutLive.a in Frameworks */ = {isa = PBXBuildFile; fileRef = C411D1CEBC225A6F92F136BA /* libPods-OutLive.a */; };
79B2CB702E7B954600B51753 /* OutLive-Bridging-Header.h in Sources */ = {isa = PBXBuildFile; fileRef = F11748442D0722820044C1D9 /* OutLive-Bridging-Header.h */; };
79B2CB732E7B954F00B51753 /* HealthKitManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB712E7B954F00B51753 /* HealthKitManager.m */; };
79B2CB742E7B954F00B51753 /* HealthKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB722E7B954F00B51753 /* HealthKitManager.swift */; };
91B7BA17B50D328546B5B4B8 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = B7F23062EE59F61E6260DBA8 /* PrivacyInfo.xcprivacy */; };
AE00ECEC9D078460F642F131 /* libPods-OutLive.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 6F6136AA7113B3D210693D88 /* libPods-OutLive.a */; };
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
0FF78E2879F14B0D75DF41B4 /* Pods-OutLive.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OutLive.release.xcconfig"; path = "Target Support Files/Pods-OutLive/Pods-OutLive.release.xcconfig"; sourceTree = "<group>"; };
08BACF4D920A957DC2FE4350 /* Pods-OutLive.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OutLive.debug.xcconfig"; path = "Target Support Files/Pods-OutLive/Pods-OutLive.debug.xcconfig"; sourceTree = "<group>"; };
13B07F961A680F5B00A75B9A /* OutLive.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OutLive.app; sourceTree = BUILT_PRODUCTS_DIR; };
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = OutLive/Images.xcassets; sourceTree = "<group>"; };
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = OutLive/Info.plist; sourceTree = "<group>"; };
1EA3641BAC6078512F41509D /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-OutLive/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
4FAA45EB21D2C45B94943F48 /* Pods-OutLive.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OutLive.debug.xcconfig"; path = "Target Support Files/Pods-OutLive/Pods-OutLive.debug.xcconfig"; sourceTree = "<group>"; };
6F6136AA7113B3D210693D88 /* libPods-OutLive.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-OutLive.a"; sourceTree = BUILT_PRODUCTS_DIR; };
79B2CB712E7B954F00B51753 /* HealthKitManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = HealthKitManager.m; path = OutLive/HealthKitManager.m; sourceTree = "<group>"; };
79B2CB722E7B954F00B51753 /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = HealthKitManager.swift; path = OutLive/HealthKitManager.swift; sourceTree = "<group>"; };
9B6A6CEBED2FC0931F7B7236 /* Pods-OutLive.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OutLive.release.xcconfig"; path = "Target Support Files/Pods-OutLive/Pods-OutLive.release.xcconfig"; sourceTree = "<group>"; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = OutLive/SplashScreen.storyboard; sourceTree = "<group>"; };
B7F23062EE59F61E6260DBA8 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = OutLive/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
C411D1CEBC225A6F92F136BA /* libPods-OutLive.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-OutLive.a"; sourceTree = BUILT_PRODUCTS_DIR; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = OutLive/AppDelegate.swift; sourceTree = "<group>"; };
F11748442D0722820044C1D9 /* OutLive-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "OutLive-Bridging-Header.h"; path = "OutLive/OutLive-Bridging-Header.h"; sourceTree = "<group>"; };
@@ -42,7 +42,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
646189797DBE7937221347A9 /* libPods-OutLive.a in Frameworks */,
AE00ECEC9D078460F642F131 /* libPods-OutLive.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -67,21 +67,11 @@
isa = PBXGroup;
children = (
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
C411D1CEBC225A6F92F136BA /* libPods-OutLive.a */,
6F6136AA7113B3D210693D88 /* libPods-OutLive.a */,
);
name = Frameworks;
sourceTree = "<group>";
};
7B63456AB81271603E0039A3 /* Pods */ = {
isa = PBXGroup;
children = (
4FAA45EB21D2C45B94943F48 /* Pods-OutLive.debug.xcconfig */,
0FF78E2879F14B0D75DF41B4 /* Pods-OutLive.release.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
80E2A1E8ECA8777F7264D855 /* ExpoModulesProviders */ = {
isa = PBXGroup;
children = (
@@ -107,7 +97,7 @@
83CBBA001A601CBA00E9B192 /* Products */,
2D16E6871FA4F8E400B85C8A /* Frameworks */,
80E2A1E8ECA8777F7264D855 /* ExpoModulesProviders */,
7B63456AB81271603E0039A3 /* Pods */,
D049F514815CB726258DD27E /* Pods */,
);
indentWidth = 2;
sourceTree = "<group>";
@@ -139,6 +129,16 @@
name = OutLive;
sourceTree = "<group>";
};
D049F514815CB726258DD27E /* Pods */ = {
isa = PBXGroup;
children = (
08BACF4D920A957DC2FE4350 /* Pods-OutLive.debug.xcconfig */,
9B6A6CEBED2FC0931F7B7236 /* Pods-OutLive.release.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -146,14 +146,14 @@
isa = PBXNativeTarget;
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "OutLive" */;
buildPhases = (
0B29745394A2F51EC4C9CBC7 /* [CP] Check Pods Manifest.lock */,
1EB539808FAFD6C62AD21A7F /* [CP] Check Pods Manifest.lock */,
FED23F24D8115FB0D63DF986 /* [Expo] Configure project */,
13B07F871A680F5B00A75B9A /* Sources */,
13B07F8C1A680F5B00A75B9A /* Frameworks */,
13B07F8E1A680F5B00A75B9A /* Resources */,
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
EF8950FE7A620E6B790F3042 /* [CP] Embed Pods Frameworks */,
2266B6A6AD27779BC2D49E87 /* [CP] Copy Pods Resources */,
8F598744CAFFA386101BCC07 /* [CP] Embed Pods Frameworks */,
54EB3ED8CF242B308A7FE01E /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -227,7 +227,7 @@
shellPath = /bin/sh;
shellScript = "if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n source \"$PODS_ROOT/../.xcode.env\"\nfi\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT=\"$PROJECT_DIR\"/..\n\nif [[ \"$CONFIGURATION\" = *Debug* ]]; then\n export SKIP_BUNDLING=1\nfi\nif [[ -z \"$ENTRY_FILE\" ]]; then\n # Set the entry JS file using the bundler's entry resolution.\n export ENTRY_FILE=\"$(\"$NODE_BINARY\" -e \"require('expo/scripts/resolveAppEntry')\" \"$PROJECT_ROOT\" ios absolute | tail -n 1)\"\nfi\n\nif [[ -z \"$CLI_PATH\" ]]; then\n # Use Expo CLI\n export CLI_PATH=\"$(\"$NODE_BINARY\" --print \"require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })\")\"\nfi\nif [[ -z \"$BUNDLE_COMMAND\" ]]; then\n # Default Expo CLI command for bundling\n export BUNDLE_COMMAND=\"export:embed\"\nfi\n\n# Source .xcode.env.updates if it exists to allow\n# SKIP_BUNDLING to be unset if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.updates\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.updates\"\nfi\n# Source local changes to allow overrides\n# if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n`\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\"`\n\n";
};
0B29745394A2F51EC4C9CBC7 /* [CP] Check Pods Manifest.lock */ = {
1EB539808FAFD6C62AD21A7F /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -249,7 +249,7 @@
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
2266B6A6AD27779BC2D49E87 /* [CP] Copy Pods Resources */ = {
54EB3ED8CF242B308A7FE01E /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -301,7 +301,7 @@
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-OutLive/Pods-OutLive-resources.sh\"\n";
showEnvVarsInLog = 0;
};
EF8950FE7A620E6B790F3042 /* [CP] Embed Pods Frameworks */ = {
8F598744CAFFA386101BCC07 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -367,7 +367,7 @@
/* Begin XCBuildConfiguration section */
13B07F941A680F5B00A75B9A /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 4FAA45EB21D2C45B94943F48 /* Pods-OutLive.debug.xcconfig */;
baseConfigurationReference = 08BACF4D920A957DC2FE4350 /* Pods-OutLive.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
@@ -404,7 +404,7 @@
};
13B07F951A680F5B00A75B9A /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 0FF78E2879F14B0D75DF41B4 /* Pods-OutLive.release.xcconfig */;
baseConfigurationReference = 9B6A6CEBED2FC0931F7B7236 /* Pods-OutLive.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;

View File

@@ -77,4 +77,9 @@ RCT_EXTERN_METHOD(getWaterIntakeFromHealthKit:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
// Workout Data Methods
RCT_EXTERN_METHOD(getRecentWorkouts:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
@end

View File

@@ -37,9 +37,14 @@ class HealthKitManager: NSObject, RCTBridgeModule {
static let oxygenSaturation = HKObjectType.quantityType(forIdentifier: .oxygenSaturation)!
static let activitySummary = HKObjectType.activitySummaryType()
static let dietaryWater = HKObjectType.quantityType(forIdentifier: .dietaryWater)!
static let workout = HKObjectType.workoutType()
static var all: Set<HKObjectType> {
return [sleep, stepCount, heartRate, heartRateVariability, activeEnergyBurned, basalEnergyBurned, appleExerciseTime, appleStandTime, oxygenSaturation, activitySummary, dietaryWater]
return [sleep, stepCount, heartRate, heartRateVariability, activeEnergyBurned, basalEnergyBurned, appleExerciseTime, appleStandTime, oxygenSaturation, activitySummary, dietaryWater, workout]
}
static var workoutType: HKWorkoutType {
return HKObjectType.workoutType()
}
}
@@ -557,7 +562,7 @@ class HealthKitManager: NSObject, RCTBridgeModule {
let predicate = HKQuery.predicate(forActivitySummariesBetweenStart: startDateComponents, end: endDateComponents)
let query = HKActivitySummaryQuery(predicate: predicate) { [weak self] (query, summaries, error) in
let query = HKActivitySummaryQuery(predicate: predicate) { (query, summaries, error) in
DispatchQueue.main.async {
if let error = error {
rejecter("QUERY_ERROR", "Failed to query activity summary: \(error.localizedDescription)", error)
@@ -673,7 +678,7 @@ class HealthKitManager: NSObject, RCTBridgeModule {
let result: [String: Any] = [
"data": hrvData,
"count": hrvData.count,
"bestQualityValue": bestQualityValue,
"bestQualityValue": bestQualityValue ?? NSNull(),
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
]
@@ -1577,4 +1582,125 @@ class HealthKitManager: NSObject, RCTBridgeModule {
healthStore.execute(query)
}
// MARK: - Workout Data Methods
@objc
func getRecentWorkouts(
_ options: NSDictionary,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard HKHealthStore.isHealthDataAvailable() else {
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
return
}
let workoutType = ReadTypes.workoutType
// Parse options
let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
startDate = d
} else {
// 30
startDate = Calendar.current.date(byAdding: .day, value: -30, to: Date())!
}
let endDate: Date
if let endString = options["endDate"] as? String, let d = parseDate(from: endString) {
endDate = d
} else {
endDate = Date()
}
let limit = options["limit"] as? Int ?? 10 // 10
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
let query = HKSampleQuery(sampleType: ReadTypes.workoutType,
predicate: predicate,
limit: limit,
sortDescriptors: [sortDescriptor]) { [weak self] (query, samples, error) in
DispatchQueue.main.async {
if let error = error {
rejecter("QUERY_ERROR", "Failed to query workouts: \(error.localizedDescription)", error)
return
}
guard let workoutSamples = samples as? [HKWorkout] else {
resolver([
"data": [],
"count": 0,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
])
return
}
let workoutData = workoutSamples.map { workout in
var workoutDict: [String: Any] = [
"id": workout.uuid.uuidString,
"startDate": self?.dateToISOString(workout.startDate) ?? "",
"endDate": self?.dateToISOString(workout.endDate) ?? "",
"duration": workout.duration,
"workoutActivityType": workout.workoutActivityType.rawValue,
"workoutActivityTypeString": self?.workoutActivityTypeToString(workout.workoutActivityType) ?? "unknown",
"source": [
"name": workout.sourceRevision.source.name,
"bundleIdentifier": workout.sourceRevision.source.bundleIdentifier
],
"metadata": workout.metadata ?? [:]
]
//
if let totalEnergyBurned = workout.totalEnergyBurned {
workoutDict["totalEnergyBurned"] = totalEnergyBurned.doubleValue(for: HKUnit.kilocalorie())
}
//
if let totalDistance = workout.totalDistance {
workoutDict["totalDistance"] = totalDistance.doubleValue(for: HKUnit.meter())
}
//
if let averageHeartRate = workout.metadata?["HKAverageHeartRate"] as? Double {
workoutDict["averageHeartRate"] = averageHeartRate
}
return workoutDict
}
let result: [String: Any] = [
"data": workoutData,
"count": workoutData.count,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
]
resolver(result)
}
}
healthStore.execute(query)
}
// MARK: - Workout Helper Methods
// Normalizes the HealthKit enum case so JS receives a predictable camelCase identifier.
private func workoutActivityTypeToString(_ workoutActivityType: HKWorkoutActivityType) -> String {
let description = String(describing: workoutActivityType)
let prefix = "HKWorkoutActivityType"
if description.hasPrefix(prefix) {
let rawName = description.dropFirst(prefix.count)
guard let first = rawName.first else {
return "unknown"
}
let normalized = String(first).lowercased() + rawName.dropFirst()
return normalized
}
return description.lowercased()
}
} // end class

View File

@@ -25,7 +25,7 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0.15</string>
<string>1.0.16</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>