import SwiftUI import AVFoundation import Combine import UIKit let API_BASE = "https://coach.cubecast.app" // MARK: - Models struct Script: Identifiable, Codable { let id: Int let title: String let content: String let language: String? let difficulty: Int? let translation: String? } // MARK: - Practice phase state machine enum PracticePhase: Equatable { case idle case loading // /speak fired; waiting for audio case listen // audio ready, play button live; /analyze running in bg case playing // listening to source; auto-advances to .ready when done case ready // green pulsing record button case recording // user recording + source replays muted case analyzing // parallel /harmony + /analyze for user audio case comparing(Int) // score + synchronized dual visualization } // MARK: - ContentView struct ContentView: View { var token: String = "" @EnvironmentObject var appState: AppState @State private var phase: PracticePhase = .idle @State private var scriptText = "" // Audio @State private var audioRecorder: AVAudioRecorder? @State private var audioPlayer: AVAudioPlayer? @State private var mimioAudioData: Data? = nil @State private var userAudioURL: URL? = nil @State private var isMuted = false @State private var playbackSpeed: Double = 1.0 // Source transcript @State private var wordData: [WordData] = [] @State private var isSourceAnalyzing = false @State private var sourcePosition: Double = 0 // User transcript @State private var userWordData: [WordData] = [] // Compare-phase synchronized playback clock @State private var comparePosition: Double = 0 @State private var compareIsPlaying = false // Scripts sheet @State private var showScripts = false // Language @State private var selectedLanguage = "EN" private let languages = ["EN", "FR", "ES", "PT", "IT", "RO", "LA"] // Level system @State private var currentLevel: Int = 1 @State private var scriptTranslation = "" @State private var allSentences: [[String: String]] = [] @State private var scriptAudioURL: String = "" // Teacher feedback @State private var teacherFeedback: String = "" @State private var isFetchingFeedback: Bool = false @State private var attemptCount: Int = 0 // Quote drain overlay @State private var showQuoteCard = false @State private var activeQuote = LangQuote(text: "", author: "") // Timer — fires every 50 ms; drives all position state private let positionTimer = Timer.publish(every: 0.05, on: .main, in: .common).autoconnect() // Design tokens let accentColor = Color(hex: "#FF9500") // true orange let bgColor = Color(hex: "#080810") // near-black, blue tint let surfaceColor = Color(hex: "#0d1421") let borderColor = Color(hex: "#1a2535") let textColor = Color.white let mutedColor = Color(hex: "#475569") let greenColor = Color(hex: "#34C759") let redColor = Color(hex: "#ef4444") let blueColor = Color(hex: "#3D8EF0") // brighter blue let translationColor = Color(hex: "#7EB8F7") // light blue for translations private var deviceId: String { UIDevice.current.identifierForVendor?.uuidString ?? "unknown" } /// End time for the current level — player stops here instead of at file end. private var currentEndTimeSec: Double { guard currentLevel - 1 < allSentences.count else { return 0 } return Double(Int(allSentences[currentLevel - 1]["end_time_ms"] ?? "0") ?? 0) / 1000.0 } /// Only the word-data up to the current level's end time. private var currentLevelWordData: [WordData] { wordData.filter { $0.end <= currentEndTimeSec + 0.5 } } private var isComparing: Bool { if case .comparing(_) = phase { return true } return false } // MARK: - Body var body: some View { ZStack { LinearGradient( colors: [Color(hex: "#0A0A1A"), Color(hex: "#0D1B2A")], startPoint: .top, endPoint: .bottom ) .ignoresSafeArea() Rectangle() .fill(Color.white.opacity(0.02)) .blendMode(.overlay) .ignoresSafeArea() VStack(spacing: 0) { headerView scriptAreaView .frame(maxWidth: .infinity, maxHeight: .infinity) // Bottom bar hidden during compare — score/buttons live inside the scroll view if !isComparing { bottomControlsView .frame(maxWidth: .infinity) .padding(.horizontal, 20) .padding(.top, 20) .padding(.bottom, 36) .animation(.easeInOut(duration: 0.22), value: phase) } } // Quote drain overlay — shown during script generation (slow 10×TTS call) if showQuoteCard { WaterDrainView(quote: activeQuote) { showQuoteCard = false } .zIndex(10) } } .onAppear { setupAudio() if appState.showScriptsOnAppear { showScripts = true appState.showScriptsOnAppear = false } } .onReceive(positionTimer) { _ in // Track source audio position during listen playback if let player = audioPlayer { if phase == .playing { sourcePosition = player.currentTime let endSec = currentEndTimeSec // Stop at sentence boundary (or when file ends naturally) if (endSec > 0 && sourcePosition >= endSec) || !player.isPlaying { player.stop() phase = .ready } } else if phase == .recording { sourcePosition = player.currentTime let endSec = currentEndTimeSec // Pause muted source at sentence boundary if endSec > 0 && sourcePosition >= endSec && player.isPlaying { player.pause() } } } // Advance compare clock if case .comparing(_) = phase, compareIsPlaying { let maxDur = max(currentLevelWordData.last?.end ?? 0, userWordData.last?.end ?? 0) if comparePosition < maxDur + 0.3 { comparePosition += 0.05 * playbackSpeed } else { compareIsPlaying = false } } } .onChange(of: playbackSpeed) { _, newSpeed in if phase == .playing || phase == .recording { audioPlayer?.rate = Float(newSpeed) } } .sheet(isPresented: $showScripts) { ScriptsSheet( allSentences: $allSentences, scriptAudioURL: $scriptAudioURL, showQuoteCard: $showQuoteCard, activeQuote: $activeQuote, selectedLanguage: selectedLanguage, token: token, accentColor: accentColor, bgColor: bgColor, surfaceColor: surfaceColor, borderColor: borderColor, textColor: textColor, mutedColor: mutedColor, onSelect: { scriptText = allSentences.first?["text"] ?? "" scriptTranslation = allSentences.first?["translation"] ?? "" currentLevel = 1 showScripts = false loadAudioFromURL(scriptAudioURL) // one audio file for whole script }, onDismiss: { showScripts = false } ) } } // MARK: - Header @ViewBuilder var headerView: some View { HStack(spacing: 0) { VStack(alignment: .leading, spacing: 2) { Text("MIMIO") .font(.system(size: 26, weight: .black, design: .monospaced)) .foregroundColor(accentColor) .tracking(4) Text("hear yourself, clearer") .font(.system(size: 11, design: .monospaced)) .foregroundColor(translationColor) .tracking(1) } Spacer() // Language dropdown Menu { ForEach(languages, id: \.self) { lang in Button { guard lang != selectedLanguage else { return } selectedLanguage = lang if !scriptText.isEmpty { loadMimioAudio() } } label: { HStack { Text(lang) if lang == selectedLanguage { Image(systemName: "checkmark") } } } } } label: { HStack(spacing: 4) { Text(selectedLanguage) .font(.system(size: 11, weight: .bold, design: .monospaced)) .tracking(1) .foregroundColor(accentColor) Image(systemName: "chevron.down") .font(.system(size: 9, weight: .medium)) .foregroundColor(accentColor.opacity(0.8)) } .padding(.horizontal, 10) .padding(.vertical, 6) .background(surfaceColor) .overlay(RoundedRectangle(cornerRadius: 12).stroke(accentColor.opacity(0.45), lineWidth: 1)) .cornerRadius(12) } // LVL badge — gradient blue pill with glow VStack(spacing: 0) { Text("LVL") .font(.system(size: 7, weight: .bold, design: .monospaced)) .foregroundColor(.white) .tracking(1) Text("\(currentLevel)") .font(.system(size: 14, weight: .bold, design: .monospaced)) .foregroundColor(.white) } .padding(.horizontal, 10) .padding(.vertical, 5) .background( LinearGradient(colors: [blueColor, Color(hex: "#2D6FD4")], startPoint: .top, endPoint: .bottom) ) .cornerRadius(8) .shadow(color: .blue.opacity(0.5), radius: 8) .padding(.horizontal, 10) .animation(.easeInOut(duration: 0.3), value: currentLevel) Button { showScripts = true } label: { HStack(spacing: 5) { Image(systemName: "text.alignleft").font(.system(size: 11)) Text("Scripts").font(.system(size: 11, design: .monospaced)) } .foregroundColor(.white) .padding(.horizontal, 12) .padding(.vertical, 7) .background(surfaceColor) .overlay(RoundedRectangle(cornerRadius: 16).stroke(accentColor, lineWidth: 1)) .cornerRadius(16) } .padding(.leading, 6) Button { appState.logout() } label: { Text("LOGOUT") .font(.system(size: 10, weight: .medium, design: .monospaced)) .tracking(1) .foregroundColor(mutedColor) } .padding(.leading, 12) } .padding(.horizontal, 20) .padding(.vertical, 14) .background(surfaceColor) .overlay( LinearGradient(colors: [blueColor, accentColor], startPoint: .leading, endPoint: .trailing) .frame(height: 1.5), alignment: .bottom ) } // MARK: - Script area @ViewBuilder var scriptAreaView: some View { if scriptText.isEmpty { VStack(spacing: 14) { Spacer() Image(systemName: "doc.text") .font(.system(size: 38)) .foregroundColor(borderColor) Text("TAP SCRIPTS TO BEGIN") .font(.system(size: 11, weight: .bold, design: .monospaced)) .foregroundColor(mutedColor) .tracking(3) Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) } else if phase == .analyzing { // Gap between stop and compare — pulsing dots, no other UI AnalyzingPulseView() .transition(.opacity) } else if phase == .recording { // Phase 3: blue source transcript animating while user records ScrollView { VStack(alignment: .leading, spacing: 0) { // LISTEN AGAIN — replay target audio audibly while recording Button { replayAudio() } label: { HStack(spacing: 5) { Image(systemName: "play.circle") .font(.system(size: 13)) .foregroundColor(accentColor) Text("LISTEN AGAIN") .font(.system(size: 9, weight: .bold, design: .monospaced)) .tracking(1) .foregroundColor(accentColor) } .padding(.horizontal, 10) .padding(.vertical, 5) .overlay(Capsule().stroke(accentColor, lineWidth: 1.5)) } .frame(maxWidth: .infinity, alignment: .trailing) .padding(.horizontal, 20) .padding(.top, 12) if wordData.isEmpty { VStack(alignment: .leading, spacing: 0) { Spacer(minLength: 0) interleavedScriptContent(targetColor: blueColor.opacity(0.5)) Spacer(minLength: 0) } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(.horizontal, 20) } else { TranscriptView( words: currentLevelWordData, playbackPosition: sourcePosition, tintColor: blueColor ) } if !scriptTranslation.isEmpty { translationBlock(scriptTranslation) .padding(.horizontal, 20) .padding(.bottom, 10) } } } } else if isComparing { // Phase 4: synchronized dual visualization + score ring + buttons comparisonVisualization .transition(.move(edge: .bottom).combined(with: .opacity)) } else { // Static script text (loading / listen / playing / ready) // Vertically centred: eliminates dead space for short Level 1–2 scripts. VStack(alignment: .leading, spacing: 0) { // LISTEN AGAIN — top-right, visible in YOUR TURN phase if phase == .ready { Button { playMimio() } label: { HStack(spacing: 5) { Image(systemName: "play.circle") .font(.system(size: 13)) .foregroundColor(accentColor) Text("LISTEN AGAIN") .font(.system(size: 9, weight: .bold, design: .monospaced)) .tracking(1) .foregroundColor(accentColor) } .padding(.horizontal, 10) .padding(.vertical, 5) .overlay(Capsule().stroke(accentColor, lineWidth: 1.5)) } .frame(maxWidth: .infinity, alignment: .trailing) .padding(.top, 12) .disabled(mimioAudioData == nil) .opacity(mimioAudioData == nil ? 0.3 : 1) } Spacer(minLength: 0) interleavedScriptContent(targetColor: textColor) Spacer(minLength: 0) } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(.horizontal, 20) .animation(.easeInOut(duration: 0.3), value: scriptText) } } // MARK: - Compare visualization (Phase 4) // Marked-up essay view: source words colored by how well the user matched each one. // Good match → normal. Moderate → amber underline. Poor/missed → bold red underline. @ViewBuilder var comparisonVisualization: some View { ScrollView { VStack(alignment: .leading, spacing: 0) { // ── LISTEN AGAIN ───────────────────────────────────── HStack { Spacer() Button { replayAudio() } label: { HStack(spacing: 5) { Image(systemName: "play.circle") .font(.system(size: 13)) .foregroundColor(accentColor) Text("LISTEN AGAIN") .font(.system(size: 9, weight: .bold, design: .monospaced)) .tracking(1) .foregroundColor(accentColor) } .padding(.horizontal, 10) .padding(.vertical, 5) .overlay(Capsule().stroke(accentColor, lineWidth: 1.5)) } } .padding(.horizontal, 20) .padding(.top, 16) // ── Header ─────────────────────────────────────────── HStack(spacing: 16) { HStack(spacing: 5) { Circle().fill(greenColor).frame(width: 5, height: 5) Text("GOOD") .font(.system(size: 8, weight: .bold, design: .monospaced)) .foregroundColor(mutedColor).tracking(1) } HStack(spacing: 5) { RoundedRectangle(cornerRadius: 1).fill(accentColor) .frame(width: 14, height: 2) Text("REVIEW") .font(.system(size: 8, weight: .bold, design: .monospaced)) .foregroundColor(mutedColor).tracking(1) } HStack(spacing: 5) { RoundedRectangle(cornerRadius: 1).fill(redColor) .frame(width: 14, height: 2) Text("FOCUS") .font(.system(size: 8, weight: .bold, design: .monospaced)) .foregroundColor(mutedColor).tracking(1) } } .padding(.horizontal, 20) .padding(.top, 16) .padding(.bottom, 4) // ── Marked transcript ──────────────────────────────── MarkedTranscriptView( sourceWords: currentLevelWordData, userWords: userWordData, textColor: textColor, mutedColor: mutedColor, accentColor: accentColor, redColor: redColor ) // ── Score ring + buttons ───────────────────────────── if case .comparing(let score) = phase { compareBottomSection(score: score) } } } } @ViewBuilder func compareBottomSection(score: Int) -> some View { // Boost display score by attempt — raw score goes to backend unchanged let displayScore: Int = { switch attemptCount { case 2: return min(99, score + 5) case 3: return min(99, score + 10) default: return score } }() VStack(spacing: 18) { Rectangle().frame(height: 1) .foregroundColor(borderColor) .padding(.horizontal, 20) // Teacher feedback block if isFetchingFeedback { HStack(spacing: 8) { ProgressView().scaleEffect(0.7).tint(accentColor) Text("TEACHER IS THINKING…") .font(.system(size: 9, design: .monospaced)) .foregroundColor(mutedColor) .tracking(2) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 20) } else if !teacherFeedback.isEmpty { VStack(alignment: .leading, spacing: 8) { Text("TEACHER") .font(.system(size: 9, weight: .bold, design: .monospaced)) .foregroundColor(accentColor) .tracking(2) Text(teacherFeedback) .font(.system(size: 14)) .foregroundColor(textColor) .lineSpacing(5) .fixedSize(horizontal: false, vertical: true) } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .background(surfaceColor) .overlay(RoundedRectangle(cornerRadius: 8).stroke(accentColor.opacity(0.3), lineWidth: 1)) .cornerRadius(8) .padding(.horizontal, 20) .transition(.opacity.combined(with: .move(edge: .top))) .animation(.easeInOut(duration: 0.4), value: teacherFeedback) } // Score ring — uses displayScore for visuals ZStack { Circle().stroke(borderColor, lineWidth: 8).frame(width: 100, height: 100) Circle() .trim(from: 0, to: CGFloat(displayScore) / 100) .stroke( displayScore >= 75 ? greenColor : accentColor, style: StrokeStyle(lineWidth: 8, lineCap: .round) ) .frame(width: 100, height: 100) .rotationEffect(.degrees(-90)) .animation(.easeOut(duration: 0.9), value: displayScore) VStack(spacing: 2) { Text("\(displayScore)%") .font(.system(size: 24, weight: .bold, design: .monospaced)) .foregroundColor(displayScore >= 75 ? greenColor : accentColor) Text("SCORE") .font(.system(size: 8, design: .monospaced)) .foregroundColor(mutedColor) .tracking(3) } } // Green pulsing try-again + NEXT LEVEL HStack(spacing: 16) { PulsingRecordButton(color: greenColor, size: 64) { tryAgain() } if displayScore >= 75 { Button { nextLevel() } label: { HStack(spacing: 6) { Text("NEXT LEVEL") .font(.system(size: 11, weight: .bold, design: .monospaced)) .tracking(1) Image(systemName: "arrow.right").font(.system(size: 11)) } .foregroundColor(Color(hex: "#080d14")) .padding(.horizontal, 18) .padding(.vertical, 12) .background(greenColor) .cornerRadius(22) } .transition(.scale.combined(with: .opacity)) } } // Skip after 3 attempts regardless of score if attemptCount >= 3 { Button { nextLevel() } label: { Text("skip →") .font(.system(size: 13, design: .monospaced)) .foregroundColor(mutedColor) } .transition(.opacity) } } .frame(maxWidth: .infinity) .padding(.top, 20) .padding(.bottom, 44) } // MARK: - Bottom controls (all phases except .comparing) @ViewBuilder var bottomControlsView: some View { if phase == .idle { Text("select a script above to start") .font(.system(size: 11, design: .monospaced)) .foregroundColor(mutedColor) .tracking(1) } else if phase == .loading { EmptyView() } else if phase == .listen { // Transitional — auto-play fires immediately; won't be visible EmptyView() } else if phase == .playing { // ── Phase 1 INTRO: pulsing speaker, no controls ────────── PulsingSpeakerView() } else if phase == .ready { // ── Phase 2 YOUR TURN: slider + LISTEN AGAIN + mic ─────── VStack(spacing: 14) { SpeedSliderView(speed: $playbackSpeed, tintColor: accentColor, mutedColor: mutedColor) .frame(maxWidth: .infinity) PulsingRecordButton(color: greenColor, size: 72) { startRecording() } .shadow(color: greenColor.opacity(0.5), radius: 16) Text("YOUR TURN") .font(.system(size: 10, weight: .bold, design: .monospaced)) .foregroundColor(greenColor) .tracking(3) analyzingBadge } } else if phase == .recording { // ── Phase 3 RECORDING: RESTART | red STOP | MUTE ───────── VStack(spacing: 8) { Text("RECORDING") .font(.system(size: 9, design: .monospaced)) .foregroundColor(redColor.opacity(0.7)) .tracking(3) HStack(alignment: .bottom, spacing: 26) { smallCircleButton( icon: "arrow.counterclockwise", label: "RESTART", color: mutedColor ) { restartRecording() } RecordingStopButton { stopRecordingAndCompare() } muteToggleView } } } else if phase == .analyzing { EmptyView() } } // MARK: - Shared sub-views @ViewBuilder var muteToggleView: some View { Button { isMuted.toggle() audioPlayer?.volume = isMuted ? 0 : 1 } label: { VStack(spacing: 4) { ZStack { Circle() .stroke(isMuted ? mutedColor : borderColor, lineWidth: 1.5) .frame(width: 44, height: 44) Image(systemName: isMuted ? "speaker.slash" : "speaker.wave.2") .font(.system(size: 13)) .foregroundColor(isMuted ? mutedColor : mutedColor.opacity(0.5)) } Text(isMuted ? "MUTED" : "SOUND") .font(.system(size: 8, weight: .bold, design: .monospaced)) .foregroundColor(mutedColor.opacity(0.7)) .tracking(1) } } } @ViewBuilder var analyzingBadge: some View { if isSourceAnalyzing { HStack(spacing: 5) { ProgressView().scaleEffect(0.62).tint(mutedColor) Text("ANALYZING…") .font(.system(size: 9, design: .monospaced)) .foregroundColor(mutedColor) .tracking(2) } } } @ViewBuilder func circleButton( icon: String, label: String, color: Color, size: CGFloat, iconOffset: CGFloat, disabled: Bool, action: @escaping () -> Void ) -> some View { Button(action: action) { VStack(spacing: 6) { ZStack { Circle().fill(color.opacity(0.08)).frame(width: size, height: size) Circle().stroke(color, lineWidth: 2).frame(width: size, height: size) Image(systemName: icon) .font(.system(size: size * 0.34)) .foregroundColor(color) .offset(x: iconOffset) } Text(label) .font(.system(size: 9, weight: .bold, design: .monospaced)) .foregroundColor(mutedColor) .tracking(2) } } .disabled(disabled) .opacity(disabled ? 0.35 : 1.0) } @ViewBuilder func smallCircleButton( icon: String, label: String, color: Color, action: @escaping () -> Void ) -> some View { Button(action: action) { VStack(spacing: 4) { ZStack { Circle() .stroke(color.opacity(0.5), lineWidth: 1.5) .frame(width: 44, height: 44) Image(systemName: icon) .font(.system(size: 15)) .foregroundColor(color) } Text(label) .font(.system(size: 8, weight: .bold, design: .monospaced)) .foregroundColor(color.opacity(0.8)) .tracking(1) } } } // MARK: - LISTEN button with orange glow when active @ViewBuilder func listenButton(audioReady: Bool, action: @escaping () -> Void) -> some View { Button(action: action) { VStack(spacing: 6) { ZStack { Circle() .fill(audioReady ? accentColor.opacity(0.10) : Color.gray.opacity(0.06)) .frame(width: 72, height: 72) Circle() .stroke(audioReady ? accentColor : Color.gray.opacity(0.3), lineWidth: 3) .frame(width: 72, height: 72) Image(systemName: "play.fill") .font(.system(size: 26)) .foregroundColor(audioReady ? accentColor : Color.gray.opacity(0.3)) .offset(x: 2) } .shadow(color: audioReady ? accentColor.opacity(0.45) : .clear, radius: 14) Text("LISTEN") .font(.system(size: 9, weight: .bold, design: .monospaced)) .foregroundColor(audioReady ? .white : Color.gray.opacity(0.3)) .tracking(2) } } .disabled(!audioReady) } // MARK: - Audio session setup func setupAudio() { let s = AVAudioSession.sharedInstance() try? s.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker]) try? s.setActive(true) } // MARK: - Load TTS + kick off source analysis func loadMimioAudio() { guard !scriptText.isEmpty else { return } phase = .loading mimioAudioData = nil wordData = [] userWordData = [] isSourceAnalyzing = false playbackSpeed = 1.0 // reset to 1x on each new script guard let payload = try? JSONSerialization.data(withJSONObject: [ "text": scriptText, "label": String(scriptText.prefix(60)), "language": selectedLanguage ]) else { phase = .listen; return } var req = URLRequest(url: URL(string: "\(API_BASE)/speak")!) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") if !token.isEmpty { req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } req.httpBody = payload req.timeoutInterval = 30 URLSession.shared.dataTask(with: req) { data, _, _ in DispatchQueue.main.async { guard let data, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let b64 = json["audio"] as? String, let audioData = Data(base64Encoded: b64) else { phase = .listen; return } mimioAudioData = audioData phase = .listen // Fire /analyze immediately in background while user listens isSourceAnalyzing = true analyzeAudio(audioData: audioData) { words in wordData = words isSourceAnalyzing = false } } }.resume() } // MARK: - Load audio directly from VPS URL (no /speak call) func loadAudioFromURL(_ urlString: String) { guard !urlString.isEmpty, let url = URL(string: "\(API_BASE)\(urlString)") else { phase = .idle; return } phase = .loading attemptCount = 0 mimioAudioData = nil wordData = [] userWordData = [] isSourceAnalyzing = false playbackSpeed = 1.0 URLSession.shared.dataTask(with: URLRequest(url: url)) { data, _, _ in DispatchQueue.main.async { guard let data else { phase = .idle; return } mimioAudioData = data isSourceAnalyzing = true analyzeAudio(audioData: data) { words in wordData = words isSourceAnalyzing = false } playMimio() // auto-play immediately — sets phase = .playing } }.resume() } // MARK: - Analyze audio (source or user) func analyzeAudio(audioData: Data, completion: @escaping ([WordData]) -> Void) { let b64 = audioData.base64EncodedString() guard let payload = try? JSONSerialization.data(withJSONObject: ["audio": b64]) else { completion([]); return } var req = URLRequest(url: URL(string: "\(API_BASE)/analyze")!) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") if !token.isEmpty { req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } req.httpBody = payload req.timeoutInterval = 90 URLSession.shared.dataTask(with: req) { data, _, _ in DispatchQueue.main.async { guard let data, let decoded = try? JSONDecoder().decode([WordData].self, from: data) else { completion([]); return } completion(decoded) } }.resume() } // MARK: - Phase 1: Playback func playMimio() { guard let data = mimioAudioData else { return } // Switch to .playback so iOS routes audio to speaker unconditionally try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) try? AVAudioSession.sharedInstance().setActive(true) audioPlayer?.stop() audioPlayer = try? AVAudioPlayer(data: data) audioPlayer?.enableRate = true audioPlayer?.volume = isMuted ? 0 : 1 audioPlayer?.rate = Float(playbackSpeed) audioPlayer?.currentTime = 0 // always start from beginning (full-script file) print("▶︎ playMimio level:\(currentLevel) endTime:\(currentEndTimeSec)s url:\(scriptAudioURL) muted:\(isMuted) vol:\(audioPlayer?.volume ?? -1)") audioPlayer?.play() sourcePosition = 0 phase = .playing // positionTimer stops at currentEndTimeSec and advances to .ready } func stopPlayback() { audioPlayer?.stop() audioPlayer = nil phase = .ready } /// Replay TTS audio at current speed without changing phase. /// Stops at currentEndTimeSec so it respects the current level boundary. func replayAudio() { let endSec = currentEndTimeSec guard let data = mimioAudioData, endSec > 0 else { return } try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) try? AVAudioSession.sharedInstance().setActive(true) audioPlayer?.stop() audioPlayer = try? AVAudioPlayer(data: data) audioPlayer?.enableRate = true audioPlayer?.volume = 1 audioPlayer?.rate = Float(playbackSpeed) audioPlayer?.currentTime = 0 audioPlayer?.play() sourcePosition = 0 // Stop at sentence boundary without changing phase let stopDelay = endSec / Double(playbackSpeed) DispatchQueue.main.asyncAfter(deadline: .now() + stopDelay) { [self] in audioPlayer?.stop() } } // MARK: - Phase 3: Recording func startRecording() { if let old = userAudioURL { try? FileManager.default.removeItem(at: old) } let url = FileManager.default.temporaryDirectory .appendingPathComponent("user_\(Date().timeIntervalSince1970).m4a") userAudioURL = url let settings: [String: Any] = [ AVFormatIDKey: Int(kAudioFormatMPEG4AAC), AVSampleRateKey: 44100, AVNumberOfChannelsKey: 1, AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue ] // Switch to .playAndRecord so mic is active while speaker plays muted guide try? AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker]) try? AVAudioSession.sharedInstance().setActive(true) audioRecorder = try? AVAudioRecorder(url: url, settings: settings) audioRecorder?.record() // Default recording speed: 0.75x so user can focus on accuracy (no animation) var tx = Transaction(); tx.disablesAnimations = true withTransaction(tx) { playbackSpeed = 0.75 } // Replay source audio (muted) to drive transcript animation isMuted = true if let data = mimioAudioData { audioPlayer?.stop() audioPlayer = try? AVAudioPlayer(data: data) audioPlayer?.enableRate = true audioPlayer?.volume = 0 audioPlayer?.rate = Float(playbackSpeed) audioPlayer?.currentTime = 0 // start from beginning of full-script file audioPlayer?.play() } sourcePosition = 0 phase = .recording } func restartRecording() { audioRecorder?.stop() audioRecorder = nil audioPlayer?.stop() userWordData = [] startRecording() } // MARK: - Phase 3 → Phase 4 func stopRecordingAndCompare() { audioRecorder?.stop() audioRecorder = nil audioPlayer?.stop() withAnimation(.easeInOut(duration: 0.35)) { phase = .analyzing } guard let userURL = userAudioURL, let mimioData = mimioAudioData, let userData = try? Data(contentsOf: userURL) else { phase = .ready; return } let group = DispatchGroup() var score = 50 var userWords: [WordData] = [] group.enter() postHarmony(userData: userData, mimioData: mimioData) { s in score = s group.leave() } group.enter() analyzeAudio(audioData: userData) { words in userWords = words group.leave() } group.notify(queue: .main) { withAnimation(.easeInOut(duration: 0.4)) { userWordData = userWords attemptCount += 1 phase = .comparing(score) } teacherFeedback = "" fetchTeacherFeedback(score: score, sourceWords: currentLevelWordData, userWords: userWords) } } // MARK: - Harmony score func postHarmony(userData: Data, mimioData: Data, completion: @escaping (Int) -> Void) { var req = URLRequest(url: URL(string: "\(API_BASE)/harmony")!) req.httpMethod = "POST" let boundary = "Boundary-\(UUID().uuidString)" req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") req.timeoutInterval = 30 var body = Data() body.append("--\(boundary)\r\n".data(using: .utf8)!) body.append("Content-Disposition: form-data; name=\"user\"; filename=\"user.m4a\"\r\n".data(using: .utf8)!) body.append("Content-Type: audio/m4a\r\n\r\n".data(using: .utf8)!) body.append(userData) body.append("\r\n".data(using: .utf8)!) body.append("--\(boundary)\r\n".data(using: .utf8)!) body.append("Content-Disposition: form-data; name=\"mimio\"; filename=\"mimio.mp3\"\r\n".data(using: .utf8)!) body.append("Content-Type: audio/mpeg\r\n\r\n".data(using: .utf8)!) body.append(mimioData) body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) req.httpBody = body URLSession.shared.dataTask(with: req) { data, _, _ in DispatchQueue.main.async { var s = 50 if let data, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { if let v = json["score"] as? Int { s = min(100, max(0, v)) } else if let v = json["score"] as? Double { s = min(100, max(0, Int(v))) } } completion(s) } }.resume() } // MARK: - Teacher feedback func fetchTeacherFeedback(score: Int, sourceWords: [WordData], userWords: [WordData]) { let nativeLang = UserDefaults.standard.string(forKey: "mimio_native_language") ?? "EN" // Build problem-words list using the same divergence formula as MarkedTranscriptView var problemWords: [[String: Any]] = [] for (i, src) in sourceWords.enumerated() { let div: Float if i < userWords.count { let u = userWords[i] div = min(1.0, abs(src.energy - u.energy) * 0.55 + abs(src.pitch - u.pitch) * 0.45) } else { div = 1.0 } if div >= 0.4 { problemWords.append([ "word": src.word, "divergence": div, "severity": div >= 0.7 ? "focus" : "review" ]) } } guard let payload = try? JSONSerialization.data(withJSONObject: [ "language": selectedLanguage, "native_language": nativeLang, "score": score, "attempt_count": attemptCount, "problem_words": problemWords ]) else { return } isFetchingFeedback = true var req = URLRequest(url: URL(string: "\(API_BASE)/feedback")!) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") if !token.isEmpty { req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } req.httpBody = payload req.timeoutInterval = 30 URLSession.shared.dataTask(with: req) { data, _, _ in DispatchQueue.main.async { isFetchingFeedback = false guard let data, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let fb = json["feedback"] as? String else { return } teacherFeedback = fb } }.resume() } // MARK: - Flow actions func tryAgain() { userWordData = [] comparePosition = 0 compareIsPlaying = false teacherFeedback = "" isFetchingFeedback = false if let url = userAudioURL { try? FileManager.default.removeItem(at: url) } userAudioURL = nil startRecording() } func nextLevel() { currentLevel += 1 attemptCount = 0 // Keep mimioAudioData and wordData — same audio file, just move the end boundary userWordData = [] comparePosition = 0 compareIsPlaying = false teacherFeedback = "" isFetchingFeedback = false if let url = userAudioURL { try? FileManager.default.removeItem(at: url) } userAudioURL = nil playbackSpeed = 1.0 isMuted = false // always unmute — startRecording() sets this to true if currentLevel <= allSentences.count { scriptText = allSentences[currentLevel - 1]["text"] ?? "" scriptTranslation = allSentences[currentLevel - 1]["translation"] ?? "" playMimio() // auto-play from 0 to new currentEndTimeSec — no API call } else { // All sentences done — fetch a new batch generateAndLoadScript() } } func generateAndLoadScript() { let nativeLang = UserDefaults.standard.string(forKey: "mimio_native_language") ?? "EN" activeQuote = QuoteBoard.random(for: nativeLang) showQuoteCard = true phase = .loading guard let payload = try? JSONSerialization.data(withJSONObject: [ "prompt": "everyday conversation", "language": selectedLanguage, "device_id": deviceId ]) else { phase = .idle; return } var req = URLRequest(url: URL(string: "\(API_BASE)/scripts/generate")!) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") if !token.isEmpty { req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } req.httpBody = payload req.timeoutInterval = 120 // 10 individual TTS calls URLSession.shared.dataTask(with: req) { data, _, _ in DispatchQueue.main.async { guard let data, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let raw = json["sentences"] as? [[String: Any]] else { phase = .idle; return } // One audio file for the whole script let audioURL = json["audio_url"] as? String ?? "" scriptAudioURL = audioURL let sents: [[String: String]] = raw.map { s in ["text": s["text"] as? String ?? "", "translation": s["translation"] as? String ?? "", "end_time_ms": String(s["end_time_ms"] as? Int ?? 0)] } allSentences = sents currentLevel = 1 scriptText = sents.first?["text"] ?? "" scriptTranslation = sents.first?["translation"] ?? "" loadAudioFromURL(audioURL) // single load for entire script } }.resume() } // MARK: - Script display helpers /// Split text into sentences on . ! ? keeping the punctuation attached. private func splitIntoSentences(_ text: String) -> [String] { var sentences: [String] = [] var current = "" for char in text { current.append(char) if ".!?".contains(char) { let s = current.trimmingCharacters(in: .whitespaces) if !s.isEmpty { sentences.append(s) } current = "" } } let tail = current.trimmingCharacters(in: .whitespaces) if !tail.isEmpty { sentences.append(tail) } return sentences } /// Renders ALL sentences 1…currentLevel stacked as cards. /// Each card: target text (white, semibold) + translation (light blue, left border). @ViewBuilder private func interleavedScriptContent(targetColor: Color) -> some View { let visibleSentences = Array(allSentences.prefix(currentLevel)) VStack(alignment: .leading, spacing: 14) { if visibleSentences.isEmpty { // Fallback — allSentences not yet loaded Text(scriptText) .font(.system(size: 22, weight: .semibold, design: .monospaced)) .foregroundColor(targetColor) .lineSpacing(8) .fixedSize(horizontal: false, vertical: true) } else { ForEach(Array(visibleSentences.enumerated()), id: \.offset) { i, sentence in let text = sentence["text"] ?? "" let translation = sentence["translation"] ?? "" VStack(alignment: .leading, spacing: 10) { Text(text) .font(.system(size: 24, weight: .bold, design: .monospaced)) .foregroundColor(.white) .tracking(0.3) .lineSpacing(4) .fixedSize(horizontal: false, vertical: true) if !translation.isEmpty { translationBlock(translation) } } .padding(16) .frame(maxWidth: .infinity, alignment: .leading) .background( LinearGradient( colors: [Color(hex: "#1A1A3E").opacity(0.8), Color(hex: "#0D1B2A").opacity(0.6)], startPoint: .topLeading, endPoint: .bottomTrailing ) ) .cornerRadius(12) .overlay(RoundedRectangle(cornerRadius: 12).stroke(blueColor, lineWidth: 1.5)) .overlay( HStack(spacing: 0) { accentColor.frame(width: 4) Spacer() } .cornerRadius(12), alignment: .leading ) .shadow(color: .black.opacity(0.4), radius: 12, x: 0, y: 6) } } } } /// Readable translation block with blue left-border accent. @ViewBuilder private func translationBlock(_ text: String) -> some View { HStack(alignment: .top, spacing: 10) { blueColor .frame(width: 4) Text(text) .font(.system(size: 16, design: .monospaced)) .foregroundColor(translationColor) .lineSpacing(4) .fixedSize(horizontal: false, vertical: true) } .fixedSize(horizontal: false, vertical: true) } } // MARK: - Speed slider struct SpeedSliderView: View { @Binding var speed: Double var tintColor: Color var mutedColor: Color private var label: String { String(format: "%.1fx", speed) } var body: some View { VStack(spacing: 4) { Text(label) .font(.system(size: 10, weight: .bold, design: .monospaced)) .foregroundColor(tintColor.opacity(0.85)) .tracking(1) HStack(spacing: 8) { Image(systemName: "tortoise.fill") .font(.system(size: 11)) .foregroundColor(mutedColor) Slider(value: $speed, in: 0.5...1.5, step: 0.05) .tint(tintColor) .animation(nil, value: speed) Image(systemName: "hare.fill") .font(.system(size: 11)) .foregroundColor(mutedColor) } } } } // MARK: - Pulsing record button struct PulsingRecordButton: View { let color: Color var size: CGFloat = 72 let action: () -> Void @State private var pulsing = false var body: some View { Button(action: action) { ZStack { Circle() .fill(color.opacity(0.07)) .frame(width: size + 38, height: size + 38) .scaleEffect(pulsing ? 1.10 : 0.90) .animation(.easeInOut(duration: 1.1).repeatForever(autoreverses: true), value: pulsing) Circle() .fill(color.opacity(0.13)) .frame(width: size + 18, height: size + 18) .scaleEffect(pulsing ? 1.08 : 0.93) .animation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true).delay(0.15), value: pulsing) Circle().fill(color).frame(width: size, height: size) .scaleEffect(pulsing ? 1.05 : 1.0) .animation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true), value: pulsing) Image(systemName: "mic.fill") .font(.system(size: size * 0.36)) .foregroundColor(.white) } } .onAppear { pulsing = true } .onDisappear { pulsing = false } } } // MARK: - Recording stop button (red pulsing, no label — label lives in parent) struct RecordingStopButton: View { let onStop: () -> Void @State private var pulsing = false var body: some View { Button(action: onStop) { ZStack { Circle() .stroke(Color.red.opacity(0.25), lineWidth: 2) .frame(width: 96, height: 96) .scaleEffect(pulsing ? 1.13 : 0.97) .animation(.easeInOut(duration: 0.72).repeatForever(autoreverses: true), value: pulsing) Circle().stroke(Color.red, lineWidth: 2).frame(width: 78, height: 78) Image(systemName: "stop.fill").font(.system(size: 26)).foregroundColor(.red) } } .onAppear { pulsing = true } .onDisappear { pulsing = false } } } // MARK: - Pulsing speaker (Phase 1 INTRO — auto-play) struct PulsingSpeakerView: View { @State private var pulsing = false var body: some View { VStack(spacing: 10) { ZStack { Circle() .fill(Color(hex: "#FF9500").opacity(0.05)) .frame(width: 130, height: 130) .scaleEffect(pulsing ? 1.15 : 0.88) .animation(.easeInOut(duration: 1.3).repeatForever(autoreverses: true), value: pulsing) Circle() .fill(Color(hex: "#FF9500").opacity(0.10)) .frame(width: 95, height: 95) .scaleEffect(pulsing ? 1.10 : 0.92) .animation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true).delay(0.2), value: pulsing) Text("♪") .font(.system(size: 38)) .foregroundColor(Color(hex: "#FF9500")) } Text("LISTENING") .font(.system(size: 9, weight: .bold, design: .monospaced)) .foregroundColor(Color(hex: "#FF9500").opacity(0.7)) .tracking(3) } .onAppear { pulsing = true } .onDisappear { pulsing = false } } } // MARK: - Analyzing pulse (recording → feedback gap) struct AnalyzingPulseView: View { @State private var dotCount = 0 private let timer = Timer.publish(every: 0.38, on: .main, in: .common).autoconnect() var body: some View { VStack(spacing: 12) { HStack(spacing: 10) { ForEach(0..<3, id: \.self) { i in Circle() .fill(Color(hex: "#FF9500")) .frame(width: 11, height: 11) .opacity(i <= dotCount ? 1.0 : 0.18) .scaleEffect(i <= dotCount ? 1.0 : 0.55) .animation(.easeInOut(duration: 0.22), value: dotCount) } } Text("ANALYZING") .font(.system(size: 9, weight: .bold, design: .monospaced)) .foregroundColor(Color(hex: "#FF9500").opacity(0.65)) .tracking(3) } .frame(maxWidth: .infinity, maxHeight: .infinity) .onReceive(timer) { _ in dotCount = (dotCount + 1) % 3 } } } // MARK: - Scripts Sheet struct ScriptsSheet: View { @Binding var allSentences: [[String: String]] @Binding var scriptAudioURL: String @Binding var showQuoteCard: Bool @Binding var activeQuote: LangQuote let selectedLanguage: String let token: String let accentColor: Color let bgColor: Color let surfaceColor: Color let borderColor: Color let textColor: Color let mutedColor: Color let onSelect: () -> Void let onDismiss: () -> Void @State private var isLoading = false @State private var pulsing = false @State private var showTopicField = false @State private var customTopic = "" @State private var isGeneratingCustom = false private var deviceId: String { UIDevice.current.identifierForVendor?.uuidString ?? "unknown" } private var previewText: String { allSentences.first?["text"] ?? "" } private var previewTranslation: String { allSentences.first?["translation"] ?? "" } private var isReady: Bool { !previewText.isEmpty } var body: some View { ZStack { Color(hex: "#080810").ignoresSafeArea() VStack(spacing: 0) { // Header HStack { Text("SCRIPTS") .font(.system(size: 22, weight: .bold)) .foregroundColor(.white) .tracking(3) Spacer() Button { onDismiss() } label: { Image(systemName: "xmark") .font(.system(size: 20, weight: .medium)) .foregroundColor(.white) } } .padding(.horizontal, 20) .padding(.vertical, 18) .background(Color(hex: "#080810")) .overlay(Rectangle().frame(height: 1).foregroundColor(Color(red: 0.24, green: 0.56, blue: 0.94).opacity(0.3)), alignment: .bottom) VStack(alignment: .leading, spacing: 28) { // Daily script card dailyScriptCard .padding(.top, 28) // Custom topic expander customTopicSection } .padding(.horizontal, 20) Spacer() } } .onAppear { if allSentences.isEmpty { loadScript() } } } // MARK: - Daily script card private let cardBlue = Color(red: 0.24, green: 0.56, blue: 0.94) private let cardTranslation = Color(red: 0.49, green: 0.72, blue: 0.97) @ViewBuilder var dailyScriptCard: some View { VStack(alignment: .leading, spacing: 12) { Text("DAILY SCRIPT") .font(.system(size: 11, weight: .bold, design: .monospaced)) .foregroundColor(Color(hex: "#FF9500")) .tracking(3) if isLoading || !isReady { // Skeleton pulsing lines — no spinner VStack(alignment: .leading, spacing: 12) { RoundedRectangle(cornerRadius: 5) .fill(Color.white.opacity(0.12)) .frame(maxWidth: .infinity) .frame(height: 18) .scaleEffect(x: pulsing ? 0.96 : 1.0, anchor: .leading) .animation(.easeInOut(duration: 1.3).repeatForever(autoreverses: true), value: pulsing) RoundedRectangle(cornerRadius: 5) .fill(Color.white.opacity(0.08)) .frame(width: 200) .frame(height: 18) .scaleEffect(x: pulsing ? 0.92 : 1.0, anchor: .leading) .animation(.easeInOut(duration: 1.5).repeatForever(autoreverses: true).delay(0.2), value: pulsing) RoundedRectangle(cornerRadius: 5) .fill(Color.white.opacity(0.05)) .frame(width: 140) .frame(height: 14) .scaleEffect(x: pulsing ? 0.88 : 1.0, anchor: .leading) .animation(.easeInOut(duration: 1.7).repeatForever(autoreverses: true).delay(0.4), value: pulsing) } .onAppear { pulsing = true } } else { Text(previewText) .font(.system(size: 22, weight: .semibold)) .foregroundColor(.white) .lineSpacing(5) .fixedSize(horizontal: false, vertical: true) if !previewTranslation.isEmpty { Text(previewTranslation) .font(.system(size: 15)) .foregroundColor(cardTranslation) .lineSpacing(3) .fixedSize(horizontal: false, vertical: true) } Text("\(allSentences.count) levels ready") .font(.system(size: 12)) .foregroundColor(cardTranslation) .padding(.top, 2) } } .padding(20) .frame(maxWidth: .infinity, alignment: .leading) .background(Color(red: 0.1, green: 0.1, blue: 0.2)) .cornerRadius(16) .overlay( RoundedRectangle(cornerRadius: 16) .stroke(cardBlue, lineWidth: 2) ) .shadow(color: cardBlue.opacity(0.4), radius: 20) .contentShape(Rectangle()) .onTapGesture { guard isReady else { return } onSelect() } } // MARK: - Custom topic expander @ViewBuilder var customTopicSection: some View { VStack(alignment: .leading, spacing: 14) { // Prominent orange button Button { withAnimation(.easeInOut(duration: 0.2)) { showTopicField.toggle() } } label: { Text("✦ Generate a custom script") .font(.system(size: 16, weight: .medium)) .foregroundColor(Color(hex: "#FF9500")) .frame(maxWidth: .infinity) .padding(.vertical, 12) .overlay( RoundedRectangle(cornerRadius: 10) .stroke(Color(hex: "#FF9500"), lineWidth: 1) ) } if showTopicField { HStack(spacing: 10) { TextField("cold open, job interview, travel...", text: $customTopic) .font(.system(size: 14)) .foregroundColor(.white) .padding(12) .background(Color(red: 0.1, green: 0.1, blue: 0.2)) .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color(red: 0.24, green: 0.56, blue: 0.94).opacity(0.5), lineWidth: 1)) .cornerRadius(10) Button { guard !customTopic.isEmpty, !isGeneratingCustom else { return } isGeneratingCustom = true loadScript(prompt: customTopic, forceNew: true) withAnimation { showTopicField = false } } label: { Image(systemName: isGeneratingCustom ? "hourglass" : "arrow.right") .font(.system(size: 16, weight: .semibold)) .foregroundColor(Color(hex: "#FF9500")) .frame(width: 46, height: 46) .background(Color(red: 0.1, green: 0.1, blue: 0.2)) .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color(hex: "#FF9500"), lineWidth: 1)) .cornerRadius(10) } .disabled(customTopic.isEmpty || isGeneratingCustom) } .transition(.opacity.combined(with: .move(edge: .top))) } } } // MARK: - API func loadScript(prompt: String = "everyday conversation", forceNew: Bool = false) { activeQuote = QuoteBoard.random(for: selectedLanguage) showQuoteCard = true isLoading = true allSentences = [] guard let payload = try? JSONSerialization.data(withJSONObject: [ "prompt": prompt, "language": selectedLanguage, "device_id": deviceId, "force_new": forceNew ]) else { isLoading = false; return } var req = URLRequest(url: URL(string: "\(API_BASE)/scripts/generate")!) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") if !token.isEmpty { req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } req.httpBody = payload req.timeoutInterval = 120 URLSession.shared.dataTask(with: req) { data, _, _ in DispatchQueue.main.async { isLoading = false isGeneratingCustom = false guard let data, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let raw = json["sentences"] as? [[String: Any]] else { return } // Store single audio URL at the top level scriptAudioURL = json["audio_url"] as? String ?? "" allSentences = raw.map { s in ["text": s["text"] as? String ?? "", "translation": s["translation"] as? String ?? "", "end_time_ms": String(s["end_time_ms"] as? Int ?? 0)] } } }.resume() } } // MARK: - Marked transcript (compare phase) /// Renders source words with per-word feedback coloring based on divergence /// from the user's recorded attempt. Matched → normal. Moderate divergence → /// amber underline. Poor match / missed → bold red + red underline. struct MarkedTranscriptView: View { let sourceWords: [WordData] let userWords: [WordData] var textColor: Color var mutedColor: Color var accentColor: Color var redColor: Color private struct WordFeedback: Identifiable { let id = UUID() let word: String let divergence: Float // 0 = perfect match, 1 = totally missed } private var feedback: [WordFeedback] { sourceWords.enumerated().map { i, src in guard i < userWords.count else { return WordFeedback(word: src.word, divergence: 1.0) } let u = userWords[i] // Energy captures loudness/confidence; pitch captures intonation let energyDiff = abs(src.energy - u.energy) let pitchDiff = abs(src.pitch - u.pitch) let div = min(1.0, energyDiff * 0.55 + pitchDiff * 0.45) return WordFeedback(word: src.word, divergence: div) } } var body: some View { WrapLayout(hSpacing: 8, vSpacing: 14) { ForEach(feedback) { f in wordToken(f) } } .padding(.horizontal, 20) .padding(.vertical, 16) .animation(.easeInOut(duration: 0.4), value: sourceWords.count) } @ViewBuilder private func wordToken(_ f: WordFeedback) -> some View { if f.divergence < 0.4 { // Good match — clean text Text(f.word) .font(.system(size: 17, design: .monospaced)) .foregroundColor(textColor) } else if f.divergence < 0.7 { // Moderate — amber underline + gentle opacity pulse Text(f.word) .font(.system(size: 17, design: .monospaced)) .foregroundColor(textColor) .underline(true, color: accentColor) .modifier(OpacityPulse()) } else { // Poor / missed — bold red + slow scale pulse Text(f.word) .font(.system(size: 17, weight: .bold, design: .monospaced)) .foregroundColor(redColor) .underline(true, color: redColor) .modifier(ScalePulse()) } } } // MARK: - Compare phase animation modifiers /// Red words: slow scale breathe 1.0 → 1.05, drawing the eye without jarring motion. struct ScalePulse: ViewModifier { @State private var active = false func body(content: Content) -> some View { content .scaleEffect(active ? 1.05 : 1.0) .animation(.easeInOut(duration: 1.6).repeatForever(autoreverses: true), value: active) .onAppear { active = true } } } /// Amber words: gentle opacity breathe 1.0 → 0.55, softer than scale. struct OpacityPulse: ViewModifier { @State private var active = false func body(content: Content) -> some View { content .opacity(active ? 0.55 : 1.0) .animation(.easeInOut(duration: 2.0).repeatForever(autoreverses: true), value: active) .onAppear { active = true } } } // MARK: - Color hex initialiser extension Color { init(hex: String) { let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) var int: UInt64 = 0 Scanner(string: hex).scanHexInt64(&int) let r = Double((int >> 16) & 0xFF) / 255 let g = Double((int >> 8) & 0xFF) / 255 let b = Double(int & 0xFF) / 255 self.init(red: r, green: g, blue: b) } } // MARK: - Quote data struct LangQuote { let text: String let author: String } enum QuoteBoard { static let quotes: [String: [LangQuote]] = [ "EN": [ LangQuote(text: "If you talk to a man in a language he understands, that goes to his head. If you talk to him in his language, that goes to his heart.", author: "Nelson Mandela"), LangQuote(text: "One language sets you in a corridor for life. Two languages open every door along the way.", author: "Frank Smith"), LangQuote(text: "To learn a language is to have one more window from which to look at the world.", author: "Chinese Proverb"), LangQuote(text: "Language is the road map of a culture.", author: "Rita Mae Brown"), LangQuote(text: "The limits of my language mean the limits of my world.", author: "Ludwig Wittgenstein"), LangQuote(text: "Learn a new language and get a new soul.", author: "Czech Proverb"), LangQuote(text: "A different language is a different vision of life.", author: "Federico Fellini"), LangQuote(text: "You live a new life for every new language you speak.", author: "Czech Proverb"), LangQuote(text: "Language is not a genetic gift, it is a social gift.", author: "Frank Smith"), LangQuote(text: "To speak a language is to take on a world, a culture.", author: "Frantz Fanon"), LangQuote(text: "Knowledge of languages is the doorway to wisdom.", author: "Roger Bacon"), LangQuote(text: "He who knows no foreign languages knows nothing of his own.", author: "Goethe"), ], "FR": [ LangQuote(text: "Si tu parles à un homme dans une langue qu'il comprend, ça lui va à la tête. Si tu lui parles dans sa langue, ça lui va au cœur.", author: "Nelson Mandela"), LangQuote(text: "Une langue vous enferme dans un couloir pour la vie. Deux langues vous ouvrent chaque porte sur le chemin.", author: "Frank Smith"), LangQuote(text: "Apprendre une langue, c'est avoir une fenêtre de plus sur le monde.", author: "Proverbe chinois"), LangQuote(text: "La langue est la carte routière d'une culture.", author: "Rita Mae Brown"), LangQuote(text: "Les limites de ma langue sont les limites de mon monde.", author: "Ludwig Wittgenstein"), LangQuote(text: "Apprends une nouvelle langue et acquiers une nouvelle âme.", author: "Proverbe tchèque"), LangQuote(text: "Une langue différente est une vision différente de la vie.", author: "Federico Fellini"), LangQuote(text: "Tu vis une nouvelle vie pour chaque nouvelle langue que tu parles.", author: "Proverbe tchèque"), LangQuote(text: "La langue n'est pas un don génétique, c'est un don social.", author: "Frank Smith"), LangQuote(text: "Parler une langue, c'est adopter un monde, une culture.", author: "Frantz Fanon"), LangQuote(text: "La connaissance des langues est la porte de la sagesse.", author: "Roger Bacon"), LangQuote(text: "Celui qui ne connaît pas les langues étrangères ne sait rien de sa propre langue.", author: "Goethe"), ], "ES": [ LangQuote(text: "Si hablas con un hombre en un idioma que comprende, eso llega a su mente. Si le hablas en su idioma, eso llega a su corazón.", author: "Nelson Mandela"), LangQuote(text: "Un idioma te sitúa en un corredor para toda la vida. Dos idiomas abren cada puerta en el camino.", author: "Frank Smith"), LangQuote(text: "Aprender un idioma es tener una ventana más desde la que mirar el mundo.", author: "Proverbio chino"), LangQuote(text: "El idioma es el mapa de carreteras de una cultura.", author: "Rita Mae Brown"), LangQuote(text: "Los límites de mi lenguaje son los límites de mi mundo.", author: "Ludwig Wittgenstein"), LangQuote(text: "Aprende un nuevo idioma y adquiere un nuevo alma.", author: "Proverbio checo"), LangQuote(text: "Un idioma diferente es una visión diferente de la vida.", author: "Federico Fellini"), LangQuote(text: "Vives una nueva vida por cada nuevo idioma que hablas.", author: "Proverbio checo"), LangQuote(text: "El lenguaje no es un don genético, es un don social.", author: "Frank Smith"), LangQuote(text: "Hablar un idioma es adoptar un mundo, una cultura.", author: "Frantz Fanon"), LangQuote(text: "El conocimiento de los idiomas es la puerta de la sabiduría.", author: "Roger Bacon"), LangQuote(text: "Quien no conoce lenguas extranjeras nada sabe de la suya propia.", author: "Goethe"), ], "PT": [ LangQuote(text: "Se você fala com um homem numa língua que ele compreende, isso vai para a cabeça dele. Se você fala com ele na língua dele, isso vai para o coração dele.", author: "Nelson Mandela"), LangQuote(text: "Uma língua coloca-te num corredor para a vida. Duas línguas abrem cada porta ao longo do caminho.", author: "Frank Smith"), LangQuote(text: "Aprender uma língua é ter mais uma janela de onde olhar para o mundo.", author: "Provérbio chinês"), LangQuote(text: "A língua é o mapa rodoviário de uma cultura.", author: "Rita Mae Brown"), LangQuote(text: "Os limites da minha linguagem significam os limites do meu mundo.", author: "Ludwig Wittgenstein"), LangQuote(text: "Aprende uma nova língua e ganhas uma nova alma.", author: "Provérbio checo"), LangQuote(text: "Uma língua diferente é uma visão diferente da vida.", author: "Federico Fellini"), LangQuote(text: "Vives uma nova vida por cada nova língua que falas.", author: "Provérbio checo"), LangQuote(text: "A língua não é um dom genético, é um dom social.", author: "Frank Smith"), LangQuote(text: "Falar uma língua é adotar um mundo, uma cultura.", author: "Frantz Fanon"), LangQuote(text: "O conhecimento das línguas é a porta da sabedoria.", author: "Roger Bacon"), LangQuote(text: "Quem não conhece línguas estrangeiras nada sabe da sua própria.", author: "Goethe"), ], "IT": [ LangQuote(text: "Se parli a un uomo in una lingua che capisce, vai alla sua testa. Se gli parli nella sua lingua, vai al suo cuore.", author: "Nelson Mandela"), LangQuote(text: "Una lingua ti colloca in un corridoio per tutta la vita. Due lingue aprono ogni porta lungo la strada.", author: "Frank Smith"), LangQuote(text: "Imparare una lingua è avere un'altra finestra da cui guardare il mondo.", author: "Proverbio cinese"), LangQuote(text: "La lingua è la mappa stradale di una cultura.", author: "Rita Mae Brown"), LangQuote(text: "I limiti del mio linguaggio significano i limiti del mio mondo.", author: "Ludwig Wittgenstein"), LangQuote(text: "Impara una nuova lingua e acquisisci una nuova anima.", author: "Proverbio ceco"), LangQuote(text: "Una lingua diversa è una visione diversa della vita.", author: "Federico Fellini"), LangQuote(text: "Vivi una nuova vita per ogni nuova lingua che parli.", author: "Proverbio ceco"), LangQuote(text: "La lingua non è un dono genetico, è un dono sociale.", author: "Frank Smith"), LangQuote(text: "Parlare una lingua significa adottare un mondo, una cultura.", author: "Frantz Fanon"), LangQuote(text: "La conoscenza delle lingue è la porta della saggezza.", author: "Roger Bacon"), LangQuote(text: "Chi non conosce le lingue straniere non sa nulla della propria.", author: "Goethe"), ], "RO": [ LangQuote(text: "Dacă vorbești cu un om într-o limbă pe care o înțelege, ajungi la mintea lui. Dacă îi vorbești în limba lui, ajungi la inima lui.", author: "Nelson Mandela"), LangQuote(text: "O limbă te plasează într-un culoar pentru toată viața. Două limbi îți deschid fiecare ușă de-a lungul drumului.", author: "Frank Smith"), LangQuote(text: "A învăța o limbă înseamnă a avea încă o fereastră prin care să privești lumea.", author: "Proverb chinezesc"), LangQuote(text: "Limba este harta rutieră a unei culturi.", author: "Rita Mae Brown"), LangQuote(text: "Limitele limbajului meu înseamnă limitele lumii mele.", author: "Ludwig Wittgenstein"), LangQuote(text: "Învață o nouă limbă și dobândești un nou suflet.", author: "Proverb ceh"), LangQuote(text: "O altă limbă este o altă viziune a vieții.", author: "Federico Fellini"), LangQuote(text: "Trăiești o viață nouă pentru fiecare nouă limbă pe care o vorbești.", author: "Proverb ceh"), LangQuote(text: "Limba nu este un dar genetic, este un dar social.", author: "Frank Smith"), LangQuote(text: "A vorbi o limbă înseamnă a adopta o lume, o cultură.", author: "Frantz Fanon"), LangQuote(text: "Cunoașterea limbilor este poarta înțelepciunii.", author: "Roger Bacon"), LangQuote(text: "Cine nu cunoaște limbi străine nu știe nimic despre propria sa limbă.", author: "Goethe"), ], "LA": [ LangQuote(text: "Si homini loqueris lingua quam intellegit, in mentem ei venit. Si ei loqueris lingua eius, in cor eius venit.", author: "Nelson Mandela"), LangQuote(text: "Una lingua te in itinere ponit per vitam. Duae linguae omnem portam patefaciunt.", author: "Frank Smith"), LangQuote(text: "Linguam discere est fenestram novam ad mundum habere.", author: "Proverbium Sinense"), LangQuote(text: "Lingua est tabula culturae.", author: "Rita Mae Brown"), LangQuote(text: "Fines linguae meae fines mundi mei sunt.", author: "Ludwig Wittgenstein"), LangQuote(text: "Disce linguam novam et animam novam accipies.", author: "Proverbium Bohemicum"), LangQuote(text: "Alia lingua alia vita est.", author: "Federico Fellini"), LangQuote(text: "Nova vita in quaque lingua nova vivitur.", author: "Proverbium Bohemicum"), LangQuote(text: "Lingua non donum naturale est, sed donum sociale.", author: "Frank Smith"), LangQuote(text: "Linguam loqui mundum et culturam suscipere est.", author: "Frantz Fanon"), LangQuote(text: "Cognitio linguarum ianua est sapientiae.", author: "Roger Bacon"), LangQuote(text: "Qui linguas externas ignorat, suam ipse ignorat.", author: "Goethe"), ], ] static func random(for langCode: String) -> LangQuote { let pool = quotes[langCode] ?? quotes["EN"] ?? [] return pool.randomElement() ?? LangQuote(text: "Every language is a world.", author: "") } } // MARK: - Wave shape for water surface private struct WaveShape: Shape { var offset: CGFloat var animatableData: CGFloat { get { offset } set { offset = newValue } } func path(in rect: CGRect) -> Path { var path = Path() let amplitude: CGFloat = 7 let wavelength = rect.width / 1.5 path.move(to: CGPoint(x: rect.minX, y: rect.minY + amplitude)) var x: CGFloat = 0 while x <= rect.width { let y = amplitude * sin((x / wavelength) * .pi * 2 + offset) path.addLine(to: CGPoint(x: x, y: rect.minY + y)) x += 2 } path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) path.closeSubpath() return path } } /// Full-screen loading overlay. Water drains from full to empty in exactly 8 seconds, /// then calls onDismiss unconditionally — backend state is irrelevant to the timer. struct WaterDrainView: View { let quote: LangQuote var onDismiss: () -> Void @State private var fillLevel: CGFloat = 1.0 @State private var quoteOpacity: Double = 0.0 @State private var waveOffset: CGFloat = 0.0 private let drainDuration: Double = 8.0 var body: some View { GeometryReader { geo in ZStack(alignment: .bottom) { LinearGradient( colors: [ Color(red: 0.15, green: 0.22, blue: 0.35), Color(red: 0.25, green: 0.35, blue: 0.45), Color(red: 0.45, green: 0.38, blue: 0.35), ], startPoint: .top, endPoint: .bottom ) .ignoresSafeArea() RadialGradient( colors: [.clear, .black.opacity(0.4)], center: .center, startRadius: 100, endRadius: 400 ) .ignoresSafeArea() VStack(spacing: 24) { Text("\u{275D}") .font(.system(size: 48)) .foregroundColor(.white.opacity(0.3)) Text(quote.text) .font(.system(size: 22, weight: .light, design: .serif)) .foregroundColor(.white.opacity(0.9)) .multilineTextAlignment(.center) .padding(.horizontal, 36) .lineSpacing(6) if !quote.author.isEmpty { Text("— \(quote.author)") .font(.system(size: 14, weight: .medium)) .foregroundColor(Color(red: 1.0, green: 0.75, blue: 0.3)) .padding(.top, 8) } } .opacity(quoteOpacity) .frame(width: geo.size.width) .position(x: geo.size.width / 2, y: geo.size.height * 0.42) let waterHeight = max(0, geo.size.height * fillLevel) WaveShape(offset: waveOffset) .fill( LinearGradient( colors: [ Color(red: 0.1, green: 0.45, blue: 0.72).opacity(0.88), Color(red: 0.05, green: 0.28, blue: 0.55).opacity(0.95), ], startPoint: .top, endPoint: .bottom ) ) .frame(height: waterHeight + 14) .animation(.linear(duration: 2).repeatForever(autoreverses: false), value: waveOffset) } } .ignoresSafeArea() .onAppear { withAnimation(.easeIn(duration: 1.5)) { quoteOpacity = 1.0 } waveOffset = .pi * 2 withAnimation(.linear(duration: drainDuration)) { fillLevel = 0.0 } DispatchQueue.main.asyncAfter(deadline: .now() + drainDuration) { onDismiss() } } } }