Compare commits

..

4 Commits

Author SHA1 Message Date
jinyaqiu
55255bf0f8 feat: yangshi 2025-09-03 15:43:52 +08:00
jinyaqiu
b1cd957d0c feat: 样式 2025-09-03 14:53:03 +08:00
jinyaqiu
36b95abc37 feat: blind接口解析问题 2025-09-03 14:12:50 +08:00
jinyaqiu
df32ea71bb feat: 样式优化 2025-09-03 11:42:14 +08:00
137 changed files with 4852 additions and 4437 deletions

View File

@ -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"
} }

View File

@ -1,149 +0,0 @@
# perf-1 基线测试指南与记录模板
更新时间2025-09-09 12:25 +08
本文件用于后续进行盲盒主路径的性能基线采集与分析,包含目标范围、事件口径、采集步骤、分析方法、优化建议以及可直接填写的记录模板。
---
## 1. 目标与范围
- 场景 AImage 盲盒):冷启动 → 准备Unopened→ 开启 → 展示图片
- 场景 BVideo 盲盒):冷启动 → 准备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/ProfileXcode → Product → Scheme → Edit Scheme → Run = Release
- 关闭调试开关(如 Metal API Validation
## 5. 数据采集Xcode Instruments
1) Xcode 菜单 `Product → Profile`,选择模板:
- OS Signpost主时间轴
- Time ProfilerCPU/主线程占用)
- Core AnimationFPS 与掉帧)
- 选配Network若要看请求耗时
2) 运行路径:
- 场景 AImage
- 冷启动 App → 进入 `BlindBoxView` → 等待状态到 Unopened → 点击开启 → 等待图片展示 → 停止录制
- 场景 BVideo
- 冷启动 App → 进入 `BlindBoxView` → 等待状态到 Unopened → 点击开启 → 视频开始播放 → 停止录制
3) 标注与导出:
- 在 OS Signpost 轨道上对齐事件(见第 2 节),测量 T_* 并记录
- 导出 A/B 两条 trace 作为基线归档
## 6. 可选 CLIxctrace
- 查看模板与设备:
```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
## 场景 AImage
- 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) ________%
- 结论与问题:
## 场景 BVideo
- 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`)与相关事件;若需要我直接提交“后台解码 + 细粒度埋点”的实现,请在下次迭代时告知,我会以最小改动提交补丁。

View File

@ -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.25s0.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 等
## 实施计划(分阶段)
- 第一阶段12 天,先解卡顿):
1) 统一导航:移除子页面 `NavigationView`,使用顶层 `NavigationStack + Router`
2) 计时器降频0.250.5s;如非必要移除毫秒级显示。
3) GIF 限制播放或替换为 Lottie关/收敛网络大日志。
- 第二阶段24 天):
4) 为 `BlindBox` 引入 ViewModel迁移副作用与状态。
5) 轮询改为可取消的异步序列;媒体预热与尺寸探测后台化。
6) 视图拆分与体量控制。
- 第三阶段(持续):
7) 目录重组ViewModel 标注 `@MainActor`;保留 `os_signpost` 监测关键路径。
## 验收标准DoD
- 导航:仅顶层 `NavigationStack`;子页面无 `NavigationView`
- 性能:转场掉帧率明显下降;主界面进入/退出动画流畅。
- 结构:`BlindBoxView` < 300 主要状态/副作用位于 ViewModel
- 资源GIF 仅在可见时播放或替换为 Lottie网络日志按需输出。
## 任务清单(同步 todo
- [x] nav-1 统一导航(移除子页面 NavigationViewRouter 返回)
- [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 的第 13 步迁移:
- 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 重构,其它模块随后跟进。

View File

@ -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

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

View 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":{}}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

BIN
wake/Assets/Png/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

View 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

View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 184 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.5 MiB

View 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

View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 134 KiB

5
wake/Assets/Svg/Box.svg Normal file
View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

9
wake/Assets/Svg/IP.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 184 KiB

9
wake/Assets/Svg/IP1.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 183 KiB

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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
View 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

View 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
View 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

View 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
View 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

View 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

File diff suppressed because it is too large Load Diff

View File

@ -1,2 +0,0 @@
# Core/DesignSystem
存放 `Theme.swift``Typography.swift` 等设计系统文件。

View File

@ -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)
}
}

View File

@ -1,2 +0,0 @@
# Core/Diagnostics
性能与诊断相关:`Performance.swift`,以及后续埋点/日志工具。

View File

@ -1,2 +0,0 @@
# Core/Navigation
存放路由与导航相关:`Router.swift`

View File

@ -1,2 +0,0 @@
# Core/Network
通用网络层与配置:`NetworkService.swift``APIConfig.swift`、ApiClient 公共代码。

View File

@ -1,9 +0,0 @@
# Core
跨特性共享的核心能力:
- DesignSystem主题、字体、间距
- Navigation路由/导航栈)
- Diagnostics性能与日志
- Network网络与配置
建议通过 Xcode Group 先完成组织,再视需要同步到磁盘。

View File

@ -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 {
// APIdatanull
print("✅ 盲盒开启成功")
} else {
throw NetworkError.serverError("服务器返回错误码: \(response.code)")
}
}
}

View File

@ -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()
}
}
}
}

View File

@ -1,2 +0,0 @@
# Features/BlindBox/API
盲盒相关 API 封装与轮询:`BlindBoxApi.swift``BlindBoxPolling.swift`

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}
}
}
}

View File

@ -1,9 +0,0 @@
import Foundation
//
enum BlindBoxAnimationPhase {
case loading
case ready
case opening
case none
}

View File

@ -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)
}
}

View File

@ -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()
}
}

View File

@ -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)
}
}

View File

@ -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

View File

@ -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
}
}
}
}
}

View File

@ -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)
}
}

View File

@ -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)
)
}
}

View File

@ -1,2 +0,0 @@
# Features/BlindBox/Components
盲盒专属子组件与片段视图。

View File

@ -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) {
//
}
}

View File

@ -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
// coverFileFileInfo
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 ?? ""
}
}

View File

@ -1,2 +0,0 @@
# Features/BlindBox/Models
盲盒业务模型:`BlindModels.swift` 等。

View File

@ -1,2 +0,0 @@
# Features/BlindBox
盲盒业务代码View / ViewModel / API / Models / Components。

View File

@ -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
}
}

View File

@ -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

View File

@ -1,2 +0,0 @@
# Features/BlindBox/View
盲盒界面视图文件(例如 `BlindBoxView.swift`)。

View File

@ -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)")
}
}
}
}

View File

@ -1,2 +0,0 @@
# Features/BlindBox/ViewModel
盲盒视图模型,如 `BlindBoxViewModel.swift`

View File

@ -1,2 +0,0 @@
# Features/Subscribe
订阅相关页面与组件:`SubscribeView``CreditsInfoCard``PlanCompare` 等。

View File

@ -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
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

View File

@ -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
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View File

@ -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
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View File

@ -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

View File

@ -1,84 +0,0 @@
import SwiftUI
import Lottie
struct LottieView: UIViewRepresentable {
let name: String
let loopMode: LottieLoopMode
let animationSpeed: CGFloat
let isPlaying: Bool
init(name: String, loopMode: LottieLoopMode = .loop, animationSpeed: CGFloat = 1.0, isPlaying: Bool = true) {
self.name = name
self.loopMode = loopMode
self.animationSpeed = animationSpeed
self.isPlaying = isPlaying
}
func makeUIView(context: Context) -> UIView {
// 使 LottieAnimationView SwiftUI frame
let container = UIView()
container.clipsToBounds = true
let animationView = LottieAnimationView()
animationView.translatesAutoresizingMaskIntoConstraints = false
// 1: 使
if let animation = LottieAnimation.named(name) {
animationView.animation = animation
}
// 2: 1使
else if let path = Bundle.main.path(forResource: name, ofType: "json") {
let animation = LottieAnimation.filepath(path)
animationView.animation = animation
}
//
animationView.loopMode = loopMode
animationView.animationSpeed = animationSpeed
animationView.contentMode = .scaleAspectFit
animationView.backgroundBehavior = .pauseAndRestore
container.addSubview(animationView)
NSLayoutConstraint.activate([
animationView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
animationView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
animationView.topAnchor.constraint(equalTo: container.topAnchor),
animationView.bottomAnchor.constraint(equalTo: container.bottomAnchor)
])
// Coordinator 便 updateUIView
context.coordinator.animationView = animationView
// /
if isPlaying {
animationView.play()
} else {
animationView.pause()
}
return container
}
func updateUIView(_ uiView: UIView, context: Context) {
guard let animationView = context.coordinator.animationView else { return }
// isPlaying /
if isPlaying {
if !animationView.isAnimationPlaying {
animationView.play()
}
} else {
if animationView.isAnimationPlaying {
animationView.pause()
}
}
}
// 使 Coordinator make/update animationView
class Coordinator {
var animationView: LottieAnimationView?
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
}

View File

@ -1,2 +0,0 @@
# SharedUI/Animation
Lottie 等动画封装:`LottieView.swift`

View File

@ -1,2 +0,0 @@
# SharedUI/Controls
通用按钮、输入控件等。

View File

@ -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

View File

@ -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

View File

@ -1,2 +0,0 @@
# SharedUI/Graphics
共享图形包装。

View File

@ -1,110 +0,0 @@
import SwiftUI
///
struct ScoopRoundedRect: Shape {
var cornerRadius: CGFloat = 20
/// >0 <0
var scoopDepth: CGFloat = 10
///
var scoopHalfWidth: CGFloat = 18
/// 0~10.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 -> P3Lf~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)
}
}

View File

@ -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

View File

@ -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主清单内含多编码/多分辨率分流,兼容性更佳。

View File

@ -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)
}

View File

@ -1,2 +0,0 @@
# SharedUI/Modals
通用弹层与模态:`SheetModal.swift` 等。

View File

@ -1,2 +0,0 @@
# SharedUI
跨特性共享 UI 组件与资源包装Animation、Media、Modals、Controls、Graphics 等。

View File

@ -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 {

View File

@ -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
View 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?()
}
}
}
// UIImageGIF
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()
}
}

View File

@ -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
} }

View File

@ -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()

View File

@ -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

Some files were not shown because too many files have changed in this diff Show More