Compare commits
4 Commits
main
...
BlindBoxVi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55255bf0f8 | ||
|
|
b1cd957d0c | ||
|
|
36b95abc37 | ||
|
|
df32ea71bb |
3
.vscode/settings.json
vendored
@ -1,5 +1,4 @@
|
|||||||
{
|
{
|
||||||
"lldb.library": "/Library/Developer/CommandLineTools/Library/PrivateFrameworks/LLDB.framework/Versions/A/LLDB",
|
"lldb.library": "/Library/Developer/CommandLineTools/Library/PrivateFrameworks/LLDB.framework/Versions/A/LLDB",
|
||||||
"lldb.launch.expressions": "native",
|
"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,8 +7,8 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
0DE4253B2E78470700B519F0 /* SVGKit in Frameworks */ = {isa = PBXBuildFile; productRef = AB6693C92E65C94400BCAAC1 /* SVGKit */; };
|
AB6693CA2E65C94400BCAAC1 /* SVGKit in Frameworks */ = {isa = PBXBuildFile; productRef = AB6693C92E65C94400BCAAC1 /* SVGKit */; };
|
||||||
0DE4253C2E78470700B519F0 /* SVGKitSwift in Frameworks */ = {isa = PBXBuildFile; productRef = AB6693CB2E65C94400BCAAC1 /* SVGKitSwift */; };
|
AB6693CC2E65C94400BCAAC1 /* SVGKitSwift in Frameworks */ = {isa = PBXBuildFile; productRef = AB6693CB2E65C94400BCAAC1 /* SVGKitSwift */; };
|
||||||
AB6695272E67015600BCAAC1 /* WaterfallGrid in Frameworks */ = {isa = PBXBuildFile; productRef = AB6695262E67015600BCAAC1 /* WaterfallGrid */; };
|
AB6695272E67015600BCAAC1 /* WaterfallGrid in Frameworks */ = {isa = PBXBuildFile; productRef = AB6695262E67015600BCAAC1 /* WaterfallGrid */; };
|
||||||
AB8773632E4E04400071CB53 /* .gitignore in Resources */ = {isa = PBXBuildFile; fileRef = AB8773622E4E040E0071CB53 /* .gitignore */; };
|
AB8773632E4E04400071CB53 /* .gitignore in Resources */ = {isa = PBXBuildFile; fileRef = AB8773622E4E040E0071CB53 /* .gitignore */; };
|
||||||
ABC150C12E5DB39A00A1F970 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = ABC150C02E5DB39A00A1F970 /* Lottie */; };
|
ABC150C12E5DB39A00A1F970 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = ABC150C02E5DB39A00A1F970 /* Lottie */; };
|
||||||
@ -38,25 +38,7 @@
|
|||||||
AB4FA8642E4F7074005D9955 /* Exceptions for "wake" folder in "wake" target */ = {
|
AB4FA8642E4F7074005D9955 /* Exceptions for "wake" folder in "wake" target */ = {
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
Core/DesignSystem/README.md,
|
|
||||||
Core/Diagnostics/README.md,
|
|
||||||
Core/Navigation/README.md,
|
|
||||||
Core/Network/README.md,
|
|
||||||
Core/README.md,
|
|
||||||
Features/BlindBox/API/README.md,
|
|
||||||
Features/BlindBox/Components/README.md,
|
|
||||||
Features/BlindBox/Models/README.md,
|
|
||||||
Features/BlindBox/README.md,
|
|
||||||
Features/BlindBox/View/README.md,
|
|
||||||
Features/BlindBox/ViewModel/README.md,
|
|
||||||
Features/Subscribe/README.md,
|
|
||||||
Info.plist,
|
Info.plist,
|
||||||
SharedUI/Animation/README.md,
|
|
||||||
SharedUI/Controls/README.md,
|
|
||||||
SharedUI/Graphics/README.md,
|
|
||||||
SharedUI/Media/README.md,
|
|
||||||
SharedUI/Modals/README.md,
|
|
||||||
SharedUI/README.md,
|
|
||||||
);
|
);
|
||||||
target = ABB4E2072E4B75D900660198 /* wake */;
|
target = ABB4E2072E4B75D900660198 /* wake */;
|
||||||
};
|
};
|
||||||
@ -78,8 +60,8 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
0DE4253C2E78470700B519F0 /* SVGKitSwift in Frameworks */,
|
AB6693CC2E65C94400BCAAC1 /* SVGKitSwift in Frameworks */,
|
||||||
0DE4253B2E78470700B519F0 /* SVGKit in Frameworks */,
|
AB6693CA2E65C94400BCAAC1 /* SVGKit in Frameworks */,
|
||||||
ABE8998E2E533A7100CD7BA6 /* Alamofire in Frameworks */,
|
ABE8998E2E533A7100CD7BA6 /* Alamofire in Frameworks */,
|
||||||
AB6695272E67015600BCAAC1 /* WaterfallGrid in Frameworks */,
|
AB6695272E67015600BCAAC1 /* WaterfallGrid in Frameworks */,
|
||||||
ABC150C12E5DB39A00A1F970 /* Lottie in Frameworks */,
|
ABC150C12E5DB39A00A1F970 /* Lottie in Frameworks */,
|
||||||
@ -343,8 +325,9 @@
|
|||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = wake/Info.plist;
|
INFOPLIST_FILE = wake/Info.plist;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "";
|
INFOPLIST_KEY_LSApplicationCategoryType = "";
|
||||||
INFOPLIST_KEY_NSCameraUsageDescription = "We need access to your camera to capture your profile picture";
|
INFOPLIST_KEY_NSCameraUsageDescription = "We need access to your camera to take photos";
|
||||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "We need to access your photo library to select photos as avatars or blind box inputs";
|
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "We need access to your photo library to select photos";
|
||||||
|
INFOPLIST_KEY_NSUserTrackingUsageDescription = "我们需要访问您的Apple ID信息来为您提供登录服务";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
@ -377,8 +360,9 @@
|
|||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = wake/Info.plist;
|
INFOPLIST_FILE = wake/Info.plist;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "";
|
INFOPLIST_KEY_LSApplicationCategoryType = "";
|
||||||
INFOPLIST_KEY_NSCameraUsageDescription = "We need access to your camera to capture your profile picture";
|
INFOPLIST_KEY_NSCameraUsageDescription = "We need access to your camera to take photos";
|
||||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "We need to access your photo library to select photos as avatars or blind box inputs";
|
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "We need access to your photo library to select photos";
|
||||||
|
INFOPLIST_KEY_NSUserTrackingUsageDescription = "我们需要访问您的Apple ID信息来为您提供登录服务";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
|||||||
BIN
wake/Assets/.DS_Store
vendored
BIN
wake/Assets/Images/Gif/BlindLoading.gif
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
BIN
wake/Assets/Images/Gif/BlindOpen.gif
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
wake/Assets/Images/Gif/BlindReady.gif
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
1
wake/Assets/Lottie/data.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"v":"5.12.2","fr":25,"ip":0,"op":77,"w":1080,"h":1080,"nm":"合成 1","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"方","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[561,635,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sr","sy":2,"d":1,"pt":{"a":0,"k":4,"ix":3},"p":{"a":1,"k":[{"i":{"x":0.053,"y":1},"o":{"x":0.333,"y":0},"t":50,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.918,"y":0},"t":63,"s":[0,-365],"to":[0,0],"ti":[0,0]},{"t":75,"s":[0,0]}],"ix":4},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":50,"s":[0]},{"t":63,"s":[360]}],"ix":5},"or":{"a":0,"k":180.325,"ix":7},"os":{"a":0,"k":49,"ix":9},"ix":1,"nm":"多边星形路径 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"描边 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.713725507259,0.270588248968,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"填充 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-21,24],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"变换"}],"nm":"多边星形 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":50,"op":76,"st":50,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"圆","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[561,635,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sr","sy":2,"d":1,"pt":{"a":0,"k":50,"ix":3},"p":{"a":1,"k":[{"i":{"x":0.053,"y":1},"o":{"x":0.333,"y":0},"t":25,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.918,"y":0},"t":38,"s":[0,-365],"to":[0,0],"ti":[0,0]},{"t":50,"s":[0,0]}],"ix":4},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":25,"s":[0]},{"t":38,"s":[360]}],"ix":5},"or":{"a":0,"k":180.325,"ix":7},"os":{"a":0,"k":49,"ix":9},"ix":1,"nm":"多边星形路径 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"描边 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.905882358551,0.760784327984,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"填充 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-21,24],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"变换"}],"nm":"多边星形 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":25,"op":51,"st":25,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"三角","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[561,635,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sr","sy":2,"d":1,"pt":{"a":0,"k":3,"ix":3},"p":{"a":1,"k":[{"i":{"x":0.053,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.918,"y":0},"t":13,"s":[0,-365],"to":[0,0],"ti":[0,0]},{"t":25,"s":[0,0]}],"ix":4},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"t":13,"s":[360]}],"ix":5},"or":{"a":0,"k":180.325,"ix":7},"os":{"a":0,"k":49,"ix":9},"ix":1,"nm":"多边星形路径 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"描边 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.045751713216,0.045751713216,0.045751713216,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"填充 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-21,24],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"变换"}],"nm":"多边星形 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":26,"st":0,"ct":1,"bm":0}],"markers":[],"props":{}}
|
||||||
BIN
wake/Assets/Png/logo.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
9
wake/Assets/Svg/AboutIP.svg
Normal file
|
After Width: | Height: | Size: 18 KiB |
13
wake/Assets/Svg/AboutUs.svg
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_6215_3465)">
|
||||||
|
<path d="M9.99984 18.3332C12.301 18.3332 14.3843 17.4004 15.8924 15.8924C17.4004 14.3843 18.3332 12.301 18.3332 9.99984C18.3332 7.69867 17.4004 5.61534 15.8924 4.10728C14.3843 2.59925 12.301 1.6665 9.99984 1.6665C7.69867 1.6665 5.61534 2.59925 4.10728 4.10728C2.59925 5.61534 1.6665 7.69867 1.6665 9.99984C1.6665 12.301 2.59925 14.3843 4.10728 15.8924C5.61534 17.4004 7.69867 18.3332 9.99984 18.3332Z" stroke="black" stroke-width="1.66667" stroke-linejoin="round"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.0002 4.5835C10.5755 4.5835 11.0418 5.04987 11.0418 5.62516C11.0418 6.20045 10.5755 6.66683 10.0002 6.66683C9.42487 6.66683 8.9585 6.20045 8.9585 5.62516C8.9585 5.04987 9.42487 4.5835 10.0002 4.5835Z" fill="black"/>
|
||||||
|
<path d="M10.2083 14.1668V8.3335H9.79167H9.375" stroke="black" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M8.75 14.1665H11.6667" stroke="black" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_6215_3465">
|
||||||
|
<rect width="20" height="20" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
12
wake/Assets/Svg/Account.svg
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_6215_3436)">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.99984 18.3332C14.6022 18.3332 18.3332 14.6022 18.3332 9.99984C18.3332 5.39746 14.6022 1.6665 9.99984 1.6665C5.39746 1.6665 1.6665 5.39746 1.6665 9.99984C1.6665 14.6022 5.39746 18.3332 9.99984 18.3332Z" stroke="black" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M9.99984 9.58317C11.1504 9.58317 12.0832 8.65042 12.0832 7.49984C12.0832 6.34925 11.1504 5.4165 9.99984 5.4165C8.84925 5.4165 7.9165 6.34925 7.9165 7.49984C7.9165 8.65042 8.84925 9.58317 9.99984 9.58317Z" stroke="black" stroke-width="1.66667" stroke-linejoin="round"/>
|
||||||
|
<path d="M4.17578 15.9718C4.31899 13.8004 6.12561 12.0835 8.33328 12.0835H11.6666C13.8714 12.0835 15.6762 13.7959 15.8236 15.9632" stroke="black" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_6215_3436">
|
||||||
|
<rect width="20" height="20" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
16
wake/Assets/Svg/Avatar.svg
Normal file
|
After Width: | Height: | Size: 184 KiB |
48
wake/Assets/Svg/BlindBg.svg
Normal file
|
After Width: | Height: | Size: 6.5 MiB |
10
wake/Assets/Svg/BlindBoxBg.svg
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<svg width="358" height="549" viewBox="0 0 358 549" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M358 531V18C358 8.05887 349.941 0 340 0H252.517C246.504 0 240.981 3.31723 238.157 8.62569C235.332 13.9342 229.809 17.2514 223.796 17.2514H124.079C118.428 17.2514 113.335 13.8467 111.174 8.62569C109.013 3.40468 103.919 0 98.2688 0H18C8.05887 0 0 8.05889 0 18V531C0 540.941 8.05887 549 18 549H340C349.941 549 358 540.941 358 531Z" fill="#FFB645"/>
|
||||||
|
<path d="M339 461.5V72C339 62.0589 330.941 54 321 54H96.5552C87.5728 54 79.9652 60.6222 78.7271 69.5189L76.5056 85.4811C75.2675 94.3778 67.6599 101 58.6775 101H37C27.0589 101 19 109.059 19 119V500C19 509.941 27.0589 518 37 518H248.463C254.425 518 260 515.048 263.351 510.117L278.8 487.383C282.151 482.452 287.726 479.5 293.688 479.5H321C330.941 479.5 339 471.441 339 461.5Z" fill="white"/>
|
||||||
|
<path d="M46.2666 88.0056C47.2481 88.0087 48.0853 87.2989 48.2426 86.3304L51.6466 65.3641C51.8439 64.1487 50.9059 63.0431 49.6742 63.0393L44.3504 63.0228C43.3875 63.0198 42.5607 63.7033 42.383 64.6493L38.4445 85.614C38.2135 86.8437 39.1565 87.9835 40.4083 87.9874L46.2666 88.0056Z" fill="black"/>
|
||||||
|
<path d="M32.9979 88.0048C33.971 88.0078 34.8036 87.31 34.9702 86.3516L38.616 65.3861C38.8285 64.1641 37.8882 63.0431 36.6474 63.0392L30.6621 63.0206C29.7089 63.0177 28.8875 63.6878 28.6996 64.6219L24.4812 85.5857C24.2323 86.8226 25.1778 87.9805 26.4401 87.9844L32.9979 88.0048Z" fill="black"/>
|
||||||
|
<path d="M59.9979 88.0048C60.971 88.0078 61.8036 87.31 61.9702 86.3516L65.616 65.3861C65.8285 64.1641 64.8882 63.0431 63.6474 63.0392L57.6621 63.0206C56.7089 63.0177 55.8875 63.6878 55.6996 64.6219L51.4812 85.5857C51.2323 86.8226 52.1778 87.9805 53.4401 87.9844L59.9979 88.0048Z" fill="black"/>
|
||||||
|
<path d="M308.289 518.006C309.271 518.009 310.108 517.299 310.265 516.33L313.669 495.364C313.866 494.149 312.928 493.043 311.697 493.039L306.373 493.023C305.41 493.02 304.583 493.703 304.405 494.649L300.467 515.614C300.236 516.844 301.179 517.984 302.431 517.987L308.289 518.006Z" fill="black"/>
|
||||||
|
<path d="M294.998 518.005C295.971 518.008 296.804 517.31 296.97 516.352L300.616 495.386C300.828 494.164 299.888 493.043 298.647 493.039L292.662 493.021C291.709 493.018 290.888 493.688 290.7 494.622L286.481 515.586C286.232 516.823 287.178 517.98 288.44 517.984L294.998 518.005Z" fill="black"/>
|
||||||
|
<path d="M322.044 518.005C323.017 518.008 323.849 517.31 324.016 516.352L327.662 495.386C327.874 494.164 326.934 493.043 325.693 493.039L319.708 493.021C318.755 493.018 317.933 493.688 317.745 494.622L313.527 515.586C313.278 516.823 314.224 517.98 315.486 517.984L322.044 518.005Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
4
wake/Assets/Svg/BlindCount.svg
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<svg width="90" height="66" viewBox="0 0 90 66" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M0.666667 60C0.666667 62.9455 3.05448 65.3333 6 65.3333C8.94552 65.3333 11.3333 62.9455 11.3333 60C11.3333 57.0545 8.94552 54.6667 6 54.6667C3.05448 54.6667 0.666667 57.0545 0.666667 60ZM28.9006 28.8162L29.7066 29.4081L28.9006 28.8162ZM75.5 28V27H30.5126V28V29H75.5V28ZM28.9006 28.8162L28.0946 28.2243L5.194 59.4081L6 60L6.806 60.5919L29.7066 29.4081L28.9006 28.8162ZM30.5126 28V27C29.5577 27 28.6598 27.4546 28.0946 28.2243L28.9006 28.8162L29.7066 29.4081C29.895 29.1515 30.1943 29 30.5126 29V28Z" fill="black"/>
|
||||||
|
<rect x="15.5" width="74" height="30" rx="15" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 687 B |
9
wake/Assets/Svg/BlindNone.svg
Normal file
|
After Width: | Height: | Size: 134 KiB |
5
wake/Assets/Svg/Box.svg
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M16.6667 5H3.33333C2.8731 5 2.5 5.3731 2.5 5.83333V16.6667C2.5 17.1269 2.8731 17.5 3.33333 17.5H16.6667C17.1269 17.5 17.5 17.1269 17.5 16.6667V5.83333C17.5 5.3731 17.1269 5 16.6667 5Z" stroke="black" stroke-width="1.66667" stroke-linejoin="round"/>
|
||||||
|
<path d="M7.479 10.0034H12.479" stroke="black" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M2.5 5.41683L5.41667 2.0835H14.5833L17.5 5.41683" stroke="black" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 626 B |
24
wake/Assets/Svg/Free.svg
Normal file
|
After Width: | Height: | Size: 17 KiB |
9
wake/Assets/Svg/IP.svg
Normal file
|
After Width: | Height: | Size: 184 KiB |
9
wake/Assets/Svg/IP1.svg
Normal file
|
After Width: | Height: | Size: 183 KiB |
11
wake/Assets/Svg/JoinList.svg
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_6596_2873)">
|
||||||
|
<rect x="0.0969238" y="0.567871" width="30.9623" height="30.9623" rx="15.4812" transform="rotate(0.179418 0.0969238 0.567871)" fill="black"/>
|
||||||
|
<path d="M15.578 0.597365L28.9286 23.8893L2.08191 23.8052L15.578 0.597365Z" fill="#D9D9D9"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_6596_2873">
|
||||||
|
<rect x="0.0969238" y="0.567871" width="30.9623" height="30.9623" rx="15.4812" transform="rotate(0.179418 0.0969238 0.567871)" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 582 B |
45
wake/Assets/Svg/Light1.svg
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
<svg width="372" height="504" viewBox="0 0 372 504" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g opacity="0.8" filter="url(#filter0_dddddd_6966_2904)">
|
||||||
|
<path d="M340 427.5V38C340 28.0589 331.941 20 322 20H97.5552C88.5728 20 80.9652 26.6222 79.7271 35.5189L77.5056 51.4811C76.2675 60.3778 68.6599 67 59.6775 67H38C28.0589 67 20 75.0589 20 85V466C20 475.941 28.0589 484 38 484H249.463C255.425 484 261 481.048 264.351 476.117L279.8 453.383C283.151 448.452 288.726 445.5 294.688 445.5H322C331.941 445.5 340 437.441 340 427.5Z" fill="url(#paint0_linear_6966_2904)"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<filter id="filter0_dddddd_6966_2904" x="-11.1789" y="-11.1789" width="382.358" height="526.358" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||||
|
<feOffset/>
|
||||||
|
<feGaussianBlur stdDeviation="0.371177"/>
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||||
|
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_6966_2904"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||||
|
<feOffset/>
|
||||||
|
<feGaussianBlur stdDeviation="0.742355"/>
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||||
|
<feBlend mode="normal" in2="effect1_dropShadow_6966_2904" result="effect2_dropShadow_6966_2904"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||||
|
<feOffset/>
|
||||||
|
<feGaussianBlur stdDeviation="2.59824"/>
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||||
|
<feBlend mode="normal" in2="effect2_dropShadow_6966_2904" result="effect3_dropShadow_6966_2904"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||||
|
<feOffset/>
|
||||||
|
<feGaussianBlur stdDeviation="5.19648"/>
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||||
|
<feBlend mode="normal" in2="effect3_dropShadow_6966_2904" result="effect4_dropShadow_6966_2904"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||||
|
<feOffset/>
|
||||||
|
<feGaussianBlur stdDeviation="8.90826"/>
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||||
|
<feBlend mode="normal" in2="effect4_dropShadow_6966_2904" result="effect5_dropShadow_6966_2904"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||||
|
<feOffset/>
|
||||||
|
<feGaussianBlur stdDeviation="15.5894"/>
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||||
|
<feBlend mode="normal" in2="effect5_dropShadow_6966_2904" result="effect6_dropShadow_6966_2904"/>
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="effect6_dropShadow_6966_2904" result="shape"/>
|
||||||
|
</filter>
|
||||||
|
<linearGradient id="paint0_linear_6966_2904" x1="240.5" y1="150" x2="16.0608" y2="502.337" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="white"/>
|
||||||
|
<stop offset="1" stop-color="#FFE688"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.1 KiB |
48
wake/Assets/Svg/Light2.svg
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<svg width="384" height="504" viewBox="0 0 384 504" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g opacity="0.8" filter="url(#filter0_dddddd_6966_2902)">
|
||||||
|
<path d="M352 427.5V38C352 28.0589 343.941 20 334 20H109.555C100.573 20 92.9652 26.6222 91.7271 35.5189L89.5056 51.4811C88.2675 60.3778 80.6599 67 71.6775 67H50C40.0589 67 32 75.0589 32 85V466C32 475.941 40.0589 484 50 484H261.463C267.425 484 273 481.048 276.351 476.117L291.8 453.383C295.151 448.452 300.726 445.5 306.688 445.5H334C343.941 445.5 352 437.441 352 427.5Z" fill="url(#paint0_linear_6966_2902)"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<filter id="filter0_dddddd_6966_2902" x="0.821104" y="-11.1789" width="382.358" height="526.358" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||||
|
<feOffset/>
|
||||||
|
<feGaussianBlur stdDeviation="0.371177"/>
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||||
|
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_6966_2902"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||||
|
<feOffset/>
|
||||||
|
<feGaussianBlur stdDeviation="0.742355"/>
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||||
|
<feBlend mode="normal" in2="effect1_dropShadow_6966_2902" result="effect2_dropShadow_6966_2902"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||||
|
<feOffset/>
|
||||||
|
<feGaussianBlur stdDeviation="2.59824"/>
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||||
|
<feBlend mode="normal" in2="effect2_dropShadow_6966_2902" result="effect3_dropShadow_6966_2902"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||||
|
<feOffset/>
|
||||||
|
<feGaussianBlur stdDeviation="5.19648"/>
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||||
|
<feBlend mode="normal" in2="effect3_dropShadow_6966_2902" result="effect4_dropShadow_6966_2902"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||||
|
<feOffset/>
|
||||||
|
<feGaussianBlur stdDeviation="8.90826"/>
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||||
|
<feBlend mode="normal" in2="effect4_dropShadow_6966_2902" result="effect5_dropShadow_6966_2902"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||||
|
<feOffset/>
|
||||||
|
<feGaussianBlur stdDeviation="15.5894"/>
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||||
|
<feBlend mode="normal" in2="effect5_dropShadow_6966_2902" result="effect6_dropShadow_6966_2902"/>
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="effect6_dropShadow_6966_2902" result="shape"/>
|
||||||
|
</filter>
|
||||||
|
<linearGradient id="paint0_linear_6966_2902" x1="386.214" y1="6.49997" x2="28.9202" y2="503.128" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#FFEDAA"/>
|
||||||
|
<stop offset="0.293269" stop-color="#FFFBEA"/>
|
||||||
|
<stop offset="0.331731" stop-color="#FFFBED"/>
|
||||||
|
<stop offset="0.600962" stop-color="white"/>
|
||||||
|
<stop offset="1" stop-color="#FDEBA6"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.3 KiB |
45
wake/Assets/Svg/Light3.svg
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
<svg width="372" height="504" viewBox="0 0 372 504" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g opacity="0.8" filter="url(#filter0_dddddd_6966_2903)">
|
||||||
|
<path d="M352 427.5V38C352 28.0589 343.941 20 334 20H109.555C100.573 20 92.9652 26.6222 91.7271 35.5189L89.5056 51.4811C88.2675 60.3778 80.6599 67 71.6775 67H50C40.0589 67 32 75.0589 32 85V466C32 475.941 40.0589 484 50 484H261.463C267.425 484 273 481.048 276.351 476.117L291.8 453.383C295.151 448.452 300.726 445.5 306.688 445.5H334C343.941 445.5 352 437.441 352 427.5Z" fill="url(#paint0_linear_6966_2903)"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<filter id="filter0_dddddd_6966_2903" x="0.821104" y="-11.1789" width="382.358" height="526.358" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||||
|
<feOffset/>
|
||||||
|
<feGaussianBlur stdDeviation="0.371177"/>
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||||
|
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_6966_2903"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||||
|
<feOffset/>
|
||||||
|
<feGaussianBlur stdDeviation="0.742355"/>
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||||
|
<feBlend mode="normal" in2="effect1_dropShadow_6966_2903" result="effect2_dropShadow_6966_2903"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||||
|
<feOffset/>
|
||||||
|
<feGaussianBlur stdDeviation="2.59824"/>
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||||
|
<feBlend mode="normal" in2="effect2_dropShadow_6966_2903" result="effect3_dropShadow_6966_2903"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||||
|
<feOffset/>
|
||||||
|
<feGaussianBlur stdDeviation="5.19648"/>
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||||
|
<feBlend mode="normal" in2="effect3_dropShadow_6966_2903" result="effect4_dropShadow_6966_2903"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||||
|
<feOffset/>
|
||||||
|
<feGaussianBlur stdDeviation="8.90826"/>
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||||
|
<feBlend mode="normal" in2="effect4_dropShadow_6966_2903" result="effect5_dropShadow_6966_2903"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||||
|
<feOffset/>
|
||||||
|
<feGaussianBlur stdDeviation="15.5894"/>
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||||
|
<feBlend mode="normal" in2="effect5_dropShadow_6966_2903" result="effect6_dropShadow_6966_2903"/>
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="effect6_dropShadow_6966_2903" result="shape"/>
|
||||||
|
</filter>
|
||||||
|
<linearGradient id="paint0_linear_6966_2903" x1="119" y1="382" x2="372.15" y2="-12.5078" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="white"/>
|
||||||
|
<stop offset="1" stop-color="#FFE78E"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.1 KiB |
19
wake/Assets/Svg/LoadingNpng.svg
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||||
|
<!-- 定义动画 -->
|
||||||
|
<style>
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
.spinner {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
transform-origin: 50% 50%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- 圆形加载动画 -->
|
||||||
|
<g class="spinner">
|
||||||
|
<circle cx="50" cy="50" r="40" fill="none" stroke="#3498db" stroke-width="8" stroke-dasharray="251.2" stroke-dashoffset="0"/>
|
||||||
|
<circle cx="50" cy="50" r="20" fill="#3498db"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 588 B |
5
wake/Assets/Svg/Memory.svg
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M16.25 2.5H3.75C3.05965 2.5 2.5 3.05965 2.5 3.75V16.25C2.5 16.9404 3.05965 17.5 3.75 17.5H16.25C16.9404 17.5 17.5 16.9404 17.5 16.25V3.75C17.5 3.05965 16.9404 2.5 16.25 2.5Z" stroke="black" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M7.49996 9.58317C8.65054 9.58317 9.58329 8.65042 9.58329 7.49984C9.58329 6.34925 8.65054 5.4165 7.49996 5.4165C6.34938 5.4165 5.41663 6.34925 5.41663 7.49984C5.41663 8.65042 6.34938 9.58317 7.49996 9.58317Z" stroke="black" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M17.5 15.0002L12.9167 10.8335L8.75 14.5835L5.83333 12.0835L2.5 14.5835" stroke="black" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 846 B |
5
wake/Assets/Svg/Permission.svg
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M16.6667 9.1665H3.33333C2.8731 9.1665 2.5 9.5396 2.5 9.99984V17.4998C2.5 17.9601 2.8731 18.3332 3.33333 18.3332H16.6667C17.1269 18.3332 17.5 17.9601 17.5 17.4998V9.99984C17.5 9.5396 17.1269 9.1665 16.6667 9.1665Z" stroke="black" stroke-width="1.66667" stroke-linejoin="round"/>
|
||||||
|
<path d="M5.8335 9.1665V5.83317C5.8335 3.53198 7.699 1.6665 10.0002 1.6665C12.3013 1.6665 14.1668 3.53198 14.1668 5.83317V9.1665" stroke="black" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M10 12.5V15" stroke="black" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 716 B |
14
wake/Assets/Svg/Pioneer.svg
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<svg width="274" height="120" viewBox="0 0 274 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 120H256C265.941 120 274 111.941 274 102V60.6067C274 50.6656 265.941 42.6067 256 42.6067H226.773C221.12 42.6067 215.795 39.9507 212.394 35.4346L197.136 15.1722C193.735 10.656 188.41 8 182.757 8H18C8.05887 8 0 16.0589 0 26V102C0 111.941 8.05888 120 18 120Z" fill="#FFB645"/>
|
||||||
|
<rect x="203.396" width="54.9113" height="54.9113" rx="27.4556" transform="rotate(13.0475 203.396 0)" fill="black"/>
|
||||||
|
<path d="M227.444 17.8455L235.272 43.5257L209.119 37.4648L227.444 17.8455Z" fill="#D9D9D9"/>
|
||||||
|
<path d="M13.4714 27.032C13.9079 27.032 14.2938 26.7489 14.4247 26.3326L17.2577 17.3222C17.4603 16.6778 16.9786 16.0216 16.303 16.0216L12.1813 16.0216C11.7444 16.0216 11.3583 16.3052 11.2278 16.7221L8.40683 25.7324C8.20516 26.3766 8.68677 27.0319 9.36191 27.0319L13.4714 27.032Z" fill="black"/>
|
||||||
|
<path d="M52.1379 27.032C52.5744 27.032 52.9603 26.7489 53.0912 26.3326L55.9242 17.3222C56.1269 16.6778 55.6452 16.0216 54.9695 16.0216L50.8478 16.0216C50.4109 16.0216 50.0248 16.3052 49.8943 16.7221L47.0733 25.7324C46.8717 26.3766 47.3533 27.0319 48.0284 27.0319L52.1379 27.032Z" fill="black"/>
|
||||||
|
<path d="M23.1382 27.031C23.5747 27.0311 23.9605 26.748 24.0914 26.3317L26.9245 17.3212C27.1271 16.6768 26.6454 16.0206 25.9697 16.0206L21.8481 16.0206C21.4112 16.0206 21.025 16.3043 20.8945 16.7211L18.0736 25.7315C17.8719 26.3756 18.3535 27.0309 19.0287 27.0309L23.1382 27.031Z" fill="black"/>
|
||||||
|
<path d="M42.4714 27.031C42.9079 27.0311 43.2938 26.748 43.4247 26.3317L46.2577 17.3212C46.4603 16.6768 45.9786 16.0206 45.303 16.0206L41.1813 16.0206C40.7444 16.0206 40.3583 16.3043 40.2278 16.7211L37.4068 25.7315C37.2052 26.3756 37.6868 27.0309 38.3619 27.0309L42.4714 27.031Z" fill="black"/>
|
||||||
|
<path d="M32.8049 27.031C33.2414 27.0311 33.6273 26.748 33.7582 26.3317L36.5912 17.3212C36.7938 16.6768 36.3121 16.0206 35.6365 16.0206L31.5148 16.0206C31.0779 16.0206 30.6918 16.3043 30.5613 16.7211L27.7403 25.7315C27.5387 26.3756 28.0203 27.0309 28.6954 27.0309L32.8049 27.031Z" fill="black"/>
|
||||||
|
<path d="M61.8049 27.031C62.2414 27.0311 62.6273 26.748 62.7582 26.3317L65.5912 17.3212C65.7938 16.6768 65.3121 16.0206 64.6365 16.0206L60.5148 16.0206C60.0779 16.0206 59.6918 16.3043 59.5613 16.7211L56.7403 25.7315C56.5387 26.3756 57.0203 27.0309 57.6954 27.0309L61.8049 27.031Z" fill="black"/>
|
||||||
|
<path d="M252.201 111.98C252.637 111.98 253.023 111.697 253.154 111.281L255.97 102.323C256.173 101.679 255.691 101.023 255.016 101.023L250.924 101.023C250.487 101.023 250.101 101.306 249.97 101.723L247.166 110.681C246.964 111.325 247.446 111.98 248.121 111.98L252.201 111.98Z" fill="black"/>
|
||||||
|
<path d="M242.581 111.98C243.017 111.98 243.403 111.697 243.534 111.281L246.35 102.323C246.553 101.679 246.071 101.023 245.396 101.023L241.304 101.023C240.867 101.023 240.481 101.306 240.35 101.723L237.546 110.681C237.344 111.325 237.826 111.98 238.501 111.98L242.581 111.98Z" fill="black"/>
|
||||||
|
<path d="M261.821 111.98C262.258 111.98 262.644 111.697 262.775 111.281L265.591 102.323C265.794 101.679 265.312 101.023 264.636 101.023L260.545 101.023C260.108 101.023 259.721 101.306 259.591 101.723L256.787 110.681C256.585 111.325 257.066 111.98 257.742 111.98L261.821 111.98Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.2 KiB |
4
wake/Assets/Svg/Set.svg
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M7.61821 17.9878C6.22192 17.5721 4.97904 16.8003 3.9949 15.7777C4.36204 15.3426 4.58329 14.7804 4.58329 14.1665C4.58329 12.7858 3.46401 11.6665 2.08329 11.6665C1.99977 11.6665 1.91721 11.6706 1.83579 11.6786C1.72487 11.1363 1.66663 10.5749 1.66663 9.99984C1.66663 9.12876 1.80028 8.28888 2.04821 7.49959C2.05988 7.49976 2.07158 7.49984 2.08329 7.49984C3.46401 7.49984 4.58329 6.38055 4.58329 4.99984C4.58329 4.60347 4.49104 4.22867 4.32688 3.89568C5.29058 2.99959 6.46683 2.32897 7.77167 1.96777C8.18513 2.77821 9.02775 3.33319 9.99996 3.33319C10.9722 3.33319 11.8148 2.77821 12.2283 1.96777C13.5331 2.32897 14.7093 2.99959 15.673 3.89568C15.5089 4.22867 15.4166 4.60347 15.4166 4.99984C15.4166 6.38055 16.5359 7.49984 17.9166 7.49984C17.9283 7.49984 17.94 7.49976 17.9517 7.49959C18.1996 8.28888 18.3333 9.12876 18.3333 9.99984C18.3333 10.5749 18.275 11.1363 18.1641 11.6786C18.0827 11.6706 18.0002 11.6665 17.9166 11.6665C16.5359 11.6665 15.4166 12.7858 15.4166 14.1665C15.4166 14.7804 15.6379 15.3426 16.005 15.7777C15.0209 16.8003 13.778 17.5721 12.3817 17.9878C12.0595 16.9798 11.115 16.2498 9.99996 16.2498C8.88496 16.2498 7.94046 16.9798 7.61821 17.9878Z" stroke="black" stroke-width="1.66667" stroke-linejoin="round"/>
|
||||||
|
<path d="M10 12.9168C11.6109 12.9168 12.9167 11.611 12.9167 10.0002C12.9167 8.38933 11.6109 7.0835 10 7.0835C8.38921 7.0835 7.08337 8.38933 7.08337 10.0002C7.08337 11.611 8.38921 12.9168 10 12.9168Z" stroke="black" stroke-width="1.66667" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
12
wake/Assets/Svg/Suport.svg
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_6215_3454)">
|
||||||
|
<path d="M15 13.3332C16.841 13.3332 18.3333 11.8408 18.3333 9.99984C18.3333 8.15888 16.841 6.6665 15 6.6665" stroke="black" stroke-width="1.66667" stroke-linejoin="round"/>
|
||||||
|
<path d="M4.99984 6.6665C3.15889 6.6665 1.6665 8.15888 1.6665 9.99984C1.6665 11.8408 3.15889 13.3332 4.99984 13.3332" stroke="black" stroke-width="1.66667" stroke-linejoin="round"/>
|
||||||
|
<path d="M5 13.3332V13.1248V12.0832V9.99984V6.6665C5 3.90508 7.23858 1.6665 10 1.6665C12.7614 1.6665 15 3.90508 15 6.6665V13.3332C15 16.0946 12.7614 18.3332 10 18.3332" stroke="black" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_6215_3454">
|
||||||
|
<rect width="20" height="20" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 860 B |
11
wake/Assets/Svg/Tips.svg
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_7357_2809)">
|
||||||
|
<path d="M3.33325 12.6668V6.00016C3.33325 3.42283 5.42259 1.3335 7.99992 1.3335C10.5773 1.3335 12.6666 3.42283 12.6666 6.00016V12.6668M1.33325 12.6668H14.6666" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M7.99992 14.6665C8.92039 14.6665 9.66659 13.9203 9.66659 12.9998V12.6665H6.33325V12.9998C6.33325 13.9203 7.07945 14.6665 7.99992 14.6665Z" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_7357_2809">
|
||||||
|
<rect width="16" height="16" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 731 B |
10
wake/Assets/Svg/Upload.svg
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<mask id="mask0_7079_2856" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="20" height="20">
|
||||||
|
<path d="M20 0H0V20H20V0Z" fill="black"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask0_7079_2856)">
|
||||||
|
<path d="M2.5 10.0035V17.5H17.5V10" stroke="black" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M13.75 6.25L10 2.5L6.25 6.25" stroke="black" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M9.99658 13.3333V2.5" stroke="black" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 672 B |
5
wake/Assets/Svg/User.svg
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3.97485 5.97461H19.9748" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M3.97485 11.9746H19.9748" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M3.97485 17.9746H19.9748" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 451 B |
46
wake/Components/Lottie/LottieView.swift
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Lottie
|
||||||
|
|
||||||
|
struct LottieView: UIViewRepresentable {
|
||||||
|
let name: String
|
||||||
|
let loopMode: LottieLoopMode
|
||||||
|
let animationSpeed: CGFloat
|
||||||
|
|
||||||
|
init(name: String, loopMode: LottieLoopMode = .loop, animationSpeed: CGFloat = 1.0) {
|
||||||
|
self.name = name
|
||||||
|
self.loopMode = loopMode
|
||||||
|
self.animationSpeed = animationSpeed
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> LottieAnimationView {
|
||||||
|
let animationView = LottieAnimationView()
|
||||||
|
|
||||||
|
// 方法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
|
||||||
|
|
||||||
|
// 播放动画
|
||||||
|
animationView.play()
|
||||||
|
|
||||||
|
return animationView
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: LottieAnimationView, context: Context) {
|
||||||
|
// 确保动画持续播放
|
||||||
|
if !uiView.isAnimationPlaying {
|
||||||
|
uiView.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1084
wake/ContentView.swift
Normal file
@ -1,2 +0,0 @@
|
|||||||
# Core/DesignSystem
|
|
||||||
存放 `Theme.swift`、`Typography.swift` 等设计系统文件。
|
|
||||||
@ -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,2 +0,0 @@
|
|||||||
# Core/Network
|
|
||||||
通用网络层与配置:`NetworkService.swift`、`APIConfig.swift`、ApiClient 公共代码。
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
# Core
|
|
||||||
|
|
||||||
跨特性共享的核心能力:
|
|
||||||
- DesignSystem(主题、字体、间距)
|
|
||||||
- Navigation(路由/导航栈)
|
|
||||||
- Diagnostics(性能与日志)
|
|
||||||
- Network(网络与配置)
|
|
||||||
|
|
||||||
建议通过 Xcode Group 先完成组织,再视需要同步到磁盘。
|
|
||||||
@ -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` 等。
|
|
||||||
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 |
@ -16,41 +16,6 @@ struct MemberProfileResponse: Codable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
// MARK: - MemberProfile
|
||||||
struct MemberProfile: Codable {
|
struct MemberProfile: Codable {
|
||||||
let materialCounter: MaterialCounter
|
let materialCounter: MaterialCounter
|
||||||
@ -61,7 +26,7 @@ struct MemberProfile: Codable {
|
|||||||
let totalPoints: Int
|
let totalPoints: Int
|
||||||
let usedBytes: Int
|
let usedBytes: Int
|
||||||
let totalBytes: Int
|
let totalBytes: Int
|
||||||
let titleRankings: [TitleRanking]
|
let titleRankings: [String]
|
||||||
let medalInfos: [MedalInfo]
|
let medalInfos: [MedalInfo]
|
||||||
let membershipLevel: String
|
let membershipLevel: String
|
||||||
let membershipEndAt: String
|
let membershipEndAt: String
|
||||||
@ -92,7 +57,7 @@ struct MemberProfile: Codable {
|
|||||||
totalPoints = try container.decode(Int.self, forKey: .totalPoints)
|
totalPoints = try container.decode(Int.self, forKey: .totalPoints)
|
||||||
usedBytes = try container.decode(Int.self, forKey: .usedBytes)
|
usedBytes = try container.decode(Int.self, forKey: .usedBytes)
|
||||||
totalBytes = try container.decode(Int.self, forKey: .totalBytes)
|
totalBytes = try container.decode(Int.self, forKey: .totalBytes)
|
||||||
titleRankings = try container.decode([TitleRanking].self, forKey: .titleRankings)
|
titleRankings = try container.decode([String].self, forKey: .titleRankings)
|
||||||
|
|
||||||
if let medalInfos = try? container.decode([MedalInfo].self, forKey: .medalInfos) {
|
if let medalInfos = try? container.decode([MedalInfo].self, forKey: .medalInfos) {
|
||||||
self.medalInfos = medalInfos
|
self.medalInfos = medalInfos
|
||||||
|
|||||||
@ -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`。
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
# SharedUI/Controls
|
|
||||||
通用按钮、输入控件等。
|
|
||||||
@ -1,99 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
/// 一个可配置的圆形视图封装,便于在项目中复用。
|
|
||||||
/// 使用示例:
|
|
||||||
/// CircleView(diameter: 24, color: .orange, stroke: .white, lineWidth: 2)
|
|
||||||
public struct CircleView: View {
|
|
||||||
public var diameter: CGFloat
|
|
||||||
public var color: Color
|
|
||||||
public var stroke: Color? = nil
|
|
||||||
public var lineWidth: CGFloat = 1
|
|
||||||
|
|
||||||
public init(
|
|
||||||
diameter: CGFloat,
|
|
||||||
color: Color = .black,
|
|
||||||
stroke: Color? = nil,
|
|
||||||
lineWidth: CGFloat = 1
|
|
||||||
) {
|
|
||||||
self.diameter = diameter
|
|
||||||
self.color = color
|
|
||||||
self.stroke = stroke
|
|
||||||
self.lineWidth = lineWidth
|
|
||||||
}
|
|
||||||
|
|
||||||
public var body: some View {
|
|
||||||
Group {
|
|
||||||
if let stroke = stroke, lineWidth > 0 {
|
|
||||||
Circle()
|
|
||||||
.fill(color)
|
|
||||||
.overlay(
|
|
||||||
Circle().stroke(stroke, lineWidth: lineWidth)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Circle().fill(color)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(width: diameter, height: diameter)
|
|
||||||
.accessibilityHidden(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 水平渲染多个等尺寸圆形的小组件。
|
|
||||||
public struct CircleRow: View {
|
|
||||||
public var count: Int
|
|
||||||
public var diameter: CGFloat
|
|
||||||
public var color: Color
|
|
||||||
public var spacing: CGFloat
|
|
||||||
|
|
||||||
public init(
|
|
||||||
count: Int,
|
|
||||||
diameter: CGFloat,
|
|
||||||
color: Color = .black,
|
|
||||||
spacing: CGFloat = 8
|
|
||||||
) {
|
|
||||||
self.count = count
|
|
||||||
self.diameter = diameter
|
|
||||||
self.color = color
|
|
||||||
self.spacing = spacing
|
|
||||||
}
|
|
||||||
|
|
||||||
public var body: some View {
|
|
||||||
HStack(spacing: spacing) {
|
|
||||||
ForEach(0..<(max(0, count)), id: \.self) { _ in
|
|
||||||
CircleView(diameter: diameter, color: color)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.accessibilityElement(children: .ignore)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
struct CircleView_Previews: PreviewProvider {
|
|
||||||
static var previews: some View {
|
|
||||||
Group {
|
|
||||||
CircleView(diameter: 40, color: .black, stroke: .white, lineWidth: 2)
|
|
||||||
.padding()
|
|
||||||
.previewDisplayName("Single Circle")
|
|
||||||
|
|
||||||
ZStack {
|
|
||||||
Color(.black)
|
|
||||||
HStack {
|
|
||||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
||||||
.fill(.white)
|
|
||||||
.shadow(color: Color.black.opacity(0.06), radius: 6, x: 0, y: 2)
|
|
||||||
.overlay(
|
|
||||||
HStack {
|
|
||||||
CircleRow(count: 6, diameter: 10, color: .black, spacing: 6)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
)
|
|
||||||
.frame(height: 56)
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.previewDisplayName("Circle Row")
|
|
||||||
}
|
|
||||||
.previewLayout(.sizeThatFits)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@ -1,174 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
/// A skewed rounded rectangle rendered via shearing a RoundedRectangle.
|
|
||||||
/// - Parameters:
|
|
||||||
/// - shear: Horizontal shear factor. Positive values lean to the right. Typical 0.15 ~ 0.35
|
|
||||||
/// - cornerRadius: Corner radius of the base rounded rectangle before shear.
|
|
||||||
struct ParallelogramShape: InsettableShape {
|
|
||||||
var shear: CGFloat = 0.25
|
|
||||||
var cornerRadius: CGFloat = 6
|
|
||||||
var insetAmount: CGFloat = 0
|
|
||||||
|
|
||||||
var animatableData: AnimatablePair<CGFloat, CGFloat> {
|
|
||||||
get { AnimatablePair(shear, cornerRadius) }
|
|
||||||
set {
|
|
||||||
shear = newValue.first
|
|
||||||
cornerRadius = newValue.second
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func inset(by amount: CGFloat) -> ParallelogramShape {
|
|
||||||
var copy = self
|
|
||||||
copy.insetAmount += amount
|
|
||||||
return copy
|
|
||||||
}
|
|
||||||
|
|
||||||
func path(in rect: CGRect) -> Path {
|
|
||||||
// To keep the final shape inside `rect`, we draw a rounded rectangle inset by half of the shear expansion
|
|
||||||
// and apply a shear transform around the vertical center.
|
|
||||||
let h = rect.height
|
|
||||||
let expandX = abs(shear) * h // total width expansion after shearing around center
|
|
||||||
let insetX = expandX / 2 + insetAmount
|
|
||||||
let insetRect = rect.insetBy(dx: insetX, dy: insetAmount)
|
|
||||||
|
|
||||||
let rr = RoundedRectangle(cornerRadius: max(0, cornerRadius - insetAmount), style: .continuous)
|
|
||||||
var path = rr.path(in: insetRect)
|
|
||||||
|
|
||||||
// Transform: translate to center, shear, translate back
|
|
||||||
let toCenter = CGAffineTransform(translationX: 0, y: -rect.midY)
|
|
||||||
let shearTransform = CGAffineTransform(a: 1, b: 0, c: shear, d: 1, tx: 0, ty: 0)
|
|
||||||
let back = CGAffineTransform(translationX: 0, y: rect.midY)
|
|
||||||
path = path.applying(toCenter).applying(shearTransform).applying(back)
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A configurable parallelogram view.
|
|
||||||
/// Example:
|
|
||||||
/// ParallelogramView(width: 36, height: 18, shear: 0.3, cornerRadius: 5, color: .black)
|
|
||||||
public struct ParallelogramView: View {
|
|
||||||
public var width: CGFloat
|
|
||||||
public var height: CGFloat
|
|
||||||
public var shear: CGFloat
|
|
||||||
public var cornerRadius: CGFloat
|
|
||||||
public var color: Color
|
|
||||||
public var stroke: Color? = nil
|
|
||||||
public var lineWidth: CGFloat = 1
|
|
||||||
|
|
||||||
public init(
|
|
||||||
width: CGFloat,
|
|
||||||
height: CGFloat,
|
|
||||||
shear: CGFloat = 0.25,
|
|
||||||
cornerRadius: CGFloat = 6,
|
|
||||||
color: Color = .black,
|
|
||||||
stroke: Color? = nil,
|
|
||||||
lineWidth: CGFloat = 1
|
|
||||||
) {
|
|
||||||
self.width = width
|
|
||||||
self.height = height
|
|
||||||
self.shear = shear
|
|
||||||
self.cornerRadius = cornerRadius
|
|
||||||
self.color = color
|
|
||||||
self.stroke = stroke
|
|
||||||
self.lineWidth = lineWidth
|
|
||||||
}
|
|
||||||
|
|
||||||
public var body: some View {
|
|
||||||
let shape = ParallelogramShape(shear: shear, cornerRadius: cornerRadius)
|
|
||||||
Group {
|
|
||||||
if let stroke = stroke, lineWidth > 0 {
|
|
||||||
shape.fill(color)
|
|
||||||
.overlay(
|
|
||||||
shape.stroke(stroke, lineWidth: lineWidth)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
shape.fill(color)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(width: width, height: height)
|
|
||||||
.accessibilityHidden(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A horizontal row that renders a given number of parallelograms.
|
|
||||||
public struct ParallelogramRow: View {
|
|
||||||
public var count: Int
|
|
||||||
public var itemSize: CGSize
|
|
||||||
public var shear: CGFloat
|
|
||||||
public var cornerRadius: CGFloat
|
|
||||||
public var color: Color
|
|
||||||
public var spacing: CGFloat
|
|
||||||
|
|
||||||
public init(
|
|
||||||
count: Int,
|
|
||||||
itemSize: CGSize,
|
|
||||||
shear: CGFloat = 0.25,
|
|
||||||
cornerRadius: CGFloat = 6,
|
|
||||||
color: Color = .black,
|
|
||||||
spacing: CGFloat = 8
|
|
||||||
) {
|
|
||||||
self.count = count
|
|
||||||
self.itemSize = itemSize
|
|
||||||
self.shear = shear
|
|
||||||
self.cornerRadius = cornerRadius
|
|
||||||
self.color = color
|
|
||||||
self.spacing = spacing
|
|
||||||
}
|
|
||||||
|
|
||||||
public var body: some View {
|
|
||||||
HStack(spacing: spacing) {
|
|
||||||
ForEach(0..<(max(0, count)), id: \.self) { _ in
|
|
||||||
ParallelogramView(
|
|
||||||
width: itemSize.width,
|
|
||||||
height: itemSize.height,
|
|
||||||
shear: shear,
|
|
||||||
cornerRadius: cornerRadius,
|
|
||||||
color: color
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.accessibilityElement(children: .ignore)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
struct Parallelogram_Previews: PreviewProvider {
|
|
||||||
static var previews: some View {
|
|
||||||
Group {
|
|
||||||
// Single parallelogram preview
|
|
||||||
ParallelogramView(width: 20, height: 18, shear: -0.3, cornerRadius: 2, color: .black)
|
|
||||||
.padding()
|
|
||||||
.previewDisplayName("Single")
|
|
||||||
|
|
||||||
// Row preview (similar to the screenshot)
|
|
||||||
ZStack {
|
|
||||||
Color(white: 0.97)
|
|
||||||
HStack {
|
|
||||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
|
||||||
.fill(.white)
|
|
||||||
.shadow(color: Color.black.opacity(0.06), radius: 6, x: 0, y: 2)
|
|
||||||
.overlay(
|
|
||||||
HStack {
|
|
||||||
ParallelogramRow(
|
|
||||||
count: 5,
|
|
||||||
itemSize: CGSize(width: 18, height: 20),
|
|
||||||
shear: -0.3,
|
|
||||||
cornerRadius: 2,
|
|
||||||
color: .black,
|
|
||||||
spacing: 2
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 18)
|
|
||||||
)
|
|
||||||
.frame(height: 64)
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.previewDisplayName("Row")
|
|
||||||
}
|
|
||||||
.previewLayout(.sizeThatFits)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
# SharedUI/Graphics
|
|
||||||
共享图形包装。
|
|
||||||
@ -1,110 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
/// 顶部带“内凹”弧形的连续圆角矩形
|
|
||||||
struct ScoopRoundedRect: Shape {
|
|
||||||
var cornerRadius: CGFloat = 20
|
|
||||||
/// 凹凸的“深度”:>0 向下凸(更接近你截图的效果),<0 为向上凹
|
|
||||||
var scoopDepth: CGFloat = 10
|
|
||||||
/// 半宽:控制水平占据范围(越大越“平缓”)
|
|
||||||
var scoopHalfWidth: CGFloat = 18
|
|
||||||
/// 相对位置(0~1,0.5 正中)
|
|
||||||
var scoopCenterX: CGFloat = 0.33
|
|
||||||
/// 是否向下凸;为 false 时表现为向上“凹”(与 notch 类似)
|
|
||||||
var convexDown: Bool = true
|
|
||||||
/// 凹陷/鼓包底部的“平底”半宽(0 为无平底)
|
|
||||||
var flatHalfWidth: CGFloat = 8
|
|
||||||
|
|
||||||
func path(in rect: CGRect) -> Path {
|
|
||||||
let r = min(cornerRadius, min(rect.width, rect.height) * 0.5)
|
|
||||||
let topY = rect.minY
|
|
||||||
|
|
||||||
// 约束中心与范围,避免穿出圆角
|
|
||||||
let minX = rect.minX + r
|
|
||||||
let maxX = rect.maxX - r
|
|
||||||
let centerX = rect.minX + rect.width * scoopCenterX
|
|
||||||
let hw = min(scoopHalfWidth, (maxX - minX) * 0.45)
|
|
||||||
let flatHW = max(0, min(flatHalfWidth, hw * 0.8))
|
|
||||||
let shoulder = max(1, hw - flatHW) // 两侧曲线的水平长度
|
|
||||||
let startX = max(minX, centerX - (flatHW + shoulder))
|
|
||||||
let endX = min(maxX, centerX + (flatHW + shoulder))
|
|
||||||
let leftFlatX = max(minX, centerX - flatHW)
|
|
||||||
let rightFlatX = min(maxX, centerX + flatHW)
|
|
||||||
let depth = (convexDown ? 1 : -1) * scoopDepth
|
|
||||||
|
|
||||||
// 左曲线:P0 -> Lf(水平);右曲线:Rf -> P3(水平);Lf~Rf 之间是一段水平直线
|
|
||||||
let P0 = CGPoint(x: startX, y: topY)
|
|
||||||
let Lf = CGPoint(x: leftFlatX, y: topY + depth)
|
|
||||||
let Rf = CGPoint(x: rightFlatX, y: topY + depth)
|
|
||||||
let P3 = CGPoint(x: endX, y: topY)
|
|
||||||
|
|
||||||
// 使用圆角近似系数控制手柄长度(基于 shoulder)
|
|
||||||
let k = shoulder * 0.5522847498
|
|
||||||
let C1 = CGPoint(x: P0.x + k, y: P0.y) // P0 水平切线
|
|
||||||
let C2 = CGPoint(x: Lf.x - k, y: Lf.y) // Lf 水平切线
|
|
||||||
let C3 = CGPoint(x: Rf.x + k, y: Rf.y) // Rf 水平切线
|
|
||||||
let C4 = CGPoint(x: P3.x - k, y: P3.y) // P3 水平切线
|
|
||||||
|
|
||||||
var p = Path()
|
|
||||||
// 顶部从左上圆角起
|
|
||||||
p.move(to: CGPoint(x: rect.minX + r, y: topY))
|
|
||||||
// 左侧直线到凹/凸开始
|
|
||||||
p.addLine(to: P0)
|
|
||||||
// 左侧进入曲线
|
|
||||||
p.addCurve(to: Lf, control1: C1, control2: C2)
|
|
||||||
// 平底水平直线
|
|
||||||
p.addLine(to: Rf)
|
|
||||||
// 右侧离开曲线
|
|
||||||
p.addCurve(to: P3, control1: C3, control2: C4)
|
|
||||||
// 顶部到右上圆角
|
|
||||||
p.addLine(to: CGPoint(x: rect.maxX - r, y: topY))
|
|
||||||
// 右上圆角
|
|
||||||
p.addQuadCurve(to: CGPoint(x: rect.maxX, y: rect.minY + r),
|
|
||||||
control: CGPoint(x: rect.maxX, y: rect.minY))
|
|
||||||
// 右侧直线到右下圆角
|
|
||||||
p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - r))
|
|
||||||
// 右下圆角
|
|
||||||
p.addQuadCurve(to: CGPoint(x: rect.maxX - r, y: rect.maxY),
|
|
||||||
control: CGPoint(x: rect.maxX, y: rect.maxY))
|
|
||||||
// 底边到左下圆角
|
|
||||||
p.addLine(to: CGPoint(x: rect.minX + r, y: rect.maxY))
|
|
||||||
// 左下圆角
|
|
||||||
p.addQuadCurve(to: CGPoint(x: rect.minX, y: rect.maxY - r),
|
|
||||||
control: CGPoint(x: rect.minX, y: rect.maxY))
|
|
||||||
// 左侧到左上圆角
|
|
||||||
p.addLine(to: CGPoint(x: rect.minX, y: rect.minY + r))
|
|
||||||
// 左上圆角
|
|
||||||
p.addQuadCurve(to: CGPoint(x: rect.minX + r, y: rect.minY),
|
|
||||||
control: CGPoint(x: rect.minX, y: rect.minY))
|
|
||||||
p.closeSubpath()
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ScoopRoundedRect_Previews: PreviewProvider {
|
|
||||||
static var previews: some View {
|
|
||||||
VStack(spacing: 30) {
|
|
||||||
// 向下“鼓包”且底部有平直段
|
|
||||||
ScoopRoundedRect(cornerRadius: 24, scoopDepth: 8, scoopHalfWidth: 26, scoopCenterX: 0.25, convexDown: true, flatHalfWidth: 12)
|
|
||||||
.fill(Color.orange)
|
|
||||||
.frame(height: 140)
|
|
||||||
.shadow(color: .black.opacity(0.08), radius: 12, y: 6)
|
|
||||||
.padding()
|
|
||||||
|
|
||||||
// 中央更深、更宽,平底更宽
|
|
||||||
ScoopRoundedRect(cornerRadius: 28, scoopDepth: 12, scoopHalfWidth: 36, scoopCenterX: 0.5, convexDown: true, flatHalfWidth: 18)
|
|
||||||
.fill(Color.orange)
|
|
||||||
.frame(height: 140)
|
|
||||||
.shadow(color: .black.opacity(0.08), radius: 12, y: 6)
|
|
||||||
.padding()
|
|
||||||
|
|
||||||
// 作为对比:向上“凹陷”的 notch,带平底
|
|
||||||
ScoopRoundedRect(cornerRadius: 24, scoopDepth: 10, scoopHalfWidth: 22, scoopCenterX: 0.6, convexDown: false, flatHalfWidth: 10)
|
|
||||||
.fill(Color.orange)
|
|
||||||
.frame(height: 140)
|
|
||||||
.shadow(color: .black.opacity(0.08), radius: 12, y: 6)
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
.background(Color(white: 0.96))
|
|
||||||
.previewLayout(.sizeThatFits)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,202 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
/// 三角形方向
|
|
||||||
public enum TriangleDirection: Equatable {
|
|
||||||
case up
|
|
||||||
case down
|
|
||||||
case left
|
|
||||||
case right
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 一个可插入(inset)的三角形 Shape,支持四个方向。
|
|
||||||
struct TriangleShape: InsettableShape {
|
|
||||||
var direction: TriangleDirection = .up
|
|
||||||
var insetAmount: CGFloat = 0
|
|
||||||
|
|
||||||
var animatableData: CGFloat {
|
|
||||||
get { insetAmount }
|
|
||||||
set { insetAmount = newValue }
|
|
||||||
}
|
|
||||||
|
|
||||||
func inset(by amount: CGFloat) -> TriangleShape {
|
|
||||||
var copy = self
|
|
||||||
copy.insetAmount += amount
|
|
||||||
return copy
|
|
||||||
}
|
|
||||||
|
|
||||||
func path(in rect: CGRect) -> Path {
|
|
||||||
let r = rect.insetBy(dx: insetAmount, dy: insetAmount)
|
|
||||||
|
|
||||||
// 顶点布局:根据方向决定三点位置
|
|
||||||
let p1: CGPoint
|
|
||||||
let p2: CGPoint
|
|
||||||
let p3: CGPoint
|
|
||||||
|
|
||||||
switch direction {
|
|
||||||
case .up:
|
|
||||||
p1 = CGPoint(x: r.midX, y: r.minY)
|
|
||||||
p2 = CGPoint(x: r.maxX, y: r.maxY)
|
|
||||||
p3 = CGPoint(x: r.minX, y: r.maxY)
|
|
||||||
|
|
||||||
case .down:
|
|
||||||
p1 = CGPoint(x: r.midX, y: r.maxY)
|
|
||||||
p2 = CGPoint(x: r.minX, y: r.minY)
|
|
||||||
p3 = CGPoint(x: r.maxX, y: r.minY)
|
|
||||||
|
|
||||||
case .left:
|
|
||||||
p1 = CGPoint(x: r.minX, y: r.midY)
|
|
||||||
p2 = CGPoint(x: r.maxX, y: r.minY)
|
|
||||||
p3 = CGPoint(x: r.maxX, y: r.maxY)
|
|
||||||
|
|
||||||
case .right:
|
|
||||||
p1 = CGPoint(x: r.maxX, y: r.midY)
|
|
||||||
p2 = CGPoint(x: r.minX, y: r.maxY)
|
|
||||||
p3 = CGPoint(x: r.minX, y: r.minY)
|
|
||||||
}
|
|
||||||
|
|
||||||
var path = Path()
|
|
||||||
path.move(to: p1)
|
|
||||||
path.addLine(to: p2)
|
|
||||||
path.addLine(to: p3)
|
|
||||||
path.closeSubpath()
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 可配置的三角形视图封装,便于在项目中复用。
|
|
||||||
/// 示例:
|
|
||||||
/// TriangleView(width: 20, height: 18, direction: .up, color: .black, stroke: .white, lineWidth: 2)
|
|
||||||
public struct TriangleView: View {
|
|
||||||
public var width: CGFloat
|
|
||||||
public var height: CGFloat
|
|
||||||
public var direction: TriangleDirection
|
|
||||||
public var color: Color
|
|
||||||
public var stroke: Color? = nil
|
|
||||||
public var lineWidth: CGFloat = 1
|
|
||||||
public var rotation: Angle = .degrees(0)
|
|
||||||
|
|
||||||
public init(
|
|
||||||
width: CGFloat,
|
|
||||||
height: CGFloat,
|
|
||||||
direction: TriangleDirection = .up,
|
|
||||||
color: Color = .black,
|
|
||||||
stroke: Color? = nil,
|
|
||||||
lineWidth: CGFloat = 1,
|
|
||||||
rotation: Angle = .degrees(0)
|
|
||||||
) {
|
|
||||||
self.width = width
|
|
||||||
self.height = height
|
|
||||||
self.direction = direction
|
|
||||||
self.color = color
|
|
||||||
self.stroke = stroke
|
|
||||||
self.lineWidth = lineWidth
|
|
||||||
self.rotation = rotation
|
|
||||||
}
|
|
||||||
|
|
||||||
public var body: some View {
|
|
||||||
let shape = TriangleShape(direction: direction)
|
|
||||||
Group {
|
|
||||||
if let stroke = stroke, lineWidth > 0 {
|
|
||||||
shape.fill(color)
|
|
||||||
.overlay(
|
|
||||||
shape.stroke(stroke, lineWidth: lineWidth)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
shape.fill(color)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(width: width, height: height)
|
|
||||||
.rotationEffect(rotation)
|
|
||||||
.accessibilityHidden(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 水平渲染多个等尺寸三角形的小组件。
|
|
||||||
public struct TriangleRow: View {
|
|
||||||
public var count: Int
|
|
||||||
public var itemSize: CGSize
|
|
||||||
public var direction: TriangleDirection
|
|
||||||
public var color: Color
|
|
||||||
public var spacing: CGFloat
|
|
||||||
public var rotation: Angle
|
|
||||||
|
|
||||||
public init(
|
|
||||||
count: Int,
|
|
||||||
itemSize: CGSize,
|
|
||||||
direction: TriangleDirection = .up,
|
|
||||||
color: Color = .black,
|
|
||||||
spacing: CGFloat = 8,
|
|
||||||
rotation: Angle = .degrees(0)
|
|
||||||
) {
|
|
||||||
self.count = count
|
|
||||||
self.itemSize = itemSize
|
|
||||||
self.direction = direction
|
|
||||||
self.color = color
|
|
||||||
self.spacing = spacing
|
|
||||||
self.rotation = rotation
|
|
||||||
}
|
|
||||||
|
|
||||||
public var body: some View {
|
|
||||||
HStack(spacing: spacing) {
|
|
||||||
ForEach(0..<(max(0, count)), id: \.self) { _ in
|
|
||||||
TriangleView(
|
|
||||||
width: itemSize.width,
|
|
||||||
height: itemSize.height,
|
|
||||||
direction: direction,
|
|
||||||
color: color,
|
|
||||||
rotation: rotation
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.accessibilityElement(children: .ignore)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
struct Triangle_Previews: PreviewProvider {
|
|
||||||
static var previews: some View {
|
|
||||||
Group {
|
|
||||||
// 单个三角形
|
|
||||||
TriangleView(width: 24, height: 20, direction: .up, color: .black, stroke: .white, lineWidth: 2)
|
|
||||||
.padding()
|
|
||||||
.previewDisplayName("Single Up")
|
|
||||||
|
|
||||||
TriangleView(width: 24, height: 20, direction: .right, color: .orange)
|
|
||||||
.padding()
|
|
||||||
.background(Color(.systemGroupedBackground))
|
|
||||||
.previewDisplayName("Single Right")
|
|
||||||
|
|
||||||
TriangleView(width: 24, height: 20, direction: .up, color: .blue, rotation: .degrees(132))
|
|
||||||
.padding()
|
|
||||||
.previewDisplayName("Rotated 132")
|
|
||||||
|
|
||||||
// 行展示
|
|
||||||
ZStack {
|
|
||||||
Color(white: 0.97)
|
|
||||||
HStack {
|
|
||||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
|
||||||
.fill(.white)
|
|
||||||
.shadow(color: Color.black.opacity(0.06), radius: 6, x: 0, y: 2)
|
|
||||||
.overlay(
|
|
||||||
HStack {
|
|
||||||
TriangleRow(
|
|
||||||
count: 6,
|
|
||||||
itemSize: CGSize(width: 16, height: 14),
|
|
||||||
direction: .up,
|
|
||||||
color: .black,
|
|
||||||
spacing: 6,
|
|
||||||
rotation: .degrees(180)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 18)
|
|
||||||
)
|
|
||||||
.frame(height: 64)
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.previewDisplayName("Row")
|
|
||||||
}
|
|
||||||
.previewLayout(.sizeThatFits)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@ -1,76 +0,0 @@
|
|||||||
# SharedUI/Media
|
|
||||||
媒体通用视图与组件。
|
|
||||||
|
|
||||||
## WakeVideoPlayer
|
|
||||||
一个遵循项目 Theme 风格的 SwiftUI 视频播放组件,基于 `AVKit` 封装。
|
|
||||||
|
|
||||||
支持:
|
|
||||||
- 播放 / 暂停
|
|
||||||
- 进度条拖动(支持拖动中不打断播放进度回调)
|
|
||||||
- 静音切换
|
|
||||||
- 全屏播放(`fullScreenCover`)
|
|
||||||
- 自动隐藏控件(播放中 2.5s 无操作自动隐藏)
|
|
||||||
- 自动播放与循环播放
|
|
||||||
- 自定义填充模式(`videoGravity`)
|
|
||||||
|
|
||||||
### 用法示例
|
|
||||||
```swift
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct DemoVideoCard: View {
|
|
||||||
var body: some View {
|
|
||||||
WakeVideoPlayer(
|
|
||||||
url: URL(string: "https://devstreaming-cdn.apple.com/videos/wwdc/2020/10653/4/17B5F5F3-4D9E-4BAE-8E8F-2C3C7A01F3F2/cmaf.m3u8")!,
|
|
||||||
autoPlay: false,
|
|
||||||
isLooping: true,
|
|
||||||
showsControls: true,
|
|
||||||
allowFullscreen: true,
|
|
||||||
muteInitially: false,
|
|
||||||
videoGravity: .resizeAspectFill
|
|
||||||
)
|
|
||||||
.frame(height: 220)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large, style: .continuous))
|
|
||||||
.shadow(color: Theme.Shadows.cardShadow.color, radius: Theme.Shadows.cardShadow.radius, x: Theme.Shadows.cardShadow.x, y: Theme.Shadows.cardShadow.y)
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 初始化参数
|
|
||||||
- `url: URL` 必填。视频资源地址,支持网络或本地文件 URL。
|
|
||||||
- `autoPlay: Bool = true` 首次出现是否自动播放。
|
|
||||||
- `isLooping: Bool = false` 是否循环播放。
|
|
||||||
- `showsControls: Bool = true` 是否显示自定义控制层。
|
|
||||||
- `allowFullscreen: Bool = true` 是否允许进入全屏播放。
|
|
||||||
- `muteInitially: Bool = false` 初始是否静音。
|
|
||||||
- `videoGravity: AVLayerVideoGravity = .resizeAspectFill` 视频填充模式,如 `.resizeAspect` / `.resizeAspectFill`。
|
|
||||||
- `fallbackURL: URL? = nil` 备用码流地址(建议提供 H.264/HLS)。当检测到资源为 HEVC 且当前环境不支持硬解码(如模拟器)时,自动使用该地址播放。
|
|
||||||
|
|
||||||
### 注意事项
|
|
||||||
- 如果是新加入的文件,确保在 Xcode 中将 `WakeVideoPlayer.swift` 添加到对应 Target,否则无法被编译。
|
|
||||||
- 远程流地址需确保允许跨域与 HTTPS,示例使用 Apple 公共 HLS 资源。
|
|
||||||
- 如果需要画中画(PiP)、双击快退/快进、手势亮度/音量等高级功能,可在此基础上扩展。
|
|
||||||
|
|
||||||
### HEVC/H.265 支持说明与降级策略
|
|
||||||
- 模拟器通常不支持 HEVC 硬解码,表现为“只有声音、无画面”。真机(A9 及以上设备)通常支持。
|
|
||||||
- 组件会在加载时异步分析资源轨道编码;若检测到 HEVC 且当前环境不支持硬解码,则:
|
|
||||||
- 若提供了 `fallbackURL`(建议为 H.264 或多码率 HLS),将自动切换播放该备用源;
|
|
||||||
- 若未提供 `fallbackURL`,会显示顶部黄色提示,建议在真机测试或提供备用码流。
|
|
||||||
|
|
||||||
示例:
|
|
||||||
```swift
|
|
||||||
WakeVideoPlayer(
|
|
||||||
url: URL(string: "https://example.com/video_h265.mp4")!,
|
|
||||||
fallbackURL: URL(string: "https://example.com/video_h264.m3u8")!,
|
|
||||||
autoPlay: true,
|
|
||||||
isLooping: false,
|
|
||||||
showsControls: true,
|
|
||||||
allowFullscreen: true,
|
|
||||||
muteInitially: false,
|
|
||||||
videoGravity: .resizeAspect
|
|
||||||
)
|
|
||||||
.frame(height: 220)
|
|
||||||
```
|
|
||||||
|
|
||||||
建议优先使用 HLS(.m3u8)主清单,内含多编码/多分辨率分流,兼容性更佳。
|
|
||||||
|
|
||||||
@ -1,582 +0,0 @@
|
|||||||
//
|
|
||||||
// WakeVideoPlayer.swift
|
|
||||||
// wake
|
|
||||||
//
|
|
||||||
// Created by Cascade on 2025/9/12.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import AVKit
|
|
||||||
import VideoToolbox
|
|
||||||
|
|
||||||
/// 一个遵循项目 Theme 风格的 SwiftUI 视频播放组件。
|
|
||||||
/// 支持:播放/暂停、进度条、静音、全屏、自动隐藏控件、自动播放与循环。
|
|
||||||
public struct WakeVideoPlayer: View {
|
|
||||||
// MARK: - Public Config
|
|
||||||
private let url: URL
|
|
||||||
private let autoPlay: Bool
|
|
||||||
private let isLooping: Bool
|
|
||||||
private let showsControls: Bool
|
|
||||||
private let allowFullscreen: Bool
|
|
||||||
private let muteInitially: Bool
|
|
||||||
private let videoGravity: AVLayerVideoGravity
|
|
||||||
private let fallbackURL: URL?
|
|
||||||
|
|
||||||
// MARK: - Internal State
|
|
||||||
@State private var player: AVPlayer = AVPlayer()
|
|
||||||
@State private var isPlaying: Bool = false
|
|
||||||
@State private var isMuted: Bool = false
|
|
||||||
@State private var duration: Double = 0
|
|
||||||
@State private var currentTime: Double = 0
|
|
||||||
@State private var isScrubbing: Bool = false
|
|
||||||
@State private var isControlsVisible: Bool = true
|
|
||||||
@State private var isFullscreen: Bool = false
|
|
||||||
@State private var warningMessage: String?
|
|
||||||
|
|
||||||
@State private var timeObserverToken: Any?
|
|
||||||
@State private var endObserver: Any?
|
|
||||||
|
|
||||||
// 自动隐藏控件的定时器
|
|
||||||
@State private var autoHideWorkItem: DispatchWorkItem?
|
|
||||||
|
|
||||||
public init(
|
|
||||||
url: URL,
|
|
||||||
autoPlay: Bool = true,
|
|
||||||
isLooping: Bool = false,
|
|
||||||
showsControls: Bool = true,
|
|
||||||
allowFullscreen: Bool = true,
|
|
||||||
muteInitially: Bool = false,
|
|
||||||
videoGravity: AVLayerVideoGravity = .resizeAspectFill,
|
|
||||||
fallbackURL: URL? = nil
|
|
||||||
) {
|
|
||||||
self.url = url
|
|
||||||
self.autoPlay = autoPlay
|
|
||||||
self.isLooping = isLooping
|
|
||||||
self.showsControls = showsControls
|
|
||||||
self.allowFullscreen = allowFullscreen
|
|
||||||
self.muteInitially = muteInitially
|
|
||||||
self.videoGravity = videoGravity
|
|
||||||
self.fallbackURL = fallbackURL
|
|
||||||
}
|
|
||||||
|
|
||||||
public var body: some View {
|
|
||||||
ZStack {
|
|
||||||
VideoPlayerRepresentable(player: player, videoGravity: videoGravity)
|
|
||||||
.background(Color.black)
|
|
||||||
.onTapGesture { toggleControls() }
|
|
||||||
|
|
||||||
if showsControls && isControlsVisible {
|
|
||||||
controlsOverlay
|
|
||||||
.transition(.opacity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onAppear(perform: setup)
|
|
||||||
.onDisappear(perform: cleanup)
|
|
||||||
.fullScreenCover(isPresented: $isFullscreen) {
|
|
||||||
FullscreenContainer(
|
|
||||||
player: player,
|
|
||||||
isPlaying: $isPlaying,
|
|
||||||
isMuted: $isMuted,
|
|
||||||
duration: $duration,
|
|
||||||
currentTime: $currentTime,
|
|
||||||
isScrubbing: $isScrubbing,
|
|
||||||
onTogglePlay: togglePlay,
|
|
||||||
onSeek: seek(to:),
|
|
||||||
onMute: toggleMute,
|
|
||||||
onDismiss: { isFullscreen = false },
|
|
||||||
videoGravity: videoGravity
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - UI
|
|
||||||
private extension WakeVideoPlayer {
|
|
||||||
var controlsOverlay: some View {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
// Top gradient (可按需添加标题/返回等)
|
|
||||||
LinearGradient(colors: [Color.black.opacity(0.35), .clear], startPoint: .top, endPoint: .bottom)
|
|
||||||
.frame(height: 80)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.allowsHitTesting(false)
|
|
||||||
|
|
||||||
if let warningMessage {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
|
||||||
.foregroundColor(.black)
|
|
||||||
Text(warningMessage)
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundColor(.black)
|
|
||||||
.lineLimit(3)
|
|
||||||
.multilineTextAlignment(.leading)
|
|
||||||
}
|
|
||||||
.padding(8)
|
|
||||||
.background(Theme.Colors.warning.opacity(0.95))
|
|
||||||
.cornerRadius(8)
|
|
||||||
.padding(.horizontal, Theme.Spacing.lg)
|
|
||||||
.padding(.top, Theme.Spacing.md)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
// Center play/pause button(大按钮便于点按)
|
|
||||||
Button(action: togglePlay) {
|
|
||||||
Image(systemName: isPlaying ? "pause.circle.fill" : "play.circle.fill")
|
|
||||||
.resizable()
|
|
||||||
.frame(width: 64, height: 64)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.shadow(color: Theme.Shadows.large, radius: 12, x: 0, y: 8)
|
|
||||||
}
|
|
||||||
.padding(.bottom, Theme.Spacing.lg)
|
|
||||||
|
|
||||||
// Bottom bar controls
|
|
||||||
VStack(spacing: Theme.Spacing.sm) {
|
|
||||||
HStack {
|
|
||||||
Text(formatTime(currentTime))
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.white.opacity(0.85))
|
|
||||||
.frame(width: 46, alignment: .leading)
|
|
||||||
|
|
||||||
Slider(value: Binding(
|
|
||||||
get: { currentTime },
|
|
||||||
set: { newVal in
|
|
||||||
currentTime = min(max(0, newVal), duration)
|
|
||||||
}
|
|
||||||
), in: 0...max(duration, 0.01), onEditingChanged: { editing in
|
|
||||||
isScrubbing = editing
|
|
||||||
if !editing { seek(to: currentTime) }
|
|
||||||
})
|
|
||||||
.tint(Theme.Colors.primary)
|
|
||||||
|
|
||||||
Text(formatTime(duration))
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.white.opacity(0.85))
|
|
||||||
.frame(width: 46, alignment: .trailing)
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack(spacing: Theme.Spacing.lg) {
|
|
||||||
Button(action: toggleMute) {
|
|
||||||
Image(systemName: isMuted ? "speaker.slash.fill" : "speaker.wave.2.fill")
|
|
||||||
.font(.system(size: 16, weight: .semibold))
|
|
||||||
.foregroundColor(.white)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
if allowFullscreen {
|
|
||||||
Button(action: { isFullscreen = true }) {
|
|
||||||
Image(systemName: "arrow.up.left.and.down.right.magnifyingglass")
|
|
||||||
.font(.system(size: 16, weight: .semibold))
|
|
||||||
.foregroundColor(.white)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, Theme.Spacing.lg)
|
|
||||||
.padding(.top, Theme.Spacing.sm)
|
|
||||||
.padding(.bottom, Theme.Spacing.lg + 4)
|
|
||||||
.background(
|
|
||||||
LinearGradient(colors: [Color.black.opacity(0.0), Color.black.opacity(0.55)], startPoint: .top, endPoint: .bottom)
|
|
||||||
.edgesIgnoringSafeArea(.bottom)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.onAppear { scheduleAutoHideIfNeeded() }
|
|
||||||
.onChange(of: isPlaying) { _, _ in scheduleAutoHideIfNeeded() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Lifecycle & Player Setup
|
|
||||||
private extension WakeVideoPlayer {
|
|
||||||
func setup() {
|
|
||||||
// 异步分析编码并选择源
|
|
||||||
Task { @MainActor in
|
|
||||||
let srcURL = url
|
|
||||||
let asset = AVURLAsset(url: srcURL)
|
|
||||||
do {
|
|
||||||
let (hasVideo, hasHEVC) = try await analyzeAsset(asset)
|
|
||||||
// 设置提示(不阻塞)
|
|
||||||
if hasVideo && hasHEVC && !isHEVCHardwareDecodeSupported() {
|
|
||||||
warningMessage = "当前运行环境不支持 HEVC 硬解码(模拟器常见)。已尝试使用备用码流。"
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasVideo && hasHEVC && !isHEVCHardwareDecodeSupported(), let fallback = fallbackURL {
|
|
||||||
prepare(with: fallback)
|
|
||||||
} else {
|
|
||||||
prepare(with: srcURL)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// 分析失败时直接尝试原地址
|
|
||||||
prepare(with: srcURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func prepare(with sourceURL: URL) {
|
|
||||||
// 清理旧观察者
|
|
||||||
if let token = timeObserverToken {
|
|
||||||
player.removeTimeObserver(token)
|
|
||||||
timeObserverToken = nil
|
|
||||||
}
|
|
||||||
if let endObs = endObserver {
|
|
||||||
NotificationCenter.default.removeObserver(endObs)
|
|
||||||
endObserver = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let item = AVPlayerItem(url: sourceURL)
|
|
||||||
player.replaceCurrentItem(with: item)
|
|
||||||
player.isMuted = muteInitially
|
|
||||||
isMuted = muteInitially
|
|
||||||
player.automaticallyWaitsToMinimizeStalling = true
|
|
||||||
player.allowsExternalPlayback = false
|
|
||||||
|
|
||||||
// 监听时长
|
|
||||||
let cmDuration = item.asset.duration.secondsNonNaN
|
|
||||||
if cmDuration.isFinite { duration = cmDuration }
|
|
||||||
|
|
||||||
// 时间观察者
|
|
||||||
addTimeObserver()
|
|
||||||
|
|
||||||
// 循环播放
|
|
||||||
if isLooping {
|
|
||||||
endObserver = NotificationCenter.default.addObserver(
|
|
||||||
forName: .AVPlayerItemDidPlayToEndTime,
|
|
||||||
object: item,
|
|
||||||
queue: .main
|
|
||||||
) { _ in
|
|
||||||
player.seek(to: .zero)
|
|
||||||
if autoPlay { player.play() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 自动播放
|
|
||||||
if autoPlay {
|
|
||||||
player.play()
|
|
||||||
isPlaying = true
|
|
||||||
scheduleAutoHideIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func cleanup() {
|
|
||||||
if let token = timeObserverToken {
|
|
||||||
player.removeTimeObserver(token)
|
|
||||||
timeObserverToken = nil
|
|
||||||
}
|
|
||||||
if let endObs = endObserver {
|
|
||||||
NotificationCenter.default.removeObserver(endObs)
|
|
||||||
endObserver = nil
|
|
||||||
}
|
|
||||||
autoHideWorkItem?.cancel()
|
|
||||||
autoHideWorkItem = nil
|
|
||||||
player.pause()
|
|
||||||
}
|
|
||||||
|
|
||||||
func addTimeObserver() {
|
|
||||||
// 每 0.5s 回调一次
|
|
||||||
let interval = CMTime(seconds: 0.5, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
|
|
||||||
timeObserverToken = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { time in
|
|
||||||
guard !isScrubbing else { return }
|
|
||||||
currentTime = time.secondsNonNaN
|
|
||||||
if duration <= 0 {
|
|
||||||
if let cm = player.currentItem?.duration {
|
|
||||||
let total = cm.secondsNonNaN
|
|
||||||
if total.isFinite { duration = total }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Actions
|
|
||||||
private extension WakeVideoPlayer {
|
|
||||||
func togglePlay() {
|
|
||||||
if isPlaying {
|
|
||||||
player.pause()
|
|
||||||
isPlaying = false
|
|
||||||
showControls()
|
|
||||||
} else {
|
|
||||||
player.play()
|
|
||||||
isPlaying = true
|
|
||||||
scheduleAutoHideIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func toggleMute() {
|
|
||||||
isMuted.toggle()
|
|
||||||
player.isMuted = isMuted
|
|
||||||
showControls()
|
|
||||||
scheduleAutoHideIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
func seek(to seconds: Double) {
|
|
||||||
let clamped = min(max(0, seconds), max(duration, 0))
|
|
||||||
let time = CMTime(seconds: clamped, preferredTimescale: 600)
|
|
||||||
player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero)
|
|
||||||
if isPlaying { scheduleAutoHideIfNeeded() }
|
|
||||||
}
|
|
||||||
|
|
||||||
func toggleControls() {
|
|
||||||
withAnimation(.easeInOut(duration: 0.2)) {
|
|
||||||
isControlsVisible.toggle()
|
|
||||||
}
|
|
||||||
if isControlsVisible { scheduleAutoHideIfNeeded() }
|
|
||||||
}
|
|
||||||
|
|
||||||
func showControls() {
|
|
||||||
withAnimation(.easeInOut(duration: 0.2)) { isControlsVisible = true }
|
|
||||||
}
|
|
||||||
|
|
||||||
func scheduleAutoHideIfNeeded() {
|
|
||||||
autoHideWorkItem?.cancel()
|
|
||||||
guard showsControls && isPlaying else { return }
|
|
||||||
let work = DispatchWorkItem {
|
|
||||||
withAnimation(.easeOut(duration: 0.25)) { isControlsVisible = false }
|
|
||||||
}
|
|
||||||
autoHideWorkItem = work
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5, execute: work)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Helpers
|
|
||||||
private extension WakeVideoPlayer {
|
|
||||||
func isHEVCHardwareDecodeSupported() -> Bool {
|
|
||||||
#if targetEnvironment(simulator)
|
|
||||||
return false
|
|
||||||
#else
|
|
||||||
return VTIsHardwareDecodeSupported(kCMVideoCodecType_HEVC)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
func analyzeAsset(_ asset: AVAsset) async throws -> (hasVideo: Bool, hasHEVC: Bool) {
|
|
||||||
let tracks = try await asset.load(.tracks)
|
|
||||||
var hasVideo = false
|
|
||||||
var hasHEVC = false
|
|
||||||
for track in tracks {
|
|
||||||
// mediaType 可同步访问
|
|
||||||
if track.mediaType == .video {
|
|
||||||
hasVideo = true
|
|
||||||
let fds: [CMFormatDescription] = try await track.load(.formatDescriptions)
|
|
||||||
for desc in fds {
|
|
||||||
let subtype = CMFormatDescriptionGetMediaSubType(desc)
|
|
||||||
if subtype == kCMVideoCodecType_HEVC { hasHEVC = true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (hasVideo, hasHEVC)
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatTime(_ seconds: Double) -> String {
|
|
||||||
guard seconds.isFinite && !seconds.isNaN else { return "00:00" }
|
|
||||||
let total = Int(seconds)
|
|
||||||
let h = total / 3600
|
|
||||||
let m = (total % 3600) / 60
|
|
||||||
let s = (total % 60)
|
|
||||||
if h > 0 {
|
|
||||||
return String(format: "%02d:%02d:%02d", h, m, s)
|
|
||||||
} else {
|
|
||||||
return String(format: "%02d:%02d", m, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Representable: 控制 videoGravity
|
|
||||||
private struct VideoPlayerRepresentable: UIViewControllerRepresentable {
|
|
||||||
let player: AVPlayer
|
|
||||||
let videoGravity: AVLayerVideoGravity
|
|
||||||
|
|
||||||
func makeUIViewController(context: Context) -> AVPlayerViewController {
|
|
||||||
let vc = AVPlayerViewController()
|
|
||||||
vc.player = player
|
|
||||||
vc.showsPlaybackControls = false
|
|
||||||
vc.videoGravity = videoGravity
|
|
||||||
return vc
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {
|
|
||||||
uiViewController.player = player
|
|
||||||
uiViewController.videoGravity = videoGravity
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Fullscreen Container
|
|
||||||
private struct FullscreenContainer: View {
|
|
||||||
let player: AVPlayer
|
|
||||||
@Binding var isPlaying: Bool
|
|
||||||
@Binding var isMuted: Bool
|
|
||||||
@Binding var duration: Double
|
|
||||||
@Binding var currentTime: Double
|
|
||||||
@Binding var isScrubbing: Bool
|
|
||||||
|
|
||||||
let onTogglePlay: () -> Void
|
|
||||||
let onSeek: (Double) -> Void
|
|
||||||
let onMute: () -> Void
|
|
||||||
let onDismiss: () -> Void
|
|
||||||
let videoGravity: AVLayerVideoGravity
|
|
||||||
|
|
||||||
@State private var isControlsVisible: Bool = true
|
|
||||||
@State private var autoHideWorkItem: DispatchWorkItem?
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
VideoPlayerRepresentable(player: player, videoGravity: videoGravity)
|
|
||||||
.ignoresSafeArea()
|
|
||||||
.background(Color.black)
|
|
||||||
.onTapGesture { toggleControls() }
|
|
||||||
|
|
||||||
if isControlsVisible {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
HStack {
|
|
||||||
Button(action: onDismiss) {
|
|
||||||
Image(systemName: "xmark.circle.fill")
|
|
||||||
.font(.system(size: 24, weight: .semibold))
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.shadow(color: Theme.Shadows.large, radius: 12, x: 0, y: 8)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding(.horizontal, Theme.Spacing.lg)
|
|
||||||
.padding(.top, Theme.Spacing.lg)
|
|
||||||
.padding(.bottom, Theme.Spacing.md)
|
|
||||||
.background(
|
|
||||||
LinearGradient(colors: [Color.black.opacity(0.55), .clear], startPoint: .top, endPoint: .bottom)
|
|
||||||
.ignoresSafeArea(edges: .top)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
VStack(spacing: Theme.Spacing.sm) {
|
|
||||||
HStack {
|
|
||||||
Text(formatTime(currentTime))
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.white.opacity(0.85))
|
|
||||||
.frame(width: 46, alignment: .leading)
|
|
||||||
|
|
||||||
Slider(value: Binding(
|
|
||||||
get: { currentTime },
|
|
||||||
set: { newVal in
|
|
||||||
currentTime = min(max(0, newVal), duration)
|
|
||||||
}
|
|
||||||
), in: 0...max(duration, 0.01), onEditingChanged: { editing in
|
|
||||||
isScrubbing = editing
|
|
||||||
if !editing { onSeek(currentTime) }
|
|
||||||
})
|
|
||||||
.tint(Theme.Colors.primary)
|
|
||||||
|
|
||||||
Text(formatTime(duration))
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.white.opacity(0.85))
|
|
||||||
.frame(width: 46, alignment: .trailing)
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack(spacing: Theme.Spacing.lg) {
|
|
||||||
Button(action: onTogglePlay) {
|
|
||||||
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
|
|
||||||
.font(.system(size: 16, weight: .semibold))
|
|
||||||
.foregroundColor(.white)
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(action: onMute) {
|
|
||||||
Image(systemName: isMuted ? "speaker.slash.fill" : "speaker.wave.2.fill")
|
|
||||||
.font(.system(size: 16, weight: .semibold))
|
|
||||||
.foregroundColor(.white)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, Theme.Spacing.lg)
|
|
||||||
.padding(.top, Theme.Spacing.sm)
|
|
||||||
.padding(.bottom, Theme.Spacing.lg + 4)
|
|
||||||
.background(
|
|
||||||
LinearGradient(colors: [Color.black.opacity(0.0), Color.black.opacity(0.7)], startPoint: .top, endPoint: .bottom)
|
|
||||||
.ignoresSafeArea(edges: .bottom)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.transition(.opacity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onAppear { scheduleAutoHideIfNeeded() }
|
|
||||||
.onChange(of: isPlaying) { _, _ in scheduleAutoHideIfNeeded() }
|
|
||||||
}
|
|
||||||
|
|
||||||
func toggleControls() {
|
|
||||||
withAnimation(.easeInOut(duration: 0.2)) {
|
|
||||||
isControlsVisible.toggle()
|
|
||||||
}
|
|
||||||
if isControlsVisible { scheduleAutoHideIfNeeded() }
|
|
||||||
}
|
|
||||||
|
|
||||||
func scheduleAutoHideIfNeeded() {
|
|
||||||
autoHideWorkItem?.cancel()
|
|
||||||
guard isPlaying else { return }
|
|
||||||
let work = DispatchWorkItem {
|
|
||||||
withAnimation(.easeOut(duration: 0.25)) { isControlsVisible = false }
|
|
||||||
}
|
|
||||||
autoHideWorkItem = work
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5, execute: work)
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatTime(_ seconds: Double) -> String {
|
|
||||||
guard seconds.isFinite && !seconds.isNaN else { return "00:00" }
|
|
||||||
let total = Int(seconds)
|
|
||||||
let h = total / 3600
|
|
||||||
let m = (total % 3600) / 60
|
|
||||||
let s = (total % 60)
|
|
||||||
if h > 0 {
|
|
||||||
return String(format: "%02d:%02d:%02d", h, m, s)
|
|
||||||
} else {
|
|
||||||
return String(format: "%02d:%02d", m, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - CMTime helpers
|
|
||||||
private extension CMTime {
|
|
||||||
var secondsNonNaN: Double {
|
|
||||||
let s = CMTimeGetSeconds(self)
|
|
||||||
if s.isNaN || s.isInfinite { return 0 }
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Preview
|
|
||||||
#Preview("WakeVideoPlayer - Basic") {
|
|
||||||
VStack(spacing: 16) {
|
|
||||||
Text("WakeVideoPlayer 预览")
|
|
||||||
.font(.headline)
|
|
||||||
WakeVideoPlayer(
|
|
||||||
url: URL(string: "https://cdn.memorywake.com/users/7350439663116619888/files/7361241959983353857/7361241920703696897.mp4")!,
|
|
||||||
autoPlay: false,
|
|
||||||
isLooping: true,
|
|
||||||
showsControls: true,
|
|
||||||
allowFullscreen: true,
|
|
||||||
muteInitially: true,
|
|
||||||
videoGravity: .resizeAspectFill
|
|
||||||
)
|
|
||||||
.frame(height: 220)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large, style: .continuous))
|
|
||||||
.shadow(color: Theme.Shadows.cardShadow.color, radius: Theme.Shadows.cardShadow.radius, x: Theme.Shadows.cardShadow.x, y: Theme.Shadows.cardShadow.y)
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
.background(Theme.Colors.background)
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview("WakeVideoPlayer - HLS (Primary)") {
|
|
||||||
VStack(spacing: 16) {
|
|
||||||
Text("WakeVideoPlayer HLS 主播放 预览")
|
|
||||||
.font(.headline)
|
|
||||||
WakeVideoPlayer(
|
|
||||||
url: URL(string: "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8")!,
|
|
||||||
autoPlay: false,
|
|
||||||
isLooping: true,
|
|
||||||
showsControls: true,
|
|
||||||
allowFullscreen: true,
|
|
||||||
muteInitially: true,
|
|
||||||
videoGravity: .resizeAspect
|
|
||||||
)
|
|
||||||
.frame(height: 220)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large, style: .continuous))
|
|
||||||
.shadow(color: Theme.Shadows.cardShadow.color, radius: Theme.Shadows.cardShadow.radius, x: Theme.Shadows.cardShadow.x, y: Theme.Shadows.cardShadow.y)
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
.background(Theme.Colors.background)
|
|
||||||
}
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
# SharedUI/Modals
|
|
||||||
通用弹层与模态:`SheetModal.swift` 等。
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
# SharedUI
|
|
||||||
跨特性共享 UI 组件与资源包装:Animation、Media、Modals、Controls、Graphics 等。
|
|
||||||
@ -3,7 +3,7 @@ import Foundation
|
|||||||
/// API 配置信息
|
/// API 配置信息
|
||||||
public enum APIConfig {
|
public enum APIConfig {
|
||||||
/// API 基础 URL
|
/// API 基础 URL
|
||||||
public static let baseURL = "https://api.memorywake.com/api/v1"
|
public static let baseURL = "https://api-dev.memorywake.com:31274/api/v1"
|
||||||
|
|
||||||
/// 认证 token - 从 Keychain 中获取
|
/// 认证 token - 从 Keychain 中获取
|
||||||
public static var authToken: String {
|
public static var authToken: String {
|
||||||
@ -1,112 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
// MARK: - 数据模型
|
|
||||||
struct MaterialRequest: Codable {
|
|
||||||
let fileId: String
|
|
||||||
let previewFileId: String
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case fileId = "file_id"
|
|
||||||
case previewFileId = "preview_file_id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AddMaterialResponse: Codable {
|
|
||||||
let code: Int
|
|
||||||
let data: [String]?
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 素材上传工具类
|
|
||||||
class MaterialUpload {
|
|
||||||
static let shared = MaterialUpload()
|
|
||||||
|
|
||||||
private init() {}
|
|
||||||
|
|
||||||
/// 添加素材到服务器
|
|
||||||
/// - Parameters:
|
|
||||||
/// - fileId: 文件ID
|
|
||||||
/// - previewFileId: 预览文件ID
|
|
||||||
/// - completion: 完成回调,返回结果ID数组或错误
|
|
||||||
func addMaterial(
|
|
||||||
fileId: String,
|
|
||||||
previewFileId: String,
|
|
||||||
completion: @escaping (Result<[String]?, Error>) -> Void
|
|
||||||
) {
|
|
||||||
// 创建请求数据
|
|
||||||
let materials: [[String: String]] = [[
|
|
||||||
"file_id": fileId,
|
|
||||||
"preview_file_id": previewFileId
|
|
||||||
]]
|
|
||||||
|
|
||||||
// 调试信息:检查参数是否为有效的JSON对象
|
|
||||||
print("🔍 准备发送的参数: \(materials)")
|
|
||||||
|
|
||||||
|
|
||||||
// 使用NetworkService发送请求
|
|
||||||
NetworkService.shared.post(
|
|
||||||
path: "/material",
|
|
||||||
parameters: materials,
|
|
||||||
completion: { (result: Result<AddMaterialResponse, 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):
|
|
||||||
print("❌ 素材上传失败: \(error.localizedDescription)")
|
|
||||||
completion(.failure(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 使用 async/await 方式添加素材到服务器
|
|
||||||
/// - Parameters:
|
|
||||||
/// - fileId: 文件ID
|
|
||||||
/// - previewFileId: 预览文件ID
|
|
||||||
/// - Returns: 结果ID数组(可为空)
|
|
||||||
/// - Throws: NetworkError 或其他错误
|
|
||||||
func addMaterial(
|
|
||||||
fileId: String,
|
|
||||||
previewFileId: String
|
|
||||||
) async throws -> [String]? {
|
|
||||||
// 创建请求数据(数组结构,与现有接口保持一致)
|
|
||||||
let materials: [[String: String]] = [[
|
|
||||||
"file_id": fileId,
|
|
||||||
"preview_file_id": previewFileId
|
|
||||||
]]
|
|
||||||
|
|
||||||
// 调试信息
|
|
||||||
print("🔍(async) 准备发送的参数: \(materials)")
|
|
||||||
|
|
||||||
// 直接使用 async/await 版本的 post
|
|
||||||
let response: AddMaterialResponse = try await NetworkService.shared.post(
|
|
||||||
path: "/material",
|
|
||||||
parameters: materials
|
|
||||||
)
|
|
||||||
|
|
||||||
// 按业务约定检查 code
|
|
||||||
if response.code == 0 {
|
|
||||||
return response.data
|
|
||||||
} else {
|
|
||||||
throw NetworkError.serverError("服务器返回错误码: \(response.code)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func addMaterials(files: [[String: String]]) async throws -> [String]? {
|
|
||||||
let response: AddMaterialResponse = try await NetworkService.shared.post(
|
|
||||||
path: "/material",
|
|
||||||
parameters: files
|
|
||||||
)
|
|
||||||
if response.code == 0 {
|
|
||||||
return response.data
|
|
||||||
} else {
|
|
||||||
throw NetworkError.serverError("服务器返回错误码: \(response.code)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
125
wake/Utils/GIFView.swift
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
struct GIFView: UIViewRepresentable {
|
||||||
|
let name: String
|
||||||
|
var onTap: (() -> Void)? = nil
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> UIImageView {
|
||||||
|
let imageView = UIImageView()
|
||||||
|
|
||||||
|
// 加载GIF
|
||||||
|
guard let url = Bundle.main.url(forResource: name, withExtension: "gif"),
|
||||||
|
let data = try? Data(contentsOf: url),
|
||||||
|
let image = UIImage.gif(data: data) else {
|
||||||
|
return imageView
|
||||||
|
}
|
||||||
|
|
||||||
|
imageView.image = image
|
||||||
|
imageView.contentMode = .scaleAspectFit
|
||||||
|
|
||||||
|
// 添加点击手势
|
||||||
|
if onTap != nil {
|
||||||
|
imageView.isUserInteractionEnabled = true
|
||||||
|
let tapGesture = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap))
|
||||||
|
imageView.addGestureRecognizer(tapGesture)
|
||||||
|
}
|
||||||
|
|
||||||
|
return imageView
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: UIImageView, context: Context) {}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
class Coordinator: NSObject {
|
||||||
|
var parent: GIFView
|
||||||
|
|
||||||
|
init(_ parent: GIFView) {
|
||||||
|
self.parent = parent
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func handleTap() {
|
||||||
|
parent.onTap?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UIImage的扩展,用于处理GIF
|
||||||
|
extension UIImage {
|
||||||
|
static func gif(data: Data) -> UIImage? {
|
||||||
|
guard let source = CGImageSourceCreateWithData(data as CFData, nil) else {
|
||||||
|
print("无法创建CGImageSource")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = CGImageSourceGetCount(source)
|
||||||
|
var images = [UIImage]()
|
||||||
|
var duration: TimeInterval = 0
|
||||||
|
|
||||||
|
for i in 0..<count {
|
||||||
|
guard let cgImage = CGImageSourceCreateImageAtIndex(source, i, nil) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
duration += UIImage.gifDelayForImageAtIndex(source: source, index: i)
|
||||||
|
images.append(UIImage(cgImage: cgImage, scale: UIScreen.main.scale, orientation: .up))
|
||||||
|
}
|
||||||
|
|
||||||
|
if count == 1 {
|
||||||
|
return images.first
|
||||||
|
} else {
|
||||||
|
return UIImage.animatedImage(with: images, duration: duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func gifDelayForImageAtIndex(source: CGImageSource, index: Int) -> TimeInterval {
|
||||||
|
var delay = 0.1
|
||||||
|
|
||||||
|
let cfProperties = CGImageSourceCopyPropertiesAtIndex(source, index, nil)
|
||||||
|
let properties = cfProperties as? [String: Any] ?? [:]
|
||||||
|
let gifProperties = properties[kCGImagePropertyGIFDictionary as String] as? [String: Any] ?? [:]
|
||||||
|
|
||||||
|
if let delayTime = gifProperties[kCGImagePropertyGIFUnclampedDelayTime as String] as? Double {
|
||||||
|
delay = delayTime
|
||||||
|
} else if let delayTime = gifProperties[kCGImagePropertyGIFDelayTime as String] as? Double {
|
||||||
|
delay = delayTime
|
||||||
|
}
|
||||||
|
|
||||||
|
if delay < 0.011 {
|
||||||
|
delay = 0.1
|
||||||
|
}
|
||||||
|
|
||||||
|
return delay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用示例 - 带点击事件
|
||||||
|
struct GIFWithTapExample: View {
|
||||||
|
@State private var tapCount = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Text("点击GIF图片")
|
||||||
|
.font(.title)
|
||||||
|
|
||||||
|
GIFView(name: "Blind") {
|
||||||
|
// 点击事件处理
|
||||||
|
Router.shared.navigate(to: .blindBox(mediaType: .video))
|
||||||
|
}
|
||||||
|
.frame(width: 300, height: 300)
|
||||||
|
.border(Color.blue) // 可选:添加边框显示可点击区域
|
||||||
|
|
||||||
|
Text("点击次数: \(tapCount)")
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GIFWithTapExample_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
GIFWithTapExample()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,7 +11,7 @@ import Foundation
|
|||||||
//enum AnyCodable: Codable {}
|
//enum AnyCodable: Codable {}
|
||||||
|
|
||||||
func passwordLogin(username: String, password: String) {
|
func passwordLogin(username: String, password: String) {
|
||||||
guard let url = URL(string: "https://api.memorywake.com/api/v1/iam/login/password-login") else {
|
guard let url = URL(string: "http://192.168.31.156:31646/api/v1/iam/login/password-login") else {
|
||||||
print("❌ 无效的URL")
|
print("❌ 无效的URL")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,19 +48,6 @@ extension NetworkService: NetworkServiceProtocol {
|
|||||||
post(path: path, parameters: parameters, headers: headers, completion: completion)
|
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
|
@discardableResult
|
||||||
public func upload(
|
public func upload(
|
||||||
request: URLRequest,
|
request: URLRequest,
|
||||||
@ -108,79 +95,6 @@ extension NetworkService: NetworkServiceProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
public enum NetworkError: Error {
|
||||||
case invalidURL
|
case invalidURL
|
||||||
case noData
|
case noData
|
||||||
@ -287,15 +201,13 @@ class NetworkService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 打印请求信息(仅 Debug)
|
// 打印请求信息
|
||||||
#if DEBUG
|
|
||||||
print("""
|
print("""
|
||||||
🌐 [Network][#\(requestId)][\(method) \(path)] 开始请求
|
🌐 [Network][#\(requestId)][\(method) \(path)] 开始请求
|
||||||
🔗 URL: \(url.absoluteString)
|
🔗 URL: \(url.absoluteString)
|
||||||
📤 Headers: \(request.allHTTPHeaderFields ?? [:])
|
📤 Headers: \(request.allHTTPHeaderFields ?? [:])
|
||||||
📦 Body: \(request.httpBody.flatMap { String(data: $0, encoding: .utf8) } ?? "")
|
📦 Body: \(request.httpBody.flatMap { String(data: $0, encoding: .utf8) } ?? "")
|
||||||
""")
|
""")
|
||||||
#endif
|
|
||||||
|
|
||||||
// 创建任务
|
// 创建任务
|
||||||
let startTime = Date()
|
let startTime = Date()
|
||||||
@ -340,12 +252,10 @@ class NetworkService {
|
|||||||
|
|
||||||
// 处理401未授权
|
// 处理401未授权
|
||||||
if statusCode == 401 {
|
if statusCode == 401 {
|
||||||
#if DEBUG
|
|
||||||
print("""
|
print("""
|
||||||
🔑 [Network][#\(requestId)][\(method) \(path)] 检测到未授权,尝试刷新token...
|
🔑 [Network][#\(requestId)][\(method) \(path)] 检测到未授权,尝试刷新token...
|
||||||
⏱️ 耗时: \(duration)
|
⏱️ 耗时: \(duration)
|
||||||
""")
|
""")
|
||||||
#endif
|
|
||||||
|
|
||||||
// 将请求加入重试队列
|
// 将请求加入重试队列
|
||||||
let dataResult = data.flatMap { Result<Data, NetworkError>.success($0) } ?? .failure(.noData)
|
let dataResult = data.flatMap { Result<Data, NetworkError>.success($0) } ?? .failure(.noData)
|
||||||
@ -355,12 +265,10 @@ class NetworkService {
|
|||||||
do {
|
do {
|
||||||
let decoder = JSONDecoder()
|
let decoder = JSONDecoder()
|
||||||
let result = try decoder.decode(T.self, from: data)
|
let result = try decoder.decode(T.self, from: data)
|
||||||
#if DEBUG
|
|
||||||
print("""
|
print("""
|
||||||
✅ [Network][#\(requestId)][\(method) \(path)] 重试成功
|
✅ [Network][#\(requestId)][\(method) \(path)] 重试成功
|
||||||
⏱️ 总耗时: \(duration) (包含token刷新时间)
|
⏱️ 总耗时: \(duration) (包含token刷新时间)
|
||||||
""")
|
""")
|
||||||
#endif
|
|
||||||
completion(.success(result))
|
completion(.success(result))
|
||||||
} catch let decodingError as DecodingError {
|
} catch let decodingError as DecodingError {
|
||||||
print("""
|
print("""
|
||||||
@ -395,25 +303,22 @@ class NetworkService {
|
|||||||
// 处理其他错误状态码
|
// 处理其他错误状态码
|
||||||
if !(200...299).contains(statusCode) {
|
if !(200...299).contains(statusCode) {
|
||||||
let errorMessage = data.flatMap { String(data: $0, encoding: .utf8) } ?? ""
|
let errorMessage = data.flatMap { String(data: $0, encoding: .utf8) } ?? ""
|
||||||
let truncated = errorMessage.count > 300 ? String(errorMessage.prefix(300)) + "..." : errorMessage
|
|
||||||
print("""
|
print("""
|
||||||
❌ [Network][#\(requestId)][\(method) \(path)] 请求失败
|
❌ [Network][#\(requestId)][\(method) \(path)] 请求失败
|
||||||
📊 状态码: \(statusCode) (\(statusMessage))
|
📊 状态码: \(statusCode) (\(statusMessage))
|
||||||
⏱️ 耗时: \(duration)
|
⏱️ 耗时: \(duration)
|
||||||
🔍 错误响应: \(truncated)
|
🔍 错误响应: \(errorMessage)
|
||||||
""")
|
""")
|
||||||
completion(.failure(.serverError("状态码: \(statusCode), 响应: \(truncated)")))
|
completion(.failure(.serverError("状态码: \(statusCode), 响应: \(errorMessage)")))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 成功响应(仅 Debug)
|
// 成功响应
|
||||||
#if DEBUG
|
|
||||||
print("""
|
print("""
|
||||||
✅ [Network][#\(requestId)][\(method) \(path)] 请求成功
|
✅ [Network][#\(requestId)][\(method) \(path)] 请求成功
|
||||||
📊 状态码: \(statusCode) (\(statusMessage))
|
📊 状态码: \(statusCode) (\(statusMessage))
|
||||||
⏱️ 耗时: \(duration)
|
⏱️ 耗时: \(duration)
|
||||||
""")
|
""")
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理网络错误
|
// 处理网络错误
|
||||||
@ -437,15 +342,13 @@ class NetworkService {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 打印响应数据(仅 Debug)
|
// 打印响应数据(调试用)
|
||||||
#if DEBUG
|
|
||||||
if let responseString = String(data: data, encoding: .utf8) {
|
if let responseString = String(data: data, encoding: .utf8) {
|
||||||
print("""
|
print("""
|
||||||
📥 [Network][#\(requestId)][\(method) \(path)] 响应数据:
|
📥 [Network][#\(requestId)][\(method) \(path)] 响应数据:
|
||||||
\(responseString.prefix(1000))\(responseString.count > 1000 ? "..." : "")
|
\(responseString.prefix(1000))\(responseString.count > 1000 ? "..." : "")
|
||||||
""")
|
""")
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
// 解析JSON数据
|
// 解析JSON数据
|
||||||
@ -453,18 +356,11 @@ class NetworkService {
|
|||||||
let result = try decoder.decode(T.self, from: data)
|
let result = try decoder.decode(T.self, from: data)
|
||||||
completion(.success(result))
|
completion(.success(result))
|
||||||
} catch let decodingError as DecodingError {
|
} catch let decodingError as DecodingError {
|
||||||
#if DEBUG
|
|
||||||
print("""
|
print("""
|
||||||
❌ [Network][#\(requestId)][\(method) \(path)] JSON解析失败
|
❌ [Network][#\(requestId)][\(method) \(path)] JSON解析失败
|
||||||
🔍 错误: \(decodingError.localizedDescription)
|
🔍 错误: \(decodingError.localizedDescription)
|
||||||
📦 原始数据: \(String(data: data, encoding: .utf8) ?? "")
|
📦 原始数据: \(String(data: data, encoding: .utf8) ?? "")
|
||||||
""")
|
""")
|
||||||
#else
|
|
||||||
print("""
|
|
||||||
❌ [Network][#\(requestId)][\(method) \(path)] JSON解析失败
|
|
||||||
🔍 错误: \(decodingError.localizedDescription)
|
|
||||||
""")
|
|
||||||
#endif
|
|
||||||
completion(.failure(.decodingError(decodingError)))
|
completion(.failure(.decodingError(decodingError)))
|
||||||
} catch {
|
} catch {
|
||||||
print("""
|
print("""
|
||||||
@ -481,9 +377,7 @@ class NetworkService {
|
|||||||
isRefreshing = true
|
isRefreshing = true
|
||||||
let refreshStartTime = Date()
|
let refreshStartTime = Date()
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
print("🔄 [Network] 开始刷新Token...")
|
print("🔄 [Network] 开始刷新Token...")
|
||||||
#endif
|
|
||||||
|
|
||||||
TokenManager.shared.refreshToken { [weak self] success, _ in
|
TokenManager.shared.refreshToken { [weak self] success, _ in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
@ -491,13 +385,11 @@ class NetworkService {
|
|||||||
let refreshDuration = String(format: "%.3fs", Date().timeIntervalSince(refreshStartTime))
|
let refreshDuration = String(format: "%.3fs", Date().timeIntervalSince(refreshStartTime))
|
||||||
|
|
||||||
if success {
|
if success {
|
||||||
#if DEBUG
|
|
||||||
print("""
|
print("""
|
||||||
✅ [Network] Token刷新成功
|
✅ [Network] Token刷新成功
|
||||||
⏱️ 耗时: \(refreshDuration)
|
⏱️ 耗时: \(refreshDuration)
|
||||||
🔄 准备重试\(self.requestsToRetry.count)个请求...
|
🔄 准备重试\(self.requestsToRetry.count)个请求...
|
||||||
""")
|
""")
|
||||||
#endif
|
|
||||||
|
|
||||||
// 重试所有待处理的请求
|
// 重试所有待处理的请求
|
||||||
let requestsToRetry = self.requestsToRetry
|
let requestsToRetry = self.requestsToRetry
|
||||||
@ -521,13 +413,11 @@ class NetworkService {
|
|||||||
task.resume()
|
task.resume()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
#if DEBUG
|
|
||||||
print("""
|
print("""
|
||||||
❌ [Network] Token刷新失败
|
❌ [Network] Token刷新失败
|
||||||
⏱️ 耗时: \(refreshDuration)
|
⏱️ 耗时: \(refreshDuration)
|
||||||
🚪 清除登录状态...
|
🚪 清除登录状态...
|
||||||
""")
|
""")
|
||||||
#endif
|
|
||||||
|
|
||||||
// 清除token并通知需要重新登录
|
// 清除token并通知需要重新登录
|
||||||
TokenManager.shared.clearTokens()
|
TokenManager.shared.clearTokens()
|
||||||
@ -7,7 +7,7 @@ struct LoginResponse: Codable {
|
|||||||
|
|
||||||
func callLoginAPI() {
|
func callLoginAPI() {
|
||||||
// 1. 创建 URL
|
// 1. 创建 URL
|
||||||
let urlString = "https://api.memorywake.com/api/v1/iam/login/password-login"
|
let urlString = "http://192.168.31.156:31646/api/v1/iam/login/password-login"
|
||||||
guard let url = URL(string: urlString) else {
|
guard let url = URL(string: urlString) else {
|
||||||
print("Invalid URL")
|
print("Invalid URL")
|
||||||
return
|
return
|
||||||
|
|||||||