From 5c25d0bf4ccbd9aef8a66877b6dd0b78200660c2 Mon Sep 17 00:00:00 2001 From: Junhui Chen Date: Mon, 8 Sep 2025 18:36:45 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E4=BF=AE=E6=94=B9=E5=8A=A8?= =?UTF-8?q?=E7=94=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- specs/refactor_spec.md | 5 ++ wake/View/Blind/BlindBoxViewModel.swift | 51 +++++++++++-- wake/View/Blind/ContentView.swift | 98 ++++++------------------- 3 files changed, 74 insertions(+), 80 deletions(-) diff --git a/specs/refactor_spec.md b/specs/refactor_spec.md index 85a387a..8358511 100644 --- a/specs/refactor_spec.md +++ b/specs/refactor_spec.md @@ -93,6 +93,11 @@ - 2025-09-08 16:28 +08: 完成 polling-1: - 新增 `wake/View/Blind/BlindBoxPolling.swift`,提供 `AsyncThrowingStream` 序列:`singleBox(boxId:)` 与 `firstUnopened()`,内部可取消(`Task.isCancelled`)并使用统一的间隔控制。 - `wake/View/Blind/ContentView.swift` 轮询改为 `for try await ... in` 形式;通过 `pollingTask` 管理任务,在 `stopPolling()` 与 `onDisappear` 中取消,避免视图中出现 `while + Task.sleep`。 + - 2025-09-08 18:05 +08: 推进 mvvm-1 与 media-1: + - ViewModel(`BlindBoxViewModel`)新增 `applyStatusSideEffects()`,将状态联动的副作用集中处理:`Preparing` 开始 1s 倒计时(`countdownText`),其它状态停止倒计时;在 `bootstrapInitialState()` 与 `startPolling()` 的数据落地后调用。 + - 倒计时迁移至 VM:新增 `startCountdown/stopCountdown`、`countdownText`,视图使用 `viewModel.countdownText` 展示;`ContentView` 移除本地倒计时与定时器。 + - 首帧无闪烁:`ContentView` 初始 `animationPhase = .none`,监听 `viewModel.didBootstrap` 后按初始状态(大小写不敏感)切换 `loading/ready`;操作按钮在 `didBootstrap` 前隐藏。 + - 媒体优化起步:将 `loading` 阶段的 GIF 替换为 Lottie(`LottieView(name: "loading")`,资源位于 `wake/Assets/Lottie/loading.json`)。 ## 决策记录 - 采用顶层 `NavigationStack + Router`,子页面取消 `NavigationView`。 diff --git a/wake/View/Blind/BlindBoxViewModel.swift b/wake/View/Blind/BlindBoxViewModel.swift index 153100b..65c73b5 100644 --- a/wake/View/Blind/BlindBoxViewModel.swift +++ b/wake/View/Blind/BlindBoxViewModel.swift @@ -39,7 +39,7 @@ final class BlindBoxViewModel: ObservableObject { func startPolling() async { // 如果已经是 Unopened,无需继续轮询 - if blindGenerate?.status == "Unopened" { return } + if blindGenerate?.status.lowercased() == "unopened" { return } stopPolling() if let boxId = currentBoxId { // Poll a single box until unopened @@ -107,11 +107,11 @@ final class BlindBoxViewModel: ObservableObject { } else { do { let list = try await BlindBoxApi.shared.getBlindBoxList() - // 更新未开启数量 - let count = (list ?? []).filter { $0.status == "Unopened" }.count + // 更新未开启数量(忽略大小写) + let count = (list ?? []).filter { $0.status.lowercased() == "unopened" }.count self.blindCount = BlindCount(availableQuantity: count) - if let item = list?.first(where: { $0.status == "Unopened" }) { + if let item = list?.first(where: { $0.status.lowercased() == "unopened" }) { self.blindGenerate = item if mediaType == .image { self.imageURL = item.resultFile?.url ?? "" @@ -165,10 +165,51 @@ final class BlindBoxViewModel: ObservableObject { private func loadBlindCount() async { do { let list = try await BlindBoxApi.shared.getBlindBoxList() - let count = (list ?? []).filter { $0.status == "Unopened" }.count + let count = (list ?? []).filter { $0.status.lowercased() == "unopened" }.count self.blindCount = BlindCount(availableQuantity: count) } catch { print("❌ 获取盲盒列表失败: \(error)") } } + + // MARK: - 状态副作用(倒计时等) + private func applyStatusSideEffects() { + let status = blindGenerate?.status.lowercased() ?? "" + if status == "preparing" { + // 若没有在计时或已结束,则从默认 36:50 开始;如后续需要可改为读取服务端剩余时间 + if countdownTask == nil || remainingSeconds <= 0 { + startCountdown(minutes: 36, seconds: 50) + } + } else { + stopCountdown() + } + } + + func startCountdown(minutes: Int = 36, seconds: Int = 50) { + stopCountdown() + remainingSeconds = max(0, minutes * 60 + seconds) + countdownText = String(format: "%02d:%02d", remainingSeconds / 60, remainingSeconds % 60) + countdownTask = Task { [weak self] in + while let self, !Task.isCancelled, self.remainingSeconds > 0 { + do { + try await Task.sleep(nanoseconds: 1_000_000_000) + } catch { + break + } + await MainActor.run { + self.remainingSeconds -= 1 + self.countdownText = String( + format: "%02d:%02d", + self.remainingSeconds / 60, + self.remainingSeconds % 60 + ) + } + } + } + } + + func stopCountdown() { + countdownTask?.cancel() + countdownTask = nil + } } diff --git a/wake/View/Blind/ContentView.swift b/wake/View/Blind/ContentView.swift index bb60109..28f0078 100644 --- a/wake/View/Blind/ContentView.swift +++ b/wake/View/Blind/ContentView.swift @@ -75,9 +75,7 @@ struct BlindBoxView: View { @State private var showModal = false // 控制用户资料弹窗显示 @State private var showSettings = false // 控制设置页面显示 @State private var showLogin = false - // 按钮状态 倒计时 - @State private var countdown: (minutes: Int, seconds: Int, milliseconds: Int) = (36, 50, 0) - @State private var countdownTimer: Timer? + // 倒计时由 ViewModel 管理(countdownText) // 盲盒数据 @State private var showScalingOverlay = false @State private var animationPhase: BlindBoxAnimationPhase = .none @@ -99,32 +97,7 @@ struct BlindBoxView: View { _viewModel = StateObject(wrappedValue: BlindBoxViewModel(mediaType: mediaType, currentBoxId: blindBoxId)) } - // 倒计时 - private func startCountdown() { - // 重置为36:50:20 - countdown = (36, 50, 0) - - countdownTimer?.invalidate() - countdownTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in - var (minutes, seconds, _) = countdown - - // 每秒更新 - seconds -= 1 - if seconds < 0 { - seconds = 59 - minutes -= 1 - } - - // 如果倒计时结束,停止计时器 - if minutes <= 0 && seconds <= 0 { - countdownTimer?.invalidate() - countdownTimer = nil - return - } - - countdown = (minutes, seconds, 0) - } - } + // 倒计时已迁移至 ViewModel // 已由 ViewModel 承担加载与轮询逻辑 @@ -293,8 +266,7 @@ struct BlindBoxView: View { } .onDisappear { viewModel.stopPolling() - countdownTimer?.invalidate() - countdownTimer = nil + viewModel.stopCountdown() // Clean up video player videoPlayer?.pause() @@ -317,30 +289,26 @@ struct BlindBoxView: View { } .onChange(of: animationPhase) { phase in if phase != .loading { - countdownTimer?.invalidate() - countdownTimer = nil + // 仅用于迁移前清理;现倒计时在 VM 中管理 } } .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" { + let initialStatus = viewModel.blindGenerate?.status.lowercased() ?? "" + if initialStatus == "unopened" { withAnimation { self.animationPhase = .ready } - } else if viewModel.blindGenerate?.status.lowercased() == "preparing" { + } else if initialStatus == "preparing" { withAnimation { self.animationPhase = .loading } } else { // 若未知状态,保持 none;后续 onChange 会驱动到正确态 @@ -526,7 +494,7 @@ struct BlindBoxView: View { VStack(spacing: 20) { switch animationPhase { case .loading: - GIFView(name: "BlindLoading") + LottieView(name: "loading") .frame(width: 300, height: 300) // .onAppear { // DispatchQueue.main.asyncAfter(deadline: .now() + 6) { @@ -538,7 +506,7 @@ struct BlindBoxView: View { case .ready: ZStack { - GIFView(name: "BlindReady") + LottieView(name: "data") .frame(width: 300, height: 300) // Add a transparent overlay to capture taps @@ -548,21 +516,11 @@ struct BlindBoxView: View { .onTapGesture { print("点击了盲盒") - // 标记盲盒开启 - if let boxId = self.currentBoxId { + let boxIdToOpen = self.currentBoxId ?? self.viewModel.blindGenerate?.id + if let boxId = boxIdToOpen { Task { do { - try await BlindBoxApi.shared.openBlindBox(boxId: boxId) - print("✅ 盲盒开启成功") - } catch { - print("❌ 开启盲盒失败: \(error)") - } - } - } - if let boxId = self.viewModel.blindGenerate?.id { - Task { - do { - try await BlindBoxApi.shared.openBlindBox(boxId: boxId) + try await viewModel.openBlindBox(for: boxId) print("✅ 盲盒开启成功") } catch { print("❌ 开启盲盒失败: \(error)") @@ -578,10 +536,13 @@ struct BlindBoxView: View { case .opening: ZStack { - GIFView(name: "BlindOpen") - .frame(width: 300, height: 300) - .scaleEffect(scale) - .opacity(showMedia ? 0 : 1) // 当显示媒体时隐藏GIF + if !showMedia { + GIFView(name: "BlindOpen") + .frame(width: 300, height: 300) + .scaleEffect(scale) + } + // 当显示媒体时,移除 GIFView 避免后台播放 + Color.clear .onAppear { print("开始播放开启动画") // 初始缩放为1(原始大小) @@ -662,21 +623,11 @@ struct BlindBoxView: View { Button(action: { if animationPhase == .ready { // 准备就绪点击,开启盲盒 - // 标记盲盒开启 - if let boxId = self.currentBoxId { + let boxIdToOpen = self.currentBoxId ?? self.viewModel.blindGenerate?.id + if let boxId = boxIdToOpen { Task { do { - try await BlindBoxApi.shared.openBlindBox(boxId: boxId) - print("✅ 盲盒开启成功") - } catch { - print("❌ 开启盲盒失败: \(error)") - } - } - } - if let boxId = self.viewModel.blindGenerate?.id { - Task { - do { - try await BlindBoxApi.shared.openBlindBox(boxId: boxId) + try await viewModel.openBlindBox(for: boxId) print("✅ 盲盒开启成功") } catch { print("❌ 开启盲盒失败: \(error)") @@ -691,7 +642,7 @@ struct BlindBoxView: View { } }) { if animationPhase == .loading { - Text("Next: \(countdown.minutes):\(String(format: "%02d", countdown.seconds))") + Text("Next: \(viewModel.countdownText)") .font(Typography.font(for: .body)) .fontWeight(.bold) .frame(maxWidth: .infinity) @@ -699,9 +650,6 @@ struct BlindBoxView: View { .background(Color.white) .foregroundColor(.black) .cornerRadius(32) - .onAppear { - startCountdown() - } } else if animationPhase == .ready { Text("Ready") .font(Typography.font(for: .body))