diff --git a/wake/View/Blind/BlindBoxPolling.swift b/wake/View/Blind/BlindBoxPolling.swift index 4fe71e3..8c04d14 100644 --- a/wake/View/Blind/BlindBoxPolling.swift +++ b/wake/View/Blind/BlindBoxPolling.swift @@ -12,7 +12,7 @@ enum BlindBoxPolling { do { let result = try await BlindBoxApi.shared.getBlindBox(boxId: boxId) if let data = result { - if data.status == "Unopened" { + if data.status.lowercased() == "unopened" { continuation.yield(data) continuation.finish() break @@ -42,7 +42,7 @@ enum BlindBoxPolling { while !Task.isCancelled { do { let list = try await BlindBoxApi.shared.getBlindBoxList() - if let item = list?.first(where: { $0.status == "Unopened" }) { + if let item = list?.first(where: { $0.status.lowercased() == "unopened" }) { continuation.yield(item) continuation.finish() break diff --git a/wake/View/Blind/BlindBoxViewModel.swift b/wake/View/Blind/BlindBoxViewModel.swift new file mode 100644 index 0000000..153100b --- /dev/null +++ b/wake/View/Blind/BlindBoxViewModel.swift @@ -0,0 +1,174 @@ +import Foundation +import Combine + +@MainActor +final class BlindBoxViewModel: ObservableObject { + // Inputs + let mediaType: BlindBoxMediaType + let currentBoxId: String? + + // Published state + @Published var isMember: Bool = false + @Published var memberDate: String = "" + @Published var memberProfile: MemberProfile? = nil + + @Published var blindCount: BlindCount? = nil + @Published var blindGenerate: BlindBoxData? = nil + + @Published var videoURL: String = "" + @Published var imageURL: String = "" + @Published var didBootstrap: Bool = false + @Published var countdownText: String = "" + + // Tasks + private var pollingTask: Task? = nil + private var countdownTask: Task? = nil + private var remainingSeconds: Int = 0 + + init(mediaType: BlindBoxMediaType, currentBoxId: String?) { + self.mediaType = mediaType + self.currentBoxId = currentBoxId + } + + func load() async { + await bootstrapInitialState() + await startPolling() + loadMemberProfile() + await loadBlindCount() + } + + func startPolling() async { + // 如果已经是 Unopened,无需继续轮询 + if blindGenerate?.status == "Unopened" { return } + stopPolling() + if let boxId = currentBoxId { + // Poll a single box until unopened + pollingTask = Task { @MainActor [weak self] in + guard let self else { return } + do { + for try await data in BlindBoxPolling.singleBox(boxId: boxId, intervalSeconds: 2.0) { + print("[VM] SingleBox polled status: \(data.status)") + self.blindGenerate = data + if self.mediaType == .image { + self.imageURL = data.resultFile?.url ?? "" + } else { + self.videoURL = data.resultFile?.url ?? "" + } + self.applyStatusSideEffects() + break + } + } catch is CancellationError { + // cancelled + } catch { + print("❌ BlindBoxViewModel polling error (single): \(error)") + } + } + } else { + // Poll list and yield first unopened + pollingTask = Task { @MainActor [weak self] in + guard let self else { return } + do { + for try await item in BlindBoxPolling.firstUnopened(intervalSeconds: 2.0) { + print("[VM] List polled first unopened: id=\(item.id ?? "nil"), status=\(item.status)") + self.blindGenerate = item + if self.mediaType == .image { + self.imageURL = item.resultFile?.url ?? "" + } else { + self.videoURL = item.resultFile?.url ?? "" + } + self.applyStatusSideEffects() + break + } + } catch is CancellationError { + // cancelled + } catch { + print("❌ BlindBoxViewModel polling error (list): \(error)") + } + } + } + } + + private func bootstrapInitialState() async { + if let boxId = currentBoxId { + do { + let data = try await BlindBoxApi.shared.getBlindBox(boxId: boxId) + if let data = data { + self.blindGenerate = data + if mediaType == .image { + self.imageURL = data.resultFile?.url ?? "" + } else { + self.videoURL = data.resultFile?.url ?? "" + } + self.applyStatusSideEffects() + } + } catch { + print("❌ bootstrapInitialState (single) failed: \(error)") + } + } else { + do { + let list = try await BlindBoxApi.shared.getBlindBoxList() + // 更新未开启数量 + let count = (list ?? []).filter { $0.status == "Unopened" }.count + self.blindCount = BlindCount(availableQuantity: count) + + if let item = list?.first(where: { $0.status == "Unopened" }) { + self.blindGenerate = item + if mediaType == .image { + self.imageURL = item.resultFile?.url ?? "" + } else { + self.videoURL = item.resultFile?.url ?? "" + } + self.applyStatusSideEffects() + } else if let first = list?.first { + // 没有 Unopened,选取第一个用于展示状态(通常是 Preparing) + self.blindGenerate = first + self.applyStatusSideEffects() + } + } catch { + print("❌ bootstrapInitialState (list) failed: \(error)") + } + } + // 标记首帧状态已准备,供视图决定是否显示 loading/ready + self.didBootstrap = true + } + + func stopPolling() { + pollingTask?.cancel() + pollingTask = nil + } + + func openBlindBox(for id: String) async throws { + try await BlindBoxApi.shared.openBlindBox(boxId: id) + } + + private func loadMemberProfile() { + NetworkService.shared.get( + path: "/membership/personal-center-info", + parameters: nil + ) { [weak self] (result: Result) in + Task { @MainActor in + guard let self else { return } + switch result { + case .success(let response): + self.memberProfile = response.data + self.isMember = response.data.membershipLevel == "Pioneer" + self.memberDate = response.data.membershipEndAt ?? "" + print("✅ 成功获取会员信息:", response.data) + print("✅ 用户ID:", response.data.userInfo.userId) + case .failure(let error): + print("❌ 获取会员信息失败:", error) + } + } + } + } + + private func loadBlindCount() async { + do { + let list = try await BlindBoxApi.shared.getBlindBoxList() + let count = (list ?? []).filter { $0.status == "Unopened" }.count + self.blindCount = BlindCount(availableQuantity: count) + } catch { + print("❌ 获取盲盒列表失败: \(error)") + } + } +} diff --git a/wake/View/Blind/ContentView.swift b/wake/View/Blind/ContentView.swift index 5edafea..bb60109 100644 --- a/wake/View/Blind/ContentView.swift +++ b/wake/View/Blind/ContentView.swift @@ -71,33 +71,16 @@ struct AVPlayerController: UIViewControllerRepresentable { struct BlindBoxView: View { let mediaType: BlindBoxMediaType let currentBoxId: String? - + @StateObject private var viewModel: BlindBoxViewModel @State private var showModal = false // 控制用户资料弹窗显示 @State private var showSettings = false // 控制设置页面显示 - @State private var isMember = false // 是否是会员 - @State private var memberDate = "" // 会员到期时间 @State private var showLogin = false - @State private var memberProfile: MemberProfile? = nil - @State private var blindCount: BlindCount? = nil - @State private var blindList: [BlindList] = [] // Changed to array - // 生成盲盒 - @State private var blindGenerate: BlindBoxData? - @State private var showLottieAnimation = true - // 轮询接口 - @State private var isPolling = false - @State private var pollingTimer: Timer? - @State private var pollingTask: Task? = nil - @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, 0) @State private var countdownTimer: Timer? // 盲盒数据 - @State private var displayData: BlindBoxData? = nil @State private var showScalingOverlay = false - @State private var animationPhase: BlindBoxAnimationPhase = .loading + @State private var animationPhase: BlindBoxAnimationPhase = .none @State private var scale: CGFloat = 0.1 @State private var videoPlayer: AVPlayer? @State private var showControls = false @@ -113,6 +96,7 @@ struct BlindBoxView: View { init(mediaType: BlindBoxMediaType, blindBoxId: String? = nil) { self.mediaType = mediaType self.currentBoxId = blindBoxId + _viewModel = StateObject(wrappedValue: BlindBoxViewModel(mediaType: mediaType, currentBoxId: blindBoxId)) } // 倒计时 @@ -142,222 +126,16 @@ struct BlindBoxView: View { } } - private func loadBlindBox() async { - print("loadMedia called with mediaType: \(mediaType)") - - // 启动轮询任务(可取消) - stopPolling() - isPolling = true - if self.currentBoxId != nil { - print("指定监听某盲盒结果: ", self.currentBoxId! as Any) - pollingTask = Task { @MainActor in - await pollingToQuerySingleBox() - } - } else { - pollingTask = Task { @MainActor in - await pollingToQueryBlindBox() - } - } - - // switch mediaType { - // case .video: - // loadVideo() - // currentBoxType = "Video" - // startPolling() - // case .image: - // loadImage() - // currentBoxType = "Image" - // startPolling() - // case .all: - // print("Loading all content...") - // // 检查盲盒列表,如果不存在First/Second盲盒,则跳转到对应的页面重新触发新手引导 - // // 注意:这部分代码仍使用传统的闭包方式,因为NetworkService.shared.get不支持async/await - // NetworkService.shared.get( - // path: "/blind_boxs/query", - // parameters: nil - // ) { (result: Result, NetworkError>) in - // DispatchQueue.main.async { - // switch result { - // case .success(let response): - // if response.data.count == 0 { - // // 跳转到新手引导-First盲盒页面 - // print("❌ 没有盲盒,跳转到新手引导-First盲盒页面") - // // return - // } - // if response.data.count == 1 && response.data[0].boxType == "First" { - // // 跳转到新手引导-Second盲盒页面 - // print("❌ 只有First盲盒,跳转到新手引导-Second盲盒页面") - // // return - // } - - // self.blindList = response.data ?? [] - // // 如果列表为空数组 设置盲盒状态为none - // if self.blindList.isEmpty { - // self.animationPhase = .none - // } - // print("✅ 成功获取 \(self.blindList.count) 个盲盒") - // case .failure(let error): - // self.blindList = [] - // self.animationPhase = .none - // print("❌ 获取盲盒列表失败:", error.localizedDescription) - // } - // } - // } - - // 会员信息 - NetworkService.shared.get( - path: "/membership/personal-center-info", - parameters: nil - ) { (result: Result) in - DispatchQueue.main.async { - switch result { - case .success(let response): - self.memberProfile = response.data - self.isMember = response.data.membershipLevel == "Pioneer" - self.memberDate = response.data.membershipEndAt ?? "" - print("✅ 成功获取会员信息:", response.data) - print("✅ 用户ID:", response.data.userInfo.userId) - case .failure(let error): - print("❌ 获取会员信息失败:", error) - } - } - } - // 盲盒数量 - // NetworkService.shared.get( - // path: "/blind_box/available/quantity", - // parameters: nil - // ) { (result: Result, NetworkError>) in - // DispatchQueue.main.async { - // switch result { - // case .success(let response): - // self.blindCount = response.data - // print("✅ 成功获取盲盒数量:", response.data) - // case .failure(let error): - // print("❌ 获取数量失败:", error) - // } - // } - // } - // } - } + // 已由 ViewModel 承担加载与轮询逻辑 - private func pollingToQuerySingleBox() async { - guard let boxId = self.currentBoxId else { return } - do { - for try await data in BlindBoxPolling.singleBox(boxId: boxId, intervalSeconds: 2.0) { - self.blindGenerate = data - if mediaType == .image { - self.imageURL = data.resultFile?.url ?? "" - } else { - self.videoURL = data.resultFile?.url ?? "" - } - withAnimation { self.animationPhase = .ready } - stopPolling() - break - } - } catch is CancellationError { - // 任务被取消 - } catch { - print("❌ 获取盲盒数据失败: \(error)") - self.animationPhase = .none - stopPolling() - } - } + // 已迁移至 ViewModel - private func pollingToQueryBlindBox() async { - do { - for try await blindBox in BlindBoxPolling.firstUnopened(intervalSeconds: 2.0) { - self.blindGenerate = blindBox - if mediaType == .image { - self.imageURL = blindBox.resultFile?.url ?? "" - } else { - self.videoURL = blindBox.resultFile?.url ?? "" - } - withAnimation { self.animationPhase = .ready } - print("✅ 成功获取盲盒数据: \(blindBox.name), 状态: \(blindBox.status)") - stopPolling() - break - } - } catch is CancellationError { - // 任务被取消 - } catch { - print("❌ 获取盲盒列表失败: \(error)") - stopPolling() - } - } + // 已迁移至 ViewModel - // 轮询接口 - private func startPolling() { - stopPolling() - isPolling = true - checkBlindBoxStatus() - } - - private func stopPolling() { - pollingTimer?.invalidate() - pollingTimer = nil - isPolling = false - pollingTask?.cancel() - pollingTask = nil - } - - private func checkBlindBoxStatus() { - guard !currentBoxType.isEmpty else { - stopPolling() - return - } - -// NetworkService.shared.postWithToken( -// path: "/blind_box/generate/mock", -// parameters: ["box_type": currentBoxType] -// ) { (result: Result) in -// DispatchQueue.main.async { -// switch result { -// case .success(let response): -// let data = response.data -// self.blindGenerate = data -// print("当前盲盒状态: \(data?.status ?? "Unknown")") -// // 更新显示数据 -// if self.mediaType == .all, let firstItem = self.blindList.first { -// self.displayData = BlindBoxData(from: firstItem) -// } else { -// self.displayData = data -// } -// -// // 发送状态变更通知 -// if let status = data?.status { -// NotificationCenter.default.post( -// name: .blindBoxStatusChanged, -// object: nil, -// userInfo: ["status": status] -// ) -// } -// -// if data?.status != "Preparing" { -// self.stopPolling() -// print("✅ 盲盒准备就绪,状态: \(data?.status ?? "Unknown")") -// if self.mediaType == .video { -// self.videoURL = data?.resultFile?.url ?? "" -// } else if self.mediaType == .image { -// self.imageURL = data?.resultFile?.url ?? "" -// } -// } else { -// self.pollingTimer = Timer.scheduledTimer( -// withTimeInterval: 2.0, -// repeats: false -// ) { _ in -// self.checkBlindBoxStatus() -// } -// } -// case .failure(let error): -// print("❌ 获取盲盒状态失败: \(error.localizedDescription)") -// self.stopPolling() -// } -// } -// } - } + // 已迁移至 ViewModel private func loadImage() { - guard !imageURL.isEmpty, let url = URL(string: imageURL) else { + guard !viewModel.imageURL.isEmpty, let url = URL(string: viewModel.imageURL) else { print("⚠️ 图片URL无效或为空") return } @@ -375,7 +153,7 @@ struct BlindBoxView: View { } private func loadVideo() { - guard !videoURL.isEmpty, let url = URL(string: videoURL) else { + guard !viewModel.videoURL.isEmpty, let url = URL(string: viewModel.videoURL) else { print("⚠️ 视频URL无效或为空") return } @@ -401,7 +179,7 @@ struct BlindBoxView: View { } private func prepareVideo() { - guard !videoURL.isEmpty, let url = URL(string: videoURL) else { + guard !viewModel.videoURL.isEmpty, let url = URL(string: viewModel.videoURL) else { print("⚠️ 视频URL无效或为空") return } @@ -425,7 +203,7 @@ struct BlindBoxView: View { } private func prepareImage() { - guard !imageURL.isEmpty, let url = URL(string: imageURL) else { + guard !viewModel.imageURL.isEmpty, let url = URL(string: viewModel.imageURL) else { print("⚠️ 图片URL无效或为空") return } @@ -510,11 +288,11 @@ struct BlindBoxView: View { // } // 调用接口 Task { - await loadBlindBox() + await viewModel.load() } } .onDisappear { - stopPolling() + viewModel.stopPolling() countdownTimer?.invalidate() countdownTimer = nil @@ -529,6 +307,46 @@ struct BlindBoxView: View { object: nil ) } + .onChange(of: viewModel.blindGenerate?.status) { status in + guard let status = status?.lowercased() else { return } + if status == "unopened" { + withAnimation { self.animationPhase = .ready } + } else if status == "preparing" { + withAnimation { self.animationPhase = .loading } + } + } + .onChange(of: animationPhase) { phase in + if phase != .loading { + countdownTimer?.invalidate() + countdownTimer = nil + } + } + .onChange(of: viewModel.videoURL) { url in + if !url.isEmpty { + withAnimation { self.animationPhase = .ready } + countdownTimer?.invalidate() + countdownTimer = nil + } + } + .onChange(of: viewModel.imageURL) { url in + if !url.isEmpty { + withAnimation { self.animationPhase = .ready } + countdownTimer?.invalidate() + countdownTimer = nil + } + } + .onChange(of: viewModel.didBootstrap) { done in + guard done else { return } + // 根据首帧状态决定初始动画态,避免先显示 loading 再跳到 ready 的割裂感 + if viewModel.blindGenerate?.status.lowercased() == "unopened" { + withAnimation { self.animationPhase = .ready } + } else if viewModel.blindGenerate?.status.lowercased() == "preparing" { + withAnimation { self.animationPhase = .loading } + } else { + // 若未知状态,保持 none;后续 onChange 会驱动到正确态 + self.animationPhase = .none + } + } if showScalingOverlay { ZStack { @@ -565,10 +383,10 @@ struct BlindBoxView: View { HStack { Button(action: { // 导航到BlindOutcomeView - if mediaType == .all, !videoURL.isEmpty, let url = URL(string: videoURL) { - Router.shared.navigate(to: .blindOutcome(media: .video(url, nil), time: blindGenerate?.name ?? "Your box", description:blindGenerate?.description ?? "", isMember: self.isMember)) + if mediaType == .all, !viewModel.videoURL.isEmpty, let url = URL(string: viewModel.videoURL) { + Router.shared.navigate(to: .blindOutcome(media: .video(url, nil), time: viewModel.blindGenerate?.name ?? "Your box", description:viewModel.blindGenerate?.description ?? "", isMember: viewModel.isMember)) } else if mediaType == .image, let image = displayImage { - Router.shared.navigate(to: .blindOutcome(media: .image(image), time: blindGenerate?.name ?? "Your box", description:blindGenerate?.description ?? "", isMember: self.isMember)) + Router.shared.navigate(to: .blindOutcome(media: .image(image), time: viewModel.blindGenerate?.name ?? "Your box", description:viewModel.blindGenerate?.description ?? "", isMember: viewModel.isMember)) } }) { Image(systemName: "chevron.left") @@ -647,7 +465,7 @@ struct BlindBoxView: View { // LoginView() // } NavigationLink(destination: SubscribeView()) { - Text("\(memberProfile?.remainPoints ?? 0)") + Text("\(viewModel.memberProfile?.remainPoints ?? 0)") .font(Typography.font(for: .subtitle)) .fontWeight(.bold) .padding(.horizontal, 12) @@ -694,7 +512,7 @@ struct BlindBoxView: View { SVGImage(svgName: "BlindCount") .frame(width: 100, height: 60) - Text("\(blindCount?.availableQuantity ?? 0) Boxes") + Text("\(viewModel.blindCount?.availableQuantity ?? 0) Boxes") .font(Typography.font(for: .body, family: .quicksandBold)) .foregroundColor(.white) .offset(x: 6, y: -18) @@ -741,7 +559,7 @@ struct BlindBoxView: View { } } } - if let boxId = self.blindGenerate?.id { + if let boxId = self.viewModel.blindGenerate?.id { Task { do { try await BlindBoxApi.shared.openBlindBox(boxId: boxId) @@ -802,8 +620,8 @@ struct BlindBoxView: View { .frame(width: 300, height: 300) case .none: - // FIXME: 临时使用 BlindLoading GIF - GIFView(name: "BlindLoading") + // 首帧占位,避免加载时闪烁 + Color.clear .frame(width: 300, height: 300) // SVGImage(svgName: "BlindNone") // .frame(width: 300, height: 300) @@ -817,10 +635,10 @@ struct BlindBoxView: View { if !showScalingOverlay && !showMedia { VStack(alignment: .leading, spacing: 8) { // 从变量blindGenerate中获取description - Text(blindGenerate?.name ?? "Some box") + Text(viewModel.blindGenerate?.name ?? "Some box") .font(Typography.font(for: .body, family: .quicksandBold)) .foregroundColor(Color.themeTextMessageMain) - Text(blindGenerate?.description ?? "") + Text(viewModel.blindGenerate?.description ?? "") .font(.system(size: 14)) .foregroundColor(Color.themeTextMessageMain) } @@ -840,7 +658,7 @@ struct BlindBoxView: View { .animation(.easeInOut(duration: 1.5), value: showScalingOverlay) // 打开 TODO 引导时,也要有按钮 - if mediaType == .all { + if mediaType == .all, viewModel.didBootstrap { Button(action: { if animationPhase == .ready { // 准备就绪点击,开启盲盒 @@ -855,7 +673,7 @@ struct BlindBoxView: View { } } } - if let boxId = self.blindGenerate?.id { + if let boxId = self.viewModel.blindGenerate?.id { Task { do { try await BlindBoxApi.shared.openBlindBox(boxId: boxId) @@ -922,8 +740,8 @@ struct BlindBoxView: View { UserProfileModal( showModal: $showModal, showSettings: $showSettings, - isMember: $isMember, - memberDate: $memberDate + isMember: .init(get: { viewModel.isMember }, set: { viewModel.isMember = $0 }), + memberDate: .init(get: { viewModel.memberDate }, set: { viewModel.memberDate = $0 }) ) } .offset(x: showSettings ? UIScreen.main.bounds.width : 0)