feat: 上传组件 #1
Binary file not shown.
BIN
wake/.DS_Store
vendored
BIN
wake/.DS_Store
vendored
Binary file not shown.
BIN
wake/Assets/.DS_Store
vendored
Normal file
BIN
wake/Assets/.DS_Store
vendored
Normal file
Binary file not shown.
9
wake/Assets/Svg/IP.svg
Normal file
9
wake/Assets/Svg/IP.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 184 KiB |
265
wake/Components/Media/CustomCameraView.swift
Normal file
265
wake/Components/Media/CustomCameraView.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
BIN
wake/CoreData/.DS_Store
vendored
Normal file
BIN
wake/CoreData/.DS_Store
vendored
Normal file
Binary file not shown.
@ -20,10 +20,14 @@
|
||||
</array>
|
||||
<key>NSAppleIDUsageDescription</key>
|
||||
<string>Sign in with Apple is used to authenticate your account</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>We need access to your camera to take photos</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>We need access to your photo library to select photos</string>
|
||||
<key>UIAppFonts</key>
|
||||
<array>
|
||||
<string>Inter.ttf</string>
|
||||
<string>Quicksand x.ttf</string>
|
||||
<string>SankeiCutePopanime.ttf</string>
|
||||
<string>Quicksand-Regular.ttf</string>
|
||||
<string>Quicksand-Bold.ttf</string>
|
||||
<string>Quicksand-SemiBold.ttf</string>
|
||||
|
||||
59
wake/Models/AuthModels.swift
Normal file
59
wake/Models/AuthModels.swift
Normal file
@ -0,0 +1,59 @@
|
||||
import Foundation
|
||||
|
||||
/// API基础响应模型
|
||||
struct BaseResponse<T: Codable>: Codable {
|
||||
let code: Int
|
||||
let data: T?
|
||||
let message: String?
|
||||
}
|
||||
|
||||
/// 用户登录信息
|
||||
struct UserLoginInfo: Codable {
|
||||
let userId: String
|
||||
let accessToken: String
|
||||
let refreshToken: String
|
||||
let nickname: String
|
||||
let account: String
|
||||
let email: String
|
||||
let avatarFileUrl: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case userId = "user_id"
|
||||
case accessToken = "access_token"
|
||||
case refreshToken = "refresh_token"
|
||||
case nickname
|
||||
case account
|
||||
case email
|
||||
case avatarFileUrl = "avatar_file_url"
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录响应数据
|
||||
struct LoginResponseData: Codable {
|
||||
let userLoginInfo: UserLoginInfo
|
||||
let isNewUser: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case userLoginInfo = "user_login_info"
|
||||
case isNewUser = "is_new_user"
|
||||
}
|
||||
}
|
||||
|
||||
/// 认证响应模型
|
||||
typealias AuthResponse = BaseResponse<LoginResponseData>
|
||||
|
||||
/// 用户信息响应数据
|
||||
struct UserInfoData: Codable {
|
||||
let userId: String
|
||||
let username: String
|
||||
let avatarFileId: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case userId = "user_id"
|
||||
case username
|
||||
case avatarFileId = "avatar_file_id"
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户信息响应模型
|
||||
typealias UserInfoResponse = BaseResponse<UserInfoData>
|
||||
49
wake/Models/AuthState.swift
Normal file
49
wake/Models/AuthState.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
57
wake/Models/UploadModels.swift
Normal file
57
wake/Models/UploadModels.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
BIN
wake/Resources/.DS_Store
vendored
Normal file
BIN
wake/Resources/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
wake/Resources/Fonts/Inter.ttf
Normal file
BIN
wake/Resources/Fonts/Inter.ttf
Normal file
Binary file not shown.
Binary file not shown.
@ -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: - 预览
|
||||
|
||||
@ -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)
|
||||
]
|
||||
|
||||
34
wake/Utils/APIConfig.swift
Normal file
34
wake/Utils/APIConfig.swift
Normal file
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
83
wake/Utils/KeychainHelper.swift
Normal file
83
wake/Utils/KeychainHelper.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
84
wake/Utils/MediaUtils.swift
Normal file
84
wake/Utils/MediaUtils.swift
Normal file
@ -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<UIImage, Error>) -> 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<UIImage, Error>) -> 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
406
wake/Utils/NetworkService.swift
Normal file
406
wake/Utils/NetworkService.swift
Normal file
@ -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<Data, NetworkError>) -> Void, Int)] = []
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - 基础请求方法
|
||||
private func request<T: Decodable>(
|
||||
_ method: String,
|
||||
path: String,
|
||||
parameters: [String: Any]? = nil,
|
||||
headers: [String: String]? = nil,
|
||||
completion: @escaping (Result<T, NetworkError>) -> Void
|
||||
) {
|
||||
// 生成请求ID
|
||||
let requestId = RequestIdentifier.next()
|
||||
|
||||
// 构建URL
|
||||
let fullURL = APIConfig.baseURL + path
|
||||
guard let url = URL(string: fullURL) else {
|
||||
print("❌ [Network][#\(requestId)][\(method) \(path)] 无效的URL")
|
||||
completion(.failure(.invalidURL))
|
||||
return
|
||||
}
|
||||
|
||||
// 创建请求
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method
|
||||
|
||||
// 设置请求头 - 合并默认头、认证头和自定义头
|
||||
defaultHeaders.forEach { key, value in
|
||||
request.setValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
|
||||
// 添加认证头
|
||||
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<T, NetworkError>) in
|
||||
completion(result)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 开始请求
|
||||
task.resume()
|
||||
}
|
||||
|
||||
private func handleResponse<T: Decodable>(
|
||||
requestId: Int,
|
||||
method: String,
|
||||
path: String,
|
||||
data: Data?,
|
||||
response: URLResponse?,
|
||||
error: Error?,
|
||||
request: URLRequest,
|
||||
duration: String,
|
||||
completion: @escaping (Result<T, NetworkError>) -> Void
|
||||
) {
|
||||
// 打印响应信息
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
let statusCode = httpResponse.statusCode
|
||||
let statusMessage = HTTPURLResponse.localizedString(forStatusCode: statusCode)
|
||||
|
||||
// 处理401未授权
|
||||
if statusCode == 401 {
|
||||
print("""
|
||||
🔑 [Network][#\(requestId)][\(method) \(path)] 检测到未授权,尝试刷新token...
|
||||
⏱️ 耗时: \(duration)
|
||||
""")
|
||||
|
||||
// 将请求加入重试队列
|
||||
let dataResult = data.flatMap { Result<Data, NetworkError>.success($0) } ?? .failure(.noData)
|
||||
self.requestsToRetry.append((request, { result in
|
||||
switch result {
|
||||
case .success(let data):
|
||||
do {
|
||||
let decoder = JSONDecoder()
|
||||
let result = try decoder.decode(T.self, from: data)
|
||||
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<T: Decodable>(
|
||||
path: String,
|
||||
parameters: [String: Any]? = nil,
|
||||
headers: [String: String]? = nil,
|
||||
completion: @escaping (Result<T, NetworkError>) -> Void
|
||||
) {
|
||||
request("GET", path: path, parameters: parameters, headers: headers, completion: completion)
|
||||
}
|
||||
|
||||
/// POST 请求
|
||||
func post<T: Decodable>(
|
||||
path: String,
|
||||
parameters: [String: Any]? = nil,
|
||||
headers: [String: String]? = nil,
|
||||
completion: @escaping (Result<T, NetworkError>) -> Void
|
||||
) {
|
||||
request("POST", path: path, parameters: parameters, headers: headers, completion: completion)
|
||||
}
|
||||
|
||||
/// POST 请求(带Token)
|
||||
func postWithToken<T: Decodable>(
|
||||
path: String,
|
||||
parameters: [String: Any]? = nil,
|
||||
headers: [String: String]? = nil,
|
||||
completion: @escaping (Result<T, NetworkError>) -> Void
|
||||
) {
|
||||
var headers = headers ?? [:]
|
||||
if let token = KeychainHelper.getAccessToken() {
|
||||
headers["Authorization"] = "Bearer \(token)"
|
||||
}
|
||||
post(path: path, parameters: parameters, headers: headers, completion: completion)
|
||||
}
|
||||
|
||||
/// DELETE 请求
|
||||
func delete<T: Decodable>(
|
||||
path: String,
|
||||
parameters: [String: Any]? = nil,
|
||||
headers: [String: String]? = nil,
|
||||
completion: @escaping (Result<T, NetworkError>) -> Void
|
||||
) {
|
||||
request("DELETE", path: path, parameters: parameters, headers: headers, completion: completion)
|
||||
}
|
||||
|
||||
/// PUT 请求
|
||||
func put<T: Decodable>(
|
||||
path: String,
|
||||
parameters: [String: Any]? = nil,
|
||||
headers: [String: String]? = nil,
|
||||
completion: @escaping (Result<T, NetworkError>) -> Void
|
||||
) {
|
||||
request("PUT", path: path, parameters: parameters, headers: headers, completion: completion)
|
||||
}
|
||||
}
|
||||
321
wake/Utils/TokenManager.swift
Normal file
321
wake/Utils/TokenManager.swift
Normal file
@ -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<TokenResponse, NetworkError>) 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")
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
301
wake/View/Blind/Box.swift
Normal file
301
wake/View/Blind/Box.swift
Normal file
@ -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..<symbols.count * 3, id: \.self) { index in
|
||||
Circle()
|
||||
.fill(Color.black)
|
||||
.frame(width: 12, height: 12)
|
||||
.offset(y: -75)
|
||||
}
|
||||
}
|
||||
.frame(width: totalWidth * 3),
|
||||
alignment: .leading
|
||||
)
|
||||
.overlay(
|
||||
// 符号内容
|
||||
HStack(spacing: spacing) {
|
||||
ForEach(0..<symbols.count * 3, id: \.self) { index in
|
||||
let actualIndex = index % symbols.count
|
||||
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color.white)
|
||||
.frame(width: itemWidth - 10, height: 100)
|
||||
|
||||
Image(systemName: symbols[actualIndex])
|
||||
.font(.system(size: 30))
|
||||
.foregroundColor(stripColor)
|
||||
.shadow(radius: 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
.offset(x: animate ? -CGFloat(targetIndex) * (itemWidth + spacing) - totalWidth : 0)
|
||||
.frame(width: totalWidth * 3),
|
||||
alignment: .leading
|
||||
)
|
||||
}
|
||||
.frame(height: 180)
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + offset) {
|
||||
withAnimation(
|
||||
.timingCurve(0.2, 0.1, 0.8, 0.9, duration: 3.5)
|
||||
) {
|
||||
animate = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 增强版胶片视图(带边框和阴影)
|
||||
struct EnhancedFilmStrip: 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 = 110
|
||||
let spacing: CGFloat = 10
|
||||
let totalWidth = itemWidth * CGFloat(symbols.count) + spacing * CGFloat(symbols.count - 1)
|
||||
|
||||
ZStack {
|
||||
// 胶片阴影
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color.black.opacity(0.3))
|
||||
.frame(height: 170)
|
||||
.offset(y: 5)
|
||||
.blur(radius: 3)
|
||||
|
||||
// 胶片主体
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(stripColor)
|
||||
.frame(height: 170)
|
||||
.overlay(
|
||||
// 胶片齿孔
|
||||
HStack(spacing: spacing) {
|
||||
ForEach(0..<symbols.count * 3, id: \.self) { index in
|
||||
VStack {
|
||||
Circle()
|
||||
.fill(Color.black)
|
||||
.frame(width: 14, height: 14)
|
||||
.padding(.top, 8)
|
||||
Spacer()
|
||||
Circle()
|
||||
.fill(Color.black)
|
||||
.frame(width: 14, height: 14)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.frame(height: 170)
|
||||
}
|
||||
}
|
||||
.frame(width: totalWidth * 3),
|
||||
alignment: .leading
|
||||
)
|
||||
|
||||
// 符号内容
|
||||
HStack(spacing: spacing) {
|
||||
ForEach(0..<symbols.count * 3, id: \.self) { index in
|
||||
let actualIndex = index % symbols.count
|
||||
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color.white)
|
||||
.frame(width: itemWidth - 15, height: 110)
|
||||
.shadow(color: .black.opacity(0.2), radius: 3, x: 0, y: 2)
|
||||
|
||||
VStack {
|
||||
Image(systemName: symbols[actualIndex])
|
||||
.font(.system(size: 32, weight: .bold))
|
||||
.foregroundColor(stripColor)
|
||||
|
||||
Text("\(actualIndex + 1)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.offset(x: animate ? -CGFloat(targetIndex) * (itemWidth + spacing) - totalWidth : 0)
|
||||
.frame(width: totalWidth * 3)
|
||||
}
|
||||
}
|
||||
.frame(height: 180)
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + offset) {
|
||||
withAnimation(
|
||||
.timingCurve(0.2, 0.1, 0.8, 0.9, duration: 3.5)
|
||||
) {
|
||||
animate = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 预览
|
||||
struct FilmStripView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
FilmStripView()
|
||||
}
|
||||
}
|
||||
|
||||
// 使用增强版的视图
|
||||
struct EnhancedFilmStripView: View {
|
||||
@State private var animate = false
|
||||
private let symbolNames = [
|
||||
"camera.fill", "film.fill", "photo.fill", "heart.fill",
|
||||
"star.fill", "bookmark.fill", "flag.fill", "bell.fill"
|
||||
]
|
||||
private let targetIndices = [2, 5, 3]
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
gradient: Gradient(colors: [Color.blue.opacity(0.8), Color.purple.opacity(0.8)]),
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
|
||||
VStack(spacing: 30) {
|
||||
Text("胶片动效展示")
|
||||
.font(.title)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.white)
|
||||
.padding(.top)
|
||||
|
||||
EnhancedFilmStrip(
|
||||
symbols: symbolNames,
|
||||
targetIndex: targetIndices[0],
|
||||
offset: 0,
|
||||
stripColor: .red
|
||||
)
|
||||
.rotationEffect(.degrees(4))
|
||||
|
||||
EnhancedFilmStrip(
|
||||
symbols: symbolNames,
|
||||
targetIndex: targetIndices[1],
|
||||
offset: 0.4,
|
||||
stripColor: .blue
|
||||
)
|
||||
.rotationEffect(.degrees(-2))
|
||||
|
||||
EnhancedFilmStrip(
|
||||
symbols: symbolNames,
|
||||
targetIndex: targetIndices[2],
|
||||
offset: 0.8,
|
||||
stripColor: .green
|
||||
)
|
||||
.rotationEffect(.degrees(3))
|
||||
|
||||
Button("重新播放") {
|
||||
restartAnimation()
|
||||
}
|
||||
.padding()
|
||||
.background(Color.white)
|
||||
.foregroundColor(.blue)
|
||||
.cornerRadius(10)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
startAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
private func startAnimation() {
|
||||
withAnimation(
|
||||
.timingCurve(0.2, 0.1, 0.8, 0.9, duration: 4.0)
|
||||
) {
|
||||
animate = true
|
||||
}
|
||||
}
|
||||
|
||||
private func restartAnimation() {
|
||||
animate = false
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
startAnimation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 预览增强版
|
||||
struct EnhancedFilmStripView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
EnhancedFilmStripView()
|
||||
}
|
||||
}
|
||||
253
wake/View/Blind/Box1.swift
Normal file
253
wake/View/Blind/Box1.swift
Normal file
@ -0,0 +1,253 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ReplayableFilmReelAnimation: View {
|
||||
// 控制动画状态的变量
|
||||
@State private var animationProgress: CGFloat = 0
|
||||
@State private var isCatching: Bool = false
|
||||
@State private var isDisappearing: Bool = false
|
||||
@State private var showReplayButton: Bool = false
|
||||
|
||||
// 更长的胶卷图片数据(16帧)
|
||||
private let reelImages: [[String]] = [
|
||||
(0..<16).map { "film1-\($0+1)" }, // 上方倾斜胶卷
|
||||
(0..<16).map { "film2-\($0+1)" }, // 中间正胶卷
|
||||
(0..<16).map { "film3-\($0+1)" } // 下方倾斜胶卷
|
||||
]
|
||||
|
||||
// 两边胶卷的倾斜角度
|
||||
private let topTiltAngle: Double = -7 // 上方胶卷左倾
|
||||
private let bottomTiltAngle: Double = 7 // 下方胶卷右倾
|
||||
|
||||
// 最终要突出显示的图片索引
|
||||
private let targetImageIndex: Int = 8
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// 深色背景增强胶片质感
|
||||
Color(red: 0.08, green: 0.08, blue: 0.08).edgesIgnoringSafeArea(.all)
|
||||
|
||||
// 胶卷层 - 中间正胶卷,上下各一个倾斜胶卷
|
||||
ZStack {
|
||||
// 上方倾斜胶卷(向右移动)
|
||||
FilmReelView1(images: reelImages[0])
|
||||
.rotationEffect(Angle(degrees: topTiltAngle))
|
||||
.offset(x: calculateTopOffset(), y: -200)
|
||||
.opacity(isDisappearing ? 0 : 1)
|
||||
.zIndex(1)
|
||||
|
||||
// 中间正胶卷(向左移动)
|
||||
FilmReelView1(images: reelImages[1])
|
||||
.offset(x: calculateMiddleOffset(), y: 0)
|
||||
.scaleEffect(isCatching ? 1.03 : 1.0)
|
||||
.opacity(isDisappearing ? 0 : 1)
|
||||
.zIndex(2)
|
||||
|
||||
// 下方倾斜胶卷(向右移动)
|
||||
FilmReelView1(images: reelImages[2])
|
||||
.rotationEffect(Angle(degrees: bottomTiltAngle))
|
||||
.offset(x: calculateBottomOffset(), y: 200)
|
||||
.opacity(isDisappearing ? 0 : 1)
|
||||
.zIndex(1)
|
||||
|
||||
// 最终显示的图片
|
||||
if isDisappearing {
|
||||
Image(reelImages[1][targetImageIndex])
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding()
|
||||
.transition(.opacity.combined(with: .scale))
|
||||
.zIndex(3)
|
||||
}
|
||||
}
|
||||
|
||||
// 重复播放按钮
|
||||
if showReplayButton {
|
||||
Button(action: resetAndReplay) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.black.opacity(0.7))
|
||||
.frame(width: 60, height: 60)
|
||||
.shadow(radius: 10)
|
||||
|
||||
Image(systemName: "arrow.counterclockwise")
|
||||
.foregroundColor(.white)
|
||||
.font(.system(size: 24))
|
||||
}
|
||||
}
|
||||
.position(x: UIScreen.main.bounds.width - 40, y: 40)
|
||||
.transition(.opacity.combined(with: .scale))
|
||||
.zIndex(4)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
startAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
// 重置并重新播放动画
|
||||
private func resetAndReplay() {
|
||||
withAnimation(.easeInOut(duration: 0.5)) {
|
||||
showReplayButton = false
|
||||
isDisappearing = false
|
||||
isCatching = false
|
||||
animationProgress = 0
|
||||
}
|
||||
|
||||
// 延迟一小段时间后重新启动动画
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
startAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
// 上方倾斜胶卷偏移量计算(向右移动)
|
||||
private func calculateTopOffset() -> 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()
|
||||
}
|
||||
}
|
||||
|
||||
226
wake/View/Blind/Box3.swift
Normal file
226
wake/View/Blind/Box3.swift
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
140
wake/View/Blind/Box4.swift
Normal file
140
wake/View/Blind/Box4.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
222
wake/View/Blind/Box5.swift
Normal file
222
wake/View/Blind/Box5.swift
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
250
wake/View/Blind/Box6.swift
Normal file
250
wake/View/Blind/Box6.swift
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
95
wake/View/Components/AppleSignInButton.swift
Normal file
95
wake/View/Components/AppleSignInButton.swift
Normal file
@ -0,0 +1,95 @@
|
||||
import SwiftUI
|
||||
import AuthenticationServices
|
||||
import CryptoKit
|
||||
|
||||
/// 自定义的 Apple 登录按钮组件
|
||||
struct AppleSignInButton: View {
|
||||
// MARK: - 属性
|
||||
|
||||
/// 授权请求回调
|
||||
let onRequest: (ASAuthorizationAppleIDRequest) -> Void
|
||||
|
||||
/// 授权完成回调
|
||||
let onCompletion: (Result<ASAuthorization, Error>) -> Void
|
||||
|
||||
/// 按钮文字
|
||||
let buttonText: String
|
||||
|
||||
// MARK: - 初始化方法
|
||||
|
||||
init(buttonText: String = "Continue with Apple",
|
||||
onRequest: @escaping (ASAuthorizationAppleIDRequest) -> Void,
|
||||
onCompletion: @escaping (Result<ASAuthorization, Error>) -> 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<ASAuthorization, Error>) -> Void
|
||||
|
||||
init(onCompletion: @escaping (Result<ASAuthorization, Error>) -> Void) {
|
||||
self.onCompletion = onCompletion
|
||||
}
|
||||
|
||||
// 授权成功回调
|
||||
func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
|
||||
onCompletion(.success(authorization))
|
||||
}
|
||||
|
||||
// 授权失败回调
|
||||
func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
|
||||
onCompletion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
70
wake/View/Components/Upload/ImageCaptureView.swift
Normal file
70
wake/View/Components/Upload/ImageCaptureView.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
176
wake/View/Components/Upload/ImageMultiUploader.swift
Normal file
176
wake/View/Components/Upload/ImageMultiUploader.swift
Normal file
@ -0,0 +1,176 @@
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
import os
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
public struct MultiImageUploader<Content: View>: 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<Bool>,
|
||||
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<Bool>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
58
wake/View/Components/Upload/ImagePicker.swift
Normal file
58
wake/View/Components/Upload/ImagePicker.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
404
wake/View/Components/Upload/ImageUploadService.swift
Normal file
404
wake/View/Components/Upload/ImageUploadService.swift
Normal file
@ -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<ImageUploaderGetID.UploadResult, Error>) -> 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<ImageUploaderGetID.UploadResult, Error>) -> 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<ImageUploadService.UploadResults, Error>) -> 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<MediaUploadResult, Error>) -> 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<MediaUploadResult, Error>) -> 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
612
wake/View/Components/Upload/ImageUploaderGetID.swift
Normal file
612
wake/View/Components/Upload/ImageUploaderGetID.swift
Normal file
@ -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<UploadResult, Error>) -> 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<UploadResult, Error>) -> 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<UploadResult, Error>) -> 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, Error>) -> 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
303
wake/View/Components/Upload/MediaPicker.swift
Normal file
303
wake/View/Components/Upload/MediaPicker.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
327
wake/View/Components/Upload/MediaUpload.swift
Normal file
327
wake/View/Components/Upload/MediaUpload.swift
Normal file
@ -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..<uploadManager.selectedMedia.count, id: \.self) { index in
|
||||
let media = uploadManager.selectedMedia[index]
|
||||
let mediaId = "\(index)"
|
||||
let status = uploadManager.uploadStatus[mediaId] ?? .pending
|
||||
|
||||
HStack {
|
||||
// 缩略图
|
||||
MediaThumbnailView(media: media, onDelete: nil)
|
||||
.frame(width: 60, height: 60)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(media.isVideo ? "视频" : "图片")
|
||||
.font(.subheadline)
|
||||
|
||||
// 上传状态
|
||||
Text(status.description)
|
||||
.font(.caption)
|
||||
.foregroundColor(statusColor(status))
|
||||
|
||||
// 上传进度条
|
||||
if case .uploading(let progress) = status {
|
||||
ProgressView(value: progress, total: 1.0)
|
||||
.progressViewStyle(LinearProgressViewStyle())
|
||||
.frame(height: 4)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
indexSet.forEach { index in
|
||||
uploadManager.removeMedia(at: index)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 300)
|
||||
}
|
||||
.padding(.top)
|
||||
} else {
|
||||
Text("未选择任何媒体")
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.top, 50)
|
||||
}
|
||||
}
|
||||
|
||||
private func statusColor(_ status: MediaUploadStatus) -> 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)
|
||||
}
|
||||
187
wake/View/Examples/MediaDemo.swift
Normal file
187
wake/View/Examples/MediaDemo.swift
Normal file
@ -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..<uploadManager.selectedMedia.count, id: \.self) { index in
|
||||
MediaItemView(
|
||||
media: uploadManager.selectedMedia[index],
|
||||
status: uploadManager.uploadStatus["\(index)"] ?? .pending
|
||||
)
|
||||
.onTapGesture {
|
||||
// 点击查看大图或播放视频
|
||||
if case .video = uploadManager.selectedMedia[index] {
|
||||
// 处理视频播放
|
||||
print("Play video at index \(index)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
// 显示上传状态
|
||||
if isUploading {
|
||||
VStack {
|
||||
// 显示上传进度
|
||||
if let progress = uploadManager.uploadStatus.values.compactMap({ status -> 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)
|
||||
}
|
||||
162
wake/View/Examples/MultiImageUploadExampleView.swift
Normal file
162
wake/View/Examples/MultiImageUploadExampleView.swift
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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<ASAuthorization, Error>) {
|
||||
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<AuthResponse, NetworkError>) 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 {
|
||||
|
||||
202
wake/View/Owner/UserInfo/AvatarPicker.swift
Normal file
202
wake/View/Owner/UserInfo/AvatarPicker.swift
Normal file
@ -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<UIImage?>,
|
||||
showUsername: Binding<Bool>,
|
||||
isKeyboardVisible: Binding<Bool>,
|
||||
uploadedFileId: Binding<String?>) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
30
wake/View/Owner/UserInfo/CameraView.swift
Normal file
30
wake/View/Owner/UserInfo/CameraView.swift
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<UserInfoResponse, NetworkError>) 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
42
wake/View/Welcome/SplashView.swift
Normal file
42
wake/View/Welcome/SplashView.swift
Normal file
@ -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
|
||||
@ -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 = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<style>
|
||||
body, html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div style="width:100%; height:100%; display:flex; align-items:center; justify-content:center;">
|
||||
<img src="\(url.absoluteString)" style="max-width:100%; max-height:100%;" />
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
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 = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<style>
|
||||
body, html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background-color: transparent;
|
||||
}
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div style="width:100%; height:100%; display:flex; align-items:center; justify-content:center;">
|
||||
<img src="\(fileURL.lastPathComponent)" />
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
// 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")
|
||||
}
|
||||
@ -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
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user