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