Compare commits
1 Commits
main
...
feat/subsc
| Author | SHA1 | Date | |
|---|---|---|---|
| 53215f5c3d |
3
.gitignore
vendored
@ -1,3 +0,0 @@
|
||||
wake.xcodeproj/xcuserdata
|
||||
wake.xcodeproj/project.xcworkspace/xcuserdata
|
||||
wake/CoreData
|
||||
5
.vscode/settings.json
vendored
@ -1,5 +0,0 @@
|
||||
{
|
||||
"lldb.library": "/Library/Developer/CommandLineTools/Library/PrivateFrameworks/LLDB.framework/Versions/A/LLDB",
|
||||
"lldb.launch.expressions": "native",
|
||||
"sweetpad.build.xcodeWorkspacePath": "wake.xcodeproj/project.xcworkspace"
|
||||
}
|
||||
@ -1,149 +0,0 @@
|
||||
# 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`)与相关事件;若需要我直接提交“后台解码 + 细粒度埋点”的实现,请在下次迭代时告知,我会以最小改动提交补丁。
|
||||
@ -1,172 +0,0 @@
|
||||
# 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 重构,其它模块随后跟进。
|
||||
@ -7,11 +7,7 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
0DE4253B2E78470700B519F0 /* SVGKit in Frameworks */ = {isa = PBXBuildFile; productRef = AB6693C92E65C94400BCAAC1 /* SVGKit */; };
|
||||
0DE4253C2E78470700B519F0 /* SVGKitSwift in Frameworks */ = {isa = PBXBuildFile; productRef = AB6693CB2E65C94400BCAAC1 /* SVGKitSwift */; };
|
||||
AB6695272E67015600BCAAC1 /* WaterfallGrid in Frameworks */ = {isa = PBXBuildFile; productRef = AB6695262E67015600BCAAC1 /* WaterfallGrid */; };
|
||||
AB8773632E4E04400071CB53 /* .gitignore in Resources */ = {isa = PBXBuildFile; fileRef = AB8773622E4E040E0071CB53 /* .gitignore */; };
|
||||
ABC150C12E5DB39A00A1F970 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = ABC150C02E5DB39A00A1F970 /* Lottie */; };
|
||||
ABE8998E2E533A7100CD7BA6 /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = ABE8998D2E533A7100CD7BA6 /* Alamofire */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
@ -38,25 +34,7 @@
|
||||
AB4FA8642E4F7074005D9955 /* Exceptions for "wake" folder in "wake" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
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,
|
||||
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 */;
|
||||
};
|
||||
@ -78,11 +56,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
0DE4253C2E78470700B519F0 /* SVGKitSwift in Frameworks */,
|
||||
0DE4253B2E78470700B519F0 /* SVGKit in Frameworks */,
|
||||
ABE8998E2E533A7100CD7BA6 /* Alamofire in Frameworks */,
|
||||
AB6695272E67015600BCAAC1 /* WaterfallGrid in Frameworks */,
|
||||
ABC150C12E5DB39A00A1F970 /* Lottie in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@ -137,10 +111,6 @@
|
||||
name = wake;
|
||||
packageProductDependencies = (
|
||||
ABE8998D2E533A7100CD7BA6 /* Alamofire */,
|
||||
ABC150C02E5DB39A00A1F970 /* Lottie */,
|
||||
AB6693C92E65C94400BCAAC1 /* SVGKit */,
|
||||
AB6693CB2E65C94400BCAAC1 /* SVGKitSwift */,
|
||||
AB6695262E67015600BCAAC1 /* WaterfallGrid */,
|
||||
);
|
||||
productName = wake;
|
||||
productReference = ABB4E2082E4B75D900660198 /* wake.app */;
|
||||
@ -172,9 +142,6 @@
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
ABE8998C2E533A7100CD7BA6 /* XCRemoteSwiftPackageReference "Alamofire" */,
|
||||
ABC150BF2E5DB39A00A1F970 /* XCRemoteSwiftPackageReference "lottie-spm" */,
|
||||
AB6693C82E65C94400BCAAC1 /* XCRemoteSwiftPackageReference "SVGKit" */,
|
||||
AB6695252E67015600BCAAC1 /* XCRemoteSwiftPackageReference "WaterfallGrid" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = ABB4E2092E4B75D900660198 /* Products */;
|
||||
@ -334,7 +301,6 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = wake/wake.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
@ -343,19 +309,17 @@
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = wake/Info.plist;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "";
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "We need access to your camera to capture your profile picture";
|
||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "We need to access your photo library to select photos as avatars or blind box inputs";
|
||||
INFOPLIST_KEY_NSUserTrackingUsageDescription = "我们需要访问您的Apple ID信息来为您提供登录服务";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.memowake.app;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.memowake.fairclip2025;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
@ -368,7 +332,6 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = wake/wake.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
@ -377,19 +340,17 @@
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = wake/Info.plist;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "";
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "We need access to your camera to capture your profile picture";
|
||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "We need to access your photo library to select photos as avatars or blind box inputs";
|
||||
INFOPLIST_KEY_NSUserTrackingUsageDescription = "我们需要访问您的Apple ID信息来为您提供登录服务";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.memowake.app;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.memowake.fairclip2025;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
@ -421,30 +382,6 @@
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
AB6693C82E65C94400BCAAC1 /* XCRemoteSwiftPackageReference "SVGKit" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/SVGKit/SVGKit.git";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 3.0.0;
|
||||
};
|
||||
};
|
||||
AB6695252E67015600BCAAC1 /* XCRemoteSwiftPackageReference "WaterfallGrid" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/paololeonardi/WaterfallGrid.git";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 1.1.0;
|
||||
};
|
||||
};
|
||||
ABC150BF2E5DB39A00A1F970 /* XCRemoteSwiftPackageReference "lottie-spm" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/airbnb/lottie-spm.git";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 4.5.2;
|
||||
};
|
||||
};
|
||||
ABE8998C2E533A7100CD7BA6 /* XCRemoteSwiftPackageReference "Alamofire" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/Alamofire/Alamofire.git";
|
||||
@ -456,26 +393,6 @@
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
AB6693C92E65C94400BCAAC1 /* SVGKit */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = AB6693C82E65C94400BCAAC1 /* XCRemoteSwiftPackageReference "SVGKit" */;
|
||||
productName = SVGKit;
|
||||
};
|
||||
AB6693CB2E65C94400BCAAC1 /* SVGKitSwift */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = AB6693C82E65C94400BCAAC1 /* XCRemoteSwiftPackageReference "SVGKit" */;
|
||||
productName = SVGKitSwift;
|
||||
};
|
||||
AB6695262E67015600BCAAC1 /* WaterfallGrid */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = AB6695252E67015600BCAAC1 /* XCRemoteSwiftPackageReference "WaterfallGrid" */;
|
||||
productName = WaterfallGrid;
|
||||
};
|
||||
ABC150C02E5DB39A00A1F970 /* Lottie */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = ABC150BF2E5DB39A00A1F970 /* XCRemoteSwiftPackageReference "lottie-spm" */;
|
||||
productName = Lottie;
|
||||
};
|
||||
ABE8998D2E533A7100CD7BA6 /* Alamofire */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = ABE8998C2E533A7100CD7BA6 /* XCRemoteSwiftPackageReference "Alamofire" */;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "7ea295cc5e3eb8ef644b89ce2b47a7600994b67c8582ee354b643cd63250740d",
|
||||
"originHash" : "e8f130fe30ac6cdc940ef06ee1e8535e9f46ffee6aeead1722b9525562f6ce08",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "alamofire",
|
||||
@ -9,51 +9,6 @@
|
||||
"revision" : "513364f870f6bfc468f9d2ff0a95caccc10044c5",
|
||||
"version" : "5.10.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "cocoalumberjack",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/CocoaLumberjack/CocoaLumberjack.git",
|
||||
"state" : {
|
||||
"revision" : "a9ed4b6f9bdedce7d77046f43adfb8ce1fd54114",
|
||||
"version" : "3.9.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "lottie-spm",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/airbnb/lottie-spm.git",
|
||||
"state" : {
|
||||
"revision" : "04f2fd18cc9404a0a0917265a449002674f24ec9",
|
||||
"version" : "4.5.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "svgkit",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/SVGKit/SVGKit.git",
|
||||
"state" : {
|
||||
"revision" : "58152b9f7c85eab239160b36ffdfd364aa43d666",
|
||||
"version" : "3.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-log",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-log",
|
||||
"state" : {
|
||||
"revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2",
|
||||
"version" : "1.6.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "waterfallgrid",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/paololeonardi/WaterfallGrid.git",
|
||||
"state" : {
|
||||
"revision" : "c7c08652c3540adf8e48409c351879b4caea7e89",
|
||||
"version" : "1.1.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict/>
|
||||
<array/>
|
||||
</plist>
|
||||
BIN
wake.xcodeproj/project.xcworkspace/xcuserdata/elliwood.xcuserdatad/UserInterfaceState.xcuserstate
generated
Normal file
@ -1,81 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1640"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "ABB4E2072E4B75D900660198"
|
||||
BuildableName = "wake.app"
|
||||
BlueprintName = "wake"
|
||||
ReferencedContainer = "container:wake.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "ABB4E2072E4B75D900660198"
|
||||
BuildableName = "wake.app"
|
||||
BlueprintName = "wake"
|
||||
ReferencedContainer = "container:wake.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<StoreKitConfigurationFileReference
|
||||
identifier = "../../wake/MemoWake.storekit">
|
||||
</StoreKitConfigurationFileReference>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "ABB4E2072E4B75D900660198"
|
||||
BuildableName = "wake.app"
|
||||
BlueprintName = "wake"
|
||||
ReferencedContainer = "container:wake.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Bucket
|
||||
uuid = "A774AEAB-F2DE-4CA6-8FAA-A05AB418F685"
|
||||
type = "1"
|
||||
version = "2.0">
|
||||
<Breakpoints>
|
||||
<BreakpointProxy
|
||||
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
|
||||
<BreakpointContent
|
||||
uuid = "4D390946-09D4-48AB-A8F5-7003641827C5"
|
||||
shouldBeEnabled = "No"
|
||||
ignoreCount = "0"
|
||||
continueAfterRunningActions = "No"
|
||||
filePath = "wake/ContentView.swift"
|
||||
startingColumnNumber = "9223372036854775807"
|
||||
endingColumnNumber = "9223372036854775807"
|
||||
startingLineNumber = "37"
|
||||
endingLineNumber = "37"
|
||||
landmarkName = "body"
|
||||
landmarkType = "24">
|
||||
</BreakpointContent>
|
||||
</BreakpointProxy>
|
||||
</Breakpoints>
|
||||
</Bucket>
|
||||
@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>SchemeUserState</key>
|
||||
<dict>
|
||||
<key>wake.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
wake/.DS_Store
vendored
BIN
wake/Assets/.DS_Store
vendored
@ -1,265 +0,0 @@
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
|
||||
struct CustomCameraView: UIViewControllerRepresentable {
|
||||
@Binding var isPresented: Bool
|
||||
let onImageCaptured: (UIImage) -> Void
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
|
||||
func makeUIViewController(context: Context) -> CustomCameraViewController {
|
||||
let viewController = CustomCameraViewController()
|
||||
viewController.delegate = context.coordinator
|
||||
return viewController
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: CustomCameraViewController, context: Context) {}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, CustomCameraViewControllerDelegate {
|
||||
let parent: CustomCameraView
|
||||
|
||||
init(_ parent: CustomCameraView) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func didCaptureImage(_ image: UIImage) {
|
||||
parent.onImageCaptured(image)
|
||||
parent.presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
|
||||
func didCancel() {
|
||||
parent.presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protocol CustomCameraViewControllerDelegate: AnyObject {
|
||||
func didCaptureImage(_ image: UIImage)
|
||||
func didCancel()
|
||||
}
|
||||
|
||||
class CustomCameraViewController: UIViewController {
|
||||
private var captureSession: AVCaptureSession?
|
||||
private var photoOutput: AVCapturePhotoOutput?
|
||||
private var previewLayer: AVCaptureVideoPreviewLayer?
|
||||
private var captureDevice: AVCaptureDevice?
|
||||
|
||||
weak var delegate: CustomCameraViewControllerDelegate?
|
||||
|
||||
private lazy var captureButton: UIButton = {
|
||||
let button = UIButton(type: .system)
|
||||
button.backgroundColor = .white
|
||||
button.tintColor = .black
|
||||
button.layer.cornerRadius = 35
|
||||
button.layer.borderWidth = 5
|
||||
button.layer.borderColor = UIColor.lightGray.cgColor
|
||||
button.addTarget(self, action: #selector(capturePhoto), for: .touchUpInside)
|
||||
return button
|
||||
}()
|
||||
|
||||
private lazy var closeButton: UIButton = {
|
||||
let button = UIButton(type: .system)
|
||||
button.setImage(UIImage(systemName: "xmark"), for: .normal)
|
||||
button.tintColor = .white
|
||||
button.addTarget(self, action: #selector(closeCamera), for: .touchUpInside)
|
||||
return button
|
||||
}()
|
||||
|
||||
private lazy var flipButton: UIButton = {
|
||||
let button = UIButton(type: .system)
|
||||
button.setImage(UIImage(systemName: "arrow.triangle.2.circlepath.camera"), for: .normal)
|
||||
button.tintColor = .white
|
||||
button.addTarget(self, action: #selector(switchCamera), for: .touchUpInside)
|
||||
return button
|
||||
}()
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .black
|
||||
checkCameraPermissions()
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
// 确保预览层填满整个视图
|
||||
previewLayer?.frame = view.bounds
|
||||
// 更新视频方向
|
||||
if let connection = previewLayer?.connection, connection.isVideoOrientationSupported {
|
||||
connection.videoOrientation = .portrait
|
||||
}
|
||||
}
|
||||
|
||||
private func checkCameraPermissions() {
|
||||
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
||||
case .authorized:
|
||||
setupCamera()
|
||||
case .notDetermined:
|
||||
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
||||
DispatchQueue.main.async {
|
||||
if granted {
|
||||
self?.setupCamera()
|
||||
} else {
|
||||
self?.delegate?.didCancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
delegate?.didCancel()
|
||||
}
|
||||
}
|
||||
|
||||
private func setupCamera() {
|
||||
let session = AVCaptureSession()
|
||||
session.sessionPreset = .high
|
||||
|
||||
// 修改这里:默认使用后置摄像头
|
||||
guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else {
|
||||
delegate?.didCancel()
|
||||
return
|
||||
}
|
||||
|
||||
captureDevice = device
|
||||
|
||||
do {
|
||||
let input = try AVCaptureDeviceInput(device: device)
|
||||
if session.canAddInput(input) {
|
||||
session.addInput(input)
|
||||
}
|
||||
|
||||
let output = AVCapturePhotoOutput()
|
||||
if session.canAddOutput(output) {
|
||||
session.addOutput(output)
|
||||
photoOutput = output
|
||||
}
|
||||
|
||||
// 创建预览层并确保填满整个屏幕
|
||||
let previewLayer = AVCaptureVideoPreviewLayer(session: session)
|
||||
previewLayer.videoGravity = .resizeAspectFill
|
||||
previewLayer.frame = view.bounds
|
||||
previewLayer.connection?.videoOrientation = .portrait
|
||||
|
||||
// 确保预览层填满整个视图
|
||||
view.layer.insertSublayer(previewLayer, at: 0)
|
||||
self.previewLayer = previewLayer
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
session.startRunning()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.setupUI()
|
||||
}
|
||||
}
|
||||
|
||||
captureSession = session
|
||||
|
||||
} catch {
|
||||
print("Error setting up camera: \(error)")
|
||||
delegate?.didCancel()
|
||||
}
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
view.bringSubviewToFront(closeButton)
|
||||
view.bringSubviewToFront(flipButton)
|
||||
view.bringSubviewToFront(captureButton)
|
||||
|
||||
view.addSubview(closeButton)
|
||||
closeButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
closeButton.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 20),
|
||||
closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
|
||||
closeButton.widthAnchor.constraint(equalToConstant: 44),
|
||||
closeButton.heightAnchor.constraint(equalToConstant: 44)
|
||||
])
|
||||
|
||||
view.addSubview(flipButton)
|
||||
flipButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
flipButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -20),
|
||||
flipButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
|
||||
flipButton.widthAnchor.constraint(equalToConstant: 44),
|
||||
flipButton.heightAnchor.constraint(equalToConstant: 44)
|
||||
])
|
||||
|
||||
view.addSubview(captureButton)
|
||||
captureButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
captureButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
captureButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -30),
|
||||
captureButton.widthAnchor.constraint(equalToConstant: 70),
|
||||
captureButton.heightAnchor.constraint(equalToConstant: 70)
|
||||
])
|
||||
}
|
||||
|
||||
@objc private func capturePhoto() {
|
||||
let settings = AVCapturePhotoSettings()
|
||||
photoOutput?.capturePhoto(with: settings, delegate: self)
|
||||
}
|
||||
|
||||
@objc private func closeCamera() {
|
||||
delegate?.didCancel()
|
||||
}
|
||||
|
||||
@objc private func switchCamera() {
|
||||
guard let currentInput = captureSession?.inputs.first as? AVCaptureDeviceInput else { return }
|
||||
|
||||
let newPosition: AVCaptureDevice.Position = currentInput.device.position == .front ? .back : .front
|
||||
|
||||
guard let newDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: newPosition) else { return }
|
||||
|
||||
do {
|
||||
let newInput = try AVCaptureDeviceInput(device: newDevice)
|
||||
captureSession?.beginConfiguration()
|
||||
captureSession?.removeInput(currentInput)
|
||||
|
||||
if captureSession?.canAddInput(newInput) == true {
|
||||
captureSession?.addInput(newInput)
|
||||
captureDevice = newDevice
|
||||
} else {
|
||||
captureSession?.addInput(currentInput)
|
||||
}
|
||||
|
||||
captureSession?.commitConfiguration()
|
||||
} catch {
|
||||
print("Error switching camera: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension CustomCameraViewController: AVCapturePhotoCaptureDelegate {
|
||||
func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
|
||||
if let error = error {
|
||||
print("Error capturing photo: \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
guard let imageData = photo.fileDataRepresentation(),
|
||||
let image = UIImage(data: imageData) else {
|
||||
return
|
||||
}
|
||||
|
||||
let fixedImage = image.fixedOrientation()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.didCaptureImage(fixedImage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension UIImage {
|
||||
func fixedOrientation() -> UIImage {
|
||||
if imageOrientation == .up {
|
||||
return self
|
||||
}
|
||||
|
||||
UIGraphicsBeginImageContextWithOptions(size, false, scale)
|
||||
draw(in: CGRect(origin: .zero, size: size))
|
||||
let normalizedImage = UIGraphicsGetImageFromCurrentImageContext() ?? self
|
||||
UIGraphicsEndImageContext()
|
||||
|
||||
return normalizedImage
|
||||
}
|
||||
}
|
||||
@ -1,163 +0,0 @@
|
||||
//
|
||||
// NaviHeader.swift
|
||||
// wake
|
||||
//
|
||||
// Created by Junhui on 2025/8/19.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 导航头组件,包含返回按钮和标题
|
||||
struct NaviHeader: View {
|
||||
let title: String
|
||||
let onBackTap: () -> Void
|
||||
var showBackButton: Bool = true
|
||||
var titleStyle: TypographyStyle = .title
|
||||
var backgroundColor: Color = Color.clear
|
||||
var rightContent: AnyView? = nil
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// 标题居中显示
|
||||
Text(title)
|
||||
.font(Typography.font(for: titleStyle, family: .quicksandBold))
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
// 左右按钮层
|
||||
HStack {
|
||||
// 左侧返回按钮
|
||||
if showBackButton {
|
||||
ReturnButton(action: onBackTap)
|
||||
} else {
|
||||
Color.clear
|
||||
.frame(width: 30)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// 右侧内容
|
||||
if let rightContent = rightContent {
|
||||
rightContent
|
||||
} else {
|
||||
Color.clear
|
||||
.frame(width: 30)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 20)
|
||||
.background(backgroundColor)
|
||||
}
|
||||
}
|
||||
|
||||
/// 带右侧按钮的导航头组件
|
||||
struct NaviHeaderWithAction: View {
|
||||
let title: String
|
||||
let onBackTap: () -> Void
|
||||
let rightButtonTitle: String
|
||||
let onRightButtonTap: () -> Void
|
||||
var showBackButton: Bool = true
|
||||
var titleStyle: TypographyStyle = .title
|
||||
var rightButtonStyle: TypographyStyle = .body
|
||||
var backgroundColor: Color = Color.clear
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// 标题居中显示
|
||||
Text(title)
|
||||
.font(Typography.font(for: titleStyle, family: .quicksandBold))
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
// 左右按钮层
|
||||
HStack {
|
||||
// 左侧返回按钮
|
||||
if showBackButton {
|
||||
ReturnButton(action: onBackTap)
|
||||
} else {
|
||||
Color.clear
|
||||
.frame(width: 30)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// 右侧按钮
|
||||
Button(action: onRightButtonTap) {
|
||||
Text(rightButtonTitle)
|
||||
.font(Typography.font(for: rightButtonStyle, family: .quicksandBold))
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 20)
|
||||
.background(backgroundColor)
|
||||
}
|
||||
}
|
||||
|
||||
/// 简洁版导航头组件
|
||||
struct SimpleNaviHeader: View {
|
||||
let title: String
|
||||
let onBackTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// 标题居中显示
|
||||
Text(title)
|
||||
.font(Typography.font(for: .title, family: .quicksandBold))
|
||||
.fontWeight(.bold)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
// 返回按钮左对齐
|
||||
HStack {
|
||||
ReturnButton(action: onBackTap)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("基础导航头") {
|
||||
VStack(spacing: 0) {
|
||||
NaviHeader(title: "Settings") {
|
||||
print("返回")
|
||||
}
|
||||
.background(Color(.systemBackground))
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
}
|
||||
|
||||
#Preview("带右侧按钮导航头") {
|
||||
VStack(spacing: 0) {
|
||||
NaviHeaderWithAction(
|
||||
title: "Profile",
|
||||
onBackTap: { print("返回") },
|
||||
rightButtonTitle: "Save",
|
||||
onRightButtonTap: { print("保存") }
|
||||
)
|
||||
.background(Color(.systemBackground))
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
}
|
||||
|
||||
#Preview("简洁导航头") {
|
||||
VStack(spacing: 0) {
|
||||
SimpleNaviHeader(title: "About") {
|
||||
print("返回")
|
||||
}
|
||||
.background(Color(.systemBackground))
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
}
|
||||
222
wake/ContentView.swift
Normal file
@ -0,0 +1,222 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
// MARK: - 自定义过渡动画
|
||||
extension AnyTransition {
|
||||
/// 创建从左向右的滑动过渡动画
|
||||
static var slideFromLeading: AnyTransition {
|
||||
.asymmetric(
|
||||
insertion: .move(edge: .trailing).combined(with: .opacity), // 从右侧滑入
|
||||
removal: .move(edge: .leading).combined(with: .opacity) // 向左侧滑出
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 主视图
|
||||
struct ContentView: View {
|
||||
// MARK: - 状态属性
|
||||
@State private var showModal = false // 控制用户资料弹窗显示
|
||||
@State private var showSettings = false // 控制设置页面显示
|
||||
@State private var contentOffset: CGFloat = 0 // 内容偏移量
|
||||
@State private var showLogin = false
|
||||
|
||||
// 获取模型上下文
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
|
||||
// 查询数据 - 简单查询
|
||||
@Query private var login: [Login]
|
||||
|
||||
// MARK: - 主体视图
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
ZStack {
|
||||
// 主内容区域
|
||||
VStack {
|
||||
VStack(spacing: 20) {
|
||||
// 状态栏占位
|
||||
Spacer().frame(height: UIApplication.shared.windows.first?.safeAreaInsets.top ?? 0)
|
||||
|
||||
// 顶部导航栏
|
||||
HStack {
|
||||
// 设置按钮
|
||||
Button(action: showUserProfile) {
|
||||
Image(systemName: "gearshape")
|
||||
.font(.title2)
|
||||
.padding()
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("Wake")
|
||||
.font(.largeTitle)
|
||||
.fontWeight(.bold)
|
||||
.onTapGesture {
|
||||
if login.isEmpty {
|
||||
print("⚠️ 没有登录记录,正在创建新记录...")
|
||||
let newLogin = Login(
|
||||
email: "jyq@example.com",
|
||||
name: "New User"
|
||||
)
|
||||
modelContext.insert(newLogin)
|
||||
try? modelContext.save()
|
||||
print("✅ 已创建新登录记录")
|
||||
} else if let firstLogin = login.first {
|
||||
// 2. 更新现有记录
|
||||
print("🔍 找到现有记录,正在更新...")
|
||||
firstLogin.email = "updated@example.com"
|
||||
firstLogin.name = "Updated Name"
|
||||
try? modelContext.save()
|
||||
print("✅ 记录已更新")
|
||||
}
|
||||
}
|
||||
// 登录按钮
|
||||
NavigationLink(destination: LoginView()) {
|
||||
Text("登录")
|
||||
.font(.headline)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.blue)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.padding(.trailing)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// 内容列表
|
||||
List {
|
||||
Section(header: Text("我的收藏")) {
|
||||
ForEach(1...5, id: \.self) { item in
|
||||
HStack {
|
||||
Image(systemName: "photo")
|
||||
.foregroundColor(.blue)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(Color.blue.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("项目 \(item)")
|
||||
.font(.headline)
|
||||
Text("这是第\(item)个项目的描述")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("最近活动")) {
|
||||
ForEach(6...10, id: \.self) { item in
|
||||
HStack {
|
||||
Image(systemName: "clock")
|
||||
.foregroundColor(.orange)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(Color.orange.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("活动 \(item)")
|
||||
.font(.headline)
|
||||
Text("\(item)分钟前更新")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("查看")
|
||||
.font(.caption)
|
||||
.padding(6)
|
||||
.background(Color.blue.opacity(0.1))
|
||||
.foregroundColor(.blue)
|
||||
.cornerRadius(4)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(GroupedListStyle())
|
||||
.padding(.top, 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(.systemBackground))
|
||||
.offset(x: showModal ? UIScreen.main.bounds.width * 0.8 : 0)
|
||||
.animation(.spring(response: 0.6, dampingFraction: 0.8), value: showModal)
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
}
|
||||
|
||||
// 用户资料弹窗
|
||||
SlideInModal(
|
||||
isPresented: $showModal,
|
||||
onDismiss: hideUserProfile
|
||||
) {
|
||||
UserProfileModal(
|
||||
showModal: $showModal,
|
||||
showSettings: $showSettings
|
||||
)
|
||||
}
|
||||
.offset(x: showSettings ? UIScreen.main.bounds.width : 0)
|
||||
.animation(.spring(response: 0.6, dampingFraction: 0.8), value: showSettings)
|
||||
|
||||
// 设置页面遮罩层
|
||||
ZStack {
|
||||
if showSettings {
|
||||
Color.black.opacity(0.3)
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
.onTapGesture(perform: hideSettings)
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
if showSettings {
|
||||
SettingsView(isPresented: $showSettings)
|
||||
.transition(.move(edge: .leading))
|
||||
.zIndex(1)
|
||||
}
|
||||
}
|
||||
.animation(.spring(response: 0.6, dampingFraction: 0.8), value: showSettings)
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// 显示用户资料弹窗
|
||||
private func showUserProfile() {
|
||||
withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
|
||||
// print("登录记录数量: \(login.count)")
|
||||
// for (index, item) in login.enumerated() {
|
||||
// print("记录 \(index + 1): 邮箱=\(item.email), 姓名=\(item.name)")
|
||||
// }
|
||||
print("当前登录记录:")
|
||||
for (index, item) in login.enumerated() {
|
||||
print("记录 \(index + 1): 邮箱=\(item.email), 姓名=\(item.name)")
|
||||
}
|
||||
// showModal.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
/// 隐藏用户资料弹窗
|
||||
private func hideUserProfile() {
|
||||
withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
|
||||
showModal = false
|
||||
}
|
||||
}
|
||||
|
||||
/// 隐藏设置页面
|
||||
private func hideSettings() {
|
||||
withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
|
||||
showSettings = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 预览
|
||||
#Preview {
|
||||
ContentView()
|
||||
}
|
||||
@ -1,2 +0,0 @@
|
||||
# Core/DesignSystem
|
||||
存放 `Theme.swift`、`Typography.swift` 等设计系统文件。
|
||||
@ -1,189 +0,0 @@
|
||||
//
|
||||
// Theme.swift
|
||||
// wake
|
||||
//
|
||||
// Created by fairclip on 2025/8/19.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - 主题色管理
|
||||
struct Theme {
|
||||
|
||||
// MARK: - 主要颜色
|
||||
struct Colors {
|
||||
|
||||
// MARK: - 品牌色
|
||||
static let primary = Color(hex: "FFB645") // 主橙色
|
||||
static let primaryLight = Color(hex: "FFF8DE") // 浅橙色
|
||||
static let primaryDark = Color(hex: "E6A03D") // 深橙色
|
||||
|
||||
// MARK: - 辅助色
|
||||
static let secondary = Color(hex: "6C7B7F") // 灰蓝色
|
||||
static let accent = Color(hex: "FF6B6B") // 强调红色
|
||||
|
||||
// MARK: - 中性色
|
||||
static let background = Color(hex: "F8F9FA") // 背景色
|
||||
static let surface = Color.white // 表面色
|
||||
static let surfaceSecondary = Color(hex: "F5F5F5") // 次级表面色
|
||||
static let surfaceTertiary = Color(hex: "F7F7F7") // 次级表面色
|
||||
|
||||
// MARK: - 文本色
|
||||
static let textPrimary = Color.black // 主文本色
|
||||
static let textSecondary = Color(hex: "6B7280") // 次级文本色
|
||||
static let textTertiary = Color(hex: "9CA3AF") // 三级文本色
|
||||
static let textInverse = Color.white // 反色文本
|
||||
static let textMessage = Color(hex: "7B7B7B") // 注释颜色
|
||||
static let textMessageMain = Color(hex: "000000") // 注释主要颜色
|
||||
static let textWhite = Color(hex: "FFFFFF") // 白色
|
||||
static let textWhiteSecondary = Color(hex: "FAFAFA") // 白色次级
|
||||
|
||||
// MARK: - 状态色
|
||||
static let success = Color(hex: "10B981") // 成功色
|
||||
static let warning = Color(hex: "F59E0B") // 警告色
|
||||
static let error = Color(hex: "EF4444") // 错误色
|
||||
static let info = Color(hex: "3B82F6") // 信息色
|
||||
|
||||
// MARK: - 边框色
|
||||
static let border = Color(hex: "E5E7EB") // 边框色
|
||||
static let borderLight = Color(hex: "F3F4F6") // 浅边框色
|
||||
static let borderDark = Color(hex: "D1D5DB") // 深边框色
|
||||
|
||||
// MARK: - 订阅相关色
|
||||
static let freeBackground = primaryLight // Free版背景
|
||||
static let pioneerBackground = primary // Pioneer版背景
|
||||
static let subscribeButton = primary // 订阅按钮色
|
||||
|
||||
// MARK: - 卡片相关色
|
||||
static let cardBackground = Color.white // 卡片背景
|
||||
}
|
||||
|
||||
// MARK: - 渐变色
|
||||
struct Gradients {
|
||||
static let primaryGradient = LinearGradient(
|
||||
colors: [Colors.primary, Colors.primaryDark],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
|
||||
static let backgroundGradient = LinearGradient(
|
||||
colors: [Colors.background, Colors.surface],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
|
||||
static let accentGradient = LinearGradient(
|
||||
colors: [Colors.accent, Color(hex: "FF8E8E")],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 阴影
|
||||
struct Shadows {
|
||||
static let small = Color.black.opacity(0.1)
|
||||
static let medium = Color.black.opacity(0.15)
|
||||
static let large = Color.black.opacity(0.2)
|
||||
|
||||
// 阴影配置
|
||||
static let cardShadow = (color: small, radius: CGFloat(4), x: CGFloat(0), y: CGFloat(2))
|
||||
static let buttonShadow = (color: medium, radius: CGFloat(6), x: CGFloat(0), y: CGFloat(3))
|
||||
static let modalShadow = (color: large, radius: CGFloat(12), x: CGFloat(0), y: CGFloat(8))
|
||||
}
|
||||
|
||||
// MARK: - 圆角
|
||||
struct CornerRadius {
|
||||
static let small: CGFloat = 8
|
||||
static let medium: CGFloat = 12
|
||||
static let large: CGFloat = 16
|
||||
static let extraLarge: CGFloat = 20
|
||||
static let round: CGFloat = 50
|
||||
}
|
||||
|
||||
// MARK: - 间距
|
||||
struct Spacing {
|
||||
static let xs: CGFloat = 4
|
||||
static let sm: CGFloat = 8
|
||||
static let md: CGFloat = 12
|
||||
static let lg: CGFloat = 16
|
||||
static let xl: CGFloat = 20
|
||||
static let xxl: CGFloat = 24
|
||||
static let xxxl: CGFloat = 32
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 便捷扩展
|
||||
extension Color {
|
||||
/// 主题色快捷访问
|
||||
static var themePrimary: Color { Theme.Colors.primary }
|
||||
static var themePrimaryLight: Color { Theme.Colors.primaryLight }
|
||||
static var themeSecondary: Color { Theme.Colors.secondary }
|
||||
static var themeAccent: Color { Theme.Colors.accent }
|
||||
static var themeBackground: Color { Theme.Colors.background }
|
||||
static var themeSurface: Color { Theme.Colors.surface }
|
||||
static var themeTextPrimary: Color { Theme.Colors.textPrimary }
|
||||
static var themeTextSecondary: Color { Theme.Colors.textSecondary }
|
||||
static var themeTextMessage: Color { Theme.Colors.textMessage }
|
||||
static var themeTextMessageMain: Color { Theme.Colors.textMessageMain }
|
||||
static var themeTextWhite: Color { Theme.Colors.textWhite }
|
||||
static var themeTextWhiteSecondary: Color { Theme.Colors.textWhiteSecondary }
|
||||
}
|
||||
|
||||
// MARK: - 预览
|
||||
#Preview("Theme Colors") {
|
||||
ScrollView {
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
// 品牌色
|
||||
ColorPreviewSection(title: "品牌色", colors: [
|
||||
("Primary", Theme.Colors.primary),
|
||||
("Primary Light", Theme.Colors.primaryLight),
|
||||
("Primary Dark", Theme.Colors.primaryDark)
|
||||
])
|
||||
|
||||
// 辅助色
|
||||
ColorPreviewSection(title: "辅助色", colors: [
|
||||
("Secondary", Theme.Colors.secondary),
|
||||
("Accent", Theme.Colors.accent)
|
||||
])
|
||||
|
||||
// 状态色
|
||||
ColorPreviewSection(title: "状态色", colors: [
|
||||
("Success", Theme.Colors.success),
|
||||
("Warning", Theme.Colors.warning),
|
||||
("Error", Theme.Colors.error),
|
||||
("Info", Theme.Colors.info)
|
||||
])
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.background(Theme.Colors.background)
|
||||
}
|
||||
|
||||
// MARK: - 预览辅助组件
|
||||
struct ColorPreviewSection: View {
|
||||
let title: String
|
||||
let colors: [(String, Color)]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.foregroundColor(Theme.Colors.textPrimary)
|
||||
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 3), spacing: Theme.Spacing.sm) {
|
||||
ForEach(colors, id: \.0) { name, color in
|
||||
VStack(spacing: Theme.Spacing.xs) {
|
||||
Rectangle()
|
||||
.fill(color)
|
||||
.frame(height: 60)
|
||||
.cornerRadius(Theme.CornerRadius.small)
|
||||
|
||||
Text(name)
|
||||
.font(.caption)
|
||||
.foregroundColor(Theme.Colors.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,21 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -1,2 +0,0 @@
|
||||
# Core/Diagnostics
|
||||
性能与诊断相关:`Performance.swift`,以及后续埋点/日志工具。
|
||||
@ -1,2 +0,0 @@
|
||||
# Core/Navigation
|
||||
存放路由与导航相关:`Router.swift`。
|
||||
@ -1,87 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
enum AppRoute: Hashable {
|
||||
case login
|
||||
case avatarBox
|
||||
case feedbackView
|
||||
case feedbackDetail(type: FeedbackView.FeedbackType)
|
||||
case mediaUpload
|
||||
case blindBox(mediaType: BlindBoxMediaType, blindBoxId: String? = nil)
|
||||
case blindOutcome(media: MediaType, title: String? = nil, description: String? = nil, isMember: Bool, goToFeedback: Bool = false)
|
||||
case memories
|
||||
case subscribe
|
||||
case userInfo(createFirstBlindBox: Bool = false)
|
||||
case account
|
||||
case about
|
||||
case permissionManagement
|
||||
case feedback
|
||||
|
||||
@ViewBuilder
|
||||
var view: some View {
|
||||
switch self {
|
||||
case .login:
|
||||
LoginView()
|
||||
case .avatarBox:
|
||||
// AvatarBoxView has been removed; route to BlindBoxView as replacement
|
||||
BlindBoxView(mediaType: .all)
|
||||
case .feedbackView:
|
||||
FeedbackView()
|
||||
case .feedbackDetail(let type):
|
||||
FeedbackDetailView(feedbackType: type)
|
||||
case .mediaUpload:
|
||||
MediaUploadView()
|
||||
case .blindBox(let mediaType, let blindBoxId):
|
||||
BlindBoxView(mediaType: mediaType, blindBoxId: blindBoxId)
|
||||
case .blindOutcome(let media, let title, let description, let isMember, let goToFeedback):
|
||||
BlindOutcomeView(
|
||||
media: media,
|
||||
title: title,
|
||||
description: description,
|
||||
isMember: isMember,
|
||||
onContinue: {
|
||||
if goToFeedback {
|
||||
Router.shared.navigate(to: .feedbackView)
|
||||
} else {
|
||||
Router.shared.navigate(to: .blindBox(mediaType: .all))
|
||||
}
|
||||
}
|
||||
)
|
||||
case .memories:
|
||||
MemoriesView()
|
||||
case .subscribe:
|
||||
SubscribeView()
|
||||
case .userInfo(let createFirstBlindBox):
|
||||
UserInfo(createFirstBlindBox: createFirstBlindBox)
|
||||
case .account:
|
||||
AccountView()
|
||||
case .about:
|
||||
AboutUsView()
|
||||
case .permissionManagement:
|
||||
PermissionManagementView()
|
||||
case .feedback:
|
||||
FeedbackView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
class Router: ObservableObject {
|
||||
static let shared = Router()
|
||||
|
||||
@Published var path = NavigationPath()
|
||||
|
||||
private init() {}
|
||||
|
||||
func navigate(to destination: AppRoute) {
|
||||
path.append(destination)
|
||||
}
|
||||
|
||||
func pop() {
|
||||
path.removeLast()
|
||||
}
|
||||
|
||||
func popToRoot() {
|
||||
path = NavigationPath()
|
||||
}
|
||||
}
|
||||
@ -1,34 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
/// API 配置信息
|
||||
public enum APIConfig {
|
||||
/// API 基础 URL
|
||||
public static let baseURL = "https://api.memorywake.com/api/v1"
|
||||
|
||||
/// 认证 token - 从 Keychain 中获取
|
||||
public static var authToken: String {
|
||||
let token = KeychainHelper.getAccessToken() ?? ""
|
||||
if !token.isEmpty {
|
||||
print("🔑 [APIConfig] 当前访问令牌: \(token.prefix(10))...") // 只打印前10个字符,避免敏感信息完全暴露
|
||||
} else {
|
||||
print("⚠️ [APIConfig] 未找到访问令牌")
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
/// 认证请求头
|
||||
public static var authHeaders: [String: String] {
|
||||
let token = authToken
|
||||
var headers = [
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json"
|
||||
]
|
||||
|
||||
if !token.isEmpty {
|
||||
headers["Authorization"] = "Bearer \(token)"
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,620 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
// 添加登出通知
|
||||
extension Notification.Name {
|
||||
static let userDidLogoutNotification = Notification.Name("UserDidLogoutNotification")
|
||||
}
|
||||
|
||||
// 请求标识符
|
||||
private struct RequestIdentifier {
|
||||
static var currentId: Int = 0
|
||||
static var lock = NSLock()
|
||||
|
||||
static func next() -> Int {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
currentId += 1
|
||||
return currentId
|
||||
}
|
||||
}
|
||||
|
||||
public protocol NetworkServiceProtocol {
|
||||
func postWithToken<T: Decodable>(
|
||||
path: String,
|
||||
parameters: [String: Any],
|
||||
completion: @escaping (Result<T, NetworkError>) -> Void
|
||||
)
|
||||
|
||||
@discardableResult
|
||||
func upload(
|
||||
request: URLRequest,
|
||||
fileData: Data,
|
||||
onProgress: @escaping (Double) -> Void,
|
||||
completion: @escaping (Result<(Data?, URLResponse), Error>) -> Void
|
||||
) -> URLSessionUploadTask?
|
||||
}
|
||||
|
||||
extension NetworkService: NetworkServiceProtocol {
|
||||
public func postWithToken<T: Decodable>(
|
||||
path: String,
|
||||
parameters: [String: Any],
|
||||
completion: @escaping (Result<T, NetworkError>) -> Void
|
||||
) {
|
||||
var headers = [String: String]()
|
||||
if let token = KeychainHelper.getAccessToken() {
|
||||
headers["Authorization"] = "Bearer \(token)"
|
||||
}
|
||||
|
||||
post(path: path, parameters: parameters, headers: headers, completion: completion)
|
||||
}
|
||||
|
||||
public func getWithToken<T: Decodable>(
|
||||
path: String,
|
||||
parameters: [String: Any]? = nil,
|
||||
completion: @escaping (Result<T, NetworkError>) -> Void
|
||||
) {
|
||||
var headers = [String: String]()
|
||||
if let token = KeychainHelper.getAccessToken() {
|
||||
headers["Authorization"] = "Bearer \(token)"
|
||||
}
|
||||
|
||||
get(path: path, parameters: parameters, headers: headers, completion: completion)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func upload(
|
||||
request: URLRequest,
|
||||
fileData: Data,
|
||||
onProgress: @escaping (Double) -> Void,
|
||||
completion: @escaping (Result<(Data?, URLResponse), Error>) -> Void
|
||||
) -> URLSessionUploadTask? {
|
||||
var request = request
|
||||
|
||||
// Set content length header if not already set
|
||||
if request.value(forHTTPHeaderField: "Content-Length") == nil {
|
||||
request.setValue("\(fileData.count)", forHTTPHeaderField: "Content-Length")
|
||||
}
|
||||
|
||||
var progressObserver: NSKeyValueObservation?
|
||||
|
||||
let task = URLSession.shared.uploadTask(with: request, from: fileData) { [weak self] data, response, error in
|
||||
// Invalidate the progress observer when the task completes
|
||||
progressObserver?.invalidate()
|
||||
|
||||
if let error = error {
|
||||
completion(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
guard let response = response else {
|
||||
completion(.failure(NetworkError.invalidURL))
|
||||
return
|
||||
}
|
||||
|
||||
completion(.success((data, response)))
|
||||
}
|
||||
|
||||
// Add progress tracking if available
|
||||
if #available(iOS 11.0, *) {
|
||||
progressObserver = task.progress.observe(\.fractionCompleted) { progressValue, _ in
|
||||
DispatchQueue.main.async {
|
||||
onProgress(progressValue.fractionCompleted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
task.resume()
|
||||
return task
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Async/Await Extensions
|
||||
extension NetworkService {
|
||||
/// 使用 async/await 的 GET 请求(带Token)
|
||||
public func getWithToken<T: Decodable>(
|
||||
path: String,
|
||||
parameters: [String: Any]? = nil
|
||||
) async throws -> T {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
getWithToken(path: path, parameters: parameters) { (result: Result<T, NetworkError>) in
|
||||
switch result {
|
||||
case .success(let value):
|
||||
continuation.resume(returning: value)
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 使用 async/await 的 POST 请求(带Token)
|
||||
public func postWithToken<T: Decodable>(
|
||||
path: String,
|
||||
parameters: [String: Any]
|
||||
) async throws -> T {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
postWithToken(path: path, parameters: parameters) { (result: Result<T, NetworkError>) in
|
||||
switch result {
|
||||
case .success(let value):
|
||||
continuation.resume(returning: value)
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 使用 async/await 的 POST 请求(支持数组或字典参数)
|
||||
public func post<T: Decodable>(
|
||||
path: String,
|
||||
parameters: Any? = nil,
|
||||
headers: [String: String]? = nil
|
||||
) async throws -> T {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
post(path: path, parameters: parameters, headers: headers) { (result: Result<T, NetworkError>) in
|
||||
switch result {
|
||||
case .success(let value):
|
||||
continuation.resume(returning: value)
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 使用 async/await 的 POST 请求(带Token,支持数组或字典参数)
|
||||
public func postWithToken<T: Decodable>(
|
||||
path: String,
|
||||
parameters: Any? = nil,
|
||||
headers: [String: String]? = nil
|
||||
) async throws -> T {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
postWithToken(path: path, parameters: parameters, headers: headers) { (result: Result<T, NetworkError>) in
|
||||
switch result {
|
||||
case .success(let value):
|
||||
continuation.resume(returning: value)
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum NetworkError: Error {
|
||||
case invalidURL
|
||||
case noData
|
||||
case decodingError(Error)
|
||||
case serverError(String)
|
||||
case unauthorized
|
||||
case other(Error)
|
||||
case networkError(Error)
|
||||
case unknownError(Error)
|
||||
case invalidParameters
|
||||
|
||||
public var localizedDescription: String {
|
||||
switch self {
|
||||
case .invalidURL:
|
||||
return "无效的URL"
|
||||
case .noData:
|
||||
return "没有接收到数据"
|
||||
case .decodingError(let error):
|
||||
return "解码错误: \(error.localizedDescription)"
|
||||
case .serverError(let message):
|
||||
return "服务器错误: \(message)"
|
||||
case .unauthorized:
|
||||
return "未授权,请重新登录"
|
||||
case .other(let error):
|
||||
return error.localizedDescription
|
||||
case .networkError(let error):
|
||||
return "网络错误: \(error.localizedDescription)"
|
||||
case .unknownError(let error):
|
||||
return "未知错误: \(error.localizedDescription)"
|
||||
case .invalidParameters:
|
||||
return "无效的参数"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NetworkService {
|
||||
static let shared = NetworkService()
|
||||
|
||||
// 默认请求头
|
||||
private let defaultHeaders: [String: String] = [
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json"
|
||||
]
|
||||
|
||||
private var isRefreshing = false
|
||||
private var requestsToRetry: [(URLRequest, (Result<Data, NetworkError>) -> Void, Int)] = []
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - 基础请求方法
|
||||
private func request<T: Decodable>(
|
||||
_ method: String,
|
||||
path: String,
|
||||
parameters: Any? = nil,
|
||||
headers: [String: String]? = nil,
|
||||
completion: @escaping (Result<T, NetworkError>) -> Void
|
||||
) {
|
||||
// 生成请求ID
|
||||
let requestId = RequestIdentifier.next()
|
||||
|
||||
// 构建URL
|
||||
let fullURL = APIConfig.baseURL + path
|
||||
guard let url = URL(string: fullURL) else {
|
||||
print("❌ [Network][#\(requestId)][\(method) \(path)] 无效的URL")
|
||||
completion(.failure(.invalidURL))
|
||||
return
|
||||
}
|
||||
|
||||
// 创建请求
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method
|
||||
|
||||
// 设置请求头 - 合并默认头、认证头和自定义头
|
||||
defaultHeaders.forEach { key, value in
|
||||
request.setValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
|
||||
// 添加认证头 - 排除登录接口
|
||||
if !path.contains("/iam/login/") {
|
||||
APIConfig.authHeaders.forEach { key, value in
|
||||
request.setValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加自定义头(如果提供)
|
||||
headers?.forEach { key, value in
|
||||
request.setValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
|
||||
// 设置请求体(如果是POST/PUT请求)
|
||||
if let parameters = parameters, (method == "POST" || method == "PUT") {
|
||||
do {
|
||||
if JSONSerialization.isValidJSONObject(parameters) {
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: parameters)
|
||||
} else {
|
||||
print("❌ [Network][#\(requestId)][\(method) \(path)] 参数不是有效的JSON对象")
|
||||
completion(.failure(.invalidParameters))
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
print("❌ [Network][#\(requestId)][\(method) \(path)] 参数序列化失败: \(error.localizedDescription)")
|
||||
completion(.failure(.other(error)))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 打印请求信息(仅 Debug)
|
||||
#if DEBUG
|
||||
print("""
|
||||
🌐 [Network][#\(requestId)][\(method) \(path)] 开始请求
|
||||
🔗 URL: \(url.absoluteString)
|
||||
📤 Headers: \(request.allHTTPHeaderFields ?? [:])
|
||||
📦 Body: \(request.httpBody.flatMap { String(data: $0, encoding: .utf8) } ?? "")
|
||||
""")
|
||||
#endif
|
||||
|
||||
// 创建任务
|
||||
let startTime = Date()
|
||||
let task = URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
|
||||
let duration = String(format: "%.3fs", Date().timeIntervalSince(startTime))
|
||||
|
||||
// 处理响应
|
||||
self?.handleResponse(
|
||||
requestId: requestId,
|
||||
method: method,
|
||||
path: path,
|
||||
data: data,
|
||||
response: response,
|
||||
error: error,
|
||||
request: request,
|
||||
duration: duration,
|
||||
completion: { (result: Result<T, NetworkError>) in
|
||||
completion(result)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 开始请求
|
||||
task.resume()
|
||||
}
|
||||
|
||||
private func handleResponse<T: Decodable>(
|
||||
requestId: Int,
|
||||
method: String,
|
||||
path: String,
|
||||
data: Data?,
|
||||
response: URLResponse?,
|
||||
error: Error?,
|
||||
request: URLRequest,
|
||||
duration: String,
|
||||
completion: @escaping (Result<T, NetworkError>) -> Void
|
||||
) {
|
||||
// 打印响应信息
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
let statusCode = httpResponse.statusCode
|
||||
let statusMessage = HTTPURLResponse.localizedString(forStatusCode: statusCode)
|
||||
|
||||
// 处理401未授权
|
||||
if statusCode == 401 {
|
||||
#if DEBUG
|
||||
print("""
|
||||
🔑 [Network][#\(requestId)][\(method) \(path)] 检测到未授权,尝试刷新token...
|
||||
⏱️ 耗时: \(duration)
|
||||
""")
|
||||
#endif
|
||||
|
||||
// 将请求加入重试队列
|
||||
let dataResult = data.flatMap { Result<Data, NetworkError>.success($0) } ?? .failure(.noData)
|
||||
self.requestsToRetry.append((request, { result in
|
||||
switch result {
|
||||
case .success(let data):
|
||||
do {
|
||||
let decoder = JSONDecoder()
|
||||
let result = try decoder.decode(T.self, from: data)
|
||||
#if DEBUG
|
||||
print("""
|
||||
✅ [Network][#\(requestId)][\(method) \(path)] 重试成功
|
||||
⏱️ 总耗时: \(duration) (包含token刷新时间)
|
||||
""")
|
||||
#endif
|
||||
completion(.success(result))
|
||||
} catch let decodingError as DecodingError {
|
||||
print("""
|
||||
❌ [Network][#\(requestId)][\(method) \(path)] JSON解析失败
|
||||
🔍 错误: \(decodingError.localizedDescription)
|
||||
📦 原始数据: \(String(data: data, encoding: .utf8) ?? "")
|
||||
""")
|
||||
completion(.failure(.decodingError(decodingError)))
|
||||
} catch {
|
||||
print("""
|
||||
❌ [Network][#\(requestId)][\(method) \(path)] 未知错误
|
||||
🔍 错误: \(error.localizedDescription)
|
||||
""")
|
||||
completion(.failure(.unknownError(error)))
|
||||
}
|
||||
case .failure(let error):
|
||||
print("""
|
||||
❌ [Network][#\(requestId)][\(method) \(path)] 重试失败
|
||||
🔍 错误: \(error.localizedDescription)
|
||||
""")
|
||||
completion(.failure(error))
|
||||
}
|
||||
}, requestId))
|
||||
|
||||
// 如果没有正在刷新的请求,则开始刷新token
|
||||
if !isRefreshing {
|
||||
refreshAndRetryRequests()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 处理其他错误状态码
|
||||
if !(200...299).contains(statusCode) {
|
||||
let errorMessage = data.flatMap { String(data: $0, encoding: .utf8) } ?? ""
|
||||
let truncated = errorMessage.count > 300 ? String(errorMessage.prefix(300)) + "..." : errorMessage
|
||||
print("""
|
||||
❌ [Network][#\(requestId)][\(method) \(path)] 请求失败
|
||||
📊 状态码: \(statusCode) (\(statusMessage))
|
||||
⏱️ 耗时: \(duration)
|
||||
🔍 错误响应: \(truncated)
|
||||
""")
|
||||
completion(.failure(.serverError("状态码: \(statusCode), 响应: \(truncated)")))
|
||||
return
|
||||
}
|
||||
|
||||
// 成功响应(仅 Debug)
|
||||
#if DEBUG
|
||||
print("""
|
||||
✅ [Network][#\(requestId)][\(method) \(path)] 请求成功
|
||||
📊 状态码: \(statusCode) (\(statusMessage))
|
||||
⏱️ 耗时: \(duration)
|
||||
""")
|
||||
#endif
|
||||
}
|
||||
|
||||
// 处理网络错误
|
||||
if let error = error {
|
||||
print("""
|
||||
❌ [Network][#\(requestId)][\(method) \(path)] 网络请求失败
|
||||
⏱️ 耗时: \(duration)
|
||||
🔍 错误: \(error.localizedDescription)
|
||||
""")
|
||||
completion(.failure(.networkError(error)))
|
||||
return
|
||||
}
|
||||
|
||||
// 检查数据是否存在
|
||||
guard let data = data else {
|
||||
print("""
|
||||
❌ [Network][#\(requestId)][\(method) \(path)] 没有收到数据
|
||||
⏱️ 耗时: \(duration)
|
||||
""")
|
||||
completion(.failure(.noData))
|
||||
return
|
||||
}
|
||||
|
||||
// 打印响应数据(仅 Debug)
|
||||
#if DEBUG
|
||||
if let responseString = String(data: data, encoding: .utf8) {
|
||||
print("""
|
||||
📥 [Network][#\(requestId)][\(method) \(path)] 响应数据:
|
||||
\(responseString.prefix(1000))\(responseString.count > 1000 ? "..." : "")
|
||||
""")
|
||||
}
|
||||
#endif
|
||||
|
||||
do {
|
||||
// 解析JSON数据
|
||||
let decoder = JSONDecoder()
|
||||
let result = try decoder.decode(T.self, from: data)
|
||||
completion(.success(result))
|
||||
} catch let decodingError as DecodingError {
|
||||
#if DEBUG
|
||||
print("""
|
||||
❌ [Network][#\(requestId)][\(method) \(path)] JSON解析失败
|
||||
🔍 错误: \(decodingError.localizedDescription)
|
||||
📦 原始数据: \(String(data: data, encoding: .utf8) ?? "")
|
||||
""")
|
||||
#else
|
||||
print("""
|
||||
❌ [Network][#\(requestId)][\(method) \(path)] JSON解析失败
|
||||
🔍 错误: \(decodingError.localizedDescription)
|
||||
""")
|
||||
#endif
|
||||
completion(.failure(.decodingError(decodingError)))
|
||||
} catch {
|
||||
print("""
|
||||
❌ [Network][#\(requestId)][\(method) \(path)] 未知错误
|
||||
🔍 错误: \(error.localizedDescription)
|
||||
""")
|
||||
completion(.failure(.unknownError(error)))
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshAndRetryRequests() {
|
||||
guard !isRefreshing else { return }
|
||||
|
||||
isRefreshing = true
|
||||
let refreshStartTime = Date()
|
||||
|
||||
#if DEBUG
|
||||
print("🔄 [Network] 开始刷新Token...")
|
||||
#endif
|
||||
|
||||
TokenManager.shared.refreshToken { [weak self] success, _ in
|
||||
guard let self = self else { return }
|
||||
|
||||
let refreshDuration = String(format: "%.3fs", Date().timeIntervalSince(refreshStartTime))
|
||||
|
||||
if success {
|
||||
#if DEBUG
|
||||
print("""
|
||||
✅ [Network] Token刷新成功
|
||||
⏱️ 耗时: \(refreshDuration)
|
||||
🔄 准备重试\(self.requestsToRetry.count)个请求...
|
||||
""")
|
||||
#endif
|
||||
|
||||
// 重试所有待处理的请求
|
||||
let requestsToRetry = self.requestsToRetry
|
||||
self.requestsToRetry.removeAll()
|
||||
|
||||
for (request, completion, requestId) in requestsToRetry {
|
||||
var newRequest = request
|
||||
if let token = KeychainHelper.getAccessToken() {
|
||||
newRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
let task = URLSession.shared.dataTask(with: newRequest) { data, response, error in
|
||||
if let data = data {
|
||||
completion(.success(data))
|
||||
} else if let error = error {
|
||||
completion(.failure(.networkError(error)))
|
||||
} else {
|
||||
completion(.failure(.noData))
|
||||
}
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
} else {
|
||||
#if DEBUG
|
||||
print("""
|
||||
❌ [Network] Token刷新失败
|
||||
⏱️ 耗时: \(refreshDuration)
|
||||
🚪 清除登录状态...
|
||||
""")
|
||||
#endif
|
||||
|
||||
// 清除token并通知需要重新登录
|
||||
TokenManager.shared.clearTokens()
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .userDidLogoutNotification, object: nil)
|
||||
}
|
||||
|
||||
// 所有待处理的请求都返回未授权错误
|
||||
self.requestsToRetry.forEach { _, completion, _ in
|
||||
completion(.failure(.unauthorized))
|
||||
}
|
||||
self.requestsToRetry.removeAll()
|
||||
}
|
||||
|
||||
self.isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 公共方法
|
||||
|
||||
/// GET 请求
|
||||
func get<T: Decodable>(
|
||||
path: String,
|
||||
parameters: [String: Any]? = nil,
|
||||
headers: [String: String]? = nil,
|
||||
completion: @escaping (Result<T, NetworkError>) -> Void
|
||||
) {
|
||||
request("GET", path: path, parameters: parameters, headers: headers, completion: completion)
|
||||
}
|
||||
|
||||
/// POST 请求
|
||||
func post<T: Decodable>(
|
||||
path: String,
|
||||
parameters: Any? = nil,
|
||||
headers: [String: String]? = nil,
|
||||
completion: @escaping (Result<T, NetworkError>) -> Void
|
||||
) {
|
||||
var params: Any?
|
||||
if let parameters = parameters {
|
||||
if let dict = parameters as? [String: Any] {
|
||||
params = dict
|
||||
} else if let array = parameters as? [Any] {
|
||||
params = array
|
||||
} else {
|
||||
print("❌ [Network] POST 请求参数类型不支持")
|
||||
completion(.failure(.invalidParameters))
|
||||
return
|
||||
}
|
||||
}
|
||||
request("POST", path: path, parameters: params, headers: headers, completion: completion)
|
||||
}
|
||||
|
||||
/// POST 请求(带Token)
|
||||
func postWithToken<T: Decodable>(
|
||||
path: String,
|
||||
parameters: Any? = nil,
|
||||
headers: [String: String]? = nil,
|
||||
completion: @escaping (Result<T, NetworkError>) -> Void
|
||||
) {
|
||||
var headers = headers ?? [:]
|
||||
if let token = KeychainHelper.getAccessToken() {
|
||||
headers["Authorization"] = "Bearer \(token)"
|
||||
}
|
||||
post(path: path, parameters: parameters, headers: headers, completion: completion)
|
||||
}
|
||||
|
||||
/// DELETE 请求
|
||||
func delete<T: Decodable>(
|
||||
path: String,
|
||||
parameters: [String: Any]? = nil,
|
||||
headers: [String: String]? = nil,
|
||||
completion: @escaping (Result<T, NetworkError>) -> Void
|
||||
) {
|
||||
var headers = headers ?? [:]
|
||||
if let token = KeychainHelper.getAccessToken() {
|
||||
headers["Authorization"] = "Bearer \(token)"
|
||||
}
|
||||
request("DELETE", path: path, parameters: parameters, headers: headers, completion: completion)
|
||||
}
|
||||
|
||||
/// PUT 请求
|
||||
func put<T: Decodable>(
|
||||
path: String,
|
||||
parameters: [String: Any]? = nil,
|
||||
headers: [String: String]? = nil,
|
||||
completion: @escaping (Result<T, NetworkError>) -> Void
|
||||
) {
|
||||
request("PUT", path: path, parameters: parameters, headers: headers, completion: completion)
|
||||
}
|
||||
}
|
||||
@ -1,2 +0,0 @@
|
||||
# Core/Network
|
||||
通用网络层与配置:`NetworkService.swift`、`APIConfig.swift`、ApiClient 公共代码。
|
||||
@ -1,9 +0,0 @@
|
||||
# Core
|
||||
|
||||
跨特性共享的核心能力:
|
||||
- DesignSystem(主题、字体、间距)
|
||||
- Navigation(路由/导航栈)
|
||||
- Diagnostics(性能与日志)
|
||||
- Network(网络与配置)
|
||||
|
||||
建议通过 Xcode Group 先完成组织,再视需要同步到磁盘。
|
||||
BIN
wake/CoreData/.DS_Store
vendored
@ -1,34 +0,0 @@
|
||||
//
|
||||
// ColorExtensions.swift
|
||||
// wake
|
||||
//
|
||||
// Created by fairclip on 2025/8/19.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Color Extension for Hex Colors
|
||||
extension Color {
|
||||
/// 通过十六进制字符串创建颜色
|
||||
/// - Parameter hex: 十六进制颜色字符串 (例如: "FF5733", "FFF8DE")
|
||||
init(hex: String) {
|
||||
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
||||
var int: UInt64 = 0
|
||||
Scanner(string: hex).scanHexInt64(&int)
|
||||
let a, r, g, b: UInt64
|
||||
switch hex.count {
|
||||
case 6: // RGB (24-bit)
|
||||
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
|
||||
default:
|
||||
(a, r, g, b) = (1, 1, 1, 0)
|
||||
}
|
||||
|
||||
self.init(
|
||||
.sRGB,
|
||||
red: Double(r) / 255,
|
||||
green: Double(g) / 255,
|
||||
blue: Double(b) / 255,
|
||||
opacity: Double(a) / 255
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,168 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Generate Blind Box Request Model
|
||||
// struct GenerateBlindBoxRequest: Codable {
|
||||
// let boxType: String
|
||||
// let materialIds: [String]
|
||||
|
||||
// enum CodingKeys: String, CodingKey {
|
||||
// case boxType = "box_type"
|
||||
// case materialIds = "material_ids"
|
||||
// }
|
||||
// }
|
||||
|
||||
// MARK: - Generate Blind Box Response Model
|
||||
struct GenerateBlindBoxResponse: Codable {
|
||||
let code: Int
|
||||
let data: BlindBoxData?
|
||||
}
|
||||
|
||||
// MARK: - Get Blind Box List Response Model
|
||||
struct BlindBoxListResponse: Codable {
|
||||
let code: Int
|
||||
let data: [BlindBoxData]
|
||||
}
|
||||
|
||||
// MARK: - Open Blind Box Response Model
|
||||
struct OpenBlindBoxResponse: Codable {
|
||||
let code: Int
|
||||
let data: BlindBoxData?
|
||||
}
|
||||
|
||||
// MARK: - Blind Box API Client
|
||||
class BlindBoxApi {
|
||||
static let shared = BlindBoxApi()
|
||||
|
||||
private init() {}
|
||||
|
||||
/// 生成盲盒
|
||||
/// - Parameters:
|
||||
/// - boxType: 盲盒类型 (如 "First")
|
||||
/// - materialIds: 素材ID数组
|
||||
/// - completion: 完成回调,返回盲盒数据或错误
|
||||
func generateBlindBox(
|
||||
boxType: String,
|
||||
materialIds: [String],
|
||||
completion: @escaping (Result<BlindBoxData?, Error>) -> Void
|
||||
) {
|
||||
// 将Codable结构体转换为字典
|
||||
let parameters: [String: Any] = [
|
||||
"box_type": boxType,
|
||||
"material_ids": materialIds
|
||||
]
|
||||
|
||||
NetworkService.shared.postWithToken(
|
||||
path: "/blind_box/generate",
|
||||
parameters: parameters,
|
||||
completion: { (result: Result<GenerateBlindBoxResponse, NetworkError>) in
|
||||
DispatchQueue.main.async {
|
||||
switch result {
|
||||
case .success(let response):
|
||||
if response.code == 0 {
|
||||
completion(.success(response.data))
|
||||
} else {
|
||||
completion(.failure(NetworkError.serverError("服务器返回错误码: \(response.code)")))
|
||||
}
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// 使用 async/await 生成盲盒
|
||||
/// - Parameters:
|
||||
/// - boxType: 盲盒类型 (如 "First")
|
||||
/// - materialIds: 素材ID数组
|
||||
/// - Returns: 盲盒数据
|
||||
@available(iOS 13.0, *)
|
||||
func generateBlindBox(boxType: String, materialIds: [String]) async throws -> BlindBoxData? {
|
||||
let parameters: [String: Any] = [
|
||||
"box_type": boxType,
|
||||
"material_ids": materialIds
|
||||
]
|
||||
let response: GenerateBlindBoxResponse = try await NetworkService.shared.postWithToken(
|
||||
path: "/blind_box/generate",
|
||||
parameters: parameters
|
||||
)
|
||||
if response.code == 0 {
|
||||
return response.data
|
||||
} else {
|
||||
throw NetworkError.serverError("服务器返回错误码: \(response.code)")
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取盲盒信息
|
||||
/// - Parameters:
|
||||
/// - boxId: 盲盒ID
|
||||
/// - completion: 完成回调,返回盲盒数据或错误
|
||||
func getBlindBox(
|
||||
boxId: String,
|
||||
completion: @escaping (Result<BlindBoxData?, Error>) -> Void
|
||||
) {
|
||||
let path = "/blind_box/query/\(boxId)"
|
||||
|
||||
NetworkService.shared.getWithToken(
|
||||
path: path,
|
||||
completion: { (result: Result<GenerateBlindBoxResponse, NetworkError>) in
|
||||
DispatchQueue.main.async {
|
||||
switch result {
|
||||
case .success(let response):
|
||||
if response.code == 0 {
|
||||
completion(.success(response.data))
|
||||
} else {
|
||||
completion(.failure(NetworkError.serverError("服务器返回错误码: \(response.code)")))
|
||||
}
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// 使用 async/await 获取盲盒信息
|
||||
/// - Parameter boxId: 盲盒ID
|
||||
/// - Returns: 盲盒数据
|
||||
@available(iOS 13.0, *)
|
||||
func getBlindBox(boxId: String) async throws -> BlindBoxData? {
|
||||
let path = "/blind_box/query/\(boxId)"
|
||||
let response: GenerateBlindBoxResponse = try await NetworkService.shared.getWithToken(path: path)
|
||||
|
||||
if response.code == 0 {
|
||||
return response.data
|
||||
} else {
|
||||
throw NetworkError.serverError("服务器返回错误码: \(response.code)")
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取盲盒列表
|
||||
@available(iOS 13.0, *)
|
||||
func getBlindBoxList() async throws -> [BlindBoxData]? {
|
||||
let response: BlindBoxListResponse = try await NetworkService.shared.getWithToken(path: "/blind_boxs/query")
|
||||
if response.code == 0 {
|
||||
return response.data
|
||||
} else {
|
||||
throw NetworkError.serverError("服务器返回错误码: \(response.code)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 将盲盒标记为开启状态
|
||||
/// - Parameter boxId: 盲盒ID
|
||||
/// - Returns: 开启后的盲盒数据(可能为null)
|
||||
@available(iOS 13.0, *)
|
||||
func openBlindBox(boxId: String) async throws {
|
||||
let response: OpenBlindBoxResponse = try await NetworkService.shared.postWithToken(
|
||||
path: "/blind_box/open",
|
||||
parameters: ["box_id": boxId]
|
||||
)
|
||||
if response.code == 0 {
|
||||
// API返回成功,data可能为null,这是正常的
|
||||
print("✅ 盲盒开启成功")
|
||||
} else {
|
||||
throw NetworkError.serverError("服务器返回错误码: \(response.code)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,65 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,2 +0,0 @@
|
||||
# Features/BlindBox/API
|
||||
盲盒相关 API 封装与轮询:`BlindBoxApi.swift`、`BlindBoxPolling.swift`。
|
||||
@ -1,20 +0,0 @@
|
||||
import SwiftUI
|
||||
import AVKit
|
||||
|
||||
// AVPlayer 容器,隐藏系统控制、透明背景
|
||||
struct AVPlayerController: UIViewControllerRepresentable {
|
||||
@Binding var player: AVPlayer?
|
||||
|
||||
func makeUIViewController(context: Context) -> AVPlayerViewController {
|
||||
let controller = AVPlayerViewController()
|
||||
controller.player = player
|
||||
controller.showsPlaybackControls = false
|
||||
controller.videoGravity = .resizeAspect
|
||||
controller.view.backgroundColor = .clear
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {
|
||||
uiViewController.player = player
|
||||
}
|
||||
}
|
||||
@ -1,77 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
|
||||
// MARK: - SwiftUI 背景重绘(方形版本)
|
||||
struct CardBlindBackground: View {
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let w = geo.size.width
|
||||
let h = geo.size.height
|
||||
ZStack {
|
||||
// 主背景卡片(方形)
|
||||
ScoopRoundedRect(cornerRadius: 20, scoopDepth: 20, scoopHalfWidth: 90, scoopCenterX: 0.5, convexDown: true, flatHalfWidth: 60)
|
||||
.fill(Theme.Colors.primary)
|
||||
.shadow(color: .black.opacity(0.08), radius: 12, y: 6)
|
||||
// .padding()
|
||||
|
||||
Rectangle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [Color(hex: "FFFFFF"), Color(hex: "FFEFB2")],
|
||||
startPoint: .topTrailing,
|
||||
endPoint: .bottomLeading
|
||||
)
|
||||
)
|
||||
.frame(width: w - 60 , height: h - 90)
|
||||
.cornerRadius(20)
|
||||
.padding(.top, Theme.Spacing.lg)
|
||||
// var view = UIView()
|
||||
// view.frame = CGRect(x: 0, y: 0, width: 320, height: 464)
|
||||
// let layer0 = CAGradientLayer()
|
||||
// layer0.colors = [
|
||||
// UIColor(red: 1, green: 1, blue: 1, alpha: 1).cgColor,
|
||||
// UIColor(red: 1, green: 0.937, blue: 0.698, alpha: 1).cgColor
|
||||
// ]
|
||||
// layer0.locations = [0, 1]
|
||||
// layer0.startPoint = CGPoint(x: 0.25, y: 0.5)
|
||||
// layer0.endPoint = CGPoint(x: 0.75, y: 0.5)
|
||||
// layer0.transform = CATransform3DMakeAffineTransform(CGAffineTransform(a: -1.13, b: 1.07, c: -1.08, d: -0.54, tx: 1.65, ty: 0.24))
|
||||
// layer0.bounds = view.bounds.insetBy(dx: -0.5*view.bounds.size.width, dy: -0.5*view.bounds.size.height)
|
||||
// layer0.position = view.center
|
||||
// view.layer.addSublayer(layer0)
|
||||
|
||||
// view.layer.cornerRadius = 18
|
||||
|
||||
// 左上光斑
|
||||
// Circle()
|
||||
// .fill(Color.themePrimary.opacity(0.18))
|
||||
// .blur(radius: 40)
|
||||
// .frame(width: min(w, h) * 0.35, height: min(w, h) * 0.35)
|
||||
// .position(x: w * 0.25, y: h * 0.25)
|
||||
|
||||
// 右下光斑
|
||||
// Circle()
|
||||
// .fill(Color.orange.opacity(0.14))
|
||||
// .blur(radius: 50)
|
||||
// .frame(width: min(w, h) * 0.40, height: min(w, h) * 0.40)
|
||||
// .position(x: w * 0.75, y: h * 0.75)
|
||||
|
||||
// 中央高光描边
|
||||
// RoundedRectangle(cornerRadius: 28)
|
||||
// .stroke(Color.white.opacity(0.35), lineWidth: 1)
|
||||
// .frame(width: w * 0.88, height: h * 0.88)
|
||||
// .position(x: w / 2, y: h / 2)
|
||||
// .blendMode(.overlay)
|
||||
// .opacity(0.7)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 预览
|
||||
struct CardBlindBackground_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
CardBlindBackground()
|
||||
.frame(width: 400, height: 600)
|
||||
}
|
||||
}
|
||||
@ -1,62 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct BlindBoxActionButton: View {
|
||||
let phase: BlindBoxAnimationPhase
|
||||
let countdownText: String
|
||||
let onOpen: () -> Void
|
||||
let onGoToBuy: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: {
|
||||
switch phase {
|
||||
case .ready:
|
||||
onOpen()
|
||||
case .none:
|
||||
onGoToBuy()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}) {
|
||||
Group {
|
||||
switch phase {
|
||||
case .loading:
|
||||
Text("Next: \(countdownText)")
|
||||
.font(Typography.font(for: .body))
|
||||
.fontWeight(.bold)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.white)
|
||||
.foregroundColor(.black)
|
||||
.cornerRadius(32)
|
||||
case .ready:
|
||||
Text("Ready")
|
||||
.font(Typography.font(for: .body))
|
||||
.fontWeight(.bold)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.themePrimary)
|
||||
.foregroundColor(Color.themeTextMessageMain)
|
||||
.cornerRadius(32)
|
||||
case .opening:
|
||||
Text("Ready")
|
||||
.font(Typography.font(for: .body))
|
||||
.fontWeight(.bold)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.themePrimary)
|
||||
.foregroundColor(Color.themeTextMessageMain)
|
||||
.cornerRadius(32)
|
||||
default:
|
||||
Text("Go to Buy")
|
||||
.font(Typography.font(for: .body))
|
||||
.fontWeight(.bold)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.themePrimary)
|
||||
.foregroundColor(Color.themeTextMessageMain)
|
||||
.cornerRadius(32)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
// 盲盒动画阶段
|
||||
enum BlindBoxAnimationPhase {
|
||||
case loading
|
||||
case ready
|
||||
case opening
|
||||
case none
|
||||
}
|
||||
@ -1,39 +0,0 @@
|
||||
import SwiftUI
|
||||
import Lottie
|
||||
|
||||
/// 统一管理盲盒开启动画 4 状态的组件:loading / ready / opening / none
|
||||
struct BlindBoxAnimationView: View {
|
||||
@Binding var phase: BlindBoxAnimationPhase
|
||||
let onTapReady: () -> Void
|
||||
let onOpeningCompleted: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
switch phase {
|
||||
case .loading:
|
||||
LottieView(name: "loading", isPlaying: true)
|
||||
case .ready:
|
||||
ZStack {
|
||||
LottieView(name: "ready", isPlaying: true)
|
||||
|
||||
Color.clear
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
onTapReady()
|
||||
}
|
||||
}
|
||||
case .opening:
|
||||
BlindBoxLottieOnceView(name: "opening") {
|
||||
onOpeningCompleted()
|
||||
}
|
||||
case .none:
|
||||
Image("Empty")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.padding(40)
|
||||
}
|
||||
}
|
||||
.frame(width: 300, height: 300)
|
||||
.animation(.easeInOut(duration: 0.5), value: phase)
|
||||
}
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct BlindBoxDescriptionView: View {
|
||||
let name: String
|
||||
let description: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(name)
|
||||
.font(Typography.font(for: .body, family: .quicksandBold))
|
||||
.foregroundColor(Color.themeTextMessageMain)
|
||||
Text(description)
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(Color.themeTextMessageMain)
|
||||
}
|
||||
.frame(width: UIScreen.main.bounds.width * 0.70, alignment: .leading)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
@ -1,37 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct BlindBoxHeaderBar: View {
|
||||
let onMenuTap: () -> Void
|
||||
let remainPoints: Int
|
||||
@Binding var showLogin: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Button(action: onMenuTap) {
|
||||
Image(systemName: "line.3.horizontal")
|
||||
.font(.system(size: 20, weight: .regular))
|
||||
.foregroundColor(.primary)
|
||||
.padding(13)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
Spacer()
|
||||
NavigationLink(destination: SubscribeView()) {
|
||||
Text("\(remainPoints)")
|
||||
.font(Typography.font(for: .subtitle))
|
||||
.fontWeight(.bold)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.black)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(16)
|
||||
}
|
||||
.padding(.trailing)
|
||||
.fullScreenCover(isPresented: $showLogin) {
|
||||
LoginView()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 20)
|
||||
}
|
||||
}
|
||||
@ -1,84 +0,0 @@
|
||||
import SwiftUI
|
||||
import Lottie
|
||||
|
||||
/// 仅播放一次并在完成时回调的 Lottie 视图
|
||||
struct BlindBoxLottieOnceView: UIViewRepresentable {
|
||||
let name: String
|
||||
var animationSpeed: CGFloat = 1.0
|
||||
let onCompleted: () -> Void
|
||||
|
||||
func makeUIView(context: Context) -> UIView {
|
||||
// 与通用 LottieView 一致:用容器承载并通过约束填充,避免尺寸偏差
|
||||
let container = UIView()
|
||||
container.clipsToBounds = true
|
||||
|
||||
let animationView = LottieAnimationView()
|
||||
animationView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
if let animation = LottieAnimation.named(name) {
|
||||
animationView.animation = animation
|
||||
} else if let path = Bundle.main.path(forResource: name, ofType: "json") {
|
||||
let animation = LottieAnimation.filepath(path)
|
||||
animationView.animation = animation
|
||||
}
|
||||
|
||||
animationView.loopMode = .playOnce
|
||||
animationView.animationSpeed = animationSpeed
|
||||
animationView.contentMode = .scaleAspectFit
|
||||
animationView.backgroundBehavior = .pauseAndRestore
|
||||
|
||||
container.addSubview(animationView)
|
||||
NSLayoutConstraint.activate([
|
||||
animationView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
||||
animationView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||
animationView.topAnchor.constraint(equalTo: container.topAnchor),
|
||||
animationView.bottomAnchor.constraint(equalTo: container.bottomAnchor)
|
||||
])
|
||||
|
||||
// 开始播放一次并在完成后回调
|
||||
animationView.play { _ in
|
||||
onCompleted()
|
||||
}
|
||||
|
||||
return container
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIView, context: Context) {
|
||||
// 单次播放,不需要在更新时重复触发
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#if DEBUG
|
||||
struct BlindBoxLottieOnceView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
VStack(spacing: 16) {
|
||||
Text("Opening • x1.0")
|
||||
.font(.caption)
|
||||
.foregroundColor(.gray)
|
||||
BlindBoxLottieOnceView(name: "opening", animationSpeed: 1.0) {
|
||||
print("Opening completed")
|
||||
}
|
||||
.frame(width: 300, height: 300)
|
||||
.background(Color.themeTextWhiteSecondary)
|
||||
}
|
||||
.previewDisplayName("Opening • 1.0x")
|
||||
|
||||
VStack(spacing: 16) {
|
||||
Text("Opening • x1.5")
|
||||
.font(.caption)
|
||||
.foregroundColor(.gray)
|
||||
BlindBoxLottieOnceView(name: "opening", animationSpeed: 1.5) {
|
||||
print("Opening completed (1.5x)")
|
||||
}
|
||||
.frame(width: 300, height: 300)
|
||||
.background(Color.themeTextWhiteSecondary)
|
||||
}
|
||||
.previewDisplayName("Opening • 1.5x")
|
||||
}
|
||||
.padding()
|
||||
.background(Color.themeTextWhiteSecondary)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@ -1,98 +0,0 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import AVKit
|
||||
|
||||
// 展示媒体的扩散缩放覆盖层(视频/图片)
|
||||
struct BlindBoxMediaOverlay: View {
|
||||
let mediaType: BlindBoxMediaType
|
||||
@Binding var player: AVPlayer?
|
||||
let displayImage: UIImage?
|
||||
let isPortrait: Bool
|
||||
let aspectRatio: CGFloat
|
||||
@Binding var scale: CGFloat
|
||||
let onBack: () -> Void
|
||||
|
||||
@State private var showControls: Bool = false
|
||||
|
||||
private var scaledWidth: CGFloat {
|
||||
if isPortrait {
|
||||
return UIScreen.main.bounds.height * scale * 1 / aspectRatio
|
||||
} else {
|
||||
return UIScreen.main.bounds.width * scale
|
||||
}
|
||||
}
|
||||
|
||||
private var scaledHeight: CGFloat {
|
||||
if isPortrait {
|
||||
return UIScreen.main.bounds.height * scale
|
||||
} else {
|
||||
return UIScreen.main.bounds.width * scale * 1 / aspectRatio
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
VisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialLight))
|
||||
.opacity(0.3)
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
|
||||
Group {
|
||||
if mediaType == .all, player != nil {
|
||||
AVPlayerController(player: $player)
|
||||
.frame(width: scaledWidth, height: scaledHeight)
|
||||
.opacity(scale == 1 ? 1 : 0.7)
|
||||
.onAppear { player?.play() }
|
||||
} else if mediaType == .image, let image = displayImage {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: scaledWidth, height: scaledHeight)
|
||||
.opacity(scale == 1 ? 1 : 0.7)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
withAnimation(.easeInOut(duration: 0.1)) {
|
||||
showControls.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
if showControls {
|
||||
VStack {
|
||||
HStack {
|
||||
Button(action: onBack) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 24))
|
||||
.foregroundColor(.black)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(.top, 50)
|
||||
.padding(.leading, 20)
|
||||
.zIndex(1000)
|
||||
.transition(.opacity)
|
||||
.onAppear {
|
||||
// 2秒后显示按钮(首显时)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
showControls = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.animation(.easeInOut(duration: 1.0), value: scale)
|
||||
.ignoresSafeArea()
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||
withAnimation(.spring(response: 2.5, dampingFraction: 0.6, blendDuration: 1.0)) {
|
||||
self.scale = 1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct BlindBoxTitleView: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Hi! Click And")
|
||||
Text("Open Your Box~")
|
||||
}
|
||||
.font(Typography.font(for: .smallLargeTitle))
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(Color.themeTextMessageMain)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, Theme.Spacing.xl)
|
||||
}
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
// 盲盒数量徽标
|
||||
struct BlindCountBadge: View {
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
Text(text)
|
||||
.font(Typography.font(for: .body, family: .quicksandBold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color.black)
|
||||
.shadow(color: Color.black.opacity(0.15), radius: 4, x: 0, y: 2)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,2 +0,0 @@
|
||||
# Features/BlindBox/Components
|
||||
盲盒专属子组件与片段视图。
|
||||
@ -1,30 +0,0 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
// 通用毛玻璃效果视图(弱化强度)
|
||||
struct VisualEffectView: UIViewRepresentable {
|
||||
var effect: UIVisualEffect?
|
||||
|
||||
func makeUIView(context: Context) -> UIVisualEffectView {
|
||||
let view = UIVisualEffectView(effect: nil)
|
||||
|
||||
let blurEffect = UIBlurEffect(style: .systemUltraThinMaterialLight)
|
||||
let blurView = UIVisualEffectView(effect: blurEffect)
|
||||
blurView.alpha = 0.3
|
||||
|
||||
let backgroundView = UIView()
|
||||
backgroundView.backgroundColor = UIColor.white.withAlphaComponent(0.1)
|
||||
backgroundView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
|
||||
view.contentView.addSubview(backgroundView)
|
||||
view.contentView.addSubview(blurView)
|
||||
blurView.frame = view.bounds
|
||||
blurView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIVisualEffectView, context: Context) {
|
||||
// 无需动态更新
|
||||
}
|
||||
}
|
||||
@ -1,174 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Blind Box Media Type
|
||||
enum BlindBoxMediaType {
|
||||
case video
|
||||
case image
|
||||
case all
|
||||
}
|
||||
|
||||
// MARK: - Blind Box List
|
||||
struct BlindList: Codable, Identifiable {
|
||||
// API 返回为字符串,这里按字符串处理
|
||||
let id: String
|
||||
let boxCode: String
|
||||
let userId: String
|
||||
let name: String
|
||||
let boxType: String
|
||||
let features: String?
|
||||
let resultFile: FileInfo?
|
||||
let status: String
|
||||
let workflowInstanceId: String?
|
||||
let videoGenerateTime: String?
|
||||
let createTime: String
|
||||
let coverFile: FileInfo?
|
||||
let description: String?
|
||||
|
||||
struct FileInfo: Codable {
|
||||
let id: String
|
||||
let fileName: String?
|
||||
let url: String?
|
||||
// 为了兼容任意元数据结构,这里使用字典的最宽松版本
|
||||
// 如果后续需要更强类型,可以引入自定义的 AnyCodable/JSONValue
|
||||
let metadata: [String: String]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case fileName = "file_name"
|
||||
case url
|
||||
case metadata
|
||||
}
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case boxCode = "box_code"
|
||||
case userId = "user_id"
|
||||
case name
|
||||
case boxType = "box_type"
|
||||
case features
|
||||
case resultFile = "result_file"
|
||||
case status
|
||||
case workflowInstanceId = "workflow_instance_id"
|
||||
case videoGenerateTime = "video_generate_time"
|
||||
case createTime = "create_time"
|
||||
case coverFile = "cover_file"
|
||||
case description
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Blind Box Count
|
||||
struct BlindCount: Codable {
|
||||
let availableQuantity: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case availableQuantity = "available_quantity"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Blind Box Data
|
||||
struct BlindBoxData: Codable {
|
||||
let id: String
|
||||
let boxCode: String
|
||||
let userId: String
|
||||
let name: String
|
||||
let boxType: String
|
||||
let features: String?
|
||||
let resultFile: FileInfo?
|
||||
let status: String
|
||||
let workflowInstanceId: String?
|
||||
let videoGenerateTime: String?
|
||||
let createTime: String
|
||||
let coverFile: FileInfo?
|
||||
let description: String
|
||||
|
||||
// 添加计算属性以获取Int64值
|
||||
var idValue: Int64 { Int64(id) ?? 0 }
|
||||
var userIdValue: Int64 { Int64(userId) ?? 0 }
|
||||
|
||||
struct FileInfo: Codable {
|
||||
let id: String
|
||||
let fileName: String?
|
||||
let url: String?
|
||||
let metadata: [String: String]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case fileName = "file_name"
|
||||
case url
|
||||
case metadata
|
||||
}
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case boxCode = "box_code"
|
||||
case userId = "user_id"
|
||||
case name
|
||||
case boxType = "box_type"
|
||||
case features
|
||||
case resultFile = "result_file"
|
||||
case status
|
||||
case workflowInstanceId = "workflow_instance_id"
|
||||
case videoGenerateTime = "video_generate_time"
|
||||
case createTime = "create_time"
|
||||
case coverFile = "cover_file"
|
||||
case description
|
||||
}
|
||||
|
||||
init(id: String, boxCode: String, userId: String, name: String, boxType: String, features: String?, resultFile: FileInfo?, status: String, workflowInstanceId: String?, videoGenerateTime: String?, createTime: String, coverFile: FileInfo?, description: String) {
|
||||
self.id = id
|
||||
self.boxCode = boxCode
|
||||
self.userId = userId
|
||||
self.name = name
|
||||
self.boxType = boxType
|
||||
self.features = features
|
||||
self.resultFile = resultFile
|
||||
self.status = status
|
||||
self.workflowInstanceId = workflowInstanceId
|
||||
self.videoGenerateTime = videoGenerateTime
|
||||
self.createTime = createTime
|
||||
self.coverFile = coverFile
|
||||
self.description = description
|
||||
}
|
||||
|
||||
init(from listItem: BlindList) {
|
||||
self.id = listItem.id
|
||||
self.boxCode = listItem.boxCode
|
||||
self.userId = listItem.userId
|
||||
self.name = listItem.name
|
||||
self.boxType = listItem.boxType
|
||||
self.features = listItem.features
|
||||
|
||||
// 转换FileInfo类型
|
||||
if let resultFileInfo = listItem.resultFile {
|
||||
self.resultFile = FileInfo(
|
||||
id: resultFileInfo.id,
|
||||
fileName: resultFileInfo.fileName,
|
||||
url: resultFileInfo.url,
|
||||
metadata: resultFileInfo.metadata
|
||||
)
|
||||
} else {
|
||||
self.resultFile = nil
|
||||
}
|
||||
|
||||
self.status = listItem.status
|
||||
self.workflowInstanceId = listItem.workflowInstanceId
|
||||
self.videoGenerateTime = listItem.videoGenerateTime
|
||||
self.createTime = listItem.createTime
|
||||
|
||||
// 转换coverFile的FileInfo类型
|
||||
if let coverFileInfo = listItem.coverFile {
|
||||
self.coverFile = FileInfo(
|
||||
id: coverFileInfo.id,
|
||||
fileName: coverFileInfo.fileName,
|
||||
url: coverFileInfo.url,
|
||||
metadata: coverFileInfo.metadata
|
||||
)
|
||||
} else {
|
||||
self.coverFile = nil
|
||||
}
|
||||
|
||||
self.description = listItem.description ?? ""
|
||||
}
|
||||
}
|
||||
@ -1,2 +0,0 @@
|
||||
# Features/BlindBox/Models
|
||||
盲盒业务模型:`BlindModels.swift` 等。
|
||||
@ -1,2 +0,0 @@
|
||||
# Features/BlindBox
|
||||
盲盒业务代码:View / ViewModel / API / Models / Components。
|
||||
@ -1,377 +0,0 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import AVKit
|
||||
import Foundation
|
||||
|
||||
// 添加通知名称
|
||||
extension Notification.Name {
|
||||
static let blindBoxStatusChanged = Notification.Name("blindBoxStatusChanged")
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let navigateToMediaViewer = Notification.Name("navigateToMediaViewer")
|
||||
}
|
||||
|
||||
// MARK: - 主视图
|
||||
struct BlindBoxView: View {
|
||||
let mediaType: BlindBoxMediaType
|
||||
let currentBoxId: String?
|
||||
@StateObject private var viewModel: BlindBoxViewModel
|
||||
@State private var showModal = false // 控制用户资料弹窗显示
|
||||
@State private var showSettings = false // 控制设置页面显示
|
||||
@State private var showLogin = false
|
||||
// 倒计时由 ViewModel 管理(countdownText)
|
||||
@State private var animationPhase: BlindBoxAnimationPhase = .none
|
||||
// 防止开箱二次点击
|
||||
@State private var isOpening: Bool = false
|
||||
|
||||
// 查询数据 - 简单查询
|
||||
@Query private var login: [Login]
|
||||
|
||||
init(mediaType: BlindBoxMediaType, blindBoxId: String? = nil) {
|
||||
self.mediaType = mediaType
|
||||
self.currentBoxId = blindBoxId
|
||||
_viewModel = StateObject(wrappedValue: BlindBoxViewModel(mediaType: mediaType, currentBoxId: blindBoxId))
|
||||
}
|
||||
|
||||
// 计算尺寸逻辑已迁移至 BlindBoxMediaOverlay 组件(已不再使用)
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.themeTextWhiteSecondary.ignoresSafeArea()
|
||||
.onAppear {
|
||||
Perf.event("BlindBox_Appear")
|
||||
print("🎯 BlindBoxView appeared with mediaType: \(mediaType)")
|
||||
print("🎯 Current thread: \(Thread.current)")
|
||||
|
||||
// 调用接口
|
||||
Task {
|
||||
await viewModel.load()
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
viewModel.stopPolling()
|
||||
viewModel.stopCountdown()
|
||||
|
||||
// Clean up video player
|
||||
viewModel.player?.pause()
|
||||
viewModel.player?.replaceCurrentItem(with: nil)
|
||||
viewModel.player = nil
|
||||
// 重置防连点状态
|
||||
isOpening = false
|
||||
|
||||
NotificationCenter.default.removeObserver(
|
||||
self,
|
||||
name: .blindBoxStatusChanged,
|
||||
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 && self.animationPhase != .opening {
|
||||
withAnimation { self.animationPhase = .ready }
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.imageURL) { _, url in
|
||||
if !url.isEmpty && self.animationPhase != .opening {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 原 overlay 分支已移除,直接展示内容
|
||||
// Original content
|
||||
VStack {
|
||||
VStack(spacing: 20) {
|
||||
if mediaType == .all {
|
||||
BlindBoxHeaderBar(
|
||||
onMenuTap: showUserProfile,
|
||||
remainPoints: viewModel.memberProfile?.remainPoints ?? 0,
|
||||
showLogin: $showLogin
|
||||
)
|
||||
}
|
||||
|
||||
// 标题
|
||||
BlindBoxTitleView()
|
||||
.opacity(animationPhase == .opening ? 0 : 1)
|
||||
|
||||
// 盲盒
|
||||
ZStack {
|
||||
// 1. 背景Card
|
||||
CardBlindBackground()
|
||||
if mediaType == .all {
|
||||
BlindCountBadge(text: "\(viewModel.blindCount?.availableQuantity ?? 0) Boxes")
|
||||
.position(x: UIScreen.main.bounds.width * 0.7,
|
||||
y: UIScreen.main.bounds.height * 0.1)
|
||||
}
|
||||
VStack(spacing: 20) {
|
||||
BlindBoxAnimationView(
|
||||
phase: $animationPhase,
|
||||
onTapReady: {
|
||||
openBlindBoxAndUpdateState()
|
||||
},
|
||||
onOpeningCompleted: {
|
||||
navigateToOutcome()
|
||||
}
|
||||
)
|
||||
}
|
||||
.compositingGroup()
|
||||
.padding()
|
||||
// 非 opening 阶段显示文字
|
||||
if animationPhase == .ready {
|
||||
BlindBoxDescriptionView(
|
||||
name: viewModel.blindGenerate?.name ?? "Some box",
|
||||
description: viewModel.blindGenerate?.description ?? ""
|
||||
)
|
||||
.offset(x: 0, y: UIScreen.main.bounds.height * 0.2)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
maxHeight: UIScreen.main.bounds.height * 0.65
|
||||
)
|
||||
// 确保开启动画层级更高
|
||||
.zIndex(animationPhase == .opening ? 1 : 0)
|
||||
|
||||
|
||||
// 打开 TODO 引导时,也要有按钮
|
||||
if mediaType == .all, viewModel.didBootstrap {
|
||||
BlindBoxActionButton(
|
||||
phase: animationPhase,
|
||||
countdownText: viewModel.countdownText,
|
||||
onOpen: {
|
||||
// 防连点:若已在处理则忽略
|
||||
guard !isOpening else { return }
|
||||
isOpening = true
|
||||
// 先播放开箱动画,动画结束后再在 onOpeningCompleted 内导航
|
||||
openBlindBoxAndUpdateState(navigateAfterOpen: false)
|
||||
},
|
||||
onGoToBuy: {
|
||||
Router.shared.navigate(to: .mediaUpload)
|
||||
}
|
||||
)
|
||||
.disabled(isOpening)
|
||||
// 开启动画时隐藏按钮,避免覆盖在动画之上
|
||||
.opacity(animationPhase == .opening ? 0 : 1)
|
||||
// 可见性切换时进行轻微淡入淡出
|
||||
.animation(.easeInOut(duration: 0.2), value: animationPhase)
|
||||
// 开启动画时完全屏蔽交互
|
||||
.allowsHitTesting(animationPhase != .opening)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.themeTextWhiteSecondary)
|
||||
.offset(x: showModal ? UIScreen.main.bounds.width * 0.8 : 0)
|
||||
.animation(.spring(response: 0.6, dampingFraction: 0.8), value: showModal)
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
}
|
||||
|
||||
// 用户资料弹窗
|
||||
SlideInModal(
|
||||
isPresented: $showModal,
|
||||
onDismiss: hideUserProfile
|
||||
) {
|
||||
UserProfileModal(
|
||||
showModal: $showModal,
|
||||
showSettings: $showSettings,
|
||||
isMember: .init(get: { viewModel.isMember }, set: { viewModel.isMember = $0 }),
|
||||
memberDate: .init(get: { viewModel.memberDate }, set: { viewModel.memberDate = $0 })
|
||||
)
|
||||
}
|
||||
.offset(x: showSettings ? UIScreen.main.bounds.width : 0)
|
||||
.animation(.spring(response: 0.6, dampingFraction: 0.8), value: showSettings)
|
||||
|
||||
// 设置页面遮罩层
|
||||
ZStack {
|
||||
if showSettings {
|
||||
Color.black.opacity(0.3)
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
.onTapGesture(perform: hideSettings)
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
if showSettings {
|
||||
SettingsView(isPresented: $showSettings)
|
||||
.transition(.move(edge: .leading))
|
||||
.zIndex(1)
|
||||
}
|
||||
}
|
||||
.animation(.spring(response: 0.6, dampingFraction: 0.8), value: showSettings)
|
||||
}
|
||||
.navigationBarBackButtonHidden(true)
|
||||
}
|
||||
/// 显示用户资料弹窗
|
||||
private func showUserProfile() {
|
||||
withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
|
||||
// print("登录记录数量: \(login.count)")
|
||||
// for (index, item) in login.enumerated() {
|
||||
// print("记录 \(index + 1): 邮箱=\(item.email), 姓名=\(item.name)")
|
||||
// }
|
||||
print("当前登录记录:")
|
||||
for (index, item) in login.enumerated() {
|
||||
print("记录 \(index + 1): 邮箱=\(item.email), 姓名=\(item.name)")
|
||||
}
|
||||
showModal.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
/// 隐藏用户资料弹窗
|
||||
private func hideUserProfile() {
|
||||
withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
|
||||
showModal = false
|
||||
}
|
||||
}
|
||||
|
||||
/// 隐藏设置页面
|
||||
private func hideSettings() {
|
||||
withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
|
||||
showSettings = false
|
||||
}
|
||||
}
|
||||
|
||||
/// 开启盲盒并更新状态;可选地在开启后直接导航到结果页
|
||||
private func openBlindBoxAndUpdateState(navigateAfterOpen: Bool = false) {
|
||||
Perf.event("BlindBox_Open_Tapped")
|
||||
print("点击了盲盒")
|
||||
let boxIdToOpen = self.currentBoxId ?? self.viewModel.blindGenerate?.id
|
||||
if let boxId = boxIdToOpen {
|
||||
Task {
|
||||
do {
|
||||
try await viewModel.openBlindBox(for: boxId)
|
||||
print("✅ 盲盒开启成功")
|
||||
await viewModel.startPolling()
|
||||
withAnimation {
|
||||
animationPhase = .opening
|
||||
}
|
||||
if navigateAfterOpen {
|
||||
navigateToOutcome()
|
||||
}
|
||||
} catch {
|
||||
print("❌ 开启盲盒失败: \(error)")
|
||||
// 失败时允许再次点击
|
||||
isOpening = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 开启动画播放完成后,准备媒体并跳转到结果页
|
||||
private func navigateToOutcome() {
|
||||
Perf.event("BlindBox_Opening_Completed")
|
||||
Task { @MainActor in
|
||||
let interval: UInt64 = 300_000_000 // 300ms
|
||||
let timeout: UInt64 = 6_000_000_000 // 6s
|
||||
var waited: UInt64 = 0
|
||||
|
||||
if mediaType == .all {
|
||||
// 等待视频 URL 就绪
|
||||
while viewModel.videoURL.isEmpty && waited < timeout {
|
||||
try? await Task.sleep(nanoseconds: interval)
|
||||
waited += interval
|
||||
}
|
||||
// 拿到 URL 即可跳转;不强依赖 player 准备
|
||||
if !viewModel.videoURL.isEmpty, let url = URL(string: viewModel.videoURL) {
|
||||
Router.shared.navigate(
|
||||
to: .blindOutcome(
|
||||
media: .video(url, nil),
|
||||
title: viewModel.blindGenerate?.name ?? "Your box",
|
||||
description: viewModel.blindGenerate?.description ?? "",
|
||||
isMember: viewModel.isMember,
|
||||
goToFeedback: false
|
||||
)
|
||||
)
|
||||
// 导航后立即重置状态,确保返回时按钮可用
|
||||
isOpening = false
|
||||
return
|
||||
}
|
||||
} else if mediaType == .image {
|
||||
// 等到有 imageURL 后再加载 UIImage
|
||||
while viewModel.imageURL.isEmpty && waited < timeout {
|
||||
try? await Task.sleep(nanoseconds: interval)
|
||||
waited += interval
|
||||
}
|
||||
if viewModel.displayImage == nil && !viewModel.imageURL.isEmpty {
|
||||
await viewModel.prepareMedia()
|
||||
}
|
||||
if let image = viewModel.displayImage {
|
||||
Router.shared.navigate(
|
||||
to: .blindOutcome(
|
||||
media: .image(image),
|
||||
title: viewModel.blindGenerate?.name ?? "Your box",
|
||||
description: viewModel.blindGenerate?.description ?? "",
|
||||
isMember: viewModel.isMember,
|
||||
goToFeedback: true
|
||||
)
|
||||
)
|
||||
// 导航后立即重置状态,确保返回时按钮可用
|
||||
isOpening = false
|
||||
return
|
||||
}
|
||||
}
|
||||
// 若仍未获取到媒体,记录日志以便排查
|
||||
print("⚠️ navigateToOutcome: 媒体尚未准备好,videoURL=\(viewModel.videoURL), image=\(String(describing: viewModel.displayImage))")
|
||||
// 如果因为媒体未就绪而导航失败,也应解锁按钮
|
||||
isOpening = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 预览
|
||||
#Preview {
|
||||
BlindBoxView(mediaType: .all)
|
||||
.onAppear {
|
||||
// 仅在Preview中设置模拟令牌(不要在生产代码中使用)
|
||||
#if DEBUG
|
||||
if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" {
|
||||
// 设置模拟令牌用于Preview
|
||||
let previewToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSJ9.eyJqdGkiOjczNzAwMTY5NzMzODE1NzA1NjAsImlkZW50aXR5IjoiNzM1MDQzOTY2MzExNjYxOTc3NyIsImV4cCI6MTc1Nzc1Mzc3NH0.tZ8p5sW4KX6HFoJpJN0e4VmJOAGhTrYD2yTwQwilKpufzqOAfXX4vpGYBurgBIcHj2KmXKX2PQMOeeAtvAypDA"
|
||||
let _ = KeychainHelper.saveAccessToken(previewToken)
|
||||
print("🔑 Preview token set for testing")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// 预览第一个盲盒
|
||||
#Preview("First Blind Box") {
|
||||
BlindBoxView(mediaType: .image, blindBoxId: "7370140297747107840")
|
||||
.onAppear {
|
||||
// 仅在Preview中设置模拟令牌(不要在生产代码中使用)
|
||||
#if DEBUG
|
||||
if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" {
|
||||
// 设置模拟令牌用于Preview
|
||||
let previewToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSJ9.eyJqdGkiOjczNzAwMTY5NzMzODE1NzA1NjAsImlkZW50aXR5IjoiNzM1MDQzOTY2MzExNjYxOTc3NyIsImV4cCI6MTc1Nzc1Mzc3NH0.tZ8p5sW4KX6HFoJpJN0e4VmJOAGhTrYD2yTwQwilKpufzqOAfXX4vpGYBurgBIcHj2KmXKX2PQMOeeAtvAypDA"
|
||||
let _ = KeychainHelper.saveAccessToken(previewToken)
|
||||
print("🔑 Preview token set for testing")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@ -1,195 +0,0 @@
|
||||
import SwiftUI
|
||||
import os.log
|
||||
|
||||
struct BlindOutcomeView: View {
|
||||
let media: MediaType
|
||||
let title: String?
|
||||
let description: String?
|
||||
let isMember: Bool
|
||||
let onContinue: () -> Void
|
||||
let showJoinModal: Bool
|
||||
|
||||
// Removed presentationMode; use Router.shared.pop() for back navigation
|
||||
@State private var showIPListModal = false
|
||||
|
||||
init(media: MediaType, title: String? = nil, description: String? = nil, isMember: Bool = false, onContinue: @escaping () -> Void, showJoinModal: Bool = false) {
|
||||
self.media = media
|
||||
self.title = title
|
||||
self.description = description
|
||||
self.isMember = isMember
|
||||
self.onContinue = onContinue
|
||||
self.showJoinModal = showJoinModal
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.themeTextWhiteSecondary.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// 通用导航栏
|
||||
// NaviHeader(
|
||||
// title: "Blind Box",
|
||||
// onBackTap: { Router.shared.pop() },
|
||||
// showBackButton: true,
|
||||
// titleStyle: .title,
|
||||
// backgroundColor: Color.themeTextWhiteSecondary
|
||||
// )
|
||||
// .zIndex(1)
|
||||
Spacer()
|
||||
.frame(height: Theme.Spacing.lg)
|
||||
// Media content
|
||||
GeometryReader { geometry in
|
||||
VStack(spacing: 16) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color.white)
|
||||
.shadow(color: Color.black.opacity(0.1), radius: 8, x: 0, y: 2)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
switch media {
|
||||
case .image(let uiImage):
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.cornerRadius(10)
|
||||
.padding(4)
|
||||
// 图片不启用全屏切换
|
||||
|
||||
case .video(let url, _):
|
||||
WakeVideoPlayer(
|
||||
url: url,
|
||||
autoPlay: true,
|
||||
isLooping: true,
|
||||
showsControls: true,
|
||||
allowFullscreen: true,
|
||||
muteInitially: false,
|
||||
videoGravity: .resizeAspect
|
||||
)
|
||||
.frame(width: UIScreen.main.bounds.width - 40)
|
||||
.background(Color.clear)
|
||||
.cornerRadius(10)
|
||||
.clipped()
|
||||
}
|
||||
|
||||
if let description = description, !description.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
// Text("Description")
|
||||
// .font(Typography.font(for: .body, family: .quicksandBold))
|
||||
// .foregroundColor(.themeTextMessageMain)
|
||||
Text(description)
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(Color.themeTextMessageMain)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(Theme.Spacing.lg)
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Button at bottom
|
||||
VStack {
|
||||
Spacer()
|
||||
Button(action: {
|
||||
if showJoinModal {
|
||||
withAnimation {
|
||||
showIPListModal = true
|
||||
}
|
||||
} else {
|
||||
onContinue()
|
||||
}
|
||||
}) {
|
||||
Text("Continue")
|
||||
.font(.headline)
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.themePrimary)
|
||||
.cornerRadius(26)
|
||||
}
|
||||
// 弹窗显示时,按钮淡出且不可交互
|
||||
.opacity(showIPListModal ? 0 : 1)
|
||||
.animation(.easeInOut(duration: 0.2), value: showIPListModal)
|
||||
.allowsHitTesting(!showIPListModal)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
|
||||
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.overlay(
|
||||
JoinModal(isPresented: $showIPListModal, onClose: { onContinue() })
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
#if DEBUG
|
||||
// MARK: - Previews
|
||||
struct BlindOutcomeView_Previews: PreviewProvider {
|
||||
private static func coloredImage(_ color: UIColor, size: CGSize = CGSize(width: 300, height: 300)) -> UIImage {
|
||||
let format = UIGraphicsImageRendererFormat()
|
||||
format.scale = 2
|
||||
let renderer = UIGraphicsImageRenderer(size: size, format: format)
|
||||
return renderer.image { ctx in
|
||||
color.setFill()
|
||||
ctx.fill(CGRect(origin: .zero, size: size))
|
||||
}
|
||||
}
|
||||
|
||||
private static func remoteImage(_ urlString: String, placeholder: UIColor = .systemPink, size: CGSize = CGSize(width: 300, height: 300)) -> UIImage {
|
||||
if let url = URL(string: urlString),
|
||||
let data = try? Data(contentsOf: url),
|
||||
let image = UIImage(data: data) {
|
||||
return image
|
||||
}
|
||||
return coloredImage(placeholder, size: size)
|
||||
}
|
||||
|
||||
static var previews: some View {
|
||||
Group {
|
||||
// 预览 1:含描述与时间,非会员
|
||||
BlindOutcomeView(
|
||||
media: .image(remoteImage("https://cdn.memorywake.com/files/7350515957925810176/original_1752499572813_screenshot-20250514-170854.png")),
|
||||
title: "00:23",
|
||||
description: "这是一段示例描述,用于在预览中验证样式与布局。",
|
||||
isMember: false,
|
||||
onContinue: {}
|
||||
)
|
||||
.previewDisplayName("Image • With Description • Guest")
|
||||
|
||||
// 预览 2:无描述无时间,会员
|
||||
BlindOutcomeView(
|
||||
media: .image(remoteImage("https://cdn.memorywake.com/files/7350515957925810176/original_1752499572813_screenshot-20250514-170854.png")),
|
||||
title: nil,
|
||||
description: nil,
|
||||
isMember: true,
|
||||
onContinue: {}
|
||||
)
|
||||
.previewDisplayName("Image • Minimal • Member")
|
||||
|
||||
// 预览 3:视频示例
|
||||
BlindOutcomeView(
|
||||
media: .video(URL(string: "https://cdn.memorywake.com/users/7350439663116619888/files/7361241959983353857/7361241920703696897.mp4")!, nil),
|
||||
title: "00:23",
|
||||
description: "视频预览示例",
|
||||
isMember: false,
|
||||
onContinue: {}
|
||||
)
|
||||
.previewDisplayName("Video • With Description • Guest")
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@ -1,2 +0,0 @@
|
||||
# Features/BlindBox/View
|
||||
盲盒界面视图文件(例如 `BlindBoxView.swift`)。
|
||||
@ -1,264 +0,0 @@
|
||||
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: 30, seconds: 0)
|
||||
}
|
||||
} else {
|
||||
stopCountdown()
|
||||
}
|
||||
}
|
||||
|
||||
func startCountdown(minutes: Int = 30, seconds: Int = 0) {
|
||||
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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,2 +0,0 @@
|
||||
# Features/BlindBox/ViewModel
|
||||
盲盒视图模型,如 `BlindBoxViewModel.swift`。
|
||||
@ -1,2 +0,0 @@
|
||||
# Features/Subscribe
|
||||
订阅相关页面与组件:`SubscribeView`、`CreditsInfoCard`、`PlanCompare` 等。
|
||||
@ -22,14 +22,13 @@
|
||||
<string>Sign in with Apple is used to authenticate your account</string>
|
||||
<key>UIAppFonts</key>
|
||||
<array>
|
||||
<string>Inter.ttf</string>
|
||||
<string>Quicksand x.ttf</string>
|
||||
<string>SankeiCutePopanime.ttf</string>
|
||||
<string>Quicksand-Regular.ttf</string>
|
||||
<string>Quicksand-Bold.ttf</string>
|
||||
<string>Quicksand-SemiBold.ttf</string>
|
||||
<string>Quicksand-Medium.ttf</string>
|
||||
<string>Quicksand-Light.ttf</string>
|
||||
<string>LavishlyYours-Regular.ttf</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
|
Before Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 962 B |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 6.9 KiB |
@ -1,158 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "40.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "60.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "29.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "1x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "58.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "87.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "80.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "120.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "57.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "1x",
|
||||
"size" : "57x57"
|
||||
},
|
||||
{
|
||||
"filename" : "114.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "57x57"
|
||||
},
|
||||
{
|
||||
"filename" : "120.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "180.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "20.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "40.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "29.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "58.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "40.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "80.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "50.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "50x50"
|
||||
},
|
||||
{
|
||||
"filename" : "100.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "50x50"
|
||||
},
|
||||
{
|
||||
"filename" : "72.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "72x72"
|
||||
},
|
||||
{
|
||||
"filename" : "144.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "72x72"
|
||||
},
|
||||
{
|
||||
"filename" : "76.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "76x76"
|
||||
},
|
||||
{
|
||||
"filename" : "152.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "76x76"
|
||||
},
|
||||
{
|
||||
"filename" : "167.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "83.5x83.5"
|
||||
},
|
||||
{
|
||||
"filename" : "1024.png",
|
||||
"idiom" : "ios-marketing",
|
||||
"scale" : "1x",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
21
wake/Media.xcassets/Empty.imageset/Contents.json
vendored
@ -1,21 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Empty.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
wake/Media.xcassets/Empty.imageset/Empty.png
vendored
|
Before Width: | Height: | Size: 30 KiB |
21
wake/Media.xcassets/IP.imageset/Contents.json
vendored
@ -1,21 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "IP.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
wake/Media.xcassets/IP.imageset/IP.png
vendored
|
Before Width: | Height: | Size: 16 KiB |
21
wake/Media.xcassets/IP1.imageset/Contents.json
vendored
@ -1,21 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "IP1.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
wake/Media.xcassets/IP1.imageset/IP1.png
vendored
|
Before Width: | Height: | Size: 16 KiB |
@ -1,215 +0,0 @@
|
||||
{
|
||||
"appPolicies" : {
|
||||
"eula" : "",
|
||||
"policies" : [
|
||||
{
|
||||
"locale" : "en_US",
|
||||
"policyText" : "",
|
||||
"policyURL" : ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"identifier" : "C75471B9",
|
||||
"nonRenewingSubscriptions" : [
|
||||
|
||||
],
|
||||
"products" : [
|
||||
|
||||
],
|
||||
"settings" : {
|
||||
"_applicationInternalID" : "6748205761",
|
||||
"_developerTeamID" : "392N3QB7XR",
|
||||
"_failTransactionsEnabled" : false,
|
||||
"_lastSynchronizedDate" : 777364219.49411595,
|
||||
"_locale" : "en_US",
|
||||
"_storefront" : "USA",
|
||||
"_storeKitErrors" : [
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Load Products"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Purchase"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Verification"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "App Store Sync"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Subscription Status"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "App Transaction"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Manage Subscriptions Sheet"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Refund Request Sheet"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Offer Code Redeem Sheet"
|
||||
}
|
||||
]
|
||||
},
|
||||
"subscriptionGroups" : [
|
||||
{
|
||||
"id" : "21759571",
|
||||
"localizations" : [
|
||||
|
||||
],
|
||||
"name" : "Membership",
|
||||
"subscriptions" : [
|
||||
{
|
||||
"adHocOffers" : [
|
||||
|
||||
],
|
||||
"codeOffers" : [
|
||||
|
||||
],
|
||||
"displayPrice" : "0.99",
|
||||
"familyShareable" : false,
|
||||
"groupNumber" : 1,
|
||||
"internalID" : "6751260055",
|
||||
"introductoryOffer" : null,
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "The Pioneer Plan unlocks many restrictions.",
|
||||
"displayName" : "Pioneer Plan",
|
||||
"locale" : "en_US"
|
||||
},
|
||||
{
|
||||
"description" : "先锋计划用户,不限制盲盒购买数量,不限制回忆上传数量,每天免费获取500积分",
|
||||
"displayName" : "先锋计划",
|
||||
"locale" : "zh_Hans"
|
||||
}
|
||||
],
|
||||
"productID" : "MEMBERSHIP_PIONEER_MONTHLY",
|
||||
"recurringSubscriptionPeriod" : "P1M",
|
||||
"referenceName" : "Pioneer计划",
|
||||
"subscriptionGroupID" : "21759571",
|
||||
"type" : "RecurringSubscription",
|
||||
"winbackOffers" : [
|
||||
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id" : "21740727",
|
||||
"localizations" : [
|
||||
|
||||
],
|
||||
"name" : "Pro会员",
|
||||
"subscriptions" : [
|
||||
{
|
||||
"adHocOffers" : [
|
||||
|
||||
],
|
||||
"codeOffers" : [
|
||||
|
||||
],
|
||||
"displayPrice" : "12.99",
|
||||
"familyShareable" : false,
|
||||
"groupNumber" : 1,
|
||||
"internalID" : "6749133482",
|
||||
"introductoryOffer" : null,
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "Pro会员每月有更高的存储空间与积分数量",
|
||||
"displayName" : "季度Pro会员",
|
||||
"locale" : "zh_Hans"
|
||||
}
|
||||
],
|
||||
"productID" : "MEMBERSHIP_PRO_QUARTERLY",
|
||||
"recurringSubscriptionPeriod" : "P3M",
|
||||
"referenceName" : "季度Pro会员",
|
||||
"subscriptionGroupID" : "21740727",
|
||||
"type" : "RecurringSubscription",
|
||||
"winbackOffers" : [
|
||||
|
||||
]
|
||||
},
|
||||
{
|
||||
"adHocOffers" : [
|
||||
|
||||
],
|
||||
"codeOffers" : [
|
||||
|
||||
],
|
||||
"displayPrice" : "59.99",
|
||||
"familyShareable" : false,
|
||||
"groupNumber" : 2,
|
||||
"internalID" : "6749229999",
|
||||
"introductoryOffer" : null,
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "Pro会员每月有更高的存储空间与积分数量",
|
||||
"displayName" : "年度Pro会员",
|
||||
"locale" : "zh_Hans"
|
||||
}
|
||||
],
|
||||
"productID" : "MEMBERSHIP_PRO_YEARLY",
|
||||
"recurringSubscriptionPeriod" : "P1Y",
|
||||
"referenceName" : "年度Pro会员",
|
||||
"subscriptionGroupID" : "21740727",
|
||||
"type" : "RecurringSubscription",
|
||||
"winbackOffers" : [
|
||||
|
||||
]
|
||||
},
|
||||
{
|
||||
"adHocOffers" : [
|
||||
|
||||
],
|
||||
"codeOffers" : [
|
||||
|
||||
],
|
||||
"displayPrice" : "3.99",
|
||||
"familyShareable" : false,
|
||||
"groupNumber" : 3,
|
||||
"internalID" : "6749230171",
|
||||
"introductoryOffer" : null,
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "Pro会员每月有更高的存储空间与积分数量",
|
||||
"displayName" : "月度Pro会员",
|
||||
"locale" : "zh_Hans"
|
||||
}
|
||||
],
|
||||
"productID" : "MEMBERSHIP_PRO_MONTH",
|
||||
"recurringSubscriptionPeriod" : "P1M",
|
||||
"referenceName" : "月度Pro会员",
|
||||
"subscriptionGroupID" : "21740727",
|
||||
"type" : "RecurringSubscription",
|
||||
"winbackOffers" : [
|
||||
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"version" : {
|
||||
"major" : 4,
|
||||
"minor" : 0
|
||||
}
|
||||
}
|
||||
@ -1,59 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
/// API基础响应模型
|
||||
struct BaseResponse<T: Codable>: Codable {
|
||||
let code: Int
|
||||
let data: T?
|
||||
let message: String?
|
||||
}
|
||||
|
||||
/// 用户登录信息
|
||||
struct UserLoginInfo: Codable {
|
||||
let userId: String
|
||||
let accessToken: String
|
||||
let refreshToken: String
|
||||
let nickname: String
|
||||
let account: String
|
||||
let email: String
|
||||
let avatarFileUrl: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case userId = "user_id"
|
||||
case accessToken = "access_token"
|
||||
case refreshToken = "refresh_token"
|
||||
case nickname
|
||||
case account
|
||||
case email
|
||||
case avatarFileUrl = "avatar_file_url"
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录响应数据
|
||||
struct LoginResponseData: Codable {
|
||||
let userLoginInfo: UserLoginInfo
|
||||
let isNewUser: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case userLoginInfo = "user_login_info"
|
||||
case isNewUser = "is_new_user"
|
||||
}
|
||||
}
|
||||
|
||||
/// 认证响应模型
|
||||
typealias AuthResponse = BaseResponse<LoginResponseData>
|
||||
|
||||
/// 用户信息响应数据
|
||||
struct UserInfoData: Codable {
|
||||
let userId: String
|
||||
let username: String
|
||||
let avatarFileId: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case userId = "user_id"
|
||||
case username
|
||||
case avatarFileId = "avatar_file_id"
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户信息响应模型
|
||||
typealias UserInfoResponse = BaseResponse<UserInfoData>
|
||||
@ -1,49 +0,0 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
/// 管理用户认证状态的类
|
||||
public class AuthState: ObservableObject {
|
||||
@Published public var isAuthenticated: Bool = false {
|
||||
didSet {
|
||||
print("🔔 认证状态变更: \(isAuthenticated ? "已登录" : "已登出")")
|
||||
}
|
||||
}
|
||||
@Published public var isLoading = false
|
||||
@Published public var errorMessage: String?
|
||||
@Published public var user: User?
|
||||
|
||||
// 单例模式
|
||||
public static let shared = AuthState()
|
||||
|
||||
private init() {}
|
||||
|
||||
/// 登录成功时调用
|
||||
public func login(user: User? = nil) {
|
||||
if let user = user {
|
||||
self.user = user
|
||||
}
|
||||
isAuthenticated = true
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
/// 登出时调用
|
||||
public func logout() {
|
||||
print("👋 用户登出")
|
||||
user = nil
|
||||
isAuthenticated = false
|
||||
|
||||
// 清除用户数据
|
||||
TokenManager.shared.clearTokens()
|
||||
UserDefaults.standard.removeObject(forKey: "lastLoginUser")
|
||||
}
|
||||
|
||||
/// 更新加载状态
|
||||
public func setLoading(_ loading: Bool) {
|
||||
isLoading = loading
|
||||
}
|
||||
|
||||
/// 设置错误信息
|
||||
public func setError(_ message: String) {
|
||||
errorMessage = message
|
||||
}
|
||||
}
|
||||
@ -1,46 +0,0 @@
|
||||
import SwiftUI
|
||||
import AVKit
|
||||
|
||||
/// Represents different types of media that can be displayed or processed
|
||||
public enum MediaType: Equatable, Hashable {
|
||||
case image(UIImage)
|
||||
case video(URL, UIImage?) // URL is the video URL, UIImage is the thumbnail
|
||||
|
||||
public var thumbnail: UIImage? {
|
||||
switch self {
|
||||
case .image(let image):
|
||||
return image
|
||||
case .video(_, let thumbnail):
|
||||
return thumbnail
|
||||
}
|
||||
}
|
||||
|
||||
public var isVideo: Bool {
|
||||
if case .video = self {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
public static func == (lhs: MediaType, rhs: MediaType) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.image(let lhsImage), .image(let rhsImage)):
|
||||
return lhsImage.pngData() == rhsImage.pngData()
|
||||
case (.video(let lhsURL, _), .video(let rhsURL, _)):
|
||||
return lhsURL == rhsURL
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
switch self {
|
||||
case .image(let image):
|
||||
hasher.combine("image")
|
||||
hasher.combine(image.pngData())
|
||||
case .video(let url, _):
|
||||
hasher.combine("video")
|
||||
hasher.combine(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,290 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - MemberProfile Response
|
||||
struct MemberProfileResponse: Codable {
|
||||
let code: Int
|
||||
let data: MemberProfile
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case code, data
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(code, forKey: .code)
|
||||
try container.encode(data, forKey: .data)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TitleRanking
|
||||
struct TitleRanking: Codable {
|
||||
let displayName: String
|
||||
let ranking: Int
|
||||
let value: Int
|
||||
let materialType: String
|
||||
let userId: String
|
||||
let region: String
|
||||
let userAvatarUrl: String?
|
||||
let userNickName: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case displayName = "display_name"
|
||||
case ranking
|
||||
case value
|
||||
case materialType = "material_type"
|
||||
case userId = "user_id"
|
||||
case region
|
||||
case userAvatarUrl = "user_avatar_url"
|
||||
case userNickName = "user_nick_name"
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(displayName, forKey: .displayName)
|
||||
try container.encode(ranking, forKey: .ranking)
|
||||
try container.encode(value, forKey: .value)
|
||||
try container.encode(materialType, forKey: .materialType)
|
||||
try container.encode(userId, forKey: .userId)
|
||||
try container.encode(region, forKey: .region)
|
||||
try container.encodeIfPresent(userAvatarUrl, forKey: .userAvatarUrl)
|
||||
try container.encodeIfPresent(userNickName, forKey: .userNickName)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MemberProfile
|
||||
struct MemberProfile: Codable {
|
||||
let materialCounter: MaterialCounter
|
||||
let userInfo: MemberUserInfo
|
||||
let storiesCount: Int
|
||||
let conversationsCount: Int
|
||||
let remainPoints: Int
|
||||
let totalPoints: Int
|
||||
let usedBytes: Int
|
||||
let totalBytes: Int
|
||||
let titleRankings: [TitleRanking]
|
||||
let medalInfos: [MedalInfo]
|
||||
let membershipLevel: String
|
||||
let membershipEndAt: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case materialCounter = "material_counter"
|
||||
case userInfo = "user_info"
|
||||
case storiesCount = "stories_count"
|
||||
case conversationsCount = "conversations_count"
|
||||
case remainPoints = "remain_points"
|
||||
case totalPoints = "total_points"
|
||||
case usedBytes = "used_bytes"
|
||||
case totalBytes = "total_bytes"
|
||||
case titleRankings = "title_rankings"
|
||||
case medalInfos = "medal_infos"
|
||||
case membershipLevel = "membership_level"
|
||||
case membershipEndAt = "membership_end_at"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
materialCounter = try container.decode(MaterialCounter.self, forKey: .materialCounter)
|
||||
userInfo = try container.decode(MemberUserInfo.self, forKey: .userInfo)
|
||||
storiesCount = try container.decode(Int.self, forKey: .storiesCount)
|
||||
conversationsCount = try container.decode(Int.self, forKey: .conversationsCount)
|
||||
remainPoints = try container.decode(Int.self, forKey: .remainPoints)
|
||||
totalPoints = try container.decode(Int.self, forKey: .totalPoints)
|
||||
usedBytes = try container.decode(Int.self, forKey: .usedBytes)
|
||||
totalBytes = try container.decode(Int.self, forKey: .totalBytes)
|
||||
titleRankings = try container.decode([TitleRanking].self, forKey: .titleRankings)
|
||||
|
||||
if let medalInfos = try? container.decode([MedalInfo].self, forKey: .medalInfos) {
|
||||
self.medalInfos = medalInfos
|
||||
} else {
|
||||
self.medalInfos = []
|
||||
}
|
||||
|
||||
membershipLevel = try container.decode(String.self, forKey: .membershipLevel)
|
||||
membershipEndAt = try container.decode(String.self, forKey: .membershipEndAt)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(materialCounter, forKey: .materialCounter)
|
||||
try container.encode(userInfo, forKey: .userInfo)
|
||||
try container.encode(storiesCount, forKey: .storiesCount)
|
||||
try container.encode(conversationsCount, forKey: .conversationsCount)
|
||||
try container.encode(remainPoints, forKey: .remainPoints)
|
||||
try container.encode(totalPoints, forKey: .totalPoints)
|
||||
try container.encode(usedBytes, forKey: .usedBytes)
|
||||
try container.encode(totalBytes, forKey: .totalBytes)
|
||||
try container.encode(titleRankings, forKey: .titleRankings)
|
||||
try container.encode(medalInfos, forKey: .medalInfos)
|
||||
try container.encode(membershipLevel, forKey: .membershipLevel)
|
||||
try container.encode(membershipEndAt, forKey: .membershipEndAt)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MemberUserInfo
|
||||
struct MemberUserInfo: Codable {
|
||||
let userId: String
|
||||
let accessToken: String
|
||||
let avatarFileUrl: String?
|
||||
let nickname: String
|
||||
let account: String
|
||||
let email: String
|
||||
let refreshToken: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case userId = "user_id"
|
||||
case accessToken = "access_token"
|
||||
case avatarFileUrl = "avatar_file_url"
|
||||
case nickname, account, email
|
||||
case refreshToken = "refresh_token"
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(userId, forKey: .userId)
|
||||
try container.encode(accessToken, forKey: .accessToken)
|
||||
try container.encodeIfPresent(avatarFileUrl, forKey: .avatarFileUrl)
|
||||
try container.encode(nickname, forKey: .nickname)
|
||||
try container.encode(account, forKey: .account)
|
||||
try container.encode(email, forKey: .email)
|
||||
try container.encodeIfPresent(refreshToken, forKey: .refreshToken)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MaterialCounter
|
||||
struct MaterialCounter: Codable {
|
||||
let userId: Int64
|
||||
let totalCount: TotalCount
|
||||
let categoryCount: [String: CategoryCount]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case userId = "user_id"
|
||||
case totalCount = "total_count"
|
||||
case categoryCount = "category_count"
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(userId, forKey: .userId)
|
||||
try container.encode(totalCount, forKey: .totalCount)
|
||||
try container.encode(categoryCount, forKey: .categoryCount)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TotalCount
|
||||
struct TotalCount: Codable {
|
||||
let videoCount: Int
|
||||
let photoCount: Int
|
||||
let liveCount: Int
|
||||
let videoLength: Double
|
||||
let coverUrl: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case videoCount = "video_count"
|
||||
case photoCount = "photo_count"
|
||||
case liveCount = "live_count"
|
||||
case videoLength = "video_length"
|
||||
case coverUrl = "cover_url"
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(videoCount, forKey: .videoCount)
|
||||
try container.encode(photoCount, forKey: .photoCount)
|
||||
try container.encode(liveCount, forKey: .liveCount)
|
||||
try container.encode(videoLength, forKey: .videoLength)
|
||||
try container.encodeIfPresent(coverUrl, forKey: .coverUrl)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CategoryCount
|
||||
struct CategoryCount: Codable {
|
||||
let videoCount: Int
|
||||
let photoCount: Int
|
||||
let liveCount: Int
|
||||
let videoLength: Double
|
||||
let coverUrl: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case videoCount = "video_count"
|
||||
case photoCount = "photo_count"
|
||||
case liveCount = "live_count"
|
||||
case videoLength = "video_length"
|
||||
case coverUrl = "cover_url"
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(videoCount, forKey: .videoCount)
|
||||
try container.encode(photoCount, forKey: .photoCount)
|
||||
try container.encode(liveCount, forKey: .liveCount)
|
||||
try container.encode(videoLength, forKey: .videoLength)
|
||||
try container.encodeIfPresent(coverUrl, forKey: .coverUrl)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MedalInfo
|
||||
struct MedalInfo: Codable, Identifiable {
|
||||
let id: Int
|
||||
let url: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, url
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encode(url, forKey: .url)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - API Response Wrapper
|
||||
struct MemberAPIResponse<T: Codable>: Codable {
|
||||
let code: Int
|
||||
let message: String
|
||||
let data: T
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case code, message, data
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(code, forKey: .code)
|
||||
try container.encode(message, forKey: .message)
|
||||
try container.encode(data, forKey: .data)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Date Formatter
|
||||
class DateFormatterManager {
|
||||
static let shared = DateFormatterManager()
|
||||
|
||||
let iso8601Full: ISO8601DateFormatter = {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private init() {}
|
||||
}
|
||||
|
||||
// MARK: - JSON Decoder Extension
|
||||
extension JSONDecoder {
|
||||
static let `default`: JSONDecoder = {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
decoder.dateDecodingStrategy = .custom { decoder -> Date in
|
||||
let container = try decoder.singleValueContainer()
|
||||
let dateString = try container.decode(String.self)
|
||||
|
||||
if let date = DateFormatterManager.shared.iso8601Full.date(from: dateString) {
|
||||
return date
|
||||
}
|
||||
|
||||
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode date string \(dateString)")
|
||||
}
|
||||
return decoder
|
||||
}()
|
||||
}
|
||||
@ -1,101 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
/// 订单信息模型
|
||||
struct OrderInfo: Codable, Identifiable {
|
||||
let id: String
|
||||
let userId: String
|
||||
let totalAmount: Amount
|
||||
let status: String
|
||||
let items: [OrderItem]
|
||||
let paymentInfo: PaymentInfo?
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
let expiredAt: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case userId = "user_id"
|
||||
case totalAmount = "total_amount"
|
||||
case status
|
||||
case items
|
||||
case paymentInfo = "payment_info"
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
case expiredAt = "expired_at"
|
||||
}
|
||||
}
|
||||
|
||||
/// 支付信息模型
|
||||
struct PaymentInfo: Codable {
|
||||
let id: String
|
||||
let paymentMethod: String
|
||||
let paymentStatus: String
|
||||
let paymentAmount: Amount
|
||||
let transactionId: String?
|
||||
let thirdPartyTransactionId: String?
|
||||
let paidAt: String?
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case paymentMethod = "payment_method"
|
||||
case paymentStatus = "payment_status"
|
||||
case paymentAmount = "payment_amount"
|
||||
case transactionId = "transaction_id"
|
||||
case thirdPartyTransactionId = "third_party_transaction_id"
|
||||
case paidAt = "paid_at"
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
}
|
||||
}
|
||||
|
||||
/// 金额模型
|
||||
struct Amount: Codable {
|
||||
let amount: String
|
||||
let currency: String
|
||||
}
|
||||
|
||||
/// 订单项模型
|
||||
struct OrderItem: Codable, Identifiable {
|
||||
let id: String
|
||||
let productId: Int
|
||||
let productType: String
|
||||
let productCode: String
|
||||
let productName: String
|
||||
let unitPrice: Amount
|
||||
let discountAmount: Amount
|
||||
let quantity: Int
|
||||
let totalPrice: Amount
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case productId = "product_id"
|
||||
case productType = "product_type"
|
||||
case productCode = "product_code"
|
||||
case productName = "product_name"
|
||||
case unitPrice = "unit_price"
|
||||
case discountAmount = "discount_amount"
|
||||
case quantity
|
||||
case totalPrice = "total_price"
|
||||
}
|
||||
}
|
||||
|
||||
/// 订单状态
|
||||
enum OrderStatus: Int, Codable {
|
||||
case pending = 0 // 待支付
|
||||
case paid = 1 // 已支付
|
||||
case completed = 2 // 已完成
|
||||
case cancelled = 3 // 已取消
|
||||
case refunded = 4 // 已退款
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .pending: return "待支付"
|
||||
case .paid: return "已支付"
|
||||
case .completed: return "已完成"
|
||||
case .cancelled: return "已取消"
|
||||
case .refunded: return "已退款"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,57 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 上传状态
|
||||
public enum UploadStatus: Equatable {
|
||||
case idle
|
||||
case uploading(progress: Double)
|
||||
case success
|
||||
case failure(Error)
|
||||
|
||||
public var isUploading: Bool {
|
||||
if case .uploading = self { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
public var progress: Double {
|
||||
if case let .uploading(progress) = self { return progress }
|
||||
return 0
|
||||
}
|
||||
|
||||
public static func == (lhs: UploadStatus, rhs: UploadStatus) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.idle, .idle):
|
||||
return true
|
||||
case let (.uploading(lhsProgress), .uploading(rhsProgress)):
|
||||
// 使用近似比较来处理浮点数的精度问题
|
||||
return abs(lhsProgress - rhsProgress) < 0.001
|
||||
case (.success, .success):
|
||||
return true
|
||||
case (.failure, .failure):
|
||||
// 对于错误类型,我们简单地认为它们不相等,因为比较 Error 对象比较复杂
|
||||
// 如果需要更精确的比较,可以在这里添加具体实现
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 上传结果
|
||||
public struct UploadResult: Identifiable, Equatable {
|
||||
public let id = UUID()
|
||||
public var fileId: String
|
||||
public var previewFileId: String
|
||||
public let image: UIImage
|
||||
public var status: UploadStatus = .idle
|
||||
|
||||
public init(fileId: String = "", previewFileId: String = "", image: UIImage, status: UploadStatus = .idle) {
|
||||
self.fileId = fileId
|
||||
self.previewFileId = previewFileId
|
||||
self.image = image
|
||||
self.status = status
|
||||
}
|
||||
|
||||
public static func == (lhs: UploadResult, rhs: UploadResult) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
}
|
||||
BIN
wake/Resources/.DS_Store
vendored
BIN
wake/Resources/SankeiCutePopanime.ttf
Normal file
@ -1,84 +0,0 @@
|
||||
import SwiftUI
|
||||
import Lottie
|
||||
|
||||
struct LottieView: UIViewRepresentable {
|
||||
let name: String
|
||||
let loopMode: LottieLoopMode
|
||||
let animationSpeed: CGFloat
|
||||
let isPlaying: Bool
|
||||
|
||||
init(name: String, loopMode: LottieLoopMode = .loop, animationSpeed: CGFloat = 1.0, isPlaying: Bool = true) {
|
||||
self.name = name
|
||||
self.loopMode = loopMode
|
||||
self.animationSpeed = animationSpeed
|
||||
self.isPlaying = isPlaying
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> UIView {
|
||||
// 使用容器视图承载 LottieAnimationView,确保 SwiftUI 的 frame 约束能生效
|
||||
let container = UIView()
|
||||
container.clipsToBounds = true
|
||||
|
||||
let animationView = LottieAnimationView()
|
||||
animationView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
// 方法1: 直接使用文件名加载
|
||||
if let animation = LottieAnimation.named(name) {
|
||||
animationView.animation = animation
|
||||
}
|
||||
// 方法2: 如果方法1失败,尝试使用文件路径加载
|
||||
else if let path = Bundle.main.path(forResource: name, ofType: "json") {
|
||||
let animation = LottieAnimation.filepath(path)
|
||||
animationView.animation = animation
|
||||
}
|
||||
|
||||
// 配置动画
|
||||
animationView.loopMode = loopMode
|
||||
animationView.animationSpeed = animationSpeed
|
||||
animationView.contentMode = .scaleAspectFit
|
||||
animationView.backgroundBehavior = .pauseAndRestore
|
||||
|
||||
container.addSubview(animationView)
|
||||
NSLayoutConstraint.activate([
|
||||
animationView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
||||
animationView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||
animationView.topAnchor.constraint(equalTo: container.topAnchor),
|
||||
animationView.bottomAnchor.constraint(equalTo: container.bottomAnchor)
|
||||
])
|
||||
|
||||
// 通过 Coordinator 保存引用,便于 updateUIView 控制播放
|
||||
context.coordinator.animationView = animationView
|
||||
|
||||
// 播放/暂停
|
||||
if isPlaying {
|
||||
animationView.play()
|
||||
} else {
|
||||
animationView.pause()
|
||||
}
|
||||
|
||||
return container
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIView, context: Context) {
|
||||
guard let animationView = context.coordinator.animationView else { return }
|
||||
// 根据 isPlaying 控制播放/暂停
|
||||
if isPlaying {
|
||||
if !animationView.isAnimationPlaying {
|
||||
animationView.play()
|
||||
}
|
||||
} else {
|
||||
if animationView.isAnimationPlaying {
|
||||
animationView.pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 Coordinator 在 make/update 周期之间保存 animationView 引用
|
||||
class Coordinator {
|
||||
var animationView: LottieAnimationView?
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
}
|
||||
@ -1,2 +0,0 @@
|
||||
# SharedUI/Animation
|
||||
Lottie 等动画封装:`LottieView.swift`。
|
||||