feat: bug
This commit is contained in:
parent
02cd217053
commit
538507f5ec
@ -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" */;
|
||||
|
||||
@ -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
|
||||
|
||||
BIN
wake/Assets/Png/logo.png
Normal file
BIN
wake/Assets/Png/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
@ -20,6 +20,8 @@ struct ReturnButton: View {
|
||||
.font(Typography.font(for: iconSize))
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(iconColor)
|
||||
.padding(10) // 增加内边距来扩大点击区域
|
||||
.contentShape(Rectangle()) // 使整个区域可点击
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
101
wake/Models/OrderInfo.swift
Normal file
101
wake/Models/OrderInfo.swift
Normal 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 "已退款"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)"
|
||||
throw error
|
||||
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)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
private var svgPath: String {
|
||||
return svgName
|
||||
}
|
||||
|
||||
let fileURL = URL(fileURLWithPath: path)
|
||||
|
||||
let svgStyle = shouldFill ? """
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
""" : """
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
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>
|
||||
"""
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: WKWebView, context: Context) {}
|
||||
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)
|
||||
}
|
||||
}
|
||||
71
wake/Utils/SVGImageHtml.swift
Normal file
71
wake/Utils/SVGImageHtml.swift
Normal 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) {}
|
||||
}
|
||||
@ -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,33 +83,28 @@ 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")
|
||||
@ -127,8 +119,6 @@ struct BlindOutcomeView: View {
|
||||
.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<Bool>, isPlaying: Binding<Bool>, 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
|
||||
}
|
||||
.onDisappear {
|
||||
player.pause()
|
||||
isPlaying = false
|
||||
}
|
||||
}
|
||||
.overlay(
|
||||
showControls ? VideoControls(
|
||||
isPlaying: $isPlaying,
|
||||
onClose: { isPresented = false }
|
||||
) : nil
|
||||
)
|
||||
}
|
||||
}
|
||||
.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
|
||||
@ -377,24 +352,3 @@ class PlayerView: UIView {
|
||||
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"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -8,7 +8,7 @@ struct SlideInModal<Content: View>: View {
|
||||
// 动画配置 - 更慢的动画
|
||||
private let animation = Animation.spring(
|
||||
response: 0.8, // 增加响应时间使动画更慢
|
||||
dampingFraction: 0.6, // 减少阻尼系数使弹跳更明显
|
||||
dampingFraction: 1, // 减少阻尼系数使弹跳更明显
|
||||
blendDuration: 0.8 // 增加混合时间使过渡更平滑
|
||||
)
|
||||
|
||||
@ -28,6 +28,8 @@ struct SlideInModal<Content: View>: View {
|
||||
}
|
||||
}
|
||||
|
||||
// 添加一个额外的容器来承载阴影
|
||||
ZStack(alignment: .leading) {
|
||||
// 弹窗内容
|
||||
VStack(spacing: 0) {
|
||||
// 顶部安全区域占位
|
||||
@ -43,6 +45,18 @@ struct SlideInModal<Content: View>: View {
|
||||
.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))
|
||||
|
||||
@ -305,7 +305,7 @@ public class ImageUploaderGetID: ObservableObject {
|
||||
}
|
||||
|
||||
print("✅ 成功获取上传URL")
|
||||
print(" - 文件ID: \(fileId)")
|
||||
print(" ❗️❗️❗️❗️❗️❗️❗️❗️❗️❗️❗️❗️❗️❗️❗️❗️❗️❗️❗️ - 文件ID: \(fileId)")
|
||||
print(" - 上传URL: \(uploadURLString)")
|
||||
|
||||
completion(.success((fileId: fileId, uploadURL: uploadURL)))
|
||||
|
||||
@ -25,10 +25,20 @@ struct APIResponse<T: Codable>: 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<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 {
|
||||
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,12 +251,42 @@ 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() {
|
||||
@ -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(""))
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
WaterfallGrid(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 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,18 +186,34 @@ struct FullScreenMediaView: View {
|
||||
// Media content with back button overlay
|
||||
ZStack {
|
||||
// Media content
|
||||
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()
|
||||
.scaledToFill()
|
||||
.frame(width: UIScreen.main.bounds.width,
|
||||
height: UIScreen.main.bounds.height)
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
.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)
|
||||
@ -205,22 +225,27 @@ struct FullScreenMediaView: View {
|
||||
}
|
||||
}
|
||||
|
||||
case .video(let url, let previewUrl):
|
||||
if let videoURL = URL(string: url) {
|
||||
VideoPlayer(player: player)
|
||||
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 {
|
||||
self.player = AVPlayer(url: videoURL)
|
||||
self.player?.play()
|
||||
self.isVideoPlaying = true
|
||||
if let previewUrl = URL(string: previewUrl) {
|
||||
loadAspectRatio(from: previewUrl)
|
||||
}
|
||||
isVideoPlaying = true
|
||||
}
|
||||
.onDisappear {
|
||||
self.player?.pause()
|
||||
self.player = nil
|
||||
isVideoPlaying = false
|
||||
}
|
||||
.frame(width: UIScreen.main.bounds.width,
|
||||
height: UIScreen.main.bounds.height)
|
||||
.onTapGesture {
|
||||
togglePlayPause()
|
||||
}
|
||||
.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,116 +280,141 @@ 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
|
||||
// private func setupVideoPlayer() {
|
||||
// if case .video(let url, _) = memory.mediaType, let videoURL = URL(string: url) {
|
||||
// // No need to set up player here
|
||||
// }
|
||||
// }
|
||||
|
||||
// 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 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() {
|
||||
// // No need to play video here
|
||||
// }
|
||||
|
||||
private func playVideo() {
|
||||
player?.play()
|
||||
isVideoPlaying = true
|
||||
}
|
||||
// private func pauseVideo() {
|
||||
// // No need to pause video here
|
||||
// }
|
||||
|
||||
private func pauseVideo() {
|
||||
player?.pause()
|
||||
isVideoPlaying = false
|
||||
}
|
||||
|
||||
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
|
||||
Group {
|
||||
if let image = phase.image {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
Color.black
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.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 {
|
||||
@ -393,31 +422,38 @@ struct MemoryCard: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case .video(_, let previewUrl):
|
||||
if let previewUrl = URL(string: previewUrl) {
|
||||
AsyncImage(url: previewUrl) { phase in
|
||||
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 {
|
||||
Color.gray.opacity(0.3)
|
||||
}
|
||||
}
|
||||
}
|
||||
.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()
|
||||
}
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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: - 日期格式化
|
||||
|
||||
@ -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() }
|
||||
// 打开隐私政策
|
||||
if let url = URL(string: "https://memorywake.com/privacy-policy") {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}) {
|
||||
Text("Restore Purchase")
|
||||
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<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
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user