feat: bug

This commit is contained in:
Junhui Chen 2025-09-01 13:24:12 +08:00 committed by jinyaqiu
parent 02cd217053
commit 538507f5ec
20 changed files with 1143 additions and 520 deletions

View File

@ -9,6 +9,7 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
AB6693CA2E65C94400BCAAC1 /* SVGKit in Frameworks */ = {isa = PBXBuildFile; productRef = AB6693C92E65C94400BCAAC1 /* SVGKit */; }; AB6693CA2E65C94400BCAAC1 /* SVGKit in Frameworks */ = {isa = PBXBuildFile; productRef = AB6693C92E65C94400BCAAC1 /* SVGKit */; };
AB6693CC2E65C94400BCAAC1 /* SVGKitSwift in Frameworks */ = {isa = PBXBuildFile; productRef = AB6693CB2E65C94400BCAAC1 /* SVGKitSwift */; }; AB6693CC2E65C94400BCAAC1 /* SVGKitSwift in Frameworks */ = {isa = PBXBuildFile; productRef = AB6693CB2E65C94400BCAAC1 /* SVGKitSwift */; };
AB6695272E67015600BCAAC1 /* WaterfallGrid in Frameworks */ = {isa = PBXBuildFile; productRef = AB6695262E67015600BCAAC1 /* WaterfallGrid */; };
AB8773632E4E04400071CB53 /* .gitignore in Resources */ = {isa = PBXBuildFile; fileRef = AB8773622E4E040E0071CB53 /* .gitignore */; }; AB8773632E4E04400071CB53 /* .gitignore in Resources */ = {isa = PBXBuildFile; fileRef = AB8773622E4E040E0071CB53 /* .gitignore */; };
ABC150C12E5DB39A00A1F970 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = ABC150C02E5DB39A00A1F970 /* Lottie */; }; ABC150C12E5DB39A00A1F970 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = ABC150C02E5DB39A00A1F970 /* Lottie */; };
ABE8998E2E533A7100CD7BA6 /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = ABE8998D2E533A7100CD7BA6 /* Alamofire */; }; ABE8998E2E533A7100CD7BA6 /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = ABE8998D2E533A7100CD7BA6 /* Alamofire */; };
@ -62,6 +63,7 @@
AB6693CC2E65C94400BCAAC1 /* SVGKitSwift in Frameworks */, AB6693CC2E65C94400BCAAC1 /* SVGKitSwift in Frameworks */,
AB6693CA2E65C94400BCAAC1 /* SVGKit in Frameworks */, AB6693CA2E65C94400BCAAC1 /* SVGKit in Frameworks */,
ABE8998E2E533A7100CD7BA6 /* Alamofire in Frameworks */, ABE8998E2E533A7100CD7BA6 /* Alamofire in Frameworks */,
AB6695272E67015600BCAAC1 /* WaterfallGrid in Frameworks */,
ABC150C12E5DB39A00A1F970 /* Lottie in Frameworks */, ABC150C12E5DB39A00A1F970 /* Lottie in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@ -120,6 +122,7 @@
ABC150C02E5DB39A00A1F970 /* Lottie */, ABC150C02E5DB39A00A1F970 /* Lottie */,
AB6693C92E65C94400BCAAC1 /* SVGKit */, AB6693C92E65C94400BCAAC1 /* SVGKit */,
AB6693CB2E65C94400BCAAC1 /* SVGKitSwift */, AB6693CB2E65C94400BCAAC1 /* SVGKitSwift */,
AB6695262E67015600BCAAC1 /* WaterfallGrid */,
); );
productName = wake; productName = wake;
productReference = ABB4E2082E4B75D900660198 /* wake.app */; productReference = ABB4E2082E4B75D900660198 /* wake.app */;
@ -153,6 +156,7 @@
ABE8998C2E533A7100CD7BA6 /* XCRemoteSwiftPackageReference "Alamofire" */, ABE8998C2E533A7100CD7BA6 /* XCRemoteSwiftPackageReference "Alamofire" */,
ABC150BF2E5DB39A00A1F970 /* XCRemoteSwiftPackageReference "lottie-spm" */, ABC150BF2E5DB39A00A1F970 /* XCRemoteSwiftPackageReference "lottie-spm" */,
AB6693C82E65C94400BCAAC1 /* XCRemoteSwiftPackageReference "SVGKit" */, AB6693C82E65C94400BCAAC1 /* XCRemoteSwiftPackageReference "SVGKit" */,
AB6695252E67015600BCAAC1 /* XCRemoteSwiftPackageReference "WaterfallGrid" */,
); );
preferredProjectObjectVersion = 77; preferredProjectObjectVersion = 77;
productRefGroup = ABB4E2092E4B75D900660198 /* Products */; productRefGroup = ABB4E2092E4B75D900660198 /* Products */;
@ -409,6 +413,14 @@
minimumVersion = 3.0.0; minimumVersion = 3.0.0;
}; };
}; };
AB6695252E67015600BCAAC1 /* XCRemoteSwiftPackageReference "WaterfallGrid" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/paololeonardi/WaterfallGrid.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.1.0;
};
};
ABC150BF2E5DB39A00A1F970 /* XCRemoteSwiftPackageReference "lottie-spm" */ = { ABC150BF2E5DB39A00A1F970 /* XCRemoteSwiftPackageReference "lottie-spm" */ = {
isa = XCRemoteSwiftPackageReference; isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/airbnb/lottie-spm.git"; repositoryURL = "https://github.com/airbnb/lottie-spm.git";
@ -438,6 +450,11 @@
package = AB6693C82E65C94400BCAAC1 /* XCRemoteSwiftPackageReference "SVGKit" */; package = AB6693C82E65C94400BCAAC1 /* XCRemoteSwiftPackageReference "SVGKit" */;
productName = SVGKitSwift; productName = SVGKitSwift;
}; };
AB6695262E67015600BCAAC1 /* WaterfallGrid */ = {
isa = XCSwiftPackageProductDependency;
package = AB6695252E67015600BCAAC1 /* XCRemoteSwiftPackageReference "WaterfallGrid" */;
productName = WaterfallGrid;
};
ABC150C02E5DB39A00A1F970 /* Lottie */ = { ABC150C02E5DB39A00A1F970 /* Lottie */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = ABC150BF2E5DB39A00A1F970 /* XCRemoteSwiftPackageReference "lottie-spm" */; package = ABC150BF2E5DB39A00A1F970 /* XCRemoteSwiftPackageReference "lottie-spm" */;

View File

@ -1,5 +1,6 @@
{ {
"originHash" : "d4b9379b4bd658fe79a6ae528c96d3386427dfe9d23635a65dad6edf12af85ff", "originHash" : "d4b9379b4bd658fe79a6ae528c96d3386427dfe9d23635a65dad6edf12af85ff",
"originHash" : "7ea295cc5e3eb8ef644b89ce2b47a7600994b67c8582ee354b643cd63250740d",
"pins" : [ "pins" : [
{ {
"identity" : "alamofire", "identity" : "alamofire",
@ -45,6 +46,15 @@
"revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2", "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2",
"version" : "1.6.4" "version" : "1.6.4"
} }
},
{
"identity" : "waterfallgrid",
"kind" : "remoteSourceControl",
"location" : "https://github.com/paololeonardi/WaterfallGrid.git",
"state" : {
"revision" : "c7c08652c3540adf8e48409c351879b4caea7e89",
"version" : "1.1.0"
}
} }
], ],
"version" : 3 "version" : 3

BIN
wake/Assets/Png/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

View File

@ -20,6 +20,8 @@ struct ReturnButton: View {
.font(Typography.font(for: iconSize)) .font(Typography.font(for: iconSize))
.fontWeight(.medium) .fontWeight(.medium)
.foregroundColor(iconColor) .foregroundColor(iconColor)
.padding(10) //
.contentShape(Rectangle()) // 使
} }
.buttonStyle(PlainButtonStyle()) .buttonStyle(PlainButtonStyle())
} }

View File

@ -182,6 +182,8 @@ struct BlindBoxView: View {
let mediaType: BlindBoxMediaType let mediaType: BlindBoxMediaType
@State private var showModal = false // @State private var showModal = false //
@State private var showSettings = false // @State private var showSettings = false //
@State private var isMember = false //
@State private var memberDate = "" //
@State private var showLogin = false @State private var showLogin = false
@State private var memberProfile: MemberProfile? = nil @State private var memberProfile: MemberProfile? = nil
@State private var blindCount: BlindCount? = nil @State private var blindCount: BlindCount? = nil
@ -275,6 +277,8 @@ struct BlindBoxView: View {
switch result { switch result {
case .success(let response): case .success(let response):
self.memberProfile = response.data self.memberProfile = response.data
self.isMember = response.data.membershipLevel == "Pioneer"
self.memberDate = response.data.membershipEndAt ?? ""
print("✅ 成功获取会员信息:", response.data) print("✅ 成功获取会员信息:", response.data)
print("✅ 用户ID:", response.data.userInfo.userId) print("✅ 用户ID:", response.data.userInfo.userId)
case .failure(let error): case .failure(let error):
@ -641,7 +645,10 @@ struct BlindBoxView: View {
Button(action: showUserProfile) { Button(action: showUserProfile) {
SVGImage(svgName: "User") SVGImage(svgName: "User")
.frame(width: 24, height: 24) .frame(width: 24, height: 24)
.padding(13) // Increases tap area while keeping visual size
.contentShape(Rectangle()) // Makes the padded area tappable
} }
.buttonStyle(PlainButtonStyle()) // Prevents the button from affecting the layout
Spacer() Spacer()
// // // //
@ -705,13 +712,9 @@ struct BlindBoxView: View {
ZStack { ZStack {
// 1. SVG // 1. SVG
if !showScalingOverlay { if !showScalingOverlay {
SVGImage(svgName: "BlindBg") SVGImage(svgName: "BlindBg", contentMode: .fit)
.frame( // .position(x: UIScreen.main.bounds.width / 2,
width: UIScreen.main.bounds.width * 1.8, // y: UIScreen.main.bounds.height * 0.325)
height: UIScreen.main.bounds.height * 0.85
)
.position(x: UIScreen.main.bounds.width / 2,
y: UIScreen.main.bounds.height * 0.325)
.opacity(showScalingOverlay ? 0 : 1) .opacity(showScalingOverlay ? 0 : 1)
.animation(.easeOut(duration: 1.5), value: showScalingOverlay) .animation(.easeOut(duration: 1.5), value: showScalingOverlay)
} }
@ -830,6 +833,7 @@ struct BlindBoxView: View {
.offset(x: -10, y: UIScreen.main.bounds.height * 0.2) .offset(x: -10, y: UIScreen.main.bounds.height * 0.2)
} }
} }
.padding()
.frame( .frame(
maxWidth: .infinity, maxWidth: .infinity,
maxHeight: UIScreen.main.bounds.height * 0.65 maxHeight: UIScreen.main.bounds.height * 0.65
@ -897,10 +901,11 @@ struct BlindBoxView: View {
) { ) {
UserProfileModal( UserProfileModal(
showModal: $showModal, showModal: $showModal,
showSettings: $showSettings showSettings: $showSettings,
isMember: $isMember,
memberDate: $memberDate
) )
} }
.shadow(color: .black.opacity(0.3), radius: 10, x: 5, y: 0)
.offset(x: showSettings ? UIScreen.main.bounds.width : 0) .offset(x: showSettings ? UIScreen.main.bounds.width : 0)
.animation(.spring(response: 0.6, dampingFraction: 0.8), value: showSettings) .animation(.spring(response: 0.6, dampingFraction: 0.8), value: showSettings)

101
wake/Models/OrderInfo.swift Normal file
View File

@ -0,0 +1,101 @@
import Foundation
///
struct OrderInfo: Codable, Identifiable {
let id: String
let userId: String
let totalAmount: Amount
let status: String
let items: [OrderItem]
let paymentInfo: PaymentInfo?
let createdAt: String
let updatedAt: String
let expiredAt: String
enum CodingKeys: String, CodingKey {
case id
case userId = "user_id"
case totalAmount = "total_amount"
case status
case items
case paymentInfo = "payment_info"
case createdAt = "created_at"
case updatedAt = "updated_at"
case expiredAt = "expired_at"
}
}
///
struct PaymentInfo: Codable {
let id: String
let paymentMethod: String
let paymentStatus: String
let paymentAmount: Amount
let transactionId: String?
let thirdPartyTransactionId: String?
let paidAt: String?
let createdAt: String
let updatedAt: String
enum CodingKeys: String, CodingKey {
case id
case paymentMethod = "payment_method"
case paymentStatus = "payment_status"
case paymentAmount = "payment_amount"
case transactionId = "transaction_id"
case thirdPartyTransactionId = "third_party_transaction_id"
case paidAt = "paid_at"
case createdAt = "created_at"
case updatedAt = "updated_at"
}
}
///
struct Amount: Codable {
let amount: String
let currency: String
}
///
struct OrderItem: Codable, Identifiable {
let id: String
let productId: Int
let productType: String
let productCode: String
let productName: String
let unitPrice: Amount
let discountAmount: Amount
let quantity: Int
let totalPrice: Amount
enum CodingKeys: String, CodingKey {
case id
case productId = "product_id"
case productType = "product_type"
case productCode = "product_code"
case productName = "product_name"
case unitPrice = "unit_price"
case discountAmount = "discount_amount"
case quantity
case totalPrice = "total_price"
}
}
///
enum OrderStatus: Int, Codable {
case pending = 0 //
case paid = 1 //
case completed = 2 //
case cancelled = 3 //
case refunded = 4 // 退
var description: String {
switch self {
case .pending: return "待支付"
case .paid: return "已支付"
case .completed: return "已完成"
case .cancelled: return "已取消"
case .refunded: return "已退款"
}
}
}

View File

@ -34,13 +34,12 @@ final class IAPManager: ObservableObject {
} }
// Trigger App Store purchase sheet // Trigger App Store purchase sheet
func purchasePioneer() async { func purchasePioneer() async throws -> String {
guard !isPurchasing else { return } guard !isPurchasing else { throw NSError(domain: "IAPError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Purchase already in progress"]) }
guard let product = pioneerProduct else { guard let product = pioneerProduct else {
// Surface an actionable error so the UI can inform the user throw NSError(domain: "IAPError", code: -2, userInfo: [NSLocalizedDescriptionKey: "Subscription product unavailable"])
self.errorMessage = "Subscription product unavailable. Please try again later."
return
} }
isPurchasing = true isPurchasing = true
defer { isPurchasing = false } defer { isPurchasing = false }
@ -50,21 +49,26 @@ final class IAPManager: ObservableObject {
case .success(let verification): case .success(let verification):
switch verification { switch verification {
case .unverified(_, let error): case .unverified(_, let error):
self.errorMessage = "Purchase unverified: \(error.localizedDescription)" throw error
case .verified(let transaction): case .verified(let transaction):
// Update entitlement for the purchased product print("🎉 订阅成功!", transaction)
print("🔄 交易验证通过 - ID: \(transaction.id), 原始ID: \(transaction.originalID), 产品ID: \(transaction.productID)")
updateEntitlement(from: transaction) updateEntitlement(from: transaction)
let transactionID = String(transaction.id)
print("📝 使用交易ID: \(transactionID)")
await transaction.finish() await transaction.finish()
return transactionID
} }
case .userCancelled: case .userCancelled:
break throw NSError(domain: "IAPError", code: -3, userInfo: [NSLocalizedDescriptionKey: "Purchase was cancelled"])
case .pending: case .pending:
break throw NSError(domain: "IAPError", code: -4, userInfo: [NSLocalizedDescriptionKey: "Purchase is pending approval"])
@unknown default: @unknown default:
break throw NSError(domain: "IAPError", code: -5, userInfo: [NSLocalizedDescriptionKey: "Unknown purchase result"])
} }
} catch { } catch {
self.errorMessage = "Purchase failed: \(error.localizedDescription)" self.errorMessage = "Purchase failed: \(error.localizedDescription)"
throw error
} }
} }

View File

@ -1,92 +1,148 @@
import SwiftUI import SwiftUI
import WebKit import SVGKit
struct SVGImage: UIViewRepresentable { struct SVGImage: UIViewRepresentable {
let svgName: String let svgName: String
var shouldFill: Bool = false var contentMode: ContentMode = .fit
var tintColor: Color?
func makeUIView(context: Context) -> WKWebView { private var svgPath: String {
let webView = WKWebView() return svgName
webView.isOpaque = false
webView.backgroundColor = .clear
webView.scrollView.isScrollEnabled = false
webView.scrollView.contentInsetAdjustmentBehavior = .never
guard let path = Bundle.main.path(forResource: svgName, ofType: "svg") else {
print("❌ 无法找到 SVG 文件: \(svgName).svg")
return webView
}
let fileURL = URL(fileURLWithPath: path)
let svgStyle = shouldFill ? """
width: 100%;
height: 100%;
object-fit: cover;
""" : """
max-width: 100%;
max-height: 100%;
object-fit: contain;
"""
let htmlString = """
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<style>
body, html {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
background-color: transparent;
}
.container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
svg {
\(svgStyle)
display: block;
}
</style>
</head>
<body>
<div class="container">
<object type="image/svg+xml" data="\(fileURL.lastPathComponent)"></object>
</div>
</body>
</html>
"""
webView.loadHTMLString(htmlString, baseURL: fileURL.deletingLastPathComponent())
return webView
} }
func updateUIView(_ uiView: WKWebView, context: Context) {} private func createImageView() -> SVGKFastImageView {
let emptySVGString = """
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1" preserveAspectRatio="xMidYMid meet">
<rect width="1" height="1" fill="transparent"/>
</svg>
"""
func sizeThatFits(_ proposal: ProposedViewSize, uiView: WKWebView, context: Context) -> CGSize? { if let data = emptySVGString.data(using: .utf8),
let svgImage = SVGKImage(data: data) {
let imageView = SVGKFastImageView(svgkImage: svgImage) ?? SVGKFastImageView()
imageView.contentMode = .scaleAspectFit
imageView.backgroundColor = .clear
return imageView
}
let fallbackView = SVGKFastImageView()
fallbackView.backgroundColor = .clear
return fallbackView
}
func makeUIView(context: Context) -> SVGKFastImageView {
print("🔄 开始加载SVG: \(svgName)")
let imageView = createImageView()
loadSVG(into: imageView)
configureView(imageView)
return imageView
}
private func loadSVG(into imageView: SVGKFastImageView) {
guard let path = Bundle.main.path(forResource: svgPath, ofType: "svg") else {
print("⚠️ 在main bundle中找不到文件: \(svgPath).svg")
return
}
let url = URL(fileURLWithPath: path)
guard let svgImage = SVGKImage(contentsOf: url) else {
print("❌ 无法从URL创建SVG: \(path)")
return
}
// SVG
let containerSize = imageView.bounds.size
if containerSize != .zero {
svgImage.size = containerSize
} else {
//
svgImage.size = CGSize(width: 100, height: 100)
}
print("✅ 成功加载SVG: \(svgName), 尺寸: \(svgImage.size)")
DispatchQueue.main.async {
imageView.image = svgImage
imageView.setNeedsLayout()
imageView.layoutIfNeeded()
}
}
private func configureView(_ imageView: SVGKFastImageView) {
imageView.contentMode = contentMode == .fit ? .scaleAspectFit : .scaleAspectFill
imageView.clipsToBounds = true
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.backgroundColor = .clear
//
imageView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
if let tintColor = tintColor?.uiColor {
imageView.tintColor = tintColor
DispatchQueue.main.async {
self.applyTintColor(tintColor, to: imageView.layer)
}
}
}
private func applyTintColor(_ color: UIColor, to layer: CALayer) {
if let shapeLayer = layer as? CAShapeLayer {
shapeLayer.fillColor = color.cgColor
}
layer.sublayers?.forEach { sublayer in
applyTintColor(color, to: sublayer)
}
}
func updateUIView(_ uiView: SVGKFastImageView, context: Context) {
loadSVG(into: uiView)
if let tintColor = tintColor?.uiColor {
uiView.tintColor = tintColor
DispatchQueue.main.async {
self.applyTintColor(tintColor, to: uiView.layer)
}
}
uiView.contentMode = contentMode == .fit ? .scaleAspectFit : .scaleAspectFill
}
func sizeThatFits(_ proposal: ProposedViewSize, uiView: SVGKFastImageView, context: Context) -> CGSize? {
return nil return nil
} }
} }
// MARK: - ContentMode
extension SVGImage {
enum ContentMode {
case fit //
case fill //
}
}
// MARK: - Preview // MARK: - Preview
#Preview { #Preview {
VStack { VStack(spacing: 20) {
Text("Filled SVG") Text("IP SVG")
SVGImage(svgName: "YourSVGName", shouldFill: true) SVGImage(svgName: "IP")
.frame(width: 200, height: 100) .frame(width: 100, height: 100)
.background(Color.gray.opacity(0.2)) .background(Color.gray.opacity(0.2))
.border(Color.red, width: 1)
Text("Intrinsic Size SVG") Text("Pioneer SVG")
SVGImage(svgName: "YourSVGName", shouldFill: false) SVGImage(svgName: "Pioneer", contentMode: .fill)
.frame(width: 200, height: 100) .frame(width: 100, height: 50)
.background(Color.gray.opacity(0.2)) .background(Color.gray.opacity(0.2))
.border(Color.blue, width: 1)
.clipped()
} }
.padding() .padding()
} }
// MARK: - Color Extension
private extension Color {
var uiColor: UIColor {
return UIColor(self)
}
}

View File

@ -0,0 +1,71 @@
import SwiftUI
import WebKit
struct SVGImageHtml: UIViewRepresentable {
let svgName: String
func makeUIView(context: Context) -> WKWebView {
let webView = WKWebView()
webView.isOpaque = false
webView.backgroundColor = .clear
webView.scrollView.isScrollEnabled = false
webView.scrollView.contentInsetAdjustmentBehavior = .never
// 1. Get the URL for the SVG file
guard let path = Bundle.main.path(forResource: svgName, ofType: "svg") else {
print("❌ Cannot find SVG file: \(svgName).svg in bundle")
return webView
}
let fileURL = URL(fileURLWithPath: path)
do {
// 2. Read the SVG content directly
let svgString = try String(contentsOf: fileURL, encoding: .utf8)
// 3. Create HTML with inline SVG for better reliability
let htmlString = """
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<style>
body, html {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
background-color: transparent;
display: flex;
justify-content: center;
align-items: center;
}
svg {
max-width: 100%;
max-height: 100%;
}
</style>
</head>
<body>
\(svgString)
</body>
</html>
"""
// 4. Load the HTML with base URL as the main bundle's resource path
if let resourcePath = Bundle.main.resourceURL {
webView.loadHTMLString(htmlString, baseURL: resourcePath)
} else {
webView.loadHTMLString(htmlString, baseURL: nil)
}
} catch {
print("❌ Error loading SVG file: \(error.localizedDescription)")
}
return webView
}
func updateUIView(_ uiView: WKWebView, context: Context) {}
}

View File

@ -2,7 +2,6 @@ import SwiftUI
import AVKit import AVKit
import os.log import os.log
/// A view that displays either an image or a video with fullscreen support
struct BlindOutcomeView: View { struct BlindOutcomeView: View {
let media: MediaType let media: MediaType
let time: String? let time: String?
@ -12,6 +11,7 @@ struct BlindOutcomeView: View {
@State private var isPlaying = false @State private var isPlaying = false
@State private var showControls = true @State private var showControls = true
@State private var showIPListModal = false @State private var showIPListModal = false
@State private var player: AVPlayer?
init(media: MediaType, time: String? = nil, description: String? = nil) { init(media: MediaType, time: String? = nil, description: String? = nil) {
self.media = media self.media = media
@ -28,7 +28,6 @@ struct BlindOutcomeView: View {
// //
HStack { HStack {
Button(action: { Button(action: {
//
presentationMode.wrappedValue.dismiss() presentationMode.wrappedValue.dismiss()
}) { }) {
HStack(spacing: 4) { HStack(spacing: 4) {
@ -47,7 +46,6 @@ struct BlindOutcomeView: View {
Spacer() Spacer()
//
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: "chevron.left") Image(systemName: "chevron.left")
.opacity(0) .opacity(0)
@ -56,7 +54,7 @@ struct BlindOutcomeView: View {
} }
.padding(.vertical, 12) .padding(.vertical, 12)
.background(Color.themeTextWhiteSecondary) .background(Color.themeTextWhiteSecondary)
.zIndex(1) // .zIndex(1)
Spacer() Spacer()
.frame(height: 30) .frame(height: 30)
@ -65,7 +63,6 @@ struct BlindOutcomeView: View {
GeometryReader { geometry in GeometryReader { geometry in
VStack(spacing: 16) { VStack(spacing: 16) {
ZStack { ZStack {
//
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 12)
.fill(Color.white) .fill(Color.white)
.shadow(color: Color.black.opacity(0.1), radius: 8, x: 0, y: 2) .shadow(color: Color.black.opacity(0.1), radius: 8, x: 0, y: 2)
@ -86,48 +83,41 @@ struct BlindOutcomeView: View {
} }
case .video(let url, _): case .video(let url, _):
VideoPlayerView(url: url, isPlaying: $isPlaying) VideoPlayerView(url: url, isPlaying: $isPlaying, player: $player)
.frame(width: UIScreen.main.bounds.width - 40) .frame(width: UIScreen.main.bounds.width - 40)
.background(Color.clear) .background(Color.clear)
.cornerRadius(10) .cornerRadius(10)
.clipped() .clipped()
.onAppear { .onAppear {
// Auto-play the video when it appears
isPlaying = true isPlaying = true
} }
.onDisappear {
isPlaying = false
player?.pause()
}
.onTapGesture { .onTapGesture {
withAnimation { withAnimation {
showControls.toggle() showControls.toggle()
} }
} }
.fullScreenCover(isPresented: $isFullscreen) { .fullScreenCover(isPresented: $isFullscreen) {
FullscreenMediaView(media: media, isPresented: $isFullscreen, isPlaying: $isPlaying, player: nil) FullscreenMediaView(media: media, isPresented: $isFullscreen, isPlaying: $isPlaying, player: player)
} }
.overlay(
showControls ? VideoControls(
isPlaying: $isPlaying,
onClose: { isFullscreen = false }
) : nil
)
} }
VStack(alignment: .leading, spacing: 8) { if let description = description, !description.isEmpty {
VStack(alignment: .leading, spacing: 2) {
if let description = description, !description.isEmpty { Text("Description")
VStack(alignment: .leading, spacing: 2) { .font(Typography.font(for: .body, family: .quicksandBold))
Text("Description") .foregroundColor(.themeTextMessageMain)
.font(Typography.font(for: .body, family: .quicksandBold)) Text(description)
.foregroundColor(.themeTextMessageMain) .font(.system(size: 12))
Text(description) .foregroundColor(Color.themeTextMessageMain)
.font(.system(size: 12)) .fixedSize(horizontal: false, vertical: true)
.foregroundColor(Color.themeTextMessageMain)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.horizontal, 12)
.padding(.bottom, 12)
} }
.padding(.horizontal, 12)
.padding(.bottom, 12)
} }
.frame(maxWidth: .infinity, alignment: .leading)
} }
.padding(.top, 8) .padding(.top, 8)
} }
@ -136,12 +126,13 @@ struct BlindOutcomeView: View {
.padding(.bottom, 20) .padding(.bottom, 20)
} }
.padding(.horizontal) .padding(.horizontal)
Spacer() Spacer()
// Button at bottom // Button at bottom
VStack { VStack {
Spacer() Spacer()
Button(action: { Button(action: {
// video
if case .video = media { if case .video = media {
withAnimation { withAnimation {
showIPListModal = true showIPListModal = true
@ -162,22 +153,20 @@ struct BlindOutcomeView: View {
} }
.padding(.bottom, 20) .padding(.bottom, 20)
} }
.onDisappear {
// Clean up video player when view disappears
if case .video = media {
isPlaying = false
}
}
} }
.navigationBarHidden(true) // .navigationBarHidden(true)
.navigationBarBackButtonHidden(true) // .navigationBarBackButtonHidden(true)
.statusBar(hidden: isFullscreen) .statusBar(hidden: isFullscreen)
} }
.navigationViewStyle(StackNavigationViewStyle()) // iPad .navigationViewStyle(StackNavigationViewStyle())
.navigationBarHidden(true) // .navigationBarHidden(true)
.overlay( .overlay(
JoinModal(isPresented: $showIPListModal) JoinModal(isPresented: $showIPListModal)
) )
.onDisappear {
player?.pause()
player = nil
}
} }
} }
@ -187,22 +176,19 @@ private struct FullscreenMediaView: View {
@Binding var isPresented: Bool @Binding var isPresented: Bool
@Binding var isPlaying: Bool @Binding var isPlaying: Bool
@State private var showControls = true @State private var showControls = true
@State private var player: AVPlayer? private let player: AVPlayer?
init(media: MediaType, isPresented: Binding<Bool>, isPlaying: Binding<Bool>, player: AVPlayer?) { init(media: MediaType, isPresented: Binding<Bool>, isPlaying: Binding<Bool>, player: AVPlayer?) {
self.media = media self.media = media
self._isPresented = isPresented self._isPresented = isPresented
self._isPlaying = isPlaying self._isPlaying = isPlaying
if let player = player { self.player = player
self._player = State(initialValue: player)
}
} }
var body: some View { var body: some View {
ZStack { ZStack {
Color.black.edgesIgnoringSafeArea(.all) Color.black.edgesIgnoringSafeArea(.all)
// Media content
ZStack { ZStack {
switch media { switch media {
case .image(let uiImage): case .image(let uiImage):
@ -216,25 +202,21 @@ private struct FullscreenMediaView: View {
} }
} }
case .video(let url, _): case .video(_, _):
VideoPlayerView(url: url, isPlaying: $isPlaying) if let player = player {
.frame(maxWidth: .infinity, maxHeight: .infinity) CustomVideoPlayer(player: player)
.onTapGesture { .onAppear {
withAnimation { player.play()
showControls.toggle() isPlaying = true
} }
} .onDisappear {
.overlay( player.pause()
showControls ? VideoControls( isPlaying = false
isPlaying: $isPlaying, }
onClose: { isPresented = false } }
) : nil
)
} }
} }
.frame(maxWidth: .infinity, maxHeight: .infinity)
// Close button (always visible)
VStack { VStack {
HStack { HStack {
Button(action: { isPresented = false }) { Button(action: { isPresented = false }) {
@ -251,42 +233,22 @@ private struct FullscreenMediaView: View {
Spacer() Spacer()
} }
} }
.onAppear {
if case .video = media {
if isPlaying {
// player?.play()
}
}
}
.onDisappear { .onDisappear {
if case .video = media { player?.pause()
// player?.pause()
// player?.replaceCurrentItem(with: nil)
// player = nil
}
} }
} }
} }
// MARK: - Video Controls // MARK: - Video Player View
private struct VideoControls: View {
@Binding var isPlaying: Bool
let onClose: () -> Void
var body: some View {
// Empty view - no controls shown
EmptyView()
}
}
// MARK: - Video Player with Dynamic Aspect Ratio
struct VideoPlayerView: UIViewRepresentable { struct VideoPlayerView: UIViewRepresentable {
let url: URL let url: URL
@Binding var isPlaying: Bool @Binding var isPlaying: Bool
@Binding var player: AVPlayer?
func makeUIView(context: Context) -> PlayerView { func makeUIView(context: Context) -> PlayerView {
let view = PlayerView() let view = PlayerView()
view.setupPlayer(url: url) let player = view.setupPlayer(url: url)
self.player = player
return view return view
} }
@ -299,39 +261,56 @@ struct VideoPlayerView: UIViewRepresentable {
} }
} }
// MARK: - Custom Video Player
@available(iOS 14.0, *)
struct CustomVideoPlayer: UIViewControllerRepresentable {
let player: AVPlayer
func makeUIViewController(context: Context) -> AVPlayerViewController {
let controller = AVPlayerViewController()
controller.player = player
controller.showsPlaybackControls = false
controller.videoGravity = .resizeAspect
return controller
}
func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {
uiViewController.player = player
}
}
// MARK: - Player View
class PlayerView: UIView { class PlayerView: UIView {
private var player: AVPlayer? private var player: AVPlayer?
private var playerLayer: AVPlayerLayer? private var playerLayer: AVPlayerLayer?
private var playerItem: AVPlayerItem? private var playerItem: AVPlayerItem?
private var playerItemObserver: NSKeyValueObservation? private var playerItemObserver: NSKeyValueObservation?
func setupPlayer(url: URL) { @discardableResult
// Clean up existing resources func setupPlayer(url: URL) -> AVPlayer {
cleanup() cleanup()
// Create new player
let asset = AVAsset(url: url) let asset = AVAsset(url: url)
let playerItem = AVPlayerItem(asset: asset) let playerItem = AVPlayerItem(asset: asset)
self.playerItem = playerItem self.playerItem = playerItem
player = AVPlayer(playerItem: playerItem) player = AVPlayer(playerItem: playerItem)
// Setup player layer
let playerLayer = AVPlayerLayer(player: player) let playerLayer = AVPlayerLayer(player: player)
playerLayer.videoGravity = .resizeAspect playerLayer.videoGravity = .resizeAspect
layer.addSublayer(playerLayer) layer.addSublayer(playerLayer)
self.playerLayer = playerLayer self.playerLayer = playerLayer
// Layout
playerLayer.frame = bounds playerLayer.frame = bounds
// Add observer for video end
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
self, self,
selector: #selector(playerItemDidReachEnd), selector: #selector(playerItemDidReachEnd),
name: .AVPlayerItemDidPlayToEndTime, name: .AVPlayerItemDidPlayToEndTime,
object: playerItem object: playerItem
) )
return player!
} }
func play() { func play() {
@ -343,21 +322,17 @@ class PlayerView: UIView {
} }
private func cleanup() { private func cleanup() {
// Remove observers
if let playerItem = playerItem { if let playerItem = playerItem {
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: playerItem) NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: playerItem)
} }
// Pause and clean up player
player?.pause() player?.pause()
player?.replaceCurrentItem(with: nil) player?.replaceCurrentItem(with: nil)
player = nil player = nil
// Remove player layer
playerLayer?.removeFromSuperlayer() playerLayer?.removeFromSuperlayer()
playerLayer = nil playerLayer = nil
// Release player item
playerItem?.cancelPendingSeeks() playerItem?.cancelPendingSeeks()
playerItem?.asset.cancelLoading() playerItem?.asset.cancelLoading()
playerItem = nil playerItem = nil
@ -377,24 +352,3 @@ class PlayerView: UIView {
cleanup() cleanup()
} }
} }
// MARK: - Preview
struct BlindOutcomeView_Previews: PreviewProvider {
static var previews: some View {
// Preview with image and details
BlindOutcomeView(
media: .image(UIImage(systemName: "photo")!),
time: "2:30",
description: "This is a sample description for the preview. It shows how the text will wrap and display below the media content."
)
// Preview with video and details
if let url = URL(string: "https://example.com/sample.mp4") {
BlindOutcomeView(
media: .video(url, nil),
time: "1:45",
description: "Video content with time and description"
)
}
}
}

View File

@ -22,7 +22,7 @@ struct JoinModal: View {
// IP Image peeking from top // IP Image peeking from top
HStack { HStack {
// Make sure you have an image named "IP" in your assets // Make sure you have an image named "IP" in your assets
SVGImage(svgName: "IP1") SVGImageHtml(svgName: "IP1")
.frame(width: 116, height: 65) .frame(width: 116, height: 65)
.offset(x: 30) .offset(x: 30)
Spacer() Spacer()

View File

@ -8,7 +8,7 @@ struct SlideInModal<Content: View>: View {
// - // -
private let animation = Animation.spring( private let animation = Animation.spring(
response: 0.8, // 使 response: 0.8, // 使
dampingFraction: 0.6, // 使 dampingFraction: 1, // 使
blendDuration: 0.8 // 使 blendDuration: 0.8 // 使
) )
@ -28,21 +28,35 @@ struct SlideInModal<Content: View>: View {
} }
} }
// //
VStack(spacing: 0) { ZStack(alignment: .leading) {
// //
Color.clear VStack(spacing: 0) {
.frame(height: UIApplication.shared.windows.first?.safeAreaInsets.top ?? 0) //
Color.clear
.frame(height: UIApplication.shared.windows.first?.safeAreaInsets.top ?? 0)
// //
content() content()
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.bottom, UIApplication.shared.windows.first?.safeAreaInsets.bottom ?? 0) .padding(.bottom, UIApplication.shared.windows.first?.safeAreaInsets.bottom ?? 0)
}
.frame(width: UIScreen.main.bounds.width * 0.8)
.frame(maxHeight: .infinity)
.background(Color(.systemBackground))
.edgesIgnoringSafeArea(.vertical)
} }
.frame(width: UIScreen.main.bounds.width * 0.8) //
.frame(maxHeight: .infinity) .background(
.background(Color(.systemBackground)) RoundedRectangle(cornerRadius: 0)
.edgesIgnoringSafeArea(.vertical) .fill(Color(.systemBackground))
.shadow(
color: .black.opacity(0.3),
radius: 10,
x: 5,
y: 0
)
)
.offset(x: isPresented ? 0 : -UIScreen.main.bounds.width) .offset(x: isPresented ? 0 : -UIScreen.main.bounds.width)
.zIndex(2) .zIndex(2)
.transition(.move(edge: .leading)) .transition(.move(edge: .leading))

View File

@ -305,8 +305,8 @@ public class ImageUploaderGetID: ObservableObject {
} }
print("✅ 成功获取上传URL") print("✅ 成功获取上传URL")
print(" - 文件ID: \(fileId)") print(" ❗️❗️❗️❗️❗️❗️❗️❗️❗️❗️❗️❗️❗️❗️❗️❗️❗️❗️❗️ - 文件ID: \(fileId)")
print(" - 上传URL: \(uploadURLString)") print(" - 上传URL: \(uploadURLString)")
completion(.success((fileId: fileId, uploadURL: uploadURL))) completion(.success((fileId: fileId, uploadURL: uploadURL)))
} catch { } catch {

View File

@ -25,10 +25,20 @@ struct APIResponse<T: Codable>: Codable {
struct UserProfileModal: View { struct UserProfileModal: View {
@Binding var showModal: Bool @Binding var showModal: Bool
@Binding var showSettings: Bool @Binding var showSettings: Bool
@Binding var isMember: Bool
@Binding var memberDate: String
@State private var userProfile: UserProfile? @State private var userProfile: UserProfile?
@State private var isLoading = false @State private var isLoading = false
@State private var errorMessage: String? @State private var errorMessage: String?
@State private var isCopied = false @State private var isCopied = false
@State private var isContentReady = false
init(showModal: Binding<Bool>, showSettings: Binding<Bool>, isMember: Binding<Bool>, memberDate: Binding<String>) {
self._showModal = showModal
self._showSettings = showSettings
self._isMember = isMember
self._memberDate = memberDate
}
var body: some View { var body: some View {
VStack(spacing: 20) { VStack(spacing: 20) {
@ -42,10 +52,35 @@ struct UserProfileModal: View {
Text(error) Text(error)
.foregroundColor(.red) .foregroundColor(.red)
.padding() .padding()
} else if isContentReady, let userProfile = userProfile {
userProfileView(userProfile: userProfile)
.opacity(isContentReady ? 1 : 0)
.animation(.easeInOut(duration: 0.3), value: isContentReady)
} else {
// Empty view with same dimensions to prevent layout shifts
Color.clear
.frame(height: UIScreen.main.bounds.height * 0.7)
} }
}
.frame(width: UIScreen.main.bounds.width * 0.8)
.background(Color.themeTextWhiteSecondary)
.edgesIgnoringSafeArea(.all)
.onAppear {
fetchUserInfo()
// Delay content appearance to sync with modal animation
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
withAnimation {
isContentReady = true
}
}
}
}
@ViewBuilder
private func userProfileView(userProfile: UserProfile) -> some View {
VStack(spacing: 20) {
HStack(alignment: .center, spacing: 16) { HStack(alignment: .center, spacing: 16) {
if let avatarUrl = userProfile?.avatarUrl, !avatarUrl.isEmpty, let url = URL(string: avatarUrl) { if let avatarUrl = userProfile.avatarUrl, !avatarUrl.isEmpty, let url = URL(string: avatarUrl) {
AsyncImage(url: url) { phase in AsyncImage(url: url) { phase in
switch phase { switch phase {
case .success(let image): case .success(let image):
@ -62,6 +97,9 @@ struct UserProfileModal: View {
.foregroundColor(.blue) .foregroundColor(.blue)
} }
} }
.onTapGesture {
Router.shared.navigate(to: .userInfo)
}
} else { } else {
Image(systemName: "person.circle.fill") Image(systemName: "person.circle.fill")
.resizable() .resizable()
@ -71,12 +109,12 @@ struct UserProfileModal: View {
} }
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
Text(userProfile?.nickname ?? "Name") Text(userProfile.nickname)
.font(Typography.font(for: .body)) .font(Typography.font(for: .body))
.fontWeight(.bold) .fontWeight(.bold)
.foregroundColor(.themeTextMessageMain) .foregroundColor(.themeTextMessageMain)
HStack(spacing: 4) { HStack(spacing: 4) {
Text("ID: \(userProfile?.userId ?? "")") Text("ID: \(userProfile.userId)")
.font(.system(size: 14)) .font(.system(size: 14))
.foregroundColor(.themeTextMessageMain) .foregroundColor(.themeTextMessageMain)
.lineLimit(1) .lineLimit(1)
@ -84,21 +122,7 @@ struct UserProfileModal: View {
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: 120) .frame(maxWidth: 120)
Button(action: { Button(action: copyUserId) {
print("Copy ID button tapped")
UIPasteboard.general.string = userProfile?.userId
print("Copied to clipboard:", userProfile?.userId ?? "nil")
withAnimation {
isCopied = true
// Reset after 2 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation {
isCopied = false
print("Reset copy button state")
}
}
}
}) {
if isCopied { if isCopied {
Image(systemName: "checkmark") Image(systemName: "checkmark")
.foregroundColor(.themePrimary) .foregroundColor(.themePrimary)
@ -109,8 +133,8 @@ struct UserProfileModal: View {
.font(.system(size: 12)) .font(.system(size: 12))
.foregroundColor(.themeTextMessageMain) .foregroundColor(.themeTextMessageMain)
.animation(.easeInOut, value: isCopied) .animation(.easeInOut, value: isCopied)
.contentShape(Rectangle()) // Make the entire button area tappable .contentShape(Rectangle())
.frame(width: 24, height: 24) // Ensure minimum touch target size .frame(width: 24, height: 24)
} }
} }
@ -124,43 +148,8 @@ struct UserProfileModal: View {
) )
.padding(.horizontal) .padding(.horizontal)
Button(action: { //
Router.shared.navigate(to: .subscribe) currentSubscriptionCard
}) {
ZStack(alignment: .center) {
// SVG -
SVGImage(svgName: "Pioneer")
.scaledToFill()
.frame(maxWidth: .infinity, maxHeight: .infinity)
//
VStack(alignment: .leading, spacing: 6) {
Text("Pioneer")
.font(Typography.font(for: .title3))
.fontWeight(.bold)
.foregroundColor(.themeTextMessageMain)
.padding(.top, 6)
Text("Expires on :")
.font(.system(size: 12))
.foregroundColor(.themeTextMessageMain)
.padding(.top, 2)
Text("March 15, 2025")
.font(.system(size: 12))
.fontWeight(.bold)
.foregroundColor(.themeTextMessageMain)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.leading, 32)
// 使
.padding(.vertical, 12)
}
.frame(height: 112)
.frame(maxWidth: .infinity)
.cornerRadius(16)
.clipped() //
}
VStack(spacing: 12) { VStack(spacing: 12) {
// upload // upload
@ -208,26 +197,26 @@ struct UserProfileModal: View {
.buttonStyle(PlainButtonStyle()) .buttonStyle(PlainButtonStyle())
// Box // Box
Button(action: { // Button(action: {
Router.shared.navigate(to: .mediaUpload) // Router.shared.navigate(to: .mediaUpload)
}) { // }) {
HStack(spacing: 16) { // HStack(spacing: 16) {
SVGImage(svgName: "Box") // SVGImage(svgName: "Box")
.foregroundColor(.orange) // .foregroundColor(.orange)
.frame(width: 20, height: 20) // .frame(width: 20, height: 20)
Text("My Blind Box") // Text("My Blind Box")
.font(Typography.font(for: .body)) // .font(Typography.font(for: .body))
.fontWeight(.bold) // .fontWeight(.bold)
.foregroundColor(.themeTextMessageMain) // .foregroundColor(.themeTextMessageMain)
Spacer() // Spacer()
} // }
.padding() // .padding()
.cornerRadius(10) // .cornerRadius(10)
.contentShape(Rectangle()) // .contentShape(Rectangle())
} // }
.buttonStyle(PlainButtonStyle()) // .buttonStyle(PlainButtonStyle())
// setting // setting
Button(action: { Button(action: {
@ -262,14 +251,44 @@ struct UserProfileModal: View {
.padding(.horizontal) .padding(.horizontal)
Spacer() Spacer()
} }
.frame(width: UIScreen.main.bounds.width * 0.8) }
.background(Color.themeTextWhiteSecondary)
.edgesIgnoringSafeArea(.all) private func copyUserId() {
.onAppear { UIPasteboard.general.string = userProfile?.userId
fetchUserInfo() withAnimation {
isCopied = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation {
isCopied = false
}
}
} }
} }
// MARK: -
private var currentSubscriptionCard: some View {
let status: SubscriptionStatus = {
if isMember {
let dateFormatter = ISO8601DateFormatter()
dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let expiryDate = dateFormatter.date(from: memberDate) ?? Date()
return .pioneer(expiryDate: expiryDate)
} else {
return .free
}
}()
return SubscriptionStatusBar(
status: status,
height: 112,
onSubscribeTap: {
//
Router.shared.navigate(to: .subscribe)
}
)
.padding(.horizontal, Theme.Spacing.xl)
}
private func fetchUserInfo() { private func fetchUserInfo() {
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
@ -295,5 +314,5 @@ struct UserProfileModal: View {
} }
#Preview { #Preview {
UserProfileModal(showModal: .constant(true), showSettings: .constant(false)) UserProfileModal(showModal: .constant(true), showSettings: .constant(false), isMember: .constant(true), memberDate: .constant(""))
} }

View File

@ -32,8 +32,8 @@ struct CreditsInfoCard: View {
mainCreditsSection mainCreditsSection
} }
.buttonStyle(PlainButtonStyle()) .buttonStyle(PlainButtonStyle())
.background(Theme.Colors.primaryLight) .background(Color.themeTextWhite)
.cornerRadius(Theme.CornerRadius.extraLarge) .cornerRadius(Theme.CornerRadius.round)
// .shadow(color: Theme.Shadows.small, radius: Theme.Shadows.cardShadow.radius, x: Theme.Shadows.cardShadow.x, y: Theme.Shadows.cardShadow.y) // .shadow(color: Theme.Shadows.small, radius: Theme.Shadows.cardShadow.radius, x: Theme.Shadows.cardShadow.x, y: Theme.Shadows.cardShadow.y)
} }

View File

@ -1,5 +1,6 @@
import SwiftUI import SwiftUI
import AVKit import AVKit
import WaterfallGrid
// MARK: - API Response Models // MARK: - API Response Models
struct MaterialResponse: Decodable { struct MaterialResponse: Decodable {
@ -27,7 +28,7 @@ struct MemoryItem: Identifiable, Decodable {
var title: String { name ?? "Untitled" } var title: String { name ?? "Untitled" }
var subtitle: String { description ?? "" } var subtitle: String { description ?? "" }
var mediaType: MemoryMediaType { var mediaType: MemoryMediaType {
let url = fileInfo.url.lowercased() let url = fileInfo.fileName.lowercased()
if url.hasSuffix(".mp4") || url.hasSuffix(".mov") { if url.hasSuffix(".mp4") || url.hasSuffix(".mov") {
return .video(url: fileInfo.url, previewUrl: previewFileInfo.url) return .video(url: fileInfo.url, previewUrl: previewFileInfo.url)
} else { } else {
@ -98,30 +99,17 @@ struct MemoriesView: View {
ZStack { ZStack {
Color.themeTextWhiteSecondary.ignoresSafeArea() Color.themeTextWhiteSecondary.ignoresSafeArea()
Group { ScrollView {
if isLoading { WaterfallGrid(memories) { memory in
ProgressView() MemoryCard(memory: memory)
.scaleEffect(1.5) .onTapGesture {
} else if let error = errorMessage { withAnimation(.spring()) {
Text("Error: \(error)") selectedMemory = memory
.foregroundColor(.red)
} else {
ScrollView {
LazyVGrid(columns: columns, spacing: 4) {
ForEach(memories) { memory in
MemoryCard(memory: memory)
.padding(.horizontal, 2)
.onTapGesture {
withAnimation(.spring()) {
selectedMemory = memory
}
}
} }
} }
.padding(.top, 4)
.padding(.horizontal, 4)
}
} }
.padding(.horizontal, 8)
.padding(.vertical, 4)
} }
} }
} }
@ -172,7 +160,23 @@ struct FullScreenMediaView: View {
@State private var isVideoPlaying = false @State private var isVideoPlaying = false
@State private var showControls = true @State private var showControls = true
@State private var controlsTimer: Timer? = nil @State private var controlsTimer: Timer? = nil
@State private var player: AVPlayer? = nil @State private var imageAspectRatio: CGFloat = 1.0
@State private var isLoading = true
private func loadAspectRatio(from url: URL) {
guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil),
let imageProperties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any],
let width = imageProperties[kCGImagePropertyPixelWidth] as? CGFloat,
let height = imageProperties[kCGImagePropertyPixelHeight] as? CGFloat,
height > 0 else {
imageAspectRatio = 16/9 // Default to 16:9 if we can't determine the aspect ratio
isLoading = false
return
}
imageAspectRatio = width / height
isLoading = false
}
var body: some View { var body: some View {
ZStack { ZStack {
@ -182,46 +186,67 @@ struct FullScreenMediaView: View {
// Media content with back button overlay // Media content with back button overlay
ZStack { ZStack {
// Media content // Media content
switch memory.mediaType { GeometryReader { geometry in
case .image(let url):
if let imageURL = URL(string: url) { switch memory.mediaType {
AsyncImage(url: imageURL) { phase in case .image(let url):
switch phase { if let imageURL = URL(string: url) {
case .success(let image): AsyncImage(url: imageURL) { phase in
image switch phase {
.resizable() case .success(let image):
.scaledToFill() GeometryReader { geometry in
.frame(width: UIScreen.main.bounds.width, ZStack {
height: UIScreen.main.bounds.height) Color.black
.edgesIgnoringSafeArea(.all) image
case .failure(_): .resizable()
Image(systemName: "exclamationmark.triangle") .scaledToFit()
.foregroundColor(.red) .frame(
case .empty: width: min(geometry.size.width, geometry.size.height * imageAspectRatio),
ProgressView() height: min(geometry.size.height, geometry.size.width / imageAspectRatio)
@unknown default: )
EmptyView() }
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.onAppear {
if let uiImage = image.asUIImage() {
let size = uiImage.size
imageAspectRatio = size.width / size.height
isLoading = false
}
}
case .failure(_):
Image(systemName: "exclamationmark.triangle")
.foregroundColor(.red)
case .empty:
ProgressView()
@unknown default:
EmptyView()
}
} }
} }
}
case .video(let url, let previewUrl): case .video(_, let previewUrl):
if let videoURL = URL(string: url) { GeometryReader { geometry in
VideoPlayer(player: player) ZStack {
.onAppear { Color.clear
self.player = AVPlayer(url: videoURL) VideoPlayer(url: memory.mediaType.url, isPlaying: $isVideoPlaying)
self.player?.play() .aspectRatio(imageAspectRatio, contentMode: .fit)
self.isVideoPlaying = true .frame(
} width: min(geometry.size.width, geometry.size.height * imageAspectRatio),
.onDisappear { height: min(geometry.size.height, geometry.size.width / imageAspectRatio)
self.player?.pause() )
self.player = nil .onAppear {
} if let previewUrl = URL(string: previewUrl) {
.frame(width: UIScreen.main.bounds.width, loadAspectRatio(from: previewUrl)
height: UIScreen.main.bounds.height) }
.onTapGesture { isVideoPlaying = true
togglePlayPause() }
.onDisappear {
isVideoPlaying = false
}
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
}
} }
} }
@ -231,13 +256,14 @@ struct FullScreenMediaView: View {
Button(action: { Button(action: {
withAnimation(.spring()) { withAnimation(.spring()) {
isPresented = nil isPresented = nil
pauseVideo() // pauseVideo()
} }
}) { }) {
Image(systemName: "chevron.left") Image(systemName: "chevron.left")
.font(.system(size: 20, weight: .bold)) .font(.system(size: 20, weight: .bold))
.foregroundColor(.white) .foregroundColor(.white)
.padding(12) .padding(12)
.background(Circle().fill(Color.black.opacity(0.4)))
} }
.padding(.leading, 16) .padding(.leading, 16)
.padding(.top, UIApplication.shared.windows.first?.safeAreaInsets.top ?? 0) .padding(.top, UIApplication.shared.windows.first?.safeAreaInsets.top ?? 0)
@ -247,28 +273,6 @@ struct FullScreenMediaView: View {
Spacer() Spacer()
} }
.zIndex(2) // Higher z-index to keep it above media content .zIndex(2) // Higher z-index to keep it above media content
// Video controls overlay (only for video)
if case .video = memory.mediaType, showControls {
VStack {
Spacer()
// Play/pause button
Button(action: {
togglePlayPause()
}) {
Image(systemName: isVideoPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 70))
.foregroundColor(.white.opacity(0.9))
.shadow(radius: 3)
}
.padding(.bottom, 30)
}
.transition(.opacity)
.onAppear {
resetControlsTimer()
}
.zIndex(3) // Highest z-index for controls
}
} }
.ignoresSafeArea() .ignoresSafeArea()
.statusBar(hidden: true) .statusBar(hidden: true)
@ -276,120 +280,146 @@ struct FullScreenMediaView: View {
.onTapGesture { .onTapGesture {
if case .video = memory.mediaType { if case .video = memory.mediaType {
withAnimation(.easeInOut) { withAnimation(.easeInOut) {
showControls.toggle() // showControls.toggle()
}
if showControls {
resetControlsTimer()
} }
// if showControls {
// resetControlsTimer()
// }
} }
} }
.statusBar(hidden: true) .statusBar(hidden: true)
.onAppear { .onAppear {
UIApplication.shared.isIdleTimerDisabled = true UIApplication.shared.isIdleTimerDisabled = true
if case .video = memory.mediaType { if case .video = memory.mediaType {
setupVideoPlayer() // setupVideoPlayer()
} }
} }
.onDisappear { .onDisappear {
UIApplication.shared.isIdleTimerDisabled = false UIApplication.shared.isIdleTimerDisabled = false
controlsTimer?.invalidate() controlsTimer?.invalidate()
pauseVideo() // pauseVideo()
} }
} }
private func setupVideoPlayer() { // private func setupVideoPlayer() {
if case .video(let url, _) = memory.mediaType, let videoURL = URL(string: url) { // if case .video(let url, _) = memory.mediaType, let videoURL = URL(string: url) {
self.player = AVPlayer(url: videoURL) // // No need to set up player here
self.player?.play() // }
self.isVideoPlaying = true // }
// Add observer for playback end // private func togglePlayPause() {
NotificationCenter.default.addObserver( // if isVideoPlaying {
forName: .AVPlayerItemDidPlayToEndTime, // pauseVideo()
object: self.player?.currentItem, // } else {
queue: .main // playVideo()
) { _ in // }
self.player?.seek(to: .zero) { _ in // withAnimation {
self.player?.play() // showControls = true
} // }
} // resetControlsTimer()
} // }
}
private func togglePlayPause() { // private func playVideo() {
if isVideoPlaying { // // No need to play video here
pauseVideo() // }
} else {
playVideo()
}
withAnimation {
showControls = true
}
resetControlsTimer()
}
private func playVideo() { // private func pauseVideo() {
player?.play() // // No need to pause video here
isVideoPlaying = true // }
}
private func pauseVideo() { // private func resetControlsTimer() {
player?.pause() // controlsTimer?.invalidate()
isVideoPlaying = false // controlsTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { _ in
} // withAnimation(.easeInOut) {
// showControls = false
private func resetControlsTimer() { // }
controlsTimer?.invalidate() // }
controlsTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { _ in // }
withAnimation(.easeInOut) {
showControls = false
}
}
}
} }
struct VideoPlayer: UIViewRepresentable { struct VideoPlayer: UIViewControllerRepresentable {
let player: AVPlayer? let url: String
@Binding var isPlaying: Bool
func makeUIView(context: Context) -> UIView { func makeUIViewController(context: Context) -> AVPlayerViewController {
let view = UIView() let controller = AVPlayerViewController()
if let player = player { let player = AVPlayer(url: URL(string: url)!)
let playerLayer = AVPlayerLayer(player: player) controller.player = player
playerLayer.frame = UIScreen.main.bounds controller.showsPlaybackControls = true
playerLayer.videoGravity = .resizeAspectFill controller.videoGravity = .resizeAspect
view.layer.addSublayer(playerLayer)
} // Make the background transparent
return view controller.view.backgroundColor = .clear
controller.view.isOpaque = false
return controller
} }
func updateUIView(_ uiView: UIView, context: Context) { func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {
if let player = player, let playerLayer = uiView.layer.sublayers?.first as? AVPlayerLayer { if isPlaying {
playerLayer.player = player uiViewController.player?.play()
playerLayer.frame = UIScreen.main.bounds } else {
uiViewController.player?.pause()
} }
} }
} }
struct MemoryCard: View { struct MemoryCard: View {
let memory: MemoryItem let memory: MemoryItem
@State private var aspectRatio: CGFloat = 1.0
@State private var isLoading = true
private func loadAspectRatio(from url: URL) {
guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil),
let imageProperties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any],
let width = imageProperties[kCGImagePropertyPixelWidth] as? CGFloat,
let height = imageProperties[kCGImagePropertyPixelHeight] as? CGFloat,
height > 0 else {
aspectRatio = 16/9 // Default to 16:9 if we can't determine the aspect ratio
isLoading = false
return
}
aspectRatio = width / height
isLoading = false
}
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) {
ZStack { ZStack {
// Media content
Group { Group {
switch memory.mediaType { switch memory.mediaType {
case .image(let url): case .image(let url):
if let url = URL(string: url) { if let url = URL(string: url) {
AsyncImage(url: url) { phase in AsyncImage(url: url) { phase in
if let image = phase.image { Group {
image if let image = phase.image {
.resizable() GeometryReader { geometry in
.aspectRatio(contentMode: .fill) ZStack {
} else if phase.error != nil { Color.black
Color.gray.opacity(0.3) image
} else { .resizable()
ProgressView() .scaledToFit()
.frame(
width: min(geometry.size.width, geometry.size.height * aspectRatio),
height: min(geometry.size.height, geometry.size.width / aspectRatio)
)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.aspectRatio(aspectRatio, contentMode: aspectRatio > 1 ? .fit : .fill)
.onAppear {
if let uiImage = image.asUIImage() {
let size = uiImage.size
aspectRatio = size.width / size.height
isLoading = false
}
}
} else if phase.error != nil {
Color.gray.opacity(0.3)
} else {
ProgressView()
}
} }
} }
} }
@ -397,14 +427,19 @@ struct MemoryCard: View {
case .video(_, let previewUrl): case .video(_, let previewUrl):
if let previewUrl = URL(string: previewUrl) { if let previewUrl = URL(string: previewUrl) {
AsyncImage(url: previewUrl) { phase in AsyncImage(url: previewUrl) { phase in
if let image = phase.image { Group {
image if let image = phase.image {
.resizable() image
.aspectRatio(contentMode: .fill) .resizable()
} else if phase.error != nil { .aspectRatio(contentMode: .fill)
Color.gray.opacity(0.3) .onAppear {
} else { loadAspectRatio(from: previewUrl)
ProgressView() }
} else if phase.error != nil {
Color.gray.opacity(0.3)
} else {
ProgressView()
}
} }
} }
} else { } else {
@ -412,12 +447,13 @@ struct MemoryCard: View {
} }
} }
} }
.frame(width: (UIScreen.main.bounds.width / 2) - 24, .frame(
height: (UIScreen.main.bounds.width / 2 - 24) * (1/memory.aspectRatio)) width: (UIScreen.main.bounds.width / 2) - 24,
height: (UIScreen.main.bounds.width / 2 - 24) / (isLoading ? 1 : aspectRatio)
)
.clipped() .clipped()
.cornerRadius(12) .cornerRadius(12)
// Show play button for videos
if case .video = memory.mediaType { if case .video = memory.mediaType {
Image(systemName: "play.circle.fill") Image(systemName: "play.circle.fill")
.font(.system(size: 40)) .font(.system(size: 40))
@ -426,8 +462,7 @@ struct MemoryCard: View {
} }
} }
// Title and Subtitle VStack(alignment: .leading, spacing: 4) {
VStack(alignment: .leading, spacing: 16) {
Text(memory.title) Text(memory.title)
.font(Typography.font(for: .body, family: .quicksandBold)) .font(Typography.font(for: .body, family: .quicksandBold))
.foregroundColor(.themeTextMessageMain) .foregroundColor(.themeTextMessageMain)
@ -444,6 +479,40 @@ struct MemoryCard: View {
} }
} }
// Add this extension to get UIImage from Image
extension View {
func asUIImage() -> UIImage? {
let controller = UIHostingController(rootView: self)
let view = controller.view
let targetSize = controller.view.intrinsicContentSize
view?.bounds = CGRect(origin: .zero, size: targetSize)
view?.backgroundColor = .clear
let renderer = UIGraphicsImageRenderer(size: targetSize)
return renderer.image { _ in
view?.drawHierarchy(in: controller.view.bounds, afterScreenUpdates: true)
}
}
}
// Add this extension to MemoryMediaType to get the URL
private extension MemoryMediaType {
var isVideo: Bool {
if case .video = self { return true }
return false
}
var url: String {
switch self {
case .image(let url):
return url
case .video(let url, _):
return url
}
}
}
#Preview { #Preview {
MemoriesView() MemoriesView()
} }

View File

@ -50,18 +50,17 @@ public struct AvatarPicker: View {
if let selectedImage = selectedImage { if let selectedImage = selectedImage {
Image(uiImage: selectedImage) Image(uiImage: selectedImage)
.resizable() .resizable()
.scaledToFill() .aspectRatio(contentMode: .fill)
.frame(width: 225, height: 225) .frame(width: 225, height: 225)
.scaleEffect(scaleFactor) .clipShape(RoundedRectangle(cornerRadius: 20))
.clipShape(RoundedRectangle(cornerRadius: 20 * scaleFactor))
.overlay( .overlay(
RoundedRectangle(cornerRadius: 20) RoundedRectangle(cornerRadius: 20)
.stroke(Color.themePrimary, lineWidth: borderWidth) .stroke(Color.themePrimary, lineWidth: borderWidth)
.scaleEffect(scaleFactor)
) )
.scaleEffect(scaleFactor)
} else { } else {
// Default SVG avatar with animated dashed border // Default SVG avatar with animated dashed border
SVGImage(svgName: "IP") SVGImageHtml(svgName: "IP")
.frame(width: 225, height: 225) .frame(width: 225, height: 225)
.scaleEffect(scaleFactor) .scaleEffect(scaleFactor)
.contentShape(Rectangle()) .contentShape(Rectangle())

View File

@ -53,50 +53,50 @@ enum SubscriptionStatus {
struct SubscriptionStatusBar: View { struct SubscriptionStatusBar: View {
let status: SubscriptionStatus let status: SubscriptionStatus
let onSubscribeTap: (() -> Void)? let onSubscribeTap: (() -> Void)?
private let height: CGFloat
init(status: SubscriptionStatus, onSubscribeTap: (() -> Void)? = nil) { init(status: SubscriptionStatus, height: CGFloat? = nil, onSubscribeTap: (() -> Void)? = nil) {
self.status = status self.status = status
self.height = height ?? 155 // 155
self.onSubscribeTap = onSubscribeTap self.onSubscribeTap = onSubscribeTap
} }
var body: some View { var body: some View {
ZStack(alignment: .topLeading) { ZStack(alignment: .leading) {
// Background SVG - First layer // Background SVG - First layer
SVGImage(svgName: status.backgroundImageName, shouldFill: true) SVGImage(svgName: status.backgroundImageName)
.frame(maxWidth: .infinity, minHeight: 120) .frame(maxWidth: .infinity, minHeight: 120)
.clipped() .clipped()
// Content - Second layer // Main content container
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
Spacer() // Title - Centered vertically
// Subscription title
Text(status.title) Text(status.title)
.font(.system(size: 28, weight: .bold, design: .rounded)) .font(.system(size: 28, weight: .bold, design: .rounded))
.foregroundColor(status.textColor) .foregroundColor(status.textColor)
.padding(.leading, 24) .frame(maxHeight: .infinity, alignment: .center) // Center vertically
.padding(.leading, 12)
.padding(.top, height < 155 ? 30 : 40)
// Expiry date or subscribe button // Expiry date - Bottom left
if case .pioneer(let expiryDate) = status { if case .pioneer(let expiryDate) = status {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("Expires on:") Text("Expires on :")
.font(.system(size: 14, weight: .medium)) .font(.system(size: 12))
.foregroundColor(status.textColor.opacity(0.9)) .foregroundColor(.themeTextMessageMain)
Text(formatDate(expiryDate)) Text(formatDate(expiryDate))
.font(.system(size: 16, weight: .semibold)) .font(.system(size: 12))
.foregroundColor(status.textColor) .fontWeight(.bold)
.foregroundColor(.themeTextMessageMain)
} }
.padding(.leading, 24) .padding(.leading, 12)
.padding(.bottom, 20) .padding(.bottom, 12)
} }
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
} }
.frame(height: 155) .frame(height: height)
.frame(maxWidth: .infinity)
.cornerRadius(20)
.clipped()
} }
// MARK: - // MARK: -

View File

@ -7,6 +7,7 @@
import SwiftUI import SwiftUI
import StoreKit import StoreKit
import Network
// MARK: - // MARK: -
enum SubscriptionPlan: String, CaseIterable { enum SubscriptionPlan: String, CaseIterable {
@ -46,6 +47,7 @@ struct SubscribeView: View {
@State private var showErrorAlert = false @State private var showErrorAlert = false
@State private var errorText = "" @State private var errorText = ""
@State private var memberProfile: MemberProfile? @State private var memberProfile: MemberProfile?
@State private var showSuccessAlert = false
// //
private let features = [ private let features = [
@ -61,7 +63,6 @@ struct SubscribeView: View {
dismiss() dismiss()
} }
.background(Color.themeTextWhiteSecondary) .background(Color.themeTextWhiteSecondary)
.padding(.bottom, Theme.Spacing.lg)
ScrollView { ScrollView {
VStack(spacing: 0) { VStack(spacing: 0) {
@ -123,12 +124,17 @@ struct SubscribeView: View {
} message: { } message: {
Text(errorText) Text(errorText)
} }
.alert("Purchase Success", isPresented: $showSuccessAlert) {
Button("OK", role: .cancel) { }
} message: {
Text("购买成功!")
}
} }
// MARK: - // MARK: -
private var currentSubscriptionCard: some View { private var currentSubscriptionCard: some View {
let status: SubscriptionStatus = { let status: SubscriptionStatus = {
if memberProfile?.membershipLevel == "pioneer" { if memberProfile?.membershipLevel == "Pioneer" {
let dateFormatter = ISO8601DateFormatter() let dateFormatter = ISO8601DateFormatter()
dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let expiryDate = memberProfile.flatMap { dateFormatter.date(from: $0.membershipEndAt) } ?? Date() let expiryDate = memberProfile.flatMap { dateFormatter.date(from: $0.membershipEndAt) } ?? Date()
@ -139,7 +145,7 @@ struct SubscribeView: View {
}() }()
return SubscriptionStatusBar( return SubscriptionStatusBar(
status: .pioneer(expiryDate: Date()), status: status,
onSubscribeTap: { onSubscribeTap: {
// //
handleSubscribe() handleSubscribe()
@ -232,6 +238,9 @@ struct SubscribeView: View {
HStack(spacing: 8) { HStack(spacing: 8) {
Button(action: { Button(action: {
// //
if let url = URL(string: "https://memorywake.com/privacy-policy") {
UIApplication.shared.open(url)
}
}) { }) {
Text("Terms of Service") Text("Terms of Service")
.underline() .underline()
@ -242,6 +251,9 @@ struct SubscribeView: View {
Button(action: { Button(action: {
// //
if let url = URL(string: "https://memorywake.com/privacy-policy") {
UIApplication.shared.open(url)
}
}) { }) {
Text("Privacy Policy") Text("Privacy Policy")
.underline() .underline()
@ -250,13 +262,24 @@ struct SubscribeView: View {
Text("|") Text("|")
.foregroundColor(.secondary) .foregroundColor(.secondary)
// Button(action: {
// Task { await store.restorePurchases() }
// }) {
// Text("Restore Purchase")
// .underline()
// }
Button(action: { Button(action: {
Task { await store.restorePurchases() } //
}) { if let url = URL(string: "https://memorywake.com/privacy-policy") {
Text("Restore Purchase") UIApplication.shared.open(url)
.underline() }
}) {
Text("AI Usage Guidelines")
.underline()
}
} }
}
.font(Typography.font(for: .caption, family: .quicksandRegular)) .font(Typography.font(for: .caption, family: .quicksandRegular))
.foregroundColor(.secondary) .foregroundColor(.secondary)
.padding(.top, Theme.Spacing.sm) .padding(.top, Theme.Spacing.sm)
@ -264,7 +287,286 @@ struct SubscribeView: View {
// MARK: - // MARK: -
private func handleSubscribe() { private func handleSubscribe() {
Task { await store.purchasePioneer() } 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 // MARK: - Helper Methods

View File

@ -599,7 +599,7 @@ struct UploadPromptView: View {
var body: some View { var body: some View {
Button(action: { showMediaPicker = true }) { Button(action: { showMediaPicker = true }) {
// //
SVGImage(svgName: "IP") SVGImageHtml(svgName: "IP")
.frame(width: 225, height: 225) .frame(width: 225, height: 225)
.contentShape(Rectangle()) .contentShape(Rectangle())
.overlay( .overlay(