Compare commits

..

1 Commits

Author SHA1 Message Date
53215f5c3d feat: 更新字体 2025-08-19 14:17:21 +08:00
159 changed files with 782 additions and 15883 deletions

BIN
.DS_Store vendored

Binary file not shown.

3
.gitignore vendored
View File

@ -1,3 +0,0 @@
wake.xcodeproj/xcuserdata
wake.xcodeproj/project.xcworkspace/xcuserdata
wake/CoreData

View File

@ -1,5 +0,0 @@
{
"lldb.library": "/Library/Developer/CommandLineTools/Library/PrivateFrameworks/LLDB.framework/Versions/A/LLDB",
"lldb.launch.expressions": "native",
"sweetpad.build.xcodeWorkspacePath": "wake.xcodeproj/project.xcworkspace"
}

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,11 +7,7 @@
objects = {
/* Begin PBXBuildFile section */
0DE4253B2E78470700B519F0 /* SVGKit in Frameworks */ = {isa = PBXBuildFile; productRef = AB6693C92E65C94400BCAAC1 /* SVGKit */; };
0DE4253C2E78470700B519F0 /* SVGKitSwift in Frameworks */ = {isa = PBXBuildFile; productRef = AB6693CB2E65C94400BCAAC1 /* SVGKitSwift */; };
AB6695272E67015600BCAAC1 /* WaterfallGrid in Frameworks */ = {isa = PBXBuildFile; productRef = AB6695262E67015600BCAAC1 /* WaterfallGrid */; };
AB8773632E4E04400071CB53 /* .gitignore in Resources */ = {isa = PBXBuildFile; fileRef = AB8773622E4E040E0071CB53 /* .gitignore */; };
ABC150C12E5DB39A00A1F970 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = ABC150C02E5DB39A00A1F970 /* Lottie */; };
ABE8998E2E533A7100CD7BA6 /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = ABE8998D2E533A7100CD7BA6 /* Alamofire */; };
/* End PBXBuildFile section */
@ -38,25 +34,7 @@
AB4FA8642E4F7074005D9955 /* Exceptions for "wake" folder in "wake" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Core/DesignSystem/README.md,
Core/Diagnostics/README.md,
Core/Navigation/README.md,
Core/Network/README.md,
Core/README.md,
Features/BlindBox/API/README.md,
Features/BlindBox/Components/README.md,
Features/BlindBox/Models/README.md,
Features/BlindBox/README.md,
Features/BlindBox/View/README.md,
Features/BlindBox/ViewModel/README.md,
Features/Subscribe/README.md,
Info.plist,
SharedUI/Animation/README.md,
SharedUI/Controls/README.md,
SharedUI/Graphics/README.md,
SharedUI/Media/README.md,
SharedUI/Modals/README.md,
SharedUI/README.md,
);
target = ABB4E2072E4B75D900660198 /* wake */;
};
@ -78,11 +56,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
0DE4253C2E78470700B519F0 /* SVGKitSwift in Frameworks */,
0DE4253B2E78470700B519F0 /* SVGKit in Frameworks */,
ABE8998E2E533A7100CD7BA6 /* Alamofire in Frameworks */,
AB6695272E67015600BCAAC1 /* WaterfallGrid in Frameworks */,
ABC150C12E5DB39A00A1F970 /* Lottie in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -137,10 +111,6 @@
name = wake;
packageProductDependencies = (
ABE8998D2E533A7100CD7BA6 /* Alamofire */,
ABC150C02E5DB39A00A1F970 /* Lottie */,
AB6693C92E65C94400BCAAC1 /* SVGKit */,
AB6693CB2E65C94400BCAAC1 /* SVGKitSwift */,
AB6695262E67015600BCAAC1 /* WaterfallGrid */,
);
productName = wake;
productReference = ABB4E2082E4B75D900660198 /* wake.app */;
@ -172,9 +142,6 @@
minimizedProjectReferenceProxies = 1;
packageReferences = (
ABE8998C2E533A7100CD7BA6 /* XCRemoteSwiftPackageReference "Alamofire" */,
ABC150BF2E5DB39A00A1F970 /* XCRemoteSwiftPackageReference "lottie-spm" */,
AB6693C82E65C94400BCAAC1 /* XCRemoteSwiftPackageReference "SVGKit" */,
AB6695252E67015600BCAAC1 /* XCRemoteSwiftPackageReference "WaterfallGrid" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = ABB4E2092E4B75D900660198 /* Products */;
@ -334,7 +301,6 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGN_ENTITLEMENTS = wake/wake.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
@ -343,19 +309,17 @@
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = wake/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "";
INFOPLIST_KEY_NSCameraUsageDescription = "We need access to your camera to capture your profile picture";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "We need to access your photo library to select photos as avatars or blind box inputs";
INFOPLIST_KEY_NSUserTrackingUsageDescription = "我们需要访问您的Apple ID信息来为您提供登录服务";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.memowake.app;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.memowake.fairclip2025;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
@ -368,7 +332,6 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGN_ENTITLEMENTS = wake/wake.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
@ -377,19 +340,17 @@
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = wake/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "";
INFOPLIST_KEY_NSCameraUsageDescription = "We need access to your camera to capture your profile picture";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "We need to access your photo library to select photos as avatars or blind box inputs";
INFOPLIST_KEY_NSUserTrackingUsageDescription = "我们需要访问您的Apple ID信息来为您提供登录服务";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.memowake.app;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.memowake.fairclip2025;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
@ -421,30 +382,6 @@
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
AB6693C82E65C94400BCAAC1 /* XCRemoteSwiftPackageReference "SVGKit" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/SVGKit/SVGKit.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 3.0.0;
};
};
AB6695252E67015600BCAAC1 /* XCRemoteSwiftPackageReference "WaterfallGrid" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/paololeonardi/WaterfallGrid.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.1.0;
};
};
ABC150BF2E5DB39A00A1F970 /* XCRemoteSwiftPackageReference "lottie-spm" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/airbnb/lottie-spm.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 4.5.2;
};
};
ABE8998C2E533A7100CD7BA6 /* XCRemoteSwiftPackageReference "Alamofire" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/Alamofire/Alamofire.git";
@ -456,26 +393,6 @@
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
AB6693C92E65C94400BCAAC1 /* SVGKit */ = {
isa = XCSwiftPackageProductDependency;
package = AB6693C82E65C94400BCAAC1 /* XCRemoteSwiftPackageReference "SVGKit" */;
productName = SVGKit;
};
AB6693CB2E65C94400BCAAC1 /* SVGKitSwift */ = {
isa = XCSwiftPackageProductDependency;
package = AB6693C82E65C94400BCAAC1 /* XCRemoteSwiftPackageReference "SVGKit" */;
productName = SVGKitSwift;
};
AB6695262E67015600BCAAC1 /* WaterfallGrid */ = {
isa = XCSwiftPackageProductDependency;
package = AB6695252E67015600BCAAC1 /* XCRemoteSwiftPackageReference "WaterfallGrid" */;
productName = WaterfallGrid;
};
ABC150C02E5DB39A00A1F970 /* Lottie */ = {
isa = XCSwiftPackageProductDependency;
package = ABC150BF2E5DB39A00A1F970 /* XCRemoteSwiftPackageReference "lottie-spm" */;
productName = Lottie;
};
ABE8998D2E533A7100CD7BA6 /* Alamofire */ = {
isa = XCSwiftPackageProductDependency;
package = ABE8998C2E533A7100CD7BA6 /* XCRemoteSwiftPackageReference "Alamofire" */;

View File

@ -1,5 +1,5 @@
{
"originHash" : "7ea295cc5e3eb8ef644b89ce2b47a7600994b67c8582ee354b643cd63250740d",
"originHash" : "e8f130fe30ac6cdc940ef06ee1e8535e9f46ffee6aeead1722b9525562f6ce08",
"pins" : [
{
"identity" : "alamofire",
@ -9,51 +9,6 @@
"revision" : "513364f870f6bfc468f9d2ff0a95caccc10044c5",
"version" : "5.10.2"
}
},
{
"identity" : "cocoalumberjack",
"kind" : "remoteSourceControl",
"location" : "https://github.com/CocoaLumberjack/CocoaLumberjack.git",
"state" : {
"revision" : "a9ed4b6f9bdedce7d77046f43adfb8ce1fd54114",
"version" : "3.9.0"
}
},
{
"identity" : "lottie-spm",
"kind" : "remoteSourceControl",
"location" : "https://github.com/airbnb/lottie-spm.git",
"state" : {
"revision" : "04f2fd18cc9404a0a0917265a449002674f24ec9",
"version" : "4.5.2"
}
},
{
"identity" : "svgkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SVGKit/SVGKit.git",
"state" : {
"revision" : "58152b9f7c85eab239160b36ffdfd364aa43d666",
"version" : "3.0.0"
}
},
{
"identity" : "swift-log",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log",
"state" : {
"revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2",
"version" : "1.6.4"
}
},
{
"identity" : "waterfallgrid",
"kind" : "remoteSourceControl",
"location" : "https://github.com/paololeonardi/WaterfallGrid.git",
"state" : {
"revision" : "c7c08652c3540adf8e48409c351879b4caea7e89",
"version" : "1.1.0"
}
}
],
"version" : 3

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
<array/>
</plist>

View File

@ -1,81 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1640"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "ABB4E2072E4B75D900660198"
BuildableName = "wake.app"
BlueprintName = "wake"
ReferencedContainer = "container:wake.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "ABB4E2072E4B75D900660198"
BuildableName = "wake.app"
BlueprintName = "wake"
ReferencedContainer = "container:wake.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<StoreKitConfigurationFileReference
identifier = "../../wake/MemoWake.storekit">
</StoreKitConfigurationFileReference>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "ABB4E2072E4B75D900660198"
BuildableName = "wake.app"
BlueprintName = "wake"
ReferencedContainer = "container:wake.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<Bucket
uuid = "A774AEAB-F2DE-4CA6-8FAA-A05AB418F685"
type = "1"
version = "2.0">
<Breakpoints>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
uuid = "4D390946-09D4-48AB-A8F5-7003641827C5"
shouldBeEnabled = "No"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "wake/ContentView.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "37"
endingLineNumber = "37"
landmarkName = "body"
landmarkType = "24">
</BreakpointContent>
</BreakpointProxy>
</Breakpoints>
</Bucket>

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>wake.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>

BIN
wake/.DS_Store vendored

Binary file not shown.

BIN
wake/Assets/.DS_Store vendored

Binary file not shown.

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

View File

@ -1,265 +0,0 @@
import SwiftUI
import AVFoundation
struct CustomCameraView: UIViewControllerRepresentable {
@Binding var isPresented: Bool
let onImageCaptured: (UIImage) -> Void
@Environment(\.presentationMode) private var presentationMode
func makeUIViewController(context: Context) -> CustomCameraViewController {
let viewController = CustomCameraViewController()
viewController.delegate = context.coordinator
return viewController
}
func updateUIViewController(_ uiViewController: CustomCameraViewController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, CustomCameraViewControllerDelegate {
let parent: CustomCameraView
init(_ parent: CustomCameraView) {
self.parent = parent
}
func didCaptureImage(_ image: UIImage) {
parent.onImageCaptured(image)
parent.presentationMode.wrappedValue.dismiss()
}
func didCancel() {
parent.presentationMode.wrappedValue.dismiss()
}
}
}
protocol CustomCameraViewControllerDelegate: AnyObject {
func didCaptureImage(_ image: UIImage)
func didCancel()
}
class CustomCameraViewController: UIViewController {
private var captureSession: AVCaptureSession?
private var photoOutput: AVCapturePhotoOutput?
private var previewLayer: AVCaptureVideoPreviewLayer?
private var captureDevice: AVCaptureDevice?
weak var delegate: CustomCameraViewControllerDelegate?
private lazy var captureButton: UIButton = {
let button = UIButton(type: .system)
button.backgroundColor = .white
button.tintColor = .black
button.layer.cornerRadius = 35
button.layer.borderWidth = 5
button.layer.borderColor = UIColor.lightGray.cgColor
button.addTarget(self, action: #selector(capturePhoto), for: .touchUpInside)
return button
}()
private lazy var closeButton: UIButton = {
let button = UIButton(type: .system)
button.setImage(UIImage(systemName: "xmark"), for: .normal)
button.tintColor = .white
button.addTarget(self, action: #selector(closeCamera), for: .touchUpInside)
return button
}()
private lazy var flipButton: UIButton = {
let button = UIButton(type: .system)
button.setImage(UIImage(systemName: "arrow.triangle.2.circlepath.camera"), for: .normal)
button.tintColor = .white
button.addTarget(self, action: #selector(switchCamera), for: .touchUpInside)
return button
}()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
checkCameraPermissions()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
//
previewLayer?.frame = view.bounds
//
if let connection = previewLayer?.connection, connection.isVideoOrientationSupported {
connection.videoOrientation = .portrait
}
}
private func checkCameraPermissions() {
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
setupCamera()
case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
DispatchQueue.main.async {
if granted {
self?.setupCamera()
} else {
self?.delegate?.didCancel()
}
}
}
default:
delegate?.didCancel()
}
}
private func setupCamera() {
let session = AVCaptureSession()
session.sessionPreset = .high
// 使
guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else {
delegate?.didCancel()
return
}
captureDevice = device
do {
let input = try AVCaptureDeviceInput(device: device)
if session.canAddInput(input) {
session.addInput(input)
}
let output = AVCapturePhotoOutput()
if session.canAddOutput(output) {
session.addOutput(output)
photoOutput = output
}
//
let previewLayer = AVCaptureVideoPreviewLayer(session: session)
previewLayer.videoGravity = .resizeAspectFill
previewLayer.frame = view.bounds
previewLayer.connection?.videoOrientation = .portrait
//
view.layer.insertSublayer(previewLayer, at: 0)
self.previewLayer = previewLayer
DispatchQueue.global(qos: .userInitiated).async {
session.startRunning()
DispatchQueue.main.async {
self.setupUI()
}
}
captureSession = session
} catch {
print("Error setting up camera: \(error)")
delegate?.didCancel()
}
}
private func setupUI() {
view.bringSubviewToFront(closeButton)
view.bringSubviewToFront(flipButton)
view.bringSubviewToFront(captureButton)
view.addSubview(closeButton)
closeButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
closeButton.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 20),
closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
closeButton.widthAnchor.constraint(equalToConstant: 44),
closeButton.heightAnchor.constraint(equalToConstant: 44)
])
view.addSubview(flipButton)
flipButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
flipButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -20),
flipButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
flipButton.widthAnchor.constraint(equalToConstant: 44),
flipButton.heightAnchor.constraint(equalToConstant: 44)
])
view.addSubview(captureButton)
captureButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
captureButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
captureButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -30),
captureButton.widthAnchor.constraint(equalToConstant: 70),
captureButton.heightAnchor.constraint(equalToConstant: 70)
])
}
@objc private func capturePhoto() {
let settings = AVCapturePhotoSettings()
photoOutput?.capturePhoto(with: settings, delegate: self)
}
@objc private func closeCamera() {
delegate?.didCancel()
}
@objc private func switchCamera() {
guard let currentInput = captureSession?.inputs.first as? AVCaptureDeviceInput else { return }
let newPosition: AVCaptureDevice.Position = currentInput.device.position == .front ? .back : .front
guard let newDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: newPosition) else { return }
do {
let newInput = try AVCaptureDeviceInput(device: newDevice)
captureSession?.beginConfiguration()
captureSession?.removeInput(currentInput)
if captureSession?.canAddInput(newInput) == true {
captureSession?.addInput(newInput)
captureDevice = newDevice
} else {
captureSession?.addInput(currentInput)
}
captureSession?.commitConfiguration()
} catch {
print("Error switching camera: \(error)")
}
}
}
extension CustomCameraViewController: AVCapturePhotoCaptureDelegate {
func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
if let error = error {
print("Error capturing photo: \(error)")
return
}
guard let imageData = photo.fileDataRepresentation(),
let image = UIImage(data: imageData) else {
return
}
let fixedImage = image.fixedOrientation()
DispatchQueue.main.async {
self.delegate?.didCaptureImage(fixedImage)
}
}
}
extension UIImage {
func fixedOrientation() -> UIImage {
if imageOrientation == .up {
return self
}
UIGraphicsBeginImageContextWithOptions(size, false, scale)
draw(in: CGRect(origin: .zero, size: size))
let normalizedImage = UIGraphicsGetImageFromCurrentImageContext() ?? self
UIGraphicsEndImageContext()
return normalizedImage
}
}

View File

@ -1,163 +0,0 @@
//
// NaviHeader.swift
// wake
//
// Created by Junhui on 2025/8/19.
//
import SwiftUI
///
struct NaviHeader: View {
let title: String
let onBackTap: () -> Void
var showBackButton: Bool = true
var titleStyle: TypographyStyle = .title
var backgroundColor: Color = Color.clear
var rightContent: AnyView? = nil
var body: some View {
ZStack {
//
Text(title)
.font(Typography.font(for: titleStyle, family: .quicksandBold))
.fontWeight(.bold)
.foregroundColor(.primary)
//
HStack {
//
if showBackButton {
ReturnButton(action: onBackTap)
} else {
Color.clear
.frame(width: 30)
}
Spacer()
//
if let rightContent = rightContent {
rightContent
} else {
Color.clear
.frame(width: 30)
}
}
}
.padding(.horizontal, 20)
.padding(.top, 10)
.padding(.bottom, 20)
.background(backgroundColor)
}
}
///
struct NaviHeaderWithAction: View {
let title: String
let onBackTap: () -> Void
let rightButtonTitle: String
let onRightButtonTap: () -> Void
var showBackButton: Bool = true
var titleStyle: TypographyStyle = .title
var rightButtonStyle: TypographyStyle = .body
var backgroundColor: Color = Color.clear
var body: some View {
ZStack {
//
Text(title)
.font(Typography.font(for: titleStyle, family: .quicksandBold))
.fontWeight(.bold)
.foregroundColor(.primary)
//
HStack {
//
if showBackButton {
ReturnButton(action: onBackTap)
} else {
Color.clear
.frame(width: 30)
}
Spacer()
//
Button(action: onRightButtonTap) {
Text(rightButtonTitle)
.font(Typography.font(for: rightButtonStyle, family: .quicksandBold))
.fontWeight(.semibold)
.foregroundColor(.blue)
}
}
}
.padding(.horizontal, 20)
.padding(.top, 10)
.padding(.bottom, 20)
.background(backgroundColor)
}
}
///
struct SimpleNaviHeader: View {
let title: String
let onBackTap: () -> Void
var body: some View {
ZStack {
//
Text(title)
.font(Typography.font(for: .title, family: .quicksandBold))
.fontWeight(.bold)
.multilineTextAlignment(.center)
//
HStack {
ReturnButton(action: onBackTap)
Spacer()
}
}
.padding(.horizontal, 20)
.padding(.vertical, 16)
}
}
#Preview("基础导航头") {
VStack(spacing: 0) {
NaviHeader(title: "Settings") {
print("返回")
}
.background(Color(.systemBackground))
Spacer()
}
.background(Color(.systemGroupedBackground))
}
#Preview("带右侧按钮导航头") {
VStack(spacing: 0) {
NaviHeaderWithAction(
title: "Profile",
onBackTap: { print("返回") },
rightButtonTitle: "Save",
onRightButtonTap: { print("保存") }
)
.background(Color(.systemBackground))
Spacer()
}
.background(Color(.systemGroupedBackground))
}
#Preview("简洁导航头") {
VStack(spacing: 0) {
SimpleNaviHeader(title: "About") {
print("返回")
}
.background(Color(.systemBackground))
Spacer()
}
.background(Color(.systemGroupedBackground))
}

222
wake/ContentView.swift Normal file
View File

@ -0,0 +1,222 @@
import SwiftUI
import SwiftData
// MARK: -
extension AnyTransition {
///
static var slideFromLeading: AnyTransition {
.asymmetric(
insertion: .move(edge: .trailing).combined(with: .opacity), //
removal: .move(edge: .leading).combined(with: .opacity) //
)
}
}
// MARK: -
struct ContentView: View {
// MARK: -
@State private var showModal = false //
@State private var showSettings = false //
@State private var contentOffset: CGFloat = 0 //
@State private var showLogin = false
//
@Environment(\.modelContext) private var modelContext
// -
@Query private var login: [Login]
// MARK: -
var body: some View {
NavigationView {
ZStack {
//
VStack {
VStack(spacing: 20) {
//
Spacer().frame(height: UIApplication.shared.windows.first?.safeAreaInsets.top ?? 0)
//
HStack {
//
Button(action: showUserProfile) {
Image(systemName: "gearshape")
.font(.title2)
.padding()
}
Spacer()
Text("Wake")
.font(.largeTitle)
.fontWeight(.bold)
.onTapGesture {
if login.isEmpty {
print("⚠️ 没有登录记录,正在创建新记录...")
let newLogin = Login(
email: "jyq@example.com",
name: "New User"
)
modelContext.insert(newLogin)
try? modelContext.save()
print("✅ 已创建新登录记录")
} else if let firstLogin = login.first {
// 2.
print("🔍 找到现有记录,正在更新...")
firstLogin.email = "updated@example.com"
firstLogin.name = "Updated Name"
try? modelContext.save()
print("✅ 记录已更新")
}
}
//
NavigationLink(destination: LoginView()) {
Text("登录")
.font(.headline)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(8)
}
.padding(.trailing)
}
Spacer()
//
List {
Section(header: Text("我的收藏")) {
ForEach(1...5, id: \.self) { item in
HStack {
Image(systemName: "photo")
.foregroundColor(.blue)
.frame(width: 40, height: 40)
.background(Color.blue.opacity(0.1))
.cornerRadius(8)
VStack(alignment: .leading, spacing: 4) {
Text("项目 \(item)")
.font(.headline)
Text("这是第\(item)个项目的描述")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.gray)
}
.padding(.vertical, 4)
}
}
Section(header: Text("最近活动")) {
ForEach(6...10, id: \.self) { item in
HStack {
Image(systemName: "clock")
.foregroundColor(.orange)
.frame(width: 40, height: 40)
.background(Color.orange.opacity(0.1))
.cornerRadius(8)
VStack(alignment: .leading, spacing: 4) {
Text("活动 \(item)")
.font(.headline)
Text("\(item)分钟前更新")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
Text("查看")
.font(.caption)
.padding(6)
.background(Color.blue.opacity(0.1))
.foregroundColor(.blue)
.cornerRadius(4)
}
.padding(.vertical, 4)
}
}
}
.listStyle(GroupedListStyle())
.padding(.top, 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemBackground))
.offset(x: showModal ? UIScreen.main.bounds.width * 0.8 : 0)
.animation(.spring(response: 0.6, dampingFraction: 0.8), value: showModal)
.edgesIgnoringSafeArea(.all)
}
//
SlideInModal(
isPresented: $showModal,
onDismiss: hideUserProfile
) {
UserProfileModal(
showModal: $showModal,
showSettings: $showSettings
)
}
.offset(x: showSettings ? UIScreen.main.bounds.width : 0)
.animation(.spring(response: 0.6, dampingFraction: 0.8), value: showSettings)
//
ZStack {
if showSettings {
Color.black.opacity(0.3)
.edgesIgnoringSafeArea(.all)
.onTapGesture(perform: hideSettings)
.transition(.opacity)
}
if showSettings {
SettingsView(isPresented: $showSettings)
.transition(.move(edge: .leading))
.zIndex(1)
}
}
.animation(.spring(response: 0.6, dampingFraction: 0.8), value: showSettings)
}
.navigationBarHidden(true)
}
}
///
private func showUserProfile() {
withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
// print(": \(login.count)")
// for (index, item) in login.enumerated() {
// print(" \(index + 1): =\(item.email), =\(item.name)")
// }
print("当前登录记录:")
for (index, item) in login.enumerated() {
print("记录 \(index + 1): 邮箱=\(item.email), 姓名=\(item.name)")
}
// showModal.toggle()
}
}
///
private func hideUserProfile() {
withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
showModal = false
}
}
///
private func hideSettings() {
withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
showSettings = false
}
}
}
// MARK: -
#Preview {
ContentView()
}

View File

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

View File

@ -1,189 +0,0 @@
//
// Theme.swift
// wake
//
// Created by fairclip on 2025/8/19.
//
import SwiftUI
// MARK: -
struct Theme {
// MARK: -
struct Colors {
// MARK: -
static let primary = Color(hex: "FFB645") //
static let primaryLight = Color(hex: "FFF8DE") //
static let primaryDark = Color(hex: "E6A03D") //
// MARK: -
static let secondary = Color(hex: "6C7B7F") //
static let accent = Color(hex: "FF6B6B") //
// MARK: -
static let background = Color(hex: "F8F9FA") //
static let surface = Color.white //
static let surfaceSecondary = Color(hex: "F5F5F5") //
static let surfaceTertiary = Color(hex: "F7F7F7") //
// MARK: -
static let textPrimary = Color.black //
static let textSecondary = Color(hex: "6B7280") //
static let textTertiary = Color(hex: "9CA3AF") //
static let textInverse = Color.white //
static let textMessage = Color(hex: "7B7B7B") //
static let textMessageMain = Color(hex: "000000") //
static let textWhite = Color(hex: "FFFFFF") //
static let textWhiteSecondary = Color(hex: "FAFAFA") //
// MARK: -
static let success = Color(hex: "10B981") //
static let warning = Color(hex: "F59E0B") //
static let error = Color(hex: "EF4444") //
static let info = Color(hex: "3B82F6") //
// MARK: -
static let border = Color(hex: "E5E7EB") //
static let borderLight = Color(hex: "F3F4F6") //
static let borderDark = Color(hex: "D1D5DB") //
// MARK: -
static let freeBackground = primaryLight // Free
static let pioneerBackground = primary // Pioneer
static let subscribeButton = primary //
// MARK: -
static let cardBackground = Color.white //
}
// MARK: -
struct Gradients {
static let primaryGradient = LinearGradient(
colors: [Colors.primary, Colors.primaryDark],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
static let backgroundGradient = LinearGradient(
colors: [Colors.background, Colors.surface],
startPoint: .top,
endPoint: .bottom
)
static let accentGradient = LinearGradient(
colors: [Colors.accent, Color(hex: "FF8E8E")],
startPoint: .leading,
endPoint: .trailing
)
}
// MARK: -
struct Shadows {
static let small = Color.black.opacity(0.1)
static let medium = Color.black.opacity(0.15)
static let large = Color.black.opacity(0.2)
//
static let cardShadow = (color: small, radius: CGFloat(4), x: CGFloat(0), y: CGFloat(2))
static let buttonShadow = (color: medium, radius: CGFloat(6), x: CGFloat(0), y: CGFloat(3))
static let modalShadow = (color: large, radius: CGFloat(12), x: CGFloat(0), y: CGFloat(8))
}
// MARK: -
struct CornerRadius {
static let small: CGFloat = 8
static let medium: CGFloat = 12
static let large: CGFloat = 16
static let extraLarge: CGFloat = 20
static let round: CGFloat = 50
}
// MARK: -
struct Spacing {
static let xs: CGFloat = 4
static let sm: CGFloat = 8
static let md: CGFloat = 12
static let lg: CGFloat = 16
static let xl: CGFloat = 20
static let xxl: CGFloat = 24
static let xxxl: CGFloat = 32
}
}
// MARK: - 便
extension Color {
/// 访
static var themePrimary: Color { Theme.Colors.primary }
static var themePrimaryLight: Color { Theme.Colors.primaryLight }
static var themeSecondary: Color { Theme.Colors.secondary }
static var themeAccent: Color { Theme.Colors.accent }
static var themeBackground: Color { Theme.Colors.background }
static var themeSurface: Color { Theme.Colors.surface }
static var themeTextPrimary: Color { Theme.Colors.textPrimary }
static var themeTextSecondary: Color { Theme.Colors.textSecondary }
static var themeTextMessage: Color { Theme.Colors.textMessage }
static var themeTextMessageMain: Color { Theme.Colors.textMessageMain }
static var themeTextWhite: Color { Theme.Colors.textWhite }
static var themeTextWhiteSecondary: Color { Theme.Colors.textWhiteSecondary }
}
// MARK: -
#Preview("Theme Colors") {
ScrollView {
VStack(spacing: Theme.Spacing.lg) {
//
ColorPreviewSection(title: "品牌色", colors: [
("Primary", Theme.Colors.primary),
("Primary Light", Theme.Colors.primaryLight),
("Primary Dark", Theme.Colors.primaryDark)
])
//
ColorPreviewSection(title: "辅助色", colors: [
("Secondary", Theme.Colors.secondary),
("Accent", Theme.Colors.accent)
])
//
ColorPreviewSection(title: "状态色", colors: [
("Success", Theme.Colors.success),
("Warning", Theme.Colors.warning),
("Error", Theme.Colors.error),
("Info", Theme.Colors.info)
])
}
.padding()
}
.background(Theme.Colors.background)
}
// MARK: -
struct ColorPreviewSection: View {
let title: String
let colors: [(String, Color)]
var body: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
Text(title)
.font(.headline)
.foregroundColor(Theme.Colors.textPrimary)
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 3), spacing: Theme.Spacing.sm) {
ForEach(colors, id: \.0) { name, color in
VStack(spacing: Theme.Spacing.xs) {
Rectangle()
.fill(color)
.frame(height: 60)
.cornerRadius(Theme.CornerRadius.small)
Text(name)
.font(.caption)
.foregroundColor(Theme.Colors.textSecondary)
}
}
}
}
}
}

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,87 +0,0 @@
import SwiftUI
@MainActor
enum AppRoute: Hashable {
case login
case avatarBox
case feedbackView
case feedbackDetail(type: FeedbackView.FeedbackType)
case mediaUpload
case blindBox(mediaType: BlindBoxMediaType, blindBoxId: String? = nil)
case blindOutcome(media: MediaType, title: String? = nil, description: String? = nil, isMember: Bool, goToFeedback: Bool = false)
case memories
case subscribe
case userInfo(createFirstBlindBox: Bool = false)
case account
case about
case permissionManagement
case feedback
@ViewBuilder
var view: some View {
switch self {
case .login:
LoginView()
case .avatarBox:
// AvatarBoxView has been removed; route to BlindBoxView as replacement
BlindBoxView(mediaType: .all)
case .feedbackView:
FeedbackView()
case .feedbackDetail(let type):
FeedbackDetailView(feedbackType: type)
case .mediaUpload:
MediaUploadView()
case .blindBox(let mediaType, let blindBoxId):
BlindBoxView(mediaType: mediaType, blindBoxId: blindBoxId)
case .blindOutcome(let media, let title, let description, let isMember, let goToFeedback):
BlindOutcomeView(
media: media,
title: title,
description: description,
isMember: isMember,
onContinue: {
if goToFeedback {
Router.shared.navigate(to: .feedbackView)
} else {
Router.shared.navigate(to: .blindBox(mediaType: .all))
}
}
)
case .memories:
MemoriesView()
case .subscribe:
SubscribeView()
case .userInfo(let createFirstBlindBox):
UserInfo(createFirstBlindBox: createFirstBlindBox)
case .account:
AccountView()
case .about:
AboutUsView()
case .permissionManagement:
PermissionManagementView()
case .feedback:
FeedbackView()
}
}
}
@MainActor
class Router: ObservableObject {
static let shared = Router()
@Published var path = NavigationPath()
private init() {}
func navigate(to destination: AppRoute) {
path.append(destination)
}
func pop() {
path.removeLast()
}
func popToRoot() {
path = NavigationPath()
}
}

View File

@ -1,34 +0,0 @@
import Foundation
/// API
public enum APIConfig {
/// API URL
public static let baseURL = "https://api.memorywake.com/api/v1"
/// token - Keychain
public static var authToken: String {
let token = KeychainHelper.getAccessToken() ?? ""
if !token.isEmpty {
print("🔑 [APIConfig] 当前访问令牌: \(token.prefix(10))...") // 10
} else {
print("⚠️ [APIConfig] 未找到访问令牌")
}
return token
}
///
public static var authHeaders: [String: String] {
let token = authToken
var headers = [
"Content-Type": "application/json",
"Accept": "application/json"
]
if !token.isEmpty {
headers["Authorization"] = "Bearer \(token)"
}
return headers
}
}

View File

@ -1,620 +0,0 @@
import Foundation
//
extension Notification.Name {
static let userDidLogoutNotification = Notification.Name("UserDidLogoutNotification")
}
//
private struct RequestIdentifier {
static var currentId: Int = 0
static var lock = NSLock()
static func next() -> Int {
lock.lock()
defer { lock.unlock() }
currentId += 1
return currentId
}
}
public protocol NetworkServiceProtocol {
func postWithToken<T: Decodable>(
path: String,
parameters: [String: Any],
completion: @escaping (Result<T, NetworkError>) -> Void
)
@discardableResult
func upload(
request: URLRequest,
fileData: Data,
onProgress: @escaping (Double) -> Void,
completion: @escaping (Result<(Data?, URLResponse), Error>) -> Void
) -> URLSessionUploadTask?
}
extension NetworkService: NetworkServiceProtocol {
public func postWithToken<T: Decodable>(
path: String,
parameters: [String: Any],
completion: @escaping (Result<T, NetworkError>) -> Void
) {
var headers = [String: String]()
if let token = KeychainHelper.getAccessToken() {
headers["Authorization"] = "Bearer \(token)"
}
post(path: path, parameters: parameters, headers: headers, completion: completion)
}
public func getWithToken<T: Decodable>(
path: String,
parameters: [String: Any]? = nil,
completion: @escaping (Result<T, NetworkError>) -> Void
) {
var headers = [String: String]()
if let token = KeychainHelper.getAccessToken() {
headers["Authorization"] = "Bearer \(token)"
}
get(path: path, parameters: parameters, headers: headers, completion: completion)
}
@discardableResult
public func upload(
request: URLRequest,
fileData: Data,
onProgress: @escaping (Double) -> Void,
completion: @escaping (Result<(Data?, URLResponse), Error>) -> Void
) -> URLSessionUploadTask? {
var request = request
// Set content length header if not already set
if request.value(forHTTPHeaderField: "Content-Length") == nil {
request.setValue("\(fileData.count)", forHTTPHeaderField: "Content-Length")
}
var progressObserver: NSKeyValueObservation?
let task = URLSession.shared.uploadTask(with: request, from: fileData) { [weak self] data, response, error in
// Invalidate the progress observer when the task completes
progressObserver?.invalidate()
if let error = error {
completion(.failure(error))
return
}
guard let response = response else {
completion(.failure(NetworkError.invalidURL))
return
}
completion(.success((data, response)))
}
// Add progress tracking if available
if #available(iOS 11.0, *) {
progressObserver = task.progress.observe(\.fractionCompleted) { progressValue, _ in
DispatchQueue.main.async {
onProgress(progressValue.fractionCompleted)
}
}
}
task.resume()
return task
}
}
// MARK: - Async/Await Extensions
extension NetworkService {
/// 使 async/await GET Token
public func getWithToken<T: Decodable>(
path: String,
parameters: [String: Any]? = nil
) async throws -> T {
return try await withCheckedThrowingContinuation { continuation in
getWithToken(path: path, parameters: parameters) { (result: Result<T, NetworkError>) in
switch result {
case .success(let value):
continuation.resume(returning: value)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
/// 使 async/await POST Token
public func postWithToken<T: Decodable>(
path: String,
parameters: [String: Any]
) async throws -> T {
return try await withCheckedThrowingContinuation { continuation in
postWithToken(path: path, parameters: parameters) { (result: Result<T, NetworkError>) in
switch result {
case .success(let value):
continuation.resume(returning: value)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
/// 使 async/await POST
public func post<T: Decodable>(
path: String,
parameters: Any? = nil,
headers: [String: String]? = nil
) async throws -> T {
return try await withCheckedThrowingContinuation { continuation in
post(path: path, parameters: parameters, headers: headers) { (result: Result<T, NetworkError>) in
switch result {
case .success(let value):
continuation.resume(returning: value)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
/// 使 async/await POST Token
public func postWithToken<T: Decodable>(
path: String,
parameters: Any? = nil,
headers: [String: String]? = nil
) async throws -> T {
return try await withCheckedThrowingContinuation { continuation in
postWithToken(path: path, parameters: parameters, headers: headers) { (result: Result<T, NetworkError>) in
switch result {
case .success(let value):
continuation.resume(returning: value)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
}
public enum NetworkError: Error {
case invalidURL
case noData
case decodingError(Error)
case serverError(String)
case unauthorized
case other(Error)
case networkError(Error)
case unknownError(Error)
case invalidParameters
public var localizedDescription: String {
switch self {
case .invalidURL:
return "无效的URL"
case .noData:
return "没有接收到数据"
case .decodingError(let error):
return "解码错误: \(error.localizedDescription)"
case .serverError(let message):
return "服务器错误: \(message)"
case .unauthorized:
return "未授权,请重新登录"
case .other(let error):
return error.localizedDescription
case .networkError(let error):
return "网络错误: \(error.localizedDescription)"
case .unknownError(let error):
return "未知错误: \(error.localizedDescription)"
case .invalidParameters:
return "无效的参数"
}
}
}
class NetworkService {
static let shared = NetworkService()
//
private let defaultHeaders: [String: String] = [
"Content-Type": "application/json",
"Accept": "application/json"
]
private var isRefreshing = false
private var requestsToRetry: [(URLRequest, (Result<Data, NetworkError>) -> Void, Int)] = []
private init() {}
// MARK: -
private func request<T: Decodable>(
_ method: String,
path: String,
parameters: Any? = nil,
headers: [String: String]? = nil,
completion: @escaping (Result<T, NetworkError>) -> Void
) {
// ID
let requestId = RequestIdentifier.next()
// URL
let fullURL = APIConfig.baseURL + path
guard let url = URL(string: fullURL) else {
print("❌ [Network][#\(requestId)][\(method) \(path)] 无效的URL")
completion(.failure(.invalidURL))
return
}
//
var request = URLRequest(url: url)
request.httpMethod = method
// -
defaultHeaders.forEach { key, value in
request.setValue(value, forHTTPHeaderField: key)
}
// -
if !path.contains("/iam/login/") {
APIConfig.authHeaders.forEach { key, value in
request.setValue(value, forHTTPHeaderField: key)
}
}
//
headers?.forEach { key, value in
request.setValue(value, forHTTPHeaderField: key)
}
// POST/PUT
if let parameters = parameters, (method == "POST" || method == "PUT") {
do {
if JSONSerialization.isValidJSONObject(parameters) {
request.httpBody = try JSONSerialization.data(withJSONObject: parameters)
} else {
print("❌ [Network][#\(requestId)][\(method) \(path)] 参数不是有效的JSON对象")
completion(.failure(.invalidParameters))
return
}
} catch {
print("❌ [Network][#\(requestId)][\(method) \(path)] 参数序列化失败: \(error.localizedDescription)")
completion(.failure(.other(error)))
return
}
}
// Debug
#if DEBUG
print("""
🌐 [Network][#\(requestId)][\(method) \(path)]
🔗 URL: \(url.absoluteString)
📤 Headers: \(request.allHTTPHeaderFields ?? [:])
📦 Body: \(request.httpBody.flatMap { String(data: $0, encoding: .utf8) } ?? "")
""")
#endif
//
let startTime = Date()
let task = URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
let duration = String(format: "%.3fs", Date().timeIntervalSince(startTime))
//
self?.handleResponse(
requestId: requestId,
method: method,
path: path,
data: data,
response: response,
error: error,
request: request,
duration: duration,
completion: { (result: Result<T, NetworkError>) in
completion(result)
}
)
}
//
task.resume()
}
private func handleResponse<T: Decodable>(
requestId: Int,
method: String,
path: String,
data: Data?,
response: URLResponse?,
error: Error?,
request: URLRequest,
duration: String,
completion: @escaping (Result<T, NetworkError>) -> Void
) {
//
if let httpResponse = response as? HTTPURLResponse {
let statusCode = httpResponse.statusCode
let statusMessage = HTTPURLResponse.localizedString(forStatusCode: statusCode)
// 401
if statusCode == 401 {
#if DEBUG
print("""
🔑 [Network][#\(requestId)][\(method) \(path)] token...
: \(duration)
""")
#endif
//
let dataResult = data.flatMap { Result<Data, NetworkError>.success($0) } ?? .failure(.noData)
self.requestsToRetry.append((request, { result in
switch result {
case .success(let data):
do {
let decoder = JSONDecoder()
let result = try decoder.decode(T.self, from: data)
#if DEBUG
print("""
[Network][#\(requestId)][\(method) \(path)]
: \(duration) (token刷新时间)
""")
#endif
completion(.success(result))
} catch let decodingError as DecodingError {
print("""
[Network][#\(requestId)][\(method) \(path)] JSON解析失败
🔍 : \(decodingError.localizedDescription)
📦 : \(String(data: data, encoding: .utf8) ?? "")
""")
completion(.failure(.decodingError(decodingError)))
} catch {
print("""
[Network][#\(requestId)][\(method) \(path)]
🔍 : \(error.localizedDescription)
""")
completion(.failure(.unknownError(error)))
}
case .failure(let error):
print("""
[Network][#\(requestId)][\(method) \(path)]
🔍 : \(error.localizedDescription)
""")
completion(.failure(error))
}
}, requestId))
// token
if !isRefreshing {
refreshAndRetryRequests()
}
return
}
//
if !(200...299).contains(statusCode) {
let errorMessage = data.flatMap { String(data: $0, encoding: .utf8) } ?? ""
let truncated = errorMessage.count > 300 ? String(errorMessage.prefix(300)) + "..." : errorMessage
print("""
[Network][#\(requestId)][\(method) \(path)]
📊 : \(statusCode) (\(statusMessage))
: \(duration)
🔍 : \(truncated)
""")
completion(.failure(.serverError("状态码: \(statusCode), 响应: \(truncated)")))
return
}
// Debug
#if DEBUG
print("""
[Network][#\(requestId)][\(method) \(path)]
📊 : \(statusCode) (\(statusMessage))
: \(duration)
""")
#endif
}
//
if let error = error {
print("""
[Network][#\(requestId)][\(method) \(path)]
: \(duration)
🔍 : \(error.localizedDescription)
""")
completion(.failure(.networkError(error)))
return
}
//
guard let data = data else {
print("""
[Network][#\(requestId)][\(method) \(path)]
: \(duration)
""")
completion(.failure(.noData))
return
}
// Debug
#if DEBUG
if let responseString = String(data: data, encoding: .utf8) {
print("""
📥 [Network][#\(requestId)][\(method) \(path)] :
\(responseString.prefix(1000))\(responseString.count > 1000 ? "..." : "")
""")
}
#endif
do {
// JSON
let decoder = JSONDecoder()
let result = try decoder.decode(T.self, from: data)
completion(.success(result))
} catch let decodingError as DecodingError {
#if DEBUG
print("""
[Network][#\(requestId)][\(method) \(path)] JSON解析失败
🔍 : \(decodingError.localizedDescription)
📦 : \(String(data: data, encoding: .utf8) ?? "")
""")
#else
print("""
[Network][#\(requestId)][\(method) \(path)] JSON解析失败
🔍 : \(decodingError.localizedDescription)
""")
#endif
completion(.failure(.decodingError(decodingError)))
} catch {
print("""
[Network][#\(requestId)][\(method) \(path)]
🔍 : \(error.localizedDescription)
""")
completion(.failure(.unknownError(error)))
}
}
private func refreshAndRetryRequests() {
guard !isRefreshing else { return }
isRefreshing = true
let refreshStartTime = Date()
#if DEBUG
print("🔄 [Network] 开始刷新Token...")
#endif
TokenManager.shared.refreshToken { [weak self] success, _ in
guard let self = self else { return }
let refreshDuration = String(format: "%.3fs", Date().timeIntervalSince(refreshStartTime))
if success {
#if DEBUG
print("""
[Network] Token刷新成功
: \(refreshDuration)
🔄 \(self.requestsToRetry.count)...
""")
#endif
//
let requestsToRetry = self.requestsToRetry
self.requestsToRetry.removeAll()
for (request, completion, requestId) in requestsToRetry {
var newRequest = request
if let token = KeychainHelper.getAccessToken() {
newRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
let task = URLSession.shared.dataTask(with: newRequest) { data, response, error in
if let data = data {
completion(.success(data))
} else if let error = error {
completion(.failure(.networkError(error)))
} else {
completion(.failure(.noData))
}
}
task.resume()
}
} else {
#if DEBUG
print("""
[Network] Token刷新失败
: \(refreshDuration)
🚪 ...
""")
#endif
// token
TokenManager.shared.clearTokens()
DispatchQueue.main.async {
NotificationCenter.default.post(name: .userDidLogoutNotification, object: nil)
}
//
self.requestsToRetry.forEach { _, completion, _ in
completion(.failure(.unauthorized))
}
self.requestsToRetry.removeAll()
}
self.isRefreshing = false
}
}
// MARK: -
/// GET
func get<T: Decodable>(
path: String,
parameters: [String: Any]? = nil,
headers: [String: String]? = nil,
completion: @escaping (Result<T, NetworkError>) -> Void
) {
request("GET", path: path, parameters: parameters, headers: headers, completion: completion)
}
/// POST
func post<T: Decodable>(
path: String,
parameters: Any? = nil,
headers: [String: String]? = nil,
completion: @escaping (Result<T, NetworkError>) -> Void
) {
var params: Any?
if let parameters = parameters {
if let dict = parameters as? [String: Any] {
params = dict
} else if let array = parameters as? [Any] {
params = array
} else {
print("❌ [Network] POST 请求参数类型不支持")
completion(.failure(.invalidParameters))
return
}
}
request("POST", path: path, parameters: params, headers: headers, completion: completion)
}
/// POST Token
func postWithToken<T: Decodable>(
path: String,
parameters: Any? = nil,
headers: [String: String]? = nil,
completion: @escaping (Result<T, NetworkError>) -> Void
) {
var headers = headers ?? [:]
if let token = KeychainHelper.getAccessToken() {
headers["Authorization"] = "Bearer \(token)"
}
post(path: path, parameters: parameters, headers: headers, completion: completion)
}
/// DELETE
func delete<T: Decodable>(
path: String,
parameters: [String: Any]? = nil,
headers: [String: String]? = nil,
completion: @escaping (Result<T, NetworkError>) -> Void
) {
var headers = headers ?? [:]
if let token = KeychainHelper.getAccessToken() {
headers["Authorization"] = "Bearer \(token)"
}
request("DELETE", path: path, parameters: parameters, headers: headers, completion: completion)
}
/// PUT
func put<T: Decodable>(
path: String,
parameters: [String: Any]? = nil,
headers: [String: String]? = nil,
completion: @escaping (Result<T, NetworkError>) -> Void
) {
request("PUT", path: path, parameters: parameters, headers: headers, completion: completion)
}
}

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 先完成组织,再视需要同步到磁盘。

Binary file not shown.

View File

@ -1,34 +0,0 @@
//
// ColorExtensions.swift
// wake
//
// Created by fairclip on 2025/8/19.
//
import SwiftUI
// MARK: - Color Extension for Hex Colors
extension Color {
///
/// - Parameter hex: (: "FF5733", "FFF8DE")
init(hex: String) {
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: hex).scanHexInt64(&int)
let a, r, g, b: UInt64
switch hex.count {
case 6: // RGB (24-bit)
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
default:
(a, r, g, b) = (1, 1, 1, 0)
}
self.init(
.sRGB,
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
opacity: Double(a) / 255
)
}
}

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

@ -22,14 +22,13 @@
<string>Sign in with Apple is used to authenticate your account</string>
<key>UIAppFonts</key>
<array>
<string>Inter.ttf</string>
<string>Quicksand x.ttf</string>
<string>SankeiCutePopanime.ttf</string>
<string>Quicksand-Regular.ttf</string>
<string>Quicksand-Bold.ttf</string>
<string>Quicksand-SemiBold.ttf</string>
<string>Quicksand-Medium.ttf</string>
<string>Quicksand-Light.ttf</string>
<string>LavishlyYours-Regular.ttf</string>
</array>
</dict>
</plist>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 962 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

View File

@ -1,158 +0,0 @@
{
"images" : [
{
"filename" : "40.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "20x20"
},
{
"filename" : "60.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "20x20"
},
{
"filename" : "29.png",
"idiom" : "iphone",
"scale" : "1x",
"size" : "29x29"
},
{
"filename" : "58.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "29x29"
},
{
"filename" : "87.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "29x29"
},
{
"filename" : "80.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "40x40"
},
{
"filename" : "120.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "40x40"
},
{
"filename" : "57.png",
"idiom" : "iphone",
"scale" : "1x",
"size" : "57x57"
},
{
"filename" : "114.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "57x57"
},
{
"filename" : "120.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "60x60"
},
{
"filename" : "180.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "60x60"
},
{
"filename" : "20.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "20x20"
},
{
"filename" : "40.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "20x20"
},
{
"filename" : "29.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "29x29"
},
{
"filename" : "58.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "29x29"
},
{
"filename" : "40.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "40x40"
},
{
"filename" : "80.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "40x40"
},
{
"filename" : "50.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "50x50"
},
{
"filename" : "100.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "50x50"
},
{
"filename" : "72.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "72x72"
},
{
"filename" : "144.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "72x72"
},
{
"filename" : "76.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "76x76"
},
{
"filename" : "152.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "76x76"
},
{
"filename" : "167.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "83.5x83.5"
},
{
"filename" : "1024.png",
"idiom" : "ios-marketing",
"scale" : "1x",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

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

@ -1,215 +0,0 @@
{
"appPolicies" : {
"eula" : "",
"policies" : [
{
"locale" : "en_US",
"policyText" : "",
"policyURL" : ""
}
]
},
"identifier" : "C75471B9",
"nonRenewingSubscriptions" : [
],
"products" : [
],
"settings" : {
"_applicationInternalID" : "6748205761",
"_developerTeamID" : "392N3QB7XR",
"_failTransactionsEnabled" : false,
"_lastSynchronizedDate" : 777364219.49411595,
"_locale" : "en_US",
"_storefront" : "USA",
"_storeKitErrors" : [
{
"current" : null,
"enabled" : false,
"name" : "Load Products"
},
{
"current" : null,
"enabled" : false,
"name" : "Purchase"
},
{
"current" : null,
"enabled" : false,
"name" : "Verification"
},
{
"current" : null,
"enabled" : false,
"name" : "App Store Sync"
},
{
"current" : null,
"enabled" : false,
"name" : "Subscription Status"
},
{
"current" : null,
"enabled" : false,
"name" : "App Transaction"
},
{
"current" : null,
"enabled" : false,
"name" : "Manage Subscriptions Sheet"
},
{
"current" : null,
"enabled" : false,
"name" : "Refund Request Sheet"
},
{
"current" : null,
"enabled" : false,
"name" : "Offer Code Redeem Sheet"
}
]
},
"subscriptionGroups" : [
{
"id" : "21759571",
"localizations" : [
],
"name" : "Membership",
"subscriptions" : [
{
"adHocOffers" : [
],
"codeOffers" : [
],
"displayPrice" : "0.99",
"familyShareable" : false,
"groupNumber" : 1,
"internalID" : "6751260055",
"introductoryOffer" : null,
"localizations" : [
{
"description" : "The Pioneer Plan unlocks many restrictions.",
"displayName" : "Pioneer Plan",
"locale" : "en_US"
},
{
"description" : "先锋计划用户不限制盲盒购买数量不限制回忆上传数量每天免费获取500积分",
"displayName" : "先锋计划",
"locale" : "zh_Hans"
}
],
"productID" : "MEMBERSHIP_PIONEER_MONTHLY",
"recurringSubscriptionPeriod" : "P1M",
"referenceName" : "Pioneer计划",
"subscriptionGroupID" : "21759571",
"type" : "RecurringSubscription",
"winbackOffers" : [
]
}
]
},
{
"id" : "21740727",
"localizations" : [
],
"name" : "Pro会员",
"subscriptions" : [
{
"adHocOffers" : [
],
"codeOffers" : [
],
"displayPrice" : "12.99",
"familyShareable" : false,
"groupNumber" : 1,
"internalID" : "6749133482",
"introductoryOffer" : null,
"localizations" : [
{
"description" : "Pro会员每月有更高的存储空间与积分数量",
"displayName" : "季度Pro会员",
"locale" : "zh_Hans"
}
],
"productID" : "MEMBERSHIP_PRO_QUARTERLY",
"recurringSubscriptionPeriod" : "P3M",
"referenceName" : "季度Pro会员",
"subscriptionGroupID" : "21740727",
"type" : "RecurringSubscription",
"winbackOffers" : [
]
},
{
"adHocOffers" : [
],
"codeOffers" : [
],
"displayPrice" : "59.99",
"familyShareable" : false,
"groupNumber" : 2,
"internalID" : "6749229999",
"introductoryOffer" : null,
"localizations" : [
{
"description" : "Pro会员每月有更高的存储空间与积分数量",
"displayName" : "年度Pro会员",
"locale" : "zh_Hans"
}
],
"productID" : "MEMBERSHIP_PRO_YEARLY",
"recurringSubscriptionPeriod" : "P1Y",
"referenceName" : "年度Pro会员",
"subscriptionGroupID" : "21740727",
"type" : "RecurringSubscription",
"winbackOffers" : [
]
},
{
"adHocOffers" : [
],
"codeOffers" : [
],
"displayPrice" : "3.99",
"familyShareable" : false,
"groupNumber" : 3,
"internalID" : "6749230171",
"introductoryOffer" : null,
"localizations" : [
{
"description" : "Pro会员每月有更高的存储空间与积分数量",
"displayName" : "月度Pro会员",
"locale" : "zh_Hans"
}
],
"productID" : "MEMBERSHIP_PRO_MONTH",
"recurringSubscriptionPeriod" : "P1M",
"referenceName" : "月度Pro会员",
"subscriptionGroupID" : "21740727",
"type" : "RecurringSubscription",
"winbackOffers" : [
]
}
]
}
],
"version" : {
"major" : 4,
"minor" : 0
}
}

View File

@ -1,59 +0,0 @@
import Foundation
/// API
struct BaseResponse<T: Codable>: Codable {
let code: Int
let data: T?
let message: String?
}
///
struct UserLoginInfo: Codable {
let userId: String
let accessToken: String
let refreshToken: String
let nickname: String
let account: String
let email: String
let avatarFileUrl: String?
enum CodingKeys: String, CodingKey {
case userId = "user_id"
case accessToken = "access_token"
case refreshToken = "refresh_token"
case nickname
case account
case email
case avatarFileUrl = "avatar_file_url"
}
}
///
struct LoginResponseData: Codable {
let userLoginInfo: UserLoginInfo
let isNewUser: Bool
enum CodingKeys: String, CodingKey {
case userLoginInfo = "user_login_info"
case isNewUser = "is_new_user"
}
}
///
typealias AuthResponse = BaseResponse<LoginResponseData>
///
struct UserInfoData: Codable {
let userId: String
let username: String
let avatarFileId: String?
enum CodingKeys: String, CodingKey {
case userId = "user_id"
case username
case avatarFileId = "avatar_file_id"
}
}
///
typealias UserInfoResponse = BaseResponse<UserInfoData>

View File

@ -1,49 +0,0 @@
import SwiftUI
import Combine
///
public class AuthState: ObservableObject {
@Published public var isAuthenticated: Bool = false {
didSet {
print("🔔 认证状态变更: \(isAuthenticated ? "已登录" : "已登出")")
}
}
@Published public var isLoading = false
@Published public var errorMessage: String?
@Published public var user: User?
//
public static let shared = AuthState()
private init() {}
///
public func login(user: User? = nil) {
if let user = user {
self.user = user
}
isAuthenticated = true
errorMessage = nil
}
///
public func logout() {
print("👋 用户登出")
user = nil
isAuthenticated = false
//
TokenManager.shared.clearTokens()
UserDefaults.standard.removeObject(forKey: "lastLoginUser")
}
///
public func setLoading(_ loading: Bool) {
isLoading = loading
}
///
public func setError(_ message: String) {
errorMessage = message
}
}

View File

@ -1,46 +0,0 @@
import SwiftUI
import AVKit
/// Represents different types of media that can be displayed or processed
public enum MediaType: Equatable, Hashable {
case image(UIImage)
case video(URL, UIImage?) // URL is the video URL, UIImage is the thumbnail
public var thumbnail: UIImage? {
switch self {
case .image(let image):
return image
case .video(_, let thumbnail):
return thumbnail
}
}
public var isVideo: Bool {
if case .video = self {
return true
}
return false
}
public static func == (lhs: MediaType, rhs: MediaType) -> Bool {
switch (lhs, rhs) {
case (.image(let lhsImage), .image(let rhsImage)):
return lhsImage.pngData() == rhsImage.pngData()
case (.video(let lhsURL, _), .video(let rhsURL, _)):
return lhsURL == rhsURL
default:
return false
}
}
public func hash(into hasher: inout Hasher) {
switch self {
case .image(let image):
hasher.combine("image")
hasher.combine(image.pngData())
case .video(let url, _):
hasher.combine("video")
hasher.combine(url)
}
}
}

View File

@ -1,290 +0,0 @@
import Foundation
// MARK: - MemberProfile Response
struct MemberProfileResponse: Codable {
let code: Int
let data: MemberProfile
enum CodingKeys: String, CodingKey {
case code, data
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(code, forKey: .code)
try container.encode(data, forKey: .data)
}
}
// MARK: - TitleRanking
struct TitleRanking: Codable {
let displayName: String
let ranking: Int
let value: Int
let materialType: String
let userId: String
let region: String
let userAvatarUrl: String?
let userNickName: String?
enum CodingKeys: String, CodingKey {
case displayName = "display_name"
case ranking
case value
case materialType = "material_type"
case userId = "user_id"
case region
case userAvatarUrl = "user_avatar_url"
case userNickName = "user_nick_name"
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(displayName, forKey: .displayName)
try container.encode(ranking, forKey: .ranking)
try container.encode(value, forKey: .value)
try container.encode(materialType, forKey: .materialType)
try container.encode(userId, forKey: .userId)
try container.encode(region, forKey: .region)
try container.encodeIfPresent(userAvatarUrl, forKey: .userAvatarUrl)
try container.encodeIfPresent(userNickName, forKey: .userNickName)
}
}
// MARK: - MemberProfile
struct MemberProfile: Codable {
let materialCounter: MaterialCounter
let userInfo: MemberUserInfo
let storiesCount: Int
let conversationsCount: Int
let remainPoints: Int
let totalPoints: Int
let usedBytes: Int
let totalBytes: Int
let titleRankings: [TitleRanking]
let medalInfos: [MedalInfo]
let membershipLevel: String
let membershipEndAt: String
enum CodingKeys: String, CodingKey {
case materialCounter = "material_counter"
case userInfo = "user_info"
case storiesCount = "stories_count"
case conversationsCount = "conversations_count"
case remainPoints = "remain_points"
case totalPoints = "total_points"
case usedBytes = "used_bytes"
case totalBytes = "total_bytes"
case titleRankings = "title_rankings"
case medalInfos = "medal_infos"
case membershipLevel = "membership_level"
case membershipEndAt = "membership_end_at"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
materialCounter = try container.decode(MaterialCounter.self, forKey: .materialCounter)
userInfo = try container.decode(MemberUserInfo.self, forKey: .userInfo)
storiesCount = try container.decode(Int.self, forKey: .storiesCount)
conversationsCount = try container.decode(Int.self, forKey: .conversationsCount)
remainPoints = try container.decode(Int.self, forKey: .remainPoints)
totalPoints = try container.decode(Int.self, forKey: .totalPoints)
usedBytes = try container.decode(Int.self, forKey: .usedBytes)
totalBytes = try container.decode(Int.self, forKey: .totalBytes)
titleRankings = try container.decode([TitleRanking].self, forKey: .titleRankings)
if let medalInfos = try? container.decode([MedalInfo].self, forKey: .medalInfos) {
self.medalInfos = medalInfos
} else {
self.medalInfos = []
}
membershipLevel = try container.decode(String.self, forKey: .membershipLevel)
membershipEndAt = try container.decode(String.self, forKey: .membershipEndAt)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(materialCounter, forKey: .materialCounter)
try container.encode(userInfo, forKey: .userInfo)
try container.encode(storiesCount, forKey: .storiesCount)
try container.encode(conversationsCount, forKey: .conversationsCount)
try container.encode(remainPoints, forKey: .remainPoints)
try container.encode(totalPoints, forKey: .totalPoints)
try container.encode(usedBytes, forKey: .usedBytes)
try container.encode(totalBytes, forKey: .totalBytes)
try container.encode(titleRankings, forKey: .titleRankings)
try container.encode(medalInfos, forKey: .medalInfos)
try container.encode(membershipLevel, forKey: .membershipLevel)
try container.encode(membershipEndAt, forKey: .membershipEndAt)
}
}
// MARK: - MemberUserInfo
struct MemberUserInfo: Codable {
let userId: String
let accessToken: String
let avatarFileUrl: String?
let nickname: String
let account: String
let email: String
let refreshToken: String?
enum CodingKeys: String, CodingKey {
case userId = "user_id"
case accessToken = "access_token"
case avatarFileUrl = "avatar_file_url"
case nickname, account, email
case refreshToken = "refresh_token"
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(userId, forKey: .userId)
try container.encode(accessToken, forKey: .accessToken)
try container.encodeIfPresent(avatarFileUrl, forKey: .avatarFileUrl)
try container.encode(nickname, forKey: .nickname)
try container.encode(account, forKey: .account)
try container.encode(email, forKey: .email)
try container.encodeIfPresent(refreshToken, forKey: .refreshToken)
}
}
// MARK: - MaterialCounter
struct MaterialCounter: Codable {
let userId: Int64
let totalCount: TotalCount
let categoryCount: [String: CategoryCount]
enum CodingKeys: String, CodingKey {
case userId = "user_id"
case totalCount = "total_count"
case categoryCount = "category_count"
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(userId, forKey: .userId)
try container.encode(totalCount, forKey: .totalCount)
try container.encode(categoryCount, forKey: .categoryCount)
}
}
// MARK: - TotalCount
struct TotalCount: Codable {
let videoCount: Int
let photoCount: Int
let liveCount: Int
let videoLength: Double
let coverUrl: String?
enum CodingKeys: String, CodingKey {
case videoCount = "video_count"
case photoCount = "photo_count"
case liveCount = "live_count"
case videoLength = "video_length"
case coverUrl = "cover_url"
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(videoCount, forKey: .videoCount)
try container.encode(photoCount, forKey: .photoCount)
try container.encode(liveCount, forKey: .liveCount)
try container.encode(videoLength, forKey: .videoLength)
try container.encodeIfPresent(coverUrl, forKey: .coverUrl)
}
}
// MARK: - CategoryCount
struct CategoryCount: Codable {
let videoCount: Int
let photoCount: Int
let liveCount: Int
let videoLength: Double
let coverUrl: String?
enum CodingKeys: String, CodingKey {
case videoCount = "video_count"
case photoCount = "photo_count"
case liveCount = "live_count"
case videoLength = "video_length"
case coverUrl = "cover_url"
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(videoCount, forKey: .videoCount)
try container.encode(photoCount, forKey: .photoCount)
try container.encode(liveCount, forKey: .liveCount)
try container.encode(videoLength, forKey: .videoLength)
try container.encodeIfPresent(coverUrl, forKey: .coverUrl)
}
}
// MARK: - MedalInfo
struct MedalInfo: Codable, Identifiable {
let id: Int
let url: String
enum CodingKeys: String, CodingKey {
case id, url
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(url, forKey: .url)
}
}
// MARK: - API Response Wrapper
struct MemberAPIResponse<T: Codable>: Codable {
let code: Int
let message: String
let data: T
enum CodingKeys: String, CodingKey {
case code, message, data
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(code, forKey: .code)
try container.encode(message, forKey: .message)
try container.encode(data, forKey: .data)
}
}
// MARK: - Date Formatter
class DateFormatterManager {
static let shared = DateFormatterManager()
let iso8601Full: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
private init() {}
}
// MARK: - JSON Decoder Extension
extension JSONDecoder {
static let `default`: JSONDecoder = {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .custom { decoder -> Date in
let container = try decoder.singleValueContainer()
let dateString = try container.decode(String.self)
if let date = DateFormatterManager.shared.iso8601Full.date(from: dateString) {
return date
}
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode date string \(dateString)")
}
return decoder
}()
}

View File

@ -1,101 +0,0 @@
import Foundation
///
struct OrderInfo: Codable, Identifiable {
let id: String
let userId: String
let totalAmount: Amount
let status: String
let items: [OrderItem]
let paymentInfo: PaymentInfo?
let createdAt: String
let updatedAt: String
let expiredAt: String
enum CodingKeys: String, CodingKey {
case id
case userId = "user_id"
case totalAmount = "total_amount"
case status
case items
case paymentInfo = "payment_info"
case createdAt = "created_at"
case updatedAt = "updated_at"
case expiredAt = "expired_at"
}
}
///
struct PaymentInfo: Codable {
let id: String
let paymentMethod: String
let paymentStatus: String
let paymentAmount: Amount
let transactionId: String?
let thirdPartyTransactionId: String?
let paidAt: String?
let createdAt: String
let updatedAt: String
enum CodingKeys: String, CodingKey {
case id
case paymentMethod = "payment_method"
case paymentStatus = "payment_status"
case paymentAmount = "payment_amount"
case transactionId = "transaction_id"
case thirdPartyTransactionId = "third_party_transaction_id"
case paidAt = "paid_at"
case createdAt = "created_at"
case updatedAt = "updated_at"
}
}
///
struct Amount: Codable {
let amount: String
let currency: String
}
///
struct OrderItem: Codable, Identifiable {
let id: String
let productId: Int
let productType: String
let productCode: String
let productName: String
let unitPrice: Amount
let discountAmount: Amount
let quantity: Int
let totalPrice: Amount
enum CodingKeys: String, CodingKey {
case id
case productId = "product_id"
case productType = "product_type"
case productCode = "product_code"
case productName = "product_name"
case unitPrice = "unit_price"
case discountAmount = "discount_amount"
case quantity
case totalPrice = "total_price"
}
}
///
enum OrderStatus: Int, Codable {
case pending = 0 //
case paid = 1 //
case completed = 2 //
case cancelled = 3 //
case refunded = 4 // 退
var description: String {
switch self {
case .pending: return "待支付"
case .paid: return "已支付"
case .completed: return "已完成"
case .cancelled: return "已取消"
case .refunded: return "已退款"
}
}
}

View File

@ -1,57 +0,0 @@
import SwiftUI
///
public enum UploadStatus: Equatable {
case idle
case uploading(progress: Double)
case success
case failure(Error)
public var isUploading: Bool {
if case .uploading = self { return true }
return false
}
public var progress: Double {
if case let .uploading(progress) = self { return progress }
return 0
}
public static func == (lhs: UploadStatus, rhs: UploadStatus) -> Bool {
switch (lhs, rhs) {
case (.idle, .idle):
return true
case let (.uploading(lhsProgress), .uploading(rhsProgress)):
// 使
return abs(lhsProgress - rhsProgress) < 0.001
case (.success, .success):
return true
case (.failure, .failure):
// Error
//
return false
default:
return false
}
}
}
///
public struct UploadResult: Identifiable, Equatable {
public let id = UUID()
public var fileId: String
public var previewFileId: String
public let image: UIImage
public var status: UploadStatus = .idle
public init(fileId: String = "", previewFileId: String = "", image: UIImage, status: UploadStatus = .idle) {
self.fileId = fileId
self.previewFileId = previewFileId
self.image = image
self.status = status
}
public static func == (lhs: UploadResult, rhs: UploadResult) -> Bool {
lhs.id == rhs.id
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

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`

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