Compare commits
8 Commits
5cc91eca51
...
c4ed6c1116
| Author | SHA1 | Date | |
|---|---|---|---|
| c4ed6c1116 | |||
| 347c662a83 | |||
| a3c4806271 | |||
| 552193b4c1 | |||
| 5c25d0bf4c | |||
| 5017594762 | |||
| 794742b6fd | |||
| feadfd92a7 |
149
specs/perf_baseline.md
Normal file
149
specs/perf_baseline.md
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
# perf-1 基线测试指南与记录模板
|
||||||
|
|
||||||
|
更新时间:2025-09-09 12:25 +08
|
||||||
|
|
||||||
|
本文件用于后续进行盲盒主路径的性能基线采集与分析,包含目标范围、事件口径、采集步骤、分析方法、优化建议以及可直接填写的记录模板。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 目标与范围
|
||||||
|
- 场景 A(Image 盲盒):冷启动 → 准备(Unopened)→ 开启 → 展示图片
|
||||||
|
- 场景 B(Video 盲盒):冷启动 → 准备(Unopened)→ 开启 → 播放视频
|
||||||
|
- 输出:关键阶段耗时(T_*)、帧率/掉帧、CPU 热点、潜在瓶颈与改进建议
|
||||||
|
|
||||||
|
## 2. 现有埋点(OS Signpost)
|
||||||
|
Perf 工具位于 `wake/Core/Diagnostics/Performance.swift`(`subsystem: app.wake`,`category: performance`)。以下事件可在 Instruments 的 OS Signpost 中看到:
|
||||||
|
|
||||||
|
- 视图(`wake/Features/BlindBox/View/BlindBoxView.swift`)
|
||||||
|
- `BlindBox_Appear`
|
||||||
|
- `BlindBox_Status_Unopened`
|
||||||
|
- `BlindBox_Status_Preparing`
|
||||||
|
- `BlindBox_Opening_Begin`
|
||||||
|
- `BlindBox_Opening_ShowMedia`
|
||||||
|
- 视图模型(`wake/Features/BlindBox/ViewModel/BlindBoxViewModel.swift`)
|
||||||
|
- `BlindVM_Load_Begin` / `BlindVM_Load_End`
|
||||||
|
- `BlindVM_Bootstrap_Done`
|
||||||
|
- `BlindVM_Poll_Single_Yield` / `BlindVM_Poll_List_Yield`
|
||||||
|
- `BlindVM_Open`(begin/end 包裹 openBlindBox 调用)
|
||||||
|
|
||||||
|
## 3. 指标口径(阶段耗时定义)
|
||||||
|
- `T_bootstrap = BlindBox_Appear → BlindVM_Bootstrap_Done`
|
||||||
|
- `T_ready = 首次出现 BlindBox_Status_Unopened`(或 `.Preparing → .Unopened` 的跃迁)
|
||||||
|
- `T_open_api = BlindVM_Open(begin) → BlindVM_Open(end)`(开盒 API 往返)
|
||||||
|
- `T_open_anim = BlindBox_Opening_Begin → BlindBox_Opening_ShowMedia`(动画到媒体展示)
|
||||||
|
- `T_prepare_media`(建议新增埋点后再测,见第 7 节)
|
||||||
|
|
||||||
|
建议阈值(参考):
|
||||||
|
- Image:`T_bootstrap ≤ 800ms`,`T_prepare_media ≤ 300ms`
|
||||||
|
- Video:`T_bootstrap ≤ 1200ms`,`首帧可播放 ≤ 1.0s`
|
||||||
|
- Core Animation:每 1 秒内掉帧 < 3;主线程占用峰值 < 80%
|
||||||
|
|
||||||
|
## 4. 环境与准备
|
||||||
|
- 设备:实体机(建议 iPhone 13 及以上),保持温度和电量稳定
|
||||||
|
- 构建:Release/Profile(Xcode → Product → Scheme → Edit Scheme → Run = Release)
|
||||||
|
- 关闭调试开关(如 Metal API Validation)
|
||||||
|
|
||||||
|
## 5. 数据采集(Xcode Instruments)
|
||||||
|
1) Xcode 菜单 `Product → Profile`,选择模板:
|
||||||
|
- OS Signpost(主时间轴)
|
||||||
|
- Time Profiler(CPU/主线程占用)
|
||||||
|
- Core Animation(FPS 与掉帧)
|
||||||
|
- 选配:Network(若要看请求耗时)
|
||||||
|
2) 运行路径:
|
||||||
|
- 场景 A(Image)
|
||||||
|
- 冷启动 App → 进入 `BlindBoxView` → 等待状态到 Unopened → 点击开启 → 等待图片展示 → 停止录制
|
||||||
|
- 场景 B(Video)
|
||||||
|
- 冷启动 App → 进入 `BlindBoxView` → 等待状态到 Unopened → 点击开启 → 视频开始播放 → 停止录制
|
||||||
|
3) 标注与导出:
|
||||||
|
- 在 OS Signpost 轨道上对齐事件(见第 2 节),测量 T_* 并记录
|
||||||
|
- 导出 A/B 两条 trace 作为基线归档
|
||||||
|
|
||||||
|
## 6. 可选 CLI(xctrace)
|
||||||
|
- 查看模板与设备:
|
||||||
|
```bash
|
||||||
|
xcrun xctrace list templates
|
||||||
|
xcrun xctrace list devices
|
||||||
|
```
|
||||||
|
- 采集 Time Profiler(将占位符替换为实际 Bundle ID):
|
||||||
|
```bash
|
||||||
|
xcrun xctrace record \
|
||||||
|
--template "Time Profiler" \
|
||||||
|
--output "~/Desktop/wake_timeprofiler.trace" \
|
||||||
|
--launch com.your.bundle.id
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 分析方法
|
||||||
|
- OS Signpost:筛选 `subsystem = app.wake`,`category = performance`,沿时间轴读取 T_*。
|
||||||
|
- Time Profiler:关注主线程热点(Lottie 渲染、SVG 绘制、SwiftUI 布局、图片解码、AVAsset 初始化等)。
|
||||||
|
- Core Animation:查看帧时间直方图与掉帧分布,对应时间截面回到 Time Profiler 交叉验证 CPU 热点。
|
||||||
|
|
||||||
|
## 8. 建议新增埋点(便于下一轮更精细分析)
|
||||||
|
- `prepareMedia()`(`BlindBoxViewModel`)中增加 begin/end:
|
||||||
|
- 图片:`BlindVM_PrepareMedia_Image`
|
||||||
|
- 视频:`BlindVM_PrepareMedia_Video`
|
||||||
|
- `BlindBoxView` 的开启动画链路,如需更细,可在 `BlindBox_Opening_Begin → BlindBox_Opening_ShowMedia` 之间插入阶段性事件(例如某帧/进度阈值)。
|
||||||
|
|
||||||
|
## 9. 常见瓶颈与建议
|
||||||
|
- 图片解码在主线程:
|
||||||
|
- 现状:`prepareMedia()` 标注了 `@MainActor`,`UIImage(data:)` 可能阻塞主线程。
|
||||||
|
- 建议:使用后台队列/`Task.detached(priority: .userInitiated)` 解码,回主线程赋值;或使用 `CGImageSource` 增量解码。
|
||||||
|
- AVAsset 初始化与尺寸计算:
|
||||||
|
- 建议:使用异步属性加载(如 `await asset.load(.tracks)`),后台计算宽高比后再回主线程设置。
|
||||||
|
- Lottie 渲染:
|
||||||
|
- 建议:仅在可见时播放(当前已实现),检查 JSON 体量与层数,必要时优化资源。
|
||||||
|
- SVG 渲染:
|
||||||
|
- 建议:大面积静态背景预栅格化为 PNG,交互区域保留矢量;或增加缓存层。
|
||||||
|
- OnBoarding 去重:
|
||||||
|
- 现状:用 `uiImage.pngData()` 做去重,计算较重。
|
||||||
|
- 建议:改用轻量哈希(缩略图 + 平均哈希/pHash)或文件 URL/尺寸 + 字节总量近似判重。
|
||||||
|
- 网络与缓存:
|
||||||
|
- 建议:图片设置合理 `URLCache`;视频首帧使用低码率预览或占位图,降低等待感;网络层收集 `URLSessionTaskMetrics` 做 RTT/吞吐量观测。
|
||||||
|
|
||||||
|
## 10. 记录模板(直接复制并填写)
|
||||||
|
```markdown
|
||||||
|
# perf-1 基线(日期:____ / 设备:____ / 系统:____ / 构建:Release)
|
||||||
|
|
||||||
|
## 场景 A(Image)
|
||||||
|
- T_bootstrap:____ ms
|
||||||
|
- T_ready:____ ms
|
||||||
|
- T_open_api:____ ms
|
||||||
|
- T_open_anim:____ ms
|
||||||
|
- T_prepare_media(若有):____ ms
|
||||||
|
- Core Animation(平均 FPS / 掉帧):____ / ____
|
||||||
|
- Time Profiler 热点(主线程 Top3):
|
||||||
|
- 1) ____(____%)
|
||||||
|
- 2) ____(____%)
|
||||||
|
- 3) ____(____%)
|
||||||
|
- 结论与问题:
|
||||||
|
|
||||||
|
## 场景 B(Video)
|
||||||
|
- T_bootstrap:____ ms
|
||||||
|
- T_ready:____ ms
|
||||||
|
- T_open_api:____ ms
|
||||||
|
- T_open_anim:____ ms
|
||||||
|
- T_prepare_media(若有):____ ms
|
||||||
|
- Core Animation(平均 FPS / 掉帧):____ / ____
|
||||||
|
- Time Profiler 热点(主线程 Top3):
|
||||||
|
- 1) ____(____%)
|
||||||
|
- 2) ____(____%)
|
||||||
|
- 3) ____(____%)
|
||||||
|
- 结论与问题:
|
||||||
|
|
||||||
|
## 归纳与下一步
|
||||||
|
- 瓶颈总结:
|
||||||
|
- 优化优先级(P0/P1/P2):
|
||||||
|
- 行动项:
|
||||||
|
```
|
||||||
|
|
||||||
|
## 11. 下次继续(执行清单)
|
||||||
|
- 采集 A/B 各 1 条 trace 并填写第 10 节模板
|
||||||
|
- (可选)为 `prepareMedia()` 增加图片/视频的 begin/end 埋点
|
||||||
|
- 将图片解码移至后台线程,回主线程赋值
|
||||||
|
- 使用 AVAsset 异步加载 track/时长并后台计算尺寸
|
||||||
|
- 检查 Lottie/SVG 资源与渲染负载(必要时优化)
|
||||||
|
- 调整 OnBoarding 去重逻辑,避免 `pngData()` 重计算
|
||||||
|
- 配置 `URLCache` 与视频首帧占位方案
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
附注:本指南依赖现有 `Perf` 工具(`Performance.swift`)与相关事件;若需要我直接提交“后台解码 + 细粒度埋点”的实现,请在下次迭代时告知,我会以最小改动提交补丁。
|
||||||
172
specs/refactor_spec.md
Normal file
172
specs/refactor_spec.md
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
# Wake iOS 重构与性能优化规格说明
|
||||||
|
|
||||||
|
版本: v0.1
|
||||||
|
创建时间: 2025-09-08 15:41 +08
|
||||||
|
|
||||||
|
## 背景与目标
|
||||||
|
- 现状问题:代码组织结构一般、页面间切换卡顿(特别是在重动画/媒体加载/网络日志时)。
|
||||||
|
- 目标:
|
||||||
|
- 提升导航一致性与可维护性(仅保留顶层 NavigationStack + Router)。
|
||||||
|
- 降低页面切换卡顿(减少主线程压力、控制刷新频率、优化媒体与动画负载、收敛网络日志)。
|
||||||
|
- 推动 Feature-Oriented 结构与 MVVM,降低视图体量与重绘范围。
|
||||||
|
|
||||||
|
## 架构调整总览
|
||||||
|
- 统一导航:顶层 `NavigationStack(path: $router.path)`(见 `wake/WakeApp.swift`),子页面不再嵌套 `NavigationView`;使用 `Router.shared.navigate/pop/popToRoot`。
|
||||||
|
- MVVM:优先对 `BlindBoxView` 引入 `BlindBoxViewModel`,将轮询、计时器、媒体预处理、会员信息等迁至 VM。
|
||||||
|
- 并发与取消:轮询改 `AsyncSequence`/`Task` 可取消;倒计时改 Combine/AsyncTimer;统一在 `onDisappear`/路由变化处取消。
|
||||||
|
- 媒体与动画:GIF 优先替换为 Lottie 或仅在可见态播放;模糊与缩放动画范围与时机控制;媒体元数据后台计算。
|
||||||
|
- 网络日志:Debug 可控、限流;Release 关闭大段打印;使用 `os_log/Logger` 分类。
|
||||||
|
- 工程结构:Feature-Oriented(`Core/`、`Features/*`、`SharedUI/`);延续 Theme/Typography/Spacing 设计系统。
|
||||||
|
|
||||||
|
## 导航设计规范
|
||||||
|
- 顶层:`WakeApp` 中唯一 `NavigationStack`;其它页面不使用 `NavigationView`。
|
||||||
|
- 路由:统一通过 `Router.shared.navigate(to:)`、`Router.shared.pop()`、`Router.shared.popToRoot()`。
|
||||||
|
- 返回按钮:子页面通过 `Router.shared.pop()` 而非 `presentationMode.dismiss()`。
|
||||||
|
|
||||||
|
## BlindBox 模块重构要点
|
||||||
|
- `BlindBoxViewModel`(@MainActor):
|
||||||
|
- 状态:盲盒列表/单盒数据、会员信息、计时与轮询状态、媒体 URL/尺寸/播放器句柄。
|
||||||
|
- 行为:`loadBlindBox()`、`start/stopPolling()`、`startCountdown()`、`prepareVideo/Image()`、资源清理。
|
||||||
|
- 视图拆分:
|
||||||
|
- `BlindBoxHeader`、`BlindBoxAnimationArea`(Loading/Ready/Opening/None)、`BlindBoxActionButton`、`BlindBoxScalingOverlay`。
|
||||||
|
- 视图仅订阅少量 `@Published`,降低 body 重绘。
|
||||||
|
|
||||||
|
## 并发与轮询规范
|
||||||
|
- 轮询:使用 `Task { for await ... in pollSequence }` + `task.cancel()`,严禁无 cancel 的 `while + Task.sleep`。
|
||||||
|
- 倒计时:优先 0.25s–0.5s 频率;必要时“毫秒展示”不落地 state;严格在主线程更新 UI 状态。
|
||||||
|
|
||||||
|
## 媒体与动画规范
|
||||||
|
- GIF -> Lottie 优先;若保留 GIF:仅在可见态播放,避免与大范围模糊+缩放并发。
|
||||||
|
- 媒体预热与尺寸探测走后台,回主线程赋值。
|
||||||
|
- 播放器生命周期集中管理,页面切换前暂停并释放。
|
||||||
|
|
||||||
|
## 网络日志策略
|
||||||
|
- Debug:按需与限长打印;错误优先;可通过开关关闭详细日志。
|
||||||
|
- Release:关闭大段请求/响应体打印。
|
||||||
|
|
||||||
|
## 工程结构规划(建议)
|
||||||
|
- `Core/`:`Utils/`、`Network/`、`Auth/`、`Router/`、`Theme/`、`Typography/`
|
||||||
|
- `Features/BlindBox/`:`View/`、`ViewModel/`、`Models/`、`API/`、`Components/`
|
||||||
|
- `Features/Subscribe/`:含 `CreditsInfoCard`、`PlanCompare` 等
|
||||||
|
- `Features/Memories/`
|
||||||
|
- `SharedUI/`:Buttons、LottieView、SVGImage、SheetModal 等
|
||||||
|
|
||||||
|
## 实施计划(分阶段)
|
||||||
|
- 第一阶段(1–2 天,先解卡顿):
|
||||||
|
1) 统一导航:移除子页面 `NavigationView`,使用顶层 `NavigationStack + Router`。
|
||||||
|
2) 计时器降频:0.25–0.5s;如非必要移除毫秒级显示。
|
||||||
|
3) GIF 限制播放或替换为 Lottie;关/收敛网络大日志。
|
||||||
|
- 第二阶段(2–4 天):
|
||||||
|
4) 为 `BlindBox` 引入 ViewModel,迁移副作用与状态。
|
||||||
|
5) 轮询改为可取消的异步序列;媒体预热与尺寸探测后台化。
|
||||||
|
6) 视图拆分与体量控制。
|
||||||
|
- 第三阶段(持续):
|
||||||
|
7) 目录重组;ViewModel 标注 `@MainActor`;保留 `os_signpost` 监测关键路径。
|
||||||
|
|
||||||
|
## 验收标准(DoD)
|
||||||
|
- 导航:仅顶层 `NavigationStack`;子页面无 `NavigationView`。
|
||||||
|
- 性能:转场掉帧率明显下降;主界面进入/退出动画流畅。
|
||||||
|
- 结构:`BlindBoxView` < 300 行,主要状态/副作用位于 ViewModel。
|
||||||
|
- 资源:GIF 仅在可见时播放或替换为 Lottie;网络日志按需输出。
|
||||||
|
|
||||||
|
## 任务清单(同步 todo)
|
||||||
|
- [x] nav-1 统一导航(移除子页面 NavigationView,Router 返回)
|
||||||
|
- [x] mvvm-1 BlindBox 引入 ViewModel,迁移逻辑
|
||||||
|
- [x] timer-1 计时器降频与取消
|
||||||
|
- [x] polling-1 轮询可取消化
|
||||||
|
- [x] media-1 媒体与动画优化(GIF->Lottie/可见播放)
|
||||||
|
- [x] concurrency-1 @MainActor 与线程安全
|
||||||
|
- [x] netlog-1 网络日志开关与限流
|
||||||
|
- [ ] structure-1 目录重组(进行中)
|
||||||
|
- [ ] perf-1 性能埋点与基线(进行中)
|
||||||
|
|
||||||
|
## 进度记录(每次执行后更新)
|
||||||
|
- 2025-09-08 15:41 +08: 创建 specs 目录与本说明(v0.1)。
|
||||||
|
- 2025-09-08 15:41 +08: 完成 nav-1(第一步):移除 `wake/View/Blind/BlindOutCome.swift` 与 `wake/View/Memories/MemoriesView.swift` 中的 `NavigationView`,改为使用 `Router.shared.pop()` 返回;依赖顶层 `NavigationStack`。
|
||||||
|
- 2025-09-08 15:51 +08: 继续完成 nav-1:
|
||||||
|
- 移除 `wake/View/Credits/CreditsDetailView.swift`、`wake/View/Welcome/SplashView.swift`、`wake/View/Owner/SettingsView.swift`、`wake/View/Components/Upload/MediaUpload.swift`(示例 `MediaUploadExample`)、`wake/View/Examples/MediaDemo.swift` 中的 `NavigationView`。
|
||||||
|
- 将 `wake/View/Feedback.swift` 中 `FeedbackView` 与 `FeedbackDetailView` 的返回行为从 `dismiss()` 统一为 `Router.shared.pop()`。
|
||||||
|
- 保留预览(Preview)中的 `NavigationView`,运行时代码已全部依赖顶层 `NavigationStack + Router`。
|
||||||
|
- 2025-09-08 16:10 +08: 完成 timer-1 与 netlog-1:
|
||||||
|
- 倒计时更新频率由 0.1s 改为 1s,移除毫秒级显示;初始值设为 `(36, 50, 0)`,减少 UI 重绘(`wake/View/Blind/ContentView.swift`)。
|
||||||
|
- 网络日志:将详细请求/成功响应日志置于 `#if DEBUG`,错误响应体截断至约 300 字符;401 刷新 Token 相关提示仅在 Debug 下打印(`wake/Utils/NetworkService.swift`)。
|
||||||
|
- 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`)。
|
||||||
|
- 2025-09-08 19:19 +08: 完成 media-1:
|
||||||
|
- Loading/Ready/Opening 全部替换为 Lottie(`loading.json`/`data.json`/`open.json`),并使用 `isPlaying` 仅在可见时播放;Opening 使用 `.playOnce`。
|
||||||
|
- 引入 `Perf` 工具类并在关键路径打点(Appear、状态切换、开盒点击、开启动画)以支持 perf-1。
|
||||||
|
- 2025-09-08 19:19 +08: 启动 structure-1(进行中):
|
||||||
|
- 创建目录骨架:`Core/`、`SharedUI/`、`Features/BlindBox/`、`Features/Subscribe/` 等,仅添加 README,不改变构建;后续在 Xcode 内移动文件以保持引用正确。
|
||||||
|
|
||||||
|
- 2025-09-09 11:26 +08: 推进 structure-1:
|
||||||
|
- 完成盲盒批次文件迁移(View/ViewModel/API/Models/Components)。
|
||||||
|
- 完成 SharedUI 与 Core 的第 1–3 步迁移:
|
||||||
|
- SharedUI:`LottieView.swift`、`GIFView.swift`、`SVGImage.swift`、`SheetModal.swift` 已迁移至 `wake/SharedUI/...`(当前均放在 `Animation/` 分组,后续可按需细分 `Media/`、`Modals/`)。
|
||||||
|
- Core:`Router.swift` 已迁至 `Core/Navigation/`,`NetworkService.swift` 已迁至 `Core/Network/`,`Theme.swift` 与 `Typography.swift` 已迁至 `Core/DesignSystem/`。
|
||||||
|
|
||||||
|
- 2025-09-09 11:30 +08: 更新 structure-1:
|
||||||
|
- 已完成 `Performance.swift` → `Core/Diagnostics/`,`APIConfig.swift` → `Core/Network/`。
|
||||||
|
- 已完成 `SVGImageHtml.swift` → `SharedUI/Media/`。
|
||||||
|
- 待优化(可选):将 `GIFView.swift`、`SVGImage.swift` 从 `SharedUI/Animation/` 细分到 `SharedUI/Media/`;将 `SheetModal.swift` 从 `SharedUI/Animation/` 移至 `SharedUI/Modals/`。
|
||||||
|
|
||||||
|
## structure-1 目录重构计划与迁移步骤
|
||||||
|
|
||||||
|
### 目标结构
|
||||||
|
- `wake/Core/`
|
||||||
|
- `DesignSystem/`(`Theme.swift`、`Typography.swift`)
|
||||||
|
- `Navigation/`(`Router.swift`)
|
||||||
|
- `Diagnostics/`(`Performance.swift`)
|
||||||
|
- `Network/`(`NetworkService.swift`、`APIConfig.swift`、通用 ApiClient/*)
|
||||||
|
- `wake/SharedUI/`
|
||||||
|
- `Animation/`(`LottieView.swift`)
|
||||||
|
- `Media/`(`GIFView.swift`、`SVGImage.swift`/`SVGImageHtml.swift`)
|
||||||
|
- `Modals/`(`SheetModal.swift` 等)
|
||||||
|
- `Controls/`(通用按钮、输入框等)
|
||||||
|
- `Graphics/`(共享图形资源包装)
|
||||||
|
- `wake/Features/BlindBox/`
|
||||||
|
- `View/`(`ContentView.swift` -> 建议更名 `BlindBoxView.swift`)
|
||||||
|
- `ViewModel/`(`BlindBoxViewModel.swift`)
|
||||||
|
- `API/`(`BlindBoxApi.swift`、`BlindBoxPolling.swift`)
|
||||||
|
- `Models/`(`BlindModels.swift`)
|
||||||
|
- `Components/`(与盲盒强相关的子视图)
|
||||||
|
- `wake/Features/Subscribe/`
|
||||||
|
- `Components/`(`CreditsInfoCard.swift`、`PlanCompare.swift` 等)
|
||||||
|
- `View/`(`SubscribeView.swift` 及其子视图)
|
||||||
|
|
||||||
|
### 迁移建议
|
||||||
|
1. 在 Xcode 的 Project Navigator 中,先创建“Group without folder”形式的虚拟分组,稳定编译;再选择是否同步到磁盘。
|
||||||
|
2. 若需要调整磁盘目录:
|
||||||
|
- 建议在 Xcode 中使用拖拽将文件移动到对应 Group,同时勾选“Move files”与“Add to targets”,避免红色引用。
|
||||||
|
- 一次迁移一个 Feature,迁移后立即编译验证。
|
||||||
|
3. 资源文件(Lottie JSON/SVG 等)保持在 `wake/Assets/` 下;仅调整其使用方的源文件位置。
|
||||||
|
4. 网络与工具类尽量放入 `Core/`,Feature 专用 API 可放在 `Features/<Feature>/API/`。
|
||||||
|
|
||||||
|
### 第一批建议迁移(盲盒)
|
||||||
|
- `wake/View/Blind/ContentView.swift` → `wake/Features/BlindBox/View/BlindBoxView.swift`(建议改名)
|
||||||
|
- `wake/View/Blind/BlindBoxViewModel.swift` → `wake/Features/BlindBox/ViewModel/BlindBoxViewModel.swift`
|
||||||
|
- `wake/View/Blind/BlindBoxPolling.swift` → `wake/Features/BlindBox/API/BlindBoxPolling.swift`
|
||||||
|
- `wake/Utils/ApiClient/BlindBoxApi.swift` → `wake/Features/BlindBox/API/BlindBoxApi.swift`
|
||||||
|
- `wake/Models/BlindModels.swift` → `wake/Features/BlindBox/Models/BlindModels.swift`
|
||||||
|
|
||||||
|
### 公共组件迁移
|
||||||
|
- `wake/Components/Lottie/LottieView.swift` → `wake/SharedUI/Animation/LottieView.swift`
|
||||||
|
- `wake/Utils/GIFView.swift` → `wake/SharedUI/Media/GIFView.swift`
|
||||||
|
- `wake/Utils/SVGImage.swift` 与 `wake/Utils/SVGImageHtml.swift` → `wake/SharedUI/Media/`
|
||||||
|
- `wake/View/Components/SheetModal.swift` → `wake/SharedUI/Modals/SheetModal.swift`
|
||||||
|
- `wake/View/Components/Button.swift`/`Buttons/` → `wake/SharedUI/Controls/`
|
||||||
|
|
||||||
|
### 核心模块迁移
|
||||||
|
- `wake/Utils/Router.swift` → `wake/Core/Navigation/Router.swift`
|
||||||
|
- `wake/Utils/Performance.swift` → `wake/Core/Diagnostics/Performance.swift`
|
||||||
|
- `wake/Utils/NetworkService.swift`、`wake/Utils/APIConfig.swift` → `wake/Core/Network/`
|
||||||
|
- `wake/Theme.swift`、`wake/Typography.swift` → `wake/Core/DesignSystem/`
|
||||||
|
|
||||||
|
## 决策记录
|
||||||
|
- 采用顶层 `NavigationStack + Router`,子页面取消 `NavigationView`。
|
||||||
|
- `BlindBox` 优先落地 MVVM 重构,其它模块随后跟进。
|
||||||
@ -38,7 +38,25 @@
|
|||||||
AB4FA8642E4F7074005D9955 /* Exceptions for "wake" folder in "wake" target */ = {
|
AB4FA8642E4F7074005D9955 /* Exceptions for "wake" folder in "wake" target */ = {
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
|
Core/DesignSystem/README.md,
|
||||||
|
Core/Diagnostics/README.md,
|
||||||
|
Core/Navigation/README.md,
|
||||||
|
Core/Network/README.md,
|
||||||
|
Core/README.md,
|
||||||
|
Features/BlindBox/API/README.md,
|
||||||
|
Features/BlindBox/Components/README.md,
|
||||||
|
Features/BlindBox/Models/README.md,
|
||||||
|
Features/BlindBox/README.md,
|
||||||
|
Features/BlindBox/View/README.md,
|
||||||
|
Features/BlindBox/ViewModel/README.md,
|
||||||
|
Features/Subscribe/README.md,
|
||||||
Info.plist,
|
Info.plist,
|
||||||
|
SharedUI/Animation/README.md,
|
||||||
|
SharedUI/Controls/README.md,
|
||||||
|
SharedUI/Graphics/README.md,
|
||||||
|
SharedUI/Media/README.md,
|
||||||
|
SharedUI/Modals/README.md,
|
||||||
|
SharedUI/README.md,
|
||||||
);
|
);
|
||||||
target = ABB4E2072E4B75D900660198 /* wake */;
|
target = ABB4E2072E4B75D900660198 /* wake */;
|
||||||
};
|
};
|
||||||
|
|||||||
2
wake/Core/DesignSystem/README.md
Normal file
2
wake/Core/DesignSystem/README.md
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# Core/DesignSystem
|
||||||
|
存放 `Theme.swift`、`Typography.swift` 等设计系统文件。
|
||||||
21
wake/Core/Diagnostics/Performance.swift
Normal file
21
wake/Core/Diagnostics/Performance.swift
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
|
enum Perf {
|
||||||
|
private static let log = OSLog(subsystem: "app.wake", category: "performance")
|
||||||
|
|
||||||
|
static func event(_ name: StaticString) {
|
||||||
|
os_signpost(.event, log: log, name: name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
static func begin(_ name: StaticString) -> OSSignpostID {
|
||||||
|
let id = OSSignpostID(log: log)
|
||||||
|
os_signpost(.begin, log: log, name: name, signpostID: id)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
static func end(_ name: StaticString, id: OSSignpostID) {
|
||||||
|
os_signpost(.end, log: log, name: name, signpostID: id)
|
||||||
|
}
|
||||||
|
}
|
||||||
2
wake/Core/Diagnostics/README.md
Normal file
2
wake/Core/Diagnostics/README.md
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# Core/Diagnostics
|
||||||
|
性能与诊断相关:`Performance.swift`,以及后续埋点/日志工具。
|
||||||
2
wake/Core/Navigation/README.md
Normal file
2
wake/Core/Navigation/README.md
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# Core/Navigation
|
||||||
|
存放路由与导航相关:`Router.swift`。
|
||||||
@ -287,13 +287,15 @@ class NetworkService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 打印请求信息
|
// 打印请求信息(仅 Debug)
|
||||||
|
#if DEBUG
|
||||||
print("""
|
print("""
|
||||||
🌐 [Network][#\(requestId)][\(method) \(path)] 开始请求
|
🌐 [Network][#\(requestId)][\(method) \(path)] 开始请求
|
||||||
🔗 URL: \(url.absoluteString)
|
🔗 URL: \(url.absoluteString)
|
||||||
📤 Headers: \(request.allHTTPHeaderFields ?? [:])
|
📤 Headers: \(request.allHTTPHeaderFields ?? [:])
|
||||||
📦 Body: \(request.httpBody.flatMap { String(data: $0, encoding: .utf8) } ?? "")
|
📦 Body: \(request.httpBody.flatMap { String(data: $0, encoding: .utf8) } ?? "")
|
||||||
""")
|
""")
|
||||||
|
#endif
|
||||||
|
|
||||||
// 创建任务
|
// 创建任务
|
||||||
let startTime = Date()
|
let startTime = Date()
|
||||||
@ -338,10 +340,12 @@ class NetworkService {
|
|||||||
|
|
||||||
// 处理401未授权
|
// 处理401未授权
|
||||||
if statusCode == 401 {
|
if statusCode == 401 {
|
||||||
|
#if DEBUG
|
||||||
print("""
|
print("""
|
||||||
🔑 [Network][#\(requestId)][\(method) \(path)] 检测到未授权,尝试刷新token...
|
🔑 [Network][#\(requestId)][\(method) \(path)] 检测到未授权,尝试刷新token...
|
||||||
⏱️ 耗时: \(duration)
|
⏱️ 耗时: \(duration)
|
||||||
""")
|
""")
|
||||||
|
#endif
|
||||||
|
|
||||||
// 将请求加入重试队列
|
// 将请求加入重试队列
|
||||||
let dataResult = data.flatMap { Result<Data, NetworkError>.success($0) } ?? .failure(.noData)
|
let dataResult = data.flatMap { Result<Data, NetworkError>.success($0) } ?? .failure(.noData)
|
||||||
@ -351,10 +355,12 @@ class NetworkService {
|
|||||||
do {
|
do {
|
||||||
let decoder = JSONDecoder()
|
let decoder = JSONDecoder()
|
||||||
let result = try decoder.decode(T.self, from: data)
|
let result = try decoder.decode(T.self, from: data)
|
||||||
|
#if DEBUG
|
||||||
print("""
|
print("""
|
||||||
✅ [Network][#\(requestId)][\(method) \(path)] 重试成功
|
✅ [Network][#\(requestId)][\(method) \(path)] 重试成功
|
||||||
⏱️ 总耗时: \(duration) (包含token刷新时间)
|
⏱️ 总耗时: \(duration) (包含token刷新时间)
|
||||||
""")
|
""")
|
||||||
|
#endif
|
||||||
completion(.success(result))
|
completion(.success(result))
|
||||||
} catch let decodingError as DecodingError {
|
} catch let decodingError as DecodingError {
|
||||||
print("""
|
print("""
|
||||||
@ -389,22 +395,25 @@ class NetworkService {
|
|||||||
// 处理其他错误状态码
|
// 处理其他错误状态码
|
||||||
if !(200...299).contains(statusCode) {
|
if !(200...299).contains(statusCode) {
|
||||||
let errorMessage = data.flatMap { String(data: $0, encoding: .utf8) } ?? ""
|
let errorMessage = data.flatMap { String(data: $0, encoding: .utf8) } ?? ""
|
||||||
|
let truncated = errorMessage.count > 300 ? String(errorMessage.prefix(300)) + "..." : errorMessage
|
||||||
print("""
|
print("""
|
||||||
❌ [Network][#\(requestId)][\(method) \(path)] 请求失败
|
❌ [Network][#\(requestId)][\(method) \(path)] 请求失败
|
||||||
📊 状态码: \(statusCode) (\(statusMessage))
|
📊 状态码: \(statusCode) (\(statusMessage))
|
||||||
⏱️ 耗时: \(duration)
|
⏱️ 耗时: \(duration)
|
||||||
🔍 错误响应: \(errorMessage)
|
🔍 错误响应: \(truncated)
|
||||||
""")
|
""")
|
||||||
completion(.failure(.serverError("状态码: \(statusCode), 响应: \(errorMessage)")))
|
completion(.failure(.serverError("状态码: \(statusCode), 响应: \(truncated)")))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 成功响应
|
// 成功响应(仅 Debug)
|
||||||
|
#if DEBUG
|
||||||
print("""
|
print("""
|
||||||
✅ [Network][#\(requestId)][\(method) \(path)] 请求成功
|
✅ [Network][#\(requestId)][\(method) \(path)] 请求成功
|
||||||
📊 状态码: \(statusCode) (\(statusMessage))
|
📊 状态码: \(statusCode) (\(statusMessage))
|
||||||
⏱️ 耗时: \(duration)
|
⏱️ 耗时: \(duration)
|
||||||
""")
|
""")
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理网络错误
|
// 处理网络错误
|
||||||
@ -428,13 +437,15 @@ class NetworkService {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 打印响应数据(调试用)
|
// 打印响应数据(仅 Debug)
|
||||||
|
#if DEBUG
|
||||||
if let responseString = String(data: data, encoding: .utf8) {
|
if let responseString = String(data: data, encoding: .utf8) {
|
||||||
print("""
|
print("""
|
||||||
📥 [Network][#\(requestId)][\(method) \(path)] 响应数据:
|
📥 [Network][#\(requestId)][\(method) \(path)] 响应数据:
|
||||||
\(responseString.prefix(1000))\(responseString.count > 1000 ? "..." : "")
|
\(responseString.prefix(1000))\(responseString.count > 1000 ? "..." : "")
|
||||||
""")
|
""")
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
do {
|
do {
|
||||||
// 解析JSON数据
|
// 解析JSON数据
|
||||||
@ -442,11 +453,18 @@ class NetworkService {
|
|||||||
let result = try decoder.decode(T.self, from: data)
|
let result = try decoder.decode(T.self, from: data)
|
||||||
completion(.success(result))
|
completion(.success(result))
|
||||||
} catch let decodingError as DecodingError {
|
} catch let decodingError as DecodingError {
|
||||||
|
#if DEBUG
|
||||||
print("""
|
print("""
|
||||||
❌ [Network][#\(requestId)][\(method) \(path)] JSON解析失败
|
❌ [Network][#\(requestId)][\(method) \(path)] JSON解析失败
|
||||||
🔍 错误: \(decodingError.localizedDescription)
|
🔍 错误: \(decodingError.localizedDescription)
|
||||||
📦 原始数据: \(String(data: data, encoding: .utf8) ?? "")
|
📦 原始数据: \(String(data: data, encoding: .utf8) ?? "")
|
||||||
""")
|
""")
|
||||||
|
#else
|
||||||
|
print("""
|
||||||
|
❌ [Network][#\(requestId)][\(method) \(path)] JSON解析失败
|
||||||
|
🔍 错误: \(decodingError.localizedDescription)
|
||||||
|
""")
|
||||||
|
#endif
|
||||||
completion(.failure(.decodingError(decodingError)))
|
completion(.failure(.decodingError(decodingError)))
|
||||||
} catch {
|
} catch {
|
||||||
print("""
|
print("""
|
||||||
@ -463,7 +481,9 @@ class NetworkService {
|
|||||||
isRefreshing = true
|
isRefreshing = true
|
||||||
let refreshStartTime = Date()
|
let refreshStartTime = Date()
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
print("🔄 [Network] 开始刷新Token...")
|
print("🔄 [Network] 开始刷新Token...")
|
||||||
|
#endif
|
||||||
|
|
||||||
TokenManager.shared.refreshToken { [weak self] success, _ in
|
TokenManager.shared.refreshToken { [weak self] success, _ in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
@ -471,11 +491,13 @@ class NetworkService {
|
|||||||
let refreshDuration = String(format: "%.3fs", Date().timeIntervalSince(refreshStartTime))
|
let refreshDuration = String(format: "%.3fs", Date().timeIntervalSince(refreshStartTime))
|
||||||
|
|
||||||
if success {
|
if success {
|
||||||
|
#if DEBUG
|
||||||
print("""
|
print("""
|
||||||
✅ [Network] Token刷新成功
|
✅ [Network] Token刷新成功
|
||||||
⏱️ 耗时: \(refreshDuration)
|
⏱️ 耗时: \(refreshDuration)
|
||||||
🔄 准备重试\(self.requestsToRetry.count)个请求...
|
🔄 准备重试\(self.requestsToRetry.count)个请求...
|
||||||
""")
|
""")
|
||||||
|
#endif
|
||||||
|
|
||||||
// 重试所有待处理的请求
|
// 重试所有待处理的请求
|
||||||
let requestsToRetry = self.requestsToRetry
|
let requestsToRetry = self.requestsToRetry
|
||||||
@ -499,11 +521,13 @@ class NetworkService {
|
|||||||
task.resume()
|
task.resume()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
#if DEBUG
|
||||||
print("""
|
print("""
|
||||||
❌ [Network] Token刷新失败
|
❌ [Network] Token刷新失败
|
||||||
⏱️ 耗时: \(refreshDuration)
|
⏱️ 耗时: \(refreshDuration)
|
||||||
🚪 清除登录状态...
|
🚪 清除登录状态...
|
||||||
""")
|
""")
|
||||||
|
#endif
|
||||||
|
|
||||||
// 清除token并通知需要重新登录
|
// 清除token并通知需要重新登录
|
||||||
TokenManager.shared.clearTokens()
|
TokenManager.shared.clearTokens()
|
||||||
2
wake/Core/Network/README.md
Normal file
2
wake/Core/Network/README.md
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# Core/Network
|
||||||
|
通用网络层与配置:`NetworkService.swift`、`APIConfig.swift`、ApiClient 公共代码。
|
||||||
9
wake/Core/README.md
Normal file
9
wake/Core/README.md
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# Core
|
||||||
|
|
||||||
|
跨特性共享的核心能力:
|
||||||
|
- DesignSystem(主题、字体、间距)
|
||||||
|
- Navigation(路由/导航栈)
|
||||||
|
- Diagnostics(性能与日志)
|
||||||
|
- Network(网络与配置)
|
||||||
|
|
||||||
|
建议通过 Xcode Group 先完成组织,再视需要同步到磁盘。
|
||||||
65
wake/Features/BlindBox/API/BlindBoxPolling.swift
Normal file
65
wake/Features/BlindBox/API/BlindBoxPolling.swift
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - BlindBox Async Polling Sequences
|
||||||
|
|
||||||
|
enum BlindBoxPolling {
|
||||||
|
/// Poll a single blind box until it becomes "Unopened".
|
||||||
|
/// Yields once when ready, then finishes.
|
||||||
|
static func singleBox(boxId: String, intervalSeconds: Double = 2.0) -> AsyncThrowingStream<BlindBoxData, Error> {
|
||||||
|
AsyncThrowingStream { continuation in
|
||||||
|
let task = Task {
|
||||||
|
while !Task.isCancelled {
|
||||||
|
do {
|
||||||
|
let result = try await BlindBoxApi.shared.getBlindBox(boxId: boxId)
|
||||||
|
if let data = result {
|
||||||
|
if data.status.lowercased() == "unopened" {
|
||||||
|
continuation.yield(data)
|
||||||
|
continuation.finish()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try await Task.sleep(nanoseconds: UInt64(intervalSeconds * 1_000_000_000))
|
||||||
|
} catch is CancellationError {
|
||||||
|
continuation.finish()
|
||||||
|
break
|
||||||
|
} catch {
|
||||||
|
continuation.finish(throwing: error)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continuation.onTermination = { @Sendable _ in
|
||||||
|
task.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll blind box list and yield first unopened box when available.
|
||||||
|
/// Yields once when found, then finishes.
|
||||||
|
static func firstUnopened(intervalSeconds: Double = 2.0) -> AsyncThrowingStream<BlindBoxData, Error> {
|
||||||
|
AsyncThrowingStream { continuation in
|
||||||
|
let task = Task {
|
||||||
|
while !Task.isCancelled {
|
||||||
|
do {
|
||||||
|
let list = try await BlindBoxApi.shared.getBlindBoxList()
|
||||||
|
if let item = list?.first(where: { $0.status.lowercased() == "unopened" }) {
|
||||||
|
continuation.yield(item)
|
||||||
|
continuation.finish()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
try await Task.sleep(nanoseconds: UInt64(intervalSeconds * 1_000_000_000))
|
||||||
|
} catch is CancellationError {
|
||||||
|
continuation.finish()
|
||||||
|
break
|
||||||
|
} catch {
|
||||||
|
continuation.finish(throwing: error)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continuation.onTermination = { @Sendable _ in
|
||||||
|
task.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
wake/Features/BlindBox/API/README.md
Normal file
2
wake/Features/BlindBox/API/README.md
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# Features/BlindBox/API
|
||||||
|
盲盒相关 API 封装与轮询:`BlindBoxApi.swift`、`BlindBoxPolling.swift`。
|
||||||
2
wake/Features/BlindBox/Components/README.md
Normal file
2
wake/Features/BlindBox/Components/README.md
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# Features/BlindBox/Components
|
||||||
|
盲盒专属子组件与片段视图。
|
||||||
2
wake/Features/BlindBox/Models/README.md
Normal file
2
wake/Features/BlindBox/Models/README.md
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# Features/BlindBox/Models
|
||||||
|
盲盒业务模型:`BlindModels.swift` 等。
|
||||||
2
wake/Features/BlindBox/README.md
Normal file
2
wake/Features/BlindBox/README.md
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# Features/BlindBox
|
||||||
|
盲盒业务代码:View / ViewModel / API / Models / Components。
|
||||||
@ -71,39 +71,17 @@ 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
|
// 倒计时由 ViewModel 管理(countdownText)
|
||||||
@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 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 = .none
|
||||||
@State private var scale: CGFloat = 0.1
|
@State private var scale: CGFloat = 0.1
|
||||||
@State private var videoPlayer: AVPlayer?
|
|
||||||
@State private var showControls = false
|
@State private var showControls = false
|
||||||
@State private var isAnimating = true
|
@State private var isAnimating = true
|
||||||
@State private var aspectRatio: CGFloat = 1.0
|
|
||||||
@State private var isPortrait: Bool = false
|
|
||||||
@State private var displayImage: UIImage?
|
|
||||||
@State private var showMedia = false
|
@State private var showMedia = false
|
||||||
|
|
||||||
// 查询数据 - 简单查询
|
// 查询数据 - 简单查询
|
||||||
@ -112,375 +90,20 @@ 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))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 倒计时
|
// 倒计时已迁移至 ViewModel
|
||||||
private func startCountdown() {
|
|
||||||
// 重置为36:50:20
|
|
||||||
countdown = (36, 50, 20)
|
|
||||||
|
|
||||||
countdownTimer?.invalidate()
|
// 已由 ViewModel 承担加载与轮询逻辑
|
||||||
countdownTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
|
|
||||||
var (minutes, seconds, milliseconds) = countdown
|
|
||||||
|
|
||||||
// 更新毫秒
|
// 已迁移至 ViewModel
|
||||||
milliseconds -= 10
|
|
||||||
if milliseconds < 0 {
|
|
||||||
milliseconds = 90
|
|
||||||
seconds -= 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新秒
|
// 已迁移至 ViewModel
|
||||||
if seconds < 0 {
|
|
||||||
seconds = 59
|
|
||||||
minutes -= 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果倒计时结束,停止计时器
|
// 已迁移至 ViewModel
|
||||||
if minutes <= 0 && seconds <= 0 && milliseconds <= 0 {
|
|
||||||
countdownTimer?.invalidate()
|
|
||||||
countdownTimer = nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
countdown = (minutes, seconds, milliseconds)
|
// 本地媒体加载逻辑已迁移至 ViewModel.prepareMedia()
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadBlindBox() async {
|
|
||||||
print("loadMedia called with mediaType: \(mediaType)")
|
|
||||||
|
|
||||||
if self.currentBoxId != nil {
|
|
||||||
print("指定监听某盲盒结果: ", self.currentBoxId! as Any)
|
|
||||||
// 启动轮询查询盲盒状态
|
|
||||||
await pollingToQuerySingleBox()
|
|
||||||
} else {
|
|
||||||
// 启动轮询查询普通盲盒列表
|
|
||||||
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<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 {
|
|
||||||
stopPolling()
|
|
||||||
isPolling = true
|
|
||||||
|
|
||||||
// 轮询查询盲盒状态,直到状态为Unopened
|
|
||||||
while isPolling {
|
|
||||||
do {
|
|
||||||
let blindBoxData = try await BlindBoxApi.shared.getBlindBox(boxId: self.currentBoxId!)
|
|
||||||
|
|
||||||
// 更新UI
|
|
||||||
if let data = blindBoxData {
|
|
||||||
self.blindGenerate = data
|
|
||||||
|
|
||||||
// 根据盲盒类型设置媒体URL
|
|
||||||
if mediaType == .image {
|
|
||||||
self.imageURL = data.resultFile?.url ?? ""
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
self.videoURL = data.resultFile?.url ?? ""
|
|
||||||
}
|
|
||||||
|
|
||||||
print("✅ 成功获取盲盒数据: \(data.name), 状态: \(data.status)")
|
|
||||||
|
|
||||||
// 检查状态是否为Unopened,如果是则停止轮询
|
|
||||||
if data.status == "Unopened" {
|
|
||||||
print("✅ 盲盒已准备就绪,停止轮询")
|
|
||||||
self.animationPhase = .ready
|
|
||||||
stopPolling()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 等待2秒后继续轮询
|
|
||||||
try await Task.sleep(nanoseconds: 2_000_000_000)
|
|
||||||
} catch {
|
|
||||||
print("❌ 获取盲盒数据失败: \(error)")
|
|
||||||
// 处理错误情况
|
|
||||||
self.animationPhase = .none
|
|
||||||
stopPolling()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func pollingToQueryBlindBox() async {
|
|
||||||
stopPolling()
|
|
||||||
isPolling = true
|
|
||||||
|
|
||||||
while isPolling {
|
|
||||||
do {
|
|
||||||
let blindBoxList = try await BlindBoxApi.shared.getBlindBoxList()
|
|
||||||
print("✅ 获取盲盒列表: \(blindBoxList?.count ?? 0) 条")
|
|
||||||
|
|
||||||
// 统计未开启盲盒数量
|
|
||||||
self.blindCount = BlindCount(availableQuantity: blindBoxList?.filter({ $0.status == "Unopened" }).count ?? 0)
|
|
||||||
|
|
||||||
// 设置第一个未开启的盲盒
|
|
||||||
if let blindBox = blindBoxList?.first(where: { $0.status == "Unopened" }) {
|
|
||||||
self.blindGenerate = blindBox
|
|
||||||
self.animationPhase = .ready
|
|
||||||
|
|
||||||
// 更新UI
|
|
||||||
// 根据盲盒类型设置媒体URL
|
|
||||||
if mediaType == .image {
|
|
||||||
self.imageURL = blindBox.resultFile?.url ?? ""
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
self.videoURL = blindBox.resultFile?.url ?? ""
|
|
||||||
}
|
|
||||||
|
|
||||||
print("✅ 成功获取盲盒数据: \(blindBox.name), 状态: \(blindBox.status)")
|
|
||||||
stopPolling()
|
|
||||||
break
|
|
||||||
} else {
|
|
||||||
if self.animationPhase != .none {
|
|
||||||
self.animationPhase = .none
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 等待2秒后继续轮询
|
|
||||||
try await Task.sleep(nanoseconds: 2_000_000_000)
|
|
||||||
} catch {
|
|
||||||
print("❌ 获取盲盒列表失败: \(error)")
|
|
||||||
stopPolling()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 轮询接口
|
|
||||||
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(
|
|
||||||
// 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() {
|
|
||||||
guard !imageURL.isEmpty, let url = URL(string: imageURL) else {
|
|
||||||
print("⚠️ 图片URL无效或为空")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
URLSession.shared.dataTask(with: url) { data, _, _ in
|
|
||||||
if let data = data, let image = UIImage(data: data) {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.displayImage = image
|
|
||||||
self.aspectRatio = image.size.width / image.size.height
|
|
||||||
self.isPortrait = image.size.height > image.size.width
|
|
||||||
self.showScalingOverlay = true // 确保显示媒体内容
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.resume()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadVideo() {
|
|
||||||
guard !videoURL.isEmpty, let url = URL(string: videoURL) else {
|
|
||||||
print("⚠️ 视频URL无效或为空")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let asset = AVAsset(url: url)
|
|
||||||
let playerItem = AVPlayerItem(asset: asset)
|
|
||||||
let player = AVPlayer(playerItem: playerItem)
|
|
||||||
|
|
||||||
let videoTracks = asset.tracks(withMediaType: .video)
|
|
||||||
if let videoTrack = videoTracks.first {
|
|
||||||
let size = videoTrack.naturalSize.applying(videoTrack.preferredTransform)
|
|
||||||
let width = abs(size.width)
|
|
||||||
let height = abs(size.height)
|
|
||||||
|
|
||||||
aspectRatio = width / height
|
|
||||||
isPortrait = height > width
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新视频播放器
|
|
||||||
videoPlayer = player
|
|
||||||
videoPlayer?.play()
|
|
||||||
showScalingOverlay = true // 确保显示媒体内容
|
|
||||||
}
|
|
||||||
|
|
||||||
private func prepareVideo() {
|
|
||||||
guard !videoURL.isEmpty, let url = URL(string: videoURL) else {
|
|
||||||
print("⚠️ 视频URL无效或为空")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let asset = AVAsset(url: url)
|
|
||||||
let playerItem = AVPlayerItem(asset: asset)
|
|
||||||
let player = AVPlayer(playerItem: playerItem)
|
|
||||||
|
|
||||||
let videoTracks = asset.tracks(withMediaType: .video)
|
|
||||||
if let videoTrack = videoTracks.first {
|
|
||||||
let size = videoTrack.naturalSize.applying(videoTrack.preferredTransform)
|
|
||||||
let width = abs(size.width)
|
|
||||||
let height = abs(size.height)
|
|
||||||
|
|
||||||
aspectRatio = width / height
|
|
||||||
isPortrait = height > width
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新视频播放器
|
|
||||||
videoPlayer = player
|
|
||||||
}
|
|
||||||
|
|
||||||
private func prepareImage() {
|
|
||||||
guard !imageURL.isEmpty, let url = URL(string: imageURL) else {
|
|
||||||
print("⚠️ 图片URL无效或为空")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
URLSession.shared.dataTask(with: url) { data, _, _ in
|
|
||||||
if let data = data, let image = UIImage(data: data) {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.displayImage = image
|
|
||||||
self.aspectRatio = image.size.width / image.size.height
|
|
||||||
self.isPortrait = image.size.height > image.size.width
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.resume()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startScalingAnimation() {
|
private func startScalingAnimation() {
|
||||||
self.scale = 0.1
|
self.scale = 0.1
|
||||||
@ -493,18 +116,18 @@ struct BlindBoxView: View {
|
|||||||
|
|
||||||
// MARK: - Computed Properties
|
// MARK: - Computed Properties
|
||||||
private var scaledWidth: CGFloat {
|
private var scaledWidth: CGFloat {
|
||||||
if isPortrait {
|
if viewModel.isPortrait {
|
||||||
return UIScreen.main.bounds.height * scale * 1/aspectRatio
|
return UIScreen.main.bounds.height * scale * 1/viewModel.aspectRatio
|
||||||
} else {
|
} else {
|
||||||
return UIScreen.main.bounds.width * scale
|
return UIScreen.main.bounds.width * scale
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var scaledHeight: CGFloat {
|
private var scaledHeight: CGFloat {
|
||||||
if isPortrait {
|
if viewModel.isPortrait {
|
||||||
return UIScreen.main.bounds.height * scale
|
return UIScreen.main.bounds.height * scale
|
||||||
} else {
|
} else {
|
||||||
return UIScreen.main.bounds.width * scale * 1/aspectRatio
|
return UIScreen.main.bounds.width * scale * 1/viewModel.aspectRatio
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -512,6 +135,7 @@ struct BlindBoxView: View {
|
|||||||
ZStack {
|
ZStack {
|
||||||
Color.themeTextWhiteSecondary.ignoresSafeArea()
|
Color.themeTextWhiteSecondary.ignoresSafeArea()
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
Perf.event("BlindBox_Appear")
|
||||||
print("🎯 BlindBoxView appeared with mediaType: \(mediaType)")
|
print("🎯 BlindBoxView appeared with mediaType: \(mediaType)")
|
||||||
print("🎯 Current thread: \(Thread.current)")
|
print("🎯 Current thread: \(Thread.current)")
|
||||||
|
|
||||||
@ -551,18 +175,17 @@ struct BlindBoxView: View {
|
|||||||
// }
|
// }
|
||||||
// 调用接口
|
// 调用接口
|
||||||
Task {
|
Task {
|
||||||
await loadBlindBox()
|
await viewModel.load()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
stopPolling()
|
viewModel.stopPolling()
|
||||||
countdownTimer?.invalidate()
|
viewModel.stopCountdown()
|
||||||
countdownTimer = nil
|
|
||||||
|
|
||||||
// Clean up video player
|
// Clean up video player
|
||||||
videoPlayer?.pause()
|
viewModel.player?.pause()
|
||||||
videoPlayer?.replaceCurrentItem(with: nil)
|
viewModel.player?.replaceCurrentItem(with: nil)
|
||||||
videoPlayer = nil
|
viewModel.player = nil
|
||||||
|
|
||||||
NotificationCenter.default.removeObserver(
|
NotificationCenter.default.removeObserver(
|
||||||
self,
|
self,
|
||||||
@ -570,6 +193,44 @@ struct BlindBoxView: View {
|
|||||||
object: nil
|
object: nil
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.onChange(of: viewModel.blindGenerate?.status) { _, status in
|
||||||
|
guard let status = status?.lowercased() else { return }
|
||||||
|
if status == "unopened" {
|
||||||
|
Perf.event("BlindBox_Status_Unopened")
|
||||||
|
withAnimation { self.animationPhase = .ready }
|
||||||
|
} else if status == "preparing" {
|
||||||
|
Perf.event("BlindBox_Status_Preparing")
|
||||||
|
withAnimation { self.animationPhase = .loading }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: animationPhase) { _, phase in
|
||||||
|
if phase != .loading {
|
||||||
|
// 仅用于迁移前清理;现倒计时在 VM 中管理
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: viewModel.videoURL) { _, url in
|
||||||
|
if !url.isEmpty {
|
||||||
|
withAnimation { self.animationPhase = .ready }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: viewModel.imageURL) { _, url in
|
||||||
|
if !url.isEmpty {
|
||||||
|
withAnimation { self.animationPhase = .ready }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: viewModel.didBootstrap) { _, done in
|
||||||
|
guard done else { return }
|
||||||
|
// 根据首帧状态决定初始动画态,避免先显示 loading 再跳到 ready 的割裂感
|
||||||
|
let initialStatus = viewModel.blindGenerate?.status.lowercased() ?? ""
|
||||||
|
if initialStatus == "unopened" {
|
||||||
|
withAnimation { self.animationPhase = .ready }
|
||||||
|
} else if initialStatus == "preparing" {
|
||||||
|
withAnimation { self.animationPhase = .loading }
|
||||||
|
} else {
|
||||||
|
// 若未知状态,保持 none;后续 onChange 会驱动到正确态
|
||||||
|
self.animationPhase = .none
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if showScalingOverlay {
|
if showScalingOverlay {
|
||||||
ZStack {
|
ZStack {
|
||||||
@ -578,14 +239,14 @@ struct BlindBoxView: View {
|
|||||||
.edgesIgnoringSafeArea(.all)
|
.edgesIgnoringSafeArea(.all)
|
||||||
|
|
||||||
Group {
|
Group {
|
||||||
if mediaType == .all, let player = videoPlayer {
|
if mediaType == .all, viewModel.player != nil {
|
||||||
// Video Player
|
// Video Player
|
||||||
AVPlayerController(player: $videoPlayer)
|
AVPlayerController(player: .init(get: { viewModel.player }, set: { viewModel.player = $0 }))
|
||||||
.frame(width: scaledWidth, height: scaledHeight)
|
.frame(width: scaledWidth, height: scaledHeight)
|
||||||
.opacity(scale == 1 ? 1 : 0.7)
|
.opacity(scale == 1 ? 1 : 0.7)
|
||||||
.onAppear { player.play() }
|
.onAppear { viewModel.player?.play() }
|
||||||
|
|
||||||
} else if mediaType == .image, let image = displayImage {
|
} else if mediaType == .image, let image = viewModel.displayImage {
|
||||||
// Image View
|
// Image View
|
||||||
Image(uiImage: image)
|
Image(uiImage: image)
|
||||||
.resizable()
|
.resizable()
|
||||||
@ -606,10 +267,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 = viewModel.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")
|
||||||
@ -688,7 +349,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)
|
||||||
@ -735,7 +396,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)
|
||||||
@ -749,7 +410,7 @@ struct BlindBoxView: View {
|
|||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
switch animationPhase {
|
switch animationPhase {
|
||||||
case .loading:
|
case .loading:
|
||||||
GIFView(name: "BlindLoading")
|
LottieView(name: "loading", isPlaying: animationPhase == .loading && !showScalingOverlay)
|
||||||
.frame(width: 300, height: 300)
|
.frame(width: 300, height: 300)
|
||||||
// .onAppear {
|
// .onAppear {
|
||||||
// DispatchQueue.main.asyncAfter(deadline: .now() + 6) {
|
// DispatchQueue.main.asyncAfter(deadline: .now() + 6) {
|
||||||
@ -761,7 +422,7 @@ struct BlindBoxView: View {
|
|||||||
|
|
||||||
case .ready:
|
case .ready:
|
||||||
ZStack {
|
ZStack {
|
||||||
GIFView(name: "BlindReady")
|
LottieView(name: "data", isPlaying: animationPhase == .ready && !showScalingOverlay)
|
||||||
.frame(width: 300, height: 300)
|
.frame(width: 300, height: 300)
|
||||||
|
|
||||||
// Add a transparent overlay to capture taps
|
// Add a transparent overlay to capture taps
|
||||||
@ -769,23 +430,14 @@ struct BlindBoxView: View {
|
|||||||
.contentShape(Rectangle()) // Make the entire area tappable
|
.contentShape(Rectangle()) // Make the entire area tappable
|
||||||
.frame(width: 300, height: 300)
|
.frame(width: 300, height: 300)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
|
Perf.event("BlindBox_Open_Tapped")
|
||||||
print("点击了盲盒")
|
print("点击了盲盒")
|
||||||
|
|
||||||
// 标记盲盒开启
|
let boxIdToOpen = self.currentBoxId ?? self.viewModel.blindGenerate?.id
|
||||||
if let boxId = self.currentBoxId {
|
if let boxId = boxIdToOpen {
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
try await BlindBoxApi.shared.openBlindBox(boxId: boxId)
|
try await viewModel.openBlindBox(for: boxId)
|
||||||
print("✅ 盲盒开启成功")
|
|
||||||
} catch {
|
|
||||||
print("❌ 开启盲盒失败: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let boxId = self.blindGenerate?.id {
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
try await BlindBoxApi.shared.openBlindBox(boxId: boxId)
|
|
||||||
print("✅ 盲盒开启成功")
|
print("✅ 盲盒开启成功")
|
||||||
} catch {
|
} catch {
|
||||||
print("❌ 开启盲盒失败: \(error)")
|
print("❌ 开启盲盒失败: \(error)")
|
||||||
@ -801,11 +453,15 @@ struct BlindBoxView: View {
|
|||||||
|
|
||||||
case .opening:
|
case .opening:
|
||||||
ZStack {
|
ZStack {
|
||||||
GIFView(name: "BlindOpen")
|
if !showMedia {
|
||||||
|
LottieView(name: "open", loopMode: .playOnce, isPlaying: !showMedia)
|
||||||
.frame(width: 300, height: 300)
|
.frame(width: 300, height: 300)
|
||||||
.scaleEffect(scale)
|
.scaleEffect(scale)
|
||||||
.opacity(showMedia ? 0 : 1) // 当显示媒体时隐藏GIF
|
}
|
||||||
|
// 当显示媒体时,移除 GIFView 避免后台播放
|
||||||
|
Color.clear
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
Perf.event("BlindBox_Opening_Begin")
|
||||||
print("开始播放开启动画")
|
print("开始播放开启动画")
|
||||||
// 初始缩放为1(原始大小)
|
// 初始缩放为1(原始大小)
|
||||||
self.scale = 1.0
|
self.scale = 1.0
|
||||||
@ -825,12 +481,9 @@ struct BlindBoxView: View {
|
|||||||
self.scale = 1.0
|
self.scale = 1.0
|
||||||
|
|
||||||
// 显示媒体内容
|
// 显示媒体内容
|
||||||
|
Perf.event("BlindBox_Opening_ShowMedia")
|
||||||
self.showScalingOverlay = true
|
self.showScalingOverlay = true
|
||||||
if mediaType == .all {
|
Task { await viewModel.prepareMedia() }
|
||||||
loadVideo()
|
|
||||||
} else if mediaType == .image {
|
|
||||||
loadImage()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 标记显示媒体,隐藏GIF
|
// 标记显示媒体,隐藏GIF
|
||||||
self.showMedia = true
|
self.showMedia = true
|
||||||
@ -843,8 +496,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)
|
||||||
@ -858,10 +511,10 @@ struct BlindBoxView: View {
|
|||||||
if !showScalingOverlay && !showMedia {
|
if !showScalingOverlay && !showMedia {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
// 从变量blindGenerate中获取description
|
// 从变量blindGenerate中获取description
|
||||||
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)
|
||||||
}
|
}
|
||||||
@ -881,25 +534,15 @@ 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 {
|
||||||
// 准备就绪点击,开启盲盒
|
// 准备就绪点击,开启盲盒
|
||||||
// 标记盲盒开启
|
let boxIdToOpen = self.currentBoxId ?? self.viewModel.blindGenerate?.id
|
||||||
if let boxId = self.currentBoxId {
|
if let boxId = boxIdToOpen {
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
try await BlindBoxApi.shared.openBlindBox(boxId: boxId)
|
try await viewModel.openBlindBox(for: boxId)
|
||||||
print("✅ 盲盒开启成功")
|
|
||||||
} catch {
|
|
||||||
print("❌ 开启盲盒失败: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let boxId = self.blindGenerate?.id {
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
try await BlindBoxApi.shared.openBlindBox(boxId: boxId)
|
|
||||||
print("✅ 盲盒开启成功")
|
print("✅ 盲盒开启成功")
|
||||||
} catch {
|
} catch {
|
||||||
print("❌ 开启盲盒失败: \(error)")
|
print("❌ 开启盲盒失败: \(error)")
|
||||||
@ -914,7 +557,7 @@ struct BlindBoxView: View {
|
|||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
if animationPhase == .loading {
|
if animationPhase == .loading {
|
||||||
Text("Next: \(countdown.minutes):\(String(format: "%02d", countdown.seconds)).\(String(format: "%02d", countdown.milliseconds))")
|
Text("Next: \(viewModel.countdownText)")
|
||||||
.font(Typography.font(for: .body))
|
.font(Typography.font(for: .body))
|
||||||
.fontWeight(.bold)
|
.fontWeight(.bold)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
@ -922,9 +565,6 @@ struct BlindBoxView: View {
|
|||||||
.background(Color.white)
|
.background(Color.white)
|
||||||
.foregroundColor(.black)
|
.foregroundColor(.black)
|
||||||
.cornerRadius(32)
|
.cornerRadius(32)
|
||||||
.onAppear {
|
|
||||||
startCountdown()
|
|
||||||
}
|
|
||||||
} else if animationPhase == .ready {
|
} else if animationPhase == .ready {
|
||||||
Text("Ready")
|
Text("Ready")
|
||||||
.font(Typography.font(for: .body))
|
.font(Typography.font(for: .body))
|
||||||
@ -963,8 +603,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)
|
||||||
@ -7,7 +7,7 @@ struct BlindOutcomeView: View {
|
|||||||
let time: String?
|
let time: String?
|
||||||
let description: String?
|
let description: String?
|
||||||
let isMember: Bool
|
let isMember: Bool
|
||||||
@Environment(\.presentationMode) var presentationMode
|
// Removed presentationMode; use Router.shared.pop() for back navigation
|
||||||
@State private var isFullscreen = false
|
@State private var isFullscreen = false
|
||||||
@State private var isPlaying = false
|
@State private var isPlaying = false
|
||||||
@State private var showControls = true
|
@State private var showControls = true
|
||||||
@ -22,7 +22,6 @@ struct BlindOutcomeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.themeTextWhiteSecondary.ignoresSafeArea()
|
Color.themeTextWhiteSecondary.ignoresSafeArea()
|
||||||
|
|
||||||
@ -30,7 +29,7 @@ struct BlindOutcomeView: View {
|
|||||||
// 自定义导航栏
|
// 自定义导航栏
|
||||||
HStack {
|
HStack {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
presentationMode.wrappedValue.dismiss()
|
Router.shared.pop()
|
||||||
}) {
|
}) {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Image(systemName: "chevron.left")
|
Image(systemName: "chevron.left")
|
||||||
@ -159,9 +158,6 @@ struct BlindOutcomeView: View {
|
|||||||
.navigationBarHidden(true)
|
.navigationBarHidden(true)
|
||||||
.navigationBarBackButtonHidden(true)
|
.navigationBarBackButtonHidden(true)
|
||||||
.statusBar(hidden: isFullscreen)
|
.statusBar(hidden: isFullscreen)
|
||||||
}
|
|
||||||
.navigationViewStyle(StackNavigationViewStyle())
|
|
||||||
.navigationBarHidden(true)
|
|
||||||
.overlay(
|
.overlay(
|
||||||
JoinModal(isPresented: $showIPListModal)
|
JoinModal(isPresented: $showIPListModal)
|
||||||
)
|
)
|
||||||
2
wake/Features/BlindBox/View/README.md
Normal file
2
wake/Features/BlindBox/View/README.md
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# Features/BlindBox/View
|
||||||
|
盲盒界面视图文件(例如 `BlindBoxView.swift`)。
|
||||||
264
wake/Features/BlindBox/ViewModel/BlindBoxViewModel.swift
Normal file
264
wake/Features/BlindBox/ViewModel/BlindBoxViewModel.swift
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
import UIKit
|
||||||
|
import AVKit
|
||||||
|
|
||||||
|
@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 = ""
|
||||||
|
// Media prepared for display
|
||||||
|
@Published var player: AVPlayer? = nil
|
||||||
|
@Published var displayImage: UIImage? = nil
|
||||||
|
@Published var aspectRatio: CGFloat = 1.0
|
||||||
|
@Published var isPortrait: Bool = false
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
Perf.event("BlindVM_Load_Begin")
|
||||||
|
await bootstrapInitialState()
|
||||||
|
await startPolling()
|
||||||
|
loadMemberProfile()
|
||||||
|
await loadBlindCount()
|
||||||
|
Perf.event("BlindVM_Load_End")
|
||||||
|
}
|
||||||
|
|
||||||
|
func startPolling() async {
|
||||||
|
// 如果已经是 Unopened,无需继续轮询
|
||||||
|
if blindGenerate?.status.lowercased() == "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) {
|
||||||
|
Perf.event("BlindVM_Poll_Single_Yield")
|
||||||
|
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()
|
||||||
|
Task { await self.prepareMedia() }
|
||||||
|
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) {
|
||||||
|
Perf.event("BlindVM_Poll_List_Yield")
|
||||||
|
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()
|
||||||
|
Task { await self.prepareMedia() }
|
||||||
|
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()
|
||||||
|
Task { await self.prepareMedia() }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("❌ bootstrapInitialState (single) failed: \(error)")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
do {
|
||||||
|
let list = try await BlindBoxApi.shared.getBlindBoxList()
|
||||||
|
// 更新未开启数量(忽略大小写)
|
||||||
|
let count = (list ?? []).filter { $0.status.lowercased() == "unopened" }.count
|
||||||
|
self.blindCount = BlindCount(availableQuantity: count)
|
||||||
|
|
||||||
|
if let item = list?.first(where: { $0.status.lowercased() == "unopened" }) {
|
||||||
|
self.blindGenerate = item
|
||||||
|
if mediaType == .image {
|
||||||
|
self.imageURL = item.resultFile?.url ?? ""
|
||||||
|
} else {
|
||||||
|
self.videoURL = item.resultFile?.url ?? ""
|
||||||
|
}
|
||||||
|
self.applyStatusSideEffects()
|
||||||
|
Task { await self.prepareMedia() }
|
||||||
|
} else if let first = list?.first {
|
||||||
|
// 没有 Unopened,选取第一个用于展示状态(通常是 Preparing)
|
||||||
|
self.blindGenerate = first
|
||||||
|
self.applyStatusSideEffects()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("❌ bootstrapInitialState (list) failed: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 标记首帧状态已准备,供视图决定是否显示 loading/ready
|
||||||
|
self.didBootstrap = true
|
||||||
|
Perf.event("BlindVM_Bootstrap_Done")
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopPolling() {
|
||||||
|
pollingTask?.cancel()
|
||||||
|
pollingTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func openBlindBox(for id: String) async throws {
|
||||||
|
let sp = Perf.begin("BlindVM_Open")
|
||||||
|
defer { Perf.end("BlindVM_Open", id: sp) }
|
||||||
|
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.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
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Media Preparation
|
||||||
|
func prepareMedia() async {
|
||||||
|
if mediaType == .all {
|
||||||
|
// Video path
|
||||||
|
guard !videoURL.isEmpty, let url = URL(string: videoURL) else { return }
|
||||||
|
let asset = AVAsset(url: url)
|
||||||
|
let item = AVPlayerItem(asset: asset)
|
||||||
|
let player = AVPlayer(playerItem: item)
|
||||||
|
if let track = asset.tracks(withMediaType: .video).first {
|
||||||
|
let size = track.naturalSize.applying(track.preferredTransform)
|
||||||
|
let width = abs(size.width)
|
||||||
|
let height = abs(size.height)
|
||||||
|
self.aspectRatio = height == 0 ? 1.0 : width / height
|
||||||
|
self.isPortrait = height > width
|
||||||
|
}
|
||||||
|
self.player = player
|
||||||
|
} else if mediaType == .image {
|
||||||
|
guard !imageURL.isEmpty, let url = URL(string: imageURL) else { return }
|
||||||
|
do {
|
||||||
|
let (data, _) = try await URLSession.shared.data(from: url)
|
||||||
|
if let image = UIImage(data: data) {
|
||||||
|
self.displayImage = image
|
||||||
|
self.aspectRatio = image.size.height == 0 ? 1.0 : image.size.width / image.size.height
|
||||||
|
self.isPortrait = image.size.height > image.size.width
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("⚠️ prepareMedia image load failed: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
wake/Features/BlindBox/ViewModel/README.md
Normal file
2
wake/Features/BlindBox/ViewModel/README.md
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# Features/BlindBox/ViewModel
|
||||||
|
盲盒视图模型,如 `BlindBoxViewModel.swift`。
|
||||||
2
wake/Features/Subscribe/README.md
Normal file
2
wake/Features/Subscribe/README.md
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# Features/Subscribe
|
||||||
|
订阅相关页面与组件:`SubscribeView`、`CreditsInfoCard`、`PlanCompare` 等。
|
||||||
@ -5,11 +5,13 @@ struct LottieView: UIViewRepresentable {
|
|||||||
let name: String
|
let name: String
|
||||||
let loopMode: LottieLoopMode
|
let loopMode: LottieLoopMode
|
||||||
let animationSpeed: CGFloat
|
let animationSpeed: CGFloat
|
||||||
|
let isPlaying: Bool
|
||||||
|
|
||||||
init(name: String, loopMode: LottieLoopMode = .loop, animationSpeed: CGFloat = 1.0) {
|
init(name: String, loopMode: LottieLoopMode = .loop, animationSpeed: CGFloat = 1.0, isPlaying: Bool = true) {
|
||||||
self.name = name
|
self.name = name
|
||||||
self.loopMode = loopMode
|
self.loopMode = loopMode
|
||||||
self.animationSpeed = animationSpeed
|
self.animationSpeed = animationSpeed
|
||||||
|
self.isPlaying = isPlaying
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeUIView(context: Context) -> LottieAnimationView {
|
func makeUIView(context: Context) -> LottieAnimationView {
|
||||||
@ -31,16 +33,26 @@ struct LottieView: UIViewRepresentable {
|
|||||||
animationView.contentMode = .scaleAspectFit
|
animationView.contentMode = .scaleAspectFit
|
||||||
animationView.backgroundBehavior = .pauseAndRestore
|
animationView.backgroundBehavior = .pauseAndRestore
|
||||||
|
|
||||||
// 播放动画
|
// 播放/暂停
|
||||||
|
if isPlaying {
|
||||||
animationView.play()
|
animationView.play()
|
||||||
|
} else {
|
||||||
|
animationView.pause()
|
||||||
|
}
|
||||||
|
|
||||||
return animationView
|
return animationView
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ uiView: LottieAnimationView, context: Context) {
|
func updateUIView(_ uiView: LottieAnimationView, context: Context) {
|
||||||
// 确保动画持续播放
|
// 根据 isPlaying 控制播放/暂停
|
||||||
|
if isPlaying {
|
||||||
if !uiView.isAnimationPlaying {
|
if !uiView.isAnimationPlaying {
|
||||||
uiView.play()
|
uiView.play()
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
if uiView.isAnimationPlaying {
|
||||||
|
uiView.pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
2
wake/SharedUI/Animation/README.md
Normal file
2
wake/SharedUI/Animation/README.md
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# SharedUI/Animation
|
||||||
|
Lottie 等动画封装:`LottieView.swift`。
|
||||||
2
wake/SharedUI/Controls/README.md
Normal file
2
wake/SharedUI/Controls/README.md
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# SharedUI/Controls
|
||||||
|
通用按钮、输入控件等。
|
||||||
2
wake/SharedUI/Graphics/README.md
Normal file
2
wake/SharedUI/Graphics/README.md
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# SharedUI/Graphics
|
||||||
|
共享图形包装。
|
||||||
2
wake/SharedUI/Media/README.md
Normal file
2
wake/SharedUI/Media/README.md
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# SharedUI/Media
|
||||||
|
媒体通用视图:`GIFView.swift`、`SVGImage.swift`/`SVGImageHtml.swift` 等。
|
||||||
2
wake/SharedUI/Modals/README.md
Normal file
2
wake/SharedUI/Modals/README.md
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# SharedUI/Modals
|
||||||
|
通用弹层与模态:`SheetModal.swift` 等。
|
||||||
2
wake/SharedUI/README.md
Normal file
2
wake/SharedUI/README.md
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# SharedUI
|
||||||
|
跨特性共享 UI 组件与资源包装:Animation、Media、Modals、Controls、Graphics 等。
|
||||||
@ -99,20 +99,20 @@ public struct MultiImageUploader<Content: View>: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: isImagePickerPresented) { newValue in
|
.onChange(of: isImagePickerPresented) { _, newValue in
|
||||||
if newValue {
|
if newValue {
|
||||||
showingImagePicker = true
|
showingImagePicker = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: showingImagePicker) { newValue in
|
.onChange(of: showingImagePicker) { _, newValue in
|
||||||
if !newValue {
|
if !newValue {
|
||||||
isImagePickerPresented = false
|
isImagePickerPresented = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: selectedImages) { newValue in
|
.onChange(of: selectedImages) { _, newValue in
|
||||||
selectedImagesBinding = newValue
|
selectedImagesBinding = newValue
|
||||||
}
|
}
|
||||||
.onChange(of: needsViewUpdate) { _ in
|
.onChange(of: needsViewUpdate) { _, _ in
|
||||||
// Trigger view update
|
// Trigger view update
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -261,7 +261,6 @@ struct MediaUploadExample: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
// 选择媒体按钮
|
// 选择媒体按钮
|
||||||
Button(action: { showMediaPicker = true }) {
|
Button(action: { showMediaPicker = true }) {
|
||||||
@ -308,7 +307,6 @@ struct MediaUploadExample: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.navigationTitle("媒体上传")
|
|
||||||
.sheet(isPresented: $showMediaPicker) {
|
.sheet(isPresented: $showMediaPicker) {
|
||||||
MediaPicker(
|
MediaPicker(
|
||||||
selectedMedia: Binding(
|
selectedMedia: Binding(
|
||||||
@ -327,7 +325,6 @@ struct MediaUploadExample: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// 媒体选择视图组件
|
/// 媒体选择视图组件
|
||||||
struct MediaSelectionView: View {
|
struct MediaSelectionView: View {
|
||||||
|
|||||||
@ -62,7 +62,7 @@ struct CreditTransaction {
|
|||||||
|
|
||||||
// MARK: - 积分详情页面
|
// MARK: - 积分详情页面
|
||||||
struct CreditsDetailView: View {
|
struct CreditsDetailView: View {
|
||||||
@Environment(\.presentationMode) var presentationMode
|
// Removed presentationMode; use Router.shared.pop() for back navigation
|
||||||
@State private var showRules = false
|
@State private var showRules = false
|
||||||
|
|
||||||
// 示例数据
|
// 示例数据
|
||||||
@ -77,7 +77,6 @@ struct CreditsDetailView: View {
|
|||||||
]
|
]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// 导航栏
|
// 导航栏
|
||||||
@ -95,12 +94,11 @@ struct CreditsDetailView: View {
|
|||||||
.background(Theme.Colors.background)
|
.background(Theme.Colors.background)
|
||||||
.navigationBarHidden(true)
|
.navigationBarHidden(true)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 导航栏
|
// MARK: - 导航栏
|
||||||
private var navigationHeader: some View {
|
private var navigationHeader: some View {
|
||||||
NaviHeader(title: "Credits") {
|
NaviHeader(title: "Credits") {
|
||||||
presentationMode.wrappedValue.dismiss()
|
Router.shared.pop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,6 @@ struct MediaUploadDemo: View {
|
|||||||
@State private var isUploading = false
|
@State private var isUploading = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
// 上传按钮
|
// 上传按钮
|
||||||
Button(action: {
|
Button(action: {
|
||||||
@ -112,7 +111,7 @@ struct MediaUploadDemo: View {
|
|||||||
dismissButton: .default(Text("确定"))
|
dismissButton: .default(Text("确定"))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.onChange(of: uploadManager.uploadStatus) { _ in
|
.onChange(of: uploadManager.uploadStatus) { _, _ in
|
||||||
// 检查是否所有上传都已完成或失败
|
// 检查是否所有上传都已完成或失败
|
||||||
let allFinished = uploadManager.uploadStatus.values.allSatisfy { status in
|
let allFinished = uploadManager.uploadStatus.values.allSatisfy { status in
|
||||||
if case .completed = status { return true }
|
if case .completed = status { return true }
|
||||||
@ -127,7 +126,6 @@ struct MediaUploadDemo: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 媒体项视图
|
// 媒体项视图
|
||||||
struct MediaItemView: View {
|
struct MediaItemView: View {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct FeedbackView: View {
|
struct FeedbackView: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
// Use Router for navigation instead of dismiss
|
||||||
@EnvironmentObject private var router: Router
|
@EnvironmentObject private var router: Router
|
||||||
|
|
||||||
@State private var selectedFeedback: FeedbackType? = FeedbackType.allCases.first
|
@State private var selectedFeedback: FeedbackType? = FeedbackType.allCases.first
|
||||||
@ -29,7 +29,7 @@ struct FeedbackView: View {
|
|||||||
// Custom Navigation Bar
|
// Custom Navigation Bar
|
||||||
HStack {
|
HStack {
|
||||||
// Back Button
|
// Back Button
|
||||||
Button(action: { dismiss() }) {
|
Button(action: { router.pop() }) {
|
||||||
Image(systemName: "chevron.left")
|
Image(systemName: "chevron.left")
|
||||||
.font(.system(size: 17, weight: .semibold))
|
.font(.system(size: 17, weight: .semibold))
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(.primary)
|
||||||
@ -140,14 +140,13 @@ struct FeedbackDetailView: View {
|
|||||||
let feedbackType: FeedbackView.FeedbackType
|
let feedbackType: FeedbackView.FeedbackType
|
||||||
@State private var feedbackText = ""
|
@State private var feedbackText = ""
|
||||||
@State private var contactInfo = ""
|
@State private var contactInfo = ""
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Navigation Bar
|
// Navigation Bar
|
||||||
HStack {
|
HStack {
|
||||||
// Back Button
|
// Back Button
|
||||||
Button(action: { dismiss() }) {
|
Button(action: { Router.shared.pop() }) {
|
||||||
Image(systemName: "chevron.left")
|
Image(systemName: "chevron.left")
|
||||||
.font(.system(size: 17, weight: .semibold))
|
.font(.system(size: 17, weight: .semibold))
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(.primary)
|
||||||
@ -248,8 +247,8 @@ struct FeedbackDetailView: View {
|
|||||||
print("Contact: \(contactInfo)")
|
print("Contact: \(contactInfo)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dismiss back to feedback type selection
|
// Navigate back to feedback type selection
|
||||||
dismiss()
|
Router.shared.pop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -62,7 +62,7 @@ enum MemoryMediaType: Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct MemoriesView: View {
|
struct MemoriesView: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
// Removed dismiss environment; use Router.shared.pop() for back navigation
|
||||||
@State private var memories: [MemoryItem] = []
|
@State private var memories: [MemoryItem] = []
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
@ -74,13 +74,12 @@ struct MemoriesView: View {
|
|||||||
]
|
]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
|
||||||
ZStack {
|
ZStack {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Top navigation bar
|
// Top navigation bar
|
||||||
HStack {
|
HStack {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
self.dismiss()
|
Router.shared.pop()
|
||||||
}) {
|
}) {
|
||||||
Image(systemName: "chevron.left")
|
Image(systemName: "chevron.left")
|
||||||
.foregroundColor(.themeTextMessageMain)
|
.foregroundColor(.themeTextMessageMain)
|
||||||
@ -121,7 +120,6 @@ struct MemoriesView: View {
|
|||||||
.zIndex(1)
|
.zIndex(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.navigationBarBackButtonHidden(true)
|
.navigationBarBackButtonHidden(true)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
fetchMemories()
|
fetchMemories()
|
||||||
|
|||||||
@ -85,7 +85,7 @@ struct MediaUploadView: View {
|
|||||||
// 媒体选择器
|
// 媒体选择器
|
||||||
mediaPickerView
|
mediaPickerView
|
||||||
}
|
}
|
||||||
.onChange(of: uploadManager.uploadResults) { newResults in
|
.onChange(of: uploadManager.uploadResults) { _, newResults in
|
||||||
handleUploadCompletion(results: newResults)
|
handleUploadCompletion(results: newResults)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -291,7 +291,7 @@ struct UserInfo: View {
|
|||||||
isKeyboardVisible = isVisible
|
isKeyboardVisible = isVisible
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: isTextFieldFocused) { newValue in
|
.onChange(of: isTextFieldFocused) { _, newValue in
|
||||||
withAnimation(.easeInOut(duration: 0.2)) {
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
isKeyboardVisible = newValue
|
isKeyboardVisible = newValue
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,7 +23,6 @@ struct SettingsView: View {
|
|||||||
// MARK: - 主体视图
|
// MARK: - 主体视图
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
|
||||||
ZStack {
|
ZStack {
|
||||||
// Theme background color
|
// Theme background color
|
||||||
Color.themeTextWhiteSecondary.edgesIgnoringSafeArea(.all)
|
Color.themeTextWhiteSecondary.edgesIgnoringSafeArea(.all)
|
||||||
@ -85,7 +84,6 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
.navigationBarHidden(true)
|
.navigationBarHidden(true)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 私有方法
|
// MARK: - 私有方法
|
||||||
|
|
||||||
|
|||||||
@ -156,7 +156,7 @@ public struct AvatarPicker: View {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.onChange(of: uploadManager.uploadStatus) { status in
|
.onChange(of: uploadManager.uploadStatus) { _, status in
|
||||||
print("🔄 Upload status changed: ", status)
|
print("🔄 Upload status changed: ", status)
|
||||||
|
|
||||||
// 检查是否有待处理的上传
|
// 检查是否有待处理的上传
|
||||||
|
|||||||
@ -109,11 +109,11 @@ struct SubscribeView: View {
|
|||||||
await store.loadProducts()
|
await store.loadProducts()
|
||||||
await store.refreshEntitlements()
|
await store.refreshEntitlements()
|
||||||
}
|
}
|
||||||
.onChange(of: store.isPurchasing) { newValue in
|
.onChange(of: store.isPurchasing) { _, newValue in
|
||||||
// Bind purchasing state to button loading
|
// Bind purchasing state to button loading
|
||||||
isLoading = newValue
|
isLoading = newValue
|
||||||
}
|
}
|
||||||
.onChange(of: store.errorMessage) { newValue in
|
.onChange(of: store.errorMessage) { _, newValue in
|
||||||
if let message = newValue, !message.isEmpty {
|
if let message = newValue, !message.isEmpty {
|
||||||
errorText = message
|
errorText = message
|
||||||
showErrorAlert = true
|
showErrorAlert = true
|
||||||
|
|||||||
@ -6,7 +6,6 @@ struct SplashView: View {
|
|||||||
@EnvironmentObject private var authState: AuthState
|
@EnvironmentObject private var authState: AuthState
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
|
||||||
ZStack {
|
ZStack {
|
||||||
// 背景渐变
|
// 背景渐变
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
@ -27,8 +26,6 @@ struct SplashView: View {
|
|||||||
isAnimating = true
|
isAnimating = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationViewStyle(StackNavigationViewStyle())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 预览
|
// 预览
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user