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