fixed some bugs
This commit is contained in:
167
ios/OralTrainer/Services/TrainingHistoryStore.swift
Normal file
167
ios/OralTrainer/Services/TrainingHistoryStore.swift
Normal file
@@ -0,0 +1,167 @@
|
||||
import Foundation
|
||||
|
||||
struct PersistedRecording {
|
||||
let audioURL: URL
|
||||
let rawURL: URL
|
||||
}
|
||||
|
||||
struct TrainingHistorySnapshot {
|
||||
var audioSegments: [Int: URL] = [:]
|
||||
var rawSegments: [Int: URL] = [:]
|
||||
var scores: [Int: ImitationAssessmentResult] = [:]
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class TrainingHistoryManager: ObservableObject {
|
||||
@Published private(set) var historyVersion = 0
|
||||
let store = TrainingHistoryStore()
|
||||
|
||||
func notifyHistoryChanged() {
|
||||
historyVersion += 1
|
||||
}
|
||||
}
|
||||
|
||||
final class TrainingHistoryStore {
|
||||
private let root: URL
|
||||
|
||||
init() {
|
||||
let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
root = documents.appendingPathComponent("TrainingHistory", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
func normalizedVideoHash(_ videoHash: String) -> String {
|
||||
let allowed = CharacterSet(charactersIn: "0123456789abcdefABCDEF")
|
||||
let filtered = videoHash.lowercased().unicodeScalars.filter { allowed.contains($0) }
|
||||
return String(String.UnicodeScalarView(filtered))
|
||||
}
|
||||
|
||||
private var userDirectory: URL {
|
||||
let identifier = normalizedVideoHash(AppConfig.currentUserId)
|
||||
return root.appendingPathComponent(identifier.isEmpty ? "anonymous" : identifier, isDirectory: true)
|
||||
}
|
||||
|
||||
func load(videoHash: String) -> TrainingHistorySnapshot {
|
||||
let directory = courseDirectory(videoHash)
|
||||
var snapshot = TrainingHistorySnapshot()
|
||||
for index in 0..<500 {
|
||||
let audio = directory.appendingPathComponent(String(format: "segment-%04d.m4a", index))
|
||||
let raw = directory.appendingPathComponent(String(format: "raw-%04d.caf", index))
|
||||
if FileManager.default.fileExists(atPath: audio.path) {
|
||||
snapshot.audioSegments[index] = audio
|
||||
}
|
||||
if FileManager.default.fileExists(atPath: raw.path) {
|
||||
snapshot.rawSegments[index] = raw
|
||||
}
|
||||
}
|
||||
|
||||
let scoreFile = directory.appendingPathComponent("scores.json")
|
||||
if let data = try? Data(contentsOf: scoreFile),
|
||||
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||
for (key, value) in object {
|
||||
guard let index = Int(key), index >= 0 else { continue }
|
||||
if let payload = value as? [String: Any],
|
||||
let payloadData = try? JSONSerialization.data(withJSONObject: payload),
|
||||
let score = try? JSONDecoder().decode(ImitationAssessmentResult.self, from: payloadData) {
|
||||
snapshot.scores[index] = score
|
||||
}
|
||||
}
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func saveSegment(
|
||||
videoHash: String,
|
||||
sentenceIndex: Int,
|
||||
audioURL: URL,
|
||||
rawURL: URL
|
||||
) throws -> PersistedRecording {
|
||||
let directory = courseDirectory(videoHash)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let savedAudio = directory.appendingPathComponent(
|
||||
String(format: "segment-%04d.m4a", sentenceIndex)
|
||||
)
|
||||
let savedRaw = directory.appendingPathComponent(
|
||||
String(format: "raw-%04d.caf", sentenceIndex)
|
||||
)
|
||||
try? FileManager.default.removeItem(at: savedAudio)
|
||||
try? FileManager.default.removeItem(at: savedRaw)
|
||||
try FileManager.default.moveItem(at: audioURL, to: savedAudio)
|
||||
try FileManager.default.moveItem(at: rawURL, to: savedRaw)
|
||||
return PersistedRecording(audioURL: savedAudio, rawURL: savedRaw)
|
||||
}
|
||||
|
||||
func audioDestination(videoHash: String, sentenceIndex: Int) -> URL {
|
||||
courseDirectory(videoHash).appendingPathComponent(
|
||||
String(format: "segment-%04d.m4a", sentenceIndex)
|
||||
)
|
||||
}
|
||||
|
||||
func saveScores(
|
||||
videoHash: String,
|
||||
scores: [Int: ImitationAssessmentResult]
|
||||
) {
|
||||
let directory = courseDirectory(videoHash)
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
var object: [String: Any] = [:]
|
||||
for (index, score) in scores {
|
||||
object[String(index)] = Self.payload(score)
|
||||
}
|
||||
let data = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys])
|
||||
try data.write(
|
||||
to: directory.appendingPathComponent("scores.json"),
|
||||
options: [.atomic]
|
||||
)
|
||||
} catch {
|
||||
// History persistence is best-effort; the server remains the source of record.
|
||||
}
|
||||
}
|
||||
|
||||
func removeScore(videoHash: String, sentenceIndex: Int) {
|
||||
var snapshot = load(videoHash: videoHash)
|
||||
snapshot.scores[sentenceIndex] = nil
|
||||
saveScores(videoHash: videoHash, scores: snapshot.scores)
|
||||
}
|
||||
|
||||
func removeAllAudio(videoHash: String) {
|
||||
let snapshot = load(videoHash: videoHash)
|
||||
for url in snapshot.audioSegments.values {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
for url in snapshot.rawSegments.values {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
|
||||
private func courseDirectory(_ videoHash: String) -> URL {
|
||||
userDirectory.appendingPathComponent(normalizedVideoHash(videoHash), isDirectory: true)
|
||||
}
|
||||
|
||||
private static func payload(_ score: ImitationAssessmentResult) -> [String: Any] {
|
||||
var value: [String: Any] = ["overall_score": score.overallScore]
|
||||
if let item = score.passed { value["passed"] = item }
|
||||
if let item = score.passScore { value["pass_score"] = item }
|
||||
if let item = score.contentScore { value["content_score"] = item }
|
||||
if let item = score.completenessScore { value["completeness_score"] = item }
|
||||
if let item = score.fluencyScore { value["fluency_score"] = item }
|
||||
if let item = score.pronunciationScore { value["pronunciation_score"] = item }
|
||||
if let item = score.prosodyScore { value["prosody_score"] = item }
|
||||
if let item = score.durationScore { value["duration_score"] = item }
|
||||
if let item = score.pauseScore { value["pause_score"] = item }
|
||||
if let item = score.speechRateScore { value["speech_rate_score"] = item }
|
||||
if let item = score.referenceText { value["reference_text"] = item }
|
||||
if let item = score.recognizedText { value["recognized_text"] = item }
|
||||
if let item = score.durationRatio { value["duration_ratio"] = item }
|
||||
if !score.missingTokens.isEmpty { value["missing_tokens"] = score.missingTokens }
|
||||
if !score.extraTokens.isEmpty { value["extra_tokens"] = score.extraTokens }
|
||||
if !score.substitutions.isEmpty {
|
||||
value["substitutions"] = score.substitutions.map {
|
||||
["expected": $0.expected, "actual": $0.actual]
|
||||
}
|
||||
}
|
||||
if let item = score.feedback { value["feedback"] = item }
|
||||
if !score.details.isEmpty { value["details"] = score.details }
|
||||
return value
|
||||
}
|
||||
}
|
||||
177
ios/OralTrainer/Services/UserHistory.swift
Normal file
177
ios/OralTrainer/Services/UserHistory.swift
Normal file
@@ -0,0 +1,177 @@
|
||||
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<T: Decodable>(
|
||||
_ 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)"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user