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,16 +301,51 @@ 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"
startPolling()
case .image: case .image:
loadImage() loadImage()
print("Loading image...") currentBoxType = "Image"
startPolling()
case .all: case .all:
print("Loading all content...") print("Loading all content...")
// //
@ -278,28 +390,82 @@ struct BlindBoxView: View {
switch result { switch result {
case .success(let response): case .success(let response):
self.blindList = response.data ?? [] self.blindList = response.data ?? []
// none
if self.blindList.isEmpty {
self.animationPhase = .none
}
print("✅ 成功获取 \(self.blindList.count) 个盲盒") print("✅ 成功获取 \(self.blindList.count) 个盲盒")
case .failure(let error): case .failure(let error):
self.blindList = [] self.blindList = []
self.animationPhase = .none
print("❌ 获取盲盒列表失败:", error.localizedDescription) print("❌ 获取盲盒列表失败:", error.localizedDescription)
} }
} }
} }
}
}
//
private func startPolling() {
stopPolling()
isPolling = true
checkBlindBoxStatus()
}
private func stopPolling() {
pollingTimer?.invalidate()
pollingTimer = nil
isPolling = false
}
private func checkBlindBoxStatus() {
guard !currentBoxType.isEmpty else {
stopPolling()
return
}
//
NetworkService.shared.postWithToken( NetworkService.shared.postWithToken(
path: "/blind_box/generate/mock", path: "/blind_box/generate/mock",
parameters: ["box_type": "Image"] parameters: ["box_type": currentBoxType]
) { (result: Result<APIResponse<[BlindList]>, NetworkError>) in ) { (result: Result<APIResponse<BlindBoxData>, NetworkError>) in
DispatchQueue.main.async { DispatchQueue.main.async {
switch result { switch result {
case .success(let response): case .success(let response):
self.blindList = response.data ?? [] let data = response.data
print("✅✅✅✅✅✅✅✅✅ 成功获取 \(self.blindList.count) 个盲盒") self.blindGenerate = data
case .failure(let error): print("当前盲盒状态: \(data.status)")
self.blindList = [] //
print("❌❌ ❌ ❌ ❌ ❌ ❌ 获取盲盒列表失败:", error.localizedDescription) 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,6 +689,7 @@ struct BlindBoxView: View {
// Original content // Original content
VStack { VStack {
VStack(spacing: 20) { VStack(spacing: 20) {
if mediaType == .all {
// //
HStack { HStack {
// //
@ -522,6 +741,7 @@ struct BlindBoxView: View {
} }
.padding(.horizontal) .padding(.horizontal)
.padding(.top, 20) .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,7 +861,38 @@ 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 {
Button(action: {
if animationPhase == .ready {
//
//
Router.shared.navigate(to: .subscribe)
} else {
showUserProfile()
}
}) {
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") Text("Go to Buy")
.font(Typography.font(for: .body)) .font(Typography.font(for: .body))
.fontWeight(.bold) .fontWeight(.bold)
@ -646,8 +902,10 @@ struct BlindBoxView: View {
.foregroundColor(Color.themeTextMessageMain) .foregroundColor(Color.themeTextMessageMain)
.cornerRadius(32) .cornerRadius(32)
} }
}
.padding(.horizontal) .padding(.horizontal)
} }
}
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.themeTextWhiteSecondary) .background(Color.themeTextWhiteSecondary)
.offset(x: showModal ? UIScreen.main.bounds.width * 0.8 : 0) .offset(x: showModal ? UIScreen.main.bounds.width * 0.8 : 0)

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
} }