feat: 上传组件

This commit is contained in:
Junhui Chen 2025-08-19 15:24:53 +08:00 committed by jinyaqiu
parent abdcea18c2
commit 2087173c79
49 changed files with 5978 additions and 369 deletions

BIN
.DS_Store vendored

Binary file not shown.

BIN
wake/.DS_Store vendored

Binary file not shown.

BIN
wake/Assets/.DS_Store vendored Normal file

Binary file not shown.

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

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

View File

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

Binary file not shown.

View File

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

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

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

View 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

Binary file not shown.

Binary file not shown.

View File

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

View File

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

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

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

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

View File

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

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

View File

@ -0,0 +1,321 @@
import Foundation
/// Token
///
class TokenManager {
///
static let shared = TokenManager()
/// tokentoken
/// 300token5
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: tokentruefalse
///
/// tokentokentoken
///
/// - Note: tokentoken
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)")
// 200token
isValid = true
print(" TokenManager: 状态码200假设token有效")
}
} else {
// 200token
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? {
// JWTtoken
// 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")
}

View File

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

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

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

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

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

View 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, // 200100 + 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, // 200100 + 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)
/// IDID
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))
}
}
}

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

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

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

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

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

View File

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

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

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

View File

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

View File

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

View 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

View File

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

View File

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