Commit 1aace1893fd2cdd5dde3d4a52503f8263632c959

Authored by 权海
1 parent 97a60633

feat(ui):添加AppleHealth原生接口和支付接口

... ... @@ -211,6 +211,38 @@ data class HealthActivityTargetData (
override fun hashCode(): Int = toList().hashCode()
}
/** Generated class from Pigeon that represents data sent in messages. */
data class HealthAuthorization (
/** -1: AppleHealth不可用, 0: 需要请求授权, 1: 已被授权且能获取到部分数据, 2: 已被拒绝或者没有数据 */
val status: Long,
val hasData: Boolean
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): HealthAuthorization {
val status = pigeonVar_list[0] as Long
val hasData = pigeonVar_list[1] as Boolean
return HealthAuthorization(status, hasData)
}
}
fun toList(): List<Any?> {
return listOf(
status,
hasData,
)
}
override fun equals(other: Any?): Boolean {
if (other !is HealthAuthorization) {
return false
}
if (this === other) {
return true
}
return HealthKitApiPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
private open class HealthKitApiPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) {
... ... @@ -234,6 +266,11 @@ private open class HealthKitApiPigeonCodec : StandardMessageCodec() {
HealthActivityTargetData.fromList(it)
}
}
133.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
HealthAuthorization.fromList(it)
}
}
else -> super.readValueOfType(type, buffer)
}
}
... ... @@ -255,6 +292,10 @@ private open class HealthKitApiPigeonCodec : StandardMessageCodec() {
stream.write(132)
writeValue(stream, value.toList())
}
is HealthAuthorization -> {
stream.write(133)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value)
}
}
... ... @@ -263,13 +304,13 @@ private open class HealthKitApiPigeonCodec : StandardMessageCodec() {
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface HealthKitHostApi {
fun checkHealthAppAuthorization(callback: (Result<Boolean>) -> Unit)
fun getHealthServerAuthUrl(callback: (Result<String>) -> Unit)
/** Opens Huawei Health client authorization UI. Returns whether user granted. */
fun requestHealthClientAuthorization(): Boolean
fun cancelHealthAppAuthorization(): Boolean
/** Runs native health read and server upload pipeline. */
fun performHealthUpload(): HealthUploadResult
/** Opens Huawei Health client authorization UI. Returns whether user granted. */
fun checkHealthAppAuthorization(callback: (Result<HealthAuthorization>) -> Unit)
fun requestHealthClientAuthorization(callback: (Result<Boolean>) -> Unit)
fun fetchHrvData(startTime: Long, endTime: Long, callback: (Result<List<HealthUploadDataPoint>>) -> Unit)
fun fetchHeartRateData(startTime: Long, endTime: Long, callback: (Result<List<HealthUploadDataPoint>>) -> Unit)
fun fetchWalkingHeartRateData(startTime: Long, endTime: Long, callback: (Result<List<HealthUploadDataPoint>>) -> Unit)
... ... @@ -296,10 +337,10 @@ interface HealthKitHostApi {
fun setUp(binaryMessenger: BinaryMessenger, api: HealthKitHostApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.doublefeel_flutter.HealthKitHostApi.checkHealthAppAuthorization$separatedMessageChannelSuffix", codec)
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.doublefeel_flutter.HealthKitHostApi.getHealthServerAuthUrl$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.checkHealthAppAuthorization{ result: Result<Boolean> ->
api.getHealthServerAuthUrl{ result: Result<String> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(HealthKitApiPigeonUtils.wrapError(error))
... ... @@ -314,29 +355,26 @@ interface HealthKitHostApi {
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.doublefeel_flutter.HealthKitHostApi.getHealthServerAuthUrl$separatedMessageChannelSuffix", codec)
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.doublefeel_flutter.HealthKitHostApi.cancelHealthAppAuthorization$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.getHealthServerAuthUrl{ result: Result<String> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(HealthKitApiPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(HealthKitApiPigeonUtils.wrapResult(data))
}
val wrapped: List<Any?> = try {
listOf(api.cancelHealthAppAuthorization())
} catch (exception: Throwable) {
HealthKitApiPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.doublefeel_flutter.HealthKitHostApi.requestHealthClientAuthorization$separatedMessageChannelSuffix", codec)
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.doublefeel_flutter.HealthKitHostApi.performHealthUpload$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.requestHealthClientAuthorization())
listOf(api.performHealthUpload())
} catch (exception: Throwable) {
HealthKitApiPigeonUtils.wrapError(exception)
}
... ... @@ -347,30 +385,36 @@ interface HealthKitHostApi {
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.doublefeel_flutter.HealthKitHostApi.cancelHealthAppAuthorization$separatedMessageChannelSuffix", codec)
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.doublefeel_flutter.HealthKitHostApi.checkHealthAppAuthorization$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.cancelHealthAppAuthorization())
} catch (exception: Throwable) {
HealthKitApiPigeonUtils.wrapError(exception)
api.checkHealthAppAuthorization{ result: Result<HealthAuthorization> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(HealthKitApiPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(HealthKitApiPigeonUtils.wrapResult(data))
}
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.doublefeel_flutter.HealthKitHostApi.performHealthUpload$separatedMessageChannelSuffix", codec)
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.doublefeel_flutter.HealthKitHostApi.requestHealthClientAuthorization$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.performHealthUpload())
} catch (exception: Throwable) {
HealthKitApiPigeonUtils.wrapError(exception)
api.requestHealthClientAuthorization{ result: Result<Boolean> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(HealthKitApiPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(HealthKitApiPigeonUtils.wrapResult(data))
}
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
... ...
... ... @@ -79,6 +79,20 @@ class FlutterError (
val details: Any? = null
) : Throwable()
enum class AppleProductPaymentErrorMsg(val raw: Int) {
MISSING_UUID(0),
PRODUCT_NOT_FOUND(1),
USER_CANCELLED(2),
FAILED_VERIFICATION(3),
UNKNOWN(4);
companion object {
fun ofRaw(raw: Int): AppleProductPaymentErrorMsg? {
return values().firstOrNull { it.raw == raw }
}
}
}
/** Generated class from Pigeon that represents data sent in messages. */
data class AppleSignInModel (
val userId: String,
... ... @@ -115,21 +129,139 @@ data class AppleSignInModel (
override fun hashCode(): Int = toList().hashCode()
}
/** Generated class from Pigeon that represents data sent in messages. */
data class AppleProductInfo (
val productId: String,
val originPriceDescription: String,
val priceDescription: String? = null,
val originPrice: Double,
val price: Double? = null,
val isTrialPeriod: Boolean
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): AppleProductInfo {
val productId = pigeonVar_list[0] as String
val originPriceDescription = pigeonVar_list[1] as String
val priceDescription = pigeonVar_list[2] as String?
val originPrice = pigeonVar_list[3] as Double
val price = pigeonVar_list[4] as Double?
val isTrialPeriod = pigeonVar_list[5] as Boolean
return AppleProductInfo(productId, originPriceDescription, priceDescription, originPrice, price, isTrialPeriod)
}
}
fun toList(): List<Any?> {
return listOf(
productId,
originPriceDescription,
priceDescription,
originPrice,
price,
isTrialPeriod,
)
}
override fun equals(other: Any?): Boolean {
if (other !is AppleProductInfo) {
return false
}
if (this === other) {
return true
}
return PlatformApiPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
/** Generated class from Pigeon that represents data sent in messages. */
data class AppleProductPaymentResult (
val productId: String,
val appAccountToken: String? = null,
val originalTransactionId: String? = null,
val transactionId: String? = null,
val success: Boolean? = null,
/**
* 错误描述:
* 和AppleProductPaymentErrorMsg匹配的flutter处理,
* 不匹配的则是StoreKit 直接给的错误描述,直接展示即可
*/
val errorMessage: String? = null
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): AppleProductPaymentResult {
val productId = pigeonVar_list[0] as String
val appAccountToken = pigeonVar_list[1] as String?
val originalTransactionId = pigeonVar_list[2] as String?
val transactionId = pigeonVar_list[3] as String?
val success = pigeonVar_list[4] as Boolean?
val errorMessage = pigeonVar_list[5] as String?
return AppleProductPaymentResult(productId, appAccountToken, originalTransactionId, transactionId, success, errorMessage)
}
}
fun toList(): List<Any?> {
return listOf(
productId,
appAccountToken,
originalTransactionId,
transactionId,
success,
errorMessage,
)
}
override fun equals(other: Any?): Boolean {
if (other !is AppleProductPaymentResult) {
return false
}
if (this === other) {
return true
}
return PlatformApiPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
private open class PlatformApiPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) {
129.toByte() -> {
return (readValue(buffer) as Long?)?.let {
AppleProductPaymentErrorMsg.ofRaw(it.toInt())
}
}
130.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
AppleSignInModel.fromList(it)
}
}
131.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
AppleProductInfo.fromList(it)
}
}
132.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
AppleProductPaymentResult.fromList(it)
}
}
else -> super.readValueOfType(type, buffer)
}
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
when (value) {
is AppleSignInModel -> {
is AppleProductPaymentErrorMsg -> {
stream.write(129)
writeValue(stream, value.raw)
}
is AppleSignInModel -> {
stream.write(130)
writeValue(stream, value.toList())
}
is AppleProductInfo -> {
stream.write(131)
writeValue(stream, value.toList())
}
is AppleProductPaymentResult -> {
stream.write(132)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value)
... ... @@ -145,7 +277,12 @@ interface PlatformHostApi {
* `{systemWebViewUA} {appName}/{versionCode}({versionName})({manufacturer}##{brand}##{model}; {OS}{osVersion}; {height}x{width})(huawei)`
*/
fun getFullUserAgent(): String
/** 请求苹果登录 */
fun requestAppleSignIn(callback: (Result<AppleSignInModel?>) -> Unit)
/** 查询指定id的苹果商品 */
fun requestAppleProductInfo(productId: String, callback: (Result<AppleProductInfo?>) -> Unit)
/** 从服务器下单后请求苹果支付 */
fun performApplePayment(productId: String, uuid: String, callback: (Result<AppleProductPaymentResult?>) -> Unit)
companion object {
/** The codec used by PlatformHostApi. */
... ... @@ -189,6 +326,47 @@ interface PlatformHostApi {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.doublefeel_flutter.PlatformHostApi.requestAppleProductInfo$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val productIdArg = args[0] as String
api.requestAppleProductInfo(productIdArg) { result: Result<AppleProductInfo?> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(PlatformApiPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(PlatformApiPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.doublefeel_flutter.PlatformHostApi.performApplePayment$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val productIdArg = args[0] as String
val uuidArg = args[1] as String
api.performApplePayment(productIdArg, uuidArg) { result: Result<AppleProductPaymentResult?> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(PlatformApiPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(PlatformApiPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
... ...
... ... @@ -745,6 +745,7 @@
"$(PODS_CONFIGURATION_BUILD_DIR)",
"$(FLUTTER_ROOT)/bin/cache/artifacts/engine/ios/Flutter.xcframework/ios-arm64_x86_64-simulator",
);
GCC_OPTIMIZATION_LEVEL = s;
GENERATE_INFOPLIST_FILE = YES;
"HEADER_SEARCH_PATHS[sdk=iphoneos*]" = (
"$(inherited)",
... ...
... ... @@ -32,7 +32,7 @@
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
buildConfiguration = "Release"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
... ...
... ... @@ -12,16 +12,26 @@ import UserNotifications
import AppTrackingTransparency
import AdSupport
typealias FlutterBridgeMethod = ((_ params: Any?, _ result: FlutterResult) -> Void)
@Observable
class AppDelegate: NSObject, UIApplicationDelegate {
enum FlutterBridgeMethodName: String{
case verifyPayment
}
private var methods: [String: FlutterBridgeMethod] = [:]
let flutterEngine: FlutterEngine = FlutterEngine(name: "main_flutter_engine")
var channel: FlutterMethodChannel?
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
UserAgent.share.genUA()
ApplePayment.shared.delegate = self
UIApplication.shared.registerForRemoteNotifications()
UNUserNotificationCenter.current().setBadgeCount(0)
... ... @@ -31,9 +41,11 @@ class AppDelegate: NSObject, UIApplicationDelegate {
flutterEngine.run()
GeneratedPluginRegistrant.register(with: flutterEngine)
NativePigeonRegistrar.register(binaryMessenger: flutterEngine.binaryMessenger)
channel = FlutterMethodChannel(name: "doublefeel_flutter_main_channel", binaryMessenger: flutterEngine.binaryMessenger)
methods = defaultMethods()
WatchConnectivityService.shared.activate()
HealthKitService.shared.startBackgroundObserversIfNeeded()
//
return true
}
... ... @@ -51,6 +63,21 @@ class AppDelegate: NSObject, UIApplicationDelegate {
}
}
extension AppDelegate{
private func defaultMethods() -> [String: FlutterBridgeMethod]{
var methods: [String: FlutterBridgeMethod] = [:]
methods[FlutterBridgeMethodName.verifyPayment.rawValue] = { params, result in
result(params)
}
return methods
}
func invoke(method: FlutterBridgeMethodName, arguments: Any?, result: @escaping FlutterResult){
channel?.invokeMethod(method.rawValue, arguments: arguments, result: result)
}
}
extension AppDelegate: UNUserNotificationCenterDelegate{
func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
... ...
... ... @@ -118,39 +118,39 @@ struct AppleHealthTestView: View {
private func runPermissionCheck() async {
do {
let isAuthorized = try await checkHealthAuthorization()
await appendLog("checkHealthAppAuthorization = \(isAuthorized)")
let authorization = try await checkHealthAuthorization()
appendLog("checkHealthAppAuthorization status=\(authorization.status), hasData=\(authorization.hasData)")
let authUrl = try await getHealthServerAuthUrl()
await appendLog("getHealthServerAuthUrl = \(authUrl.isEmpty ? "<empty, iOS system managed>" : authUrl)")
appendLog("getHealthServerAuthUrl = \(authUrl.isEmpty ? "<empty, iOS system managed>" : authUrl)")
if !isAuthorized {
await appendLog("当前未授权,开始 requestHealthClientAuthorization")
let granted = try api.requestHealthClientAuthorization()
await appendLog("requestHealthClientAuthorization = \(granted)")
if authorization.status == 0 {
appendLog("当前需要请求授权,开始 requestHealthClientAuthorization")
let granted = try await requestHealthClientAuthorization()
appendLog("requestHealthClientAuthorization = \(granted)")
} else {
await appendLog("当前已授权,跳过 requestHealthClientAuthorization")
appendLog("当前不需要再次请求授权,跳过 requestHealthClientAuthorization")
}
let cancelResult = try api.cancelHealthAppAuthorization()
await appendLog("cancelHealthAppAuthorization = \(cancelResult) (iOS 不支持应用内撤销)")
appendLog("cancelHealthAppAuthorization = \(cancelResult) (iOS 不支持应用内撤销)")
} catch {
await appendError("权限检查失败", error)
appendError("权限检查失败", error)
}
}
private func runDataFetch() async {
let endTime = Int64(Date().timeIntervalSince1970)
let startTime = Int64(Calendar.current.date(byAdding: .day, value: -7, to: Date())?.timeIntervalSince1970 ?? Date().timeIntervalSince1970)
await appendLog("数据范围: \(formatTimestamp(startTime)) -> \(formatTimestamp(endTime))")
appendLog("数据范围: \(formatTimestamp(startTime)) -> \(formatTimestamp(endTime))")
do {
let uploadResult = try api.performHealthUpload()
await appendLog(
appendLog(
"performHealthUpload common=\(uploadResult.commonUploadSuccess), sleep=\(uploadResult.sleepUploadSuccess), error=\(uploadResult.errorMessage ?? "nil")"
)
} catch {
await appendError("performHealthUpload 失败", error)
appendError("performHealthUpload 失败", error)
}
await fetchCommon("HRV", startTime, endTime, api.fetchHrvData)
... ... @@ -182,10 +182,10 @@ struct AppleHealthTestView: View {
continuation.resume(with: result)
}
}
await appendLog("\(title): \(points.count) 条")
await appendSample(points)
appendLog("\(title): \(points.count) 条")
appendSample(points)
} catch {
await appendError("\(title) 获取失败", error)
appendError("\(title) 获取失败", error)
}
}
... ... @@ -196,12 +196,12 @@ struct AppleHealthTestView: View {
continuation.resume(with: result)
}
}
await appendLog("睡眠: \(points.count) 条")
appendLog("睡眠: \(points.count) 条")
for point in points.prefix(3) {
await appendLog(" sample dataType=\(point.dataType), from=\(formatTimestamp(point.fromTime)), to=\(formatTimestamp(point.toTime))")
appendLog(" sample dataType=\(point.dataType), from=\(formatTimestamp(point.fromTime)), to=\(formatTimestamp(point.toTime))")
}
} catch {
await appendError("睡眠获取失败", error)
appendError("睡眠获取失败", error)
}
}
... ... @@ -213,16 +213,16 @@ struct AppleHealthTestView: View {
}
}
if let target {
await appendLog("活动目标: move=\(target.move.map(String.init) ?? "nil"), stand=\(target.stand.map(String.init) ?? "nil")")
appendLog("活动目标: move=\(target.move.map(String.init) ?? "nil"), stand=\(target.stand.map(String.init) ?? "nil")")
} else {
await appendLog("活动目标: nil")
appendLog("活动目标: nil")
}
} catch {
await appendError("活动目标获取失败", error)
appendError("活动目标获取失败", error)
}
}
private func checkHealthAuthorization() async throws -> Bool {
private func checkHealthAuthorization() async throws -> HealthAuthorization {
try await withCheckedThrowingContinuation { continuation in
api.checkHealthAppAuthorization { result in
continuation.resume(with: result)
... ... @@ -230,6 +230,14 @@ struct AppleHealthTestView: View {
}
}
private func requestHealthClientAuthorization() async throws -> Bool {
try await withCheckedThrowingContinuation { continuation in
api.requestHealthClientAuthorization { result in
continuation.resume(with: result)
}
}
}
private func getHealthServerAuthUrl() async throws -> String {
try await withCheckedThrowingContinuation { continuation in
api.getHealthServerAuthUrl { result in
... ...
//
// ApplePayment.swift
// hippo
//
// Created by shihao on 2025/6/27.
//
import Foundation
import StoreKit
enum StoreKitError: Error, LocalizedError {
case productNotFound
case failedVerification
case unknown
var errorDescription: String? {
switch self {
case .productNotFound:
return String(describing: AppleProductPaymentErrorMsg.productNotFound)
case .failedVerification:
return String(describing: AppleProductPaymentErrorMsg.failedVerification)
case .unknown:
return String(describing: AppleProductPaymentErrorMsg.unknown)
}
}
}
class ApplePayment {
static let shared = ApplePayment()
var delegate: AppDelegate?
func purchase(_ productId: String, uuidString: String) async -> AppleProductPaymentResult {
do {
guard let uuid = UUID(uuidString: uuidString) else {
return paymentResult(
productId: productId,
success: false,
errorMessage: String(describing: AppleProductPaymentErrorMsg.missingUUID)
)
}
let appleProduct = try await requestProducts(productId)
let result = try await appleProduct.purchase(options: [
Product.PurchaseOption.appAccountToken(uuid)
])
switch result {
case .success(let verification):
let transaction = try checkVerified(verification)
let success = await verifyWithServer(appAccountToken: transaction.appAccountToken?.uuidString ?? "", originalTransactionId: String(transaction.originalID), transactionId: String(transaction.id), productId: transaction.productID)
// 更新客户产品状态
await updateCustomerProductStatus()
// 完成交易
if success{
await transaction.finish()
return paymentResult(
productId: productId,
appAccountToken: transaction.appAccountToken?.uuidString,
originalTransactionId: String(transaction.originalID),
transactionId: String(transaction.id),
success: true
)
}else{
return paymentResult(
productId: productId,
appAccountToken: transaction.appAccountToken?.uuidString,
originalTransactionId: String(transaction.originalID),
transactionId: String(transaction.id),
success: false,
errorMessage: String(describing: AppleProductPaymentErrorMsg.unknown)
)
}
case .userCancelled:
return paymentResult(
productId: productId,
success: false,
errorMessage: String(describing: AppleProductPaymentErrorMsg.userCancelled)
)
case .pending:
return paymentResult(
productId: productId,
success: false,
errorMessage: String(describing: AppleProductPaymentErrorMsg.unknown)
)
@unknown default:
return paymentResult(
productId: productId,
success: false,
errorMessage: String(describing: AppleProductPaymentErrorMsg.unknown)
)
}
} catch {
return paymentResult(
productId: productId,
success: false,
errorMessage: paymentErrorMessage(error)
)
}
}
//获取苹果商品
func requestProducts(_ productAppleId: String) async throws
-> Product
{
let storeProducts = try await Product.products(
for: Set([productAppleId])
)
if let product = storeProducts.first {
return product
} else {
throw StoreKitError.productNotFound
}
}
func isFreeTrail(product: Product) async -> Bool{
let isActive = await hasActiveEntitlement(productID: product.id)
let eligible = await product.subscription?.isEligibleForIntroOffer ?? false
return !isActive && eligible
}
func hasActiveEntitlement(productID: String) async -> Bool {
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result else {
continue
}
if transaction.productID == productID,
transaction.revocationDate == nil {
return true
}
}
return false
}
private func updateCustomerProductStatus() async {
var activeSubscriptions: [String] = []
for await result in Transaction.currentEntitlements {
do {
let transaction = try checkVerified(result)
switch transaction.productType {
case .autoRenewable:
if let expirationDate = transaction.expirationDate,
expirationDate > Date()
{
activeSubscriptions.append(transaction.productID)
}
default:
break
}
} catch {
print("验证交易失败: \(error)")
}
}
}
// MARK: - 验证交易
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified:
throw StoreKitError.failedVerification
case .verified(let safe):
return safe
}
}
private func verifyWithServer(appAccountToken: String, originalTransactionId: String?, transactionId: String?, productId: String) async -> Bool{
guard let delegate else{
return false
}
var params: [String: String] = [
"productId": productId,
"appAccountToken": appAccountToken
]
params["originalTransactionId"] = originalTransactionId
params["transactionId"] = transactionId
return await withCheckedContinuation { continuous in
delegate.invoke(method: .verifyPayment, arguments: params) { result in
print("invoke verifyPayment result: \(String(describing: result))")
continuous.resume(returning: true)
}
}
}
private func paymentResult(
productId: String,
appAccountToken: String? = nil,
originalTransactionId: String? = nil,
transactionId: String? = nil,
success: Bool?,
errorMessage: String? = nil
) -> AppleProductPaymentResult {
AppleProductPaymentResult(
productId: productId,
appAccountToken: appAccountToken,
originalTransactionId: originalTransactionId,
transactionId: transactionId,
success: success,
errorMessage: errorMessage
)
}
private func paymentErrorMessage(_ error: Error) -> String {
if let storeKitError = error as? StoreKitError {
return storeKitError.localizedDescription
}
return error.localizedDescription
}
}
extension ApplePayment {
// MARK: - 监听交易更新
func listenForTransactions() -> Task<Void, Error> {
return Task.detached {
for await result in Transaction.updates {
do {
let transaction = try await self.checkVerified(result)
let _ = await self.verifyWithServer(appAccountToken: transaction.appAccountToken?.uuidString ?? "", originalTransactionId: String(transaction.originalID), transactionId: String(transaction.id), productId: transaction.productID)
// 更新客户产品状态
await self.updateCustomerProductStatus()
await transaction.finish()
print("交易更新处理完成: \(transaction.debugDescription)")
} catch {
print("交易更新处理失败: \(error)")
}
}
}
}
}
... ...
//
// DebugLogger.swift
// Runner
//
// Created by 权海 on 2026/6/16.
//
import Foundation
//import os
class DebugLogger{
// let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "App", category: "HealthKit")
static func log(desc: String){
print(desc)
}
}
... ...
... ... @@ -36,16 +36,67 @@ final class HealthKitService {
}
}
func shouldRequestAuthorization() async -> Bool {
guard isHealthDataAvailable else { return false }
func authorizationRequestStatus() async -> HKAuthorizationRequestStatus? {
do {
let status = try await healthStore.statusForAuthorizationRequest(
return try await healthStore.statusForAuthorizationRequest(
toShare: NativeHealthTypeCatalog.writeTypes,
read: NativeHealthTypeCatalog.readTypes
)
return status == .shouldRequest
} catch {
return true
return nil
}
}
func shouldRequestAuthorization() async -> Bool {
guard isHealthDataAvailable else { return false }
return await authorizationRequestStatus() != .unnecessary
}
func hasAnyReadableData() async -> Bool {
guard isHealthDataAvailable else { return false }
let endDate = Date()
let startDate = Calendar.current.date(byAdding: .year, value: -2, to: endDate)
?? Date(timeInterval: -2 * 365 * 24 * 60 * 60, since: endDate)
let commonFetches: [(Date, Date) async throws -> [NativeHealthDataPoint]] = [
reader.fetchHrvData,
reader.fetchHeartRateData,
reader.fetchWalkingHeartRateData,
reader.fetchRestingHeartRateData,
reader.fetchSleepingHeartRateData,
reader.fetchOxygenSaturationData,
reader.fetchActiveEnergyData,
reader.fetchExerciseData,
reader.fetchStandData,
reader.fetchStepCountData,
reader.fetchSleepingWristTemperatureData,
reader.fetchRespiratoryRateData,
reader.fetchIrregularHeartRhythmData,
]
for fetch in commonFetches {
do {
if try await !fetch(startDate, endDate).isEmpty {
return true
}
} catch {
// Keep probing other data types; HealthKit may deny or lack a single type.
}
}
do {
if try await !reader.fetchSleepData(startDate: startDate, endDate: endDate).isEmpty {
return true
}
} catch {
// Keep probing activity summaries.
}
do {
return try await reader.fetchActivityTargetData(startDate: startDate, endDate: endDate) != nil
} catch {
return false
}
}
... ...
... ... @@ -240,6 +240,36 @@ struct HealthActivityTargetData: Hashable {
}
}
/// Generated class from Pigeon that represents data sent in messages.
struct HealthAuthorization: Hashable {
/// -1: AppleHealth不可用, 0: 需要请求授权, 1: 已被授权且能获取到部分数据, 2: 已被拒绝或者没有数据
var status: Int64
var hasData: Bool
// swift-format-ignore: AlwaysUseLowerCamelCase
static func fromList(_ pigeonVar_list: [Any?]) -> HealthAuthorization? {
let status = pigeonVar_list[0] as! Int64
let hasData = pigeonVar_list[1] as! Bool
return HealthAuthorization(
status: status,
hasData: hasData
)
}
func toList() -> [Any?] {
return [
status,
hasData,
]
}
static func == (lhs: HealthAuthorization, rhs: HealthAuthorization) -> Bool {
return deepEqualsHealthKitApi(lhs.toList(), rhs.toList()) }
func hash(into hasher: inout Hasher) {
deepHashHealthKitApi(value: toList(), hasher: &hasher)
}
}
private class HealthKitApiPigeonCodecReader: FlutterStandardReader {
override func readValue(ofType type: UInt8) -> Any? {
switch type {
... ... @@ -251,6 +281,8 @@ private class HealthKitApiPigeonCodecReader: FlutterStandardReader {
return HealthSleepUploadDataPoint.fromList(self.readValue() as! [Any?])
case 132:
return HealthActivityTargetData.fromList(self.readValue() as! [Any?])
case 133:
return HealthAuthorization.fromList(self.readValue() as! [Any?])
default:
return super.readValue(ofType: type)
}
... ... @@ -271,6 +303,9 @@ private class HealthKitApiPigeonCodecWriter: FlutterStandardWriter {
} else if let value = value as? HealthActivityTargetData {
super.writeByte(132)
super.writeValue(value.toList())
} else if let value = value as? HealthAuthorization {
super.writeByte(133)
super.writeValue(value.toList())
} else {
super.writeValue(value)
}
... ... @@ -294,13 +329,13 @@ class HealthKitApiPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol HealthKitHostApi {
func checkHealthAppAuthorization(completion: @escaping (Result<Bool, Error>) -> Void)
func getHealthServerAuthUrl(completion: @escaping (Result<String, Error>) -> Void)
/// Opens Huawei Health client authorization UI. Returns whether user granted.
func requestHealthClientAuthorization() throws -> Bool
func cancelHealthAppAuthorization() throws -> Bool
/// Runs native health read and server upload pipeline.
func performHealthUpload() throws -> HealthUploadResult
/// Opens Huawei Health client authorization UI. Returns whether user granted.
func checkHealthAppAuthorization(completion: @escaping (Result<HealthAuthorization, Error>) -> Void)
func requestHealthClientAuthorization(completion: @escaping (Result<Bool, Error>) -> Void)
func fetchHrvData(startTime: Int64, endTime: Int64, completion: @escaping (Result<[HealthUploadDataPoint], Error>) -> Void)
func fetchHeartRateData(startTime: Int64, endTime: Int64, completion: @escaping (Result<[HealthUploadDataPoint], Error>) -> Void)
func fetchWalkingHeartRateData(startTime: Int64, endTime: Int64, completion: @escaping (Result<[HealthUploadDataPoint], Error>) -> Void)
... ... @@ -324,21 +359,6 @@ class HealthKitHostApiSetup {
/// Sets up an instance of `HealthKitHostApi` to handle messages through the `binaryMessenger`.
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: HealthKitHostApi?, messageChannelSuffix: String = "") {
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
let checkHealthAppAuthorizationChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.doublefeel_flutter.HealthKitHostApi.checkHealthAppAuthorization\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
checkHealthAppAuthorizationChannel.setMessageHandler { _, reply in
api.checkHealthAppAuthorization { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
checkHealthAppAuthorizationChannel.setMessageHandler(nil)
}
let getHealthServerAuthUrlChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.doublefeel_flutter.HealthKitHostApi.getHealthServerAuthUrl\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
getHealthServerAuthUrlChannel.setMessageHandler { _, reply in
... ... @@ -354,20 +374,6 @@ class HealthKitHostApiSetup {
} else {
getHealthServerAuthUrlChannel.setMessageHandler(nil)
}
/// Opens Huawei Health client authorization UI. Returns whether user granted.
let requestHealthClientAuthorizationChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.doublefeel_flutter.HealthKitHostApi.requestHealthClientAuthorization\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
requestHealthClientAuthorizationChannel.setMessageHandler { _, reply in
do {
let result = try api.requestHealthClientAuthorization()
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
requestHealthClientAuthorizationChannel.setMessageHandler(nil)
}
let cancelHealthAppAuthorizationChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.doublefeel_flutter.HealthKitHostApi.cancelHealthAppAuthorization\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
cancelHealthAppAuthorizationChannel.setMessageHandler { _, reply in
... ... @@ -395,6 +401,37 @@ class HealthKitHostApiSetup {
} else {
performHealthUploadChannel.setMessageHandler(nil)
}
/// Opens Huawei Health client authorization UI. Returns whether user granted.
let checkHealthAppAuthorizationChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.doublefeel_flutter.HealthKitHostApi.checkHealthAppAuthorization\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
checkHealthAppAuthorizationChannel.setMessageHandler { _, reply in
api.checkHealthAppAuthorization { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
checkHealthAppAuthorizationChannel.setMessageHandler(nil)
}
let requestHealthClientAuthorizationChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.doublefeel_flutter.HealthKitHostApi.requestHealthClientAuthorization\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
requestHealthClientAuthorizationChannel.setMessageHandler { _, reply in
api.requestHealthClientAuthorization { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
requestHealthClientAuthorizationChannel.setMessageHandler(nil)
}
let fetchHrvDataChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.doublefeel_flutter.HealthKitHostApi.fetchHrvData\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
fetchHrvDataChannel.setMessageHandler { message, reply in
... ...
import Foundation
import HealthKit
final class HealthKitHostApiImpl: HealthKitHostApi {
private let service: HealthKitService
... ... @@ -7,38 +8,68 @@ final class HealthKitHostApiImpl: HealthKitHostApi {
self.service = service
}
func checkHealthAppAuthorization(completion: @escaping (Result<Bool, Error>) -> Void) {
Task {
let shouldAuth = await service.shouldRequestAuthorization()
let isAuthorized = service.isHealthDataAvailable && !shouldAuth
completion(.success(isAuthorized))
}
}
func getHealthServerAuthUrl(completion: @escaping (Result<String, Error>) -> Void) {
// Apple Health authorization is system-managed, not URL based.
completion(.success(""))
}
func requestHealthClientAuthorization() throws -> Bool {
let result = runAuthorizationRequest()
if result.success {
service.startBackgroundObserversIfNeeded()
Task {
await service.refreshSharedWatchValues()
}
}
if let error = result.error {
throw error
}
return result.success
}
func cancelHealthAppAuthorization() throws -> Bool {
// iOS does not let apps revoke HealthKit permission programmatically.
// Users must revoke access in Settings > Health > Data Access & Devices.
false
}
func checkHealthAppAuthorization(completion: @escaping (Result<HealthAuthorization, any Error>) -> Void) {
Task {
guard service.isHealthDataAvailable else {
completion(.success(HealthAuthorization(status: -1, hasData: false)))
return
}
let requestStatus = await service.authorizationRequestStatus()
let status: Int64
var hasData = false
if requestStatus == .shouldRequest {
status = 0
} else {
hasData = await service.hasAnyReadableData()
status = hasData ? 1 : 2
}
DebugLogger.log(desc: "checkHealthAppAuthorization result: requestStatus=\(status) hasData=\(hasData)")
completion(.success(HealthAuthorization(status: status, hasData: hasData)))
}
}
func requestHealthClientAuthorization(completion: @escaping (Result<Bool, any Error>) -> Void) {
DebugLogger.log(desc:"requestHealthClientAuthorization")
service.requestAuthorization { [service] success, error in
if let error {
DebugLogger.log(desc:"requestHealthClientAuthorization error: \(error)")
completion(.failure(error))
return
}
Task {
let requestStatus = await service.authorizationRequestStatus()
let status: Int64
if requestStatus == .shouldRequest {
status = 0
} else {
let hasData = await service.hasAnyReadableData()
status = hasData ? 1 : 2
}
let granted = status == 1
DebugLogger.log(desc:"requestHealthClientAuthorization granted: \(granted)")
if granted {
service.startBackgroundObserversIfNeeded()
await service.refreshSharedWatchValues()
}
completion(.success(granted))
}
}
}
func performHealthUpload() throws -> HealthUploadResult {
let summary = runBlocking {
... ...
... ... @@ -112,6 +112,14 @@ func deepHashPlatformApi(value: Any?, hasher: inout Hasher) {
enum AppleProductPaymentErrorMsg: Int {
case missingUUID = 0
case productNotFound = 1
case userCancelled = 2
case failedVerification = 3
case unknown = 4
}
/// Generated class from Pigeon that represents data sent in messages.
struct AppleSignInModel: Hashable {
var userId: String
... ... @@ -149,11 +157,114 @@ struct AppleSignInModel: Hashable {
}
}
/// Generated class from Pigeon that represents data sent in messages.
struct AppleProductInfo: Hashable {
var productId: String
var originPriceDescription: String
var priceDescription: String? = nil
var originPrice: Double
var price: Double? = nil
var isTrialPeriod: Bool
// swift-format-ignore: AlwaysUseLowerCamelCase
static func fromList(_ pigeonVar_list: [Any?]) -> AppleProductInfo? {
let productId = pigeonVar_list[0] as! String
let originPriceDescription = pigeonVar_list[1] as! String
let priceDescription: String? = nilOrValue(pigeonVar_list[2])
let originPrice = pigeonVar_list[3] as! Double
let price: Double? = nilOrValue(pigeonVar_list[4])
let isTrialPeriod = pigeonVar_list[5] as! Bool
return AppleProductInfo(
productId: productId,
originPriceDescription: originPriceDescription,
priceDescription: priceDescription,
originPrice: originPrice,
price: price,
isTrialPeriod: isTrialPeriod
)
}
func toList() -> [Any?] {
return [
productId,
originPriceDescription,
priceDescription,
originPrice,
price,
isTrialPeriod,
]
}
static func == (lhs: AppleProductInfo, rhs: AppleProductInfo) -> Bool {
return deepEqualsPlatformApi(lhs.toList(), rhs.toList()) }
func hash(into hasher: inout Hasher) {
deepHashPlatformApi(value: toList(), hasher: &hasher)
}
}
/// Generated class from Pigeon that represents data sent in messages.
struct AppleProductPaymentResult: Hashable {
var productId: String
var appAccountToken: String? = nil
var originalTransactionId: String? = nil
var transactionId: String? = nil
var success: Bool? = nil
/// 错误描述:
/// 和AppleProductPaymentErrorMsg匹配的flutter处理,
/// 不匹配的则是StoreKit 直接给的错误描述,直接展示即可
var errorMessage: String? = nil
// swift-format-ignore: AlwaysUseLowerCamelCase
static func fromList(_ pigeonVar_list: [Any?]) -> AppleProductPaymentResult? {
let productId = pigeonVar_list[0] as! String
let appAccountToken: String? = nilOrValue(pigeonVar_list[1])
let originalTransactionId: String? = nilOrValue(pigeonVar_list[2])
let transactionId: String? = nilOrValue(pigeonVar_list[3])
let success: Bool? = nilOrValue(pigeonVar_list[4])
let errorMessage: String? = nilOrValue(pigeonVar_list[5])
return AppleProductPaymentResult(
productId: productId,
appAccountToken: appAccountToken,
originalTransactionId: originalTransactionId,
transactionId: transactionId,
success: success,
errorMessage: errorMessage
)
}
func toList() -> [Any?] {
return [
productId,
appAccountToken,
originalTransactionId,
transactionId,
success,
errorMessage,
]
}
static func == (lhs: AppleProductPaymentResult, rhs: AppleProductPaymentResult) -> Bool {
return deepEqualsPlatformApi(lhs.toList(), rhs.toList()) }
func hash(into hasher: inout Hasher) {
deepHashPlatformApi(value: toList(), hasher: &hasher)
}
}
private class PlatformApiPigeonCodecReader: FlutterStandardReader {
override func readValue(ofType type: UInt8) -> Any? {
switch type {
case 129:
let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?)
if let enumResultAsInt = enumResultAsInt {
return AppleProductPaymentErrorMsg(rawValue: enumResultAsInt)
}
return nil
case 130:
return AppleSignInModel.fromList(self.readValue() as! [Any?])
case 131:
return AppleProductInfo.fromList(self.readValue() as! [Any?])
case 132:
return AppleProductPaymentResult.fromList(self.readValue() as! [Any?])
default:
return super.readValue(ofType: type)
}
... ... @@ -162,8 +273,17 @@ private class PlatformApiPigeonCodecReader: FlutterStandardReader {
private class PlatformApiPigeonCodecWriter: FlutterStandardWriter {
override func writeValue(_ value: Any) {
if let value = value as? AppleSignInModel {
if let value = value as? AppleProductPaymentErrorMsg {
super.writeByte(129)
super.writeValue(value.rawValue)
} else if let value = value as? AppleSignInModel {
super.writeByte(130)
super.writeValue(value.toList())
} else if let value = value as? AppleProductInfo {
super.writeByte(131)
super.writeValue(value.toList())
} else if let value = value as? AppleProductPaymentResult {
super.writeByte(132)
super.writeValue(value.toList())
} else {
super.writeValue(value)
... ... @@ -191,7 +311,12 @@ protocol PlatformHostApi {
/// 返回完整的 User-Agent 字符串,由 native 侧组装:
/// `{systemWebViewUA} {appName}/{versionCode}({versionName})({manufacturer}##{brand}##{model}; {OS}{osVersion}; {height}x{width})(huawei)`
func getFullUserAgent() throws -> String
/// 请求苹果登录
func requestAppleSignIn(completion: @escaping (Result<AppleSignInModel?, Error>) -> Void)
/// 查询指定id的苹果商品
func requestAppleProductInfo(productId: String, completion: @escaping (Result<AppleProductInfo?, Error>) -> Void)
/// 从服务器下单后请求苹果支付
func performApplePayment(productId: String, uuid: String, completion: @escaping (Result<AppleProductPaymentResult?, Error>) -> Void)
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
... ... @@ -215,6 +340,7 @@ class PlatformHostApiSetup {
} else {
getFullUserAgentChannel.setMessageHandler(nil)
}
/// 请求苹果登录
let requestAppleSignInChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.doublefeel_flutter.PlatformHostApi.requestAppleSignIn\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
requestAppleSignInChannel.setMessageHandler { _, reply in
... ... @@ -230,5 +356,42 @@ class PlatformHostApiSetup {
} else {
requestAppleSignInChannel.setMessageHandler(nil)
}
/// 查询指定id的苹果商品
let requestAppleProductInfoChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.doublefeel_flutter.PlatformHostApi.requestAppleProductInfo\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
requestAppleProductInfoChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let productIdArg = args[0] as! String
api.requestAppleProductInfo(productId: productIdArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
requestAppleProductInfoChannel.setMessageHandler(nil)
}
/// 从服务器下单后请求苹果支付
let performApplePaymentChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.doublefeel_flutter.PlatformHostApi.performApplePayment\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
performApplePaymentChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let productIdArg = args[0] as! String
let uuidArg = args[1] as! String
api.performApplePayment(productId: productIdArg, uuid: uuidArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
performApplePaymentChannel.setMessageHandler(nil)
}
}
}
... ...
import Foundation
import AuthenticationServices
import StoreKit
import UIKit
import WebKit
... ... @@ -11,11 +12,56 @@ import WebKit
* `{systemWebViewUA} doublefeel/{versionCode}({versionName})(Apple##Apple##{model}; iOS{osVersion}; {height}x{width})(huawei)`
*/
final class PlatformHostApiImpl: PlatformHostApi {
func requestAppleProductInfo(productId: String, completion: @escaping (Result<AppleProductInfo?, any Error>) -> Void) {
Task {
do {
let product = try await ApplePayment.shared.requestProducts(productId)
let isTrialPeriod = await ApplePayment.shared.isFreeTrail(product: product)
let originPrice = product.price
var displayPrice = product.displayPrice
var price = originPrice
if let introductoryOffer = product.subscription?.introductoryOffer, introductoryOffer.price > 0{
price = introductoryOffer.price
displayPrice = introductoryOffer.displayPrice
}else if let offer = product.subscription?.promotionalOffers.first(where: { $0.id == product.id }){
price = offer.price
displayPrice = offer.displayPrice
}
let productInfo = AppleProductInfo(
productId: product.id,
originPriceDescription: product.displayPrice,
priceDescription: displayPrice,
originPrice: NSDecimalNumber(decimal: originPrice).doubleValue,
price: NSDecimalNumber(decimal: price).doubleValue,
isTrialPeriod: isTrialPeriod
)
print("[PlatformHostApiImpl.requestAppleProductInfo] return: \(productInfo)")
completion(.success(productInfo))
} catch StoreKitError.productNotFound {
print("[PlatformHostApiImpl.requestAppleProductInfo] return: nil")
completion(.success(nil))
} catch {
print("[PlatformHostApiImpl.requestAppleProductInfo] error: \(error.localizedDescription)")
completion(.failure(error))
}
}
}
func performApplePayment(productId: String, uuid: String, completion: @escaping (Result<AppleProductPaymentResult?, any Error>) -> Void) {
Task {
let result = await ApplePayment.shared.purchase(productId, uuidString: uuid)
print("[PlatformHostApiImpl.performApplePayment] return: \(result)")
completion(.success(result))
}
}
private var appleSignInCoordinator: AppleSignInCoordinator?
func requestAppleSignIn(completion: @escaping (Result<AppleSignInModel?, any Error>) -> Void) {
DispatchQueue.main.async { [weak self] in
guard let self else {
print("[PlatformHostApiImpl.requestAppleSignIn] error: \(PlatformHostApiError.deallocated.localizedDescription)")
completion(.failure(PlatformHostApiError.deallocated))
return
}
... ... @@ -25,12 +71,19 @@ final class PlatformHostApiImpl: PlatformHostApi {
request.requestedScopes = [.fullName, .email]
guard let presentationAnchor = AppleSignInCoordinator.currentPresentationAnchor() else {
print("[PlatformHostApiImpl.requestAppleSignIn] error: \(PlatformHostApiError.missingPresentationAnchor.localizedDescription)")
completion(.failure(PlatformHostApiError.missingPresentationAnchor))
return
}
let coordinator = AppleSignInCoordinator(presentationAnchor: presentationAnchor) { [weak self] result in
self?.appleSignInCoordinator = nil
switch result {
case .success(let model):
print("[PlatformHostApiImpl.requestAppleSignIn] return: \(String(describing: model))")
case .failure(let error):
print("[PlatformHostApiImpl.requestAppleSignIn] error: \(error.localizedDescription)")
}
completion(result)
}
appleSignInCoordinator = coordinator
... ... @@ -43,7 +96,8 @@ final class PlatformHostApiImpl: PlatformHostApi {
}
func getFullUserAgent() throws -> String {
return UserAgent.share.finalUA
let userAgent = UserAgent.share.finalUA
print("[PlatformHostApiImpl.getFullUserAgent] return: \(userAgent)")
return userAgent
}
}
... ...
... ... @@ -15,6 +15,9 @@ struct RunnerApp: App {
WindowGroup {
FlutterRootView(engine:appDelegate.flutterEngine )
.ignoresSafeArea()
.onAppear {
_ = ApplePayment.shared.listenForTransactions()
}
}
}
}
... ...
import 'dart:convert';
import 'package:doublefeel_flutter/data/local/user_account_storage.dart';
import 'models/health_upload_data_type.dart';
class AppleHealthUploadLocalRecorder {
AppleHealthUploadLocalRecorder(this._storage);
final UserAccountStorage _storage;
AppleHealthUploadLocalRecordList load(int userId) {
final raw = _storage.appleHealthUploadLocalRecordJson(userId);
if (raw == null || raw.isEmpty) {
return const AppleHealthUploadLocalRecordList(records: []);
}
try {
return AppleHealthUploadLocalRecordList.fromJson(
jsonDecode(raw) as Map<String, dynamic>,
);
} catch (_) {
return const AppleHealthUploadLocalRecordList(records: []);
}
}
Future<void> saveLatestDataTime({
required int userId,
required HealthUploadDataType dataType,
required int latestDataTime,
}) async {
final records = [...load(userId).records];
records.removeWhere((item) => item.dataType == dataType);
records.add(
AppleHealthUploadLocalRecord(
dataType: dataType,
latestDataTime: latestDataTime,
),
);
await _save(userId, AppleHealthUploadLocalRecordList(records: records));
}
Future<void> saveLatestDataTimes({
required int userId,
required Map<HealthUploadDataType, int> latestDataTimes,
}) async {
if (latestDataTimes.isEmpty) return;
final records = [...load(userId).records];
for (final entry in latestDataTimes.entries) {
records.removeWhere((item) => item.dataType == entry.key);
records.add(
AppleHealthUploadLocalRecord(
dataType: entry.key,
latestDataTime: entry.value,
),
);
}
await _save(userId, AppleHealthUploadLocalRecordList(records: records));
}
Future<void> clear(int userId) {
return _storage.clearAppleHealthUploadLocalRecord(userId);
}
Future<void> _save(
int userId,
AppleHealthUploadLocalRecordList records,
) {
return _storage.saveAppleHealthUploadLocalRecordJson(
userId,
jsonEncode(records.toJson()),
);
}
}
class AppleHealthUploadLocalRecordList {
const AppleHealthUploadLocalRecordList({required this.records});
final List<AppleHealthUploadLocalRecord> records;
factory AppleHealthUploadLocalRecordList.fromJson(
Map<String, dynamic> json,
) {
return AppleHealthUploadLocalRecordList(
records: (json['latest_data_time_list'] as List<dynamic>? ?? [])
.whereType<Map<String, dynamic>>()
.map(AppleHealthUploadLocalRecord.fromJson)
.where((item) => item.dataType != HealthUploadDataType.unknown)
.toList(),
);
}
Map<String, dynamic> toJson() {
return {
'latest_data_time_list': records.map((item) => item.toJson()).toList(),
};
}
int? latestDataTime(HealthUploadDataType dataType) {
for (final record in records) {
if (record.dataType == dataType) return record.latestDataTime;
}
return null;
}
}
class AppleHealthUploadLocalRecord {
const AppleHealthUploadLocalRecord({
required this.dataType,
required this.latestDataTime,
});
final HealthUploadDataType dataType;
final int latestDataTime;
factory AppleHealthUploadLocalRecord.fromJson(Map<String, dynamic> json) {
final rawDataType = json['data_type'];
return AppleHealthUploadLocalRecord(
dataType: rawDataType is int
? HealthUploadDataType.fromValue(rawDataType)
: HealthUploadDataType.unknown,
latestDataTime: _intFromJson(json['latest_data_time']),
);
}
Map<String, dynamic> toJson() {
return {
'data_type': dataType.value,
'latest_data_time': latestDataTime,
};
}
static int _intFromJson(Object? value) {
if (value is int) return value;
if (value is double) return value.round();
if (value is String) return int.tryParse(value) ?? 0;
return 0;
}
}
... ...
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;
... ...
import 'package:get/get.dart';
import '../apple_health_upload/apple_health_upload_api.dart';
import '../apple_health_upload/apple_health_upload_local_recorder.dart';
import '../apple_health_upload/apple_health_upload_tool.dart';
import '../../core/config/app_environment_config.dart';
import '../../core/error/app_error_handler.dart';
... ... @@ -75,7 +76,15 @@ void registerHealthDeps(DioClient dioClient) {
Get.lazyPut(() => HealthApi(dioClient), fenix: true);
Get.lazyPut(() => AppleHealthUploadApi(dioClient), fenix: true);
Get.lazyPut(
() => AppleHealthUploadTool(Get.find<AppleHealthUploadApi>()),
() => AppleHealthUploadLocalRecorder(Get.find<UserAccountStorage>()),
fenix: true,
);
Get.lazyPut(
() => AppleHealthUploadTool(
Get.find<AppleHealthUploadApi>(),
Get.find<AppleHealthUploadLocalRecorder>(),
Get.find<UserPreferencesStorage>(),
),
fenix: true,
);
Get.lazyPut(
... ...
import 'package:doublefeel_flutter/app/apple_health_upload/apple_health_upload_tool.dart';
import 'package:doublefeel_flutter/data/local/user_account_storage.dart';
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
import 'package:get/get.dart';
import '../controllers/apple_health_upload_test_controller.dart';
... ... @@ -9,6 +11,8 @@ class AppleHealthUploadTestBinding extends Bindings {
Get.lazyPut<AppleHealthUploadTestController>(
() => AppleHealthUploadTestController(
Get.find<AppleHealthUploadTool>(),
Get.find<UserAccountStorage>(),
Get.find<UserPreferencesStorage>(),
),
);
}
... ...
... ... @@ -6,14 +6,22 @@ import 'package:doublefeel_flutter/app/apple_health_upload/models/apple_health_u
import 'package:doublefeel_flutter/app/apple_health_upload/models/upload_activity_target.dart';
import 'package:doublefeel_flutter/app/apple_health_upload/models/upload_sleep.dart';
import 'package:doublefeel_flutter/core/util/app_toast.dart';
import 'package:doublefeel_flutter/data/local/user_account_storage.dart';
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
import 'package:doublefeel_flutter/pigeon/health_kit_api.g.dart';
import 'package:flutter/foundation.dart';
import 'package:get/get.dart';
class AppleHealthUploadTestController extends GetxController {
AppleHealthUploadTestController(this._uploadTool);
AppleHealthUploadTestController(
this._uploadTool,
this._userAccountStorage,
this._userPreferencesStorage,
);
final AppleHealthUploadTool _uploadTool;
final UserAccountStorage _userAccountStorage;
final UserPreferencesStorage _userPreferencesStorage;
final HealthKitHostApi _hostApi = HealthKitHostApi();
final logText = ''.obs;
... ... @@ -28,13 +36,23 @@ class AppleHealthUploadTestController extends GetxController {
Future<void> checkPermission() async {
_appendLog('开始检测 AppleHealth 权限');
try {
final hasPermission = await _hostApi.checkHealthAppAuthorization();
_printHostApiResult('checkHealthAppAuthorization', hasPermission);
if (hasPermission) {
final result = await _hostApi.checkHealthAppAuthorization();
_printHostApiResult('checkHealthAppAuthorization', result.hasData);
if (result.status == 0) {
permissionTitle.value = '权限:待请求权限';
_appendLog('AppleHealth 待请求权限');
return;
}
if (result.status == 1) {
permissionTitle.value = '权限:已授权';
_appendLog('AppleHealth 权限已授权');
return;
}
if (result.status == 2) {
permissionTitle.value = '权限:已拒绝/无数据';
_appendLog('AppleHealth 已拒绝/无数据');
return;
}
final granted = await _hostApi.requestHealthClientAuthorization();
_printHostApiResult('requestHealthClientAuthorization', granted);
... ... @@ -56,11 +74,9 @@ class AppleHealthUploadTestController extends GetxController {
_activityTarget = null;
_appendLog('开始同步 HealthKitHostApi 数据');
final userId = _currentUserId;
final endTime = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final startTime = DateTime.now()
.subtract(const Duration(days: 365 * 2))
.millisecondsSinceEpoch ~/
1000;
final startTime = _resolveSyncStartTime(userId);
_appendLog('同步范围:$startTime -> $endTime');
try {
... ... @@ -124,6 +140,13 @@ class AppleHealthUploadTestController extends GetxController {
'同步完成:common=${_commonData.length}, sleep=${_sleepData.length}, '
'activityTarget=${_activityTarget == null ? 0 : 1}',
);
if (userId > 0) {
await _userAccountStorage.saveAppleHealthUploadTestLastSyncTime(
userId,
endTime,
);
_appendLog('已保存本次同步时间:$endTime');
}
} catch (error, stackTrace) {
_appendLog('同步失败:$error');
_appendLog(stackTrace.toString());
... ... @@ -132,6 +155,23 @@ class AppleHealthUploadTestController extends GetxController {
}
}
int get _currentUserId =>
_userPreferencesStorage.preferences.value.meUserInfo?.id ?? 0;
int _resolveSyncStartTime(int userId) {
if (userId > 0) {
final lastSyncTime =
_userAccountStorage.appleHealthUploadTestLastSyncTime(userId);
if (lastSyncTime != null && lastSyncTime > 0) {
return lastSyncTime;
}
}
final start = DateTime.now().subtract(const Duration(days: 365 * 2));
return DateTime(start.year, start.month, start.day)
.millisecondsSinceEpoch ~/
1000;
}
Future<void> uploadHealthData() async {
if (isUploading.value) return;
if (_commonData.isEmpty && _sleepData.isEmpty && _activityTarget == null) {
... ...
import 'package:doublefeel_flutter/app/apple_health_upload/apple_health_upload_tool.dart';
import 'package:doublefeel_flutter/app/modules/home/controllers/my_controller.dart';
import 'package:doublefeel_flutter/core/network/api/health_api.dart';
import 'package:doublefeel_flutter/core/network/api/user_api.dart';
... ... @@ -20,6 +21,7 @@ class HomeBinding extends Bindings {
Get.find<HealthApi>(),
Get.find<UserStateService>(),
Get.find<HealthKitUploadService>(),
Get.find<AppleHealthUploadTool>(),
),
fenix: true,
);
... ...
import 'dart:async';
import 'package:doublefeel_flutter/app/apple_health_upload/apple_health_upload_tool.dart';
import 'package:doublefeel_flutter/app/modules/home/widgets/today/no_health_data_page.dart';
import 'package:doublefeel_flutter/app/routes/app_pages.dart';
import 'package:doublefeel_flutter/core/logging/app_logger.dart';
import 'package:doublefeel_flutter/core/network/api/health_api.dart';
import 'package:doublefeel_flutter/core/network/api/user_api.dart';
... ... @@ -12,6 +12,8 @@ import 'package:doublefeel_flutter/core/services/user_state_service.dart';
import 'package:doublefeel_flutter/data/models/enums/app_enums.dart';
import 'package:doublefeel_flutter/data/models/health/health_models.dart';
import 'package:doublefeel_flutter/data/models/health/health_upload_models.dart';
import 'package:doublefeel_flutter/data/models/user/user_models.dart';
import 'package:doublefeel_flutter/pigeon/health_kit_api.g.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
... ... @@ -44,12 +46,15 @@ class TodayController extends GetxController {
this._healthApi,
this._userStateService,
this._healthKitUploadService,
this._appleHealthUploadTool,
);
final UserApi _userApi;
final HealthApi _healthApi;
final UserStateService _userStateService;
final HealthKitUploadService _healthKitUploadService;
final AppleHealthUploadTool _appleHealthUploadTool;
final HealthKitHostApi _hostApi = HealthKitHostApi();
UserStateService get userStateService => _userStateService;
... ... @@ -105,18 +110,25 @@ class TodayController extends GetxController {
final hrvAnnotations = <HrvAnnotation>[].obs;
Future<void> requestHealthAuthorization() async {
// try {
// await _healthKitUploadService.requestClientAuthorization();
// await _refreshHealthAuthorizationState();
// } catch (error) {
// debugPrint('Health authorization skipped: $error');
// }
Get.to(NoHealthDataPage(
onRefresh: () {},
onHelp: () {
Get.toNamed(Routes.HELP);
},
));
try {
final result = await _hostApi.checkHealthAppAuthorization();
print("checkHealthAppAuthorization : $result");
if (result.status == 0) {
bool success = await _hostApi.requestHealthClientAuthorization();
print("requestHealthClientAuthorization : $success");
if (success) {
_performDataUpload();
} else {
Get.to(NoHealthDataPage(
onRefresh: _performDataUpload,
));
}
return;
}
Get.to(NoHealthDataPage(
onRefresh: _performDataUpload,
));
} catch (e) {}
}
@override
... ... @@ -152,7 +164,8 @@ class TodayController extends GetxController {
isLoadingToday.value = true;
try {
await Future.wait([
// _refreshHealthAuthorizationState(),
_refreshUserGreeting(),
_refreshHealthAuthorizationState(),
_refreshHealthDataForDate(date),
]);
} catch (error, stackTrace) {
... ... @@ -164,23 +177,40 @@ class TodayController extends GetxController {
}
}
Future<void> _refreshHealthAuthorizationState() async {
final serverAuth =
await _healthApi.checkServerHealthAuth(errorHandlingPolicy: null);
final hasServerAuth = switch (serverAuth) {
AppSuccess<HealthAuthResponse>(data: final auth) =>
auth.scope?.trim().isNotEmpty == true,
_ => false,
};
bool hasClientAuth = false;
/// 上传 Apple Health 新数据。
Future<void> _performDataUpload() async {
try {
hasClientAuth = await _healthKitUploadService.isHealthAuthorized();
final result =
await _appleHealthUploadTool.uploadAllNewDataFromHealthKit();
AppLogger.i(
'Apple Health upload finished: '
'common=${result.commonUploadSuccess}(${result.commonCount}), '
'sleep=${result.sleepUploadSuccess}(${result.sleepCount}), '
'activityTarget=${result.activityTargetUploadSuccess}'
'(${result.activityTargetCount}), '
'success=${result.isSuccess}',
);
} catch (error, stackTrace) {
AppLogger.w('Health authorization check failed', error, stackTrace);
AppLogger.e('Apple Health upload failed', error, stackTrace);
}
}
Future<void> _refreshUserGreeting() async {
final result = await _userApi.getUserInfo(errorHandlingPolicy: null);
if (result case AppSuccess<UserInfoResponse>(data: final user)) {
final name = user.nickname?.trim();
stressSubtitle.value = name == null || name.isEmpty
? 'Hi, 你今日的综合压力状态'
: 'Hi, $name 今日的综合压力状态';
}
}
Future<void> _refreshHealthAuthorizationState() async {
try {
final result = await _hostApi.checkHealthAppAuthorization();
showHealthDataAuthCard.value = !(hasServerAuth || hasClientAuth);
showHealthDataAuthCard.value = result.status != 1;
} catch (e) {}
}
Future<void> _refreshHealthDataForDate(DateTime date) async {
... ...
... ... @@ -182,6 +182,7 @@ final officialThemes = <WatchThemeItem>[
),
],
),
/*
WatchThemeItem(
title: '垂耳粉兔',
infoList: [
... ... @@ -266,6 +267,7 @@ final officialThemes = <WatchThemeItem>[
),
],
),
*/
];
final customThemes = <WatchThemeItem>[
... ...
... ... @@ -17,10 +17,11 @@ class HealthKitUploadService {
final HealthKitHostApi _healthKitHost;
Future<bool> isHealthAuthorized() async {
if (!isAndroid) {
return false;
}
return _healthKitHost.checkHealthAppAuthorization();
// if (isAndroid) {
// return false;
// }
final result = await _healthKitHost.checkHealthAppAuthorization();
return result.status == 1;
}
Future<String> getServerAuthUrl() => _healthKitHost.getHealthServerAuthUrl();
... ...
... ... @@ -18,6 +18,14 @@ class UserAccountStorage {
static String _onboardingKey(int userId) =>
'account_onboarding_stage_$userId';
/// Apple Health 上传时间记录 key,按 userId 隔离。
static String _appleHealthUploadRecordKey(int userId) =>
'apple_health_upload_local_records_$userId';
/// Apple Health 上传测试页上次同步时间 key,按 userId 隔离。
static String _appleHealthUploadTestLastSyncTimeKey(int userId) =>
'apple_health_upload_test_last_sync_time_$userId';
/// Onboarding 已全部完成的哨兵值
static const int _kOnboardingCompleted = -1;
... ... @@ -55,4 +63,30 @@ class UserAccountStorage {
/// 引导全部完成时调用。
Future<void> markOnboardingCompleted(int userId) =>
_prefs.setInt(_onboardingKey(userId), _kOnboardingCompleted);
// ─── Apple Health 上传记录 ────────────────────────────────────────────────
String? appleHealthUploadLocalRecordJson(int userId) =>
_prefs.getString(_appleHealthUploadRecordKey(userId));
Future<void> saveAppleHealthUploadLocalRecordJson(
int userId,
String value,
) =>
_prefs.setString(_appleHealthUploadRecordKey(userId), value);
Future<void> clearAppleHealthUploadLocalRecord(int userId) =>
_prefs.remove(_appleHealthUploadRecordKey(userId));
int? appleHealthUploadTestLastSyncTime(int userId) =>
_prefs.getInt(_appleHealthUploadTestLastSyncTimeKey(userId));
Future<void> saveAppleHealthUploadTestLastSyncTime(
int userId,
int latestSyncTime,
) =>
_prefs.setInt(
_appleHealthUploadTestLastSyncTimeKey(userId),
latestSyncTime,
);
}
... ...
... ... @@ -229,6 +229,53 @@ class HealthActivityTargetData {
;
}
class HealthAuthorization {
HealthAuthorization({
required this.status,
required this.hasData,
});
/// -1: AppleHealth不可用, 0: 需要请求授权, 1: 已被授权且能获取到部分数据, 2: 已被拒绝或者没有数据
int status;
bool hasData;
List<Object?> _toList() {
return <Object?>[
status,
hasData,
];
}
Object encode() {
return _toList(); }
static HealthAuthorization decode(Object result) {
result as List<Object?>;
return HealthAuthorization(
status: result[0]! as int,
hasData: result[1]! as bool,
);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
bool operator ==(Object other) {
if (other is! HealthAuthorization || other.runtimeType != runtimeType) {
return false;
}
if (identical(this, other)) {
return true;
}
return _deepEquals(encode(), other.encode());
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => Object.hashAll(_toList())
;
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
... ... @@ -249,6 +296,9 @@ class _PigeonCodec extends StandardMessageCodec {
} else if (value is HealthActivityTargetData) {
buffer.putUint8(132);
writeValue(buffer, value.encode());
} else if (value is HealthAuthorization) {
buffer.putUint8(133);
writeValue(buffer, value.encode());
} else {
super.writeValue(buffer, value);
}
... ... @@ -265,6 +315,8 @@ class _PigeonCodec extends StandardMessageCodec {
return HealthSleepUploadDataPoint.decode(readValue(buffer)!);
case 132:
return HealthActivityTargetData.decode(readValue(buffer)!);
case 133:
return HealthAuthorization.decode(readValue(buffer)!);
default:
return super.readValueOfType(type, buffer);
}
... ... @@ -284,8 +336,8 @@ class HealthKitHostApi {
final String pigeonVar_messageChannelSuffix;
Future<bool> checkHealthAppAuthorization() async {
final String pigeonVar_channelName = 'dev.flutter.pigeon.doublefeel_flutter.HealthKitHostApi.checkHealthAppAuthorization$pigeonVar_messageChannelSuffix';
Future<String> getHealthServerAuthUrl() async {
final String pigeonVar_channelName = 'dev.flutter.pigeon.doublefeel_flutter.HealthKitHostApi.getHealthServerAuthUrl$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
... ... @@ -308,12 +360,12 @@ class HealthKitHostApi {
message: 'Host platform returned null value for non-null return value.',
);
} else {
return (pigeonVar_replyList[0] as bool?)!;
return (pigeonVar_replyList[0] as String?)!;
}
}
Future<String> getHealthServerAuthUrl() async {
final String pigeonVar_channelName = 'dev.flutter.pigeon.doublefeel_flutter.HealthKitHostApi.getHealthServerAuthUrl$pigeonVar_messageChannelSuffix';
Future<bool> cancelHealthAppAuthorization() async {
final String pigeonVar_channelName = 'dev.flutter.pigeon.doublefeel_flutter.HealthKitHostApi.cancelHealthAppAuthorization$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
... ... @@ -336,13 +388,13 @@ class HealthKitHostApi {
message: 'Host platform returned null value for non-null return value.',
);
} else {
return (pigeonVar_replyList[0] as String?)!;
return (pigeonVar_replyList[0] as bool?)!;
}
}
/// Opens Huawei Health client authorization UI. Returns whether user granted.
Future<bool> requestHealthClientAuthorization() async {
final String pigeonVar_channelName = 'dev.flutter.pigeon.doublefeel_flutter.HealthKitHostApi.requestHealthClientAuthorization$pigeonVar_messageChannelSuffix';
/// Runs native health read and server upload pipeline.
Future<HealthUploadResult> performHealthUpload() async {
final String pigeonVar_channelName = 'dev.flutter.pigeon.doublefeel_flutter.HealthKitHostApi.performHealthUpload$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
... ... @@ -365,12 +417,13 @@ class HealthKitHostApi {
message: 'Host platform returned null value for non-null return value.',
);
} else {
return (pigeonVar_replyList[0] as bool?)!;
return (pigeonVar_replyList[0] as HealthUploadResult?)!;
}
}
Future<bool> cancelHealthAppAuthorization() async {
final String pigeonVar_channelName = 'dev.flutter.pigeon.doublefeel_flutter.HealthKitHostApi.cancelHealthAppAuthorization$pigeonVar_messageChannelSuffix';
/// Opens Huawei Health client authorization UI. Returns whether user granted.
Future<HealthAuthorization> checkHealthAppAuthorization() async {
final String pigeonVar_channelName = 'dev.flutter.pigeon.doublefeel_flutter.HealthKitHostApi.checkHealthAppAuthorization$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
... ... @@ -393,13 +446,12 @@ class HealthKitHostApi {
message: 'Host platform returned null value for non-null return value.',
);
} else {
return (pigeonVar_replyList[0] as bool?)!;
return (pigeonVar_replyList[0] as HealthAuthorization?)!;
}
}
/// Runs native health read and server upload pipeline.
Future<HealthUploadResult> performHealthUpload() async {
final String pigeonVar_channelName = 'dev.flutter.pigeon.doublefeel_flutter.HealthKitHostApi.performHealthUpload$pigeonVar_messageChannelSuffix';
Future<bool> requestHealthClientAuthorization() async {
final String pigeonVar_channelName = 'dev.flutter.pigeon.doublefeel_flutter.HealthKitHostApi.requestHealthClientAuthorization$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
... ... @@ -422,7 +474,7 @@ class HealthKitHostApi {
message: 'Host platform returned null value for non-null return value.',
);
} else {
return (pigeonVar_replyList[0] as HealthUploadResult?)!;
return (pigeonVar_replyList[0] as bool?)!;
}
}
... ...
... ... @@ -30,6 +30,14 @@ bool _deepEquals(Object? a, Object? b) {
}
enum AppleProductPaymentErrorMsg {
missingUUID,
productNotFound,
userCancelled,
failedVerification,
unknown,
}
class AppleSignInModel {
AppleSignInModel({
required this.userId,
... ... @@ -86,6 +94,141 @@ class AppleSignInModel {
;
}
class AppleProductInfo {
AppleProductInfo({
required this.productId,
required this.originPriceDescription,
this.priceDescription,
required this.originPrice,
this.price,
required this.isTrialPeriod,
});
String productId;
String originPriceDescription;
String? priceDescription;
double originPrice;
double? price;
bool isTrialPeriod;
List<Object?> _toList() {
return <Object?>[
productId,
originPriceDescription,
priceDescription,
originPrice,
price,
isTrialPeriod,
];
}
Object encode() {
return _toList(); }
static AppleProductInfo decode(Object result) {
result as List<Object?>;
return AppleProductInfo(
productId: result[0]! as String,
originPriceDescription: result[1]! as String,
priceDescription: result[2] as String?,
originPrice: result[3]! as double,
price: result[4] as double?,
isTrialPeriod: result[5]! as bool,
);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
bool operator ==(Object other) {
if (other is! AppleProductInfo || other.runtimeType != runtimeType) {
return false;
}
if (identical(this, other)) {
return true;
}
return _deepEquals(encode(), other.encode());
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => Object.hashAll(_toList())
;
}
class AppleProductPaymentResult {
AppleProductPaymentResult({
required this.productId,
this.appAccountToken,
this.originalTransactionId,
this.transactionId,
this.success,
this.errorMessage,
});
String productId;
String? appAccountToken;
String? originalTransactionId;
String? transactionId;
bool? success;
/// 错误描述:
/// 和AppleProductPaymentErrorMsg匹配的flutter处理,
/// 不匹配的则是StoreKit 直接给的错误描述,直接展示即可
String? errorMessage;
List<Object?> _toList() {
return <Object?>[
productId,
appAccountToken,
originalTransactionId,
transactionId,
success,
errorMessage,
];
}
Object encode() {
return _toList(); }
static AppleProductPaymentResult decode(Object result) {
result as List<Object?>;
return AppleProductPaymentResult(
productId: result[0]! as String,
appAccountToken: result[1] as String?,
originalTransactionId: result[2] as String?,
transactionId: result[3] as String?,
success: result[4] as bool?,
errorMessage: result[5] as String?,
);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
bool operator ==(Object other) {
if (other is! AppleProductPaymentResult || other.runtimeType != runtimeType) {
return false;
}
if (identical(this, other)) {
return true;
}
return _deepEquals(encode(), other.encode());
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => Object.hashAll(_toList())
;
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
... ... @@ -94,8 +237,17 @@ class _PigeonCodec extends StandardMessageCodec {
if (value is int) {
buffer.putUint8(4);
buffer.putInt64(value);
} else if (value is AppleSignInModel) {
} else if (value is AppleProductPaymentErrorMsg) {
buffer.putUint8(129);
writeValue(buffer, value.index);
} else if (value is AppleSignInModel) {
buffer.putUint8(130);
writeValue(buffer, value.encode());
} else if (value is AppleProductInfo) {
buffer.putUint8(131);
writeValue(buffer, value.encode());
} else if (value is AppleProductPaymentResult) {
buffer.putUint8(132);
writeValue(buffer, value.encode());
} else {
super.writeValue(buffer, value);
... ... @@ -106,7 +258,14 @@ class _PigeonCodec extends StandardMessageCodec {
Object? readValueOfType(int type, ReadBuffer buffer) {
switch (type) {
case 129:
final int? value = readValue(buffer) as int?;
return value == null ? null : AppleProductPaymentErrorMsg.values[value];
case 130:
return AppleSignInModel.decode(readValue(buffer)!);
case 131:
return AppleProductInfo.decode(readValue(buffer)!);
case 132:
return AppleProductPaymentResult.decode(readValue(buffer)!);
default:
return super.readValueOfType(type, buffer);
}
... ... @@ -156,6 +315,7 @@ class PlatformHostApi {
}
}
/// 请求苹果登录
Future<AppleSignInModel?> requestAppleSignIn() async {
final String pigeonVar_channelName = 'dev.flutter.pigeon.doublefeel_flutter.PlatformHostApi.requestAppleSignIn$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
... ... @@ -178,4 +338,52 @@ class PlatformHostApi {
return (pigeonVar_replyList[0] as AppleSignInModel?);
}
}
/// 查询指定id的苹果商品
Future<AppleProductInfo?> requestAppleProductInfo(String productId) async {
final String pigeonVar_channelName = 'dev.flutter.pigeon.doublefeel_flutter.PlatformHostApi.requestAppleProductInfo$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[productId]);
final List<Object?>? pigeonVar_replyList =
await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else {
return (pigeonVar_replyList[0] as AppleProductInfo?);
}
}
/// 从服务器下单后请求苹果支付
Future<AppleProductPaymentResult?> performApplePayment(String productId, String uuid) async {
final String pigeonVar_channelName = 'dev.flutter.pigeon.doublefeel_flutter.PlatformHostApi.performApplePayment$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[productId, uuid]);
final List<Object?>? pigeonVar_replyList =
await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else {
return (pigeonVar_replyList[0] as AppleProductPaymentResult?);
}
}
}
... ...
... ... @@ -46,6 +46,14 @@ class HealthActivityTargetData {
int? stand;
}
class HealthAuthorization {
/// -1: AppleHealth不可用, 0: 需要请求授权, 1: 已被授权且能获取到部分数据, 2: 已被拒绝或者没有数据
final int status;
final bool hasData;
HealthAuthorization({required this.status, required this.hasData});
}
@ConfigurePigeon(
PigeonOptions(
dartOut: 'lib/pigeon/health_kit_api.g.dart',
... ... @@ -64,87 +72,92 @@ class HealthActivityTargetData {
)
@HostApi()
abstract class HealthKitHostApi {
@async
bool checkHealthAppAuthorization();
// @optional
@async
@async
String getHealthServerAuthUrl();
/// Opens Huawei Health client authorization UI. Returns whether user granted.
bool requestHealthClientAuthorization();
bool cancelHealthAppAuthorization();
/// Runs native health read and server upload pipeline.
HealthUploadResult performHealthUpload();
@async
// @required
/// Opens Huawei Health client authorization UI. Returns whether user granted.
@async
HealthAuthorization checkHealthAppAuthorization();
@async
bool requestHealthClientAuthorization();
@async
List<HealthUploadDataPoint> fetchHrvData(int startTime, int endTime);
@async
@async
List<HealthUploadDataPoint> fetchHeartRateData(int startTime, int endTime);
@async
@async
List<HealthUploadDataPoint> fetchWalkingHeartRateData(
int startTime,
int endTime,
);
@async
@async
List<HealthUploadDataPoint> fetchRestingHeartRateData(
int startTime,
int endTime,
);
@async
@async
List<HealthUploadDataPoint> fetchSleepingHeartRateData(
int startTime,
int endTime,
);
@async
@async
List<HealthUploadDataPoint> fetchOxygenSaturationData(
int startTime,
int endTime,
);
@async
@async
List<HealthUploadDataPoint> fetchActiveEnergyData(
int startTime,
int endTime,
);
@async
@async
List<HealthUploadDataPoint> fetchExerciseData(int startTime, int endTime);
@async
@async
List<HealthUploadDataPoint> fetchStandData(int startTime, int endTime);
@async
@async
List<HealthUploadDataPoint> fetchStepCountData(int startTime, int endTime);
@async
@async
List<HealthSleepUploadDataPoint> fetchSleepData(int startTime, int endTime);
@async
@async
List<HealthUploadDataPoint> fetchSleepingWristTemperatureData(
int startTime,
int endTime,
);
@async
@async
List<HealthUploadDataPoint> fetchRespiratoryRateData(
int startTime,
int endTime,
);
@async
@async
List<HealthUploadDataPoint> fetchIrregularHeartRhythmData(
int startTime,
int endTime,
);
@async
@async
HealthActivityTargetData? fetchActivityTargetData(
int startTime,
int endTime,
... ...
... ... @@ -14,6 +14,53 @@ class AppleSignInModel {
});
}
class AppleProductInfo {
final String productId;
final String originPriceDescription;
final String? priceDescription;
final double originPrice;
final double? price;
final bool isTrialPeriod;
AppleProductInfo({
required this.productId,
required this.originPriceDescription,
this.priceDescription,
required this.originPrice,
this.price,
required this.isTrialPeriod,
});
}
enum AppleProductPaymentErrorMsg {
missingUUID,
productNotFound,
userCancelled,
failedVerification,
unknown,
}
class AppleProductPaymentResult {
final String productId;
final String? appAccountToken;
final String? originalTransactionId;
final String? transactionId;
final bool? success;
/// 错误描述:
/// 和AppleProductPaymentErrorMsg匹配的flutter处理,
/// 不匹配的则是StoreKit 直接给的错误描述,直接展示即可
final String? errorMessage;
AppleProductPaymentResult({
required this.productId,
this.appAccountToken,
this.originalTransactionId,
this.transactionId,
this.success,
this.errorMessage,
});
}
@ConfigurePigeon(
PigeonOptions(
dartOut: 'lib/pigeon/platform_api.g.dart',
... ... @@ -36,6 +83,15 @@ abstract class PlatformHostApi {
/// `{systemWebViewUA} {appName}/{versionCode}({versionName})({manufacturer}##{brand}##{model}; {OS}{osVersion}; {height}x{width})(huawei)`
String getFullUserAgent();
@async
/// 请求苹果登录
@async
AppleSignInModel? requestAppleSignIn();
/// 查询指定id的苹果商品
@async
AppleProductInfo? requestAppleProductInfo(String productId);
/// 从服务器下单后请求苹果支付
@async
AppleProductPaymentResult? performApplePayment(String productId, String uuid);
}
... ...
... ... @@ -133,10 +133,10 @@ packages:
dependency: transitive
description:
name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605"
url: "https://pub.dev"
source: hosted
version: "1.4.0"
version: "1.3.0"
checked_yaml:
dependency: transitive
description:
... ... @@ -149,10 +149,10 @@ packages:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf
url: "https://pub.dev"
source: hosted
version: "1.1.2"
version: "1.1.1"
code_builder:
dependency: transitive
description:
... ... @@ -165,10 +165,10 @@ packages:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf
url: "https://pub.dev"
source: hosted
version: "1.19.1"
version: "1.19.0"
convert:
dependency: transitive
description:
... ... @@ -237,10 +237,10 @@ packages:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
version: "1.3.1"
ffi:
dependency: transitive
description:
... ... @@ -426,7 +426,7 @@ packages:
dependency: transitive
description:
path: image_cropper_for_web
ref: "65c2c99891882ea59732959a672f3d5993a837bb"
ref: "br_v9.1.0_ohos"
resolved-ref: "65c2c99891882ea59732959a672f3d5993a837bb"
url: "https://gitcode.com/openharmony-sig/fluttertpc_image_cropper.git"
source: git
... ... @@ -435,7 +435,7 @@ packages:
dependency: transitive
description:
path: image_cropper_platform_interface
ref: "65c2c99891882ea59732959a672f3d5993a837bb"
ref: "br_v9.1.0_ohos"
resolved-ref: "65c2c99891882ea59732959a672f3d5993a837bb"
url: "https://gitcode.com/openharmony-sig/fluttertpc_image_cropper.git"
source: git
... ... @@ -517,10 +517,10 @@ packages:
dependency: "direct main"
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
url: "https://pub.dev"
source: hosted
version: "0.20.2"
version: "0.19.0"
io:
dependency: transitive
description:
... ... @@ -549,26 +549,26 @@ packages:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
version: "10.0.7"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
version: "3.0.8"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
version: "3.0.1"
lints:
dependency: transitive
description:
... ... @@ -597,10 +597,10 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
url: "https://pub.dev"
source: hosted
version: "0.12.17"
version: "0.12.16+1"
material_color_utilities:
dependency: transitive
description:
... ... @@ -613,10 +613,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
url: "https://pub.dev"
source: hosted
version: "1.17.0"
version: "1.15.0"
mime:
dependency: transitive
description:
... ... @@ -645,10 +645,10 @@ packages:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
version: "1.9.0"
path_provider:
dependency: transitive
description:
... ... @@ -966,18 +966,18 @@ packages:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
version: "1.12.0"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
url: "https://pub.dev"
source: hosted
version: "2.1.4"
version: "2.1.2"
stream_transform:
dependency: transitive
description:
... ... @@ -1006,10 +1006,10 @@ packages:
dependency: "direct main"
description:
name: table_calendar
sha256: "0c0c6219878b363a2d5f40c7afb159d845f253d061dc3c822aa0d5fe0f721982"
sha256: b2896b7c86adf3a4d9c911d860120fe3dbe03c85db43b22fd61f14ee78cdbb63
url: "https://pub.dev"
source: hosted
version: "3.2.0"
version: "3.1.3"
term_glyph:
dependency: transitive
description:
... ... @@ -1022,10 +1022,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c"
url: "https://pub.dev"
source: hosted
version: "0.7.7"
version: "0.7.3"
timing:
dependency: transitive
description:
... ... @@ -1054,10 +1054,10 @@ packages:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
version: "2.1.4"
vm_service:
dependency: transitive
description:
... ... @@ -1111,8 +1111,8 @@ packages:
dependency: transitive
description:
path: "packages/webview_flutter/webview_flutter_android"
ref: de942e79c9057b32ad31106508bd87c0d60aef83
resolved-ref: de942e79c9057b32ad31106508bd87c0d60aef83
ref: "br_webview_flutter-v4.13.0_ohos"
resolved-ref: ecd3d0b6195945ee0660c324d45f1acbc6198cc3
url: "https://gitcode.com/openharmony-tpc/flutter_packages.git"
source: git
version: "4.7.0"
... ... @@ -1120,8 +1120,8 @@ packages:
dependency: transitive
description:
path: "packages/webview_flutter/webview_flutter_ohos"
ref: de942e79c9057b32ad31106508bd87c0d60aef83
resolved-ref: de942e79c9057b32ad31106508bd87c0d60aef83
ref: "br_webview_flutter-v4.13.0_ohos"
resolved-ref: ecd3d0b6195945ee0660c324d45f1acbc6198cc3
url: "https://gitcode.com/openharmony-tpc/flutter_packages.git"
source: git
version: "4.7.0"
... ... @@ -1129,8 +1129,8 @@ packages:
dependency: transitive
description:
path: "packages/webview_flutter/webview_flutter_platform_interface"
ref: de942e79c9057b32ad31106508bd87c0d60aef83
resolved-ref: de942e79c9057b32ad31106508bd87c0d60aef83
ref: "br_webview_flutter-v4.13.0_ohos"
resolved-ref: ecd3d0b6195945ee0660c324d45f1acbc6198cc3
url: "https://gitcode.com/openharmony-tpc/flutter_packages.git"
source: git
version: "2.13.1"
... ... @@ -1138,8 +1138,8 @@ packages:
dependency: transitive
description:
path: "packages/webview_flutter/webview_flutter_wkwebview"
ref: de942e79c9057b32ad31106508bd87c0d60aef83
resolved-ref: de942e79c9057b32ad31106508bd87c0d60aef83
ref: "br_webview_flutter-v4.13.0_ohos"
resolved-ref: ecd3d0b6195945ee0660c324d45f1acbc6198cc3
url: "https://gitcode.com/openharmony-tpc/flutter_packages.git"
source: git
version: "3.22.0"
... ... @@ -1160,5 +1160,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.8.0-0 <4.0.0"
dart: ">=3.6.2 <4.0.0"
flutter: ">=3.27.0"
... ...