wake-ios/wake/View/Subscribe/SubscribeView.swift
2025-09-02 20:25:35 +08:00

600 lines
24 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// SubscribeView.swift
// wake
//
// Created by fairclip on 2025/8/19.
//
import SwiftUI
import StoreKit
import Network
// MARK: -
enum SubscriptionPlan: String, CaseIterable {
case free = "Free"
case pioneer = "Pioneer"
var displayName: String {
return self.rawValue
}
var price: String {
switch self {
case .free:
return "Free"
case .pioneer:
return "1$/Mon"
}
}
var isPopular: Bool {
return self == .pioneer
}
}
// MARK: -
struct SubscriptionFeature {
let name: String
let freeValue: String
let proValue: String
}
struct SubscribeView: View {
@Environment(\.dismiss) private var dismiss
@StateObject private var store = IAPManager()
@State private var selectedPlan: SubscriptionPlan? = .pioneer
@State private var isLoading = false
@State private var showErrorAlert = false
@State private var errorText = ""
@State private var memberProfile: MemberProfile?
@State private var showSuccessAlert = false
//
private let features = [
SubscriptionFeature(name: "Mystery Box Purchase:", freeValue: "3 /week", proValue: "Free"),
SubscriptionFeature(name: "Material Upload:", freeValue: "50 images and\n5 videos/day", proValue: "Unlimited"),
SubscriptionFeature(name: "Free Credits:", freeValue: "200 /day", proValue: "500 /day")
]
var body: some View {
VStack(spacing: 0) {
//
SimpleNaviHeader(title: "Subscription") {
dismiss()
}
.background(Color.themeTextWhiteSecondary)
ScrollView {
VStack(spacing: 0) {
//
currentSubscriptionCard
//
creditsSection
VStack {
//
subscriptionPlansSection
//
specialOfferBanner
}
.background(Theme.Colors.cardBackground)
.cornerRadius(Theme.CornerRadius.medium)
.padding(.horizontal, Theme.Spacing.lg)
.padding(.vertical, Theme.Spacing.lg)
//
featureComparisonTable
//
subscribeButton
//
legalLinks
Spacer(minLength: 100)
}
}
.background(Theme.Colors.background)
}
.background(Color.themeTextWhiteSecondary)
.navigationBarHidden(true)
.onAppear {
fetchMemberInfo()
}
.task {
// Load products and refresh current entitlements on appear
await store.loadProducts()
await store.refreshEntitlements()
}
.onChange(of: store.isPurchasing) { newValue in
// Bind purchasing state to button loading
isLoading = newValue
}
.onChange(of: store.errorMessage) { newValue in
if let message = newValue, !message.isEmpty {
errorText = message
showErrorAlert = true
}
}
.alert("Purchase Error", isPresented: $showErrorAlert) {
Button("OK", role: .cancel) { store.errorMessage = nil }
} message: {
Text(errorText)
}
.alert("Purchase Success", isPresented: $showSuccessAlert) {
Button("OK", role: .cancel) { }
} message: {
Text("购买成功!")
}
}
// MARK: -
private var currentSubscriptionCard: some View {
let status: SubscriptionStatus = {
if memberProfile?.membershipLevel == "Pioneer" {
let dateFormatter = ISO8601DateFormatter()
dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let expiryDate = memberProfile.flatMap { dateFormatter.date(from: $0.membershipEndAt) } ?? Date()
return .pioneer(expiryDate: expiryDate)
} else {
return .free
}
}()
return SubscriptionStatusBar(
status: status,
onSubscribeTap: {
//
handleSubscribe()
}
)
.padding(.horizontal, Theme.Spacing.xl)
}
// MARK: -
private var creditsSection: some View {
VStack(spacing: 16) {
CreditsInfoCard(
totalCredits: memberProfile?.remainPoints ?? 0,
onInfoTap: {
//
},
onDetailTap: {
//
}
)
}
.padding(.horizontal, Theme.Spacing.xl)
.padding(.top, Theme.Spacing.xl)
}
// MARK: -
private var subscriptionPlansSection: some View {
PlanSelector(
selectedPlan: $selectedPlan,
onPlanSelected: { plan in
print("Selected plan: \(plan.displayName)")
}
)
.padding(.horizontal, Theme.Spacing.xl)
.padding(.top, Theme.Spacing.xl)
}
// MARK: -
private var specialOfferBanner: some View {
HStack(spacing: 0) {
Text("First")
.font(Typography.font(for: .footnote, family: .quicksandRegular))
.foregroundColor(Theme.Colors.textPrimary)
Text(" 100")
.font(Typography.font(for: .footnote, family: .quicksandBold))
.foregroundColor(Theme.Colors.textPrimary)
Text(" users get a special deal: justs")
.font(Typography.font(for: .footnote, family: .quicksandRegular))
.foregroundColor(Theme.Colors.textPrimary)
Text(" $1")
.font(Typography.font(for: .footnote, family: .quicksandBold))
.foregroundColor(Theme.Colors.textPrimary)
Text(" for your first month!")
.font(Typography.font(for: .footnote, family: .quicksandRegular))
.foregroundColor(Theme.Colors.textPrimary)
}
.multilineTextAlignment(.center)
.padding(.horizontal, Theme.Spacing.lg)
.padding(.top, Theme.Spacing.sm)
.padding(.bottom, Theme.Spacing.lg)
}
// MARK: -
private var featureComparisonTable: some View {
PlanCompare()
.padding(.horizontal, Theme.Spacing.lg)
}
// MARK: -
private var subscribeButton: some View {
VStack(spacing: 12) {
SubscribeButton(
title: "Subscribe",
isLoading: isLoading,
subscribed: store.isSubscribed,
action: handleSubscribe
)
}
.padding(.horizontal, Theme.Spacing.xl)
.padding(.top, Theme.Spacing.lg)
}
// MARK: -
private var legalLinks: some View {
HStack(spacing: 8) {
Button(action: {
//
if let url = URL(string: "https://memorywake.com/privacy-policy") {
UIApplication.shared.open(url)
}
}) {
Text("Terms of Service")
.underline()
}
Text("|")
.foregroundColor(.secondary)
Button(action: {
//
if let url = URL(string: "https://memorywake.com/privacy-policy") {
UIApplication.shared.open(url)
}
}) {
Text("Privacy Policy")
.underline()
}
Text("|")
.foregroundColor(.secondary)
// Button(action: {
// Task { await store.restorePurchases() }
// }) {
// Text("Restore Purchase")
// .underline()
// }
Button(action: {
//
if let url = URL(string: "https://memorywake.com/privacy-policy") {
UIApplication.shared.open(url)
}
}) {
Text("AI Usage Guidelines")
.underline()
}
}
.font(Typography.font(for: .caption, family: .quicksandRegular))
.foregroundColor(.secondary)
.padding(.top, Theme.Spacing.sm)
}
// MARK: -
private func handleSubscribe() {
isLoading = true
Task {
do {
print("🔄 开始订阅流程...")
// 1.
print("🔄 正在创建订单...")
let orderInfo = try await createOrder()
// 2. id
print("🔄 正在创建支付...")
let paymentInfo = try await createPayment(orderId: orderInfo.id)
// 3. 使
print("🔄 开始苹果内购流程...")
do {
//
let transactionId = try await store.purchasePioneer()
print("✅ 苹果内购成功交易ID: \(transactionId)")
// 4.
print("🔄 正在通知服务器支付处理中...")
_ = try await notifyPaymentProcessing(
transactionId: paymentInfo.transactionId ?? paymentInfo.id,
// thirdPartyTransactionId: transactionId
)
print("🔄 正在通知服务器支付成功...")
_ = try await notifyPaymentSuccess(
transactionId: paymentInfo.transactionId ?? paymentInfo.id,
// thirdPartyTransactionId: transactionId
)
print("✅ 订阅流程完成")
// 5.
await MainActor.run {
self.isLoading = false
self.dismiss()
}
} catch let purchaseError as NSError {
print("❌ 苹果内购失败: \(purchaseError.localizedDescription)")
//
print("🔄 正在通知服务器支付失败...")
_ = try? await notifyPaymentFailure(
transactionId: paymentInfo.transactionId ?? paymentInfo.id,
reason: purchaseError.localizedDescription
)
// 便
throw purchaseError
}
} catch let error as NSError {
print("❌ 订阅失败: \(error.localizedDescription)")
//
var errorMessage = error.localizedDescription
if error.domain == "NetworkError" {
errorMessage = "网络连接失败,请检查您的网络设置"
} else if error.domain == "APIError" {
errorMessage = "请求失败,请稍后重试 (错误码: \(error.code))"
} else if error.domain == NSURLErrorDomain {
switch error.code {
case NSURLErrorNotConnectedToInternet, NSURLErrorNetworkConnectionLost:
errorMessage = "网络连接已断开,请检查您的网络设置"
case NSURLErrorTimedOut:
errorMessage = "请求超时,请稍后重试"
case NSURLErrorCannotConnectToHost, NSURLErrorCannotFindHost:
errorMessage = "无法连接到服务器,请稍后重试"
default:
errorMessage = "网络错误: \(error.localizedDescription)"
}
}
// 线UI
await MainActor.run {
self.isLoading = false
self.errorText = errorMessage
self.showErrorAlert = true
print("❌ 错误提示: \(errorMessage)")
}
}
}
}
//
private func createOrder() async throws -> OrderInfo {
return try await withCheckedThrowingContinuation { continuation in
let parameters: [String: Any] = [
"items": [
[
"product_item_id": 5,
"quantity": 1
]
]
]
print("🔄 开始创建订单请求,参数:\(parameters)")
//
let monitor = NWPathMonitor()
let queue = DispatchQueue(label: "NetworkMonitor")
monitor.pathUpdateHandler = { path in
if path.status == .satisfied {
//
NetworkService.shared.postWithToken(
path: "/order/create",
parameters: parameters
) { (result: Result<APIResponse<OrderInfo>, NetworkError>) in
switch result {
case .success(let response):
print("✅ 请求成功,状态码:\(response.code)")
print("📦 返回数据:\(String(describing: response.data))")
if response.code == 0 {
continuation.resume(returning: response.data)
} else {
let errorMessage = "创建订单失败,状态码:\(response.code)"
print("\(errorMessage)")
continuation.resume(throwing: NSError(
domain: "APIError",
code: response.code,
userInfo: [NSLocalizedDescriptionKey: errorMessage]
))
}
case .failure(let error):
print("❌ 请求异常:\(error.localizedDescription)")
print("🔍 错误详情:\(error)")
if let urlError = error as? URLError {
print("🌐 URL错误: \(urlError.code.rawValue) - \(urlError.localizedDescription)")
print("🔗 失败URL: \(urlError.failingURL?.absoluteString ?? "未知")")
}
continuation.resume(throwing: error)
}
}
} else {
//
let errorMessage = "网络连接不可用,请检查网络设置"
print("\(errorMessage)")
continuation.resume(throwing: NSError(domain: "NetworkError", code: 0, userInfo: [NSLocalizedDescriptionKey: errorMessage]))
}
}
monitor.start(queue: queue)
}
}
//
private func createPayment(orderId: String) async throws -> PaymentInfo {
return try await withCheckedThrowingContinuation { continuation in
let parameters: [String: Any] = [
"order_id": orderId,
"payment_method": "ApplePay"
]
print("🔄 开始创建支付请求,参数:\(parameters)")
NetworkService.shared.postWithToken(
path: "/order/pay",
parameters: parameters
) { (result: Result<APIResponse<PaymentInfo>, NetworkError>) in
switch result {
case .success(let response):
print("✅ 请求成功,状态码:\(response.code)")
print("📦 返回数据:\(String(describing: response.data))")
if response.code == 0 {
continuation.resume(returning: response.data)
} else {
let errorMessage = "创建支付失败,状态码:\(response.code)"
print("\(errorMessage)")
continuation.resume(throwing: NSError(
domain: "APIError",
code: response.code,
userInfo: [NSLocalizedDescriptionKey: errorMessage]
))
}
case .failure(let error):
print("❌ 请求异常:\(error.localizedDescription)")
print("🔍 错误详情:\(error)")
if let urlError = error as? URLError {
print("🌐 URL错误: \(urlError.code.rawValue) - \(urlError.localizedDescription)")
print("🔗 失败URL: \(urlError.failingURL?.absoluteString ?? "未知")")
}
continuation.resume(throwing: error)
}
}
}
}
// MARK: -
///
/// - Parameter transactionId: ID
/// - Parameter thirdPartyTransactionId: ID
private func notifyPaymentProcessing(transactionId: String, thirdPartyTransactionId: String? = nil) async throws -> Bool {
return try await withCheckedThrowingContinuation { continuation in
var parameters: [String: Any] = ["transaction_id": transactionId]
// ID
if let thirdPartyId = thirdPartyTransactionId {
parameters["third_party_transaction_id"] = thirdPartyId
}
print("🔄 通知服务器支付处理中,参数:\(parameters)")
NetworkService.shared.postWithToken(
path: "/order/pay-processing",
parameters: parameters
) { (result: Result<APIResponse<[String: String]?>, NetworkError>) in
switch result {
case .success(let response):
print("✅ 支付处理通知发送成功,状态码:\(response.code)")
continuation.resume(returning: response.code == 0)
case .failure(let error):
print("❌ 支付处理通知发送失败:\(error.localizedDescription)")
continuation.resume(throwing: error)
}
}
}
}
///
/// - Parameter transactionId: ID
/// - Parameter thirdPartyTransactionId: ID
private func notifyPaymentSuccess(transactionId: String, thirdPartyTransactionId: String? = nil) async throws -> Bool {
return try await withCheckedThrowingContinuation { continuation in
var parameters: [String: Any] = ["transaction_id": transactionId]
// ID
if let thirdPartyId = thirdPartyTransactionId {
parameters["third_party_transaction_id"] = thirdPartyId
}
print("🔄 通知服务器支付成功,参数:\(parameters)")
NetworkService.shared.postWithToken(
path: "/order/pay-success",
parameters: parameters
) { (result: Result<APIResponse<[String: String]?>, NetworkError>) in
switch result {
case .success(let response):
print("✅ 支付成功通知发送成功,状态码:\(response.code)")
continuation.resume(returning: response.code == 0)
case .failure(let error):
print("❌ 支付成功通知发送失败:\(error.localizedDescription)")
continuation.resume(throwing: error)
}
}
}
}
///
/// - Parameter transactionId: ID
/// - Parameter reason:
private func notifyPaymentFailure(transactionId: String, reason: String) async throws -> Bool {
return try await withCheckedThrowingContinuation { continuation in
let parameters: [String: Any] = [
"transaction_id": transactionId,
"reason": reason
]
print("🔄 通知服务器支付失败,参数:\(parameters)")
NetworkService.shared.postWithToken(
path: "/order/pay-failure",
parameters: parameters
) { (result: Result<APIResponse<[String: String]?>, NetworkError>) in
switch result {
case .success(let response):
print("✅ 支付失败通知发送成功,状态码:\(response.code)")
continuation.resume(returning: response.code == 0)
case .failure(let error):
print("❌ 支付失败通知发送失败:\(error.localizedDescription)")
continuation.resume(throwing: error)
}
}
}
}
// MARK: - Helper Methods
private func fetchMemberInfo() {
NetworkService.shared.get(
path: "/membership/personal-center-info",
parameters: nil
) { (result: Result<APIResponse<MemberProfile>, NetworkError>) in
DispatchQueue.main.async {
switch result {
case .success(let response):
self.memberProfile = response.data
print("✅ 成功获取会员信息:", response.data)
print("✅ 用户ID:", response.data.userInfo.userId)
print("✅ 用户昵称:", response.data.userInfo.nickname)
print("✅ 用户邮箱:", response.data.userInfo.email)
case .failure(let error):
print("❌ 获取会员信息失败:", error)
// Optionally show an error message to the user
self.errorText = "Failed to load member info: \(error.localizedDescription)"
self.showErrorAlert = true
}
}
}
}
}
#Preview {
SubscribeView()
}