refactor: BlindBoxViewModel驱动状态更新

This commit is contained in:
Junhui Chen 2025-09-08 17:58:15 +08:00
parent 794742b6fd
commit 5017594762
3 changed files with 243 additions and 251 deletions

View File

@ -12,7 +12,7 @@ enum BlindBoxPolling {
do { do {
let result = try await BlindBoxApi.shared.getBlindBox(boxId: boxId) let result = try await BlindBoxApi.shared.getBlindBox(boxId: boxId)
if let data = result { if let data = result {
if data.status == "Unopened" { if data.status.lowercased() == "unopened" {
continuation.yield(data) continuation.yield(data)
continuation.finish() continuation.finish()
break break
@ -42,7 +42,7 @@ enum BlindBoxPolling {
while !Task.isCancelled { while !Task.isCancelled {
do { do {
let list = try await BlindBoxApi.shared.getBlindBoxList() 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.yield(item)
continuation.finish() continuation.finish()
break break

View File

@ -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<Void, Never>? = nil
private var countdownTask: Task<Void, Never>? = 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<MemberProfileResponse, NetworkError>) 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)")
}
}
}

View File

@ -71,33 +71,16 @@ struct AVPlayerController: UIViewControllerRepresentable {
struct BlindBoxView: View { struct BlindBoxView: View {
let mediaType: BlindBoxMediaType let mediaType: BlindBoxMediaType
let currentBoxId: String? let currentBoxId: String?
@StateObject private var viewModel: BlindBoxViewModel
@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 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<Void, Never>? = 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 countdown: (minutes: Int, seconds: Int, milliseconds: Int) = (36, 50, 0)
@State private var countdownTimer: Timer? @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 = .none
@State private var scale: CGFloat = 0.1 @State private var scale: CGFloat = 0.1
@State private var videoPlayer: AVPlayer? @State private var videoPlayer: AVPlayer?
@State private var showControls = false @State private var showControls = false
@ -113,6 +96,7 @@ struct BlindBoxView: View {
init(mediaType: BlindBoxMediaType, blindBoxId: String? = nil) { init(mediaType: BlindBoxMediaType, blindBoxId: String? = nil) {
self.mediaType = mediaType self.mediaType = mediaType
self.currentBoxId = blindBoxId self.currentBoxId = blindBoxId
_viewModel = StateObject(wrappedValue: BlindBoxViewModel(mediaType: mediaType, currentBoxId: blindBoxId))
} }
// //
@ -142,222 +126,16 @@ struct BlindBoxView: View {
} }
} }
private func loadBlindBox() async { // ViewModel
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.getasync/await
// NetworkService.shared.get(
// path: "/blind_boxs/query",
// parameters: nil
// ) { (result: Result<APIResponse<[BlindList]>, 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<MemberProfileResponse, NetworkError>) 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<APIResponse<BlindCount>, NetworkError>) in
// DispatchQueue.main.async {
// switch result {
// case .success(let response):
// self.blindCount = response.data
// print(" :", response.data)
// case .failure(let error):
// print(" :", error)
// }
// }
// }
// }
}
private func pollingToQuerySingleBox() async { // ViewModel
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()
}
}
private func pollingToQueryBlindBox() async { // ViewModel
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<GenerateBlindBoxResponse, NetworkError>) 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()
// }
// }
// }
}
private func loadImage() { 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无效或为空") print("⚠️ 图片URL无效或为空")
return return
} }
@ -375,7 +153,7 @@ struct BlindBoxView: View {
} }
private func loadVideo() { 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无效或为空") print("⚠️ 视频URL无效或为空")
return return
} }
@ -401,7 +179,7 @@ struct BlindBoxView: View {
} }
private func prepareVideo() { 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无效或为空") print("⚠️ 视频URL无效或为空")
return return
} }
@ -425,7 +203,7 @@ struct BlindBoxView: View {
} }
private func prepareImage() { 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无效或为空") print("⚠️ 图片URL无效或为空")
return return
} }
@ -510,11 +288,11 @@ struct BlindBoxView: View {
// } // }
// //
Task { Task {
await loadBlindBox() await viewModel.load()
} }
} }
.onDisappear { .onDisappear {
stopPolling() viewModel.stopPolling()
countdownTimer?.invalidate() countdownTimer?.invalidate()
countdownTimer = nil countdownTimer = nil
@ -529,6 +307,46 @@ struct BlindBoxView: View {
object: nil 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 { if showScalingOverlay {
ZStack { ZStack {
@ -565,10 +383,10 @@ struct BlindBoxView: View {
HStack { HStack {
Button(action: { Button(action: {
// BlindOutcomeView // BlindOutcomeView
if mediaType == .all, !videoURL.isEmpty, let url = URL(string: videoURL) { if mediaType == .all, !viewModel.videoURL.isEmpty, let url = URL(string: viewModel.videoURL) {
Router.shared.navigate(to: .blindOutcome(media: .video(url, nil), time: blindGenerate?.name ?? "Your box", description:blindGenerate?.description ?? "", isMember: self.isMember)) 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 { } 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") Image(systemName: "chevron.left")
@ -647,7 +465,7 @@ struct BlindBoxView: View {
// LoginView() // LoginView()
// } // }
NavigationLink(destination: SubscribeView()) { NavigationLink(destination: SubscribeView()) {
Text("\(memberProfile?.remainPoints ?? 0)") Text("\(viewModel.memberProfile?.remainPoints ?? 0)")
.font(Typography.font(for: .subtitle)) .font(Typography.font(for: .subtitle))
.fontWeight(.bold) .fontWeight(.bold)
.padding(.horizontal, 12) .padding(.horizontal, 12)
@ -694,7 +512,7 @@ struct BlindBoxView: View {
SVGImage(svgName: "BlindCount") SVGImage(svgName: "BlindCount")
.frame(width: 100, height: 60) .frame(width: 100, height: 60)
Text("\(blindCount?.availableQuantity ?? 0) Boxes") Text("\(viewModel.blindCount?.availableQuantity ?? 0) Boxes")
.font(Typography.font(for: .body, family: .quicksandBold)) .font(Typography.font(for: .body, family: .quicksandBold))
.foregroundColor(.white) .foregroundColor(.white)
.offset(x: 6, y: -18) .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 { Task {
do { do {
try await BlindBoxApi.shared.openBlindBox(boxId: boxId) try await BlindBoxApi.shared.openBlindBox(boxId: boxId)
@ -802,8 +620,8 @@ struct BlindBoxView: View {
.frame(width: 300, height: 300) .frame(width: 300, height: 300)
case .none: case .none:
// FIXME: 使 BlindLoading GIF //
GIFView(name: "BlindLoading") Color.clear
.frame(width: 300, height: 300) .frame(width: 300, height: 300)
// SVGImage(svgName: "BlindNone") // SVGImage(svgName: "BlindNone")
// .frame(width: 300, height: 300) // .frame(width: 300, height: 300)
@ -817,10 +635,10 @@ struct BlindBoxView: View {
if !showScalingOverlay && !showMedia { if !showScalingOverlay && !showMedia {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
// blindGeneratedescription // blindGeneratedescription
Text(blindGenerate?.name ?? "Some box") Text(viewModel.blindGenerate?.name ?? "Some box")
.font(Typography.font(for: .body, family: .quicksandBold)) .font(Typography.font(for: .body, family: .quicksandBold))
.foregroundColor(Color.themeTextMessageMain) .foregroundColor(Color.themeTextMessageMain)
Text(blindGenerate?.description ?? "") Text(viewModel.blindGenerate?.description ?? "")
.font(.system(size: 14)) .font(.system(size: 14))
.foregroundColor(Color.themeTextMessageMain) .foregroundColor(Color.themeTextMessageMain)
} }
@ -840,7 +658,7 @@ struct BlindBoxView: View {
.animation(.easeInOut(duration: 1.5), value: showScalingOverlay) .animation(.easeInOut(duration: 1.5), value: showScalingOverlay)
// TODO // TODO
if mediaType == .all { if mediaType == .all, viewModel.didBootstrap {
Button(action: { Button(action: {
if animationPhase == .ready { if animationPhase == .ready {
// //
@ -855,7 +673,7 @@ struct BlindBoxView: View {
} }
} }
} }
if let boxId = self.blindGenerate?.id { if let boxId = self.viewModel.blindGenerate?.id {
Task { Task {
do { do {
try await BlindBoxApi.shared.openBlindBox(boxId: boxId) try await BlindBoxApi.shared.openBlindBox(boxId: boxId)
@ -922,8 +740,8 @@ struct BlindBoxView: View {
UserProfileModal( UserProfileModal(
showModal: $showModal, showModal: $showModal,
showSettings: $showSettings, showSettings: $showSettings,
isMember: $isMember, isMember: .init(get: { viewModel.isMember }, set: { viewModel.isMember = $0 }),
memberDate: $memberDate memberDate: .init(get: { viewModel.memberDate }, set: { viewModel.memberDate = $0 })
) )
} }
.offset(x: showSettings ? UIScreen.main.bounds.width : 0) .offset(x: showSettings ? UIScreen.main.bounds.width : 0)