bug #3
@ -9,6 +9,7 @@
|
|||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
AB6693CA2E65C94400BCAAC1 /* SVGKit in Frameworks */ = {isa = PBXBuildFile; productRef = AB6693C92E65C94400BCAAC1 /* SVGKit */; };
|
AB6693CA2E65C94400BCAAC1 /* SVGKit in Frameworks */ = {isa = PBXBuildFile; productRef = AB6693C92E65C94400BCAAC1 /* SVGKit */; };
|
||||||
AB6693CC2E65C94400BCAAC1 /* SVGKitSwift in Frameworks */ = {isa = PBXBuildFile; productRef = AB6693CB2E65C94400BCAAC1 /* SVGKitSwift */; };
|
AB6693CC2E65C94400BCAAC1 /* SVGKitSwift in Frameworks */ = {isa = PBXBuildFile; productRef = AB6693CB2E65C94400BCAAC1 /* SVGKitSwift */; };
|
||||||
|
AB6695272E67015600BCAAC1 /* WaterfallGrid in Frameworks */ = {isa = PBXBuildFile; productRef = AB6695262E67015600BCAAC1 /* WaterfallGrid */; };
|
||||||
AB8773632E4E04400071CB53 /* .gitignore in Resources */ = {isa = PBXBuildFile; fileRef = AB8773622E4E040E0071CB53 /* .gitignore */; };
|
AB8773632E4E04400071CB53 /* .gitignore in Resources */ = {isa = PBXBuildFile; fileRef = AB8773622E4E040E0071CB53 /* .gitignore */; };
|
||||||
ABC150C12E5DB39A00A1F970 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = ABC150C02E5DB39A00A1F970 /* Lottie */; };
|
ABC150C12E5DB39A00A1F970 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = ABC150C02E5DB39A00A1F970 /* Lottie */; };
|
||||||
ABE8998E2E533A7100CD7BA6 /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = ABE8998D2E533A7100CD7BA6 /* Alamofire */; };
|
ABE8998E2E533A7100CD7BA6 /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = ABE8998D2E533A7100CD7BA6 /* Alamofire */; };
|
||||||
@ -62,6 +63,7 @@
|
|||||||
AB6693CC2E65C94400BCAAC1 /* SVGKitSwift in Frameworks */,
|
AB6693CC2E65C94400BCAAC1 /* SVGKitSwift in Frameworks */,
|
||||||
AB6693CA2E65C94400BCAAC1 /* SVGKit in Frameworks */,
|
AB6693CA2E65C94400BCAAC1 /* SVGKit in Frameworks */,
|
||||||
ABE8998E2E533A7100CD7BA6 /* Alamofire in Frameworks */,
|
ABE8998E2E533A7100CD7BA6 /* Alamofire in Frameworks */,
|
||||||
|
AB6695272E67015600BCAAC1 /* WaterfallGrid in Frameworks */,
|
||||||
ABC150C12E5DB39A00A1F970 /* Lottie in Frameworks */,
|
ABC150C12E5DB39A00A1F970 /* Lottie in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@ -120,6 +122,7 @@
|
|||||||
ABC150C02E5DB39A00A1F970 /* Lottie */,
|
ABC150C02E5DB39A00A1F970 /* Lottie */,
|
||||||
AB6693C92E65C94400BCAAC1 /* SVGKit */,
|
AB6693C92E65C94400BCAAC1 /* SVGKit */,
|
||||||
AB6693CB2E65C94400BCAAC1 /* SVGKitSwift */,
|
AB6693CB2E65C94400BCAAC1 /* SVGKitSwift */,
|
||||||
|
AB6695262E67015600BCAAC1 /* WaterfallGrid */,
|
||||||
);
|
);
|
||||||
productName = wake;
|
productName = wake;
|
||||||
productReference = ABB4E2082E4B75D900660198 /* wake.app */;
|
productReference = ABB4E2082E4B75D900660198 /* wake.app */;
|
||||||
@ -153,6 +156,7 @@
|
|||||||
ABE8998C2E533A7100CD7BA6 /* XCRemoteSwiftPackageReference "Alamofire" */,
|
ABE8998C2E533A7100CD7BA6 /* XCRemoteSwiftPackageReference "Alamofire" */,
|
||||||
ABC150BF2E5DB39A00A1F970 /* XCRemoteSwiftPackageReference "lottie-spm" */,
|
ABC150BF2E5DB39A00A1F970 /* XCRemoteSwiftPackageReference "lottie-spm" */,
|
||||||
AB6693C82E65C94400BCAAC1 /* XCRemoteSwiftPackageReference "SVGKit" */,
|
AB6693C82E65C94400BCAAC1 /* XCRemoteSwiftPackageReference "SVGKit" */,
|
||||||
|
AB6695252E67015600BCAAC1 /* XCRemoteSwiftPackageReference "WaterfallGrid" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = ABB4E2092E4B75D900660198 /* Products */;
|
productRefGroup = ABB4E2092E4B75D900660198 /* Products */;
|
||||||
@ -409,6 +413,14 @@
|
|||||||
minimumVersion = 3.0.0;
|
minimumVersion = 3.0.0;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
AB6695252E67015600BCAAC1 /* XCRemoteSwiftPackageReference "WaterfallGrid" */ = {
|
||||||
|
isa = XCRemoteSwiftPackageReference;
|
||||||
|
repositoryURL = "https://github.com/paololeonardi/WaterfallGrid.git";
|
||||||
|
requirement = {
|
||||||
|
kind = upToNextMajorVersion;
|
||||||
|
minimumVersion = 1.1.0;
|
||||||
|
};
|
||||||
|
};
|
||||||
ABC150BF2E5DB39A00A1F970 /* XCRemoteSwiftPackageReference "lottie-spm" */ = {
|
ABC150BF2E5DB39A00A1F970 /* XCRemoteSwiftPackageReference "lottie-spm" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/airbnb/lottie-spm.git";
|
repositoryURL = "https://github.com/airbnb/lottie-spm.git";
|
||||||
@ -438,6 +450,11 @@
|
|||||||
package = AB6693C82E65C94400BCAAC1 /* XCRemoteSwiftPackageReference "SVGKit" */;
|
package = AB6693C82E65C94400BCAAC1 /* XCRemoteSwiftPackageReference "SVGKit" */;
|
||||||
productName = SVGKitSwift;
|
productName = SVGKitSwift;
|
||||||
};
|
};
|
||||||
|
AB6695262E67015600BCAAC1 /* WaterfallGrid */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = AB6695252E67015600BCAAC1 /* XCRemoteSwiftPackageReference "WaterfallGrid" */;
|
||||||
|
productName = WaterfallGrid;
|
||||||
|
};
|
||||||
ABC150C02E5DB39A00A1F970 /* Lottie */ = {
|
ABC150C02E5DB39A00A1F970 /* Lottie */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = ABC150BF2E5DB39A00A1F970 /* XCRemoteSwiftPackageReference "lottie-spm" */;
|
package = ABC150BF2E5DB39A00A1F970 /* XCRemoteSwiftPackageReference "lottie-spm" */;
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "d4b9379b4bd658fe79a6ae528c96d3386427dfe9d23635a65dad6edf12af85ff",
|
"originHash" : "d4b9379b4bd658fe79a6ae528c96d3386427dfe9d23635a65dad6edf12af85ff",
|
||||||
|
"originHash" : "7ea295cc5e3eb8ef644b89ce2b47a7600994b67c8582ee354b643cd63250740d",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "alamofire",
|
"identity" : "alamofire",
|
||||||
@ -45,6 +46,15 @@
|
|||||||
"revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2",
|
"revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2",
|
||||||
"version" : "1.6.4"
|
"version" : "1.6.4"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "waterfallgrid",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/paololeonardi/WaterfallGrid.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "c7c08652c3540adf8e48409c351879b4caea7e89",
|
||||||
|
"version" : "1.1.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version" : 3
|
"version" : 3
|
||||||
|
|||||||
BIN
wake/Assets/Png/logo.png
Normal file
BIN
wake/Assets/Png/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
24
wake/Assets/Svg/Free.svg
Normal file
24
wake/Assets/Svg/Free.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 17 KiB |
@ -20,6 +20,8 @@ struct ReturnButton: View {
|
|||||||
.font(Typography.font(for: iconSize))
|
.font(Typography.font(for: iconSize))
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
.foregroundColor(iconColor)
|
.foregroundColor(iconColor)
|
||||||
|
.padding(10) // 增加内边距来扩大点击区域
|
||||||
|
.contentShape(Rectangle()) // 使整个区域可点击
|
||||||
}
|
}
|
||||||
.buttonStyle(PlainButtonStyle())
|
.buttonStyle(PlainButtonStyle())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -105,36 +105,6 @@ struct BlindBoxView: View {
|
|||||||
case description
|
case description
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 会员信息
|
|
||||||
struct MemberProfile: Codable {
|
|
||||||
let materialCounter: MaterialCounter
|
|
||||||
let userInfo: UserInfo
|
|
||||||
let storiesCount: Int
|
|
||||||
let conversationsCount: Int
|
|
||||||
let remainPoints: Int
|
|
||||||
let totalPoints: Int
|
|
||||||
let usedBytes: Int
|
|
||||||
let totalBytes: Int
|
|
||||||
let titleRankings: [String]
|
|
||||||
let medalInfos: [MedalInfo]
|
|
||||||
let membershipLevel: String
|
|
||||||
let membershipEndAt: String
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case materialCounter = "material_counter"
|
|
||||||
case userInfo = "user_info"
|
|
||||||
case storiesCount = "stories_count"
|
|
||||||
case conversationsCount = "conversations_count"
|
|
||||||
case remainPoints = "remain_points"
|
|
||||||
case totalPoints = "total_points"
|
|
||||||
case usedBytes = "used_bytes"
|
|
||||||
case totalBytes = "total_bytes"
|
|
||||||
case titleRankings = "title_rankings"
|
|
||||||
case medalInfos = "medal_infos"
|
|
||||||
case membershipLevel = "membership_level"
|
|
||||||
case membershipEndAt = "membership_end_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 盲盒数量
|
// 盲盒数量
|
||||||
struct BlindCount: Codable {
|
struct BlindCount: Codable {
|
||||||
let availableQuantity: Int
|
let availableQuantity: Int
|
||||||
@ -144,59 +114,6 @@ struct BlindBoxView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct MaterialCounter: Codable {
|
|
||||||
let userId: Int64
|
|
||||||
let totalCount: MediaCount
|
|
||||||
let categoryCount: [String: MediaCount]
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case userId = "user_id"
|
|
||||||
case totalCount = "total_count"
|
|
||||||
case categoryCount = "category_count"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct MediaCount: Codable {
|
|
||||||
let videoCount: Int
|
|
||||||
let photoCount: Int
|
|
||||||
let liveCount: Int
|
|
||||||
let videoLength: Double
|
|
||||||
let coverUrl: String?
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case videoCount = "video_count"
|
|
||||||
case photoCount = "photo_count"
|
|
||||||
case liveCount = "live_count"
|
|
||||||
case videoLength = "video_length"
|
|
||||||
case coverUrl = "cover_url"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct UserInfo: Codable {
|
|
||||||
let userId: String
|
|
||||||
let accessToken: String
|
|
||||||
let avatarFileUrl: String?
|
|
||||||
let nickname: String
|
|
||||||
let account: String
|
|
||||||
let email: String
|
|
||||||
let refreshToken: String?
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case userId = "user_id"
|
|
||||||
case accessToken = "access_token"
|
|
||||||
case avatarFileUrl = "avatar_file_url"
|
|
||||||
case nickname
|
|
||||||
case account
|
|
||||||
case email
|
|
||||||
case refreshToken = "refresh_token"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct MedalInfo: Codable, Identifiable {
|
|
||||||
let id: Int
|
|
||||||
let url: String
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - BlindBox Response Model
|
// MARK: - BlindBox Response Model
|
||||||
|
|
||||||
struct BlindBoxData: Codable {
|
struct BlindBoxData: Codable {
|
||||||
@ -265,6 +182,8 @@ struct BlindBoxView: View {
|
|||||||
let mediaType: BlindBoxMediaType
|
let mediaType: BlindBoxMediaType
|
||||||
@State private var showModal = false // 控制用户资料弹窗显示
|
@State private var showModal = false // 控制用户资料弹窗显示
|
||||||
@State private var showSettings = false // 控制设置页面显示
|
@State private var showSettings = false // 控制设置页面显示
|
||||||
|
@State private var isMember = false // 是否是会员
|
||||||
|
@State private var memberDate = "" // 会员到期时间
|
||||||
@State private var showLogin = false
|
@State private var showLogin = false
|
||||||
@State private var memberProfile: MemberProfile? = nil
|
@State private var memberProfile: MemberProfile? = nil
|
||||||
@State private var blindCount: BlindCount? = nil
|
@State private var blindCount: BlindCount? = nil
|
||||||
@ -353,15 +272,15 @@ struct BlindBoxView: View {
|
|||||||
NetworkService.shared.get(
|
NetworkService.shared.get(
|
||||||
path: "/membership/personal-center-info",
|
path: "/membership/personal-center-info",
|
||||||
parameters: nil
|
parameters: nil
|
||||||
) { (result: Result<APIResponse<MemberProfile>, NetworkError>) in
|
) { (result: Result<MemberProfileResponse, NetworkError>) in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let response):
|
case .success(let response):
|
||||||
self.memberProfile = response.data
|
self.memberProfile = response.data
|
||||||
|
self.isMember = response.data.membershipLevel == "Pioneer"
|
||||||
|
self.memberDate = response.data.membershipEndAt ?? ""
|
||||||
print("✅ 成功获取会员信息:", response.data)
|
print("✅ 成功获取会员信息:", response.data)
|
||||||
print("✅ 用户ID:", response.data.userInfo.userId)
|
print("✅ 用户ID:", response.data.userInfo.userId)
|
||||||
print("✅ 用户昵称:", response.data.userInfo.nickname)
|
|
||||||
print("✅ 用户邮箱:", response.data.userInfo.email)
|
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
print("❌ 获取会员信息失败:", error)
|
print("❌ 获取会员信息失败:", error)
|
||||||
}
|
}
|
||||||
@ -472,24 +391,6 @@ struct BlindBoxView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadData() {
|
|
||||||
// 会员信息
|
|
||||||
NetworkService.shared.get(
|
|
||||||
path: "/membership/personal-center-info",
|
|
||||||
parameters: nil
|
|
||||||
) { (result: Result<APIResponse<MemberProfile>, NetworkError>) in
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
switch result {
|
|
||||||
case .success(let response):
|
|
||||||
self.memberProfile = response.data
|
|
||||||
print("✅ Successfully fetched user info:", response.data)
|
|
||||||
case .failure(let error):
|
|
||||||
print("❌ Failed to fetch user info:", error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadImage() {
|
private func loadImage() {
|
||||||
guard !imageURL.isEmpty, let url = URL(string: imageURL) else {
|
guard !imageURL.isEmpty, let url = URL(string: imageURL) else {
|
||||||
print("⚠️ 图片URL无效或为空")
|
print("⚠️ 图片URL无效或为空")
|
||||||
@ -744,7 +645,10 @@ struct BlindBoxView: View {
|
|||||||
Button(action: showUserProfile) {
|
Button(action: showUserProfile) {
|
||||||
SVGImage(svgName: "User")
|
SVGImage(svgName: "User")
|
||||||
.frame(width: 24, height: 24)
|
.frame(width: 24, height: 24)
|
||||||
|
.padding(13) // Increases tap area while keeping visual size
|
||||||
|
.contentShape(Rectangle()) // Makes the padded area tappable
|
||||||
}
|
}
|
||||||
|
.buttonStyle(PlainButtonStyle()) // Prevents the button from affecting the layout
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
// // 测试质感页面入口
|
// // 测试质感页面入口
|
||||||
@ -808,13 +712,9 @@ struct BlindBoxView: View {
|
|||||||
ZStack {
|
ZStack {
|
||||||
// 1. 背景SVG
|
// 1. 背景SVG
|
||||||
if !showScalingOverlay {
|
if !showScalingOverlay {
|
||||||
SVGImage(svgName: "BlindBg")
|
SVGImage(svgName: "BlindBg", contentMode: .fit)
|
||||||
.frame(
|
// .position(x: UIScreen.main.bounds.width / 2,
|
||||||
width: UIScreen.main.bounds.width * 1.8,
|
// y: UIScreen.main.bounds.height * 0.325)
|
||||||
height: UIScreen.main.bounds.height * 0.85
|
|
||||||
)
|
|
||||||
.position(x: UIScreen.main.bounds.width / 2,
|
|
||||||
y: UIScreen.main.bounds.height * 0.325)
|
|
||||||
.opacity(showScalingOverlay ? 0 : 1)
|
.opacity(showScalingOverlay ? 0 : 1)
|
||||||
.animation(.easeOut(duration: 1.5), value: showScalingOverlay)
|
.animation(.easeOut(duration: 1.5), value: showScalingOverlay)
|
||||||
}
|
}
|
||||||
@ -933,6 +833,7 @@ struct BlindBoxView: View {
|
|||||||
.offset(x: -10, y: UIScreen.main.bounds.height * 0.2)
|
.offset(x: -10, y: UIScreen.main.bounds.height * 0.2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding()
|
||||||
.frame(
|
.frame(
|
||||||
maxWidth: .infinity,
|
maxWidth: .infinity,
|
||||||
maxHeight: UIScreen.main.bounds.height * 0.65
|
maxHeight: UIScreen.main.bounds.height * 0.65
|
||||||
@ -1000,10 +901,11 @@ struct BlindBoxView: View {
|
|||||||
) {
|
) {
|
||||||
UserProfileModal(
|
UserProfileModal(
|
||||||
showModal: $showModal,
|
showModal: $showModal,
|
||||||
showSettings: $showSettings
|
showSettings: $showSettings,
|
||||||
|
isMember: $isMember,
|
||||||
|
memberDate: $memberDate
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.shadow(color: .black.opacity(0.3), radius: 10, x: 5, y: 0)
|
|
||||||
.offset(x: showSettings ? UIScreen.main.bounds.width : 0)
|
.offset(x: showSettings ? UIScreen.main.bounds.width : 0)
|
||||||
.animation(.spring(response: 0.6, dampingFraction: 0.8), value: showSettings)
|
.animation(.spring(response: 0.6, dampingFraction: 0.8), value: showSettings)
|
||||||
|
|
||||||
|
|||||||
255
wake/Models/MemberProfile.swift
Normal file
255
wake/Models/MemberProfile.swift
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - MemberProfile Response
|
||||||
|
struct MemberProfileResponse: Codable {
|
||||||
|
let code: Int
|
||||||
|
let data: MemberProfile
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case code, data
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(code, forKey: .code)
|
||||||
|
try container.encode(data, forKey: .data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - MemberProfile
|
||||||
|
struct MemberProfile: Codable {
|
||||||
|
let materialCounter: MaterialCounter
|
||||||
|
let userInfo: MemberUserInfo
|
||||||
|
let storiesCount: Int
|
||||||
|
let conversationsCount: Int
|
||||||
|
let remainPoints: Int
|
||||||
|
let totalPoints: Int
|
||||||
|
let usedBytes: Int
|
||||||
|
let totalBytes: Int
|
||||||
|
let titleRankings: [String]
|
||||||
|
let medalInfos: [MedalInfo]
|
||||||
|
let membershipLevel: String
|
||||||
|
let membershipEndAt: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case materialCounter = "material_counter"
|
||||||
|
case userInfo = "user_info"
|
||||||
|
case storiesCount = "stories_count"
|
||||||
|
case conversationsCount = "conversations_count"
|
||||||
|
case remainPoints = "remain_points"
|
||||||
|
case totalPoints = "total_points"
|
||||||
|
case usedBytes = "used_bytes"
|
||||||
|
case totalBytes = "total_bytes"
|
||||||
|
case titleRankings = "title_rankings"
|
||||||
|
case medalInfos = "medal_infos"
|
||||||
|
case membershipLevel = "membership_level"
|
||||||
|
case membershipEndAt = "membership_end_at"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
|
materialCounter = try container.decode(MaterialCounter.self, forKey: .materialCounter)
|
||||||
|
userInfo = try container.decode(MemberUserInfo.self, forKey: .userInfo)
|
||||||
|
storiesCount = try container.decode(Int.self, forKey: .storiesCount)
|
||||||
|
conversationsCount = try container.decode(Int.self, forKey: .conversationsCount)
|
||||||
|
remainPoints = try container.decode(Int.self, forKey: .remainPoints)
|
||||||
|
totalPoints = try container.decode(Int.self, forKey: .totalPoints)
|
||||||
|
usedBytes = try container.decode(Int.self, forKey: .usedBytes)
|
||||||
|
totalBytes = try container.decode(Int.self, forKey: .totalBytes)
|
||||||
|
titleRankings = try container.decode([String].self, forKey: .titleRankings)
|
||||||
|
|
||||||
|
if let medalInfos = try? container.decode([MedalInfo].self, forKey: .medalInfos) {
|
||||||
|
self.medalInfos = medalInfos
|
||||||
|
} else {
|
||||||
|
self.medalInfos = []
|
||||||
|
}
|
||||||
|
|
||||||
|
membershipLevel = try container.decode(String.self, forKey: .membershipLevel)
|
||||||
|
membershipEndAt = try container.decode(String.self, forKey: .membershipEndAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(materialCounter, forKey: .materialCounter)
|
||||||
|
try container.encode(userInfo, forKey: .userInfo)
|
||||||
|
try container.encode(storiesCount, forKey: .storiesCount)
|
||||||
|
try container.encode(conversationsCount, forKey: .conversationsCount)
|
||||||
|
try container.encode(remainPoints, forKey: .remainPoints)
|
||||||
|
try container.encode(totalPoints, forKey: .totalPoints)
|
||||||
|
try container.encode(usedBytes, forKey: .usedBytes)
|
||||||
|
try container.encode(totalBytes, forKey: .totalBytes)
|
||||||
|
try container.encode(titleRankings, forKey: .titleRankings)
|
||||||
|
try container.encode(medalInfos, forKey: .medalInfos)
|
||||||
|
try container.encode(membershipLevel, forKey: .membershipLevel)
|
||||||
|
try container.encode(membershipEndAt, forKey: .membershipEndAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - MemberUserInfo
|
||||||
|
struct MemberUserInfo: Codable {
|
||||||
|
let userId: String
|
||||||
|
let accessToken: String
|
||||||
|
let avatarFileUrl: String?
|
||||||
|
let nickname: String
|
||||||
|
let account: String
|
||||||
|
let email: String
|
||||||
|
let refreshToken: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case userId = "user_id"
|
||||||
|
case accessToken = "access_token"
|
||||||
|
case avatarFileUrl = "avatar_file_url"
|
||||||
|
case nickname, account, email
|
||||||
|
case refreshToken = "refresh_token"
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(userId, forKey: .userId)
|
||||||
|
try container.encode(accessToken, forKey: .accessToken)
|
||||||
|
try container.encodeIfPresent(avatarFileUrl, forKey: .avatarFileUrl)
|
||||||
|
try container.encode(nickname, forKey: .nickname)
|
||||||
|
try container.encode(account, forKey: .account)
|
||||||
|
try container.encode(email, forKey: .email)
|
||||||
|
try container.encodeIfPresent(refreshToken, forKey: .refreshToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - MaterialCounter
|
||||||
|
struct MaterialCounter: Codable {
|
||||||
|
let userId: Int64
|
||||||
|
let totalCount: TotalCount
|
||||||
|
let categoryCount: [String: CategoryCount]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case userId = "user_id"
|
||||||
|
case totalCount = "total_count"
|
||||||
|
case categoryCount = "category_count"
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(userId, forKey: .userId)
|
||||||
|
try container.encode(totalCount, forKey: .totalCount)
|
||||||
|
try container.encode(categoryCount, forKey: .categoryCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - TotalCount
|
||||||
|
struct TotalCount: Codable {
|
||||||
|
let videoCount: Int
|
||||||
|
let photoCount: Int
|
||||||
|
let liveCount: Int
|
||||||
|
let videoLength: Double
|
||||||
|
let coverUrl: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case videoCount = "video_count"
|
||||||
|
case photoCount = "photo_count"
|
||||||
|
case liveCount = "live_count"
|
||||||
|
case videoLength = "video_length"
|
||||||
|
case coverUrl = "cover_url"
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(videoCount, forKey: .videoCount)
|
||||||
|
try container.encode(photoCount, forKey: .photoCount)
|
||||||
|
try container.encode(liveCount, forKey: .liveCount)
|
||||||
|
try container.encode(videoLength, forKey: .videoLength)
|
||||||
|
try container.encodeIfPresent(coverUrl, forKey: .coverUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CategoryCount
|
||||||
|
struct CategoryCount: Codable {
|
||||||
|
let videoCount: Int
|
||||||
|
let photoCount: Int
|
||||||
|
let liveCount: Int
|
||||||
|
let videoLength: Double
|
||||||
|
let coverUrl: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case videoCount = "video_count"
|
||||||
|
case photoCount = "photo_count"
|
||||||
|
case liveCount = "live_count"
|
||||||
|
case videoLength = "video_length"
|
||||||
|
case coverUrl = "cover_url"
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(videoCount, forKey: .videoCount)
|
||||||
|
try container.encode(photoCount, forKey: .photoCount)
|
||||||
|
try container.encode(liveCount, forKey: .liveCount)
|
||||||
|
try container.encode(videoLength, forKey: .videoLength)
|
||||||
|
try container.encodeIfPresent(coverUrl, forKey: .coverUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - MedalInfo
|
||||||
|
struct MedalInfo: Codable, Identifiable {
|
||||||
|
let id: Int
|
||||||
|
let url: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, url
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(id, forKey: .id)
|
||||||
|
try container.encode(url, forKey: .url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - API Response Wrapper
|
||||||
|
struct MemberAPIResponse<T: Codable>: Codable {
|
||||||
|
let code: Int
|
||||||
|
let message: String
|
||||||
|
let data: T
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case code, message, data
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(code, forKey: .code)
|
||||||
|
try container.encode(message, forKey: .message)
|
||||||
|
try container.encode(data, forKey: .data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Date Formatter
|
||||||
|
class DateFormatterManager {
|
||||||
|
static let shared = DateFormatterManager()
|
||||||
|
|
||||||
|
let iso8601Full: ISO8601DateFormatter = {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - JSON Decoder Extension
|
||||||
|
extension JSONDecoder {
|
||||||
|
static let `default`: JSONDecoder = {
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||||
|
decoder.dateDecodingStrategy = .custom { decoder -> Date in
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
let dateString = try container.decode(String.self)
|
||||||
|
|
||||||
|
if let date = DateFormatterManager.shared.iso8601Full.date(from: dateString) {
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
|
||||||
|
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode date string \(dateString)")
|
||||||
|
}
|
||||||
|
return decoder
|
||||||
|
}()
|
||||||
|
}
|
||||||
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
|
// Trigger App Store purchase sheet
|
||||||
func purchasePioneer() async {
|
func purchasePioneer() async throws -> String {
|
||||||
guard !isPurchasing else { return }
|
guard !isPurchasing else { throw NSError(domain: "IAPError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Purchase already in progress"]) }
|
||||||
guard let product = pioneerProduct else {
|
guard let product = pioneerProduct else {
|
||||||
// Surface an actionable error so the UI can inform the user
|
throw NSError(domain: "IAPError", code: -2, userInfo: [NSLocalizedDescriptionKey: "Subscription product unavailable"])
|
||||||
self.errorMessage = "Subscription product unavailable. Please try again later."
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isPurchasing = true
|
isPurchasing = true
|
||||||
defer { isPurchasing = false }
|
defer { isPurchasing = false }
|
||||||
|
|
||||||
@ -50,21 +49,26 @@ final class IAPManager: ObservableObject {
|
|||||||
case .success(let verification):
|
case .success(let verification):
|
||||||
switch verification {
|
switch verification {
|
||||||
case .unverified(_, let error):
|
case .unverified(_, let error):
|
||||||
self.errorMessage = "Purchase unverified: \(error.localizedDescription)"
|
throw error
|
||||||
case .verified(let transaction):
|
case .verified(let transaction):
|
||||||
// Update entitlement for the purchased product
|
print("🎉 订阅成功!", transaction)
|
||||||
|
print("🔄 交易验证通过 - ID: \(transaction.id), 原始ID: \(transaction.originalID), 产品ID: \(transaction.productID)")
|
||||||
updateEntitlement(from: transaction)
|
updateEntitlement(from: transaction)
|
||||||
|
let transactionID = String(transaction.id)
|
||||||
|
print("📝 使用交易ID: \(transactionID)")
|
||||||
await transaction.finish()
|
await transaction.finish()
|
||||||
|
return transactionID
|
||||||
}
|
}
|
||||||
case .userCancelled:
|
case .userCancelled:
|
||||||
break
|
throw NSError(domain: "IAPError", code: -3, userInfo: [NSLocalizedDescriptionKey: "Purchase was cancelled"])
|
||||||
case .pending:
|
case .pending:
|
||||||
break
|
throw NSError(domain: "IAPError", code: -4, userInfo: [NSLocalizedDescriptionKey: "Purchase is pending approval"])
|
||||||
@unknown default:
|
@unknown default:
|
||||||
break
|
throw NSError(domain: "IAPError", code: -5, userInfo: [NSLocalizedDescriptionKey: "Unknown purchase result"])
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
self.errorMessage = "Purchase failed: \(error.localizedDescription)"
|
self.errorMessage = "Purchase failed: \(error.localizedDescription)"
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,68 +1,148 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import WebKit
|
import SVGKit
|
||||||
|
|
||||||
struct SVGImage: UIViewRepresentable {
|
struct SVGImage: UIViewRepresentable {
|
||||||
let svgName: String
|
let svgName: String
|
||||||
|
var contentMode: ContentMode = .fit
|
||||||
|
var tintColor: Color?
|
||||||
|
|
||||||
func makeUIView(context: Context) -> WKWebView {
|
private var svgPath: String {
|
||||||
let webView = WKWebView()
|
return svgName
|
||||||
webView.isOpaque = false
|
|
||||||
webView.backgroundColor = .clear
|
|
||||||
webView.scrollView.isScrollEnabled = false
|
|
||||||
webView.scrollView.contentInsetAdjustmentBehavior = .never
|
|
||||||
|
|
||||||
// 1. 获取 SVG 文件路径(注意:移除了 inDirectory 参数)
|
|
||||||
guard let path = Bundle.main.path(forResource: svgName, ofType: "svg") else {
|
|
||||||
print("❌ 无法找到 SVG 文件: \(svgName).svg")
|
|
||||||
// 打印所有可用的资源文件,用于调试
|
|
||||||
if let resourcePath = Bundle.main.resourcePath {
|
|
||||||
print("可用的资源文件: \(try? FileManager.default.contentsOfDirectory(atPath: resourcePath))")
|
|
||||||
}
|
|
||||||
return webView
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 创建文件 URL
|
|
||||||
let fileURL = URL(fileURLWithPath: path)
|
|
||||||
|
|
||||||
// 3. 创建 HTML 字符串
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
svg {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
img {
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div style="width:100%; height:100%; display:flex; align-items:center; justify-content:center;">
|
|
||||||
<img src="\(fileURL.lastPathComponent)" />
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
|
|
||||||
// 4. 加载 HTML 字符串
|
|
||||||
webView.loadHTMLString(htmlString, baseURL: fileURL.deletingLastPathComponent())
|
|
||||||
return webView
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ uiView: WKWebView, context: Context) {}
|
private func createImageView() -> SVGKFastImageView {
|
||||||
|
let emptySVGString = """
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1" preserveAspectRatio="xMidYMid meet">
|
||||||
|
<rect width="1" height="1" fill="transparent"/>
|
||||||
|
</svg>
|
||||||
|
"""
|
||||||
|
|
||||||
|
if let data = emptySVGString.data(using: .utf8),
|
||||||
|
let svgImage = SVGKImage(data: data) {
|
||||||
|
let imageView = SVGKFastImageView(svgkImage: svgImage) ?? SVGKFastImageView()
|
||||||
|
imageView.contentMode = .scaleAspectFit
|
||||||
|
imageView.backgroundColor = .clear
|
||||||
|
return imageView
|
||||||
|
}
|
||||||
|
|
||||||
|
let fallbackView = SVGKFastImageView()
|
||||||
|
fallbackView.backgroundColor = .clear
|
||||||
|
return fallbackView
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> SVGKFastImageView {
|
||||||
|
print("🔄 开始加载SVG: \(svgName)")
|
||||||
|
let imageView = createImageView()
|
||||||
|
loadSVG(into: imageView)
|
||||||
|
configureView(imageView)
|
||||||
|
return imageView
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadSVG(into imageView: SVGKFastImageView) {
|
||||||
|
guard let path = Bundle.main.path(forResource: svgPath, ofType: "svg") else {
|
||||||
|
print("⚠️ 在main bundle中找不到文件: \(svgPath).svg")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = URL(fileURLWithPath: path)
|
||||||
|
guard let svgImage = SVGKImage(contentsOf: url) else {
|
||||||
|
print("❌ 无法从URL创建SVG: \(path)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置SVG的尺寸为容器大小
|
||||||
|
let containerSize = imageView.bounds.size
|
||||||
|
if containerSize != .zero {
|
||||||
|
svgImage.size = containerSize
|
||||||
|
} else {
|
||||||
|
// 如果容器大小未知,设置一个默认大小
|
||||||
|
svgImage.size = CGSize(width: 100, height: 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
print("✅ 成功加载SVG: \(svgName), 尺寸: \(svgImage.size)")
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
imageView.image = svgImage
|
||||||
|
imageView.setNeedsLayout()
|
||||||
|
imageView.layoutIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureView(_ imageView: SVGKFastImageView) {
|
||||||
|
imageView.contentMode = contentMode == .fit ? .scaleAspectFit : .scaleAspectFill
|
||||||
|
imageView.clipsToBounds = true
|
||||||
|
imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
imageView.backgroundColor = .clear
|
||||||
|
|
||||||
|
// 确保图片视图可以正确缩放
|
||||||
|
imageView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||||
|
|
||||||
|
if let tintColor = tintColor?.uiColor {
|
||||||
|
imageView.tintColor = tintColor
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.applyTintColor(tintColor, to: imageView.layer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyTintColor(_ color: UIColor, to layer: CALayer) {
|
||||||
|
if let shapeLayer = layer as? CAShapeLayer {
|
||||||
|
shapeLayer.fillColor = color.cgColor
|
||||||
|
}
|
||||||
|
|
||||||
|
layer.sublayers?.forEach { sublayer in
|
||||||
|
applyTintColor(color, to: sublayer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: SVGKFastImageView, context: Context) {
|
||||||
|
loadSVG(into: uiView)
|
||||||
|
|
||||||
|
if let tintColor = tintColor?.uiColor {
|
||||||
|
uiView.tintColor = tintColor
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.applyTintColor(tintColor, to: uiView.layer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uiView.contentMode = contentMode == .fit ? .scaleAspectFit : .scaleAspectFill
|
||||||
|
}
|
||||||
|
|
||||||
|
func sizeThatFits(_ proposal: ProposedViewSize, uiView: SVGKFastImageView, context: Context) -> CGSize? {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ContentMode
|
||||||
|
extension SVGImage {
|
||||||
|
enum ContentMode {
|
||||||
|
case fit // 保持宽高比,适应容器
|
||||||
|
case fill // 保持宽高比,填充容器(可能被裁剪)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
#Preview {
|
||||||
|
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("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 AVKit
|
||||||
import os.log
|
import os.log
|
||||||
|
|
||||||
/// A view that displays either an image or a video with fullscreen support
|
|
||||||
struct BlindOutcomeView: View {
|
struct BlindOutcomeView: View {
|
||||||
let media: MediaType
|
let media: MediaType
|
||||||
let time: String?
|
let time: String?
|
||||||
@ -12,6 +11,7 @@ struct BlindOutcomeView: View {
|
|||||||
@State private var isPlaying = false
|
@State private var isPlaying = false
|
||||||
@State private var showControls = true
|
@State private var showControls = true
|
||||||
@State private var showIPListModal = false
|
@State private var showIPListModal = false
|
||||||
|
@State private var player: AVPlayer?
|
||||||
|
|
||||||
init(media: MediaType, time: String? = nil, description: String? = nil) {
|
init(media: MediaType, time: String? = nil, description: String? = nil) {
|
||||||
self.media = media
|
self.media = media
|
||||||
@ -28,7 +28,6 @@ struct BlindOutcomeView: View {
|
|||||||
// 自定义导航栏
|
// 自定义导航栏
|
||||||
HStack {
|
HStack {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
// 返回上一级
|
|
||||||
presentationMode.wrappedValue.dismiss()
|
presentationMode.wrappedValue.dismiss()
|
||||||
}) {
|
}) {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
@ -47,7 +46,6 @@ struct BlindOutcomeView: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// 占位,保持标题居中
|
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Image(systemName: "chevron.left")
|
Image(systemName: "chevron.left")
|
||||||
.opacity(0)
|
.opacity(0)
|
||||||
@ -56,7 +54,7 @@ struct BlindOutcomeView: View {
|
|||||||
}
|
}
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
.background(Color.themeTextWhiteSecondary)
|
.background(Color.themeTextWhiteSecondary)
|
||||||
.zIndex(1) // 确保导航栏在其他内容之上
|
.zIndex(1)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
.frame(height: 30)
|
.frame(height: 30)
|
||||||
@ -65,7 +63,6 @@ struct BlindOutcomeView: View {
|
|||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
ZStack {
|
ZStack {
|
||||||
// 添加白色背景
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
RoundedRectangle(cornerRadius: 12)
|
||||||
.fill(Color.white)
|
.fill(Color.white)
|
||||||
.shadow(color: Color.black.opacity(0.1), radius: 8, x: 0, y: 2)
|
.shadow(color: Color.black.opacity(0.1), radius: 8, x: 0, y: 2)
|
||||||
@ -86,48 +83,41 @@ struct BlindOutcomeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case .video(let url, _):
|
case .video(let url, _):
|
||||||
VideoPlayerView(url: url, isPlaying: $isPlaying)
|
VideoPlayerView(url: url, isPlaying: $isPlaying, player: $player)
|
||||||
.frame(width: UIScreen.main.bounds.width - 40)
|
.frame(width: UIScreen.main.bounds.width - 40)
|
||||||
.background(Color.clear)
|
.background(Color.clear)
|
||||||
.cornerRadius(10)
|
.cornerRadius(10)
|
||||||
.clipped()
|
.clipped()
|
||||||
.onAppear {
|
.onAppear {
|
||||||
// Auto-play the video when it appears
|
|
||||||
isPlaying = true
|
isPlaying = true
|
||||||
}
|
}
|
||||||
|
.onDisappear {
|
||||||
|
isPlaying = false
|
||||||
|
player?.pause()
|
||||||
|
}
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
withAnimation {
|
withAnimation {
|
||||||
showControls.toggle()
|
showControls.toggle()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.fullScreenCover(isPresented: $isFullscreen) {
|
.fullScreenCover(isPresented: $isFullscreen) {
|
||||||
FullscreenMediaView(media: media, isPresented: $isFullscreen, isPlaying: $isPlaying, player: nil)
|
FullscreenMediaView(media: media, isPresented: $isFullscreen, isPlaying: $isPlaying, player: player)
|
||||||
}
|
}
|
||||||
.overlay(
|
|
||||||
showControls ? VideoControls(
|
|
||||||
isPlaying: $isPlaying,
|
|
||||||
onClose: { isFullscreen = false }
|
|
||||||
) : nil
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
if let description = description, !description.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
if let description = description, !description.isEmpty {
|
Text("Description")
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
.font(Typography.font(for: .body, family: .quicksandBold))
|
||||||
Text("Description")
|
.foregroundColor(.themeTextMessageMain)
|
||||||
.font(Typography.font(for: .body, family: .quicksandBold))
|
Text(description)
|
||||||
.foregroundColor(.themeTextMessageMain)
|
.font(.system(size: 12))
|
||||||
Text(description)
|
.foregroundColor(Color.themeTextMessageMain)
|
||||||
.font(.system(size: 12))
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.foregroundColor(Color.themeTextMessageMain)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.bottom, 12)
|
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.bottom, 12)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
}
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
@ -136,12 +126,13 @@ struct BlindOutcomeView: View {
|
|||||||
.padding(.bottom, 20)
|
.padding(.bottom, 20)
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// Button at bottom
|
// Button at bottom
|
||||||
VStack {
|
VStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(action: {
|
Button(action: {
|
||||||
// 如果携带的类型是video显示弹窗
|
|
||||||
if case .video = media {
|
if case .video = media {
|
||||||
withAnimation {
|
withAnimation {
|
||||||
showIPListModal = true
|
showIPListModal = true
|
||||||
@ -162,22 +153,20 @@ struct BlindOutcomeView: View {
|
|||||||
}
|
}
|
||||||
.padding(.bottom, 20)
|
.padding(.bottom, 20)
|
||||||
}
|
}
|
||||||
.onDisappear {
|
|
||||||
// Clean up video player when view disappears
|
|
||||||
if case .video = media {
|
|
||||||
isPlaying = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.navigationBarHidden(true) // 确保隐藏系统导航栏
|
.navigationBarHidden(true)
|
||||||
.navigationBarBackButtonHidden(true) // 确保隐藏系统返回按钮
|
.navigationBarBackButtonHidden(true)
|
||||||
.statusBar(hidden: isFullscreen)
|
.statusBar(hidden: isFullscreen)
|
||||||
}
|
}
|
||||||
.navigationViewStyle(StackNavigationViewStyle()) // 确保在iPad上也能正确显示
|
.navigationViewStyle(StackNavigationViewStyle())
|
||||||
.navigationBarHidden(true) // 额外确保隐藏导航栏
|
.navigationBarHidden(true)
|
||||||
.overlay(
|
.overlay(
|
||||||
JoinModal(isPresented: $showIPListModal)
|
JoinModal(isPresented: $showIPListModal)
|
||||||
)
|
)
|
||||||
|
.onDisappear {
|
||||||
|
player?.pause()
|
||||||
|
player = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -187,22 +176,19 @@ private struct FullscreenMediaView: View {
|
|||||||
@Binding var isPresented: Bool
|
@Binding var isPresented: Bool
|
||||||
@Binding var isPlaying: Bool
|
@Binding var isPlaying: Bool
|
||||||
@State private var showControls = true
|
@State private var showControls = true
|
||||||
@State private var player: AVPlayer?
|
private let player: AVPlayer?
|
||||||
|
|
||||||
init(media: MediaType, isPresented: Binding<Bool>, isPlaying: Binding<Bool>, player: AVPlayer?) {
|
init(media: MediaType, isPresented: Binding<Bool>, isPlaying: Binding<Bool>, player: AVPlayer?) {
|
||||||
self.media = media
|
self.media = media
|
||||||
self._isPresented = isPresented
|
self._isPresented = isPresented
|
||||||
self._isPlaying = isPlaying
|
self._isPlaying = isPlaying
|
||||||
if let player = player {
|
self.player = player
|
||||||
self._player = State(initialValue: player)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.black.edgesIgnoringSafeArea(.all)
|
Color.black.edgesIgnoringSafeArea(.all)
|
||||||
|
|
||||||
// Media content
|
|
||||||
ZStack {
|
ZStack {
|
||||||
switch media {
|
switch media {
|
||||||
case .image(let uiImage):
|
case .image(let uiImage):
|
||||||
@ -216,25 +202,21 @@ private struct FullscreenMediaView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case .video(let url, _):
|
case .video(_, _):
|
||||||
VideoPlayerView(url: url, isPlaying: $isPlaying)
|
if let player = player {
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
CustomVideoPlayer(player: player)
|
||||||
.onTapGesture {
|
.onAppear {
|
||||||
withAnimation {
|
player.play()
|
||||||
showControls.toggle()
|
isPlaying = true
|
||||||
}
|
}
|
||||||
}
|
.onDisappear {
|
||||||
.overlay(
|
player.pause()
|
||||||
showControls ? VideoControls(
|
isPlaying = false
|
||||||
isPlaying: $isPlaying,
|
}
|
||||||
onClose: { isPresented = false }
|
}
|
||||||
) : nil
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
|
|
||||||
// Close button (always visible)
|
|
||||||
VStack {
|
VStack {
|
||||||
HStack {
|
HStack {
|
||||||
Button(action: { isPresented = false }) {
|
Button(action: { isPresented = false }) {
|
||||||
@ -251,42 +233,22 @@ private struct FullscreenMediaView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
|
||||||
if case .video = media {
|
|
||||||
if isPlaying {
|
|
||||||
// player?.play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
if case .video = media {
|
player?.pause()
|
||||||
// player?.pause()
|
|
||||||
// player?.replaceCurrentItem(with: nil)
|
|
||||||
// player = nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Video Controls
|
// MARK: - Video Player View
|
||||||
private struct VideoControls: View {
|
|
||||||
@Binding var isPlaying: Bool
|
|
||||||
let onClose: () -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
// Empty view - no controls shown
|
|
||||||
EmptyView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Video Player with Dynamic Aspect Ratio
|
|
||||||
struct VideoPlayerView: UIViewRepresentable {
|
struct VideoPlayerView: UIViewRepresentable {
|
||||||
let url: URL
|
let url: URL
|
||||||
@Binding var isPlaying: Bool
|
@Binding var isPlaying: Bool
|
||||||
|
@Binding var player: AVPlayer?
|
||||||
|
|
||||||
func makeUIView(context: Context) -> PlayerView {
|
func makeUIView(context: Context) -> PlayerView {
|
||||||
let view = PlayerView()
|
let view = PlayerView()
|
||||||
view.setupPlayer(url: url)
|
let player = view.setupPlayer(url: url)
|
||||||
|
self.player = player
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -299,39 +261,56 @@ struct VideoPlayerView: UIViewRepresentable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Custom Video Player
|
||||||
|
@available(iOS 14.0, *)
|
||||||
|
struct CustomVideoPlayer: UIViewControllerRepresentable {
|
||||||
|
let player: AVPlayer
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> AVPlayerViewController {
|
||||||
|
let controller = AVPlayerViewController()
|
||||||
|
controller.player = player
|
||||||
|
controller.showsPlaybackControls = false
|
||||||
|
controller.videoGravity = .resizeAspect
|
||||||
|
return controller
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {
|
||||||
|
uiViewController.player = player
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Player View
|
||||||
class PlayerView: UIView {
|
class PlayerView: UIView {
|
||||||
private var player: AVPlayer?
|
private var player: AVPlayer?
|
||||||
private var playerLayer: AVPlayerLayer?
|
private var playerLayer: AVPlayerLayer?
|
||||||
private var playerItem: AVPlayerItem?
|
private var playerItem: AVPlayerItem?
|
||||||
private var playerItemObserver: NSKeyValueObservation?
|
private var playerItemObserver: NSKeyValueObservation?
|
||||||
|
|
||||||
func setupPlayer(url: URL) {
|
@discardableResult
|
||||||
// Clean up existing resources
|
func setupPlayer(url: URL) -> AVPlayer {
|
||||||
cleanup()
|
cleanup()
|
||||||
|
|
||||||
// Create new player
|
|
||||||
let asset = AVAsset(url: url)
|
let asset = AVAsset(url: url)
|
||||||
let playerItem = AVPlayerItem(asset: asset)
|
let playerItem = AVPlayerItem(asset: asset)
|
||||||
self.playerItem = playerItem
|
self.playerItem = playerItem
|
||||||
|
|
||||||
player = AVPlayer(playerItem: playerItem)
|
player = AVPlayer(playerItem: playerItem)
|
||||||
|
|
||||||
// Setup player layer
|
|
||||||
let playerLayer = AVPlayerLayer(player: player)
|
let playerLayer = AVPlayerLayer(player: player)
|
||||||
playerLayer.videoGravity = .resizeAspect
|
playerLayer.videoGravity = .resizeAspect
|
||||||
layer.addSublayer(playerLayer)
|
layer.addSublayer(playerLayer)
|
||||||
self.playerLayer = playerLayer
|
self.playerLayer = playerLayer
|
||||||
|
|
||||||
// Layout
|
|
||||||
playerLayer.frame = bounds
|
playerLayer.frame = bounds
|
||||||
|
|
||||||
// Add observer for video end
|
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
self,
|
self,
|
||||||
selector: #selector(playerItemDidReachEnd),
|
selector: #selector(playerItemDidReachEnd),
|
||||||
name: .AVPlayerItemDidPlayToEndTime,
|
name: .AVPlayerItemDidPlayToEndTime,
|
||||||
object: playerItem
|
object: playerItem
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return player!
|
||||||
}
|
}
|
||||||
|
|
||||||
func play() {
|
func play() {
|
||||||
@ -343,21 +322,17 @@ class PlayerView: UIView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func cleanup() {
|
private func cleanup() {
|
||||||
// Remove observers
|
|
||||||
if let playerItem = playerItem {
|
if let playerItem = playerItem {
|
||||||
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: playerItem)
|
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: playerItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pause and clean up player
|
|
||||||
player?.pause()
|
player?.pause()
|
||||||
player?.replaceCurrentItem(with: nil)
|
player?.replaceCurrentItem(with: nil)
|
||||||
player = nil
|
player = nil
|
||||||
|
|
||||||
// Remove player layer
|
|
||||||
playerLayer?.removeFromSuperlayer()
|
playerLayer?.removeFromSuperlayer()
|
||||||
playerLayer = nil
|
playerLayer = nil
|
||||||
|
|
||||||
// Release player item
|
|
||||||
playerItem?.cancelPendingSeeks()
|
playerItem?.cancelPendingSeeks()
|
||||||
playerItem?.asset.cancelLoading()
|
playerItem?.asset.cancelLoading()
|
||||||
playerItem = nil
|
playerItem = nil
|
||||||
@ -376,25 +351,4 @@ class PlayerView: UIView {
|
|||||||
deinit {
|
deinit {
|
||||||
cleanup()
|
cleanup()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Preview
|
|
||||||
struct BlindOutcomeView_Previews: PreviewProvider {
|
|
||||||
static var previews: some View {
|
|
||||||
// Preview with image and details
|
|
||||||
BlindOutcomeView(
|
|
||||||
media: .image(UIImage(systemName: "photo")!),
|
|
||||||
time: "2:30",
|
|
||||||
description: "This is a sample description for the preview. It shows how the text will wrap and display below the media content."
|
|
||||||
)
|
|
||||||
|
|
||||||
// Preview with video and details
|
|
||||||
if let url = URL(string: "https://example.com/sample.mp4") {
|
|
||||||
BlindOutcomeView(
|
|
||||||
media: .video(url, nil),
|
|
||||||
time: "1:45",
|
|
||||||
description: "Video content with time and description"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -22,7 +22,7 @@ struct JoinModal: View {
|
|||||||
// IP Image peeking from top
|
// IP Image peeking from top
|
||||||
HStack {
|
HStack {
|
||||||
// Make sure you have an image named "IP" in your assets
|
// Make sure you have an image named "IP" in your assets
|
||||||
SVGImage(svgName: "IP1")
|
SVGImageHtml(svgName: "IP1")
|
||||||
.frame(width: 116, height: 65)
|
.frame(width: 116, height: 65)
|
||||||
.offset(x: 30)
|
.offset(x: 30)
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|||||||
@ -8,7 +8,7 @@ struct SlideInModal<Content: View>: View {
|
|||||||
// 动画配置 - 更慢的动画
|
// 动画配置 - 更慢的动画
|
||||||
private let animation = Animation.spring(
|
private let animation = Animation.spring(
|
||||||
response: 0.8, // 增加响应时间使动画更慢
|
response: 0.8, // 增加响应时间使动画更慢
|
||||||
dampingFraction: 0.6, // 减少阻尼系数使弹跳更明显
|
dampingFraction: 1, // 减少阻尼系数使弹跳更明显
|
||||||
blendDuration: 0.8 // 增加混合时间使过渡更平滑
|
blendDuration: 0.8 // 增加混合时间使过渡更平滑
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -28,21 +28,35 @@ struct SlideInModal<Content: View>: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 弹窗内容
|
// 添加一个额外的容器来承载阴影
|
||||||
VStack(spacing: 0) {
|
ZStack(alignment: .leading) {
|
||||||
// 顶部安全区域占位
|
// 弹窗内容
|
||||||
Color.clear
|
VStack(spacing: 0) {
|
||||||
.frame(height: UIApplication.shared.windows.first?.safeAreaInsets.top ?? 0)
|
// 顶部安全区域占位
|
||||||
|
Color.clear
|
||||||
// 内容区域
|
.frame(height: UIApplication.shared.windows.first?.safeAreaInsets.top ?? 0)
|
||||||
content()
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
// 内容区域
|
||||||
.padding(.bottom, UIApplication.shared.windows.first?.safeAreaInsets.bottom ?? 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(
|
||||||
.background(Color(.systemBackground))
|
RoundedRectangle(cornerRadius: 0)
|
||||||
.edgesIgnoringSafeArea(.vertical)
|
.fill(Color(.systemBackground))
|
||||||
|
.shadow(
|
||||||
|
color: .black.opacity(0.3),
|
||||||
|
radius: 10,
|
||||||
|
x: 5,
|
||||||
|
y: 0
|
||||||
|
)
|
||||||
|
)
|
||||||
.offset(x: isPresented ? 0 : -UIScreen.main.bounds.width)
|
.offset(x: isPresented ? 0 : -UIScreen.main.bounds.width)
|
||||||
.zIndex(2)
|
.zIndex(2)
|
||||||
.transition(.move(edge: .leading))
|
.transition(.move(edge: .leading))
|
||||||
|
|||||||
@ -305,8 +305,8 @@ public class ImageUploaderGetID: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
print("✅ 成功获取上传URL")
|
print("✅ 成功获取上传URL")
|
||||||
print(" - 文件ID: \(fileId)")
|
print(" ❗️❗️❗️❗️❗️❗️❗️❗️❗️❗️❗️❗️❗️❗️❗️❗️❗️❗️❗️ - 文件ID: \(fileId)")
|
||||||
print(" - 上传URL: \(uploadURLString)")
|
print(" - 上传URL: \(uploadURLString)")
|
||||||
|
|
||||||
completion(.success((fileId: fileId, uploadURL: uploadURL)))
|
completion(.success((fileId: fileId, uploadURL: uploadURL)))
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@ -25,10 +25,20 @@ struct APIResponse<T: Codable>: Codable {
|
|||||||
struct UserProfileModal: View {
|
struct UserProfileModal: View {
|
||||||
@Binding var showModal: Bool
|
@Binding var showModal: Bool
|
||||||
@Binding var showSettings: Bool
|
@Binding var showSettings: Bool
|
||||||
|
@Binding var isMember: Bool
|
||||||
|
@Binding var memberDate: String
|
||||||
@State private var userProfile: UserProfile?
|
@State private var userProfile: UserProfile?
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
@State private var isCopied = false
|
@State private var isCopied = false
|
||||||
|
@State private var isContentReady = false
|
||||||
|
|
||||||
|
init(showModal: Binding<Bool>, showSettings: Binding<Bool>, isMember: Binding<Bool>, memberDate: Binding<String>) {
|
||||||
|
self._showModal = showModal
|
||||||
|
self._showSettings = showSettings
|
||||||
|
self._isMember = isMember
|
||||||
|
self._memberDate = memberDate
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
@ -42,10 +52,35 @@ struct UserProfileModal: View {
|
|||||||
Text(error)
|
Text(error)
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
.padding()
|
.padding()
|
||||||
|
} else if isContentReady, let userProfile = userProfile {
|
||||||
|
userProfileView(userProfile: userProfile)
|
||||||
|
.opacity(isContentReady ? 1 : 0)
|
||||||
|
.animation(.easeInOut(duration: 0.3), value: isContentReady)
|
||||||
|
} else {
|
||||||
|
// Empty view with same dimensions to prevent layout shifts
|
||||||
|
Color.clear
|
||||||
|
.frame(height: UIScreen.main.bounds.height * 0.7)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
.frame(width: UIScreen.main.bounds.width * 0.8)
|
||||||
|
.background(Color.themeTextWhiteSecondary)
|
||||||
|
.edgesIgnoringSafeArea(.all)
|
||||||
|
.onAppear {
|
||||||
|
fetchUserInfo()
|
||||||
|
// Delay content appearance to sync with modal animation
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||||
|
withAnimation {
|
||||||
|
isContentReady = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func userProfileView(userProfile: UserProfile) -> some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
HStack(alignment: .center, spacing: 16) {
|
HStack(alignment: .center, spacing: 16) {
|
||||||
if let avatarUrl = userProfile?.avatarUrl, !avatarUrl.isEmpty, let url = URL(string: avatarUrl) {
|
if let avatarUrl = userProfile.avatarUrl, !avatarUrl.isEmpty, let url = URL(string: avatarUrl) {
|
||||||
AsyncImage(url: url) { phase in
|
AsyncImage(url: url) { phase in
|
||||||
switch phase {
|
switch phase {
|
||||||
case .success(let image):
|
case .success(let image):
|
||||||
@ -62,6 +97,9 @@ struct UserProfileModal: View {
|
|||||||
.foregroundColor(.blue)
|
.foregroundColor(.blue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onTapGesture {
|
||||||
|
Router.shared.navigate(to: .userInfo)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Image(systemName: "person.circle.fill")
|
Image(systemName: "person.circle.fill")
|
||||||
.resizable()
|
.resizable()
|
||||||
@ -71,12 +109,12 @@ struct UserProfileModal: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Text(userProfile?.nickname ?? "Name")
|
Text(userProfile.nickname)
|
||||||
.font(Typography.font(for: .body))
|
.font(Typography.font(for: .body))
|
||||||
.fontWeight(.bold)
|
.fontWeight(.bold)
|
||||||
.foregroundColor(.themeTextMessageMain)
|
.foregroundColor(.themeTextMessageMain)
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Text("ID: \(userProfile?.userId ?? "")")
|
Text("ID: \(userProfile.userId)")
|
||||||
.font(.system(size: 14))
|
.font(.system(size: 14))
|
||||||
.foregroundColor(.themeTextMessageMain)
|
.foregroundColor(.themeTextMessageMain)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@ -84,21 +122,7 @@ struct UserProfileModal: View {
|
|||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.frame(maxWidth: 120)
|
.frame(maxWidth: 120)
|
||||||
|
|
||||||
Button(action: {
|
Button(action: copyUserId) {
|
||||||
print("Copy ID button tapped")
|
|
||||||
UIPasteboard.general.string = userProfile?.userId
|
|
||||||
print("Copied to clipboard:", userProfile?.userId ?? "nil")
|
|
||||||
withAnimation {
|
|
||||||
isCopied = true
|
|
||||||
// Reset after 2 seconds
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
|
||||||
withAnimation {
|
|
||||||
isCopied = false
|
|
||||||
print("Reset copy button state")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
if isCopied {
|
if isCopied {
|
||||||
Image(systemName: "checkmark")
|
Image(systemName: "checkmark")
|
||||||
.foregroundColor(.themePrimary)
|
.foregroundColor(.themePrimary)
|
||||||
@ -109,8 +133,8 @@ struct UserProfileModal: View {
|
|||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
.foregroundColor(.themeTextMessageMain)
|
.foregroundColor(.themeTextMessageMain)
|
||||||
.animation(.easeInOut, value: isCopied)
|
.animation(.easeInOut, value: isCopied)
|
||||||
.contentShape(Rectangle()) // Make the entire button area tappable
|
.contentShape(Rectangle())
|
||||||
.frame(width: 24, height: 24) // Ensure minimum touch target size
|
.frame(width: 24, height: 24)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -124,43 +148,8 @@ struct UserProfileModal: View {
|
|||||||
)
|
)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
|
||||||
Button(action: {
|
// 当前订阅状态卡片
|
||||||
Router.shared.navigate(to: .subscribe)
|
currentSubscriptionCard
|
||||||
}) {
|
|
||||||
ZStack(alignment: .center) {
|
|
||||||
// SVG背景 - 确保铺满整个区域
|
|
||||||
SVGImage(svgName: "Pioneer")
|
|
||||||
.scaledToFill()
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
|
|
||||||
// 内容区域
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
Text("Pioneer")
|
|
||||||
.font(Typography.font(for: .title3))
|
|
||||||
.fontWeight(.bold)
|
|
||||||
.foregroundColor(.themeTextMessageMain)
|
|
||||||
.padding(.top, 6)
|
|
||||||
|
|
||||||
Text("Expires on :")
|
|
||||||
.font(.system(size: 12))
|
|
||||||
.foregroundColor(.themeTextMessageMain)
|
|
||||||
.padding(.top, 2)
|
|
||||||
|
|
||||||
Text("March 15, 2025")
|
|
||||||
.font(.system(size: 12))
|
|
||||||
.fontWeight(.bold)
|
|
||||||
.foregroundColor(.themeTextMessageMain)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.padding(.leading, 32)
|
|
||||||
// 可以添加内边距使内容不紧贴边缘
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
}
|
|
||||||
.frame(height: 112)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.cornerRadius(16)
|
|
||||||
.clipped() // 确保内容不会超出边界
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
// upload
|
// upload
|
||||||
@ -208,26 +197,26 @@ struct UserProfileModal: View {
|
|||||||
.buttonStyle(PlainButtonStyle())
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
|
||||||
// Box
|
// Box
|
||||||
Button(action: {
|
// Button(action: {
|
||||||
Router.shared.navigate(to: .mediaUpload)
|
// Router.shared.navigate(to: .mediaUpload)
|
||||||
}) {
|
// }) {
|
||||||
HStack(spacing: 16) {
|
// HStack(spacing: 16) {
|
||||||
SVGImage(svgName: "Box")
|
// SVGImage(svgName: "Box")
|
||||||
.foregroundColor(.orange)
|
// .foregroundColor(.orange)
|
||||||
.frame(width: 20, height: 20)
|
// .frame(width: 20, height: 20)
|
||||||
|
|
||||||
Text("My Blind Box")
|
// Text("My Blind Box")
|
||||||
.font(Typography.font(for: .body))
|
// .font(Typography.font(for: .body))
|
||||||
.fontWeight(.bold)
|
// .fontWeight(.bold)
|
||||||
.foregroundColor(.themeTextMessageMain)
|
// .foregroundColor(.themeTextMessageMain)
|
||||||
|
|
||||||
Spacer()
|
// Spacer()
|
||||||
}
|
// }
|
||||||
.padding()
|
// .padding()
|
||||||
.cornerRadius(10)
|
// .cornerRadius(10)
|
||||||
.contentShape(Rectangle())
|
// .contentShape(Rectangle())
|
||||||
}
|
// }
|
||||||
.buttonStyle(PlainButtonStyle())
|
// .buttonStyle(PlainButtonStyle())
|
||||||
|
|
||||||
// setting
|
// setting
|
||||||
Button(action: {
|
Button(action: {
|
||||||
@ -262,14 +251,44 @@ struct UserProfileModal: View {
|
|||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.frame(width: UIScreen.main.bounds.width * 0.8)
|
|
||||||
.background(Color.themeTextWhiteSecondary)
|
|
||||||
.edgesIgnoringSafeArea(.all)
|
|
||||||
.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() {
|
private func fetchUserInfo() {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
@ -295,5 +314,5 @@ struct UserProfileModal: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
UserProfileModal(showModal: .constant(true), showSettings: .constant(false))
|
UserProfileModal(showModal: .constant(true), showSettings: .constant(false), isMember: .constant(true), memberDate: .constant(""))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,8 +32,8 @@ struct CreditsInfoCard: View {
|
|||||||
mainCreditsSection
|
mainCreditsSection
|
||||||
}
|
}
|
||||||
.buttonStyle(PlainButtonStyle())
|
.buttonStyle(PlainButtonStyle())
|
||||||
.background(Theme.Colors.primaryLight)
|
.background(Color.themeTextWhite)
|
||||||
.cornerRadius(Theme.CornerRadius.extraLarge)
|
.cornerRadius(Theme.CornerRadius.round)
|
||||||
// .shadow(color: Theme.Shadows.small, radius: Theme.Shadows.cardShadow.radius, x: Theme.Shadows.cardShadow.x, y: Theme.Shadows.cardShadow.y)
|
// .shadow(color: Theme.Shadows.small, radius: Theme.Shadows.cardShadow.radius, x: Theme.Shadows.cardShadow.x, y: Theme.Shadows.cardShadow.y)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import AVKit
|
import AVKit
|
||||||
|
import WaterfallGrid
|
||||||
|
|
||||||
// MARK: - API Response Models
|
// MARK: - API Response Models
|
||||||
struct MaterialResponse: Decodable {
|
struct MaterialResponse: Decodable {
|
||||||
@ -27,7 +28,7 @@ struct MemoryItem: Identifiable, Decodable {
|
|||||||
var title: String { name ?? "Untitled" }
|
var title: String { name ?? "Untitled" }
|
||||||
var subtitle: String { description ?? "" }
|
var subtitle: String { description ?? "" }
|
||||||
var mediaType: MemoryMediaType {
|
var mediaType: MemoryMediaType {
|
||||||
let url = fileInfo.url.lowercased()
|
let url = fileInfo.fileName.lowercased()
|
||||||
if url.hasSuffix(".mp4") || url.hasSuffix(".mov") {
|
if url.hasSuffix(".mp4") || url.hasSuffix(".mov") {
|
||||||
return .video(url: fileInfo.url, previewUrl: previewFileInfo.url)
|
return .video(url: fileInfo.url, previewUrl: previewFileInfo.url)
|
||||||
} else {
|
} else {
|
||||||
@ -98,30 +99,17 @@ struct MemoriesView: View {
|
|||||||
ZStack {
|
ZStack {
|
||||||
Color.themeTextWhiteSecondary.ignoresSafeArea()
|
Color.themeTextWhiteSecondary.ignoresSafeArea()
|
||||||
|
|
||||||
Group {
|
ScrollView {
|
||||||
if isLoading {
|
WaterfallGrid(memories) { memory in
|
||||||
ProgressView()
|
MemoryCard(memory: memory)
|
||||||
.scaleEffect(1.5)
|
.onTapGesture {
|
||||||
} else if let error = errorMessage {
|
withAnimation(.spring()) {
|
||||||
Text("Error: \(error)")
|
selectedMemory = memory
|
||||||
.foregroundColor(.red)
|
|
||||||
} else {
|
|
||||||
ScrollView {
|
|
||||||
LazyVGrid(columns: columns, spacing: 4) {
|
|
||||||
ForEach(memories) { memory in
|
|
||||||
MemoryCard(memory: memory)
|
|
||||||
.padding(.horizontal, 2)
|
|
||||||
.onTapGesture {
|
|
||||||
withAnimation(.spring()) {
|
|
||||||
selectedMemory = memory
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.top, 4)
|
|
||||||
.padding(.horizontal, 4)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -172,7 +160,23 @@ struct FullScreenMediaView: View {
|
|||||||
@State private var isVideoPlaying = false
|
@State private var isVideoPlaying = false
|
||||||
@State private var showControls = true
|
@State private var showControls = true
|
||||||
@State private var controlsTimer: Timer? = nil
|
@State private var controlsTimer: Timer? = nil
|
||||||
@State private var player: AVPlayer? = nil
|
@State private var imageAspectRatio: CGFloat = 1.0
|
||||||
|
@State private var isLoading = true
|
||||||
|
|
||||||
|
private func loadAspectRatio(from url: URL) {
|
||||||
|
guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil),
|
||||||
|
let imageProperties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any],
|
||||||
|
let width = imageProperties[kCGImagePropertyPixelWidth] as? CGFloat,
|
||||||
|
let height = imageProperties[kCGImagePropertyPixelHeight] as? CGFloat,
|
||||||
|
height > 0 else {
|
||||||
|
imageAspectRatio = 16/9 // Default to 16:9 if we can't determine the aspect ratio
|
||||||
|
isLoading = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
imageAspectRatio = width / height
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
@ -182,46 +186,67 @@ struct FullScreenMediaView: View {
|
|||||||
// Media content with back button overlay
|
// Media content with back button overlay
|
||||||
ZStack {
|
ZStack {
|
||||||
// Media content
|
// Media content
|
||||||
switch memory.mediaType {
|
GeometryReader { geometry in
|
||||||
case .image(let url):
|
|
||||||
if let imageURL = URL(string: url) {
|
switch memory.mediaType {
|
||||||
AsyncImage(url: imageURL) { phase in
|
case .image(let url):
|
||||||
switch phase {
|
if let imageURL = URL(string: url) {
|
||||||
case .success(let image):
|
AsyncImage(url: imageURL) { phase in
|
||||||
image
|
switch phase {
|
||||||
.resizable()
|
case .success(let image):
|
||||||
.scaledToFill()
|
GeometryReader { geometry in
|
||||||
.frame(width: UIScreen.main.bounds.width,
|
ZStack {
|
||||||
height: UIScreen.main.bounds.height)
|
Color.black
|
||||||
.edgesIgnoringSafeArea(.all)
|
image
|
||||||
case .failure(_):
|
.resizable()
|
||||||
Image(systemName: "exclamationmark.triangle")
|
.scaledToFit()
|
||||||
.foregroundColor(.red)
|
.frame(
|
||||||
case .empty:
|
width: min(geometry.size.width, geometry.size.height * imageAspectRatio),
|
||||||
ProgressView()
|
height: min(geometry.size.height, geometry.size.width / imageAspectRatio)
|
||||||
@unknown default:
|
)
|
||||||
EmptyView()
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
if let uiImage = image.asUIImage() {
|
||||||
|
let size = uiImage.size
|
||||||
|
imageAspectRatio = size.width / size.height
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .failure(_):
|
||||||
|
Image(systemName: "exclamationmark.triangle")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
case .empty:
|
||||||
|
ProgressView()
|
||||||
|
@unknown default:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
case .video(_, let previewUrl):
|
||||||
case .video(let url, let previewUrl):
|
GeometryReader { geometry in
|
||||||
if let videoURL = URL(string: url) {
|
ZStack {
|
||||||
VideoPlayer(player: player)
|
Color.clear
|
||||||
.onAppear {
|
VideoPlayer(url: memory.mediaType.url, isPlaying: $isVideoPlaying)
|
||||||
self.player = AVPlayer(url: videoURL)
|
.aspectRatio(imageAspectRatio, contentMode: .fit)
|
||||||
self.player?.play()
|
.frame(
|
||||||
self.isVideoPlaying = true
|
width: min(geometry.size.width, geometry.size.height * imageAspectRatio),
|
||||||
}
|
height: min(geometry.size.height, geometry.size.width / imageAspectRatio)
|
||||||
.onDisappear {
|
)
|
||||||
self.player?.pause()
|
.onAppear {
|
||||||
self.player = nil
|
if let previewUrl = URL(string: previewUrl) {
|
||||||
}
|
loadAspectRatio(from: previewUrl)
|
||||||
.frame(width: UIScreen.main.bounds.width,
|
}
|
||||||
height: UIScreen.main.bounds.height)
|
isVideoPlaying = true
|
||||||
.onTapGesture {
|
}
|
||||||
togglePlayPause()
|
.onDisappear {
|
||||||
|
isVideoPlaying = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -231,13 +256,14 @@ struct FullScreenMediaView: View {
|
|||||||
Button(action: {
|
Button(action: {
|
||||||
withAnimation(.spring()) {
|
withAnimation(.spring()) {
|
||||||
isPresented = nil
|
isPresented = nil
|
||||||
pauseVideo()
|
// pauseVideo()
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
Image(systemName: "chevron.left")
|
Image(systemName: "chevron.left")
|
||||||
.font(.system(size: 20, weight: .bold))
|
.font(.system(size: 20, weight: .bold))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.padding(12)
|
.padding(12)
|
||||||
|
.background(Circle().fill(Color.black.opacity(0.4)))
|
||||||
}
|
}
|
||||||
.padding(.leading, 16)
|
.padding(.leading, 16)
|
||||||
.padding(.top, UIApplication.shared.windows.first?.safeAreaInsets.top ?? 0)
|
.padding(.top, UIApplication.shared.windows.first?.safeAreaInsets.top ?? 0)
|
||||||
@ -247,28 +273,6 @@ struct FullScreenMediaView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.zIndex(2) // Higher z-index to keep it above media content
|
.zIndex(2) // Higher z-index to keep it above media content
|
||||||
|
|
||||||
// Video controls overlay (only for video)
|
|
||||||
if case .video = memory.mediaType, showControls {
|
|
||||||
VStack {
|
|
||||||
Spacer()
|
|
||||||
// Play/pause button
|
|
||||||
Button(action: {
|
|
||||||
togglePlayPause()
|
|
||||||
}) {
|
|
||||||
Image(systemName: isVideoPlaying ? "pause.circle.fill" : "play.circle.fill")
|
|
||||||
.font(.system(size: 70))
|
|
||||||
.foregroundColor(.white.opacity(0.9))
|
|
||||||
.shadow(radius: 3)
|
|
||||||
}
|
|
||||||
.padding(.bottom, 30)
|
|
||||||
}
|
|
||||||
.transition(.opacity)
|
|
||||||
.onAppear {
|
|
||||||
resetControlsTimer()
|
|
||||||
}
|
|
||||||
.zIndex(3) // Highest z-index for controls
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
.statusBar(hidden: true)
|
.statusBar(hidden: true)
|
||||||
@ -276,120 +280,146 @@ struct FullScreenMediaView: View {
|
|||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
if case .video = memory.mediaType {
|
if case .video = memory.mediaType {
|
||||||
withAnimation(.easeInOut) {
|
withAnimation(.easeInOut) {
|
||||||
showControls.toggle()
|
// showControls.toggle()
|
||||||
}
|
|
||||||
if showControls {
|
|
||||||
resetControlsTimer()
|
|
||||||
}
|
}
|
||||||
|
// if showControls {
|
||||||
|
// resetControlsTimer()
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.statusBar(hidden: true)
|
.statusBar(hidden: true)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
UIApplication.shared.isIdleTimerDisabled = true
|
UIApplication.shared.isIdleTimerDisabled = true
|
||||||
if case .video = memory.mediaType {
|
if case .video = memory.mediaType {
|
||||||
setupVideoPlayer()
|
// setupVideoPlayer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
UIApplication.shared.isIdleTimerDisabled = false
|
UIApplication.shared.isIdleTimerDisabled = false
|
||||||
controlsTimer?.invalidate()
|
controlsTimer?.invalidate()
|
||||||
pauseVideo()
|
// pauseVideo()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupVideoPlayer() {
|
// private func setupVideoPlayer() {
|
||||||
if case .video(let url, _) = memory.mediaType, let videoURL = URL(string: url) {
|
// if case .video(let url, _) = memory.mediaType, let videoURL = URL(string: url) {
|
||||||
self.player = AVPlayer(url: videoURL)
|
// // No need to set up player here
|
||||||
self.player?.play()
|
// }
|
||||||
self.isVideoPlaying = true
|
// }
|
||||||
|
|
||||||
// Add observer for playback end
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
forName: .AVPlayerItemDidPlayToEndTime,
|
|
||||||
object: self.player?.currentItem,
|
|
||||||
queue: .main
|
|
||||||
) { _ in
|
|
||||||
self.player?.seek(to: .zero) { _ in
|
|
||||||
self.player?.play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func togglePlayPause() {
|
// private func togglePlayPause() {
|
||||||
if isVideoPlaying {
|
// if isVideoPlaying {
|
||||||
pauseVideo()
|
// pauseVideo()
|
||||||
} else {
|
// } else {
|
||||||
playVideo()
|
// playVideo()
|
||||||
}
|
// }
|
||||||
withAnimation {
|
// withAnimation {
|
||||||
showControls = true
|
// showControls = true
|
||||||
}
|
// }
|
||||||
resetControlsTimer()
|
// resetControlsTimer()
|
||||||
}
|
// }
|
||||||
|
|
||||||
private func playVideo() {
|
// private func playVideo() {
|
||||||
player?.play()
|
// // No need to play video here
|
||||||
isVideoPlaying = true
|
// }
|
||||||
}
|
|
||||||
|
|
||||||
private func pauseVideo() {
|
// private func pauseVideo() {
|
||||||
player?.pause()
|
// // No need to pause video here
|
||||||
isVideoPlaying = false
|
// }
|
||||||
}
|
|
||||||
|
|
||||||
private func resetControlsTimer() {
|
// private func resetControlsTimer() {
|
||||||
controlsTimer?.invalidate()
|
// controlsTimer?.invalidate()
|
||||||
controlsTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { _ in
|
// controlsTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { _ in
|
||||||
withAnimation(.easeInOut) {
|
// withAnimation(.easeInOut) {
|
||||||
showControls = false
|
// showControls = false
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
struct VideoPlayer: UIViewRepresentable {
|
struct VideoPlayer: UIViewControllerRepresentable {
|
||||||
let player: AVPlayer?
|
let url: String
|
||||||
|
@Binding var isPlaying: Bool
|
||||||
|
|
||||||
func makeUIView(context: Context) -> UIView {
|
func makeUIViewController(context: Context) -> AVPlayerViewController {
|
||||||
let view = UIView()
|
let controller = AVPlayerViewController()
|
||||||
if let player = player {
|
let player = AVPlayer(url: URL(string: url)!)
|
||||||
let playerLayer = AVPlayerLayer(player: player)
|
controller.player = player
|
||||||
playerLayer.frame = UIScreen.main.bounds
|
controller.showsPlaybackControls = true
|
||||||
playerLayer.videoGravity = .resizeAspectFill
|
controller.videoGravity = .resizeAspect
|
||||||
view.layer.addSublayer(playerLayer)
|
|
||||||
}
|
// Make the background transparent
|
||||||
return view
|
controller.view.backgroundColor = .clear
|
||||||
|
controller.view.isOpaque = false
|
||||||
|
|
||||||
|
return controller
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ uiView: UIView, context: Context) {
|
func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {
|
||||||
if let player = player, let playerLayer = uiView.layer.sublayers?.first as? AVPlayerLayer {
|
if isPlaying {
|
||||||
playerLayer.player = player
|
uiViewController.player?.play()
|
||||||
playerLayer.frame = UIScreen.main.bounds
|
} else {
|
||||||
|
uiViewController.player?.pause()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct MemoryCard: View {
|
struct MemoryCard: View {
|
||||||
let memory: MemoryItem
|
let memory: MemoryItem
|
||||||
|
@State private var aspectRatio: CGFloat = 1.0
|
||||||
|
@State private var isLoading = true
|
||||||
|
|
||||||
|
private func loadAspectRatio(from url: URL) {
|
||||||
|
guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil),
|
||||||
|
let imageProperties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any],
|
||||||
|
let width = imageProperties[kCGImagePropertyPixelWidth] as? CGFloat,
|
||||||
|
let height = imageProperties[kCGImagePropertyPixelHeight] as? CGFloat,
|
||||||
|
height > 0 else {
|
||||||
|
aspectRatio = 16/9 // Default to 16:9 if we can't determine the aspect ratio
|
||||||
|
isLoading = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
aspectRatio = width / height
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
ZStack {
|
ZStack {
|
||||||
// Media content
|
|
||||||
Group {
|
Group {
|
||||||
switch memory.mediaType {
|
switch memory.mediaType {
|
||||||
case .image(let url):
|
case .image(let url):
|
||||||
if let url = URL(string: url) {
|
if let url = URL(string: url) {
|
||||||
AsyncImage(url: url) { phase in
|
AsyncImage(url: url) { phase in
|
||||||
if let image = phase.image {
|
Group {
|
||||||
image
|
if let image = phase.image {
|
||||||
.resizable()
|
GeometryReader { geometry in
|
||||||
.aspectRatio(contentMode: .fill)
|
ZStack {
|
||||||
} else if phase.error != nil {
|
Color.black
|
||||||
Color.gray.opacity(0.3)
|
image
|
||||||
} else {
|
.resizable()
|
||||||
ProgressView()
|
.scaledToFit()
|
||||||
|
.frame(
|
||||||
|
width: min(geometry.size.width, geometry.size.height * aspectRatio),
|
||||||
|
height: min(geometry.size.height, geometry.size.width / aspectRatio)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
.aspectRatio(aspectRatio, contentMode: aspectRatio > 1 ? .fit : .fill)
|
||||||
|
.onAppear {
|
||||||
|
if let uiImage = image.asUIImage() {
|
||||||
|
let size = uiImage.size
|
||||||
|
aspectRatio = size.width / size.height
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if phase.error != nil {
|
||||||
|
Color.gray.opacity(0.3)
|
||||||
|
} else {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -397,14 +427,19 @@ struct MemoryCard: View {
|
|||||||
case .video(_, let previewUrl):
|
case .video(_, let previewUrl):
|
||||||
if let previewUrl = URL(string: previewUrl) {
|
if let previewUrl = URL(string: previewUrl) {
|
||||||
AsyncImage(url: previewUrl) { phase in
|
AsyncImage(url: previewUrl) { phase in
|
||||||
if let image = phase.image {
|
Group {
|
||||||
image
|
if let image = phase.image {
|
||||||
.resizable()
|
image
|
||||||
.aspectRatio(contentMode: .fill)
|
.resizable()
|
||||||
} else if phase.error != nil {
|
.aspectRatio(contentMode: .fill)
|
||||||
Color.gray.opacity(0.3)
|
.onAppear {
|
||||||
} else {
|
loadAspectRatio(from: previewUrl)
|
||||||
ProgressView()
|
}
|
||||||
|
} else if phase.error != nil {
|
||||||
|
Color.gray.opacity(0.3)
|
||||||
|
} else {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -412,12 +447,13 @@ struct MemoryCard: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: (UIScreen.main.bounds.width / 2) - 24,
|
.frame(
|
||||||
height: (UIScreen.main.bounds.width / 2 - 24) * (1/memory.aspectRatio))
|
width: (UIScreen.main.bounds.width / 2) - 24,
|
||||||
|
height: (UIScreen.main.bounds.width / 2 - 24) / (isLoading ? 1 : aspectRatio)
|
||||||
|
)
|
||||||
.clipped()
|
.clipped()
|
||||||
.cornerRadius(12)
|
.cornerRadius(12)
|
||||||
|
|
||||||
// Show play button for videos
|
|
||||||
if case .video = memory.mediaType {
|
if case .video = memory.mediaType {
|
||||||
Image(systemName: "play.circle.fill")
|
Image(systemName: "play.circle.fill")
|
||||||
.font(.system(size: 40))
|
.font(.system(size: 40))
|
||||||
@ -426,8 +462,7 @@ struct MemoryCard: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Title and Subtitle
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
|
||||||
Text(memory.title)
|
Text(memory.title)
|
||||||
.font(Typography.font(for: .body, family: .quicksandBold))
|
.font(Typography.font(for: .body, family: .quicksandBold))
|
||||||
.foregroundColor(.themeTextMessageMain)
|
.foregroundColor(.themeTextMessageMain)
|
||||||
@ -444,6 +479,40 @@ struct MemoryCard: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add this extension to get UIImage from Image
|
||||||
|
extension View {
|
||||||
|
func asUIImage() -> UIImage? {
|
||||||
|
let controller = UIHostingController(rootView: self)
|
||||||
|
let view = controller.view
|
||||||
|
|
||||||
|
let targetSize = controller.view.intrinsicContentSize
|
||||||
|
view?.bounds = CGRect(origin: .zero, size: targetSize)
|
||||||
|
view?.backgroundColor = .clear
|
||||||
|
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: targetSize)
|
||||||
|
return renderer.image { _ in
|
||||||
|
view?.drawHierarchy(in: controller.view.bounds, afterScreenUpdates: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add this extension to MemoryMediaType to get the URL
|
||||||
|
private extension MemoryMediaType {
|
||||||
|
var isVideo: Bool {
|
||||||
|
if case .video = self { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var url: String {
|
||||||
|
switch self {
|
||||||
|
case .image(let url):
|
||||||
|
return url
|
||||||
|
case .video(let url, _):
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
MemoriesView()
|
MemoriesView()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -50,18 +50,17 @@ public struct AvatarPicker: View {
|
|||||||
if let selectedImage = selectedImage {
|
if let selectedImage = selectedImage {
|
||||||
Image(uiImage: selectedImage)
|
Image(uiImage: selectedImage)
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFill()
|
.aspectRatio(contentMode: .fill)
|
||||||
.frame(width: 225, height: 225)
|
.frame(width: 225, height: 225)
|
||||||
.scaleEffect(scaleFactor)
|
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 20 * scaleFactor))
|
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 20)
|
RoundedRectangle(cornerRadius: 20)
|
||||||
.stroke(Color.themePrimary, lineWidth: borderWidth)
|
.stroke(Color.themePrimary, lineWidth: borderWidth)
|
||||||
.scaleEffect(scaleFactor)
|
|
||||||
)
|
)
|
||||||
|
.scaleEffect(scaleFactor)
|
||||||
} else {
|
} else {
|
||||||
// Default SVG avatar with animated dashed border
|
// Default SVG avatar with animated dashed border
|
||||||
SVGImage(svgName: "IP")
|
SVGImageHtml(svgName: "IP")
|
||||||
.frame(width: 225, height: 225)
|
.frame(width: 225, height: 225)
|
||||||
.scaleEffect(scaleFactor)
|
.scaleEffect(scaleFactor)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
|
|||||||
@ -15,28 +15,19 @@ enum SubscriptionStatus {
|
|||||||
var title: String {
|
var title: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .free:
|
case .free:
|
||||||
return "Free"
|
return ""
|
||||||
case .pioneer:
|
case .pioneer:
|
||||||
return "Pioneer"
|
return "Pioneer"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var hasExpiry: Bool {
|
|
||||||
switch self {
|
|
||||||
case .free:
|
|
||||||
return false
|
|
||||||
case .pioneer:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var backgroundColor: Color {
|
var backgroundColor: Color {
|
||||||
switch self {
|
switch self {
|
||||||
case .free:
|
case .free:
|
||||||
return Theme.Colors.freeBackground // 浅橙色背景
|
return .clear
|
||||||
case .pioneer:
|
case .pioneer:
|
||||||
return Theme.Colors.pioneerBackground // 橙色背景
|
return .clear
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var textColor: Color {
|
var textColor: Color {
|
||||||
@ -44,7 +35,16 @@ enum SubscriptionStatus {
|
|||||||
case .free:
|
case .free:
|
||||||
return Theme.Colors.textPrimary
|
return Theme.Colors.textPrimary
|
||||||
case .pioneer:
|
case .pioneer:
|
||||||
return Theme.Colors.textPrimary
|
return .themeTextMessageMain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var backgroundImageName: String {
|
||||||
|
switch self {
|
||||||
|
case .free:
|
||||||
|
return "Free"
|
||||||
|
case .pioneer:
|
||||||
|
return "Pioneer"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -53,88 +53,80 @@ enum SubscriptionStatus {
|
|||||||
struct SubscriptionStatusBar: View {
|
struct SubscriptionStatusBar: View {
|
||||||
let status: SubscriptionStatus
|
let status: SubscriptionStatus
|
||||||
let onSubscribeTap: (() -> Void)?
|
let onSubscribeTap: (() -> Void)?
|
||||||
|
private let height: CGFloat
|
||||||
|
|
||||||
init(status: SubscriptionStatus, onSubscribeTap: (() -> Void)? = nil) {
|
init(status: SubscriptionStatus, height: CGFloat? = nil, onSubscribeTap: (() -> Void)? = nil) {
|
||||||
self.status = status
|
self.status = status
|
||||||
|
self.height = height ?? 155 // 默认高度为155
|
||||||
self.onSubscribeTap = onSubscribeTap
|
self.onSubscribeTap = onSubscribeTap
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 16) {
|
ZStack(alignment: .leading) {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
// Background SVG - First layer
|
||||||
// 订阅类型标题
|
SVGImage(svgName: status.backgroundImageName)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 120)
|
||||||
|
.clipped()
|
||||||
|
|
||||||
|
// Main content container
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
// Title - Centered vertically
|
||||||
Text(status.title)
|
Text(status.title)
|
||||||
.font(.system(size: 28, weight: .bold, design: .rounded))
|
.font(.system(size: 28, weight: .bold, design: .rounded))
|
||||||
.foregroundColor(status.textColor)
|
.foregroundColor(status.textColor)
|
||||||
|
.frame(maxHeight: .infinity, alignment: .center) // Center vertically
|
||||||
|
.padding(.leading, 12)
|
||||||
|
.padding(.top, height < 155 ? 30 : 40)
|
||||||
|
|
||||||
// 过期时间或订阅按钮
|
// Expiry date - Bottom left
|
||||||
if case .pioneer(let expiryDate) = status {
|
if case .pioneer(let expiryDate) = status {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("Expires on :")
|
Text("Expires on :")
|
||||||
.font(.system(size: 14, weight: .medium))
|
.font(.system(size: 12))
|
||||||
.foregroundColor(status.textColor.opacity(0.8))
|
.foregroundColor(.themeTextMessageMain)
|
||||||
|
|
||||||
Text(formatDate(expiryDate))
|
Text(formatDate(expiryDate))
|
||||||
.font(.system(size: 16, weight: .semibold))
|
.font(.system(size: 12))
|
||||||
.foregroundColor(status.textColor)
|
.fontWeight(.bold)
|
||||||
}
|
.foregroundColor(.themeTextMessageMain)
|
||||||
} else {
|
|
||||||
Button(action: {
|
|
||||||
onSubscribeTap?()
|
|
||||||
}) {
|
|
||||||
Text("Subscribe")
|
|
||||||
.font(.system(size: 14, weight: .semibold))
|
|
||||||
.foregroundColor(Theme.Colors.textPrimary)
|
|
||||||
.padding(.horizontal, 20)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
.background(Theme.Colors.subscribeButton)
|
|
||||||
.cornerRadius(Theme.CornerRadius.large)
|
|
||||||
}
|
}
|
||||||
|
.padding(.leading, 12)
|
||||||
|
.padding(.bottom, 12)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||||
Spacer()
|
|
||||||
|
|
||||||
// 播放按钮图标
|
|
||||||
Circle()
|
|
||||||
.fill(Color.black)
|
|
||||||
.frame(width: 60, height: 60)
|
|
||||||
.overlay(
|
|
||||||
Image(systemName: "play.fill")
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.font(.title2)
|
|
||||||
.offset(x: 2) // 微调播放图标位置
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.padding(20)
|
.frame(height: height)
|
||||||
.background(status.backgroundColor)
|
|
||||||
.cornerRadius(20)
|
|
||||||
// .shadow(color: Color.black.opacity(0.1), radius: 4, x: 0, y: 2)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 日期格式化
|
// MARK: - 日期格式化
|
||||||
private func formatDate(_ date: Date) -> String {
|
private func formatDate(_ date: Date) -> String {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateFormat = "MMMM d, yyyy"
|
formatter.dateFormat = "MMM d, yyyy"
|
||||||
return formatter.string(from: date)
|
return formatter.string(from: date)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 预览
|
// MARK: - 预览
|
||||||
#Preview("Free Status") {
|
#Preview {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
|
// Free status preview
|
||||||
SubscriptionStatusBar(
|
SubscriptionStatusBar(
|
||||||
status: .free,
|
status: .free,
|
||||||
onSubscribeTap: {
|
onSubscribeTap: {
|
||||||
print("Subscribe tapped")
|
print("Subscribe tapped")
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.padding()
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
// Pioneer status preview
|
||||||
SubscriptionStatusBar(
|
SubscriptionStatusBar(
|
||||||
status: .pioneer(expiryDate: Calendar.current.date(byAdding: .month, value: 6, to: Date()) ?? Date())
|
status: .pioneer(
|
||||||
|
expiryDate: Calendar.current.date(byAdding: .month, value: 6, to: Date()) ?? Date()
|
||||||
|
)
|
||||||
)
|
)
|
||||||
.padding()
|
.padding(.horizontal)
|
||||||
}
|
}
|
||||||
.background(Color(.systemGroupedBackground))
|
.padding()
|
||||||
|
.background(Theme.Colors.background)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import StoreKit
|
import StoreKit
|
||||||
|
import Network
|
||||||
|
|
||||||
// MARK: - 订阅计划枚举
|
// MARK: - 订阅计划枚举
|
||||||
enum SubscriptionPlan: String, CaseIterable {
|
enum SubscriptionPlan: String, CaseIterable {
|
||||||
@ -45,7 +46,8 @@ struct SubscribeView: View {
|
|||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
@State private var showErrorAlert = false
|
@State private var showErrorAlert = false
|
||||||
@State private var errorText = ""
|
@State private var errorText = ""
|
||||||
|
@State private var memberProfile: MemberProfile?
|
||||||
|
@State private var showSuccessAlert = false
|
||||||
|
|
||||||
// 功能对比数据
|
// 功能对比数据
|
||||||
private let features = [
|
private let features = [
|
||||||
@ -61,7 +63,6 @@ struct SubscribeView: View {
|
|||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
.background(Color.themeTextWhiteSecondary)
|
.background(Color.themeTextWhiteSecondary)
|
||||||
.padding(.bottom, Theme.Spacing.lg)
|
|
||||||
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@ -100,6 +101,9 @@ struct SubscribeView: View {
|
|||||||
}
|
}
|
||||||
.background(Color.themeTextWhiteSecondary)
|
.background(Color.themeTextWhiteSecondary)
|
||||||
.navigationBarHidden(true)
|
.navigationBarHidden(true)
|
||||||
|
.onAppear {
|
||||||
|
fetchMemberInfo()
|
||||||
|
}
|
||||||
.task {
|
.task {
|
||||||
// Load products and refresh current entitlements on appear
|
// Load products and refresh current entitlements on appear
|
||||||
await store.loadProducts()
|
await store.loadProducts()
|
||||||
@ -120,13 +124,21 @@ struct SubscribeView: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text(errorText)
|
Text(errorText)
|
||||||
}
|
}
|
||||||
|
.alert("Purchase Success", isPresented: $showSuccessAlert) {
|
||||||
|
Button("OK", role: .cancel) { }
|
||||||
|
} message: {
|
||||||
|
Text("购买成功!")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 当前订阅状态卡片
|
// MARK: - 当前订阅状态卡片
|
||||||
private var currentSubscriptionCard: some View {
|
private var currentSubscriptionCard: some View {
|
||||||
let status: SubscriptionStatus = {
|
let status: SubscriptionStatus = {
|
||||||
if store.isSubscribed {
|
if memberProfile?.membershipLevel == "Pioneer" {
|
||||||
return .pioneer(expiryDate: store.subscriptionExpiry ?? Date())
|
let dateFormatter = ISO8601DateFormatter()
|
||||||
|
dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
let expiryDate = memberProfile.flatMap { dateFormatter.date(from: $0.membershipEndAt) } ?? Date()
|
||||||
|
return .pioneer(expiryDate: expiryDate)
|
||||||
} else {
|
} else {
|
||||||
return .free
|
return .free
|
||||||
}
|
}
|
||||||
@ -146,7 +158,7 @@ struct SubscribeView: View {
|
|||||||
private var creditsSection: some View {
|
private var creditsSection: some View {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
CreditsInfoCard(
|
CreditsInfoCard(
|
||||||
totalCredits: 3290,
|
totalCredits: memberProfile?.remainPoints ?? 0,
|
||||||
onInfoTap: {
|
onInfoTap: {
|
||||||
// 显示积分信息说明
|
// 显示积分信息说明
|
||||||
},
|
},
|
||||||
@ -226,6 +238,9 @@ struct SubscribeView: View {
|
|||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
// 打开服务条款
|
// 打开服务条款
|
||||||
|
if let url = URL(string: "https://memorywake.com/privacy-policy") {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
}) {
|
}) {
|
||||||
Text("Terms of Service")
|
Text("Terms of Service")
|
||||||
.underline()
|
.underline()
|
||||||
@ -236,6 +251,9 @@ struct SubscribeView: View {
|
|||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
// 打开隐私政策
|
// 打开隐私政策
|
||||||
|
if let url = URL(string: "https://memorywake.com/privacy-policy") {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
}) {
|
}) {
|
||||||
Text("Privacy Policy")
|
Text("Privacy Policy")
|
||||||
.underline()
|
.underline()
|
||||||
@ -244,13 +262,24 @@ struct SubscribeView: View {
|
|||||||
Text("|")
|
Text("|")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
// Button(action: {
|
||||||
|
// Task { await store.restorePurchases() }
|
||||||
|
// }) {
|
||||||
|
// Text("Restore Purchase")
|
||||||
|
// .underline()
|
||||||
|
// }
|
||||||
Button(action: {
|
Button(action: {
|
||||||
Task { await store.restorePurchases() }
|
// 打开隐私政策
|
||||||
}) {
|
if let url = URL(string: "https://memorywake.com/privacy-policy") {
|
||||||
Text("Restore Purchase")
|
UIApplication.shared.open(url)
|
||||||
.underline()
|
}
|
||||||
|
}) {
|
||||||
|
Text("AI Usage Guidelines")
|
||||||
|
.underline()
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.font(Typography.font(for: .caption, family: .quicksandRegular))
|
.font(Typography.font(for: .caption, family: .quicksandRegular))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.padding(.top, Theme.Spacing.sm)
|
.padding(.top, Theme.Spacing.sm)
|
||||||
@ -258,7 +287,310 @@ struct SubscribeView: View {
|
|||||||
|
|
||||||
// MARK: - 订阅处理
|
// MARK: - 订阅处理
|
||||||
private func handleSubscribe() {
|
private func handleSubscribe() {
|
||||||
Task { await store.purchasePioneer() }
|
isLoading = true
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
print("🔄 开始订阅流程...")
|
||||||
|
|
||||||
|
// 1. 调用后端创建订单
|
||||||
|
print("🔄 正在创建订单...")
|
||||||
|
let orderInfo = try await createOrder()
|
||||||
|
|
||||||
|
// 2. 根据创建订单返回的id来调用创建支付接口
|
||||||
|
print("🔄 正在创建支付...")
|
||||||
|
let paymentInfo = try await createPayment(orderId: orderInfo.id)
|
||||||
|
|
||||||
|
// 3. 使用订单信息进行应用内购买
|
||||||
|
print("🔄 开始苹果内购流程...")
|
||||||
|
do {
|
||||||
|
// 发起苹果内购
|
||||||
|
let transactionId = try await store.purchasePioneer()
|
||||||
|
print("✅ 苹果内购成功,交易ID: \(transactionId)")
|
||||||
|
|
||||||
|
// 4. 通知服务器支付成功
|
||||||
|
print("🔄 正在通知服务器支付处理中...")
|
||||||
|
_ = try await notifyPaymentProcessing(
|
||||||
|
transactionId: paymentInfo.transactionId ?? paymentInfo.id,
|
||||||
|
// thirdPartyTransactionId: transactionId
|
||||||
|
)
|
||||||
|
|
||||||
|
print("🔄 正在通知服务器支付成功...")
|
||||||
|
_ = try await notifyPaymentSuccess(
|
||||||
|
transactionId: paymentInfo.transactionId ?? paymentInfo.id,
|
||||||
|
// thirdPartyTransactionId: transactionId
|
||||||
|
)
|
||||||
|
|
||||||
|
print("✅ 订阅流程完成")
|
||||||
|
|
||||||
|
// 5. 成功后关闭页面
|
||||||
|
await MainActor.run {
|
||||||
|
self.isLoading = false
|
||||||
|
self.dismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch let purchaseError as NSError {
|
||||||
|
print("❌ 苹果内购失败: \(purchaseError.localizedDescription)")
|
||||||
|
|
||||||
|
// 通知服务器支付失败
|
||||||
|
print("🔄 正在通知服务器支付失败...")
|
||||||
|
_ = try? await notifyPaymentFailure(
|
||||||
|
transactionId: paymentInfo.transactionId ?? paymentInfo.id,
|
||||||
|
reason: purchaseError.localizedDescription
|
||||||
|
)
|
||||||
|
|
||||||
|
// 重新抛出错误以便外部处理
|
||||||
|
throw purchaseError
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch let error as NSError {
|
||||||
|
print("❌ 订阅失败: \(error.localizedDescription)")
|
||||||
|
|
||||||
|
// 根据错误类型显示不同的错误信息
|
||||||
|
var errorMessage = error.localizedDescription
|
||||||
|
|
||||||
|
if error.domain == "NetworkError" {
|
||||||
|
errorMessage = "网络连接失败,请检查您的网络设置"
|
||||||
|
} else if error.domain == "APIError" {
|
||||||
|
errorMessage = "请求失败,请稍后重试 (错误码: \(error.code))"
|
||||||
|
} else if error.domain == NSURLErrorDomain {
|
||||||
|
switch error.code {
|
||||||
|
case NSURLErrorNotConnectedToInternet, NSURLErrorNetworkConnectionLost:
|
||||||
|
errorMessage = "网络连接已断开,请检查您的网络设置"
|
||||||
|
case NSURLErrorTimedOut:
|
||||||
|
errorMessage = "请求超时,请稍后重试"
|
||||||
|
case NSURLErrorCannotConnectToHost, NSURLErrorCannotFindHost:
|
||||||
|
errorMessage = "无法连接到服务器,请稍后重试"
|
||||||
|
default:
|
||||||
|
errorMessage = "网络错误: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在主线程更新UI
|
||||||
|
await MainActor.run {
|
||||||
|
self.isLoading = false
|
||||||
|
self.errorText = errorMessage
|
||||||
|
self.showErrorAlert = true
|
||||||
|
print("❌ 错误提示: \(errorMessage)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建订单
|
||||||
|
private func createOrder() async throws -> OrderInfo {
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
let parameters: [String: Any] = [
|
||||||
|
"items": [
|
||||||
|
[
|
||||||
|
"product_item_id": 5,
|
||||||
|
"quantity": 1
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
print("🔄 开始创建订单请求,参数:\(parameters)")
|
||||||
|
|
||||||
|
// 检查网络连接
|
||||||
|
let monitor = NWPathMonitor()
|
||||||
|
let queue = DispatchQueue(label: "NetworkMonitor")
|
||||||
|
monitor.pathUpdateHandler = { path in
|
||||||
|
if path.status == .satisfied {
|
||||||
|
// 网络可用,继续执行网络请求
|
||||||
|
NetworkService.shared.postWithToken(
|
||||||
|
path: "/order/create",
|
||||||
|
parameters: parameters
|
||||||
|
) { (result: Result<APIResponse<OrderInfo>, NetworkError>) in
|
||||||
|
switch result {
|
||||||
|
case .success(let response):
|
||||||
|
print("✅ 请求成功,状态码:\(response.code)")
|
||||||
|
print("📦 返回数据:\(String(describing: response.data))")
|
||||||
|
|
||||||
|
if response.code == 0 {
|
||||||
|
continuation.resume(returning: response.data)
|
||||||
|
} else {
|
||||||
|
let errorMessage = "创建订单失败,状态码:\(response.code)"
|
||||||
|
print("❌ \(errorMessage)")
|
||||||
|
continuation.resume(throwing: NSError(
|
||||||
|
domain: "APIError",
|
||||||
|
code: response.code,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: errorMessage]
|
||||||
|
))
|
||||||
|
}
|
||||||
|
case .failure(let error):
|
||||||
|
print("❌ 请求异常:\(error.localizedDescription)")
|
||||||
|
print("🔍 错误详情:\(error)")
|
||||||
|
if let urlError = error as? URLError {
|
||||||
|
print("🌐 URL错误: \(urlError.code.rawValue) - \(urlError.localizedDescription)")
|
||||||
|
print("🔗 失败URL: \(urlError.failingURL?.absoluteString ?? "未知")")
|
||||||
|
}
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 网络不可用,抛出错误
|
||||||
|
let errorMessage = "网络连接不可用,请检查网络设置"
|
||||||
|
print("❌ \(errorMessage)")
|
||||||
|
continuation.resume(throwing: NSError(domain: "NetworkError", code: 0, userInfo: [NSLocalizedDescriptionKey: errorMessage]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
monitor.start(queue: queue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 创建支付
|
||||||
|
private func createPayment(orderId: String) async throws -> PaymentInfo {
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
let parameters: [String: Any] = [
|
||||||
|
"order_id": orderId,
|
||||||
|
"payment_method": "ApplePay"
|
||||||
|
]
|
||||||
|
|
||||||
|
print("🔄 开始创建支付请求,参数:\(parameters)")
|
||||||
|
|
||||||
|
NetworkService.shared.postWithToken(
|
||||||
|
path: "/order/pay",
|
||||||
|
parameters: parameters
|
||||||
|
) { (result: Result<APIResponse<PaymentInfo>, NetworkError>) in
|
||||||
|
switch result {
|
||||||
|
case .success(let response):
|
||||||
|
print("✅ 请求成功,状态码:\(response.code)")
|
||||||
|
print("📦 返回数据:\(String(describing: response.data))")
|
||||||
|
|
||||||
|
if response.code == 0 {
|
||||||
|
continuation.resume(returning: response.data)
|
||||||
|
} else {
|
||||||
|
let errorMessage = "创建支付失败,状态码:\(response.code)"
|
||||||
|
print("❌ \(errorMessage)")
|
||||||
|
continuation.resume(throwing: NSError(
|
||||||
|
domain: "APIError",
|
||||||
|
code: response.code,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: errorMessage]
|
||||||
|
))
|
||||||
|
}
|
||||||
|
case .failure(let error):
|
||||||
|
print("❌ 请求异常:\(error.localizedDescription)")
|
||||||
|
print("🔍 错误详情:\(error)")
|
||||||
|
if let urlError = error as? URLError {
|
||||||
|
print("🌐 URL错误: \(urlError.code.rawValue) - \(urlError.localizedDescription)")
|
||||||
|
print("🔗 失败URL: \(urlError.failingURL?.absoluteString ?? "未知")")
|
||||||
|
}
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 支付结果处理
|
||||||
|
|
||||||
|
/// 通知服务器支付处理中
|
||||||
|
/// - Parameter transactionId: 交易ID
|
||||||
|
/// - Parameter thirdPartyTransactionId: 第三方交易ID,可选
|
||||||
|
private func notifyPaymentProcessing(transactionId: String, thirdPartyTransactionId: String? = nil) async throws -> Bool {
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
var parameters: [String: Any] = ["transaction_id": transactionId]
|
||||||
|
|
||||||
|
// 只有在提供了第三方交易ID时才添加到参数中
|
||||||
|
if let thirdPartyId = thirdPartyTransactionId {
|
||||||
|
parameters["third_party_transaction_id"] = thirdPartyId
|
||||||
|
}
|
||||||
|
|
||||||
|
print("🔄 通知服务器支付处理中,参数:\(parameters)")
|
||||||
|
|
||||||
|
NetworkService.shared.postWithToken(
|
||||||
|
path: "/order/pay-processing",
|
||||||
|
parameters: parameters
|
||||||
|
) { (result: Result<APIResponse<[String: String]?>, NetworkError>) in
|
||||||
|
switch result {
|
||||||
|
case .success(let response):
|
||||||
|
print("✅ 支付处理通知发送成功,状态码:\(response.code)")
|
||||||
|
continuation.resume(returning: response.code == 0)
|
||||||
|
case .failure(let error):
|
||||||
|
print("❌ 支付处理通知发送失败:\(error.localizedDescription)")
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 通知服务器支付成功
|
||||||
|
/// - Parameter transactionId: 交易ID
|
||||||
|
/// - Parameter thirdPartyTransactionId: 第三方交易ID,可选
|
||||||
|
private func notifyPaymentSuccess(transactionId: String, thirdPartyTransactionId: String? = nil) async throws -> Bool {
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
var parameters: [String: Any] = ["transaction_id": transactionId]
|
||||||
|
|
||||||
|
// 只有在提供了第三方交易ID时才添加到参数中
|
||||||
|
if let thirdPartyId = thirdPartyTransactionId {
|
||||||
|
parameters["third_party_transaction_id"] = thirdPartyId
|
||||||
|
}
|
||||||
|
|
||||||
|
print("🔄 通知服务器支付成功,参数:\(parameters)")
|
||||||
|
|
||||||
|
NetworkService.shared.postWithToken(
|
||||||
|
path: "/order/pay-success",
|
||||||
|
parameters: parameters
|
||||||
|
) { (result: Result<APIResponse<[String: String]?>, NetworkError>) in
|
||||||
|
switch result {
|
||||||
|
case .success(let response):
|
||||||
|
print("✅ 支付成功通知发送成功,状态码:\(response.code)")
|
||||||
|
continuation.resume(returning: response.code == 0)
|
||||||
|
case .failure(let error):
|
||||||
|
print("❌ 支付成功通知发送失败:\(error.localizedDescription)")
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 通知服务器支付失败
|
||||||
|
/// - Parameter transactionId: 交易ID
|
||||||
|
/// - Parameter reason: 失败原因
|
||||||
|
private func notifyPaymentFailure(transactionId: String, reason: String) async throws -> Bool {
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
let parameters: [String: Any] = [
|
||||||
|
"transaction_id": transactionId,
|
||||||
|
"reason": reason
|
||||||
|
]
|
||||||
|
|
||||||
|
print("🔄 通知服务器支付失败,参数:\(parameters)")
|
||||||
|
|
||||||
|
NetworkService.shared.postWithToken(
|
||||||
|
path: "/order/pay-failure",
|
||||||
|
parameters: parameters
|
||||||
|
) { (result: Result<APIResponse<[String: String]?>, NetworkError>) in
|
||||||
|
switch result {
|
||||||
|
case .success(let response):
|
||||||
|
print("✅ 支付失败通知发送成功,状态码:\(response.code)")
|
||||||
|
continuation.resume(returning: response.code == 0)
|
||||||
|
case .failure(let error):
|
||||||
|
print("❌ 支付失败通知发送失败:\(error.localizedDescription)")
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helper Methods
|
||||||
|
private func fetchMemberInfo() {
|
||||||
|
NetworkService.shared.get(
|
||||||
|
path: "/membership/personal-center-info",
|
||||||
|
parameters: nil
|
||||||
|
) { (result: Result<APIResponse<MemberProfile>, NetworkError>) in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
switch result {
|
||||||
|
case .success(let response):
|
||||||
|
self.memberProfile = response.data
|
||||||
|
print("✅ 成功获取会员信息:", response.data)
|
||||||
|
print("✅ 用户ID:", response.data.userInfo.userId)
|
||||||
|
print("✅ 用户昵称:", response.data.userInfo.nickname)
|
||||||
|
print("✅ 用户邮箱:", response.data.userInfo.email)
|
||||||
|
case .failure(let error):
|
||||||
|
print("❌ 获取会员信息失败:", error)
|
||||||
|
// Optionally show an error message to the user
|
||||||
|
self.errorText = "Failed to load member info: \(error.localizedDescription)"
|
||||||
|
self.showErrorAlert = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -599,7 +599,7 @@ struct UploadPromptView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: { showMediaPicker = true }) {
|
Button(action: { showMediaPicker = true }) {
|
||||||
// 上传图标
|
// 上传图标
|
||||||
SVGImage(svgName: "IP")
|
SVGImageHtml(svgName: "IP")
|
||||||
.frame(width: 225, height: 225)
|
.frame(width: 225, height: 225)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.overlay(
|
.overlay(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user