diff --git a/.DS_Store b/.DS_Store
index 9d48cbe..c9ef09f 100644
Binary files a/.DS_Store and b/.DS_Store differ
diff --git a/wake.xcodeproj/project.xcworkspace/xcuserdata/elliwood.xcuserdatad/UserInterfaceState.xcuserstate b/wake.xcodeproj/project.xcworkspace/xcuserdata/elliwood.xcuserdatad/UserInterfaceState.xcuserstate
index 2a4b61b..6dc6901 100644
Binary files a/wake.xcodeproj/project.xcworkspace/xcuserdata/elliwood.xcuserdatad/UserInterfaceState.xcuserstate and b/wake.xcodeproj/project.xcworkspace/xcuserdata/elliwood.xcuserdatad/UserInterfaceState.xcuserstate differ
diff --git a/wake/.DS_Store b/wake/.DS_Store
index b3853f3..efa3987 100644
Binary files a/wake/.DS_Store and b/wake/.DS_Store differ
diff --git a/wake/Assets/.DS_Store b/wake/Assets/.DS_Store
new file mode 100644
index 0000000..bb35e99
Binary files /dev/null and b/wake/Assets/.DS_Store differ
diff --git a/wake/Assets/Svg/IP.svg b/wake/Assets/Svg/IP.svg
new file mode 100644
index 0000000..4673a82
--- /dev/null
+++ b/wake/Assets/Svg/IP.svg
@@ -0,0 +1,9 @@
+
diff --git a/wake/Components/Media/CustomCameraView.swift b/wake/Components/Media/CustomCameraView.swift
new file mode 100644
index 0000000..f63763e
--- /dev/null
+++ b/wake/Components/Media/CustomCameraView.swift
@@ -0,0 +1,265 @@
+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
+ }
+}
diff --git a/wake/ContentView.swift b/wake/ContentView.swift
index f85ebda..c5810e5 100644
--- a/wake/ContentView.swift
+++ b/wake/ContentView.swift
@@ -70,7 +70,9 @@ struct ContentView: View {
}
}
// 登录按钮
- NavigationLink(destination: LoginView()) {
+ Button(action: {
+ showLogin = true
+ }) {
Text("登录")
.font(.headline)
.padding(.horizontal, 16)
@@ -102,6 +104,9 @@ struct ContentView: View {
.cornerRadius(8)
}
.padding(.trailing)
+ .fullScreenCover(isPresented: $showLogin) {
+ LoginView()
+ }
}
Spacer()
diff --git a/wake/CoreData/.DS_Store b/wake/CoreData/.DS_Store
new file mode 100644
index 0000000..57909a7
Binary files /dev/null and b/wake/CoreData/.DS_Store differ
diff --git a/wake/Info.plist b/wake/Info.plist
index 8e8cc04..a1302a6 100644
--- a/wake/Info.plist
+++ b/wake/Info.plist
@@ -20,10 +20,14 @@
NSAppleIDUsageDescription
Sign in with Apple is used to authenticate your account
+ NSCameraUsageDescription
+ We need access to your camera to take photos
+ NSPhotoLibraryUsageDescription
+ We need access to your photo library to select photos
UIAppFonts
+ Inter.ttf
Quicksand x.ttf
- SankeiCutePopanime.ttf
Quicksand-Regular.ttf
Quicksand-Bold.ttf
Quicksand-SemiBold.ttf
diff --git a/wake/Models/AuthModels.swift b/wake/Models/AuthModels.swift
new file mode 100644
index 0000000..21ff48a
--- /dev/null
+++ b/wake/Models/AuthModels.swift
@@ -0,0 +1,59 @@
+import Foundation
+
+/// API基础响应模型
+struct BaseResponse: 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
+
+/// 用户信息响应数据
+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
diff --git a/wake/Models/AuthState.swift b/wake/Models/AuthState.swift
new file mode 100644
index 0000000..c57917d
--- /dev/null
+++ b/wake/Models/AuthState.swift
@@ -0,0 +1,49 @@
+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
+ }
+}
diff --git a/wake/Models/UploadModels.swift b/wake/Models/UploadModels.swift
new file mode 100644
index 0000000..336653e
--- /dev/null
+++ b/wake/Models/UploadModels.swift
@@ -0,0 +1,57 @@
+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
+ }
+}
diff --git a/wake/Resources/.DS_Store b/wake/Resources/.DS_Store
new file mode 100644
index 0000000..99b4c6a
Binary files /dev/null and b/wake/Resources/.DS_Store differ
diff --git a/wake/Resources/Fonts/Inter.ttf b/wake/Resources/Fonts/Inter.ttf
new file mode 100644
index 0000000..e31b51e
Binary files /dev/null and b/wake/Resources/Fonts/Inter.ttf differ
diff --git a/wake/Resources/Quicksand x.ttf b/wake/Resources/Fonts/Quicksand x.ttf
similarity index 100%
rename from wake/Resources/Quicksand x.ttf
rename to wake/Resources/Fonts/Quicksand x.ttf
diff --git a/wake/Resources/SankeiCutePopanime.ttf b/wake/Resources/SankeiCutePopanime.ttf
deleted file mode 100644
index 5f31eeb..0000000
Binary files a/wake/Resources/SankeiCutePopanime.ttf and /dev/null differ
diff --git a/wake/Theme.swift b/wake/Theme.swift
index dfcc994..30b40cb 100644
--- a/wake/Theme.swift
+++ b/wake/Theme.swift
@@ -23,7 +23,7 @@ struct Theme {
static let accent = Color(hex: "FF6B6B") // 强调红色
// MARK: - 中性色
- static let background = Color(hex: "FAFAFA") // 背景色
+ static let background = Color(hex: "F8F9FA") // 背景色
static let surface = Color.white // 表面色
static let surfaceSecondary = Color(hex: "F5F5F5") // 次级表面色
@@ -32,6 +32,10 @@ struct Theme {
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") // 成功色
@@ -40,18 +44,14 @@ struct Theme {
static let info = Color(hex: "3B82F6") // 信息色
// MARK: - 边框色
- static let border = Color(hex: "D9D9D9") // 边框色
+ static let border = Color(hex: "E5E7EB") // 边框色
static let borderLight = Color(hex: "F3F4F6") // 浅边框色
- static let borderBlack = Color.black // 黑色边框色
- static let borderDark = borderBlack // 深边框色
+ 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: - 渐变色
@@ -63,13 +63,9 @@ struct Theme {
)
static let backgroundGradient = LinearGradient(
- gradient: Gradient(colors: [
- Color(hex: "FBC063"),
- Color(hex: "FEE9BE"),
- Color(hex: "FAB851")
- ]),
- startPoint: .topLeading,
- endPoint: .bottomTrailing
+ colors: [Colors.background, Colors.surface],
+ startPoint: .top,
+ endPoint: .bottom
)
static let accentGradient = LinearGradient(
@@ -77,12 +73,6 @@ struct Theme {
startPoint: .leading,
endPoint: .trailing
)
-
- // static let creditsInfoTooltip = LinearGradient(
- // colors: [Colors(hex: "FFD38F"), Colors(hex: "FFF8DE"), Colors(hex: "FECE83")],
- // startPoint: .topLeading,
- // endPoint: .bottomTrailing
- // )
}
// MARK: - 阴影
@@ -129,6 +119,10 @@ extension Color {
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: - 预览
diff --git a/wake/Typography.swift b/wake/Typography.swift
index e655d70..8e6db8e 100644
--- a/wake/Typography.swift
+++ b/wake/Typography.swift
@@ -3,12 +3,10 @@ import SwiftUI
// MARK: - 字体库枚举
/// 定义应用中可用的字体库
enum FontFamily: String, CaseIterable {
- case sankeiCute = "SankeiCutePopanime" // 可爱风格字体
- case quicksandRegular = "Quicksand-Regular" // 主题字体(常规)
+ case quicksand = "Quicksand x"
case quicksandBold = "Quicksand-Bold"
- case lavishlyYours = "LavishlyYours-Regular"
- // 后续添加新字体库时在这里添加新 case
- // 例如: case anotherFont = "AnotherFontName"
+ case quicksandRegular = "Quicksand-Regular"
+ case inter = "Inter"
/// 获取字体名称
var name: String {
@@ -25,8 +23,10 @@ extension FontFamily {
// MARK: - 文本样式枚举
/// 定义应用中使用的文本样式类型
enum TypographyStyle {
+ case largeTitle // 大标题
case headline // 大标题
case title // 标题
+ case title2 // 标题
case body // 正文
case subtitle // 副标题
case caption // 说明文字
@@ -50,11 +50,13 @@ struct Typography {
/// 文本样式配置表
private static let styleConfig: [TypographyStyle: TypographyConfig] = [
+ .largeTitle: TypographyConfig(size: 32, weight: .heavy, textStyle: .largeTitle),
.headline: TypographyConfig(size: 24, weight: .bold, textStyle: .headline),
.title: TypographyConfig(size: 20, weight: .semibold, textStyle: .title2),
+ .title2: TypographyConfig(size: 18, weight: .bold, textStyle: .title2),
.body: TypographyConfig(size: 16, weight: .regular, textStyle: .body),
.subtitle: TypographyConfig(size: 14, weight: .medium, textStyle: .subheadline),
- .caption: TypographyConfig(size: 12, weight: .light, textStyle: .caption1),
+ .caption: TypographyConfig(size: 12, weight: .regular, textStyle: .caption1),
.footnote: TypographyConfig(size: 11, weight: .regular, textStyle: .footnote),
.small: TypographyConfig(size: 10, weight: .regular, textStyle: .headline)
]
diff --git a/wake/Utils/APIConfig.swift b/wake/Utils/APIConfig.swift
new file mode 100644
index 0000000..5b8265b
--- /dev/null
+++ b/wake/Utils/APIConfig.swift
@@ -0,0 +1,34 @@
+import Foundation
+
+/// API 配置信息
+public enum APIConfig {
+ /// API 基础 URL
+ public static let baseURL = "https://api-dev.memorywake.com:31274/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
+ }
+
+}
diff --git a/wake/Utils/KeychainHelper.swift b/wake/Utils/KeychainHelper.swift
new file mode 100644
index 0000000..b2c4127
--- /dev/null
+++ b/wake/Utils/KeychainHelper.swift
@@ -0,0 +1,83 @@
+import Foundation
+import Security
+
+/// 用于安全存储和检索敏感信息(如令牌)的 Keychain 帮助类
+public class KeychainHelper {
+ // Keychain 键名
+ private enum KeychainKey: String {
+ case accessToken = "com.memorywake.accessToken"
+ case refreshToken = "com.memorywake.refreshToken"
+ }
+
+ /// 保存访问令牌到 Keychain
+ public static func saveAccessToken(_ token: String) -> Bool {
+ return save(token, for: .accessToken)
+ }
+
+ /// 从 Keychain 获取访问令牌
+ public static func getAccessToken() -> String? {
+ return get(for: .accessToken)
+ }
+
+ /// 保存刷新令牌到 Keychain
+ public static func saveRefreshToken(_ token: String) -> Bool {
+ return save(token, for: .refreshToken)
+ }
+
+ /// 从 Keychain 获取刷新令牌
+ public static func getRefreshToken() -> String? {
+ return get(for: .refreshToken)
+ }
+
+ /// 删除所有存储的令牌
+ public static func clearTokens() {
+ delete(for: .accessToken)
+ delete(for: .refreshToken)
+ }
+
+ // MARK: - 私有方法
+
+ private static func save(_ string: String, for key: KeychainKey) -> Bool {
+ guard let data = string.data(using: .utf8) else { return false }
+
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrAccount as String: key.rawValue,
+ kSecValueData as String: data
+ ]
+
+ // 先删除已存在的项目
+ SecItemDelete(query as CFDictionary)
+
+ // 添加新项目
+ let status = SecItemAdd(query as CFDictionary, nil)
+ return status == errSecSuccess
+ }
+
+ private static func get(for key: KeychainKey) -> String? {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrAccount as String: key.rawValue,
+ kSecReturnData as String: true,
+ kSecMatchLimit as String: kSecMatchLimitOne
+ ]
+
+ var dataTypeRef: AnyObject?
+ let status = SecItemCopyMatching(query as CFDictionary, &dataTypeRef)
+
+ guard status == errSecSuccess, let data = dataTypeRef as? Data else {
+ return nil
+ }
+
+ return String(data: data, encoding: .utf8)
+ }
+
+ private static func delete(for key: KeychainKey) {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrAccount as String: key.rawValue
+ ]
+
+ SecItemDelete(query as CFDictionary)
+ }
+}
diff --git a/wake/Utils/MediaUtils.swift b/wake/Utils/MediaUtils.swift
new file mode 100644
index 0000000..abb6cba
--- /dev/null
+++ b/wake/Utils/MediaUtils.swift
@@ -0,0 +1,84 @@
+import AVFoundation
+import UIKit
+import os.log
+
+/// 媒体工具类,提供视频处理相关功能
+enum MediaUtils {
+ private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.example.app", category: "MediaUtils")
+
+ /// 从视频URL中提取第一帧
+ /// - Parameters:
+ /// - videoURL: 视频文件的URL
+ /// - completion: 完成回调,返回UIImage或错误
+ static func extractFirstFrame(from videoURL: URL, completion: @escaping (Result) -> Void) {
+ let asset = AVURLAsset(url: videoURL)
+ let assetImgGenerate = AVAssetImageGenerator(asset: asset)
+ assetImgGenerate.appliesPreferredTrackTransform = true
+
+ // 获取视频时长
+ let duration = asset.duration
+ let durationTime = CMTimeGetSeconds(duration)
+
+ // 如果视频时长小于等于0,返回错误
+ guard durationTime > 0 else {
+ let error = NSError(domain: "com.yourapp.media", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid video duration"])
+ completion(.failure(error))
+ return
+ }
+
+ // 获取第一帧(时间点为0)
+ let time = CMTime(seconds: 0, preferredTimescale: 600)
+
+ // 生成图片
+ assetImgGenerate.generateCGImagesAsynchronously(forTimes: [NSValue(time: time)]) { (_, cgImage, _, result, error) in
+ if let error = error {
+ logger.error("Failed to generate image: \(error.localizedDescription)")
+ DispatchQueue.main.async {
+ completion(.failure(error))
+ }
+ return
+ }
+
+ guard result == .succeeded, let cgImage = cgImage else {
+ let error = NSError(domain: "com.yourapp.media", code: -2, userInfo: [NSLocalizedDescriptionKey: "Failed to generate image from video"])
+ logger.error("Failed to generate image: \(error.localizedDescription)")
+ DispatchQueue.main.async {
+ completion(.failure(error))
+ }
+ return
+ }
+
+ // 创建UIImage并返回
+ let image = UIImage(cgImage: cgImage)
+ DispatchQueue.main.async {
+ completion(.success(image))
+ }
+ }
+ }
+
+ /// 从视频数据中提取第一帧
+ /// - Parameters:
+ /// - videoData: 视频数据
+ /// - completion: 完成回调,返回UIImage或错误
+ static func extractFirstFrame(from videoData: Data, completion: @escaping (Result) -> Void) {
+ // 创建临时文件URL
+ let tempDirectoryURL = FileManager.default.temporaryDirectory
+ let fileName = "tempVideo_\(UUID().uuidString).mov"
+ let fileURL = tempDirectoryURL.appendingPathComponent(fileName)
+
+ do {
+ // 将数据写入临时文件
+ try videoData.write(to: fileURL)
+
+ // 调用URL版本的方法
+ extractFirstFrame(from: fileURL) { result in
+ // 清理临时文件
+ try? FileManager.default.removeItem(at: fileURL)
+ completion(result)
+ }
+ } catch {
+ logger.error("Failed to write video data to temporary file: \(error.localizedDescription)")
+ completion(.failure(error))
+ }
+ }
+}
diff --git a/wake/Utils/NetWork.swift b/wake/Utils/NetWork.swift
deleted file mode 100644
index 0cdffc9..0000000
--- a/wake/Utils/NetWork.swift
+++ /dev/null
@@ -1,34 +0,0 @@
-import SwiftUI
-
-class Network: ObservableObject {
- @Published var users: [User] = []
-
- func getUsers() {
- guard let url = URL(string: "http://192.168.31.156:31646/api/iam/login/password-login") else { fatalError("Missing URL") }
-
- let urlRequest = URLRequest(url: url)
-
- let dataTask = URLSession.shared.dataTask(with: urlRequest) { (data, response, error) in
- if let error = error {
- print("Request error: ", error)
- return
- }
-
- guard let response = response as? HTTPURLResponse else { return }
-
- if response.statusCode == 200 {
- guard let data = data else { return }
- DispatchQueue.main.async {
- do {
- let decodedUsers = try JSONDecoder().decode([User].self, from: data)
- self.users = decodedUsers
- } catch let error {
- print("Error decoding: ", error)
- }
- }
- }
- }
-
- dataTask.resume()
- }
-}
diff --git a/wake/Utils/NetworkService.swift b/wake/Utils/NetworkService.swift
new file mode 100644
index 0000000..ac67c70
--- /dev/null
+++ b/wake/Utils/NetworkService.swift
@@ -0,0 +1,406 @@
+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
+ }
+}
+
+enum NetworkError: Error {
+ case invalidURL
+ case noData
+ case decodingError(Error)
+ case serverError(String)
+ case unauthorized
+ case other(Error)
+ case networkError(Error)
+ case unknownError(Error)
+
+ 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)"
+ }
+ }
+}
+
+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) -> Void, Int)] = []
+
+ private init() {}
+
+ // MARK: - 基础请求方法
+ private func request(
+ _ method: String,
+ path: String,
+ parameters: [String: Any]? = nil,
+ headers: [String: String]? = nil,
+ completion: @escaping (Result) -> 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)
+ }
+
+ // 添加认证头
+ 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 {
+ request.httpBody = try JSONSerialization.data(withJSONObject: parameters)
+ } catch {
+ print("❌ [Network][#\(requestId)][\(method) \(path)] 参数序列化失败: \(error.localizedDescription)")
+ completion(.failure(.other(error)))
+ return
+ }
+ }
+
+ // 打印请求信息
+ print("""
+ 🌐 [Network][#\(requestId)][\(method) \(path)] 开始请求
+ 🔗 URL: \(url.absoluteString)
+ 📤 Headers: \(request.allHTTPHeaderFields ?? [:])
+ 📦 Body: \(request.httpBody.flatMap { String(data: $0, encoding: .utf8) } ?? "")
+ """)
+
+ // 创建任务
+ 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) in
+ completion(result)
+ }
+ )
+ }
+
+ // 开始请求
+ task.resume()
+ }
+
+ private func handleResponse(
+ requestId: Int,
+ method: String,
+ path: String,
+ data: Data?,
+ response: URLResponse?,
+ error: Error?,
+ request: URLRequest,
+ duration: String,
+ completion: @escaping (Result) -> Void
+ ) {
+ // 打印响应信息
+ if let httpResponse = response as? HTTPURLResponse {
+ let statusCode = httpResponse.statusCode
+ let statusMessage = HTTPURLResponse.localizedString(forStatusCode: statusCode)
+
+ // 处理401未授权
+ if statusCode == 401 {
+ print("""
+ 🔑 [Network][#\(requestId)][\(method) \(path)] 检测到未授权,尝试刷新token...
+ ⏱️ 耗时: \(duration)
+ """)
+
+ // 将请求加入重试队列
+ let dataResult = data.flatMap { Result.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)
+ print("""
+ ✅ [Network][#\(requestId)][\(method) \(path)] 重试成功
+ ⏱️ 总耗时: \(duration) (包含token刷新时间)
+ """)
+ 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) } ?? ""
+ print("""
+ ❌ [Network][#\(requestId)][\(method) \(path)] 请求失败
+ 📊 状态码: \(statusCode) (\(statusMessage))
+ ⏱️ 耗时: \(duration)
+ 🔍 错误响应: \(errorMessage)
+ """)
+ completion(.failure(.serverError("状态码: \(statusCode), 响应: \(errorMessage)")))
+ return
+ }
+
+ // 成功响应
+ print("""
+ ✅ [Network][#\(requestId)][\(method) \(path)] 请求成功
+ 📊 状态码: \(statusCode) (\(statusMessage))
+ ⏱️ 耗时: \(duration)
+ """)
+ }
+
+ // 处理网络错误
+ 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
+ }
+
+ // 打印响应数据(调试用)
+ if let responseString = String(data: data, encoding: .utf8) {
+ print("""
+ 📥 [Network][#\(requestId)][\(method) \(path)] 响应数据:
+ \(responseString.prefix(1000))\(responseString.count > 1000 ? "..." : "")
+ """)
+ }
+
+ do {
+ // 解析JSON数据
+ let decoder = JSONDecoder()
+ let result = try decoder.decode(T.self, from: data)
+ 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)))
+ }
+ }
+
+ private func refreshAndRetryRequests() {
+ guard !isRefreshing else { return }
+
+ isRefreshing = true
+ let refreshStartTime = Date()
+
+ print("🔄 [Network] 开始刷新Token...")
+
+ TokenManager.shared.refreshToken { [weak self] success, _ in
+ guard let self = self else { return }
+
+ let refreshDuration = String(format: "%.3fs", Date().timeIntervalSince(refreshStartTime))
+
+ if success {
+ print("""
+ ✅ [Network] Token刷新成功
+ ⏱️ 耗时: \(refreshDuration)
+ 🔄 准备重试\(self.requestsToRetry.count)个请求...
+ """)
+
+ // 重试所有待处理的请求
+ 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 {
+ print("""
+ ❌ [Network] Token刷新失败
+ ⏱️ 耗时: \(refreshDuration)
+ 🚪 清除登录状态...
+ """)
+
+ // 清除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(
+ path: String,
+ parameters: [String: Any]? = nil,
+ headers: [String: String]? = nil,
+ completion: @escaping (Result) -> Void
+ ) {
+ request("GET", path: path, parameters: parameters, headers: headers, completion: completion)
+ }
+
+ /// POST 请求
+ func post(
+ path: String,
+ parameters: [String: Any]? = nil,
+ headers: [String: String]? = nil,
+ completion: @escaping (Result) -> Void
+ ) {
+ request("POST", path: path, parameters: parameters, headers: headers, completion: completion)
+ }
+
+ /// POST 请求(带Token)
+ func postWithToken(
+ path: String,
+ parameters: [String: Any]? = nil,
+ headers: [String: String]? = nil,
+ completion: @escaping (Result) -> 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(
+ path: String,
+ parameters: [String: Any]? = nil,
+ headers: [String: String]? = nil,
+ completion: @escaping (Result) -> Void
+ ) {
+ request("DELETE", path: path, parameters: parameters, headers: headers, completion: completion)
+ }
+
+ /// PUT 请求
+ func put(
+ path: String,
+ parameters: [String: Any]? = nil,
+ headers: [String: String]? = nil,
+ completion: @escaping (Result) -> Void
+ ) {
+ request("PUT", path: path, parameters: parameters, headers: headers, completion: completion)
+ }
+}
diff --git a/wake/Utils/TokenManager.swift b/wake/Utils/TokenManager.swift
new file mode 100644
index 0000000..ad6c2d9
--- /dev/null
+++ b/wake/Utils/TokenManager.swift
@@ -0,0 +1,321 @@
+import Foundation
+
+/// Token管理器
+/// 负责管理应用的认证令牌,包括验证、刷新和过期处理
+class TokenManager {
+ /// 单例实例
+ static let shared = TokenManager()
+
+ /// token有效期阈值(秒),在token即将过期前进行刷新
+ /// 例如:设置为300表示在token过期前5分钟开始刷新
+ private let tokenValidityThreshold: TimeInterval = 300
+
+ /// 私有化初始化方法,确保单例模式
+ private init() {}
+
+ // MARK: - Token 状态检查
+
+ /// 检查是否存在有效的访问令牌
+ var hasToken: Bool {
+ return KeychainHelper.getAccessToken()?.isEmpty == false
+ }
+
+ // MARK: - Token 验证
+
+ /// 验证并刷新token(如果需要)
+ /// - 检查token是否存在
+ /// - 检查token是否有效
+ /// - 在token即将过期时自动刷新
+ /// - Parameter completion: 完成回调,返回验证/刷新结果
+ /// - isValid: token是否有效
+ /// - error: 错误信息(如果有)
+ func validateAndRefreshTokenIfNeeded(completion: @escaping (Bool, Error?) -> Void) {
+ // 1. 检查token是否存在
+ guard let token = KeychainHelper.getAccessToken(), !token.isEmpty else {
+ // token不存在,返回未授权错误
+ let error = NSError(
+ domain: "TokenManager",
+ code: 401,
+ userInfo: [NSLocalizedDescriptionKey: "未找到访问令牌"]
+ )
+ completion(false, error)
+ return
+ }
+
+ // 2. 检查token是否有效
+ if isTokenValid(token) {
+ // token有效,直接返回成功
+ completion(true, nil)
+ return
+ }
+
+ // 3. token无效或即将过期,尝试刷新
+ refreshToken { [weak self] success, error in
+ if success {
+ // 刷新成功,返回成功
+ completion(true, nil)
+ } else {
+ // 刷新失败,返回错误信息
+ let finalError = error ?? NSError(
+ domain: "TokenManager",
+ code: 401,
+ userInfo: [NSLocalizedDescriptionKey: "Token刷新失败"]
+ )
+ completion(false, finalError)
+ }
+ }
+ }
+
+ /// 检查token是否有效
+ /// - Parameter token: 要检查的token字符串
+ /// - Returns: 如果token有效返回true,否则返回false
+ ///
+ /// 该方法会检查token的有效性,包括检查token是否为空、是否过期以及通过网络请求验证token。
+ ///
+ /// - Note: 该方法会打印一些调试信息,包括token验证开始、token过期时间等。
+ public func isTokenValid(_ token: String) -> Bool {
+ print("🔍 TokenManager: 开始验证token...")
+
+ // 1. 基础验证:检查token是否为空
+ guard !token.isEmpty else {
+ print("❌ TokenManager: Token为空")
+ return false
+ }
+
+ // 2. 检查token是否过期(如果可能)
+ if let expiryDate = getTokenExpiryDate(token) {
+ print("⏰ TokenManager: Token过期时间: \(expiryDate)")
+ if Date() > expiryDate {
+ print("❌ TokenManager: Token已过期")
+ return false
+ }
+ }
+
+ // 3. 创建信号量用于同步网络请求
+ let semaphore = DispatchSemaphore(value: 0)
+ var isValid = false
+ var requestCompleted = false
+
+ print("🌐 TokenManager: 发送验证请求到服务器...")
+
+ // 4. 发送验证请求
+ let task = URLSession.shared.dataTask(with: createValidationRequest(token: token)) { data, response, error in
+ defer {
+ requestCompleted = true
+ semaphore.signal()
+ }
+
+ // 检查网络错误
+ if let error = error {
+ print("❌ TokenManager: 验证请求错误: \(error.localizedDescription)")
+ return
+ }
+
+ // 检查响应状态码
+ guard let httpResponse = response as? HTTPURLResponse else {
+ print("❌ TokenManager: 无效的服务器响应")
+ return
+ }
+
+ print("📡 TokenManager: 服务器响应状态码: \(httpResponse.statusCode)")
+
+ // 检查状态码
+ guard (200...299).contains(httpResponse.statusCode) else {
+ print("❌ TokenManager: 服务器返回错误状态码: \(httpResponse.statusCode)")
+ return
+ }
+
+ // 检查是否有数据
+ if let data = data, !data.isEmpty {
+ do {
+ // 尝试解析响应数据
+ let response = try JSONDecoder().decode(IdentityCheckResponse.self, from: data)
+ isValid = response.isValid
+ print("✅ TokenManager: Token验证\(isValid ? "成功" : "失败")")
+ } catch {
+ print("❌ TokenManager: 解析响应数据失败: \(error.localizedDescription)")
+ // 如果解析失败但状态码是200,我们假设token是有效的
+ isValid = true
+ print("ℹ️ TokenManager: 状态码200,假设token有效")
+ }
+ } else {
+ // 如果没有返回数据但状态码是200,我们假设token是有效的
+ print("ℹ️ TokenManager: 没有返回数据,但状态码为200,假设token有效")
+ isValid = true
+ }
+ }
+
+ task.resume()
+
+ // 5. 设置超时时间(10秒)
+ let timeoutResult = semaphore.wait(timeout: .now() + 15)
+
+ // 检查是否超时
+ if !requestCompleted && timeoutResult == .timedOut {
+ print("⚠️ TokenManager: 验证请求超时")
+ task.cancel()
+ return false
+ }
+
+ return isValid
+ }
+
+ /// 创建验证请求
+ private func createValidationRequest(token: String) -> URLRequest {
+ let url = URL(string: APIConfig.baseURL + "/iam/identity-check")!
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+ return request
+ }
+
+ /// 从token中提取过期时间(示例实现)
+ private func getTokenExpiryDate(_ token: String) -> Date? {
+ // 这里需要根据实际的JWT或其他token格式来解析过期时间
+ // 以下是JWT token的示例解析
+ let parts = token.components(separatedBy: ".")
+ guard parts.count > 1, let payloadData = base64UrlDecode(parts[1]) else {
+ return nil
+ }
+
+ do {
+ if let payload = try JSONSerialization.jsonObject(with: payloadData) as? [String: Any],
+ let exp = payload["exp"] as? TimeInterval {
+ return Date(timeIntervalSince1970: exp)
+ }
+ } catch {
+ print("❌ TokenManager: 解析token过期时间失败: \(error.localizedDescription)")
+ }
+
+ return nil
+ }
+
+ private func base64UrlDecode(_ base64Url: String) -> Data? {
+ var base64 = base64Url
+ .replacingOccurrences(of: "-", with: "+")
+ .replacingOccurrences(of: "_", with: "/")
+
+ // 添加必要的填充
+ let length = Double(base64.lengthOfBytes(using: .utf8))
+ let requiredLength = 4 * ceil(length / 4.0)
+ let paddingLength = requiredLength - length
+ if paddingLength > 0 {
+ let padding = "".padding(toLength: Int(paddingLength), withPad: "=", startingAt: 0)
+ base64 = base64 + padding
+ }
+
+ return Data(base64Encoded: base64)
+ }
+
+ /// 刷新token
+ /// - Parameter completion: 刷新完成回调
+ /// - success: 是否刷新成功
+ /// - error: 错误信息(如果有)
+ func refreshToken(completion: @escaping (Bool, Error?) -> Void) {
+ // 获取刷新令牌
+ guard let refreshToken = KeychainHelper.getRefreshToken(), !refreshToken.isEmpty else {
+ // 没有可用的刷新令牌
+ let error = NSError(
+ domain: "TokenManager",
+ code: 401,
+ userInfo: [NSLocalizedDescriptionKey: "未找到刷新令牌"]
+ )
+ completion(false, error)
+ return
+ }
+
+ // 准备刷新请求参数
+ let parameters: [String: Any] = [
+ "refresh_token": refreshToken,
+ "grant_type": "refresh_token"
+ ]
+
+ // 发送刷新请求
+ NetworkService.shared.post(path: "/v1/iam/access-token-refresh", parameters: parameters) {
+ (result: Result) in
+
+ switch result {
+ case .success(let tokenResponse):
+ // 1. 保存新的访问令牌
+ KeychainHelper.saveAccessToken(tokenResponse.accessToken)
+
+ // 2. 如果返回了新的刷新令牌,也保存起来
+ if let newRefreshToken = tokenResponse.refreshToken {
+ KeychainHelper.saveRefreshToken(newRefreshToken)
+ }
+
+ print("✅ Token刷新成功")
+ completion(true, nil)
+
+ case .failure(let error):
+ print("❌ Token刷新失败: \(error.localizedDescription)")
+
+ // 刷新失败,清除本地token,需要用户重新登录
+ KeychainHelper.clearTokens()
+
+ completion(false, error)
+ }
+ }
+ }
+
+ /// 清除所有存储的 token
+ func clearTokens() {
+ print("🗑️ TokenManager: 清除所有 token")
+ KeychainHelper.clearTokens()
+ // 清除其他与 token 相关的存储
+ UserDefaults.standard.removeObject(forKey: "tokenExpiryDate")
+ UserDefaults.standard.synchronize()
+ }
+}
+
+// MARK: - Token响应模型
+/// 用于解析token刷新接口的响应数据
+private struct TokenResponse: Codable {
+ /// 访问令牌
+ let accessToken: String
+
+ /// 刷新令牌(可选)
+ let refreshToken: String?
+
+ /// 过期时间(秒)
+ let expiresIn: TimeInterval?
+
+ /// 令牌类型(如:Bearer)
+ let tokenType: String?
+
+ // 使用CodingKeys自定义键名映射
+ enum CodingKeys: String, CodingKey {
+ case accessToken = "access_token"
+ case refreshToken = "refresh_token"
+ case expiresIn = "expires_in"
+ case tokenType = "token_type"
+ }
+}
+
+// MARK: - 身份验证响应模型
+/// 用于解析身份验证接口的响应数据
+private struct IdentityCheckResponse: Codable {
+ /// 是否有效
+ let isValid: Bool
+
+ /// 用户ID(可选)
+ let userId: String?
+
+ /// 过期时间(可选)
+ let expiresAt: Date?
+
+ enum CodingKeys: String, CodingKey {
+ case isValid = "is_valid"
+ case userId = "user_id"
+ case expiresAt = "expires_at"
+ }
+}
+
+// MARK: - 通知名称
+/// 定义应用中使用的通知名称
+extension Notification.Name {
+ /// 用户登出通知
+ /// 当token失效或用户主动登出时发送
+ static let userDidLogout = Notification.Name("UserDidLogoutNotification")
+}
diff --git a/wake/Utils/User.swift b/wake/Utils/User.swift
index 2587619..797be1a 100644
--- a/wake/Utils/User.swift
+++ b/wake/Utils/User.swift
@@ -1,31 +1,61 @@
import Foundation
-struct User: Identifiable, Decodable {
- var id: Int
- var name: String
- var username: String
- var email: String
- var address: Address
- var phone: String
- var website: String
- var company: Company
-
- struct Address: Decodable {
- var street: String
- var suite: String
- var city: String
- var zipcode: String
- var geo: Geo
-
- struct Geo: Decodable {
- var lat: String
- var lng: String
+public struct User: Identifiable, Decodable {
+ public var id: Int
+ public var name: String
+ public var username: String
+ public var email: String
+ public var address: Address
+ public var phone: String
+ public var website: String
+ public var company: Company
+
+ public init(id: Int, name: String, username: String, email: String, address: Address, phone: String, website: String, company: Company) {
+ self.id = id
+ self.name = name
+ self.username = username
+ self.email = email
+ self.address = address
+ self.phone = phone
+ self.website = website
+ self.company = company
+ }
+
+ public struct Address: Decodable {
+ public var street: String
+ public var suite: String
+ public var city: String
+ public var zipcode: String
+ public var geo: Geo
+
+ public init(street: String, suite: String, city: String, zipcode: String, geo: Geo) {
+ self.street = street
+ self.suite = suite
+ self.city = city
+ self.zipcode = zipcode
+ self.geo = geo
+ }
+
+ public struct Geo: Decodable {
+ public var lat: String
+ public var lng: String
+
+ public init(lat: String, lng: String) {
+ self.lat = lat
+ self.lng = lng
+ }
}
}
-
- struct Company: Decodable {
- var name: String
- var catchPhrase: String
- var bs: String
+
+ public struct Company: Decodable {
+ public var name: String
+ public var catchPhrase: String
+ public var bs: String
+
+ public init(name: String, catchPhrase: String, bs: String) {
+ self.name = name
+ self.catchPhrase = catchPhrase
+ self.bs = bs
+ }
}
}
diff --git a/wake/View/Blind/Box.swift b/wake/View/Blind/Box.swift
new file mode 100644
index 0000000..9da0c25
--- /dev/null
+++ b/wake/View/Blind/Box.swift
@@ -0,0 +1,301 @@
+import SwiftUI
+
+struct FilmStripView: View {
+ @State private var animate = false
+ // 使用SF Symbols名称数组
+ private let symbolNames = [
+ "photo.fill", "heart.fill", "star.fill", "bookmark.fill",
+ "flag.fill", "bell.fill", "tag.fill", "paperplane.fill"
+ ]
+ private let targetIndices = [2, 5, 3] // 每条胶片最终停止的位置
+
+ var body: some View {
+ ZStack {
+ Color.black.edgesIgnoringSafeArea(.all)
+
+ // 三条胶片带
+ FilmStrip(
+ symbols: symbolNames,
+ targetIndex: targetIndices[0],
+ offset: 0,
+ stripColor: .red
+ )
+ .rotationEffect(.degrees(5))
+ .zIndex(1)
+
+ FilmStrip(
+ symbols: symbolNames,
+ targetIndex: targetIndices[1],
+ offset: 0.3,
+ stripColor: .blue
+ )
+ .rotationEffect(.degrees(-3))
+ .zIndex(2)
+
+ FilmStrip(
+ symbols: symbolNames,
+ targetIndex: targetIndices[2],
+ offset: 0.6,
+ stripColor: .green
+ )
+ .rotationEffect(.degrees(2))
+ .zIndex(3)
+ }
+ .onAppear {
+ withAnimation(
+ .timingCurve(0.2, 0.1, 0.8, 0.9, duration: 4.0)
+ ) {
+ animate = true
+ }
+ }
+ }
+}
+
+// 单个胶片带视图
+struct FilmStrip: View {
+ let symbols: [String]
+ let targetIndex: Int
+ let offset: Double
+ let stripColor: Color
+ @State private var animate = false
+
+ var body: some View {
+ GeometryReader { geometry in
+ let itemWidth: CGFloat = 100
+ let spacing: CGFloat = 8
+ let totalWidth = itemWidth * CGFloat(symbols.count) + spacing * CGFloat(symbols.count - 1)
+
+ // 胶片背景
+ RoundedRectangle(cornerRadius: 10)
+ .fill(stripColor.opacity(0.8))
+ .frame(height: 160)
+ .overlay(
+ // 胶片齿孔
+ HStack(spacing: spacing) {
+ ForEach(0.. CGFloat {
+ let baseDistance: CGFloat = 1000
+ let speedFactor: CGFloat = 1.0
+
+ return baseDistance * speedFactor * progressCurve()
+ }
+
+ // 中间正胶卷偏移量计算(向左移动)
+ private func calculateMiddleOffset() -> CGFloat {
+ let baseDistance: CGFloat = -1100
+ let speedFactor: CGFloat = 1.05
+
+ return baseDistance * speedFactor * progressCurve()
+ }
+
+ // 下方倾斜胶卷偏移量计算(向右移动)
+ private func calculateBottomOffset() -> CGFloat {
+ let baseDistance: CGFloat = 1000
+ let speedFactor: CGFloat = 0.95
+
+ return baseDistance * speedFactor * progressCurve()
+ }
+
+ // 动画曲线:先慢后快,最后卡顿
+ private func progressCurve() -> CGFloat {
+ if animationProgress < 0.6 {
+ // 初期加速阶段
+ return easeInQuad(animationProgress / 0.6) * 0.7
+ } else if animationProgress < 0.85 {
+ // 高速移动阶段
+ return 0.7 + easeOutQuad((animationProgress - 0.6) / 0.25) * 0.25
+ } else {
+ // 卡顿阶段
+ let t = (animationProgress - 0.85) / 0.15
+ return 0.95 + t * 0.05
+ }
+ }
+
+ // 缓入曲线
+ private func easeInQuad(_ t: CGFloat) -> CGFloat {
+ return t * t
+ }
+
+ // 缓出曲线
+ private func easeOutQuad(_ t: CGFloat) -> CGFloat {
+ return t * (2 - t)
+ }
+
+ // 启动动画序列
+ private func startAnimation() {
+ // 第一阶段:逐渐加速
+ withAnimation(.easeIn(duration: 3.5)) {
+ animationProgress = 0.6
+ }
+
+ // 第二阶段:高速移动
+ DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) {
+ withAnimation(.linear(duration: 2.5)) {
+ animationProgress = 0.85
+ }
+
+ // 第三阶段:卡顿效果
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
+ withAnimation(.easeOut(duration: 1.8)) {
+ animationProgress = 1.0
+ isCatching = true
+ }
+
+ // 卡顿后重合消失,显示目标图片
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.8) {
+ withAnimation(.easeInOut(duration: 0.7)) {
+ isDisappearing = true
+ }
+
+ // 显示重复播放按钮
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
+ withAnimation(.easeInOut(duration: 0.3)) {
+ showReplayButton = true
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+// 电影胶卷视图组件
+struct FilmReelView1: View {
+ let images: [String]
+
+ var body: some View {
+ HStack(spacing: 10) {
+ ForEach(images.indices, id: \.self) { index in
+ ZStack {
+ // 胶卷边框
+ RoundedRectangle(cornerRadius: 4)
+ .stroke(Color.gray, lineWidth: 2)
+ .background(Color(red: 0.15, green: 0.15, blue: 0.15))
+
+ // 图片内容
+ Rectangle()
+ .fill(
+ LinearGradient(
+ gradient: Gradient(colors: [.blue, .indigo]),
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ )
+ )
+ .opacity(0.9)
+ .cornerRadius(2)
+ .padding(2)
+
+ // 模拟图片文本
+ Text("\(images[index])")
+ .foregroundColor(.white)
+ .font(.caption2)
+ }
+ .frame(width: 90, height: 130)
+ // 胶卷孔洞
+ .overlay(
+ HStack {
+ VStack(spacing: 6) {
+ ForEach(0..<6) { _ in
+ Circle()
+ .frame(width: 6, height: 6)
+ .foregroundColor(.gray)
+ }
+ }
+ Spacer()
+ VStack(spacing: 6) {
+ ForEach(0..<6) { _ in
+ Circle()
+ .frame(width: 6, height: 6)
+ .foregroundColor(.gray)
+ }
+ }
+ }
+ )
+ }
+ }
+ }
+}
+
+// 预览
+struct ReplayableFilmReelAnimation_Previews: PreviewProvider {
+ static var previews: some View {
+ ReplayableFilmReelAnimation()
+ }
+}
+
\ No newline at end of file
diff --git a/wake/View/Blind/Box3.swift b/wake/View/Blind/Box3.swift
new file mode 100644
index 0000000..c482278
--- /dev/null
+++ b/wake/View/Blind/Box3.swift
@@ -0,0 +1,226 @@
+import SwiftUI
+
+struct FilmAnimation1: View {
+ // 设备尺寸
+ private let deviceWidth = UIScreen.main.bounds.width
+ private let deviceHeight = UIScreen.main.bounds.height
+
+ // 动画状态控制
+ @State private var animationProgress: CGFloat = 0.0 // 0-1总进度
+ @State private var isAnimating: Bool = false
+ @State private var animationComplete: Bool = false
+
+ // 胶卷数据
+ private let reelImages: [[String]] = [
+ (0..<150).map { "film1-\($0+1)" }, // 上方胶卷
+ (0..<180).map { "film2-\($0+1)" }, // 中间胶卷(垂直)
+ (0..<150).map { "film3-\($0+1)" } // 下方胶卷
+ ]
+
+ // 胶卷参数
+ private let frameWidth: CGFloat = 90
+ private let frameHeight: CGFloat = 130
+ private let frameSpacing: CGFloat = 10
+ private let totalDistance: CGFloat = 2000 // 总移动距离
+
+ // 动画时间参数
+ private let accelerationDuration: Double = 5.0 // 加速阶段时长(0-5s)
+ private let constantSpeedDuration: Double = 6.0 // 匀速+放大阶段时长(5-11s)
+ private var totalDuration: Double { accelerationDuration + constantSpeedDuration }
+ private var scaleStartProgress: CGFloat { accelerationDuration / totalDuration }
+ private let finalScale: CGFloat = 3.0 // 展示完整胶片的缩放比例
+
+ // 对称布局核心参数(重点调整)
+ private let symmetricTiltAngle: Double = 8 // 减小倾斜角度,增强对称感
+ private let verticalOffset: CGFloat = 140 // 减小垂直距离,靠近中间胶卷
+ private let initialMiddleY: CGFloat = 50 // 中间胶卷初始位置上移,缩短与上下距离
+
+ // 上下胶卷与中间胶卷的初始水平偏移(确保视觉对称)
+ private let horizontalOffset: CGFloat = 30
+
+ var body: some View {
+ ZStack {
+ // 深色背景
+ Color(red: 0.08, green: 0.08, blue: 0.08)
+ .edgesIgnoringSafeArea(.all)
+
+ // 上方倾斜胶卷(左高右低,与中间距离适中)
+ FilmReelView3(images: reelImages[0])
+ .rotationEffect(Angle(degrees: -symmetricTiltAngle))
+ .offset(x: topReelPosition - horizontalOffset, y: -verticalOffset) // 水平微调增强对称
+ .opacity(upperLowerOpacity)
+ .zIndex(1)
+
+ // 下方倾斜胶卷(左低右高,与中间距离适中)
+ FilmReelView3(images: reelImages[2])
+ .rotationEffect(Angle(degrees: symmetricTiltAngle))
+ .offset(x: bottomReelPosition + horizontalOffset, y: verticalOffset) // 水平微调增强对称
+ .opacity(upperLowerOpacity)
+ .zIndex(1)
+
+ // 中间胶卷(垂直居中)
+ FilmReelView3(images: reelImages[1])
+ .offset(x: middleReelPosition, y: middleYPosition)
+ .scaleEffect(currentScale)
+ .position(centerPosition)
+ .zIndex(2)
+ .edgesIgnoringSafeArea(.all)
+ }
+ .onAppear {
+ startAnimation()
+ }
+ }
+
+ // MARK: - 动画逻辑
+
+ private func startAnimation() {
+ guard !isAnimating && !animationComplete else { return }
+ isAnimating = true
+
+ withAnimation(Animation.timingCurve(0.2, 0.0, 0.8, 1.0, duration: totalDuration)) {
+ animationProgress = 1.0
+ }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + totalDuration) {
+ isAnimating = false
+ animationComplete = true
+ }
+ }
+
+ // MARK: - 动画计算
+
+ private var currentScale: CGFloat {
+ guard animationProgress >= scaleStartProgress else {
+ return 1.0
+ }
+
+ let scalePhaseProgress = (animationProgress - scaleStartProgress) / (1.0 - scaleStartProgress)
+ return 1.0 + (finalScale - 1.0) * scalePhaseProgress
+ }
+
+ // 中间胶卷Y轴位置(微调至更居中)
+ private var middleYPosition: CGFloat {
+ if animationProgress < scaleStartProgress {
+ return initialMiddleY - (initialMiddleY * (animationProgress / scaleStartProgress))
+ } else {
+ return 0 // 5s后精准居中
+ }
+ }
+
+ private var upperLowerOpacity: Double {
+ if animationProgress < scaleStartProgress {
+ return 0.8
+ } else {
+ let fadeProgress = (animationProgress - scaleStartProgress) / (1.0 - scaleStartProgress)
+ return 0.8 * (1.0 - fadeProgress)
+ }
+ }
+
+ private var centerPosition: CGPoint {
+ CGPoint(x: deviceWidth / 2, y: deviceHeight / 2)
+ }
+
+ // MARK: - 位置计算(确保对称运动)
+
+ private var motionProgress: CGFloat {
+ if animationProgress < scaleStartProgress {
+ let t = animationProgress / scaleStartProgress
+ return t * t // 加速阶段
+ } else {
+ return 1.0 + (animationProgress - scaleStartProgress) *
+ (scaleStartProgress / (1.0 - scaleStartProgress))
+ }
+ }
+
+ // 上方胶卷位置(与下方保持对称速度)
+ private var topReelPosition: CGFloat {
+ totalDistance * 0.9 * motionProgress
+ }
+
+ // 中间胶卷位置(主视觉移动)
+ private var middleReelPosition: CGFloat {
+ -totalDistance * 1.2 * motionProgress
+ }
+
+ // 下方胶卷位置(与上方保持对称速度)
+ private var bottomReelPosition: CGFloat {
+ totalDistance * 0.9 * motionProgress // 与上方速度完全一致
+ }
+}
+
+// MARK: - 胶卷组件
+
+struct FilmReelView3: View {
+ let images: [String]
+
+ var body: some View {
+ HStack(spacing: 10) {
+ ForEach(images.indices, id: \.self) { index in
+ FilmFrameView3(imageName: images[index])
+ }
+ }
+ }
+}
+
+struct FilmFrameView3: View {
+ let imageName: String
+
+ var body: some View {
+ ZStack {
+ // 胶卷边框
+ RoundedRectangle(cornerRadius: 4)
+ .stroke(Color.gray, lineWidth: 2)
+ .background(Color(red: 0.15, green: 0.15, blue: 0.15))
+
+ // 帧内容
+ Rectangle()
+ .fill(gradientColor)
+ .cornerRadius(2)
+ .padding(2)
+
+ // 帧标识
+ Text(imageName)
+ .foregroundColor(.white)
+ .font(.caption2)
+ }
+ .frame(width: 90, height: 130)
+ // 胶卷孔洞
+ .overlay(
+ HStack {
+ VStack(spacing: 6) {
+ ForEach(0..<6) { _ in
+ Circle()
+ .frame(width: 6, height: 6)
+ .foregroundColor(.gray)
+ }
+ }
+ Spacer()
+ VStack(spacing: 6) {
+ ForEach(0..<6) { _ in
+ Circle()
+ .frame(width: 6, height: 6)
+ .foregroundColor(.gray)
+ }
+ }
+ }
+ )
+ }
+
+ private var gradientColor: LinearGradient {
+ if imageName.hasPrefix("film1") {
+ return LinearGradient(gradient: Gradient(colors: [.blue, .indigo]), startPoint: .topLeading, endPoint: .bottomTrailing)
+ } else if imageName.hasPrefix("film2") {
+ return LinearGradient(gradient: Gradient(colors: [.yellow, .orange]), startPoint: .topLeading, endPoint: .bottomTrailing)
+ } else {
+ return LinearGradient(gradient: Gradient(colors: [.teal, .cyan]), startPoint: .topLeading, endPoint: .bottomTrailing)
+ }
+ }
+}
+
+// 预览
+struct FilmAnimation_Previews3: PreviewProvider {
+ static var previews: some View {
+ FilmAnimation1()
+ }
+}
+
\ No newline at end of file
diff --git a/wake/View/Blind/Box4.swift b/wake/View/Blind/Box4.swift
new file mode 100644
index 0000000..0ef9004
--- /dev/null
+++ b/wake/View/Blind/Box4.swift
@@ -0,0 +1,140 @@
+import SwiftUI
+
+// MARK: - 主视图:电影胶卷盲盒动效
+struct FilmStripBlindBoxView: View {
+ @State private var isAnimating = false
+ @State private var revealCenter = false
+
+ // 三格盲盒内容(使用 SF Symbols 模拟不同“隐藏款”)
+ let boxContents = ["popcorn", "star", "music.note"]
+
+ var body: some View {
+ GeometryReader { geometry in
+ let width = geometry.size.width
+
+ ZStack {
+ // 左边盲盒胶卷帧
+ BlindBoxFrame(symbol: boxContents[0])
+ .offset(x: isAnimating ? -width / 4 : -width)
+ .opacity(isAnimating ? 1 : 0)
+
+ // 中间盲盒胶卷帧(最终放大)
+ BlindBoxFrame(symbol: boxContents[1])
+ .scaleEffect(revealCenter ? 1.6 : 1)
+ .offset(x: isAnimating ? 0 : width)
+ .opacity(isAnimating ? 1 : 0)
+
+ // 右边盲盒胶卷帧
+ BlindBoxFrame(symbol: boxContents[2])
+ .offset(x: isAnimating ? width / 4 : width * 1.5)
+ .opacity(isAnimating ? 1 : 0)
+ }
+ .onAppear {
+ // 第一阶段:胶卷滑入
+ withAnimation(.easeOut(duration: 1.0)) {
+ isAnimating = true
+ }
+
+ // 第二阶段:中间帧“开盒”放大
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
+ withAnimation(
+ .interpolatingSpring(stiffness: 80, damping: 12).delay(0.3)
+ ) {
+ revealCenter = true
+ }
+ }
+ }
+ }
+ .frame(height: 140)
+ .padding()
+ .background(Color.black.opacity(0.05))
+ }
+}
+
+// MARK: - 盲盒胶卷帧:带孔 + 橙色背景 + SF Symbol
+struct BlindBoxFrame: View {
+ let symbol: String
+
+ var body: some View {
+ ZStack {
+ // 胶片边框(橙色 + 打孔)
+ FilmBorder()
+
+ // SF Symbol 作为“盲盒内容”
+ Image(systemName: symbol)
+ .resizable()
+ .scaledToFit()
+ .foregroundColor(.white.opacity(0.85))
+ .frame(width: 60, height: 60)
+ }
+ .frame(width: 120, height: 120)
+ }
+}
+
+// MARK: - 胶片边框:#FFB645 背景 + 打孔
+struct FilmBorder: View {
+ var body: some View {
+ Canvas { context, size in
+ let w = size.width
+ let h = size.height
+
+ // 背景色:FFB645
+ let bgColor = Color(hex: 0xFFB645)
+ context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(bgColor))
+
+ // 打孔参数
+ let holeRadius: CGFloat = 3.5
+ let margin: CGFloat = 12
+ let holeYOffset: CGFloat = h * 0.25
+
+ // 左侧打孔(3个)
+ for i in 0..<3 {
+ let y = CGFloat(i + 1) * (h / 4)
+ context.fill(
+ Path(ellipseIn: CGRect(
+ x: margin - holeRadius * 2,
+ y: y - holeRadius,
+ width: holeRadius * 2,
+ height: holeRadius * 2
+ )),
+ with: .color(.black)
+ )
+ }
+
+ // 右侧打孔(3个)
+ for i in 0..<3 {
+ let y = CGFloat(i + 1) * (h / 4)
+ context.fill(
+ Path(ellipseIn: CGRect(
+ x: w - margin,
+ y: y - holeRadius,
+ width: holeRadius * 2,
+ height: holeRadius * 2
+ )),
+ with: .color(.black)
+ )
+ }
+ }
+ }
+}
+
+// MARK: - Color 扩展:支持 HEX 颜色
+extension Color {
+ init(hex: UInt) {
+ self.init(
+ .sRGB,
+ red: Double((hex >> 16) & 0xff) / 255,
+ green: Double((hex >> 8) & 0xff) / 255,
+ blue: Double(hex & 0xff) / 255,
+ opacity: 1.0
+ )
+ }
+}
+
+// MARK: - 预览
+struct FilmStripBlindBoxView_Previews: PreviewProvider {
+ static var previews: some View {
+ FilmStripBlindBoxView()
+ .preferredColorScheme(.dark)
+ }
+}
\ No newline at end of file
diff --git a/wake/View/Blind/Box5.swift b/wake/View/Blind/Box5.swift
new file mode 100644
index 0000000..93dd160
--- /dev/null
+++ b/wake/View/Blind/Box5.swift
@@ -0,0 +1,222 @@
+import SwiftUI
+
+struct FilmAnimation5: View {
+ // 设备尺寸
+ private let deviceWidth = UIScreen.main.bounds.width
+ private let deviceHeight = UIScreen.main.bounds.height
+
+ // 动画状态控制
+ @State private var animationProgress: CGFloat = 0.0 // 0-1总进度
+ @State private var isAnimating: Bool = false
+ @State private var animationComplete: Bool = false
+
+ // 胶卷数据
+ private let reelImages: [[String]] = [
+ (0..<150).map { "film1-\($0+1)" }, // 上方倾斜胶卷
+ (0..<180).map { "film2-\($0+1)" }, // 中间胶卷
+ (0..<150).map { "film3-\($0+1)" } // 下方倾斜胶卷
+ ]
+
+ // 胶卷参数
+ private let frameWidth: CGFloat = 90
+ private let frameHeight: CGFloat = 130
+ private let totalDistance: CGFloat = 1800 // 总移动距离
+
+ // 动画阶段时间参数(核心调整)
+ private let accelerationDuration: Double = 5.0 // 0-5s加速
+ private let constantSpeedDuration: Double = 1.0 // 5-6s匀速移动
+ private let scaleDuration: Double = 2.0 // 6-8s共同放大
+ private var totalDuration: Double { accelerationDuration + constantSpeedDuration + scaleDuration }
+
+ // 各阶段进度阈值
+ private var accelerationEnd: CGFloat { accelerationDuration / totalDuration }
+ private var constantSpeedEnd: CGFloat { (accelerationDuration + constantSpeedDuration) / totalDuration }
+
+ // 对称倾斜参数
+ private let symmetricTiltAngle: Double = 10 // 上下胶卷对称倾斜角度
+ private let verticalOffset: CGFloat = 120 // 上下胶卷垂直距离(对称)
+ private let finalScale: CGFloat = 4.0 // 最终放大倍数
+
+ var body: some View {
+ ZStack {
+ // 深色背景
+ Color(red: 0.08, green: 0.08, blue: 0.08)
+ .edgesIgnoringSafeArea(.all)
+
+ // 上方倾斜胶卷(向右移动)
+ FilmReelView5(images: reelImages[0])
+ .rotationEffect(Angle(degrees: -symmetricTiltAngle))
+ .offset(x: topReelPosition, y: -verticalOffset)
+ .scaleEffect(currentScale)
+ .opacity(upperLowerOpacity)
+ .zIndex(2)
+
+ // 下方倾斜胶卷(向右移动)
+ FilmReelView5(images: reelImages[2])
+ .rotationEffect(Angle(degrees: symmetricTiltAngle))
+ .offset(x: bottomReelPosition, y: verticalOffset)
+ .scaleEffect(currentScale)
+ .opacity(upperLowerOpacity)
+ .zIndex(2)
+
+ // 中间胶卷(向左移动,最终保留)
+ FilmReelView5(images: reelImages[1])
+ .offset(x: middleReelPosition, y: 0)
+ .scaleEffect(currentScale)
+ .opacity(1.0) // 始终不透明
+ .zIndex(1)
+ .edgesIgnoringSafeArea(.all)
+ }
+ .onAppear {
+ startAnimation()
+ }
+ }
+
+ // MARK: - 动画逻辑
+
+ private func startAnimation() {
+ guard !isAnimating && !animationComplete else { return }
+ isAnimating = true
+
+ // 分阶段动画曲线:先加速后匀速
+ withAnimation(Animation.timingCurve(0.3, 0.0, 0.7, 1.0, duration: totalDuration)) {
+ animationProgress = 1.0
+ }
+
+ // 动画结束标记
+ DispatchQueue.main.asyncAfter(deadline: .now() + totalDuration) {
+ isAnimating = false
+ animationComplete = true
+ }
+ }
+
+ // MARK: - 动画计算
+
+ // 共同放大比例(6s后开始放大)
+ private var currentScale: CGFloat {
+ guard animationProgress >= constantSpeedEnd else {
+ return 1.0 // 前6s保持原尺寸
+ }
+
+ // 放大阶段相对进度(0-1)
+ let scalePhaseProgress = (animationProgress - constantSpeedEnd) / (1.0 - constantSpeedEnd)
+ return 1.0 + (finalScale - 1.0) * scalePhaseProgress
+ }
+
+ // 上下胶卷透明度(放大阶段逐渐隐藏)
+ private var upperLowerOpacity: Double {
+ guard animationProgress >= constantSpeedEnd else {
+ return 0.8 // 前6s保持可见
+ }
+
+ // 放大阶段同步淡出
+ let fadeProgress = (animationProgress - constantSpeedEnd) / (1.0 - constantSpeedEnd)
+ return 0.8 * (1.0 - fadeProgress)
+ }
+
+ // MARK: - 移动速度控制(确保匀速阶段速度一致)
+
+ private var motionProgress: CGFloat {
+ if animationProgress < accelerationEnd {
+ // 0-5s加速阶段:二次方曲线加速
+ let t = animationProgress / accelerationEnd
+ return t * t
+ } else {
+ // 5s后匀速阶段:保持最大速度
+ return 1.0 + (animationProgress - accelerationEnd) *
+ (accelerationEnd / (1.0 - accelerationEnd))
+ }
+ }
+
+ // 上方胶卷位置(向右移动)
+ private var topReelPosition: CGFloat {
+ totalDistance * 0.8 * motionProgress
+ }
+
+ // 中间胶卷位置(向左移动)
+ private var middleReelPosition: CGFloat {
+ -totalDistance * 0.8 * motionProgress // 与上下胶卷速度大小相同,方向相反
+ }
+
+ // 下方胶卷位置(向右移动)
+ private var bottomReelPosition: CGFloat {
+ totalDistance * 0.8 * motionProgress // 与上方胶卷速度完全一致,保持对称
+ }
+}
+
+// MARK: - 胶卷组件
+
+struct FilmReelView5: View {
+ let images: [String]
+
+ var body: some View {
+ HStack(spacing: 10) {
+ ForEach(images.indices, id: \.self) { index in
+ FilmFrameView5(imageName: images[index])
+ }
+ }
+ }
+}
+
+struct FilmFrameView5: View {
+ let imageName: String
+
+ var body: some View {
+ ZStack {
+ // 胶卷边框
+ RoundedRectangle(cornerRadius: 4)
+ .stroke(Color.gray, lineWidth: 2)
+ .background(Color(red: 0.15, green: 0.15, blue: 0.15))
+
+ // 帧内容
+ Rectangle()
+ .fill(gradientColor)
+ .cornerRadius(2)
+ .padding(2)
+
+ // 帧标识
+ Text(imageName)
+ .foregroundColor(.white)
+ .font(.caption2)
+ }
+ .frame(width: 90, height: 130)
+ // 胶卷孔洞
+ .overlay(
+ HStack {
+ VStack(spacing: 6) {
+ ForEach(0..<6) { _ in
+ Circle()
+ .frame(width: 6, height: 6)
+ .foregroundColor(.gray)
+ }
+ }
+ Spacer()
+ VStack(spacing: 6) {
+ ForEach(0..<6) { _ in
+ Circle()
+ .frame(width: 6, height: 6)
+ .foregroundColor(.gray)
+ }
+ }
+ }
+ )
+ }
+
+ private var gradientColor: LinearGradient {
+ if imageName.hasPrefix("film1") {
+ return LinearGradient(gradient: Gradient(colors: [.blue, .indigo]), startPoint: .topLeading, endPoint: .bottomTrailing)
+ } else if imageName.hasPrefix("film2") {
+ return LinearGradient(gradient: Gradient(colors: [.yellow, .orange]), startPoint: .topLeading, endPoint: .bottomTrailing)
+ } else {
+ return LinearGradient(gradient: Gradient(colors: [.teal, .cyan]), startPoint: .topLeading, endPoint: .bottomTrailing)
+ }
+ }
+}
+
+// 预览
+struct FilmAnimation_Previews5: PreviewProvider {
+ static var previews: some View {
+ FilmAnimation5()
+ }
+}
+
\ No newline at end of file
diff --git a/wake/View/Blind/Box6.swift b/wake/View/Blind/Box6.swift
new file mode 100644
index 0000000..5ba1d07
--- /dev/null
+++ b/wake/View/Blind/Box6.swift
@@ -0,0 +1,250 @@
+import SwiftUI
+
+struct FilmAnimation: View {
+ // 设备尺寸
+ private let deviceWidth = UIScreen.main.bounds.width
+ private let deviceHeight = UIScreen.main.bounds.height
+
+ // 动画状态控制
+ @State private var animationProgress: CGFloat = 0.0 // 0-1总进度
+ @State private var isAnimating: Bool = false
+ @State private var animationComplete: Bool = false
+
+ // 胶卷数据
+ private let reelImages: [[String]] = [
+ (0..<300).map { "film1-\($0+1)" }, // 上方胶卷
+ (0..<350).map { "film2-\($0+1)" }, // 中间胶卷
+ (0..<300).map { "film3-\($0+1)" } // 下方胶卷
+ ]
+
+ // 胶卷参数
+ private let frameWidth: CGFloat = 90
+ private let frameHeight: CGFloat = 130
+ private let frameSpacing: CGFloat = 12
+
+ // 动画阶段时间参数
+ private let accelerationDuration: Double = 5.0 // 0-5s加速
+ private let constantSpeedDuration: Double = 1.0 // 5-6s匀速
+ private let scaleStartDuration: Double = 1.0 // 6-7s共同放大
+ private let scaleFinishDuration: Double = 1.0 // 7-8s仅中间胶卷放大
+ private var totalDuration: Double {
+ accelerationDuration + constantSpeedDuration + scaleStartDuration + scaleFinishDuration
+ }
+
+ // 各阶段进度阈值
+ private var accelerationEnd: CGFloat { accelerationDuration / totalDuration }
+ private var constantSpeedEnd: CGFloat { (accelerationDuration + constantSpeedDuration) / totalDuration }
+ private var scaleStartEnd: CGFloat {
+ (accelerationDuration + constantSpeedDuration + scaleStartDuration) / totalDuration
+ }
+
+ // 布局与运动参数(核心:对称倾斜角度)
+ private let tiltAngle: Double = 10 // 基础倾斜角度
+ private let upperTilt: Double = -10 // 上方胶卷:左高右低(负角度)
+ private let lowerTilt: Double = 10 // 下方胶卷:左低右高(正角度)
+ private let verticalSpacing: CGFloat = 200 // 上下胶卷垂直间距
+ private let finalScale: CGFloat = 4.5
+
+ // 移动距离参数
+ private let maxTiltedReelMovement: CGFloat = 3500 // 倾斜胶卷最大移动距离
+ private let maxMiddleReelMovement: CGFloat = -3000 // 中间胶卷最大移动距离
+
+ var body: some View {
+ // 固定背景
+ Color(red: 0.08, green: 0.08, blue: 0.08)
+ .edgesIgnoringSafeArea(.all)
+ .overlay(
+ ZStack {
+ // 上方倾斜胶卷(左高右低,向右移动)
+ if showTiltedReels {
+ FilmReelView(images: reelImages[0])
+ .rotationEffect(Angle(degrees: upperTilt)) // 左高右低
+ .offset(x: upperReelXPosition, y: -verticalSpacing/2)
+ .scaleEffect(tiltedScale)
+ .opacity(tiltedOpacity)
+ .zIndex(1)
+ }
+
+ // 下方倾斜胶卷(左低右高,向右移动)
+ if showTiltedReels {
+ FilmReelView(images: reelImages[2])
+ .rotationEffect(Angle(degrees: lowerTilt)) // 左低右高
+ .offset(x: lowerReelXPosition, y: verticalSpacing/2)
+ .scaleEffect(tiltedScale)
+ .opacity(tiltedOpacity)
+ .zIndex(1)
+ }
+
+ // 中间胶卷(垂直,向左移动)
+ FilmReelView(images: reelImages[1])
+ .offset(x: middleReelXPosition, y: 0)
+ .scaleEffect(middleScale)
+ .opacity(1.0)
+ .zIndex(2)
+ .edgesIgnoringSafeArea(.all)
+ }
+ )
+ .onAppear {
+ startAnimation()
+ }
+ }
+
+ // MARK: - 动画逻辑
+
+ private func startAnimation() {
+ guard !isAnimating && !animationComplete else { return }
+ isAnimating = true
+
+ withAnimation(Animation.easeInOut(duration: totalDuration)) {
+ animationProgress = 1.0
+ }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + totalDuration) {
+ isAnimating = false
+ animationComplete = true
+ }
+ }
+
+ // MARK: - 位置计算(确保向右移动)
+
+ // 上方倾斜胶卷X位置
+ private var upperReelXPosition: CGFloat {
+ let startPosition: CGFloat = -deviceWidth * 1.2 // 左侧屏幕外起始
+ return startPosition + (maxTiltedReelMovement * movementProgress)
+ }
+
+ // 下方倾斜胶卷X位置
+ private var lowerReelXPosition: CGFloat {
+ let startPosition: CGFloat = -deviceWidth * 0.8 // 稍右于上方胶卷起始
+ return startPosition + (maxTiltedReelMovement * movementProgress)
+ }
+
+ // 中间胶卷X位置
+ private var middleReelXPosition: CGFloat {
+ let startPosition: CGFloat = deviceWidth * 0.3
+ return startPosition + (maxMiddleReelMovement * movementProgress)
+ }
+
+ // 移动进度(0-1)
+ private var movementProgress: CGFloat {
+ if animationProgress < constantSpeedEnd {
+ return animationProgress / constantSpeedEnd
+ } else {
+ return 1.0 // 6秒后停止移动
+ }
+ }
+
+ // MARK: - 缩放与显示控制
+
+ // 中间胶卷缩放
+ private var middleScale: CGFloat {
+ guard animationProgress >= constantSpeedEnd else {
+ return 1.0
+ }
+
+ let scalePhaseProgress = (animationProgress - constantSpeedEnd) / (1.0 - constantSpeedEnd)
+ return 1.0 + (finalScale - 1.0) * scalePhaseProgress
+ }
+
+ // 倾斜胶卷缩放
+ private var tiltedScale: CGFloat {
+ guard animationProgress >= constantSpeedEnd, animationProgress < scaleStartEnd else {
+ return 1.0
+ }
+
+ let scalePhaseProgress = (animationProgress - constantSpeedEnd) / (scaleStartEnd - constantSpeedEnd)
+ return 1.0 + (finalScale * 0.6 - 1.0) * scalePhaseProgress
+ }
+
+ // 倾斜胶卷透明度
+ private var tiltedOpacity: Double {
+ guard animationProgress >= constantSpeedEnd, animationProgress < scaleStartEnd else {
+ return 0.8
+ }
+
+ let fadeProgress = (animationProgress - constantSpeedEnd) / (scaleStartEnd - constantSpeedEnd)
+ return 0.8 * (1.0 - fadeProgress)
+ }
+
+ // 控制倾斜胶卷显示
+ private var showTiltedReels: Bool {
+ animationProgress < scaleStartEnd
+ }
+}
+
+// MARK: - 胶卷组件
+
+struct FilmReelView: View {
+ let images: [String]
+
+ var body: some View {
+ HStack(spacing: 12) {
+ ForEach(images.indices, id: \.self) { index in
+ FilmFrameView(imageName: images[index])
+ }
+ }
+ }
+}
+
+struct FilmFrameView: View {
+ let imageName: String
+
+ var body: some View {
+ ZStack {
+ // 胶卷边框
+ RoundedRectangle(cornerRadius: 4)
+ .stroke(Color.gray, lineWidth: 2)
+ .background(Color(red: 0.15, green: 0.15, blue: 0.15))
+
+ // 帧内容
+ Rectangle()
+ .fill(gradientColor)
+ .cornerRadius(2)
+ .padding(2)
+
+ // 帧标识
+ Text(imageName)
+ .foregroundColor(.white)
+ .font(.caption2)
+ }
+ .frame(width: 90, height: 130)
+ // 胶卷孔洞
+ .overlay(
+ HStack {
+ VStack(spacing: 6) {
+ ForEach(0..<6) { _ in
+ Circle()
+ .frame(width: 6, height: 6)
+ .foregroundColor(.gray)
+ }
+ }
+ Spacer()
+ VStack(spacing: 6) {
+ ForEach(0..<6) { _ in
+ Circle()
+ .frame(width: 6, height: 6)
+ .foregroundColor(.gray)
+ }
+ }
+ }
+ )
+ }
+
+ private var gradientColor: LinearGradient {
+ if imageName.hasPrefix("film1") {
+ return LinearGradient(gradient: Gradient(colors: [.blue, .indigo]), startPoint: .topLeading, endPoint: .bottomTrailing)
+ } else if imageName.hasPrefix("film2") {
+ return LinearGradient(gradient: Gradient(colors: [.yellow, .orange]), startPoint: .topLeading, endPoint: .bottomTrailing)
+ } else {
+ return LinearGradient(gradient: Gradient(colors: [.purple, .pink]), startPoint: .topLeading, endPoint: .bottomTrailing)
+ }
+ }
+}
+
+// 预览
+struct FilmAnimation_Previews: PreviewProvider {
+ static var previews: some View {
+ FilmAnimation()
+ }
+}
+
diff --git a/wake/View/Components/AppleSignInButton.swift b/wake/View/Components/AppleSignInButton.swift
new file mode 100644
index 0000000..cd61af0
--- /dev/null
+++ b/wake/View/Components/AppleSignInButton.swift
@@ -0,0 +1,95 @@
+import SwiftUI
+import AuthenticationServices
+import CryptoKit
+
+/// 自定义的 Apple 登录按钮组件
+struct AppleSignInButton: View {
+ // MARK: - 属性
+
+ /// 授权请求回调
+ let onRequest: (ASAuthorizationAppleIDRequest) -> Void
+
+ /// 授权完成回调
+ let onCompletion: (Result) -> Void
+
+ /// 按钮文字
+ let buttonText: String
+
+ // MARK: - 初始化方法
+
+ init(buttonText: String = "Continue with Apple",
+ onRequest: @escaping (ASAuthorizationAppleIDRequest) -> Void,
+ onCompletion: @escaping (Result) -> Void) {
+ self.buttonText = buttonText
+ self.onRequest = onRequest
+ self.onCompletion = onCompletion
+ }
+
+ // MARK: - 视图主体
+
+ var body: some View {
+ Button(action: handleSignIn) {
+ HStack(alignment: .center, spacing: 8) {
+ Image(systemName: "applelogo")
+ .font(.system(size: 20, weight: .regular))
+ Text(buttonText)
+ .font(.system(size: 18, weight: .regular))
+ }
+ .frame(maxWidth: .infinity)
+ .frame(height: 60)
+ .background(Color.white)
+ .foregroundColor(.black)
+ .cornerRadius(30)
+ .overlay(
+ RoundedRectangle(cornerRadius: 30)
+ .stroke(Color.black, lineWidth: 1) // 使用黑色边框
+ )
+ }
+ }
+
+ // MARK: - 私有方法
+
+ private func handleSignIn() {
+ let provider = ASAuthorizationAppleIDProvider()
+ let request = provider.createRequest()
+ request.requestedScopes = [.fullName, .email]
+
+ // 创建 nonce 用于安全验证
+ let nonce = String.randomURLSafeString(length: 32)
+ request.nonce = sha256(nonce)
+
+ // 调用请求回调
+ onRequest(request)
+
+ // 创建并显示授权控制器
+ let controller = ASAuthorizationController(authorizationRequests: [request])
+ controller.delegate = Coordinator(onCompletion: onCompletion)
+ controller.performRequests()
+ }
+
+ private func sha256(_ input: String) -> String {
+ let inputData = Data(input.utf8)
+ let hashedData = SHA256.hash(data: inputData)
+ return hashedData.compactMap { String(format: "%02x", $0) }.joined()
+ }
+
+ // MARK: - 协调器
+
+ private class Coordinator: NSObject, ASAuthorizationControllerDelegate {
+ let onCompletion: (Result) -> Void
+
+ init(onCompletion: @escaping (Result) -> Void) {
+ self.onCompletion = onCompletion
+ }
+
+ // 授权成功回调
+ func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
+ onCompletion(.success(authorization))
+ }
+
+ // 授权失败回调
+ func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
+ onCompletion(.failure(error))
+ }
+ }
+}
\ No newline at end of file
diff --git a/wake/View/Components/Upload/ImageCaptureView.swift b/wake/View/Components/Upload/ImageCaptureView.swift
new file mode 100644
index 0000000..38db249
--- /dev/null
+++ b/wake/View/Components/Upload/ImageCaptureView.swift
@@ -0,0 +1,70 @@
+import AVFoundation
+import UIKit
+
+class ImageCaptureManager: NSObject {
+ static let shared = ImageCaptureManager()
+ private var completion: ((UIImage?) -> Void)?
+ private weak var presentingViewController: UIViewController?
+
+ func captureImage(from viewController: UIViewController, completion: @escaping (UIImage?) -> Void) {
+ self.completion = completion
+ self.presentingViewController = viewController
+
+ let status = AVCaptureDevice.authorizationStatus(for: .video)
+
+ switch status {
+ case .authorized:
+ presentImagePicker()
+ case .notDetermined:
+ AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
+ DispatchQueue.main.async {
+ if granted {
+ self?.presentImagePicker()
+ } else {
+ self?.completion?(nil)
+ }
+ }
+ }
+ default:
+ completion(nil)
+ }
+ }
+
+ private func presentImagePicker() {
+ guard UIImagePickerController.isSourceTypeAvailable(.camera) else {
+ completion?(nil)
+ return
+ }
+
+ let picker = UIImagePickerController()
+ picker.sourceType = .camera
+ picker.allowsEditing = true
+ picker.delegate = self
+
+ // Present from the topmost view controller
+ var topController = UIApplication.shared.windows.first(where: { $0.isKeyWindow })?.rootViewController
+ while let presentedVC = topController?.presentedViewController {
+ topController = presentedVC
+ }
+
+ topController?.present(picker, animated: true)
+ }
+}
+
+extension ImageCaptureManager: UINavigationControllerDelegate, UIImagePickerControllerDelegate {
+ func imagePickerController(_ picker: UIImagePickerController,
+ didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
+ let image = info[.editedImage] as? UIImage ?? info[.originalImage] as? UIImage
+ picker.dismiss(animated: true) { [weak self] in
+ self?.completion?(image)
+ self?.completion = nil
+ }
+ }
+
+ func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
+ picker.dismiss(animated: true) { [weak self] in
+ self?.completion?(nil)
+ self?.completion = nil
+ }
+ }
+}
\ No newline at end of file
diff --git a/wake/View/Components/Upload/ImageMultiUploader.swift b/wake/View/Components/Upload/ImageMultiUploader.swift
new file mode 100644
index 0000000..788ea11
--- /dev/null
+++ b/wake/View/Components/Upload/ImageMultiUploader.swift
@@ -0,0 +1,176 @@
+import SwiftUI
+import PhotosUI
+import os
+
+@available(iOS 16.0, *)
+public struct MultiImageUploader: View {
+ @State var selectedImages: [UIImage] = []
+ @State private var uploadResults: [UploadResult] = []
+ @State private var isUploading = false
+ @State private var showingImagePicker = false
+ @State private var uploadProgress: [UUID: Double] = [:] // 跟踪每个上传任务的进度
+ @State private var needsViewUpdate = false // Add this line to trigger view updates
+
+ private let maxSelection: Int
+ public var onUploadComplete: ([UploadResult]) -> Void
+ private let uploadService = ImageUploadService.shared
+ private let logger = Logger(subsystem: "com.yourapp.uploader", category: "MultiImageUploader")
+
+ // 自定义内容
+ private let content: ((_ isUploading: Bool, _ selectedCount: Int) -> Content)?
+
+ /// 控制是否显示图片选择器
+ @Binding var isImagePickerPresented: Bool
+
+ /// 选中的图片
+ @Binding var selectedImagesBinding: [UIImage]?
+
+ /// 控制是否显示图片预览
+ @State private var showingImagePreview = false
+
+ // 初始化方法 - 使用自定义视图
+ public init(
+ maxSelection: Int = 10,
+ isImagePickerPresented: Binding,
+ selectedImagesBinding: Binding<[UIImage]?>,
+ @ViewBuilder content: @escaping (_ isUploading: Bool, _ selectedCount: Int) -> Content,
+ onUploadComplete: @escaping ([UploadResult]) -> Void
+ ) {
+ self.maxSelection = maxSelection
+ self._isImagePickerPresented = isImagePickerPresented
+ self._selectedImagesBinding = selectedImagesBinding
+ self.content = content
+ self.onUploadComplete = onUploadComplete
+ }
+
+ // 初始化方法 - 使用默认按钮样式(向后兼容)
+ public init(
+ maxSelection: Int = 10,
+ isImagePickerPresented: Binding,
+ selectedImagesBinding: Binding<[UIImage]?>,
+ onUploadComplete: @escaping ([UploadResult]) -> Void
+ ) where Content == EmptyView {
+ self.maxSelection = maxSelection
+ self._isImagePickerPresented = isImagePickerPresented
+ self._selectedImagesBinding = selectedImagesBinding
+ self.content = nil
+ self.onUploadComplete = onUploadComplete
+ }
+
+ public var body: some View {
+ VStack(spacing: 16) {
+ // 自定义内容或默认按钮
+ if let content = content {
+ Button(action: {
+ showingImagePicker = true
+ }) {
+ content(isUploading, selectedImages.count)
+ }
+ .buttonStyle(PlainButtonStyle())
+ } else {
+ // 默认按钮样式
+ Button(action: {
+ showingImagePicker = true
+ }) {
+ Label(
+ !selectedImages.isEmpty ?
+ "已选择 \(selectedImages.count) 张图片" :
+ "选择图片",
+ systemImage: "photo.on.rectangle"
+ )
+ .font(.headline)
+ .foregroundColor(.white)
+ .padding()
+ .frame(maxWidth: .infinity)
+ .background(Color.blue)
+ .cornerRadius(10)
+ }
+ .padding(.horizontal)
+ }
+ }
+ .sheet(isPresented: $showingImagePicker) {
+ ImagePicker(images: $selectedImages, selectionLimit: maxSelection) {
+ // 当选择完成时,关闭选择器并开始上传
+ showingImagePicker = false
+ if !selectedImages.isEmpty {
+ Task {
+ _ = await startUpload()
+ }
+ }
+ }
+ }
+ .onChange(of: isImagePickerPresented) { newValue in
+ if newValue {
+ showingImagePicker = true
+ }
+ }
+ .onChange(of: showingImagePicker) { newValue in
+ if !newValue {
+ isImagePickerPresented = false
+ }
+ }
+ .onChange(of: selectedImages) { newValue in
+ selectedImagesBinding = newValue
+ }
+ .onChange(of: needsViewUpdate) { _ in
+ // Trigger view update
+ }
+ }
+
+ /// 上传图片方法,由父组件调用
+ @MainActor
+ public func startUpload() async -> [UploadResult] {
+ guard !isUploading && !selectedImages.isEmpty else { return [] }
+
+ isUploading = true
+ uploadResults = selectedImages.map {
+ UploadResult(image: $0, status: .uploading(progress: 0))
+ }
+
+ let group = DispatchGroup()
+
+ for (index, image) in selectedImages.enumerated() {
+ group.enter()
+
+ // 使用 ImageUploadService 上传图片
+ uploadService.uploadOriginalAndCompressedImage(
+ image,
+ compressionQuality: 0.7,
+ progress: { progress in
+ // 更新上传进度
+ DispatchQueue.main.async {
+ if index < self.uploadResults.count {
+ self.uploadResults[index].status = .uploading(progress: progress.progress)
+ self.needsViewUpdate.toggle() // Trigger view update
+ }
+ }
+ },
+ completion: { result in
+ DispatchQueue.main.async {
+ guard index < self.uploadResults.count else { return }
+ switch result {
+ case .success(let uploadResults):
+ self.uploadResults[index].status = .success
+ self.uploadResults[index].fileId = uploadResults.original.fileId
+ self.uploadResults[index].previewFileId = uploadResults.compressed.fileId
+ self.needsViewUpdate.toggle() // Trigger view update
+ case .failure(let error):
+ self.uploadResults[index].status = .failure(error)
+ self.needsViewUpdate.toggle() // Trigger view update
+ self.logger.error("图片上传失败: \(error.localizedDescription)")
+ }
+ group.leave()
+ }
+ }
+ )
+ }
+
+ return await withCheckedContinuation { continuation in
+ group.notify(queue: .main) {
+ self.isUploading = false
+ self.needsViewUpdate.toggle() // Trigger view update
+ continuation.resume(returning: self.uploadResults)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/wake/View/Components/Upload/ImagePicker.swift b/wake/View/Components/Upload/ImagePicker.swift
new file mode 100644
index 0000000..cb806dc
--- /dev/null
+++ b/wake/View/Components/Upload/ImagePicker.swift
@@ -0,0 +1,58 @@
+import SwiftUI
+import PhotosUI
+
+struct ImagePicker: UIViewControllerRepresentable {
+ @Binding var images: [UIImage]
+ var selectionLimit: Int
+ var onDismiss: (() -> Void)?
+
+ func makeUIViewController(context: Context) -> PHPickerViewController {
+ var configuration = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared())
+ configuration.filter = .images
+ configuration.selectionLimit = selectionLimit
+
+ let picker = PHPickerViewController(configuration: configuration)
+ picker.delegate = context.coordinator
+ return picker
+ }
+
+ func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {}
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(self)
+ }
+
+ class Coordinator: NSObject, PHPickerViewControllerDelegate {
+ let parent: ImagePicker
+
+ init(_ parent: ImagePicker) {
+ self.parent = parent
+ }
+
+ func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
+ let group = DispatchGroup()
+ var newImages: [UIImage] = []
+
+ for result in results {
+ group.enter()
+
+ if result.itemProvider.canLoadObject(ofClass: UIImage.self) {
+ result.itemProvider.loadObject(ofClass: UIImage.self) { (image, error) in
+ if let image = image as? UIImage {
+ newImages.append(image)
+ }
+ group.leave()
+ }
+ } else {
+ group.leave()
+ }
+ }
+
+ group.notify(queue: .main) {
+ self.parent.images = newImages
+ self.parent.onDismiss?()
+ picker.dismiss(animated: true)
+ }
+ }
+ }
+}
diff --git a/wake/View/Components/Upload/ImageUploadService.swift b/wake/View/Components/Upload/ImageUploadService.swift
new file mode 100644
index 0000000..b2f0ab0
--- /dev/null
+++ b/wake/View/Components/Upload/ImageUploadService.swift
@@ -0,0 +1,404 @@
+import Foundation
+import UIKit
+
+/// 图片上传服务,封装了图片上传和进度跟踪功能
+public class ImageUploadService {
+
+ // MARK: - Shared Instance
+
+ public static let shared = ImageUploadService()
+
+ // MARK: - Properties
+
+ private let uploader: ImageUploaderGetID
+
+ // MARK: - Initialization
+
+ public init(uploader: ImageUploaderGetID = ImageUploaderGetID()) {
+ self.uploader = uploader
+ }
+
+ // MARK: - Public Methods
+
+ /// 上传图片并返回上传结果
+ /// - Parameters:
+ /// - image: 要上传的图片
+ /// - progressHandler: 上传进度回调 (0.0 到 1.0)
+ /// - completion: 完成回调,返回上传结果或错误
+ public func uploadImage(
+ _ image: UIImage,
+ progress progressHandler: @escaping (ImageUploadService.UploadProgress) -> Void,
+ completion: @escaping (Result) -> Void
+ ) {
+ uploader.uploadImage(
+ image,
+ progress: { progress in
+ let progressInfo = ImageUploadService.UploadProgress(
+ current: Int(progress * 100),
+ total: 100,
+ progress: progress,
+ isOriginal: true
+ )
+ DispatchQueue.main.async {
+ progressHandler(progressInfo)
+ }
+ },
+ completion: { result in
+ DispatchQueue.main.async {
+ completion(result)
+ }
+ }
+ )
+ }
+
+ /// 上传压缩图片并返回上传结果
+ /// - Parameters:
+ /// - image: 要上传的图片
+ /// - compressionQuality: 压缩质量 (0.0 到 1.0)
+ /// - progressHandler: 上传进度回调 (0.0 到 1.0)
+ /// - completion: 完成回调,返回上传结果或错误
+ public func uploadCompressedImage(
+ _ image: UIImage,
+ compressionQuality: CGFloat = 0.5,
+ progress progressHandler: @escaping (ImageUploadService.UploadProgress) -> Void,
+ completion: @escaping (Result) -> Void
+ ) {
+ guard let compressedImage = image.jpegData(compressionQuality: compressionQuality).flatMap(UIImage.init(data:)) else {
+ completion(.failure(NSError(domain: "com.wake.upload", code: -1, userInfo: [NSLocalizedDescriptionKey: "图片压缩失败"])))
+ return
+ }
+
+ uploadImage(
+ compressedImage,
+ progress: progressHandler,
+ completion: completion
+ )
+ }
+
+ /// 上传原图和压缩图
+ /// - Parameters:
+ /// - image: 原始图片
+ /// - compressionQuality: 压缩质量 (0.0 到 1.0)
+ /// - progressHandler: 上传进度回调
+ /// - completion: 完成回调,返回原始图和压缩图的上传结果
+ public func uploadOriginalAndCompressedImage(
+ _ image: UIImage,
+ compressionQuality: CGFloat = 0.5,
+ progress progressHandler: @escaping (ImageUploadService.UploadProgress) -> Void,
+ completion: @escaping (Result) -> Void
+ ) {
+ // 上传原图
+ uploadImage(image, progress: { progress in
+ let originalProgress = ImageUploadService.UploadProgress(
+ current: Int(progress.progress * 100),
+ total: 200, // 总进度为200(原图100 + 压缩图100)
+ progress: progress.progress * 0.5, // 原图占50%
+ isOriginal: true
+ )
+ progressHandler(originalProgress)
+ }) { [weak self] originalResult in
+ guard let self = self else { return }
+
+ switch originalResult {
+ case .success(let originalUploadResult):
+ // 原图上传成功,上传压缩图
+ self.uploadCompressedImage(
+ image,
+ compressionQuality: compressionQuality,
+ progress: { progress in
+ let compressedProgress = ImageUploadService.UploadProgress(
+ current: 100 + Int(progress.progress * 100), // 从100开始
+ total: 200, // 总进度为200(原图100 + 压缩图100)
+ progress: 0.5 + (progress.progress * 0.5), // 压缩图占后50%
+ isOriginal: false
+ )
+ progressHandler(compressedProgress)
+ },
+ completion: { compressedResult in
+ switch compressedResult {
+ case .success(let compressedUploadResult):
+ let results = ImageUploadService.UploadResults(
+ original: originalUploadResult,
+ compressed: compressedUploadResult
+ )
+ completion(.success(results))
+
+ case .failure(let error):
+ completion(.failure(error))
+ }
+ }
+ )
+
+ case .failure(let error):
+ completion(.failure(error))
+ }
+ }
+ }
+
+ // MARK: - Unified Media Upload
+
+ /// 上传媒体文件(图片或视频)
+ /// - Parameters:
+ /// - media: 媒体类型,可以是图片或视频
+ /// - progress: 上传进度回调 (0.0 到 1.0)
+ /// - completion: 完成回调,返回上传结果或错误
+ public func uploadMedia(
+ _ media: MediaType,
+ progress: @escaping (UploadProgress) -> Void,
+ completion: @escaping (Result) -> Void
+ ) {
+ switch media {
+ case .image(let image):
+ print("🖼️ 开始处理图片上传")
+ uploadImage(
+ image,
+ progress: { progressInfo in
+ print("📊 图片上传进度: \(progressInfo.current)%")
+ progress(progressInfo)
+ },
+ completion: { result in
+ switch result {
+ case .success(let uploadResult):
+ print("✅ 图片上传完成, fileId: \(uploadResult.fileId)")
+ completion(.success(.file(uploadResult)))
+ case .failure(let error):
+ print("❌ 图片上传失败: \(error.localizedDescription)")
+ completion(.failure(error))
+ }
+ }
+ )
+
+ case .video(let videoURL, _):
+ print("🎥 开始处理视频上传: \(videoURL.lastPathComponent)")
+
+ uploader.uploadVideo(
+ videoURL,
+ progress: { uploadProgress in
+ print("📊 视频上传进度: \(Int(uploadProgress * 100))%")
+ let progressInfo = UploadProgress(
+ current: Int(uploadProgress * 100),
+ total: 100,
+ progress: uploadProgress,
+ isOriginal: true
+ )
+ progress(progressInfo)
+ },
+ completion: { result in
+ switch result {
+ case .success(let videoResult):
+ print("✅ 视频文件上传完成, fileId: \(videoResult.fileId)")
+ print("🖼️ 开始提取视频缩略图...")
+
+ MediaUtils.extractFirstFrame(from: videoURL) { thumbnailResult in
+ switch thumbnailResult {
+ case .success(let thumbnailImage):
+ print("🖼️ 视频缩略图提取成功")
+
+ if let compressedThumbnail = thumbnailImage.resized(to: CGSize(width: 800, height: 800)) {
+ print("🖼️ 开始上传视频缩略图...")
+
+ self.uploader.uploadImage(
+ compressedThumbnail,
+ progress: { _ in },
+ completion: { thumbnailResult in
+ switch thumbnailResult {
+ case .success(let thumbnailUploadResult):
+ print("✅ 视频缩略图上传完成, fileId: \(thumbnailUploadResult.fileId)")
+ let result = MediaUploadResult.video(
+ video: videoResult,
+ thumbnail: thumbnailUploadResult
+ )
+ completion(.success(result))
+
+ case .failure(let error):
+ print("❌ 视频缩略图上传失败: \(error.localizedDescription)")
+ completion(.failure(error))
+ }
+ }
+ )
+ } else {
+ let error = NSError(domain: "ImageUploadService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to compress thumbnail"])
+ print("❌ 视频缩略图压缩失败")
+ completion(.failure(error))
+ }
+
+ case .failure(let error):
+ print("❌ 视频缩略图提取失败: \(error.localizedDescription)")
+ completion(.failure(error))
+ }
+ }
+
+ case .failure(let error):
+ print("❌ 视频文件上传失败: \(error.localizedDescription)")
+ completion(.failure(error))
+ }
+ }
+ )
+ }
+ }
+
+ /// 上传视频及其缩略图
+ private func uploadVideoWithThumbnail(
+ videoURL: URL,
+ existingThumbnail: UIImage?,
+ compressionQuality: CGFloat,
+ progress progressHandler: @escaping (UploadProgress) -> Void,
+ completion: @escaping (Result) -> Void
+ ) {
+ // 1. 提取视频缩略图
+ func processThumbnail(_ thumbnail: UIImage) {
+ // 2. 压缩缩略图
+ guard let compressedThumbnail = thumbnail.jpegData(compressionQuality: compressionQuality).flatMap(UIImage.init(data:)) else {
+ let error = NSError(domain: "com.wake.upload", code: -1, userInfo: [NSLocalizedDescriptionKey: "缩略图压缩失败"])
+ completion(.failure(error))
+ return
+ }
+
+ // 3. 上传视频文件
+ let videoProgress = Progress(totalUnitCount: 100)
+ let thumbnailProgress = Progress(totalUnitCount: 100)
+
+ // 组合进度
+ let totalProgress = Progress(totalUnitCount: 200) // 视频100 + 缩略图100
+ totalProgress.addChild(videoProgress, withPendingUnitCount: 100)
+ totalProgress.addChild(thumbnailProgress, withPendingUnitCount: 100)
+
+ // 上传视频
+ self.uploader.uploadVideo(
+ videoURL,
+ progress: { progress in
+ videoProgress.completedUnitCount = Int64(progress * 100)
+ let currentProgress = Double(totalProgress.completedUnitCount) / 200.0
+ progressHandler(UploadProgress(
+ current: Int(progress * 100),
+ total: 100,
+ progress: currentProgress,
+ isOriginal: true
+ ))
+ },
+ completion: { videoResult in
+ switch videoResult {
+ case .success(let videoUploadResult):
+ // 4. 上传缩略图
+ self.uploadCompressedImage(
+ compressedThumbnail,
+ compressionQuality: 1.0, // 已经压缩过,不再压缩
+ progress: { progress in
+ thumbnailProgress.completedUnitCount = Int64(progress.progress * 100)
+ let currentProgress = Double(totalProgress.completedUnitCount) / 200.0
+ progressHandler(UploadProgress(
+ current: 100 + Int(progress.progress * 100),
+ total: 200,
+ progress: currentProgress,
+ isOriginal: false
+ ))
+ },
+ completion: { thumbnailResult in
+ switch thumbnailResult {
+ case .success(let thumbnailUploadResult):
+ let result = MediaUploadResult.video(
+ video: videoUploadResult,
+ thumbnail: thumbnailUploadResult
+ )
+ completion(.success(result))
+
+ case .failure(let error):
+ completion(.failure(error))
+ }
+ }
+ )
+
+ case .failure(let error):
+ completion(.failure(error))
+ }
+ }
+ )
+ }
+
+ // 如果已有缩略图,直接使用
+ if let thumbnail = existingThumbnail {
+ processThumbnail(thumbnail)
+ } else {
+ // 否则提取第一帧作为缩略图
+ MediaUtils.extractFirstFrame(from: videoURL) { result in
+ switch result {
+ case .success(let thumbnail):
+ processThumbnail(thumbnail)
+ case .failure(let error):
+ completion(.failure(error))
+ }
+ }
+ }
+ }
+
+ // MARK: - Supporting Types
+
+ /// 媒体类型
+ public enum MediaType {
+ case image(UIImage)
+ case video(URL, UIImage?)
+ }
+
+ /// 媒体上传结果
+ public enum MediaUploadResult {
+ case file(ImageUploaderGetID.UploadResult)
+ case video(video: ImageUploaderGetID.UploadResult, thumbnail: ImageUploaderGetID.UploadResult)
+
+ /// 获取文件ID(对于视频,返回视频文件的ID)
+ public var fileId: String {
+ switch self {
+ case .file(let result):
+ return result.fileId
+ case .video(let videoResult, _):
+ return videoResult.fileId
+ }
+ }
+ }
+
+ /// 上传进度信息
+ public struct UploadProgress {
+ public let current: Int
+ public let total: Int
+ public let progress: Double
+ public let isOriginal: Bool
+
+ public init(current: Int, total: Int, progress: Double, isOriginal: Bool) {
+ self.current = current
+ self.total = total
+ self.progress = progress
+ self.isOriginal = isOriginal
+ }
+ }
+
+ /// 上传结果,包含原图和压缩图的上传信息
+ public struct UploadResults {
+ public let original: ImageUploaderGetID.UploadResult
+ public let compressed: ImageUploaderGetID.UploadResult
+
+ public init(original: ImageUploaderGetID.UploadResult,
+ compressed: ImageUploaderGetID.UploadResult) {
+ self.original = original
+ self.compressed = compressed
+ }
+ }
+}
+
+// MARK: - UIImage Extension
+
+private extension UIImage {
+ func resized(to size: CGSize) -> UIImage? {
+ let widthRatio = size.width / self.size.width
+ let heightRatio = size.height / self.size.height
+ let ratio = min(widthRatio, heightRatio)
+
+ let newSize = CGSize(
+ width: self.size.width * ratio,
+ height: self.size.height * ratio
+ )
+
+ let renderer = UIGraphicsImageRenderer(size: newSize)
+ return renderer.image { _ in
+ self.draw(in: CGRect(origin: .zero, size: newSize))
+ }
+ }
+}
diff --git a/wake/View/Components/Upload/ImageUploaderGetID.swift b/wake/View/Components/Upload/ImageUploaderGetID.swift
new file mode 100644
index 0000000..21c4c7a
--- /dev/null
+++ b/wake/View/Components/Upload/ImageUploaderGetID.swift
@@ -0,0 +1,612 @@
+import SwiftUI
+import PhotosUI
+
+/// 处理图片上传到远程服务器的类
+/// 支持上传图片并获取服务器返回的file_id
+public class ImageUploaderGetID: ObservableObject {
+ // MARK: - 类型定义
+
+ /// 上传结果
+ public struct UploadResult: Codable {
+ public let fileUrl: String
+ public let fileName: String
+ public let fileSize: Int
+ public let fileId: String
+
+ public init(fileUrl: String, fileName: String, fileSize: Int, fileId: String) {
+ self.fileUrl = fileUrl
+ self.fileName = fileName
+ self.fileSize = fileSize
+ self.fileId = fileId
+ }
+ }
+
+ /// 上传过程中可能发生的错误
+ public enum UploadError: LocalizedError {
+ case invalidImageData
+ case invalidURL
+ case serverError(String)
+ case invalidResponse
+ case uploadFailed(Error?)
+ case invalidFileId
+ case invalidResponseData
+
+ public var errorDescription: String? {
+ switch self {
+ case .invalidImageData:
+ return "无效的图片数据"
+ case .invalidURL:
+ return "无效的URL"
+ case .serverError(let message):
+ return "服务器错误: \(message)"
+ case .invalidResponse:
+ return "无效的服务器响应"
+ case .uploadFailed(let error):
+ return "上传失败: \(error?.localizedDescription ?? "未知错误")"
+ case .invalidFileId:
+ return "无效的文件ID"
+ case .invalidResponseData:
+ return "无效的响应数据"
+ }
+ }
+ }
+
+ // MARK: - 属性
+
+ private let session: URLSession
+ private let apiConfig: APIConfig.Type
+
+ // MARK: - 初始化方法
+
+ /// 初始化方法
+ /// - Parameters:
+ /// - session: 可选的URLSession,用于测试依赖注入
+ /// - apiConfig: 可选的API配置,用于测试依赖注入
+ public init(session: URLSession = .shared, apiConfig: APIConfig.Type = APIConfig.self) {
+ self.session = session
+ self.apiConfig = apiConfig
+ }
+
+ // MARK: - 公开方法
+
+ /// 上传图片到服务器
+ /// - Parameters:
+ /// - image: 要上传的图片
+ /// - progress: 上传进度回调 (0.0 到 1.0)
+ /// - completion: 完成回调
+ public func uploadImage(
+ _ image: UIImage,
+ progress: @escaping (Double) -> Void,
+ completion: @escaping (Result) -> Void
+ ) {
+ print("🔄 开始准备上传图片...")
+
+ // 1. 转换图片为Data
+ guard let imageData = image.jpegData(compressionQuality: 0.7) else {
+ let error = UploadError.invalidImageData
+ print("❌ 错误:\(error.localizedDescription)")
+ completion(.failure(error))
+ return
+ }
+
+ // 2. 获取上传URL
+ getUploadURL(for: imageData) { [weak self] result in
+ switch result {
+ case .success((let fileId, let uploadURL)):
+ print("📤 获取到上传URL,开始上传文件...")
+
+ // 3. 上传文件
+ _ = self?.uploadFile(
+ fileData: imageData,
+ to: uploadURL,
+ mimeType: "image/jpeg",
+ onProgress: { uploadProgress in
+ print("📊 上传进度: \(Int(uploadProgress * 100))%")
+ progress(uploadProgress)
+ },
+ completion: { uploadResult in
+ switch uploadResult {
+ case .success:
+ // 4. 确认上传
+ self?.confirmUpload(
+ fileId: fileId,
+ fileName: "avatar_\(UUID().uuidString).jpg",
+ fileSize: imageData.count,
+ completion: completion
+ )
+
+ case .failure(let error):
+ print("❌ 文件上传失败: \(error.localizedDescription)")
+ completion(.failure(error))
+ }
+ }
+ )
+ case .failure(let error):
+ print("❌ 获取上传URL失败: \(error.localizedDescription)")
+ completion(.failure(error))
+ }
+ }
+ }
+
+ // MARK: - Video Upload
+
+ /// 上传视频文件到服务器
+ /// - Parameters:
+ /// - videoURL: 要上传的视频文件URL
+ /// - progress: 上传进度回调 (0.0 到 1.0)
+ /// - completion: 完成回调
+ public func uploadVideo(
+ _ videoURL: URL,
+ progress: @escaping (Double) -> Void,
+ completion: @escaping (Result) -> Void
+ ) {
+ print("🔄 开始准备上传视频...")
+
+ // 1. 读取视频文件数据
+ do {
+ let videoData = try Data(contentsOf: videoURL)
+ let fileExtension = videoURL.pathExtension.lowercased()
+ let mimeType: String
+
+ // 根据文件扩展名设置MIME类型
+ switch fileExtension {
+ case "mp4":
+ mimeType = "video/mp4"
+ case "mov":
+ mimeType = "video/quicktime"
+ case "m4v":
+ mimeType = "video/x-m4v"
+ case "avi":
+ mimeType = "video/x-msvideo"
+ default:
+ mimeType = "video/mp4" // 默认使用mp4
+ }
+
+ // 2. 获取上传URL
+ getUploadURL(
+ for: videoData,
+ mimeType: mimeType,
+ originalFilename: videoURL.lastPathComponent
+ ) { [weak self] result in
+ switch result {
+ case .success((let fileId, let uploadURL)):
+ print("📤 获取到视频上传URL,开始上传文件...")
+
+ // 3. 上传文件
+ _ = self?.uploadFile(
+ fileData: videoData,
+ to: uploadURL,
+ mimeType: mimeType,
+ onProgress: progress,
+ completion: { result in
+ switch result {
+ case .success:
+ // 4. 确认上传完成
+ self?.confirmUpload(
+ fileId: fileId,
+ fileName: videoURL.lastPathComponent,
+ fileSize: videoData.count,
+ completion: completion
+ )
+ case .failure(let error):
+ completion(.failure(error))
+ }
+ }
+ )
+
+ case .failure(let error):
+ completion(.failure(error))
+ }
+ }
+ } catch {
+ print("❌ 读取视频文件失败: \(error.localizedDescription)")
+ completion(.failure(error))
+ }
+ }
+
+ // MARK: - 私有方法
+
+ /// 获取上传URL
+ private func getUploadURL(
+ for imageData: Data,
+ completion: @escaping (Result<(fileId: String, uploadURL: URL), Error>) -> Void
+ ) {
+ let fileName = "avatar_\(UUID().uuidString).jpg"
+ let parameters: [String: Any] = [
+ "filename": fileName,
+ "content_type": "image/jpeg",
+ "file_size": imageData.count
+ ]
+
+ let urlString = "\(apiConfig.baseURL)/file/generate-upload-url"
+ guard let url = URL(string: urlString) else {
+ completion(.failure(UploadError.invalidURL))
+ return
+ }
+
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.allHTTPHeaderFields = apiConfig.authHeaders
+
+ do {
+ request.httpBody = try JSONSerialization.data(withJSONObject: parameters)
+ print("📤 准备上传请求,文件名: \(fileName), 大小: \(Double(imageData.count) / 1024.0) KB")
+ } catch {
+ print("❌ 序列化请求参数失败: \(error.localizedDescription)")
+ completion(.failure(error))
+ return
+ }
+
+ let task = session.dataTask(with: request) { data, response, error in
+ if let error = error {
+ completion(.failure(UploadError.uploadFailed(error)))
+ return
+ }
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ completion(.failure(UploadError.invalidResponse))
+ return
+ }
+
+ guard let data = data else {
+ completion(.failure(UploadError.invalidResponse))
+ return
+ }
+
+ // 打印调试信息
+ if let responseString = String(data: data, encoding: .utf8) {
+ print("📥 获取上传URL响应: \(responseString)")
+ }
+
+ do {
+ guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let dataDict = json["data"] as? [String: Any],
+ let fileId = dataDict["file_id"] as? String,
+ let uploadURLString = dataDict["upload_url"] as? String,
+ let uploadURL = URL(string: uploadURLString) else {
+ throw UploadError.invalidResponse
+ }
+
+ completion(.success((fileId: fileId, uploadURL: uploadURL)))
+ } catch {
+ completion(.failure(UploadError.invalidResponse))
+ }
+ }
+
+ task.resume()
+ }
+
+ /// 获取上传URL
+ private func getUploadURL(
+ for fileData: Data,
+ mimeType: String,
+ originalFilename: String? = nil,
+ completion: @escaping (Result<(fileId: String, uploadURL: URL), Error>) -> Void
+ ) {
+ let fileName = originalFilename ?? "file_\(UUID().uuidString)"
+ let parameters: [String: Any] = [
+ "filename": fileName,
+ "content_type": mimeType,
+ "file_size": fileData.count
+ ]
+
+ let urlString = "\(apiConfig.baseURL)/file/generate-upload-url"
+ guard let url = URL(string: urlString) else {
+ completion(.failure(UploadError.invalidURL))
+ return
+ }
+
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.allHTTPHeaderFields = apiConfig.authHeaders
+
+ do {
+ request.httpBody = try JSONSerialization.data(withJSONObject: parameters)
+ print("📤 准备上传请求,文件名: \(fileName), 大小: \(Double(fileData.count) / 1024.0) KB")
+ } catch {
+ print("❌ 序列化请求参数失败: \(error.localizedDescription)")
+ completion(.failure(error))
+ return
+ }
+
+ let task = session.dataTask(with: request) { data, response, error in
+ if let error = error {
+ completion(.failure(UploadError.uploadFailed(error)))
+ return
+ }
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ completion(.failure(UploadError.invalidResponse))
+ return
+ }
+
+ guard let data = data else {
+ completion(.failure(UploadError.invalidResponse))
+ return
+ }
+
+ // 打印调试信息
+ if let responseString = String(data: data, encoding: .utf8) {
+ print("📥 上传URL响应: \(responseString)")
+ }
+
+ do {
+ let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
+ guard let code = json?["code"] as? Int, code == 0,
+ let dataDict = json?["data"] as? [String: Any],
+ let fileId = dataDict["file_id"] as? String,
+ let uploadURLString = dataDict["upload_url"] as? String,
+ let uploadURL = URL(string: uploadURLString) else {
+ throw UploadError.invalidResponse
+ }
+
+ completion(.success((fileId: fileId, uploadURL: uploadURL)))
+ } catch {
+ completion(.failure(UploadError.invalidResponse))
+ }
+ }
+
+ task.resume()
+ }
+
+ /// 确认上传
+ private func confirmUpload(
+ fileId: String,
+ fileName: String,
+ fileSize: Int,
+ completion: @escaping (Result) -> Void
+ ) {
+ let endpoint = "\(apiConfig.baseURL)/file/confirm-upload"
+ guard let url = URL(string: endpoint) else {
+ completion(.failure(UploadError.invalidURL))
+ return
+ }
+
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.allHTTPHeaderFields = apiConfig.authHeaders
+
+ let body: [String: Any] = [
+ "file_id": fileId,
+ "file_name": fileName,
+ "file_size": fileSize
+ ]
+
+ do {
+ request.httpBody = try JSONSerialization.data(withJSONObject: body)
+ print("📤 确认上传请求,fileId: \(fileId), 文件名: \(fileName)")
+ } catch {
+ print("❌ 序列化确认上传参数失败: \(error.localizedDescription)")
+ completion(.failure(error))
+ return
+ }
+
+ let task = session.dataTask(with: request) { data, response, error in
+ if let error = error {
+ print("❌ 确认上传请求失败: \(error.localizedDescription)")
+ completion(.failure(UploadError.uploadFailed(error)))
+ return
+ }
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ print("❌ 无效的服务器响应")
+ completion(.failure(UploadError.invalidResponse))
+ return
+ }
+
+ guard (200...299).contains(httpResponse.statusCode) else {
+ let statusCode = httpResponse.statusCode
+ let errorMessage = "确认上传失败,状态码: \(statusCode)"
+ print("❌ \(errorMessage)")
+ completion(.failure(UploadError.serverError(errorMessage)))
+ return
+ }
+
+ // 创建上传结果
+ let uploadResult = UploadResult(
+ fileUrl: "\(self.apiConfig.baseURL)/files/\(fileId)",
+ fileName: fileName,
+ fileSize: fileSize,
+ fileId: fileId
+ )
+
+ print("✅ 图片上传并确认成功,fileId: \(fileId)")
+ completion(.success(uploadResult))
+ }
+
+ task.resume()
+ }
+
+ /// 上传文件到指定URL
+ public func uploadFile(
+ fileData: Data,
+ to uploadURL: URL,
+ mimeType: String = "application/octet-stream",
+ onProgress: @escaping (Double) -> Void,
+ completion: @escaping (Result) -> Void
+ ) -> URLSessionUploadTask {
+ var request = URLRequest(url: uploadURL)
+ request.httpMethod = "PUT"
+ request.setValue(mimeType, forHTTPHeaderField: "Content-Type")
+
+ let task = session.uploadTask(with: request, from: fileData) { _, response, error in
+ if let error = error {
+ completion(.failure(error))
+ return
+ }
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ completion(.failure(UploadError.invalidResponse))
+ return
+ }
+
+ guard (200...299).contains(httpResponse.statusCode) else {
+ let statusCode = httpResponse.statusCode
+ completion(.failure(UploadError.serverError("上传失败,状态码: \(statusCode)")))
+ return
+ }
+
+ completion(.success(()))
+ }
+
+ // 添加进度观察
+ if #available(iOS 11.0, *) {
+ let progressObserver = task.progress.observe(\.fractionCompleted) { progressValue, _ in
+ DispatchQueue.main.async {
+ onProgress(progressValue.fractionCompleted)
+ }
+ }
+
+ task.addCompletionHandler { [weak task] in
+ progressObserver.invalidate()
+ task?.progress.cancel()
+ }
+ } else {
+ var lastProgress: Double = 0
+ let timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in
+ let bytesSent = task.countOfBytesSent
+ let totalBytes = task.countOfBytesExpectedToSend
+ let currentProgress = totalBytes > 0 ? Double(bytesSent) / Double(totalBytes) : 0
+
+ // 只有当进度有显著变化时才回调,避免频繁更新UI
+ if abs(currentProgress - lastProgress) > 0.01 || currentProgress >= 1.0 {
+ lastProgress = currentProgress
+ DispatchQueue.main.async {
+ onProgress(min(currentProgress, 1.0))
+ }
+ }
+
+ if currentProgress >= 1.0 {
+ timer.invalidate()
+ }
+ }
+
+ task.addCompletionHandler {
+ timer.invalidate()
+ }
+ }
+
+ task.resume()
+ return task
+ }
+
+ // MARK: - 文件上传状态
+
+ /// 文件上传状态
+ public struct FileStatus {
+ public let file: Data
+ public var status: UploadStatus
+ public var progress: Double
+
+ public enum UploadStatus {
+ case pending
+ case uploading
+ case completed
+ case failed(Error)
+ }
+
+ public init(file: Data, status: UploadStatus = .pending, progress: Double = 0) {
+ self.file = file
+ self.status = status
+ self.progress = progress
+ }
+ }
+}
+
+// MARK: - URLSessionTask 扩展
+
+private class TaskObserver: NSObject {
+ private weak var task: URLSessionTask?
+ private var handlers: [() -> Void] = []
+
+ init(task: URLSessionTask) {
+ self.task = task
+ super.init()
+ task.addObserver(self, forKeyPath: #keyPath(URLSessionTask.state), options: .new, context: nil)
+ }
+
+ func addHandler(_ handler: @escaping () -> Void) {
+ handlers.append(handler)
+ }
+
+ override func observeValue(
+ forKeyPath keyPath: String?,
+ of object: Any?,
+ change: [NSKeyValueChangeKey: Any]?,
+ context: UnsafeMutableRawPointer?
+ ) {
+ guard keyPath == #keyPath(URLSessionTask.state),
+ let task = task,
+ task.state == .completed else {
+ return
+ }
+
+ // 调用所有完成处理器
+ DispatchQueue.main.async { [weak self] in
+ self?.handlers.forEach { $0() }
+ self?.cleanup()
+ }
+ }
+
+ private func cleanup() {
+ task?.removeObserver(self, forKeyPath: #keyPath(URLSessionTask.state))
+ handlers.removeAll()
+ }
+
+ deinit {
+ cleanup()
+ }
+}
+
+private extension URLSessionTask {
+ private static var taskObserverKey: UInt8 = 0
+
+ private var taskObserver: TaskObserver? {
+ get {
+ return objc_getAssociatedObject(self, &Self.taskObserverKey) as? TaskObserver
+ }
+ set {
+ objc_setAssociatedObject(self, &Self.taskObserverKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
+ }
+ }
+
+ func addCompletionHandler(_ handler: @escaping () -> Void) {
+ if #available(iOS 11.0, *) {
+ if let observer = taskObserver {
+ observer.addHandler(handler)
+ } else {
+ let observer = TaskObserver(task: self)
+ observer.addHandler(handler)
+ taskObserver = observer
+ }
+ } else {
+ // iOS 11 以下版本使用通知
+ let name = NSNotification.Name("TaskCompleted\(self.taskIdentifier)")
+ NotificationCenter.default.addObserver(
+ forName: name,
+ object: self,
+ queue: .main
+ ) { _ in
+ handler()
+ }
+ }
+ }
+}
+
+// MARK: - 响应模型
+
+struct UploadURLResponse: Codable {
+ let code: Int
+ let message: String
+ let data: UploadData
+
+ struct UploadData: Codable {
+ let fileId: String
+ let uploadUrl: String
+
+ enum CodingKeys: String, CodingKey {
+ case fileId = "file_id"
+ case uploadUrl = "upload_url"
+ }
+ }
+}
diff --git a/wake/View/Components/Upload/MediaPicker.swift b/wake/View/Components/Upload/MediaPicker.swift
new file mode 100644
index 0000000..e12aa8e
--- /dev/null
+++ b/wake/View/Components/Upload/MediaPicker.swift
@@ -0,0 +1,303 @@
+import SwiftUI
+import PhotosUI
+import os.log
+import AVKit
+
+/// 媒体类型
+public enum MediaType: Equatable {
+ case image(UIImage)
+ case video(URL, UIImage?) // URL 是视频地址,UIImage 是视频缩略图
+
+ 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
+ }
+ }
+}
+
+enum MediaTypeFilter {
+ case imagesOnly
+ case videosOnly
+ case all
+
+ var pickerFilter: PHPickerFilter {
+ switch self {
+ case .imagesOnly: return .images
+ case .videosOnly: return .videos
+ case .all: return .any(of: [.videos, .images])
+ }
+ }
+}
+
+struct MediaPicker: UIViewControllerRepresentable {
+ @Binding var selectedMedia: [MediaType]
+ let imageSelectionLimit: Int
+ let videoSelectionLimit: Int
+ let onDismiss: (() -> Void)?
+ let allowedMediaTypes: MediaTypeFilter
+ let selectionMode: SelectionMode
+
+ /// 选择模式
+ enum SelectionMode {
+ case single // 单选模式
+ case multiple // 多选模式
+
+ var selectionLimit: Int {
+ switch self {
+ case .single: return 1
+ case .multiple: return 0 // 0 表示不限制选择数量,由 imageSelectionLimit 和 videoSelectionLimit 控制
+ }
+ }
+ }
+
+ init(selectedMedia: Binding<[MediaType]>,
+ imageSelectionLimit: Int = 10,
+ videoSelectionLimit: Int = 10,
+ allowedMediaTypes: MediaTypeFilter = .all,
+ selectionMode: SelectionMode = .multiple,
+ onDismiss: (() -> Void)? = nil) {
+ self._selectedMedia = selectedMedia
+ self.imageSelectionLimit = imageSelectionLimit
+ self.videoSelectionLimit = videoSelectionLimit
+ self.allowedMediaTypes = allowedMediaTypes
+ self.selectionMode = selectionMode
+ self.onDismiss = onDismiss
+ }
+
+ func makeUIViewController(context: Context) -> PHPickerViewController {
+ var configuration = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared())
+ configuration.filter = allowedMediaTypes.pickerFilter
+ configuration.selectionLimit = selectionMode.selectionLimit
+ configuration.preferredAssetRepresentationMode = .current
+
+ let picker = PHPickerViewController(configuration: configuration)
+ picker.delegate = context.coordinator
+ context.coordinator.currentPicker = picker
+
+ return picker
+ }
+
+ func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {
+ // 更新视图控制器(如果需要)
+ }
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(self)
+ }
+
+ class Coordinator: NSObject, PHPickerViewControllerDelegate {
+ let parent: MediaPicker
+ private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.example.app", category: "MediaPicker")
+ internal var currentPicker: PHPickerViewController?
+
+ init(_ parent: MediaPicker) {
+ self.parent = parent
+ }
+
+ func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
+ guard !results.isEmpty else {
+ parent.onDismiss?()
+ return
+ }
+
+ // 如果是单选模式,清空之前的选择
+ var processedMedia = parent.selectionMode == .single ? [] : parent.selectedMedia
+
+ // 统计当前已选择的图片和视频数量
+ var currentImageCount = 0
+ var currentVideoCount = 0
+
+ for media in processedMedia {
+ switch media {
+ case .image: currentImageCount += 1
+ case .video: currentVideoCount += 1
+ }
+ }
+
+ // 检查新选择的项目
+ var newImages = 0
+ var newVideos = 0
+
+ for result in results {
+ let itemProvider = result.itemProvider
+ if itemProvider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
+ guard parent.allowedMediaTypes != .videosOnly else { continue }
+ newImages += 1
+ } else if itemProvider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) {
+ guard parent.allowedMediaTypes != .imagesOnly else { continue }
+ newVideos += 1
+ }
+ }
+
+ // 检查是否超出限制
+ if (currentImageCount + newImages > parent.imageSelectionLimit) ||
+ (currentVideoCount + newVideos > parent.videoSelectionLimit) {
+
+ // 准备错误信息
+ var message = "选择超出限制:\n"
+ var limits: [String] = []
+
+ if currentImageCount + newImages > parent.imageSelectionLimit && parent.allowedMediaTypes != .videosOnly {
+ limits.append("图片最多选择\(parent.imageSelectionLimit)张")
+ }
+ if currentVideoCount + newVideos > parent.videoSelectionLimit && parent.allowedMediaTypes != .imagesOnly {
+ limits.append("视频最多选择\(parent.videoSelectionLimit)个")
+ }
+
+ message += limits.joined(separator: "\n")
+
+ // 显示提示
+ let alert = UIAlertController(
+ title: "提示",
+ message: message,
+ preferredStyle: .alert
+ )
+ alert.addAction(UIAlertAction(title: "好的", style: .default) { _ in
+ // 不清空选择,允许用户继续选择
+ })
+
+ // 显示提示框
+ picker.present(alert, animated: true)
+ return
+ }
+
+ // 处理选择的媒体
+ processSelectedMedia(results: results, picker: picker, processedMedia: &processedMedia)
+ }
+
+ private func processSelectedMedia(results: [PHPickerResult],
+ picker: PHPickerViewController,
+ processedMedia: inout [MediaType]) {
+ let group = DispatchGroup()
+ let mediaCollector = MediaCollector()
+
+ for result in results {
+ let itemProvider = result.itemProvider
+
+ if itemProvider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
+ guard parent.allowedMediaTypes != .videosOnly else { continue }
+
+ group.enter()
+ processImage(itemProvider: itemProvider) { media in
+ if let media = media {
+ mediaCollector.add(media: media)
+ }
+ group.leave()
+ }
+ } else if itemProvider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) {
+ guard parent.allowedMediaTypes != .imagesOnly else { continue }
+
+ group.enter()
+ processVideo(itemProvider: itemProvider) { media in
+ if let media = media {
+ mediaCollector.add(media: media)
+ }
+ group.leave()
+ }
+ }
+ }
+
+ // Create a local copy of the parent reference
+ let parent = self.parent
+
+ group.notify(queue: .main) {
+ let finalMedia = mediaCollector.mediaItems
+ parent.selectedMedia = finalMedia
+ picker.dismiss(animated: true) {
+ parent.onDismiss?()
+ }
+ }
+ }
+
+ private func processImage(itemProvider: NSItemProvider, completion: @escaping (MediaType?) -> Void) {
+ itemProvider.loadObject(ofClass: UIImage.self) { (object, error) in
+ if let image = object as? UIImage {
+ completion(.image(image))
+ } else {
+ self.logger.error("Failed to load image: \(error?.localizedDescription ?? "Unknown error")")
+ completion(nil)
+ }
+ }
+ }
+
+ private func processVideo(itemProvider: NSItemProvider, completion: @escaping (MediaType?) -> Void) {
+ itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { url, error in
+ guard let videoURL = url, error == nil else {
+ self.logger.error("Failed to load video: \(error?.localizedDescription ?? "Unknown error")")
+ completion(nil)
+ return
+ }
+
+ let tempDirectory = FileManager.default.temporaryDirectory
+ let targetURL = tempDirectory.appendingPathComponent("\(UUID().uuidString).\(videoURL.pathExtension)")
+
+ do {
+ if FileManager.default.fileExists(atPath: targetURL.path) {
+ try FileManager.default.removeItem(at: targetURL)
+ }
+ try FileManager.default.copyItem(at: videoURL, to: targetURL)
+
+ if let thumbnail = self.generateThumbnail(for: targetURL) {
+ completion(.video(targetURL, thumbnail))
+ } else {
+ completion(.video(targetURL, nil))
+ }
+ } catch {
+ self.logger.error("Failed to copy video file: \(error.localizedDescription)")
+ completion(nil)
+ }
+ }
+ }
+
+ private func generateThumbnail(for videoURL: URL) -> UIImage? {
+ let asset = AVAsset(url: videoURL)
+ let imageGenerator = AVAssetImageGenerator(asset: asset)
+ imageGenerator.appliesPreferredTrackTransform = true
+
+ do {
+ let cgImage = try imageGenerator.copyCGImage(at: CMTime(seconds: 0, preferredTimescale: 1), actualTime: nil)
+ return UIImage(cgImage: cgImage)
+ } catch {
+ logger.error("Failed to generate thumbnail: \(error.localizedDescription)")
+ return nil
+ }
+ }
+ }
+}
+
+// Helper class to collect media items in a thread-safe way
+private class MediaCollector {
+ private let queue = DispatchQueue(label: "com.example.MediaCollector", attributes: .concurrent)
+ private var _mediaItems: [MediaType] = []
+
+ var mediaItems: [MediaType] {
+ queue.sync { _mediaItems }
+ }
+
+ func add(media: MediaType) {
+ queue.async(flags: .barrier) { [weak self] in
+ self?._mediaItems.append(media)
+ }
+ }
+}
diff --git a/wake/View/Components/Upload/MediaUpload.swift b/wake/View/Components/Upload/MediaUpload.swift
new file mode 100644
index 0000000..e6bde66
--- /dev/null
+++ b/wake/View/Components/Upload/MediaUpload.swift
@@ -0,0 +1,327 @@
+import SwiftUI
+import os.log
+
+/// 媒体上传状态
+public enum MediaUploadStatus: Equatable {
+ case pending
+ case uploading(progress: Double)
+ case completed(fileId: String)
+ case failed(Error)
+
+ public static func == (lhs: MediaUploadStatus, rhs: MediaUploadStatus) -> Bool {
+ switch (lhs, rhs) {
+ case (.pending, .pending):
+ return true
+ case (.uploading(let lhsProgress), .uploading(let rhsProgress)):
+ return lhsProgress == rhsProgress
+ case (.completed(let lhsId), .completed(let rhsId)):
+ return lhsId == rhsId
+ case (.failed, .failed):
+ return false // Errors don't need to be equatable
+ default:
+ return false
+ }
+ }
+
+ public var description: String {
+ switch self {
+ case .pending: return "等待上传"
+ case .uploading(let progress): return "上传中 \(Int(progress * 100))%"
+ case .completed(let fileId): return "上传完成 (ID: \(fileId.prefix(8))...)"
+ case .failed(let error): return "上传失败: \(error.localizedDescription)"
+ }
+ }
+}
+
+/// 媒体上传管理器
+public class MediaUploadManager: ObservableObject {
+ /// 已选媒体文件
+ @Published public var selectedMedia: [MediaType] = []
+ /// 上传状态
+ @Published public var uploadStatus: [String: MediaUploadStatus] = [:]
+
+ private let uploader = ImageUploadService()
+
+ public init() {}
+
+ /// 添加上传媒体
+ public func addMedia(_ media: [MediaType]) {
+ selectedMedia.append(contentsOf: media)
+ }
+
+ /// 移除指定索引的媒体
+ public func removeMedia(at index: Int) {
+ guard index < selectedMedia.count else { return }
+ selectedMedia.remove(at: index)
+ // 更新状态字典
+ var newStatus: [String: MediaUploadStatus] = [:]
+ uploadStatus.forEach { key, value in
+ if let keyInt = Int(key), keyInt < index {
+ newStatus[key] = value
+ } else if let keyInt = Int(key), keyInt > index {
+ newStatus["\(keyInt - 1)"] = value
+ }
+ }
+ uploadStatus = newStatus
+ }
+
+ /// 清空所有媒体
+ public func clearAllMedia() {
+ selectedMedia.removeAll()
+ uploadStatus.removeAll()
+ }
+
+ /// 开始上传所有选中的媒体
+ public func startUpload() {
+ print("🔄 开始批量上传 \(selectedMedia.count) 个文件")
+ // 重置上传状态
+ uploadStatus.removeAll()
+
+ for (index, media) in selectedMedia.enumerated() {
+ let id = "\(index)"
+ uploadStatus[id] = .pending
+
+ // Convert MediaType to ImageUploadService.MediaType
+ let uploadMediaType: ImageUploadService.MediaType
+ switch media {
+ case .image(let image):
+ uploadMediaType = .image(image)
+ case .video(let url, let thumbnail):
+ uploadMediaType = .video(url, thumbnail)
+ }
+ uploadMedia(uploadMediaType, id: id)
+ }
+ }
+
+ /// 获取上传结果
+ public func getUploadResults() -> [String: String] {
+ var results: [String: String] = [:]
+ for (id, status) in uploadStatus {
+ if case .completed(let fileId) = status {
+ results[id] = fileId
+ }
+ }
+ return results
+ }
+
+ /// 检查是否所有上传都已完成
+ public var isAllUploaded: Bool {
+ guard !selectedMedia.isEmpty else { return false }
+ return uploadStatus.allSatisfy { _, status in
+ if case .completed = status { return true }
+ return false
+ }
+ }
+
+ // MARK: - Private Methods
+
+ private func uploadMedia(_ media: ImageUploadService.MediaType, id: String) {
+ print("🔄 开始处理媒体: \(id)")
+ uploadStatus[id] = .uploading(progress: 0)
+
+ uploader.uploadMedia(
+ media,
+ progress: { progress in
+ print("📊 上传进度 (\(id)): \(progress.current)%")
+ DispatchQueue.main.async {
+ self.uploadStatus[id] = .uploading(progress: progress.progress)
+ }
+ },
+ completion: { [weak self] result in
+ guard let self = self else { return }
+ DispatchQueue.main.async {
+ switch result {
+ case .success(let uploadResult):
+ print("✅ 上传成功 (\(id)): \(uploadResult.fileId)")
+ self.uploadStatus[id] = .completed(fileId: uploadResult.fileId)
+ case .failure(let error):
+ print("❌ 上传失败 (\(id)): \(error.localizedDescription)")
+ self.uploadStatus[id] = .failed(error)
+ }
+ }
+ }
+ )
+ }
+}
+
+// MARK: - Preview Helper
+
+/// 示例视图,展示如何使用 MediaUploadManager
+struct MediaUploadExample: View {
+ @StateObject private var uploadManager = MediaUploadManager()
+ @State private var showMediaPicker = false
+
+ // 添加图片和视频选择限制参数
+ let imageSelectionLimit: Int
+ let videoSelectionLimit: Int
+
+ init(imageSelectionLimit: Int = 10, videoSelectionLimit: Int = 10) {
+ self.imageSelectionLimit = imageSelectionLimit
+ self.videoSelectionLimit = videoSelectionLimit
+ }
+
+ var body: some View {
+ NavigationView {
+ VStack(spacing: 20) {
+ // 选择媒体按钮
+ Button(action: { showMediaPicker = true }) {
+ Label("选择媒体", systemImage: "photo.on.rectangle")
+ .font(.headline)
+ .frame(maxWidth: .infinity)
+ .padding()
+ .background(Color.blue)
+ .foregroundColor(.white)
+ .cornerRadius(10)
+ }
+ .padding(.horizontal)
+
+ // 显示选择限制信息
+ VStack(alignment: .leading, spacing: 4) {
+ Text("选择限制:")
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ Text("• 最多选择 \(imageSelectionLimit) 张图片")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ Text("• 最多选择 \(videoSelectionLimit) 个视频")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal)
+
+ // 显示已选媒体
+ MediaSelectionView(uploadManager: uploadManager)
+
+ // 上传按钮
+ Button(action: { uploadManager.startUpload() }) {
+ Text("开始上传")
+ .font(.headline)
+ .frame(maxWidth: .infinity)
+ .padding()
+ .background(uploadManager.selectedMedia.isEmpty ? Color.gray : Color.green)
+ .foregroundColor(.white)
+ .cornerRadius(10)
+ }
+ .padding(.horizontal)
+ .disabled(uploadManager.selectedMedia.isEmpty)
+
+ Spacer()
+ }
+ .navigationTitle("媒体上传")
+ .sheet(isPresented: $showMediaPicker) {
+ MediaPicker(
+ selectedMedia: $uploadManager.selectedMedia,
+ imageSelectionLimit: imageSelectionLimit,
+ videoSelectionLimit: videoSelectionLimit,
+ onDismiss: { showMediaPicker = false }
+ )
+ }
+ }
+ }
+}
+
+/// 媒体选择视图组件
+struct MediaSelectionView: View {
+ @ObservedObject var uploadManager: MediaUploadManager
+
+ var body: some View {
+ if !uploadManager.selectedMedia.isEmpty {
+ VStack(spacing: 10) {
+ Text("已选择 \(uploadManager.selectedMedia.count) 个媒体文件")
+ .font(.headline)
+
+ // 显示媒体缩略图和上传状态
+ List {
+ ForEach(0.. Color {
+ switch status {
+ case .pending: return .secondary
+ case .uploading: return .blue
+ case .completed: return .green
+ case .failed: return .red
+ }
+ }
+}
+
+/// 媒体缩略图视图
+private struct MediaThumbnailView: View {
+ let media: MediaType
+ let onDelete: (() -> Void)?
+
+ var body: some View {
+ ZStack(alignment: .topTrailing) {
+ if let thumbnail = media.thumbnail {
+ Image(uiImage: thumbnail)
+ .resizable()
+ .scaledToFill()
+ .frame(width: 80, height: 80)
+ .cornerRadius(8)
+ .clipped()
+
+ if media.isVideo {
+ Image(systemName: "video.fill")
+ .foregroundColor(.white)
+ .padding(4)
+ .background(Color.black.opacity(0.6))
+ .clipShape(Circle())
+ .padding(4)
+ }
+ }
+ }
+ }
+}
+
+#Preview {
+ // 在预览中显示自定义限制
+ MediaUploadExample(
+ imageSelectionLimit: 5,
+ videoSelectionLimit: 2
+ )
+ .environmentObject(AuthState.shared)
+}
\ No newline at end of file
diff --git a/wake/View/Examples/MediaDemo.swift b/wake/View/Examples/MediaDemo.swift
new file mode 100644
index 0000000..b41241b
--- /dev/null
+++ b/wake/View/Examples/MediaDemo.swift
@@ -0,0 +1,187 @@
+import SwiftUI
+
+struct MediaUploadDemo: View {
+ @StateObject private var uploadManager = MediaUploadManager()
+ @State private var showMediaPicker = false
+ @State private var showUploadAlert = false
+ @State private var isUploading = false
+
+ var body: some View {
+ NavigationView {
+ VStack(spacing: 20) {
+ // 上传按钮
+ Button(action: {
+ showMediaPicker = true
+ }) {
+ Label("添加图片或视频", systemImage: "plus.circle.fill")
+ .font(.headline)
+ .frame(maxWidth: .infinity)
+ .padding()
+ .background(Color.blue)
+ .foregroundColor(.white)
+ .cornerRadius(10)
+ }
+ .padding(.horizontal)
+ .sheet(isPresented: $showMediaPicker) {
+ MediaPicker(
+ selectedMedia: $uploadManager.selectedMedia,
+ imageSelectionLimit: 1,
+ videoSelectionLimit: 0,
+ allowedMediaTypes: .imagesOnly, // This needs to come before selectionMode
+ selectionMode: .single, // This was moved after allowedMediaTypes
+ onDismiss: {
+ showMediaPicker = false
+ // 当媒体选择器关闭时,如果有选中的媒体,开始上传
+ if !uploadManager.selectedMedia.isEmpty {
+ isUploading = true
+ uploadManager.startUpload()
+ }
+ }
+ )
+ }
+
+ // 预览区域
+ if uploadManager.selectedMedia.isEmpty {
+ VStack(spacing: 16) {
+ Image(systemName: "photo.on.rectangle.angled")
+ .font(.system(size: 60))
+ .foregroundColor(.gray)
+ Text("暂无媒体文件")
+ .font(.headline)
+ .foregroundColor(.gray)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else {
+ ScrollView {
+ LazyVGrid(columns: [GridItem(.adaptive(minimum: 120), spacing: 10)], spacing: 10) {
+ ForEach(0.. Double? in
+ if case .uploading(let progress) = status { return progress }
+ return nil
+ }).first {
+ ProgressView(value: progress, total: 1.0)
+ .padding(.horizontal)
+ Text("上传中 \(Int(progress * 100))%")
+ .font(.subheadline)
+ .foregroundColor(.gray)
+ } else {
+ ProgressView()
+ .progressViewStyle(CircularProgressViewStyle())
+ .scaleEffect(1.5)
+ Text("正在准备上传...")
+ .font(.subheadline)
+ .foregroundColor(.gray)
+ .padding(.top, 8)
+ }
+ }
+ .frame(maxWidth: .infinity)
+ .padding()
+ }
+ }
+ .alert(isPresented: $showUploadAlert) {
+ Alert(
+ title: Text(uploadManager.isAllUploaded ? "上传完成" : "上传状态"),
+ message: Text(uploadManager.isAllUploaded ?
+ "所有文件上传完成!" :
+ "正在处理上传..."),
+ dismissButton: .default(Text("确定"))
+ )
+ }
+ .onChange(of: uploadManager.uploadStatus) { _ in
+ // 检查是否所有上传都已完成或失败
+ let allFinished = uploadManager.uploadStatus.values.allSatisfy { status in
+ if case .completed = status { return true }
+ if case .failed = status { return true }
+ return false
+ }
+
+ if allFinished && !uploadManager.uploadStatus.isEmpty {
+ isUploading = false
+ showUploadAlert = true
+ }
+ }
+ }
+ }
+}
+
+// 媒体项视图
+struct MediaItemView: View {
+ let media: MediaType
+ let status: MediaUploadStatus
+
+ var body: some View {
+ ZStack(alignment: .bottom) {
+ // 缩略图
+ if let thumbnail = media.thumbnail {
+ Image(uiImage: thumbnail)
+ .resizable()
+ .scaledToFill()
+ .frame(width: 120, height: 120)
+ .cornerRadius(8)
+ .clipped()
+
+ // 视频标识
+ if media.isVideo {
+ Image(systemName: "play.circle.fill")
+ .font(.system(size: 30))
+ .foregroundColor(.white)
+ .shadow(radius: 5)
+ }
+
+ // 上传状态
+ VStack {
+ Spacer()
+ if case .uploading(let progress) = status {
+ ProgressView(value: progress, total: 1.0)
+ .progressViewStyle(LinearProgressViewStyle())
+ .frame(height: 4)
+ .padding(.horizontal, 4)
+ } else if case .completed = status {
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundColor(.green)
+ .padding(4)
+ .background(Circle().fill(Color.white))
+ } else if case .failed = status {
+ Image(systemName: "exclamationmark.circle.fill")
+ .foregroundColor(.red)
+ .padding(4)
+ .background(Circle().fill(Color.white))
+ }
+ }
+ .padding(4)
+ }
+ }
+ .frame(width: 120, height: 120)
+ .overlay(
+ RoundedRectangle(cornerRadius: 8)
+ .stroke(Color.gray.opacity(0.3), lineWidth: 1)
+ )
+ }
+}
+
+// 预览
+#Preview {
+ MediaUploadDemo()
+ .environmentObject(AuthState.shared)
+}
\ No newline at end of file
diff --git a/wake/View/Examples/MultiImageUploadExampleView.swift b/wake/View/Examples/MultiImageUploadExampleView.swift
new file mode 100644
index 0000000..0409f1f
--- /dev/null
+++ b/wake/View/Examples/MultiImageUploadExampleView.swift
@@ -0,0 +1,162 @@
+import SwiftUI
+import PhotosUI
+
+struct MultiImageUploadExampleView: View {
+ @State private var isImagePickerPresented = false
+ @State private var selectedImages: [UIImage]? = []
+ @State private var uploadResults: [UploadResult] = []
+ @State private var showAlert = false
+ @State private var alertMessage = ""
+
+ var body: some View {
+ ScrollView {
+ VStack(spacing: 20) {
+ // Custom upload button with image count
+ MultiImageUploader(
+ maxSelection: 10,
+ isImagePickerPresented: $isImagePickerPresented,
+ selectedImagesBinding: $selectedImages,
+ content: { isUploading, count in
+ VStack(spacing: 8) {
+ Image(systemName: "photo.stack")
+ .font(.system(size: 24))
+
+ if isUploading {
+ ProgressView()
+ .padding(.vertical, 4)
+ Text("上传中...")
+ .font(.subheadline)
+ } else {
+ Text(count > 0 ? "已选择 \(count) 张图片" : "选择图片")
+ .font(.headline)
+ Text("最多可选择10张图片")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ }
+ .frame(maxWidth: .infinity)
+ .padding()
+ .background(Color.blue.opacity(0.1))
+ .cornerRadius(12)
+ .overlay(
+ RoundedRectangle(cornerRadius: 12)
+ .stroke(Color.blue, lineWidth: 1)
+ )
+ },
+ onUploadComplete: handleUploadComplete
+ )
+ .padding(.horizontal)
+ .padding(.top, 20)
+
+ // Selected images preview with progress
+ if let images = selectedImages, !images.isEmpty {
+ VStack(alignment: .leading, spacing: 12) {
+ Text("已选择图片")
+ .font(.headline)
+ .padding(.horizontal)
+
+ LazyVGrid(columns: [
+ GridItem(.flexible(), spacing: 8),
+ GridItem(.flexible(), spacing: 8),
+ GridItem(.flexible(), spacing: 8)
+ ], spacing: 8) {
+ ForEach(Array(images.enumerated()), id: \.offset) { index, image in
+ ZStack(alignment: .topTrailing) {
+ Image(uiImage: image)
+ .resizable()
+ .scaledToFill()
+ .frame(height: 100)
+ .clipped()
+ .cornerRadius(8)
+ .overlay(
+ RoundedRectangle(cornerRadius: 8)
+ .stroke(Color.gray.opacity(0.3), lineWidth: 1)
+ )
+
+ // Upload progress indicator
+ if index < uploadResults.count {
+ let result = uploadResults[index]
+ VStack {
+ Spacer()
+ ZStack(alignment: .leading) {
+ Rectangle()
+ .fill(Color.gray.opacity(0.2))
+ .frame(height: 4)
+
+ if case .uploading(let progress) = result.status {
+ Rectangle()
+ .fill(Color.blue)
+ .frame(width: CGFloat(progress) * 100, height: 4)
+ } else if case .success = result.status {
+ Rectangle()
+ .fill(Color.green)
+ .frame(height: 4)
+ } else if case .failure = result.status {
+ Rectangle()
+ .fill(Color.red)
+ .frame(height: 4)
+ }
+ }
+ .cornerRadius(2)
+ .padding(.horizontal, 2)
+ .padding(.bottom, 2)
+ }
+ .frame(height: 20)
+ }
+
+ // Status indicator
+ if index < uploadResults.count {
+ let result = uploadResults[index]
+ Circle()
+ .fill(statusColor(for: result.status))
+ .frame(width: 12, height: 12)
+ .padding(4)
+ .background(Circle().fill(Color.white))
+ .padding(4)
+ }
+ }
+ }
+ }
+ .padding(.horizontal)
+ }
+ }
+
+ Spacer()
+ }
+ }
+ .navigationTitle("多图上传示例")
+ .alert(isPresented: $showAlert) {
+ Alert(title: Text("上传结果"), message: Text(alertMessage), dismissButton: .default(Text("确定")))
+ }
+ }
+
+ private func handleUploadComplete(_ results: [UploadResult]) {
+ self.uploadResults = results
+ let successCount = results.filter {
+ if case .success = $0.status { return true }
+ return false
+ }.count
+
+ alertMessage = "上传完成!共 \(results.count) 张图片,成功 \(successCount) 张"
+ showAlert = true
+ }
+
+ private func statusColor(for status: UploadStatus) -> Color {
+ switch status {
+ case .uploading:
+ return .blue
+ case .success:
+ return .green
+ case .failure:
+ return .red
+ default:
+ return .gray
+ }
+ }
+}
+
+#Preview {
+ NavigationView {
+ MultiImageUploadExampleView()
+ }
+}
diff --git a/wake/View/Login/Login.swift b/wake/View/Login/Login.swift
index 7b34b71..0a736b3 100644
--- a/wake/View/Login/Login.swift
+++ b/wake/View/Login/Login.swift
@@ -2,6 +2,7 @@ import SwiftUI
import AuthenticationServices
import Alamofire
import CryptoKit
+import Foundation
/// 主登录视图 - 处理苹果登录
struct LoginView: View {
@@ -12,60 +13,57 @@ struct LoginView: View {
@State private var errorMessage = ""
@State private var currentNonce: String?
@State private var isLoggedIn = false
-
// MARK: - Body
var body: some View {
- NavigationStack {
- ZStack {
- // Background
- Color(red: 1.0, green: 0.67, blue: 0.15)
- .edgesIgnoringSafeArea(.all)
+ ZStack {
+ // Background
+ Color(red: 1.0, green: 0.67, blue: 0.15)
+ .ignoresSafeArea()
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Hi, I'm MeMo!")
+ .font(Typography.font(for: .largeTitle))
+ .foregroundColor(.black)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 24)
+ .padding(.top, 44)
- VStack(alignment: .leading, spacing: 4) {
- Text("Hi, I'm MeMo!")
- .font(.largeTitle)
- .fontWeight(.semibold)
- .foregroundColor(.black)
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(.leading, 24)
- .padding(.top, 44)
-
- Text("Welcome~")
- .font(.largeTitle)
- .fontWeight(.semibold)
- .foregroundColor(.black)
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(.leading, 24)
- .padding(.bottom, 20)
-
- Spacer()
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+ Text("Welcome~")
+ .font(Typography.font(for: .largeTitle))
+ .foregroundColor(.black)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 24)
+ .padding(.bottom, 20)
- VStack(spacing: 16) {
- Spacer()
- signInButton()
- termsAndPrivacyView()
- }
- .padding()
- .alert(isPresented: $showError) {
- Alert(
- title: Text("Error"),
- message: Text(errorMessage),
- dismissButton: .default(Text("OK"))
- )
- }
-
- if isLoading {
- loadingView()
- }
+ Spacer()
}
- .navigationBarHidden(true)
- .fullScreenCover(isPresented: $isLoggedIn) {
- NavigationStack {
- UserInfo()
- }
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+
+ VStack(spacing: 16) {
+ Spacer()
+ signInButton()
+ .padding(.horizontal, 24)
+ termsAndPrivacyView()
+ }
+ .frame(maxWidth: .infinity)
+ .alert(isPresented: $showError) {
+ Alert(
+ title: Text("Error"),
+ message: Text(errorMessage),
+ dismissButton: .default(Text("OK"))
+ )
+ }
+
+ if isLoading {
+ loadingView()
+ }
+ }
+ .navigationBarBackButtonHidden(true)
+ .navigationBarHidden(true)
+ .fullScreenCover(isPresented: $isLoggedIn) {
+ NavigationStack {
+ UserInfo()
}
}
}
@@ -73,22 +71,22 @@ struct LoginView: View {
// MARK: - Views
private func signInButton() -> some View {
- SignInWithAppleButton(
- onRequest: { request in
- let nonce = String.randomURLSafeString(length: 32)
- self.currentNonce = nonce
- request.requestedScopes = [.fullName, .email]
- request.nonce = self.sha256(nonce)
- },
- onCompletion: handleAppleSignIn
- )
- .signInWithAppleButtonStyle(.white)
- .frame(height: 50)
- .cornerRadius(25)
- .overlay(
- RoundedRectangle(cornerRadius: 25)
- .stroke(Color.black, lineWidth: 1)
- )
+ AppleSignInButton { request in
+ let nonce = String.randomURLSafeString(length: 32)
+ self.currentNonce = nonce
+ request.nonce = self.sha256(nonce)
+ } onCompletion: { result in
+ switch result {
+ case .success(let authResults):
+ print("✅ [Apple Sign In] 登录授权成功")
+ if let appleIDCredential = authResults.credential as? ASAuthorizationAppleIDCredential {
+ self.processAppleIDCredential(appleIDCredential)
+ }
+ case .failure(let error):
+ print("❌ [Apple Sign In] 登录失败: \(error.localizedDescription)")
+ self.handleSignInError(error)
+ }
+ }
}
private func termsAndPrivacyView() -> some View {
@@ -96,13 +94,13 @@ struct LoginView: View {
HStack {
Text("By continuing, you agree to our")
.font(.caption)
- .foregroundColor(.secondary)
+ .foregroundColor(.themeTextMessage)
Button("Terms of") {
openURL("https://yourwebsite.com/terms")
}
.font(.caption2)
- .foregroundColor(.blue)
+ .foregroundColor(.themeTextMessageMain)
}
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
@@ -112,24 +110,24 @@ struct LoginView: View {
openURL("https://yourwebsite.com/terms")
}
.font(.caption2)
- .foregroundColor(.blue)
+ .foregroundColor(.themeTextMessageMain)
Text("and")
- .foregroundColor(.secondary)
+ .foregroundColor(.themeTextMessage)
.font(.caption)
Button("Privacy Policy") {
openURL("https://yourwebsite.com/privacy")
}
.font(.caption2)
- .foregroundColor(.blue)
+ .foregroundColor(.themeTextMessageMain)
}
.padding(.top, 4)
}
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity)
.padding(.horizontal, 24)
- .padding(.bottom, 24)
+ .padding(.vertical, 12)
}
private func loadingView() -> some View {
@@ -147,9 +145,6 @@ struct LoginView: View {
private func handleAppleSignIn(result: Result) {
print("🔵 [Apple Sign In] 开始处理登录结果...")
- DispatchQueue.main.async {
- self.isLoggedIn = true
- }
switch result {
case .success(let authResults):
print("✅ [Apple Sign In] 登录授权成功")
@@ -196,9 +191,6 @@ struct LoginView: View {
print("🔵 [Apple ID] 准备调用后端认证...")
authenticateWithBackend(
- userId: userId,
- email: email,
- name: fullName,
identityToken: identityToken,
authCode: authCode
)
@@ -207,46 +199,49 @@ struct LoginView: View {
// MARK: - Network
private func authenticateWithBackend(
- userId: String,
- email: String,
- name: String,
identityToken: String,
authCode: String?
) {
isLoading = true
print("🔵 [Backend] 开始后端认证...")
- let url = "https://your-api-endpoint.com/api/auth/apple"
var parameters: [String: Any] = [
- "appleUserId": userId,
- "email": email,
- "name": name,
- "identityToken": identityToken
+ "token": identityToken,
+ "provider": "Apple",
]
if let authCode = authCode {
- parameters["authorizationCode"] = authCode
+ parameters["authorization_code"] = authCode
}
- print("📤 [Backend] 请求参数: \(parameters)")
-
- AF.request(url, method: .post, parameters: parameters, encoding: JSONEncoding.default)
- .validate()
- .responseJSON { response in
- self.isLoading = false
-
- switch response.result {
- case .success(let value):
- print("✅ [Backend] 认证成功: \(value)")
- self.handleSuccessfulAuthentication()
+ NetworkService.shared.post(
+ path: "/iam/login/oauth",
+ parameters: parameters
+ ) { (result: Result) in
+ DispatchQueue.main.async {
+ switch result {
+ case .success(let authResponse):
+ print("✅ [Backend] 认证成功")
+
+ // 保存token等认证信息
+ if let loginInfo = authResponse.data?.userLoginInfo {
+ KeychainHelper.saveAccessToken(loginInfo.accessToken)
+ KeychainHelper.saveRefreshToken(loginInfo.refreshToken)
+ // 可以在这里保存其他用户信息,如userId, nickname等
+ print("👤 用户ID: \(loginInfo.userId)")
+ print("👤 昵称: \(loginInfo.nickname)")
+ }
+
+ self.isLoggedIn = true
+
case .failure(let error):
print("❌ [Backend] 认证失败: \(error.localizedDescription)")
- if let data = response.data, let json = String(data: data, encoding: .utf8) {
- print("❌ [Backend] 错误详情: \(json)")
- }
- self.handleAuthenticationError(error)
+ self.errorMessage = error.localizedDescription
+ self.showError = true
+ self.isLoading = false
}
}
+ }
}
// MARK: - Helpers
@@ -264,7 +259,7 @@ struct LoginView: View {
showError(message: "登录失败: \(error.localizedDescription)")
}
- private func handleAuthenticationError(_ error: AFError) {
+ private func handleAuthenticationError(_ error: Error) {
let errorMessage = error.localizedDescription
print("❌ [Auth] 认证错误: \(errorMessage)")
DispatchQueue.main.async {
diff --git a/wake/View/Owner/UserInfo/AvatarPicker.swift b/wake/View/Owner/UserInfo/AvatarPicker.swift
new file mode 100644
index 0000000..63153b7
--- /dev/null
+++ b/wake/View/Owner/UserInfo/AvatarPicker.swift
@@ -0,0 +1,202 @@
+import SwiftUI
+
+public struct AvatarPicker: View {
+ @StateObject private var uploadManager = MediaUploadManager()
+ @State private var showMediaPicker = false
+ @State private var showImageCapture = false
+ @State private var isUploading = false
+ @Binding var selectedImage: UIImage?
+ @Binding var showUsername: Bool
+ @Binding var isKeyboardVisible: Bool
+ @Binding var uploadedFileId: String?
+
+ // Animation state
+ @State private var isAnimating = false
+
+ public init(selectedImage: Binding,
+ showUsername: Binding,
+ isKeyboardVisible: Binding,
+ uploadedFileId: Binding) {
+ self._selectedImage = selectedImage
+ self._showUsername = showUsername
+ self._isKeyboardVisible = isKeyboardVisible
+ self._uploadedFileId = uploadedFileId
+ }
+
+ private var avatarSize: CGFloat {
+ isKeyboardVisible ? 125 : 225
+ }
+
+ private var borderWidth: CGFloat {
+ isKeyboardVisible ? 3 : 4
+ }
+
+ public var body: some View {
+ VStack() {
+ VStack(spacing: 20) {
+ // Avatar Image Button
+ Button(action: {
+ withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
+ showMediaPicker = true
+ }
+ }) {
+ ZStack {
+ if let selectedImage = selectedImage {
+ Image(uiImage: selectedImage)
+ .resizable()
+ .scaledToFill()
+ .frame(width: avatarSize, height: avatarSize)
+ .clipShape(RoundedRectangle(cornerRadius: 20))
+ .overlay(
+ RoundedRectangle(cornerRadius: 20)
+ .stroke(Color.themePrimary, lineWidth: borderWidth)
+ )
+ } else {
+ // Default SVG avatar with animated dashed border
+ SVGImage(svgName: "IP")
+ .frame(width: avatarSize, height: avatarSize)
+ .contentShape(Rectangle())
+ .overlay(
+ RoundedRectangle(cornerRadius: 20)
+ .stroke(style: StrokeStyle(
+ lineWidth: borderWidth,
+ lineCap: .round,
+ dash: [12, 8],
+ dashPhase: isAnimating ? 40 : 0
+ ))
+ .foregroundColor(Color.themePrimary)
+ )
+ .onAppear {
+ withAnimation(Animation.linear(duration: 1.5).repeatForever(autoreverses: false)) {
+ isAnimating = true
+ }
+ }
+ }
+
+ // Upload indicator
+ if isUploading {
+ ProgressView()
+ .progressViewStyle(CircularProgressViewStyle(tint: .themePrimary))
+ .scaleEffect(1.5)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color.black.opacity(0.3))
+ .clipShape(RoundedRectangle(cornerRadius: 20))
+ .transition(.opacity)
+ }
+ }
+ .frame(width: avatarSize, height: avatarSize)
+ .animation(.spring(response: 0.4, dampingFraction: 0.7), value: isKeyboardVisible)
+ }
+ .buttonStyle(ScaleButtonStyle())
+
+ // Upload Button (only shown when username is not shown)
+ if !showUsername {
+ Button(action: {
+ withAnimation {
+ showMediaPicker = true
+ }
+ }) {
+ Text("Upload from Gallery")
+ .font(Typography.font(for: .subtitle, family: .inter))
+ .fontWeight(.regular)
+ .frame(maxWidth: .infinity)
+ .padding()
+ .foregroundColor(.black)
+ .background(
+ RoundedRectangle(cornerRadius: 16)
+ .fill(Color.themePrimaryLight)
+ )
+ }
+ .frame(maxWidth: .infinity)
+ .transition(.opacity.combined(with: .move(edge: .bottom)))
+ }
+ }
+ .sheet(isPresented: $showMediaPicker) {
+ MediaPicker(
+ selectedMedia: $uploadManager.selectedMedia,
+ imageSelectionLimit: 1,
+ videoSelectionLimit: 0,
+ allowedMediaTypes: .imagesOnly,
+ selectionMode: .single,
+ onDismiss: {
+ showMediaPicker = false
+ if !uploadManager.selectedMedia.isEmpty {
+ withAnimation {
+ isUploading = true
+ }
+ uploadManager.startUpload()
+ }
+ }
+ )
+ }
+ .onChange(of: uploadManager.uploadStatus) { _ in
+ if let firstMedia = uploadManager.selectedMedia.first,
+ case .image(let image) = firstMedia,
+ uploadManager.isAllUploaded {
+ withAnimation(.spring()) {
+ selectedImage = image
+ isUploading = false
+ if let status = uploadManager.uploadStatus["0"],
+ case .completed(let fileId) = status {
+ uploadedFileId = fileId
+ }
+ uploadManager.clearAllMedia()
+ }
+ }
+ }
+ if !showUsername {
+ Button(action: {
+ withAnimation {
+ showImageCapture = true
+ }
+ }) {
+ Text("Take a Photo")
+ .font(Typography.font(for: .subtitle, family: .inter))
+ .fontWeight(.regular)
+ .frame(maxWidth: .infinity)
+ .padding()
+ .foregroundColor(.black)
+ .background(
+ RoundedRectangle(cornerRadius: 16)
+ .fill(Color.themePrimaryLight)
+ )
+ }
+ .frame(maxWidth: .infinity)
+ .sheet(isPresented: $showImageCapture) {
+ CustomCameraView(isPresented: $showImageCapture) { image in
+ selectedImage = image
+ uploadManager.selectedMedia = [.image(image)]
+ withAnimation {
+ isUploading = true
+ }
+ uploadManager.startUpload()
+ }
+ }
+ }
+ }
+ }
+}
+
+// Button style for scale effect
+private struct ScaleButtonStyle: ButtonStyle {
+ func makeBody(configuration: Configuration) -> some View {
+ configuration.label
+ .scaleEffect(configuration.isPressed ? 0.95 : 1.0)
+ .animation(.easeInOut(duration: 0.2), value: configuration.isPressed)
+ }
+}
+
+// MARK: - Preview
+struct AvatarPicker_Previews: PreviewProvider {
+ static var previews: some View {
+ AvatarPicker(
+ selectedImage: .constant(nil),
+ showUsername: .constant(false),
+ isKeyboardVisible: .constant(false),
+ uploadedFileId: .constant(nil)
+ )
+ .padding()
+ .background(Color.gray.opacity(0.1))
+ .previewLayout(.sizeThatFits)
+ }
+}
\ No newline at end of file
diff --git a/wake/View/Owner/UserInfo/CameraView.swift b/wake/View/Owner/UserInfo/CameraView.swift
new file mode 100644
index 0000000..8cfa8e4
--- /dev/null
+++ b/wake/View/Owner/UserInfo/CameraView.swift
@@ -0,0 +1,30 @@
+import SwiftUI
+import UIKit
+
+struct CameraView: UIViewControllerRepresentable {
+ @Binding var isPresented: Bool
+ let onImageSelected: (UIImage) -> Void
+ @Environment(\.presentationMode) private var presentationMode
+
+ func makeUIViewController(context: Context) -> UIViewController {
+ let viewController = UIViewController()
+ viewController.view.backgroundColor = .clear
+ return viewController
+ }
+
+ func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
+ if isPresented {
+ DispatchQueue.main.async {
+ if let rootVC = UIApplication.shared.windows.first?.rootViewController {
+ ImageCaptureManager.shared.captureImage(from: rootVC) { image in
+ if let image = image {
+ onImageSelected(image)
+ }
+ isPresented = false
+ presentationMode.wrappedValue.dismiss()
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/wake/View/Owner/UserInfo/UserInfo.swift b/wake/View/Owner/UserInfo/UserInfo.swift
index 7a49929..296e253 100644
--- a/wake/View/Owner/UserInfo/UserInfo.swift
+++ b/wake/View/Owner/UserInfo/UserInfo.swift
@@ -4,119 +4,262 @@ struct UserInfo: View {
@Environment(\.dismiss) private var dismiss
// Sample user data - replace with your actual data model
- @State private var userName = "MeMo"
+ @State private var userName = ""
@State private var userEmail = "memo@example.com"
@State private var notificationsEnabled = true
@State private var darkModeEnabled = false
@State private var showLogoutAlert = false
- @State private var avatarImage: UIImage? // Add this line
+ @State private var avatarImage: UIImage?
+ @State private var showUsername: Bool = false
+ @State private var uploadedFileId: String? // Add state for file ID
+ @State private var errorMessage: String = ""
+ @State private var showError: Bool = false
+
+ // 添加一个状态来跟踪键盘是否显示
+ @State private var isKeyboardVisible = false
+ @FocusState private var isTextFieldFocused: Bool
+
+ private let keyboardPublisher = NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)
+ .map { _ in true }
+ .merge(with: NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)
+ .map { _ in false })
+ .receive(on: RunLoop.main)
var body: some View {
- VStack(spacing: 0) {
- HStack(spacing: 20) {
- Text("Choose a photo as your avatar, and we'll generate a video mystery box for you.")
- .font(Typography.font(for: .small))
- .foregroundColor(.black)
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(.vertical, 10)
- .background(
- LinearGradient(
- gradient: Gradient(colors: [
- Color(red: 1.0, green: 0.97, blue: 0.87),
- .white,
- Color(red: 1.0, green: 0.97, blue: 0.84)
- ]),
- startPoint: .topLeading,
- endPoint: .bottomTrailing
- )
- )
- }
- .padding(.vertical, 10)
- Spacer()
- VStack(spacing: 20) {
- // Title
- Text("Add Your Avatar")
- .font(Typography.font(for: .title))
- .frame(maxWidth: .infinity, alignment: .center)
-
- // Avatar
- ZStack {
- // Show either the SVG or the uploaded image
- if let avatarImage = avatarImage {
- Image(uiImage: avatarImage)
- .resizable()
- .scaledToFill()
- .frame(width: 200, height: 200)
- .clipShape(Circle())
- } else {
- SVGImage(svgName: "Avatar")
- .frame(width: 200, height: 200)
+ ZStack {
+ // 背景色
+ Color.themeTextWhiteSecondary
+ .edgesIgnoringSafeArea(.all)
+
+ VStack(spacing: 0) {
+ // 固定的顶部导航栏
+ HStack {
+ Button(action: {
+ dismiss()
+ }) {
+ Image(systemName: "chevron.left")
+ .font(.system(size: 17, weight: .semibold))
+ .foregroundColor(.themeTextMessageMain)
}
+ .padding(.leading, 16)
- // Make sure the AvatarUploader is on top and covers the entire area
- AvatarUploader(selectedImage: $avatarImage, size: 200)
- .contentShape(Rectangle()) // This makes the entire area tappable
+ Spacer()
+
+ Text("Complete Your Profile")
+ .font(Typography.font(for: .title2, family: .quicksandBold))
+ .foregroundColor(.themeTextMessageMain)
+
+ Spacer()
+
+ // 添加一个透明的占位视图来平衡布局
+ Color.clear
+ .frame(width: 24, height: 24)
+ .padding(.trailing, 16)
}
- .frame(width: 200, height: 200)
- .padding(.vertical, 20)
-
- // Buttons
- Button(action: {
- // Action for first button
- }) {
- Text("Upload from Gallery")
- .frame(maxWidth: .infinity)
- .padding()
- .foregroundColor(.black)
- .background(
- RoundedRectangle(cornerRadius: 25)
- .fill(Color(red: 1.0, green: 0.973, blue: 0.871))
- )
- }
-
- Button(action: {
- // Action for second button
- }) {
- Text("Take a Photo")
- .frame(maxWidth: .infinity)
- .padding()
+ .padding(.vertical, 12)
+ .background(Color.themeTextWhiteSecondary)
+ .zIndex(1) // 确保导航栏在最上层
+ // Dynamic text that changes based on keyboard state
+ HStack(spacing: 20) {
+ Text("Choose a photo as your avatar, and we'll generate a video mystery box for you.")
+ .font(Typography.font(for: .caption))
.foregroundColor(.black)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .lineLimit(isKeyboardVisible ? 1 : 2)
+ .padding(6)
.background(
- RoundedRectangle(cornerRadius: 25)
- .fill(Color(red: 1.0, green: 0.973, blue: 0.871))
+ LinearGradient(
+ gradient: Gradient(colors: [
+ Color(red: 1.0, green: 0.97, blue: 0.87),
+ .white,
+ Color(red: 1.0, green: 0.97, blue: 0.84)
+ ]),
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ )
)
}
+ .padding(10)
+ .animation(.easeInOut(duration: 0.3), value: isKeyboardVisible)
+ .transition(.opacity)
+
+ // 可滚动的内容区域
+ GeometryReader { geometry in
+ ScrollView {
+ VStack(spacing: 0) {
+ // 顶部Spacer - 只在非键盘状态下生效
+ if !isKeyboardVisible {
+ Spacer(minLength: 0)
+ .frame(height: geometry.size.height * 0.1) // 10% of available height
+ }
+
+ // Content VStack
+ VStack(spacing: 20) {
+ // Title
+ Text(showUsername ? "Add Your Avatar" : "What's Your Name?")
+ .font(Typography.font(for: .body, family: .quicksandBold))
+ .frame(maxWidth: .infinity, alignment: .center)
+
+ // Avatar
+ AvatarPicker(
+ selectedImage: $avatarImage,
+ showUsername: $showUsername,
+ isKeyboardVisible: $isKeyboardVisible,
+ uploadedFileId: $uploadedFileId
+ )
+ .padding(.top, isKeyboardVisible ? 0 : 20)
+
+ if showUsername {
+ TextField("Username", text: $userName)
+ .font(Typography.font(for: .subtitle, family: .inter))
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: .infinity)
+ .padding()
+ .foregroundColor(.black)
+ .background(
+ RoundedRectangle(cornerRadius: 16)
+ .fill(Color.themePrimaryLight)
+ )
+ .focused($isTextFieldFocused)
+ .submitLabel(.done)
+ .onSubmit {
+ isTextFieldFocused = false
+ }
+ }
+ }
+ .padding()
+ .background(Color(.white))
+ .cornerRadius(20)
+ .padding(.horizontal)
+
+ // 底部Spacer - 只在非键盘状态下生效
+ if !isKeyboardVisible {
+ Spacer(minLength: 0)
+ .frame(height: geometry.size.height * 0.1) // 10% of available height
+ }
+
+ // Continue Button
+ Button(action: {
+ if showUsername {
+ let parameters: [String: Any] = [
+ "username": userName,
+ "avatar_file_id": uploadedFileId ?? ""
+ ]
+
+ NetworkService.shared.postWithToken(
+ path: "/iam/user/info",
+ parameters: parameters
+ ) { (result: Result) in
+ DispatchQueue.main.async {
+ switch result {
+ case .success(let response):
+ print("✅ 用户信息更新成功")
+ // Update local state with the new user info
+ if let userData = response.data {
+ self.userName = userData.username
+ // You can update other user data here if needed
+ }
+ // Show success message or navigate back
+ self.dismiss()
+
+ case .failure(let error):
+ print("❌ 用户信息更新失败: \(error.localizedDescription)")
+ // Show error message to user
+ // You can use an @State variable to show an alert or toast
+ self.errorMessage = "更新失败: \(error.localizedDescription)"
+ self.showError = true
+ }
+ }
+ }
+ } else {
+ withAnimation {
+ showUsername = true
+ }
+ }
+ }) {
+ Text(showUsername ? "Open" : "Continue")
+ .font(Typography.font(for: .body))
+ .fontWeight(.bold)
+ .frame(maxWidth: .infinity)
+ .padding()
+ .foregroundColor(.black)
+ .background(
+ RoundedRectangle(cornerRadius: 25)
+ .fill(Color.themePrimary)
+ )
+ }
+ .padding(.horizontal, 32) // 添加上下边距,与上方按钮保持一致
+ .padding(.bottom, isKeyboardVisible ? 20 : 40)
+ .disabled(showUsername && userName.trimmingCharacters(in: .whitespaces).isEmpty)
+ .opacity((showUsername && userName.trimmingCharacters(in: .whitespaces).isEmpty) ? 0.6 : 1.0)
+ .animation(.easeInOut, value: showUsername)
+ .frame(maxWidth: .infinity)
+
+ // 底部安全区域占位
+ if isKeyboardVisible {
+ Spacer(minLength: 0)
+ .frame(height: 20) // 添加一些底部间距当键盘显示时
+ }
+ }
+ .frame(minHeight: geometry.size.height) // 确保内容至少填满可用高度
+ }
+ }
+ .background(Color.themeTextWhiteSecondary)
}
- .padding()
- .background(Color(.white))
- .cornerRadius(20)
- Spacer()
- Button(action: {
- // Action for next button
- }) {
- Text("Next")
- .frame(maxWidth: .infinity)
- .padding()
- .foregroundColor(.black)
- .background(
- RoundedRectangle(cornerRadius: 25)
- .fill(Color(red: 1.0, green: 0.714, blue: 0.271))
- )
+ .navigationBarHidden(true)
+ .navigationBarBackButtonHidden(true)
+
+ // 键盘出现时的半透明覆盖层
+ if isKeyboardVisible {
+ Color.black.opacity(0.001)
+ .edgesIgnoringSafeArea(.all)
+ .onTapGesture {
+ UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
+ }
+ }
+
+ // 错误提示
+ if showError {
+ VStack {
+ Text(errorMessage)
+ .font(Typography.font(for: .body))
+ .foregroundColor(.red)
+ .padding()
+ .background(Color.white)
+ .cornerRadius(10)
+ .shadow(radius: 2)
+ }
+ .frame(maxWidth: .infinity, alignment: .center)
+ .padding()
}
}
- .padding()
- .navigationTitle("Complete Your Profile")
- .navigationBarTitleDisplayMode(.inline)
- .background(Color(red: 0.98, green: 0.98, blue: 0.98)) // #FAFAFA
- .toolbar {
- ToolbarItem(placement: .navigationBarLeading) {
- Button(action: {
- dismiss()
- }) {
- Image(systemName: "chevron.left")
- .foregroundColor(.blue)
+ .onAppear {
+ // Set up keyboard notifications
+ NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { _ in
+ withAnimation(.easeInOut(duration: 0.3)) {
+ isKeyboardVisible = true
}
}
+
+ NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main) { _ in
+ withAnimation(.easeInOut(duration: 0.3)) {
+ isKeyboardVisible = false
+ }
+ }
+ }
+ .onDisappear {
+ // Clean up observers
+ NotificationCenter.default.removeObserver(self)
+ }
+ .onReceive(keyboardPublisher) { isVisible in
+ withAnimation(.easeInOut(duration: 0.2)) {
+ isKeyboardVisible = isVisible
+ }
+ }
+ .onChange(of: isTextFieldFocused) { newValue in
+ withAnimation(.easeInOut(duration: 0.2)) {
+ isKeyboardVisible = newValue
+ }
}
}
}
diff --git a/wake/View/Subscribe/Components/SubscriptionStatusBar.swift b/wake/View/Subscribe/Components/SubscriptionStatusBar.swift
index 48231b7..1a92267 100644
--- a/wake/View/Subscribe/Components/SubscriptionStatusBar.swift
+++ b/wake/View/Subscribe/Components/SubscriptionStatusBar.swift
@@ -61,21 +61,21 @@ struct SubscriptionStatusBar: View {
var body: some View {
HStack(spacing: 16) {
- VStack(alignment: .leading, spacing: 20) {
+ VStack(alignment: .leading, spacing: 8) {
// 订阅类型标题
Text(status.title)
- .font(Typography.font(for: .headline, family: .quicksandBold, size: 32))
+ .font(.system(size: 28, weight: .bold, design: .rounded))
.foregroundColor(status.textColor)
// 过期时间或订阅按钮
if case .pioneer(let expiryDate) = status {
VStack(alignment: .leading, spacing: 4) {
Text("Expires on :")
- .font(Typography.font(for: .body, family: .quicksandRegular))
- .foregroundColor(status.textColor.opacity(0.7))
+ .font(.system(size: 14, weight: .medium))
+ .foregroundColor(status.textColor.opacity(0.8))
Text(formatDate(expiryDate))
- .font(Typography.font(for: .body, family: .quicksandRegular))
+ .font(.system(size: 16, weight: .semibold))
.foregroundColor(status.textColor)
}
} else {
@@ -83,12 +83,12 @@ struct SubscriptionStatusBar: View {
onSubscribeTap?()
}) {
Text("Subscribe")
- .font(Typography.font(for: .title, family: .quicksandRegular, size: 16))
+ .font(.system(size: 14, weight: .semibold))
.foregroundColor(Theme.Colors.textPrimary)
- .padding(.horizontal, 12)
- .padding(.vertical, 6)
- .background(Theme.Gradients.backgroundGradient)
- .cornerRadius(Theme.CornerRadius.extraLarge)
+ .padding(.horizontal, 20)
+ .padding(.vertical, 8)
+ .background(Theme.Colors.subscribeButton)
+ .cornerRadius(Theme.CornerRadius.large)
}
}
}
diff --git a/wake/View/Welcome/SplashView.swift b/wake/View/Welcome/SplashView.swift
new file mode 100644
index 0000000..463e6c4
--- /dev/null
+++ b/wake/View/Welcome/SplashView.swift
@@ -0,0 +1,42 @@
+import SwiftUI
+
+struct SplashView: View {
+ @State private var isAnimating = false
+ @State private var showLogin = false
+ @EnvironmentObject private var authState: AuthState
+
+ var body: some View {
+ NavigationView {
+ ZStack {
+ // 背景渐变
+ LinearGradient(
+ gradient: Gradient(colors: [
+ Theme.Colors.primary, // Primary color with some transparency
+ Theme.Colors.primaryDark, // Darker shade of the primary color
+ ]),
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ )
+ .edgesIgnoringSafeArea(.all)
+ VStack(spacing: 50) {
+ FilmAnimation()
+ }
+ .padding()
+ }
+ .onAppear {
+ isAnimating = true
+ }
+ }
+ .navigationViewStyle(StackNavigationViewStyle())
+ }
+}
+
+// 预览
+#if DEBUG
+struct SplashView_Previews: PreviewProvider {
+ static var previews: some View {
+ SplashView()
+ .environmentObject(AuthState.shared)
+ }
+}
+#endif
\ No newline at end of file
diff --git a/wake/Views/Utils/SVGImage.swift b/wake/Views/Utils/SVGImage.swift
index 8604cfd..a16299d 100644
--- a/wake/Views/Utils/SVGImage.swift
+++ b/wake/Views/Utils/SVGImage.swift
@@ -11,60 +11,58 @@ struct SVGImage: UIViewRepresentable {
webView.scrollView.isScrollEnabled = false
webView.scrollView.contentInsetAdjustmentBehavior = .never
- if let url = Bundle.main.url(forResource: svgName, withExtension: "svg") {
- let htmlString = """
-
-
-
-
-
-
-
-
-
)
-
-
-
- """
-
- webView.loadHTMLString(htmlString, baseURL: nil)
+ // 1. 获取 SVG 文件路径(注意:移除了 inDirectory 参数)
+ guard let path = Bundle.main.path(forResource: svgName, ofType: "svg") else {
+ print("❌ 无法找到 SVG 文件: \(svgName).svg")
+ // 打印所有可用的资源文件,用于调试
+ if let resourcePath = Bundle.main.resourcePath {
+ print("可用的资源文件: \(try? FileManager.default.contentsOfDirectory(atPath: resourcePath))")
+ }
+ return webView
}
+ // 2. 创建文件 URL
+ let fileURL = URL(fileURLWithPath: path)
+
+ // 3. 创建 HTML 字符串
+ let htmlString = """
+
+
+
+
+
+
+
+
+
)
+
+
+
+ """
+
+ // 4. 加载 HTML 字符串
+ webView.loadHTMLString(htmlString, baseURL: fileURL.deletingLastPathComponent())
return webView
}
func updateUIView(_ uiView: WKWebView, context: Context) {}
-}
-
-// MARK: - View Modifier for SVG Image
-struct SVGImageModifier: ViewModifier {
- let size: CGSize
-
- func body(content: Content) -> some View {
- content
- .frame(width: size.width, height: size.height)
- .aspectRatio(contentMode: .fit)
- }
-}
-
-extension View {
- func svgImageStyle(size: CGSize) -> some View {
- self.modifier(SVGImageModifier(size: size))
- }
-}
-
-// Usage:
-// SVGImage(svgName: "Avatar")
+}
\ No newline at end of file
diff --git a/wake/WakeApp.swift b/wake/WakeApp.swift
index 49addd2..baf704e 100644
--- a/wake/WakeApp.swift
+++ b/wake/WakeApp.swift
@@ -1,20 +1,12 @@
-
import SwiftUI
import UIKit
import SwiftData
@main
struct WakeApp: App {
- // init() {
- // // 打印所有可用的字体
- // print("\n=== 所有可用的字体 ===")
- // for family in UIFont.familyNames.sorted() {
- // print("\n\(family):")
- // for name in UIFont.fontNames(forFamilyName: family).sorted() {
- // print(" - \(name)")
- // }
- // }
- // }
+ @StateObject private var authState = AuthState.shared
+ @State private var showSplash = true
+
// 使用更简单的方式创建模型容器
let container: ModelContainer
@@ -32,26 +24,79 @@ struct WakeApp: App {
// 3. 重新创建容器
container = try! ModelContainer(for: Login.self)
}
+
+ // 配置网络层
+ configureNetwork()
}
-
var body: some Scene {
WindowGroup {
- ContentView()
-// SettingsView()
- // 导航栏按钮
- // TabView{
- // ContentView()
- // .tabItem{
- // Label("wake", systemImage: "book")
+ ZStack {
+ if showSplash {
+ // 显示启动页
+ SplashView()
+ .environmentObject(authState)
+ // .onAppear {
+ // // 启动页显示时检查token有效性
+ // checkTokenValidity()
+ // }
+ } else {
+ // 根据登录状态显示不同视图
+ if authState.isAuthenticated {
+ // 已登录:显示userInfo页面
+ UserInfo()
+ .environmentObject(authState)
+ } else {
+ // 未登录:显示登录界面
+ // ContentView()
+ // .environmentObject(authState)
+ UserInfo()
+ .environmentObject(authState)
+ }
+ }
+ }
+ // .onAppear {
+ // //3秒后自动隐藏启动页
+ // DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
+ // withAnimation {
+ // showSplash = false
// }
- // SettingView()
- // .tabItem{
- // Label("setting", systemImage: "gear")
- // }
+ // }
// }
}
- // 注入模型容器到环境中
.modelContainer(container)
}
-}
+
+ // MARK: - 私有方法
+
+ /// 配置网络层
+ private func configureNetwork() {
+ // 配置网络请求超时时间等
+ let configuration = URLSessionConfiguration.default
+ configuration.timeoutIntervalForRequest = 30
+ configuration.timeoutIntervalForResource = 60
+
+ // 可以在这里添加其他网络配置
+ }
+
+ /// 检查token有效性
+ private func checkTokenValidity() {
+ guard TokenManager.shared.hasToken,
+ let token = KeychainHelper.getAccessToken() else {
+ showSplash = false
+ return
+ }
+
+ // 检查token是否有效
+ if TokenManager.shared.isTokenValid(token) {
+ authState.isAuthenticated = true
+ }
+
+ // 3秒后自动隐藏启动页
+ // DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
+ // withAnimation {
+ // showSplash = false
+ // }
+ // }
+ }
+}
\ No newline at end of file