diff --git a/wake.xcodeproj/project.pbxproj b/wake.xcodeproj/project.pbxproj index 40f2a91..c302b59 100644 --- a/wake.xcodeproj/project.pbxproj +++ b/wake.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ AB6693CA2E65C94400BCAAC1 /* SVGKit in Frameworks */ = {isa = PBXBuildFile; productRef = AB6693C92E65C94400BCAAC1 /* SVGKit */; }; 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 */; }; ABC150C12E5DB39A00A1F970 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = ABC150C02E5DB39A00A1F970 /* Lottie */; }; ABE8998E2E533A7100CD7BA6 /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = ABE8998D2E533A7100CD7BA6 /* Alamofire */; }; @@ -62,6 +63,7 @@ AB6693CC2E65C94400BCAAC1 /* SVGKitSwift in Frameworks */, AB6693CA2E65C94400BCAAC1 /* SVGKit in Frameworks */, ABE8998E2E533A7100CD7BA6 /* Alamofire in Frameworks */, + AB6695272E67015600BCAAC1 /* WaterfallGrid in Frameworks */, ABC150C12E5DB39A00A1F970 /* Lottie in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -120,6 +122,7 @@ ABC150C02E5DB39A00A1F970 /* Lottie */, AB6693C92E65C94400BCAAC1 /* SVGKit */, AB6693CB2E65C94400BCAAC1 /* SVGKitSwift */, + AB6695262E67015600BCAAC1 /* WaterfallGrid */, ); productName = wake; productReference = ABB4E2082E4B75D900660198 /* wake.app */; @@ -153,6 +156,7 @@ ABE8998C2E533A7100CD7BA6 /* XCRemoteSwiftPackageReference "Alamofire" */, ABC150BF2E5DB39A00A1F970 /* XCRemoteSwiftPackageReference "lottie-spm" */, AB6693C82E65C94400BCAAC1 /* XCRemoteSwiftPackageReference "SVGKit" */, + AB6695252E67015600BCAAC1 /* XCRemoteSwiftPackageReference "WaterfallGrid" */, ); preferredProjectObjectVersion = 77; productRefGroup = ABB4E2092E4B75D900660198 /* Products */; @@ -409,6 +413,14 @@ 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" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/airbnb/lottie-spm.git"; @@ -438,6 +450,11 @@ package = AB6693C82E65C94400BCAAC1 /* XCRemoteSwiftPackageReference "SVGKit" */; productName = SVGKitSwift; }; + AB6695262E67015600BCAAC1 /* WaterfallGrid */ = { + isa = XCSwiftPackageProductDependency; + package = AB6695252E67015600BCAAC1 /* XCRemoteSwiftPackageReference "WaterfallGrid" */; + productName = WaterfallGrid; + }; ABC150C02E5DB39A00A1F970 /* Lottie */ = { isa = XCSwiftPackageProductDependency; package = ABC150BF2E5DB39A00A1F970 /* XCRemoteSwiftPackageReference "lottie-spm" */; diff --git a/wake.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/wake.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7ce85c2..39ee57b 100644 --- a/wake.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/wake.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,6 @@ { "originHash" : "d4b9379b4bd658fe79a6ae528c96d3386427dfe9d23635a65dad6edf12af85ff", + "originHash" : "7ea295cc5e3eb8ef644b89ce2b47a7600994b67c8582ee354b643cd63250740d", "pins" : [ { "identity" : "alamofire", @@ -45,6 +46,15 @@ "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2", "version" : "1.6.4" } + }, + { + "identity" : "waterfallgrid", + "kind" : "remoteSourceControl", + "location" : "https://github.com/paololeonardi/WaterfallGrid.git", + "state" : { + "revision" : "c7c08652c3540adf8e48409c351879b4caea7e89", + "version" : "1.1.0" + } } ], "version" : 3 diff --git a/wake/Assets/Png/logo.png b/wake/Assets/Png/logo.png new file mode 100644 index 0000000..fc79a01 Binary files /dev/null and b/wake/Assets/Png/logo.png differ diff --git a/wake/Components/Buttons/ReturnButton.swift b/wake/Components/Buttons/ReturnButton.swift index 2882b17..e91bb3a 100644 --- a/wake/Components/Buttons/ReturnButton.swift +++ b/wake/Components/Buttons/ReturnButton.swift @@ -20,6 +20,8 @@ struct ReturnButton: View { .font(Typography.font(for: iconSize)) .fontWeight(.medium) .foregroundColor(iconColor) + .padding(10) // 增加内边距来扩大点击区域 + .contentShape(Rectangle()) // 使整个区域可点击 } .buttonStyle(PlainButtonStyle()) } diff --git a/wake/ContentView.swift b/wake/ContentView.swift index cf44360..b6f24cb 100644 --- a/wake/ContentView.swift +++ b/wake/ContentView.swift @@ -182,6 +182,8 @@ struct BlindBoxView: View { let mediaType: BlindBoxMediaType @State private var showModal = false // 控制用户资料弹窗显示 @State private var showSettings = false // 控制设置页面显示 + @State private var isMember = false // 是否是会员 + @State private var memberDate = "" // 会员到期时间 @State private var showLogin = false @State private var memberProfile: MemberProfile? = nil @State private var blindCount: BlindCount? = nil @@ -275,6 +277,8 @@ struct BlindBoxView: View { switch result { case .success(let response): self.memberProfile = response.data + self.isMember = response.data.membershipLevel == "Pioneer" + self.memberDate = response.data.membershipEndAt ?? "" print("✅ 成功获取会员信息:", response.data) print("✅ 用户ID:", response.data.userInfo.userId) case .failure(let error): @@ -641,7 +645,10 @@ struct BlindBoxView: View { Button(action: showUserProfile) { SVGImage(svgName: "User") .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() // // 测试质感页面入口 @@ -705,13 +712,9 @@ struct BlindBoxView: View { ZStack { // 1. 背景SVG if !showScalingOverlay { - SVGImage(svgName: "BlindBg") - .frame( - width: UIScreen.main.bounds.width * 1.8, - height: UIScreen.main.bounds.height * 0.85 - ) - .position(x: UIScreen.main.bounds.width / 2, - y: UIScreen.main.bounds.height * 0.325) + SVGImage(svgName: "BlindBg", contentMode: .fit) + // .position(x: UIScreen.main.bounds.width / 2, + // y: UIScreen.main.bounds.height * 0.325) .opacity(showScalingOverlay ? 0 : 1) .animation(.easeOut(duration: 1.5), value: showScalingOverlay) } @@ -830,6 +833,7 @@ struct BlindBoxView: View { .offset(x: -10, y: UIScreen.main.bounds.height * 0.2) } } + .padding() .frame( maxWidth: .infinity, maxHeight: UIScreen.main.bounds.height * 0.65 @@ -897,10 +901,11 @@ struct BlindBoxView: View { ) { UserProfileModal( 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) .animation(.spring(response: 0.6, dampingFraction: 0.8), value: showSettings) diff --git a/wake/Models/OrderInfo.swift b/wake/Models/OrderInfo.swift new file mode 100644 index 0000000..7cbca93 --- /dev/null +++ b/wake/Models/OrderInfo.swift @@ -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 "已退款" + } + } +} diff --git a/wake/Utils/IAPManager.swift b/wake/Utils/IAPManager.swift index 27fb2c9..e9b8ae2 100644 --- a/wake/Utils/IAPManager.swift +++ b/wake/Utils/IAPManager.swift @@ -34,13 +34,12 @@ final class IAPManager: ObservableObject { } // Trigger App Store purchase sheet - func purchasePioneer() async { - guard !isPurchasing else { return } + func purchasePioneer() async throws -> String { + guard !isPurchasing else { throw NSError(domain: "IAPError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Purchase already in progress"]) } guard let product = pioneerProduct else { - // Surface an actionable error so the UI can inform the user - self.errorMessage = "Subscription product unavailable. Please try again later." - return + throw NSError(domain: "IAPError", code: -2, userInfo: [NSLocalizedDescriptionKey: "Subscription product unavailable"]) } + isPurchasing = true defer { isPurchasing = false } @@ -50,21 +49,26 @@ final class IAPManager: ObservableObject { case .success(let verification): switch verification { case .unverified(_, let error): - self.errorMessage = "Purchase unverified: \(error.localizedDescription)" - case .verified(let transaction): - // Update entitlement for the purchased product + throw error + case .verified(let transaction): + print("🎉 订阅成功!", transaction) + print("🔄 交易验证通过 - ID: \(transaction.id), 原始ID: \(transaction.originalID), 产品ID: \(transaction.productID)") updateEntitlement(from: transaction) + let transactionID = String(transaction.id) + print("📝 使用交易ID: \(transactionID)") await transaction.finish() + return transactionID } case .userCancelled: - break + throw NSError(domain: "IAPError", code: -3, userInfo: [NSLocalizedDescriptionKey: "Purchase was cancelled"]) case .pending: - break + throw NSError(domain: "IAPError", code: -4, userInfo: [NSLocalizedDescriptionKey: "Purchase is pending approval"]) @unknown default: - break + throw NSError(domain: "IAPError", code: -5, userInfo: [NSLocalizedDescriptionKey: "Unknown purchase result"]) } } catch { self.errorMessage = "Purchase failed: \(error.localizedDescription)" + throw error } } diff --git a/wake/Utils/SVGImage.swift b/wake/Utils/SVGImage.swift index 78ce50d..324bc52 100644 --- a/wake/Utils/SVGImage.swift +++ b/wake/Utils/SVGImage.swift @@ -1,92 +1,148 @@ import SwiftUI -import WebKit +import SVGKit struct SVGImage: UIViewRepresentable { let svgName: String - var shouldFill: Bool = false + var contentMode: ContentMode = .fit + var tintColor: Color? - func makeUIView(context: Context) -> WKWebView { - let webView = WKWebView() - 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 = """ - - - - - - - -
- -
- - - """ - - webView.loadHTMLString(htmlString, baseURL: fileURL.deletingLastPathComponent()) - return webView + private var svgPath: String { + return svgName } - func updateUIView(_ uiView: WKWebView, context: Context) {} + private func createImageView() -> SVGKFastImageView { + let emptySVGString = """ + + + + """ + + 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 sizeThatFits(_ proposal: ProposedViewSize, uiView: WKWebView, context: Context) -> CGSize? { + 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 } } +// MARK: - ContentMode +extension SVGImage { + enum ContentMode { + case fit // 保持宽高比,适应容器 + case fill // 保持宽高比,填充容器(可能被裁剪) + } +} + // MARK: - Preview #Preview { - VStack { - Text("Filled SVG") - SVGImage(svgName: "YourSVGName", shouldFill: true) - .frame(width: 200, height: 100) + VStack(spacing: 20) { + Text("IP SVG") + SVGImage(svgName: "IP") + .frame(width: 100, height: 100) .background(Color.gray.opacity(0.2)) + .border(Color.red, width: 1) - Text("Intrinsic Size SVG") - SVGImage(svgName: "YourSVGName", shouldFill: false) - .frame(width: 200, height: 100) + Text("Pioneer SVG") + SVGImage(svgName: "Pioneer", contentMode: .fill) + .frame(width: 100, height: 50) .background(Color.gray.opacity(0.2)) + .border(Color.blue, width: 1) + .clipped() } .padding() +} + +// MARK: - Color Extension +private extension Color { + var uiColor: UIColor { + return UIColor(self) + } } \ No newline at end of file diff --git a/wake/Utils/SVGImageHtml.swift b/wake/Utils/SVGImageHtml.swift new file mode 100644 index 0000000..1798b84 --- /dev/null +++ b/wake/Utils/SVGImageHtml.swift @@ -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 = """ + + + + + + + + \(svgString) + + + """ + + // 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) {} +} \ No newline at end of file diff --git a/wake/View/Blind/BlindOutCome.swift b/wake/View/Blind/BlindOutCome.swift index f4db1a5..cdd4bc9 100644 --- a/wake/View/Blind/BlindOutCome.swift +++ b/wake/View/Blind/BlindOutCome.swift @@ -2,7 +2,6 @@ import SwiftUI import AVKit import os.log -/// A view that displays either an image or a video with fullscreen support struct BlindOutcomeView: View { let media: MediaType let time: String? @@ -12,6 +11,7 @@ struct BlindOutcomeView: View { @State private var isPlaying = false @State private var showControls = true @State private var showIPListModal = false + @State private var player: AVPlayer? init(media: MediaType, time: String? = nil, description: String? = nil) { self.media = media @@ -28,7 +28,6 @@ struct BlindOutcomeView: View { // 自定义导航栏 HStack { Button(action: { - // 返回上一级 presentationMode.wrappedValue.dismiss() }) { HStack(spacing: 4) { @@ -47,7 +46,6 @@ struct BlindOutcomeView: View { Spacer() - // 占位,保持标题居中 HStack(spacing: 4) { Image(systemName: "chevron.left") .opacity(0) @@ -56,7 +54,7 @@ struct BlindOutcomeView: View { } .padding(.vertical, 12) .background(Color.themeTextWhiteSecondary) - .zIndex(1) // 确保导航栏在其他内容之上 + .zIndex(1) Spacer() .frame(height: 30) @@ -65,7 +63,6 @@ struct BlindOutcomeView: View { GeometryReader { geometry in VStack(spacing: 16) { ZStack { - // 添加白色背景 RoundedRectangle(cornerRadius: 12) .fill(Color.white) .shadow(color: Color.black.opacity(0.1), radius: 8, x: 0, y: 2) @@ -86,48 +83,41 @@ struct BlindOutcomeView: View { } case .video(let url, _): - VideoPlayerView(url: url, isPlaying: $isPlaying) + VideoPlayerView(url: url, isPlaying: $isPlaying, player: $player) .frame(width: UIScreen.main.bounds.width - 40) .background(Color.clear) .cornerRadius(10) .clipped() .onAppear { - // Auto-play the video when it appears isPlaying = true } + .onDisappear { + isPlaying = false + player?.pause() + } .onTapGesture { withAnimation { showControls.toggle() } } .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) { - Text("Description") - .font(Typography.font(for: .body, family: .quicksandBold)) - .foregroundColor(.themeTextMessageMain) - Text(description) - .font(.system(size: 12)) - .foregroundColor(Color.themeTextMessageMain) - .fixedSize(horizontal: false, vertical: true) - } - .padding(.horizontal, 12) - .padding(.bottom, 12) + if let description = description, !description.isEmpty { + VStack(alignment: .leading, spacing: 2) { + Text("Description") + .font(Typography.font(for: .body, family: .quicksandBold)) + .foregroundColor(.themeTextMessageMain) + Text(description) + .font(.system(size: 12)) + .foregroundColor(Color.themeTextMessageMain) + .fixedSize(horizontal: false, vertical: true) } + .padding(.horizontal, 12) + .padding(.bottom, 12) } - .frame(maxWidth: .infinity, alignment: .leading) } .padding(.top, 8) } @@ -136,12 +126,13 @@ struct BlindOutcomeView: View { .padding(.bottom, 20) } .padding(.horizontal) + Spacer() + // Button at bottom VStack { Spacer() Button(action: { - // 如果携带的类型是video显示弹窗 if case .video = media { withAnimation { showIPListModal = true @@ -162,22 +153,20 @@ struct BlindOutcomeView: View { } .padding(.bottom, 20) } - .onDisappear { - // Clean up video player when view disappears - if case .video = media { - isPlaying = false - } - } } - .navigationBarHidden(true) // 确保隐藏系统导航栏 - .navigationBarBackButtonHidden(true) // 确保隐藏系统返回按钮 + .navigationBarHidden(true) + .navigationBarBackButtonHidden(true) .statusBar(hidden: isFullscreen) } - .navigationViewStyle(StackNavigationViewStyle()) // 确保在iPad上也能正确显示 - .navigationBarHidden(true) // 额外确保隐藏导航栏 + .navigationViewStyle(StackNavigationViewStyle()) + .navigationBarHidden(true) .overlay( JoinModal(isPresented: $showIPListModal) ) + .onDisappear { + player?.pause() + player = nil + } } } @@ -187,22 +176,19 @@ private struct FullscreenMediaView: View { @Binding var isPresented: Bool @Binding var isPlaying: Bool @State private var showControls = true - @State private var player: AVPlayer? + private let player: AVPlayer? init(media: MediaType, isPresented: Binding, isPlaying: Binding, player: AVPlayer?) { self.media = media self._isPresented = isPresented self._isPlaying = isPlaying - if let player = player { - self._player = State(initialValue: player) - } + self.player = player } var body: some View { ZStack { Color.black.edgesIgnoringSafeArea(.all) - // Media content ZStack { switch media { case .image(let uiImage): @@ -216,25 +202,21 @@ private struct FullscreenMediaView: View { } } - case .video(let url, _): - VideoPlayerView(url: url, isPlaying: $isPlaying) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onTapGesture { - withAnimation { - showControls.toggle() + case .video(_, _): + if let player = player { + CustomVideoPlayer(player: player) + .onAppear { + player.play() + isPlaying = true } - } - .overlay( - showControls ? VideoControls( - isPlaying: $isPlaying, - onClose: { isPresented = false } - ) : nil - ) + .onDisappear { + player.pause() + isPlaying = false + } + } } } - .frame(maxWidth: .infinity, maxHeight: .infinity) - // Close button (always visible) VStack { HStack { Button(action: { isPresented = false }) { @@ -251,42 +233,22 @@ private struct FullscreenMediaView: View { Spacer() } } - .onAppear { - if case .video = media { - if isPlaying { - // player?.play() - } - } - } .onDisappear { - if case .video = media { - // player?.pause() - // player?.replaceCurrentItem(with: nil) - // player = nil - } + player?.pause() } } } -// MARK: - Video Controls -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 +// MARK: - Video Player View struct VideoPlayerView: UIViewRepresentable { let url: URL @Binding var isPlaying: Bool + @Binding var player: AVPlayer? func makeUIView(context: Context) -> PlayerView { let view = PlayerView() - view.setupPlayer(url: url) + let player = view.setupPlayer(url: url) + self.player = player 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 { private var player: AVPlayer? private var playerLayer: AVPlayerLayer? private var playerItem: AVPlayerItem? private var playerItemObserver: NSKeyValueObservation? - func setupPlayer(url: URL) { - // Clean up existing resources + @discardableResult + func setupPlayer(url: URL) -> AVPlayer { cleanup() - // Create new player let asset = AVAsset(url: url) let playerItem = AVPlayerItem(asset: asset) self.playerItem = playerItem player = AVPlayer(playerItem: playerItem) - // Setup player layer let playerLayer = AVPlayerLayer(player: player) playerLayer.videoGravity = .resizeAspect layer.addSublayer(playerLayer) self.playerLayer = playerLayer - // Layout playerLayer.frame = bounds - // Add observer for video end NotificationCenter.default.addObserver( self, selector: #selector(playerItemDidReachEnd), name: .AVPlayerItemDidPlayToEndTime, object: playerItem ) + + return player! } func play() { @@ -343,21 +322,17 @@ class PlayerView: UIView { } private func cleanup() { - // Remove observers if let playerItem = playerItem { NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: playerItem) } - // Pause and clean up player player?.pause() player?.replaceCurrentItem(with: nil) player = nil - // Remove player layer playerLayer?.removeFromSuperlayer() playerLayer = nil - // Release player item playerItem?.cancelPendingSeeks() playerItem?.asset.cancelLoading() playerItem = nil @@ -376,25 +351,4 @@ class PlayerView: UIView { deinit { 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" - ) - } - } -} +} \ No newline at end of file diff --git a/wake/View/Blind/JoinModal.swift b/wake/View/Blind/JoinModal.swift index 222faef..2a21e5c 100644 --- a/wake/View/Blind/JoinModal.swift +++ b/wake/View/Blind/JoinModal.swift @@ -22,7 +22,7 @@ struct JoinModal: View { // IP Image peeking from top HStack { // Make sure you have an image named "IP" in your assets - SVGImage(svgName: "IP1") + SVGImageHtml(svgName: "IP1") .frame(width: 116, height: 65) .offset(x: 30) Spacer() diff --git a/wake/View/Components/SheetModal.swift b/wake/View/Components/SheetModal.swift index d464001..c69e0b8 100644 --- a/wake/View/Components/SheetModal.swift +++ b/wake/View/Components/SheetModal.swift @@ -8,7 +8,7 @@ struct SlideInModal: View { // 动画配置 - 更慢的动画 private let animation = Animation.spring( response: 0.8, // 增加响应时间使动画更慢 - dampingFraction: 0.6, // 减少阻尼系数使弹跳更明显 + dampingFraction: 1, // 减少阻尼系数使弹跳更明显 blendDuration: 0.8 // 增加混合时间使过渡更平滑 ) @@ -28,21 +28,35 @@ struct SlideInModal: View { } } - // 弹窗内容 - VStack(spacing: 0) { - // 顶部安全区域占位 - Color.clear - .frame(height: UIApplication.shared.windows.first?.safeAreaInsets.top ?? 0) - - // 内容区域 - content() - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(.bottom, UIApplication.shared.windows.first?.safeAreaInsets.bottom ?? 0) + // 添加一个额外的容器来承载阴影 + ZStack(alignment: .leading) { + // 弹窗内容 + VStack(spacing: 0) { + // 顶部安全区域占位 + Color.clear + .frame(height: UIApplication.shared.windows.first?.safeAreaInsets.top ?? 0) + + // 内容区域 + content() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .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(Color(.systemBackground)) - .edgesIgnoringSafeArea(.vertical) + // 在这里应用阴影 + .background( + RoundedRectangle(cornerRadius: 0) + .fill(Color(.systemBackground)) + .shadow( + color: .black.opacity(0.3), + radius: 10, + x: 5, + y: 0 + ) + ) .offset(x: isPresented ? 0 : -UIScreen.main.bounds.width) .zIndex(2) .transition(.move(edge: .leading)) diff --git a/wake/View/Components/Upload/ImageUploaderGetID.swift b/wake/View/Components/Upload/ImageUploaderGetID.swift index 764a55f..dff7dbd 100644 --- a/wake/View/Components/Upload/ImageUploaderGetID.swift +++ b/wake/View/Components/Upload/ImageUploaderGetID.swift @@ -305,8 +305,8 @@ public class ImageUploaderGetID: ObservableObject { } print("✅ 成功获取上传URL") - print(" - 文件ID: \(fileId)") - print(" - 上传URL: \(uploadURLString)") + print(" ❗️❗️❗️❗️❗️❗️❗️❗️❗️❗️❗️❗️❗️❗️❗️❗️❗️❗️❗️ - 文件ID: \(fileId)") + print(" - 上传URL: \(uploadURLString)") completion(.success((fileId: fileId, uploadURL: uploadURL))) } catch { diff --git a/wake/View/Components/UserProfileModal.swift b/wake/View/Components/UserProfileModal.swift index c2345dd..9c5a23c 100644 --- a/wake/View/Components/UserProfileModal.swift +++ b/wake/View/Components/UserProfileModal.swift @@ -25,10 +25,20 @@ struct APIResponse: Codable { struct UserProfileModal: View { @Binding var showModal: Bool @Binding var showSettings: Bool + @Binding var isMember: Bool + @Binding var memberDate: String @State private var userProfile: UserProfile? @State private var isLoading = false @State private var errorMessage: String? @State private var isCopied = false + @State private var isContentReady = false + + init(showModal: Binding, showSettings: Binding, isMember: Binding, memberDate: Binding) { + self._showModal = showModal + self._showSettings = showSettings + self._isMember = isMember + self._memberDate = memberDate + } var body: some View { VStack(spacing: 20) { @@ -42,10 +52,35 @@ struct UserProfileModal: View { Text(error) .foregroundColor(.red) .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) { - 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 switch phase { case .success(let image): @@ -62,6 +97,9 @@ struct UserProfileModal: View { .foregroundColor(.blue) } } + .onTapGesture { + Router.shared.navigate(to: .userInfo) + } } else { Image(systemName: "person.circle.fill") .resizable() @@ -71,12 +109,12 @@ struct UserProfileModal: View { } VStack(alignment: .leading, spacing: 10) { - Text(userProfile?.nickname ?? "Name") + Text(userProfile.nickname) .font(Typography.font(for: .body)) .fontWeight(.bold) .foregroundColor(.themeTextMessageMain) HStack(spacing: 4) { - Text("ID: \(userProfile?.userId ?? "")") + Text("ID: \(userProfile.userId)") .font(.system(size: 14)) .foregroundColor(.themeTextMessageMain) .lineLimit(1) @@ -84,21 +122,7 @@ struct UserProfileModal: View { .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: 120) - Button(action: { - 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") - } - } - } - }) { + Button(action: copyUserId) { if isCopied { Image(systemName: "checkmark") .foregroundColor(.themePrimary) @@ -109,8 +133,8 @@ struct UserProfileModal: View { .font(.system(size: 12)) .foregroundColor(.themeTextMessageMain) .animation(.easeInOut, value: isCopied) - .contentShape(Rectangle()) // Make the entire button area tappable - .frame(width: 24, height: 24) // Ensure minimum touch target size + .contentShape(Rectangle()) + .frame(width: 24, height: 24) } } @@ -124,43 +148,8 @@ struct UserProfileModal: View { ) .padding(.horizontal) - Button(action: { - Router.shared.navigate(to: .subscribe) - }) { - 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() // 确保内容不会超出边界 - } + // 当前订阅状态卡片 + currentSubscriptionCard VStack(spacing: 12) { // upload @@ -208,26 +197,26 @@ struct UserProfileModal: View { .buttonStyle(PlainButtonStyle()) // Box - Button(action: { - Router.shared.navigate(to: .mediaUpload) - }) { - HStack(spacing: 16) { - SVGImage(svgName: "Box") - .foregroundColor(.orange) - .frame(width: 20, height: 20) + // Button(action: { + // Router.shared.navigate(to: .mediaUpload) + // }) { + // HStack(spacing: 16) { + // SVGImage(svgName: "Box") + // .foregroundColor(.orange) + // .frame(width: 20, height: 20) - Text("My Blind Box") - .font(Typography.font(for: .body)) - .fontWeight(.bold) - .foregroundColor(.themeTextMessageMain) + // Text("My Blind Box") + // .font(Typography.font(for: .body)) + // .fontWeight(.bold) + // .foregroundColor(.themeTextMessageMain) - Spacer() - } - .padding() - .cornerRadius(10) - .contentShape(Rectangle()) - } - .buttonStyle(PlainButtonStyle()) + // Spacer() + // } + // .padding() + // .cornerRadius(10) + // .contentShape(Rectangle()) + // } + // .buttonStyle(PlainButtonStyle()) // setting Button(action: { @@ -262,14 +251,44 @@ struct UserProfileModal: View { .padding(.horizontal) Spacer() } - .frame(width: UIScreen.main.bounds.width * 0.8) - .background(Color.themeTextWhiteSecondary) - .edgesIgnoringSafeArea(.all) - .onAppear { - fetchUserInfo() - } } + private func copyUserId() { + UIPasteboard.general.string = userProfile?.userId + 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() { isLoading = true errorMessage = nil @@ -295,5 +314,5 @@ struct UserProfileModal: View { } #Preview { - UserProfileModal(showModal: .constant(true), showSettings: .constant(false)) + UserProfileModal(showModal: .constant(true), showSettings: .constant(false), isMember: .constant(true), memberDate: .constant("")) } diff --git a/wake/View/Credits/CreditsInfoCard.swift b/wake/View/Credits/CreditsInfoCard.swift index c6654d2..509625b 100644 --- a/wake/View/Credits/CreditsInfoCard.swift +++ b/wake/View/Credits/CreditsInfoCard.swift @@ -32,8 +32,8 @@ struct CreditsInfoCard: View { mainCreditsSection } .buttonStyle(PlainButtonStyle()) - .background(Theme.Colors.primaryLight) - .cornerRadius(Theme.CornerRadius.extraLarge) + .background(Color.themeTextWhite) + .cornerRadius(Theme.CornerRadius.round) // .shadow(color: Theme.Shadows.small, radius: Theme.Shadows.cardShadow.radius, x: Theme.Shadows.cardShadow.x, y: Theme.Shadows.cardShadow.y) } diff --git a/wake/View/Memories/MemoriesView.swift b/wake/View/Memories/MemoriesView.swift index 194de72..cb2bde6 100644 --- a/wake/View/Memories/MemoriesView.swift +++ b/wake/View/Memories/MemoriesView.swift @@ -1,5 +1,6 @@ import SwiftUI import AVKit +import WaterfallGrid // MARK: - API Response Models struct MaterialResponse: Decodable { @@ -27,7 +28,7 @@ struct MemoryItem: Identifiable, Decodable { var title: String { name ?? "Untitled" } var subtitle: String { description ?? "" } var mediaType: MemoryMediaType { - let url = fileInfo.url.lowercased() + let url = fileInfo.fileName.lowercased() if url.hasSuffix(".mp4") || url.hasSuffix(".mov") { return .video(url: fileInfo.url, previewUrl: previewFileInfo.url) } else { @@ -98,30 +99,17 @@ struct MemoriesView: View { ZStack { Color.themeTextWhiteSecondary.ignoresSafeArea() - Group { - if isLoading { - ProgressView() - .scaleEffect(1.5) - } else if let error = errorMessage { - Text("Error: \(error)") - .foregroundColor(.red) - } else { - ScrollView { - LazyVGrid(columns: columns, spacing: 4) { - ForEach(memories) { memory in - MemoryCard(memory: memory) - .padding(.horizontal, 2) - .onTapGesture { - withAnimation(.spring()) { - selectedMemory = memory - } - } + ScrollView { + WaterfallGrid(memories) { memory in + MemoryCard(memory: memory) + .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 showControls = true @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 { ZStack { @@ -182,46 +186,67 @@ struct FullScreenMediaView: View { // Media content with back button overlay ZStack { // Media content - switch memory.mediaType { - case .image(let url): - if let imageURL = URL(string: url) { - AsyncImage(url: imageURL) { phase in - switch phase { - case .success(let image): - image - .resizable() - .scaledToFill() - .frame(width: UIScreen.main.bounds.width, - height: UIScreen.main.bounds.height) - .edgesIgnoringSafeArea(.all) - case .failure(_): - Image(systemName: "exclamationmark.triangle") - .foregroundColor(.red) - case .empty: - ProgressView() - @unknown default: - EmptyView() + GeometryReader { geometry in + + switch memory.mediaType { + case .image(let url): + if let imageURL = URL(string: url) { + AsyncImage(url: imageURL) { phase in + switch phase { + case .success(let image): + GeometryReader { geometry in + ZStack { + Color.black + image + .resizable() + .scaledToFit() + .frame( + width: min(geometry.size.width, geometry.size.height * imageAspectRatio), + height: min(geometry.size.height, geometry.size.width / imageAspectRatio) + ) + } + .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): - if let videoURL = URL(string: url) { - VideoPlayer(player: player) - .onAppear { - self.player = AVPlayer(url: videoURL) - self.player?.play() - self.isVideoPlaying = true - } - .onDisappear { - self.player?.pause() - self.player = nil - } - .frame(width: UIScreen.main.bounds.width, - height: UIScreen.main.bounds.height) - .onTapGesture { - togglePlayPause() + + case .video(_, let previewUrl): + GeometryReader { geometry in + ZStack { + Color.clear + VideoPlayer(url: memory.mediaType.url, isPlaying: $isVideoPlaying) + .aspectRatio(imageAspectRatio, contentMode: .fit) + .frame( + width: min(geometry.size.width, geometry.size.height * imageAspectRatio), + height: min(geometry.size.height, geometry.size.width / imageAspectRatio) + ) + .onAppear { + if let previewUrl = URL(string: previewUrl) { + loadAspectRatio(from: previewUrl) + } + isVideoPlaying = true + } + .onDisappear { + isVideoPlaying = false + } } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } } } @@ -231,13 +256,14 @@ struct FullScreenMediaView: View { Button(action: { withAnimation(.spring()) { isPresented = nil - pauseVideo() + // pauseVideo() } }) { Image(systemName: "chevron.left") .font(.system(size: 20, weight: .bold)) .foregroundColor(.white) .padding(12) + .background(Circle().fill(Color.black.opacity(0.4))) } .padding(.leading, 16) .padding(.top, UIApplication.shared.windows.first?.safeAreaInsets.top ?? 0) @@ -247,28 +273,6 @@ struct FullScreenMediaView: View { Spacer() } .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() .statusBar(hidden: true) @@ -276,120 +280,146 @@ struct FullScreenMediaView: View { .onTapGesture { if case .video = memory.mediaType { withAnimation(.easeInOut) { - showControls.toggle() - } - if showControls { - resetControlsTimer() + // showControls.toggle() } + // if showControls { + // resetControlsTimer() + // } } } .statusBar(hidden: true) .onAppear { UIApplication.shared.isIdleTimerDisabled = true if case .video = memory.mediaType { - setupVideoPlayer() + // setupVideoPlayer() } } .onDisappear { UIApplication.shared.isIdleTimerDisabled = false controlsTimer?.invalidate() - pauseVideo() + // pauseVideo() } } - private func setupVideoPlayer() { - if case .video(let url, _) = memory.mediaType, let videoURL = URL(string: url) { - self.player = AVPlayer(url: videoURL) - self.player?.play() - self.isVideoPlaying = true - - // Add observer for playback end - NotificationCenter.default.addObserver( - forName: .AVPlayerItemDidPlayToEndTime, - object: self.player?.currentItem, - queue: .main - ) { _ in - self.player?.seek(to: .zero) { _ in - self.player?.play() - } - } - } - } + // private func setupVideoPlayer() { + // if case .video(let url, _) = memory.mediaType, let videoURL = URL(string: url) { + // // No need to set up player here + // } + // } - private func togglePlayPause() { - if isVideoPlaying { - pauseVideo() - } else { - playVideo() - } - withAnimation { - showControls = true - } - resetControlsTimer() - } + // private func togglePlayPause() { + // if isVideoPlaying { + // pauseVideo() + // } else { + // playVideo() + // } + // withAnimation { + // showControls = true + // } + // resetControlsTimer() + // } - private func playVideo() { - player?.play() - isVideoPlaying = true - } + // private func playVideo() { + // // No need to play video here + // } - private func pauseVideo() { - player?.pause() - isVideoPlaying = false - } + // private func pauseVideo() { + // // No need to pause video here + // } - private func resetControlsTimer() { - controlsTimer?.invalidate() - 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 { - let player: AVPlayer? +struct VideoPlayer: UIViewControllerRepresentable { + let url: String + @Binding var isPlaying: Bool - func makeUIView(context: Context) -> UIView { - let view = UIView() - if let player = player { - let playerLayer = AVPlayerLayer(player: player) - playerLayer.frame = UIScreen.main.bounds - playerLayer.videoGravity = .resizeAspectFill - view.layer.addSublayer(playerLayer) - } - return view + func makeUIViewController(context: Context) -> AVPlayerViewController { + let controller = AVPlayerViewController() + let player = AVPlayer(url: URL(string: url)!) + controller.player = player + controller.showsPlaybackControls = true + controller.videoGravity = .resizeAspect + + // Make the background transparent + controller.view.backgroundColor = .clear + controller.view.isOpaque = false + + return controller } - func updateUIView(_ uiView: UIView, context: Context) { - if let player = player, let playerLayer = uiView.layer.sublayers?.first as? AVPlayerLayer { - playerLayer.player = player - playerLayer.frame = UIScreen.main.bounds + func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) { + if isPlaying { + uiViewController.player?.play() + } else { + uiViewController.player?.pause() } } } struct MemoryCard: View { 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 { - VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { ZStack { - // Media content Group { switch memory.mediaType { case .image(let url): if let url = URL(string: url) { AsyncImage(url: url) { phase in - if let image = phase.image { - image - .resizable() - .aspectRatio(contentMode: .fill) - } else if phase.error != nil { - Color.gray.opacity(0.3) - } else { - ProgressView() + Group { + if let image = phase.image { + GeometryReader { geometry in + ZStack { + Color.black + image + .resizable() + .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): if let previewUrl = URL(string: previewUrl) { AsyncImage(url: previewUrl) { phase in - if let image = phase.image { - image - .resizable() - .aspectRatio(contentMode: .fill) - } else if phase.error != nil { - Color.gray.opacity(0.3) - } else { - ProgressView() + Group { + if let image = phase.image { + image + .resizable() + .aspectRatio(contentMode: .fill) + .onAppear { + loadAspectRatio(from: previewUrl) + } + } else if phase.error != nil { + Color.gray.opacity(0.3) + } else { + ProgressView() + } } } } else { @@ -412,12 +447,13 @@ struct MemoryCard: View { } } } - .frame(width: (UIScreen.main.bounds.width / 2) - 24, - height: (UIScreen.main.bounds.width / 2 - 24) * (1/memory.aspectRatio)) + .frame( + width: (UIScreen.main.bounds.width / 2) - 24, + height: (UIScreen.main.bounds.width / 2 - 24) / (isLoading ? 1 : aspectRatio) + ) .clipped() .cornerRadius(12) - // Show play button for videos if case .video = memory.mediaType { Image(systemName: "play.circle.fill") .font(.system(size: 40)) @@ -426,8 +462,7 @@ struct MemoryCard: View { } } - // Title and Subtitle - VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 4) { Text(memory.title) .font(Typography.font(for: .body, family: .quicksandBold)) .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 { MemoriesView() } diff --git a/wake/View/Owner/UserInfo/AvatarPicker.swift b/wake/View/Owner/UserInfo/AvatarPicker.swift index 0b1d657..ed7dcd4 100644 --- a/wake/View/Owner/UserInfo/AvatarPicker.swift +++ b/wake/View/Owner/UserInfo/AvatarPicker.swift @@ -50,18 +50,17 @@ public struct AvatarPicker: View { if let selectedImage = selectedImage { Image(uiImage: selectedImage) .resizable() - .scaledToFill() + .aspectRatio(contentMode: .fill) .frame(width: 225, height: 225) - .scaleEffect(scaleFactor) - .clipShape(RoundedRectangle(cornerRadius: 20 * scaleFactor)) + .clipShape(RoundedRectangle(cornerRadius: 20)) .overlay( RoundedRectangle(cornerRadius: 20) .stroke(Color.themePrimary, lineWidth: borderWidth) - .scaleEffect(scaleFactor) ) + .scaleEffect(scaleFactor) } else { // Default SVG avatar with animated dashed border - SVGImage(svgName: "IP") + SVGImageHtml(svgName: "IP") .frame(width: 225, height: 225) .scaleEffect(scaleFactor) .contentShape(Rectangle()) diff --git a/wake/View/Subscribe/Components/SubscriptionStatusBar.swift b/wake/View/Subscribe/Components/SubscriptionStatusBar.swift index 8040ee4..8137093 100644 --- a/wake/View/Subscribe/Components/SubscriptionStatusBar.swift +++ b/wake/View/Subscribe/Components/SubscriptionStatusBar.swift @@ -53,50 +53,50 @@ enum SubscriptionStatus { struct SubscriptionStatusBar: View { let status: SubscriptionStatus 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.height = height ?? 155 // 默认高度为155 self.onSubscribeTap = onSubscribeTap } var body: some View { - ZStack(alignment: .topLeading) { + ZStack(alignment: .leading) { // Background SVG - First layer - SVGImage(svgName: status.backgroundImageName, shouldFill: true) + SVGImage(svgName: status.backgroundImageName) .frame(maxWidth: .infinity, minHeight: 120) .clipped() - // Content - Second layer + // Main content container VStack(alignment: .leading, spacing: 0) { - Spacer() - - // Subscription title + // Title - Centered vertically Text(status.title) .font(.system(size: 28, weight: .bold, design: .rounded)) .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 { VStack(alignment: .leading, spacing: 4) { - Text("Expires on:") - .font(.system(size: 14, weight: .medium)) - .foregroundColor(status.textColor.opacity(0.9)) + Text("Expires on :") + .font(.system(size: 12)) + .foregroundColor(.themeTextMessageMain) Text(formatDate(expiryDate)) - .font(.system(size: 16, weight: .semibold)) - .foregroundColor(status.textColor) + .font(.system(size: 12)) + .fontWeight(.bold) + .foregroundColor(.themeTextMessageMain) } - .padding(.leading, 24) - .padding(.bottom, 20) + .padding(.leading, 12) + .padding(.bottom, 12) } } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) } - .frame(height: 155) - .frame(maxWidth: .infinity) - .cornerRadius(20) - .clipped() + .frame(height: height) } // MARK: - 日期格式化 diff --git a/wake/View/Subscribe/SubscribeView.swift b/wake/View/Subscribe/SubscribeView.swift index 1c30a7a..02a886b 100644 --- a/wake/View/Subscribe/SubscribeView.swift +++ b/wake/View/Subscribe/SubscribeView.swift @@ -7,6 +7,7 @@ import SwiftUI import StoreKit +import Network // MARK: - 订阅计划枚举 enum SubscriptionPlan: String, CaseIterable { @@ -46,6 +47,7 @@ struct SubscribeView: View { @State private var showErrorAlert = false @State private var errorText = "" @State private var memberProfile: MemberProfile? + @State private var showSuccessAlert = false // 功能对比数据 private let features = [ @@ -61,7 +63,6 @@ struct SubscribeView: View { dismiss() } .background(Color.themeTextWhiteSecondary) - .padding(.bottom, Theme.Spacing.lg) ScrollView { VStack(spacing: 0) { @@ -123,12 +124,17 @@ struct SubscribeView: View { } 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" { + if memberProfile?.membershipLevel == "Pioneer" { let dateFormatter = ISO8601DateFormatter() dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] let expiryDate = memberProfile.flatMap { dateFormatter.date(from: $0.membershipEndAt) } ?? Date() @@ -139,7 +145,7 @@ struct SubscribeView: View { }() return SubscriptionStatusBar( - status: .pioneer(expiryDate: Date()), + status: status, onSubscribeTap: { // 订阅操作 handleSubscribe() @@ -232,6 +238,9 @@ struct SubscribeView: 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() @@ -242,6 +251,9 @@ struct SubscribeView: View { Button(action: { // 打开隐私政策 + if let url = URL(string: "https://memorywake.com/privacy-policy") { + UIApplication.shared.open(url) + } }) { Text("Privacy Policy") .underline() @@ -250,13 +262,24 @@ struct SubscribeView: View { Text("|") .foregroundColor(.secondary) + // Button(action: { + // Task { await store.restorePurchases() } + // }) { + // Text("Restore Purchase") + // .underline() + // } Button(action: { - Task { await store.restorePurchases() } - }) { - Text("Restore Purchase") - .underline() + // 打开隐私政策 + 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) @@ -264,7 +287,286 @@ struct SubscribeView: View { // MARK: - 订阅处理 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, 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, 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, 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, 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, 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 diff --git a/wake/View/Upload/MediaUploadView.swift b/wake/View/Upload/MediaUploadView.swift index 683d69e..ab0250c 100644 --- a/wake/View/Upload/MediaUploadView.swift +++ b/wake/View/Upload/MediaUploadView.swift @@ -599,7 +599,7 @@ struct UploadPromptView: View { var body: some View { Button(action: { showMediaPicker = true }) { // 上传图标 - SVGImage(svgName: "IP") + SVGImageHtml(svgName: "IP") .frame(width: 225, height: 225) .contentShape(Rectangle()) .overlay(