feat: 主页面轮询

This commit is contained in:
jinyaqiu 2025-09-01 11:41:36 +08:00
parent 4cbfaefb49
commit aa954ddfb9
2 changed files with 427 additions and 167 deletions

View File

@ -2,17 +2,16 @@ import SwiftUI
import SwiftData import SwiftData
import AVKit import AVKit
// MARK: - Constants //
private enum MediaURLs { extension Notification.Name {
static let videoURL = "https://cdn.memorywake.com/users/7363409620351717377/files/7366657553935241216/39C069E1-7C3E-4261-8486-12058F855B38.mov" static let blindBoxStatusChanged = Notification.Name("blindBoxStatusChanged")
static let imageURL = "https://cdn.fairclip.cn/files/7343228671693557760/20250604-164000.jpg"
static let VideoBlindURL = "https://cdn.memorywake.com/users/7363409620351717377/files/7366658779259211776/AD970D28-9D1E-4817-A245-F11967441B8F.mp4"
} }
private enum BlindBoxAnimationPhase { private enum BlindBoxAnimationPhase {
case loading case loading
case ready case ready
case opening case opening
case none
} }
extension Notification.Name { extension Notification.Name {
@ -198,6 +197,70 @@ struct BlindBoxView: View {
let url: String let url: String
} }
// MARK: - BlindBox Response Model
struct BlindBoxData: Codable {
let id: Int64
let boxCode: String
let userId: Int64
let name: String
let boxType: String
let features: String?
let url: String?
let status: String
let workflowInstanceId: String?
//
let videoGenerateTime: String?
let createTime: String
let description: String?
enum CodingKeys: String, CodingKey {
case id
case boxCode = "box_code"
case userId = "user_id"
case name
case boxType = "box_type"
case features
case url
case status
case workflowInstanceId = "workflow_instance_id"
case videoGenerateTime = "video_generate_time"
case createTime = "create_time"
case description
}
init(id: Int64, boxCode: String, userId: Int64, name: String, boxType: String, features: String?, url: String?, status: String, workflowInstanceId: String?, videoGenerateTime: String?, createTime: String, description: String?) {
self.id = id
self.boxCode = boxCode
self.userId = userId
self.name = name
self.boxType = boxType
self.features = features
self.url = url
self.status = status
self.workflowInstanceId = workflowInstanceId
self.videoGenerateTime = videoGenerateTime
self.createTime = createTime
self.description = description
}
init(from listItem: BlindList) {
self.init(
id: listItem.id,
boxCode: listItem.boxCode,
userId: listItem.userId,
name: listItem.name,
boxType: listItem.boxType,
features: listItem.features,
url: nil,
status: listItem.status,
workflowInstanceId: listItem.workflowInstanceId,
videoGenerateTime: listItem.videoGenerateTime,
createTime: listItem.createTime,
description: listItem.description
)
}
}
let mediaType: BlindBoxMediaType let mediaType: BlindBoxMediaType
@State private var showModal = false // @State private var showModal = false //
@ -206,7 +269,21 @@ struct BlindBoxView: View {
@State private var memberProfile: MemberProfile? = nil @State private var memberProfile: MemberProfile? = nil
@State private var blindCount: BlindCount? = nil @State private var blindCount: BlindCount? = nil
@State private var blindList: [BlindList] = [] // Changed to array @State private var blindList: [BlindList] = [] // Changed to array
//
@State private var blindGenerate : BlindBoxData?
@State private var showLottieAnimation = true @State private var showLottieAnimation = true
//
@State private var isPolling = false
@State private var pollingTimer: Timer?
@State private var currentBoxType: String = ""
//
@State private var videoURL: String = ""
@State private var imageURL: String = ""
//
@State private var countdown: (minutes: Int, seconds: Int, milliseconds: Int) = (36, 50, 20)
@State private var countdownTimer: Timer?
//
@State private var displayData: BlindBoxData? = nil
@State private var showScalingOverlay = false @State private var showScalingOverlay = false
@State private var animationPhase: BlindBoxAnimationPhase = .loading @State private var animationPhase: BlindBoxAnimationPhase = .loading
@State private var scale: CGFloat = 0.1 @State private var scale: CGFloat = 0.1
@ -224,82 +301,171 @@ struct BlindBoxView: View {
self.mediaType = mediaType self.mediaType = mediaType
} }
//
private func startCountdown() {
// 36:50:20
countdown = (36, 50, 20)
countdownTimer?.invalidate()
countdownTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
var (minutes, seconds, milliseconds) = countdown
//
milliseconds -= 10
if milliseconds < 0 {
milliseconds = 90
seconds -= 1
}
//
if seconds < 0 {
seconds = 59
minutes -= 1
}
//
if minutes <= 0 && seconds <= 0 && milliseconds <= 0 {
countdownTimer?.invalidate()
countdownTimer = nil
return
}
countdown = (minutes, seconds, milliseconds)
}
}
private func loadMedia() { private func loadMedia() {
print("loadMedia called with mediaType: \(mediaType)") print("loadMedia called with mediaType: \(mediaType)")
switch mediaType { switch mediaType {
case .video: case .video:
loadVideo() loadVideo()
print("Loading video...") currentBoxType = "Video"
case .image: startPolling()
loadImage() case .image:
print("Loading image...") loadImage()
case .all: currentBoxType = "Image"
print("Loading all content...") startPolling()
// case .all:
NetworkService.shared.get( print("Loading all content...")
path: "/membership/personal-center-info", //
parameters: nil NetworkService.shared.get(
) { (result: Result<APIResponse<MemberProfile>, NetworkError>) in path: "/membership/personal-center-info",
DispatchQueue.main.async { parameters: nil
switch result { ) { (result: Result<APIResponse<MemberProfile>, NetworkError>) in
case .success(let response): DispatchQueue.main.async {
self.memberProfile = response.data switch result {
print("✅ 成功获取会员信息:", response.data) case .success(let response):
print("✅ 用户ID:", response.data.userInfo.userId) self.memberProfile = response.data
print("✅ 用户昵称:", response.data.userInfo.nickname) print("✅ 成功获取会员信息:", response.data)
print("✅ 用户邮箱:", response.data.userInfo.email) print("✅ 用户ID:", response.data.userInfo.userId)
case .failure(let error): print("✅ 用户昵称:", response.data.userInfo.nickname)
print("❌ 获取会员信息失败:", error) print("✅ 用户邮箱:", response.data.userInfo.email)
} case .failure(let error):
} print("❌ 获取会员信息失败:", error)
} }
// }
NetworkService.shared.get( }
path: "/blind_box/available/quantity", //
parameters: nil NetworkService.shared.get(
) { (result: Result<APIResponse<BlindCount>, NetworkError>) in path: "/blind_box/available/quantity",
DispatchQueue.main.async { parameters: nil
switch result { ) { (result: Result<APIResponse<BlindCount>, NetworkError>) in
case .success(let response): DispatchQueue.main.async {
self.blindCount = response.data switch result {
print("✅ 成功获取盲盒数量:", response.data) case .success(let response):
case .failure(let error): self.blindCount = response.data
print("❌ 获取数量失败:", error) print("✅ 成功获取盲盒数量:", response.data)
} case .failure(let error):
} print("❌ 获取数量失败:", error)
} }
// }
NetworkService.shared.get( }
path: "/blind_boxs/query", //
parameters: nil NetworkService.shared.get(
) { (result: Result<APIResponse<[BlindList]>, NetworkError>) in path: "/blind_boxs/query",
DispatchQueue.main.async { parameters: nil
switch result { ) { (result: Result<APIResponse<[BlindList]>, NetworkError>) in
case .success(let response): DispatchQueue.main.async {
self.blindList = response.data ?? [] switch result {
print("✅ 成功获取 \(self.blindList.count) 个盲盒") case .success(let response):
case .failure(let error): self.blindList = response.data ?? []
self.blindList = [] // none
print("❌ 获取盲盒列表失败:", error.localizedDescription) if self.blindList.isEmpty {
self.animationPhase = .none
}
print("✅ 成功获取 \(self.blindList.count) 个盲盒")
case .failure(let error):
self.blindList = []
self.animationPhase = .none
print("❌ 获取盲盒列表失败:", error.localizedDescription)
}
} }
} }
} }
}
//
private func startPolling() {
stopPolling()
isPolling = true
checkBlindBoxStatus()
}
// private func stopPolling() {
NetworkService.shared.postWithToken( pollingTimer?.invalidate()
path: "/blind_box/generate/mock", pollingTimer = nil
parameters: ["box_type": "Image"] isPolling = false
) { (result: Result<APIResponse<[BlindList]>, NetworkError>) in }
DispatchQueue.main.async {
switch result { private func checkBlindBoxStatus() {
case .success(let response): guard !currentBoxType.isEmpty else {
self.blindList = response.data ?? [] stopPolling()
print("✅✅✅✅✅✅✅✅✅ 成功获取 \(self.blindList.count) 个盲盒") return
case .failure(let error): }
self.blindList = []
print("❌❌ ❌ ❌ ❌ ❌ ❌ 获取盲盒列表失败:", error.localizedDescription) NetworkService.shared.postWithToken(
path: "/blind_box/generate/mock",
parameters: ["box_type": currentBoxType]
) { (result: Result<APIResponse<BlindBoxData>, NetworkError>) in
DispatchQueue.main.async {
switch result {
case .success(let response):
let data = response.data
self.blindGenerate = data
print("当前盲盒状态: \(data.status)")
//
if self.mediaType == .all, let firstItem = self.blindList.first {
self.displayData = BlindBoxData(from: firstItem)
} else {
self.displayData = data
} }
//
NotificationCenter.default.post(
name: .blindBoxStatusChanged,
object: nil,
userInfo: ["status": data.status]
)
if data.status != "Preparing" {
self.stopPolling()
print("✅ 盲盒准备就绪,状态: \(data.status)")
if self.mediaType == .video {
self.videoURL = data.url ?? ""
} else if self.mediaType == .image {
self.imageURL = data.url ?? ""
}
} else {
self.pollingTimer = Timer.scheduledTimer(
withTimeInterval: 2.0,
repeats: false
) { _ in
self.checkBlindBoxStatus()
}
}
case .failure(let error):
print("❌ 获取盲盒状态失败: \(error.localizedDescription)")
self.stopPolling()
} }
} }
} }
@ -324,7 +490,11 @@ struct BlindBoxView: View {
} }
private func loadImage() { private func loadImage() {
guard let url = URL(string: MediaURLs.imageURL) else { return } guard !imageURL.isEmpty, let url = URL(string: imageURL) else {
print("⚠️ 图片URL无效或为空")
return
}
URLSession.shared.dataTask(with: url) { data, _, _ in URLSession.shared.dataTask(with: url) { data, _, _ in
if let data = data, let image = UIImage(data: data) { if let data = data, let image = UIImage(data: data) {
DispatchQueue.main.async { DispatchQueue.main.async {
@ -337,7 +507,11 @@ struct BlindBoxView: View {
} }
private func loadVideo() { private func loadVideo() {
guard let url = URL(string: MediaURLs.videoURL) else { return } guard !videoURL.isEmpty, let url = URL(string: videoURL) else {
print("⚠️ 视频URL无效或为空")
return
}
let asset = AVAsset(url: url) let asset = AVAsset(url: url)
let playerItem = AVPlayerItem(asset: asset) let playerItem = AVPlayerItem(asset: asset)
let player = AVPlayer(playerItem: playerItem) let player = AVPlayer(playerItem: playerItem)
@ -352,8 +526,9 @@ struct BlindBoxView: View {
isPortrait = height > width isPortrait = height > width
} }
player.isMuted = true // Update the video player
self.videoPlayer = player videoPlayer = player
videoPlayer?.play()
} }
private func startScalingAnimation() { private func startScalingAnimation() {
@ -388,8 +563,51 @@ struct BlindBoxView: View {
.onAppear { .onAppear {
print("🎯 BlindBoxView appeared with mediaType: \(mediaType)") print("🎯 BlindBoxView appeared with mediaType: \(mediaType)")
print("🎯 Current thread: \(Thread.current)") print("🎯 Current thread: \(Thread.current)")
//
if mediaType == .all, let firstItem = blindList.first {
displayData = BlindBoxData(from: firstItem)
} else {
displayData = blindGenerate
}
//
NotificationCenter.default.addObserver(
forName: .blindBoxStatusChanged,
object: nil,
queue: .main
) { notification in
if let status = notification.userInfo?["status"] as? String {
switch status {
case "Preparing":
withAnimation {
self.animationPhase = .loading
}
case "Unopened":
withAnimation {
self.animationPhase = .ready
}
default:
//
withAnimation {
self.animationPhase = .ready
}
break
}
}
}
//
loadMedia() loadMedia()
} }
.onDisappear {
stopPolling()
countdownTimer?.invalidate()
countdownTimer = nil
NotificationCenter.default.removeObserver(
self,
name: .blindBoxStatusChanged,
object: nil
)
}
if showScalingOverlay { if showScalingOverlay {
ZStack { ZStack {
@ -426,8 +644,8 @@ struct BlindBoxView: View {
HStack { HStack {
Button(action: { Button(action: {
// BlindOutcomeView // BlindOutcomeView
if mediaType == .video, let videoURL = URL(string: MediaURLs.videoURL) { if mediaType == .video, !videoURL.isEmpty, let url = URL(string: videoURL) {
Router.shared.navigate(to: .blindOutcome(media: .video(videoURL, nil))) Router.shared.navigate(to: .blindOutcome(media: .video(url, nil)))
} else if mediaType == .image, let image = displayImage { } else if mediaType == .image, let image = displayImage {
Router.shared.navigate(to: .blindOutcome(media: .image(image))) Router.shared.navigate(to: .blindOutcome(media: .image(image)))
} }
@ -471,57 +689,59 @@ struct BlindBoxView: View {
// Original content // Original content
VStack { VStack {
VStack(spacing: 20) { VStack(spacing: 20) {
// if mediaType == .all {
HStack { //
// HStack {
Button(action: showUserProfile) { //
SVGImage(svgName: "User") Button(action: showUserProfile) {
.frame(width: 24, height: 24) SVGImage(svgName: "User")
} .frame(width: 24, height: 24)
}
Spacer() Spacer()
// // // //
// NavigationLink(destination: TestView()) { // NavigationLink(destination: TestView()) {
// Text("TestView") // Text("TestView")
// .font(.subheadline) // .font(.subheadline)
// .padding(.horizontal, 12) // .padding(.horizontal, 12)
// .padding(.vertical, 6) // .padding(.vertical, 6)
// .background(Color.brown) // .background(Color.brown)
// .foregroundColor(.white) // .foregroundColor(.white)
// .cornerRadius(8) // .cornerRadius(8)
// } // }
// // // //
// NavigationLink(destination: SubscribeView()) { // NavigationLink(destination: SubscribeView()) {
// Text("Subscribe") // Text("Subscribe")
// .font(.subheadline) // .font(.subheadline)
// .padding(.horizontal, 12) // .padding(.horizontal, 12)
// .padding(.vertical, 6) // .padding(.vertical, 6)
// .background(Color.orange) // .background(Color.orange)
// .foregroundColor(.white) // .foregroundColor(.white)
// .cornerRadius(8) // .cornerRadius(8)
// } // }
// .padding(.trailing) // .padding(.trailing)
// .fullScreenCover(isPresented: $showLogin) { // .fullScreenCover(isPresented: $showLogin) {
// LoginView() // LoginView()
// } // }
NavigationLink(destination: SubscribeView()) { NavigationLink(destination: SubscribeView()) {
Text("\(memberProfile?.remainPoints ?? 0)") Text("\(memberProfile?.remainPoints ?? 0)")
.font(Typography.font(for: .subtitle)) .font(Typography.font(for: .subtitle))
.fontWeight(.bold) .fontWeight(.bold)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
.background(Color.black) .background(Color.black)
.foregroundColor(.white) .foregroundColor(.white)
.cornerRadius(16) .cornerRadius(16)
} }
.padding(.trailing) .padding(.trailing)
.fullScreenCover(isPresented: $showLogin) { .fullScreenCover(isPresented: $showLogin) {
LoginView() LoginView()
}
} }
.padding(.horizontal)
.padding(.top, 20)
} }
.padding(.horizontal)
.padding(.top, 20)
// //
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("Hi! Click And") Text("Hi! Click And")
@ -550,19 +770,34 @@ struct BlindBoxView: View {
.opacity(showScalingOverlay ? 0 : 1) .opacity(showScalingOverlay ? 0 : 1)
.animation(.easeOut(duration: 1.5), value: showScalingOverlay) .animation(.easeOut(duration: 1.5), value: showScalingOverlay)
} }
if mediaType == .all && !showScalingOverlay {
ZStack {
SVGImage(svgName: "BlindCount")
.frame(width: 100, height: 60)
Text("\(blindCount?.availableQuantity ?? 0) Boxes")
.font(Typography.font(for: .body, family: .quicksandBold))
.foregroundColor(.white)
.offset(x: 6, y: -18)
}
.position(x: UIScreen.main.bounds.width * 0.7,
y: UIScreen.main.bounds.height * 0.18)
.opacity(showScalingOverlay ? 0 : 1)
.animation(.easeOut(duration: 1.5), value: showScalingOverlay)
}
if !showScalingOverlay { if !showScalingOverlay {
VStack(spacing: 20) { VStack(spacing: 20) {
switch animationPhase { switch animationPhase {
case .loading: case .loading:
GIFView(name: "BlindLoading") GIFView(name: "BlindLoading")
.frame(width: 300, height: 300) .frame(width: 300, height: 300)
.onAppear { // .onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 6) { // DispatchQueue.main.asyncAfter(deadline: .now() + 6) {
withAnimation { // withAnimation {
animationPhase = .ready // animationPhase = .ready
} // }
} // }
} // }
case .ready: case .ready:
ZStack { ZStack {
@ -592,33 +827,23 @@ struct BlindBoxView: View {
self.startScalingAnimation() self.startScalingAnimation()
} }
} }
case .none:
SVGImage(svgName: "BlindNone")
.frame(width: 300, height: 300)
} }
} }
.offset(y: -50) .offset(y: -50)
.compositingGroup() .compositingGroup()
.padding() .padding()
} }
if !showScalingOverlay {
ZStack {
SVGImage(svgName: "BlindCount")
.frame(width: 100, height: 60)
Text("\(blindCount?.availableQuantity ?? 0) Boxes")
.font(Typography.font(for: .body, family: .quicksandBold))
.foregroundColor(.white)
.offset(x: 6, y: -18)
}
.position(x: UIScreen.main.bounds.width * 0.7,
y: UIScreen.main.bounds.height * 0.18)
.opacity(showScalingOverlay ? 0 : 1)
.animation(.easeOut(duration: 1.5), value: showScalingOverlay)
}
if !showScalingOverlay { if !showScalingOverlay {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("hhsdshjsjdhn") // blindGeneratedescription
Text(blindGenerate?.videoGenerateTime ?? "hhsdshjsjdhn")
.font(Typography.font(for: .body, family: .quicksandBold)) .font(Typography.font(for: .body, family: .quicksandBold))
.foregroundColor(Color.themeTextMessageMain) .foregroundColor(Color.themeTextMessageMain)
Text("informationinformationinformationinformationinformationinformation") Text(blindGenerate?.description ?? "informationinformationinformationinformationinformationinformation")
.font(.system(size: 14)) .font(.system(size: 14))
.foregroundColor(Color.themeTextMessageMain) .foregroundColor(Color.themeTextMessageMain)
} }
@ -636,17 +861,50 @@ struct BlindBoxView: View {
.offset(y: showScalingOverlay ? -100 : 0) .offset(y: showScalingOverlay ? -100 : 0)
.animation(.easeInOut(duration: 1.5), value: showScalingOverlay) .animation(.easeInOut(duration: 1.5), value: showScalingOverlay)
// //
Button(action: showUserProfile) { if mediaType == .all {
Text("Go to Buy") Button(action: {
.font(Typography.font(for: .body)) if animationPhase == .ready {
.fontWeight(.bold) //
.frame(maxWidth: .infinity) //
.padding() Router.shared.navigate(to: .subscribe)
.background(Color.themePrimary) } else {
.foregroundColor(Color.themeTextMessageMain) showUserProfile()
.cornerRadius(32) }
}) {
if animationPhase == .loading {
Text("Next: \(countdown.minutes):\(String(format: "%02d", countdown.seconds)).\(String(format: "%02d", countdown.milliseconds))")
.font(Typography.font(for: .body))
.fontWeight(.bold)
.frame(maxWidth: .infinity)
.padding()
.background(Color.white)
.foregroundColor(.black)
.cornerRadius(32)
.onAppear {
startCountdown()
}
} else if animationPhase == .ready {
Text("Ready")
.font(Typography.font(for: .body))
.fontWeight(.bold)
.frame(maxWidth: .infinity)
.padding()
.background(Color.themePrimary)
.foregroundColor(Color.themeTextMessageMain)
.cornerRadius(32)
} else {
Text("Go to Buy")
.font(Typography.font(for: .body))
.fontWeight(.bold)
.frame(maxWidth: .infinity)
.padding()
.background(Color.themePrimary)
.foregroundColor(Color.themeTextMessageMain)
.cornerRadius(32)
}
} }
.padding(.horizontal) .padding(.horizontal)
}
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.themeTextWhiteSecondary) .background(Color.themeTextWhiteSecondary)

View File

@ -46,17 +46,19 @@ struct WakeApp: App {
if authState.isAuthenticated { if authState.isAuthenticated {
// //
NavigationStack(path: $router.path) { NavigationStack(path: $router.path) {
BlindBoxView(mediaType: .all) // BlindBoxView(mediaType: .all)
// .navigationDestination(for: AppRoute.self) { route in
// route.view
// }
LoginView()
.navigationDestination(for: AppRoute.self) { route in .navigationDestination(for: AppRoute.self) { route in
route.view route.view
} }
} }
} else { } else {
// //
// LoginView()
// .environmentObject(authState)
NavigationStack(path: $router.path) { NavigationStack(path: $router.path) {
BlindBoxView(mediaType: .all) LoginView()
.navigationDestination(for: AppRoute.self) { route in .navigationDestination(for: AppRoute.self) { route in
route.view route.view
} }