import Foundation struct UserResultSummary: Identifiable, Decodable { let attemptId: String let videoHash: String let courseTitle: String let sentenceIndex: Int let sentenceText: String let overallScore: Float let passed: Bool let createdAt: String var id: String { attemptId } private enum CodingKeys: String, CodingKey { case overallScore = "overall_score" case courseTitle = "course_title" case sentenceIndex = "sentence_index" case sentenceText = "sentence_text" case createdAt = "created_at" case attemptId = "attempt_id" case videoHash = "video_hash" case passed } } struct UserDubShareSummary: Identifiable, Decodable { let shareId: String let videoHash: String let courseTitle: String let title: String let segmentCount: Int let averageScore: Float let createdAt: String var id: String { shareId } private enum CodingKeys: String, CodingKey { case averageScore = "average_score" case courseTitle = "course_title" case segmentCount = "segment_count" case createdAt = "created_at" case shareId = "share_id" case videoHash = "video_hash" case title } } struct DubShareSegmentDetail: Decodable { let index: Int let overallScore: Float let recognizedText: String let scoreDetails: [String: Float] let audioURL: String private enum CodingKeys: String, CodingKey { case overallScore = "overall_score" case recognizedText = "recognized_text" case scoreDetails = "score_details" case audioURL = "audio_url" case index } } struct DubShareDetail: Decodable { let shareId: String let videoHash: String let title: String let segments: [DubShareSegmentDetail] private enum CodingKeys: String, CodingKey { case shareId = "share_id" case videoHash = "video_hash" case title case segments } } enum UserHistoryError: LocalizedError { case badStatus(Int, String) var errorDescription: String? { switch self { case .badStatus(let code, let body): return "HTTP \(code): \(body.prefix(200))" } } } enum UserHistoryAPI { static func results(token: String) async throws -> [UserResultSummary] { try await getJSON([UserResultSummary].self, path: "api/v1/me/results", token: token) } static func dubShares(token: String) async throws -> [UserDubShareSummary] { try await getJSON([UserDubShareSummary].self, path: "api/v1/me/dub-shares", token: token) } static func dubShareDetail(token: String, shareId: String) async throws -> DubShareDetail { try await getJSON( DubShareDetail.self, path: "api/v1/dub-shares/\(shareId)", token: token ) } static func deleteResult(token: String, attemptId: String) async throws { try await delete(path: "api/v1/me/results/\(attemptId)", token: token) } static func deleteDubShare(token: String, shareId: String) async throws { try await delete(path: "api/v1/me/dub-shares/\(shareId)", token: token) } static func downloadAudio( token: String, audioPath: String, destination: URL ) async throws { let result = try await HTTPClient.execute( baseURL: AppConfig.serverBaseURL, path: audioPath, method: "GET", timeout: AppConfig.readTimeout, headers: authorizationHeaders(token: token) ) guard (200...299).contains(result.statusCode) else { throw UserHistoryError.badStatus( result.statusCode, String(data: result.body, encoding: .utf8) ?? "" ) } try? FileManager.default.removeItem(at: destination) try result.body.write(to: destination, options: .atomic) } private static func getJSON( _ type: T.Type, path: String, token: String ) async throws -> T { let result = try await HTTPClient.execute( baseURL: AppConfig.serverBaseURL, path: path, method: "GET", timeout: AppConfig.readTimeout, headers: authorizationHeaders(token: token) ) guard (200...299).contains(result.statusCode) else { throw UserHistoryError.badStatus( result.statusCode, String(data: result.body, encoding: .utf8) ?? "" ) } return try JSONDecoder().decode(T.self, from: result.body) } private static func delete(path: String, token: String) async throws { let result = try await HTTPClient.execute( baseURL: AppConfig.serverBaseURL, path: path, method: "DELETE", timeout: AppConfig.readTimeout, headers: authorizationHeaders(token: token) ) guard (200...299).contains(result.statusCode) else { throw UserHistoryError.badStatus( result.statusCode, String(data: result.body, encoding: .utf8) ?? "" ) } } private static func authorizationHeaders(token: String) -> [String: String] { token.isEmpty ? [:] : ["Authorization": "Bearer \(token)"] } }