diff --git a/.DS_Store b/.DS_Store index 6d6a9c7..cbc91ce 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/Resources/Assets.xcassets/kakaoLogo.imageset/Contents.json b/Resources/Assets.xcassets/kakaoLogo.imageset/Contents.json new file mode 100644 index 0000000..a401fa4 --- /dev/null +++ b/Resources/Assets.xcassets/kakaoLogo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "kakaoLogo.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/Assets.xcassets/kakaoLogo.imageset/kakaoLogo.pdf b/Resources/Assets.xcassets/kakaoLogo.imageset/kakaoLogo.pdf new file mode 100644 index 0000000..a4e9152 Binary files /dev/null and b/Resources/Assets.xcassets/kakaoLogo.imageset/kakaoLogo.pdf differ diff --git a/Sources/CourseAPI/CourseAPI.swift b/Sources/CourseAPI/CourseAPI.swift new file mode 100644 index 0000000..24a1811 --- /dev/null +++ b/Sources/CourseAPI/CourseAPI.swift @@ -0,0 +1,155 @@ +// +// CourseAPI.swift +// Lecture2Quiz +// +// Created by 바견규 on 5/24/25. +// + +import Foundation +import Moya + +// 수업 및 텍스트 관련 API 라우터 +enum CourseAPI { + + // 수업 생성 + case createCourse(userId: Int, title: String, description: String) + + // 수업 ID로 주차 목록 조회 + // - 사용처: 녹음 종료 시 수업 선택 뷰 등에서 호출 + case getCourseWeeks(courseId: Int) + + // 주차 ID로 텍스트 조회 + // - 사용처: 텍스트 상세 열람 시 사용 + case getWeekText(weekId: Int) + + // 주차 생성 + // - 사용처: 주차 선택 뷰에서 새 주차 생성 시 + case createWeek(courseId: Int, title: String, weekNumber: Int) + + // 텍스트 ID로 키워드 조회 + case getKeywords(textId: Int) + + // 키워드 생성 요청 + case createKeyword(textId: Int) + + // 텍스트 요약 생성 요청 + case summarizeText(textId: Int) + + // 사용자별 수업 목록 조회 + // - 사용처: 수업 선택 뷰 최초 로딩 시 + case getUserCourses(userId: Int) + + // 녹음 내용을 주차에 제출 + // - 사용처: 녹음 종료 후 내용 업로드 + case submitTranscript(weekId: Int, content: String, type: String) + + // 텍스트 ID로 텍스트 단건 조회 + // - 사용처: 퀴즈 생성 시 텍스트 내용 기반 활용 + case getTextById(id: Int) + + // 수업 삭제 + case deleteCourse(courseId: Int) + + // 주차 삭제 + case deleteWeek(weekId: Int) + + // 텍스트 삭제 + case deleteText(textId: Int) + + // 텍스트 수정 + case updateText(textId: Int, content: String, type: String) +} + + +extension CourseAPI: TargetType { + var baseURL: URL { + let baseURL = Bundle.main.object(forInfoDictionaryKey: "API_URL") as! String + return URL(string: baseURL)! + } + + var path: String { + switch self { + case .createCourse: + return "/v1/course" + case .getCourseWeeks(let courseId): + return "/v1/course/\(courseId)" + case .getWeekText(let weekId): + return "/v1/texts/weeks/\(weekId)" + case .createWeek: + return "/weeks" + case .getKeywords(let textId), .createKeyword(let textId): + return "/v1/texts/keywords/\(textId)" + case .summarizeText(let textId): + return "/v1/texts/summation/\(textId)" + case .getUserCourses(let userId): + return "/v1/course/user/\(userId)/courses" + case .submitTranscript: + return "/v1/texts" + case .getTextById(let id): + return "/v1/texts/\(id)" + case .deleteCourse(let courseId): + return "/v1/course/\(courseId)" + case .deleteWeek(let weekId): + return "/api/weeks/\(weekId)" + case .deleteText(let textId), .updateText(let textId, _, _): + return "/v1/texts/\(textId)" + + } + } + + var method: Moya.Method { + switch self { + case .createCourse, .createWeek, .createKeyword, .summarizeText, .submitTranscript: + return .post + case .getCourseWeeks, .getWeekText, .getKeywords, .getUserCourses, .getTextById: + return .get + case .deleteCourse, .deleteWeek, .deleteText: + return .delete + case .updateText: + return .put + } + } + + var task: Task { + switch self { + case let .createCourse(userId, title, description): + return .requestParameters(parameters: [ + "userId": userId, + "title": title, + "description": description + ], encoding: JSONEncoding.default) + case let .createWeek(courseId, title, weekNumber): + return .requestParameters(parameters: [ + "courseId": courseId, + "title": title, + "weekNumber": weekNumber + ], encoding: JSONEncoding.default) + case .createKeyword, .summarizeText: + return .requestPlain + case let .submitTranscript(weekId, content, type): + return .requestParameters( + parameters: [ + "weekId": weekId, + "content": content, + "type": type + ], + encoding: JSONEncoding.default + ) + case .updateText(_, let content, let type): + return .requestParameters(parameters: [ + "content": content, + "type": type + ], encoding: JSONEncoding.default) + + default: + return .requestPlain + } + } + + var headers: [String: String]? { + return [ + "accept": "*/*", + "content-type": "application/json" + ] + } +} diff --git a/Sources/CourseAPI/CouseResponse.swift b/Sources/CourseAPI/CouseResponse.swift new file mode 100644 index 0000000..fc4b33a --- /dev/null +++ b/Sources/CourseAPI/CouseResponse.swift @@ -0,0 +1,73 @@ +// +// response.swift +// Lecture2Quiz +// +// Created by 바견규 on 5/24/25. +// + +import Foundation + +// MARK: - 수업(Course) + +struct CreateCourseRequest: Codable { + let userId: Int + let title: String + let description: String +} + +struct CourseResponse: Codable { + let id: Int + let userId: Int + let title: String + let description: String + let weeks: [WeekResponse]? // 주차 정보 포함 가능 +} + +// MARK: - 주차(Week) + +struct CreateWeekRequest: Codable { + let courseId: Int + let title: String + let weekNumber: Int +} + +struct WeekResponse: Codable { + let id: Int + let courseId: Int + let title: String + let weekNumber: Int +} + +// MARK: - 텍스트(Text) + +struct WeekTextResponse: Codable, Identifiable { + let id: Int + let weekId: Int + let content: String + let summation: String? +} + + +// MARK: - 사용자 수업 목록 조회 +struct CourseResponseByUserID: Decodable, Identifiable,Hashable { + let id: Int + let title: String + let description: String + let weeks: [WeekResponseByUserID] +} + +struct WeekResponseByUserID: Decodable,Identifiable,Hashable { + let id: Int + let courseId: Int + let title: String +} + + +// MARK: - 텍스트 ID로 get +struct TextDetailResponse: Codable { +let id: Int +let weekId: Int +let content: String +let type: String +let summation: String? +} diff --git a/Sources/Lecture2QuizApp.swift b/Sources/Lecture2QuizApp.swift deleted file mode 100644 index 96e7c82..0000000 --- a/Sources/Lecture2QuizApp.swift +++ /dev/null @@ -1,11 +0,0 @@ -import SwiftUI - -@main -struct Lecture2QuizApp: App { - var body: some Scene { - WindowGroup { - TableView() - } - } -} - diff --git a/Sources/Models/ColorHexExtend.swift b/Sources/Models/ColorHexExtend.swift new file mode 100644 index 0000000..96d84a0 --- /dev/null +++ b/Sources/Models/ColorHexExtend.swift @@ -0,0 +1,33 @@ +// +// ColorHexExtend.swift +// Starbucks +// +// Created by 박현규 on 3/19/25. +// + +import SwiftUI + +extension Color { + init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch hex.count { + case 6: // RGB (예: #01A862) + (a, r, g, b) = (255, (int >> 16) & 0xFF, (int >> 8) & 0xFF, int & 0xFF) + case 8: // ARGB (예: #FF01A862) + (a, r, g, b) = ((int >> 24) & 0xFF, (int >> 16) & 0xFF, (int >> 8) & 0xFF, int & 0xFF) + default: + (a, r, g, b) = (255, 0, 0, 0) // 기본값: 검정색 + } + self.init( + .sRGB, + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + opacity: Double(a) / 255 + ) + } +} + diff --git a/Sources/QuizAPI/QuizAPI.swift b/Sources/QuizAPI/QuizAPI.swift new file mode 100644 index 0000000..efeb128 --- /dev/null +++ b/Sources/QuizAPI/QuizAPI.swift @@ -0,0 +1,134 @@ +// +// QuizAPI.swift +// Lecture2Quiz +// +// Created by 바견규 on 5/27/25. +// + +import Foundation +import Moya + +// 퀴즈 관련 API 라우터 +enum QuizAPI { + // 사용자별 전체 퀴즈 조회 + case getQuizzes(userId: Int) + + // 퀴즈 생성 + case createQuiz(userId: Int, title: String, description: String, weekIds: [Int], quizType: String, questionCount: Int) + + // 주차 질문 생성 + case generateQuestions(weekId: Int, minQuestionCount: Int) + + // 주차의 질문 조회 + case getWeekQuestions(weekId: Int) + + // 퀴즈 상세 조회 + case getQuizDetail(id: Int) + + // 퀴즈 세션 시작 + case startQuizSession(quizId: Int, userId: Int) + + // 퀴즈 세션 답변 기록 + case answerQuizSession(sessionId: Int, userAnswer: String) + + // 퀴즈 세션 완료 + case completeQuizSession(sessionId: Int) + + // 사용자별 퀴즈 세션 목록 조회 + case getUserQuizSessions(userId: Int) + + // 퀴즈 세션 상세 조회 + case getQuizSessionDetail(sessionId: Int) + + // 퀴즈 삭제 + case deleteQuiz(id: Int) +} + +extension QuizAPI: TargetType { + var baseURL: URL { + let baseURL = Bundle.main.object(forInfoDictionaryKey: "API_URL") as! String + return URL(string: baseURL)! + } + + var path: String { + switch self { + case .getQuizzes: + return "/v1/quizzes" + case .createQuiz: + return "/v1/quizzes" + case .generateQuestions(let weekId, _): + return "/v1/questions/weeks/\(weekId)/generate" + case .getWeekQuestions(let weekId): + return "/v1/questions/weeks/\(weekId)" + case .getQuizDetail(let id): + return "/v1/quizzes/\(id)" + case .startQuizSession(let quizId, _): + return "/v1/quizzes/\(quizId)/start" + case .answerQuizSession(let sessionId, _): + return "/v1/quiz-sessions/\(sessionId)/answer" + case .completeQuizSession(let sessionId): + return "/v1/quiz-sessions/\(sessionId)/complete" + case .getUserQuizSessions(let userId): + return "/v1/quiz-sessions/user/\(userId)" + case .getQuizSessionDetail(let sessionId): + return "/v1/quiz-sessions/\(sessionId)" + case .deleteQuiz(let id): + return "/v1/quizzes/\(id)" + } + } + + var method: Moya.Method { + switch self { + case .createQuiz, .generateQuestions, .startQuizSession, .answerQuizSession, .completeQuizSession: + return .post + case .getQuizzes, .getWeekQuestions, .getQuizDetail, .getUserQuizSessions, .getQuizSessionDetail: + return .get + case .deleteQuiz: + return .delete + } + } + + var task: Task { + switch self { + case .getQuizzes(let userId): + // 사용자 ID로 퀴즈 목록 조회 + return .requestParameters(parameters: ["userId": userId], encoding: URLEncoding.queryString) + + case let .createQuiz(userId, title, description, weekIds, quizType, questionCount): + // 퀴즈 생성 시 필요한 파라미터 + let body: [String: Any] = [ + "userId": userId, + "title": title, + "description": description, + "weekIds": weekIds, + "quizType": quizType, + "questionCount": questionCount + ] + return .requestParameters(parameters: body, encoding: JSONEncoding.default) + + case .generateQuestions(_, let minQuestionCount): + let body = GenerateQuestionRequest(minQuestionCount: minQuestionCount) + return .requestJSONEncodable(body) + + case let .answerQuizSession(_, userAnswer): + // 사용자의 답변 기록 + return .requestParameters(parameters: ["userAnswer": userAnswer], encoding: JSONEncoding.default) + + case let .startQuizSession(_, userId): + return .requestParameters(parameters: ["userId": userId], encoding: URLEncoding.queryString) + + case .deleteQuiz: + return .requestPlain + + default: + return .requestPlain // GET 요청 또는 바디 없는 POST + } + } + + var headers: [String : String]? { + return [ + "accept": "*/*", + "content-type": "application/json" + ] + } +} diff --git a/Sources/QuizAPI/QuizResponse.swift b/Sources/QuizAPI/QuizResponse.swift new file mode 100644 index 0000000..0eab9b3 --- /dev/null +++ b/Sources/QuizAPI/QuizResponse.swift @@ -0,0 +1,131 @@ +// +// QuizResponse.swift +// Lecture2Quiz +// +// Created by 바견규 on 5/27/25. +// + +import Foundation + +// MARK: - 전체 퀴즈 요약 (목록용) +struct QuizSummary: Codable, Identifiable { + let id: Int + let title: String + let description: String + let quizType: String + let questionCount: Int // 실제는 totalQuestions로 들어옴 + let createdAt: String? + + enum CodingKeys: String, CodingKey { + case id + case title + case description + case quizType + case questionCount = "totalQuestions" // 키 매핑 + case createdAt + } +} + +// MARK: - 퀴즈 상세 +struct QuizDetailResponse: Decodable, Identifiable { + let id: Int + let title: String + let description: String + let quizType: String + let totalQuestions: Int + let creator: Creator? + let weeks: [Week] + let questions: [QuizQuestion] + let createdAt: String? + let modifiedAt: String? + + struct Creator: Decodable { + let id: Int + let name: String? + let email: String? + } + + struct Week: Decodable, Identifiable { + let id: Int + let title: String + let weekNumber: Int + let courseId: Int + let courseTitle: String + } + + struct QuizQuestion: Decodable, Identifiable { + let id: Int + let weekId: Int + let front: String + let back: String + } +} + +// MARK: - 퀴즈 세션 시작 응답 +struct QuizSessionStartResponse: Codable, Identifiable { + let id: Int // 세션 ID +} + +// MARK: - 퀴즈 세션 상세 +struct QuizSessionDetailResponse: Codable, Identifiable { + let id: Int + let quizId: Int + let quizTitle: String + let quizDescription: String + let totalQuestions: Int + let currentQuestionIndex: Int + let currentQuestion: QuizSessionQuestion? + let completed: Bool + let score: Int? + let totalQuestionsAnswered: Int? + let totalCorrectAnswers: Int? + let userAnswers: [UserAnswer] + let createdAt: String? + let completedAt: String? +} + +struct QuizSessionQuestion: Codable, Identifiable { + let id: Int + let weekId: Int + let front: String + let back: String +} + +struct UserAnswer: Codable, Identifiable { + let id: Int + let questionId: Int + let questionFront: String + let userAnswer: String + let correctAnswer: String + let isCorrect: Bool + let answeredAt: String +} + +// MARK: - 사용자별 퀴즈 세션 목록 +struct QuizSessionSummary: Codable, Identifiable { + let id: Int // 세션 ID + let quizTitle: String + let completed: Bool + let startedAt: String? +} + +// MARK: - 주차별 질문 생성 응답 +struct GenerateQuestionsResponse: Codable { + let questionIds: [Int] +} +// 주차별 질문 생성 Request Body 모델 +struct GenerateQuestionRequest: Encodable { + let minQuestionCount: Int +} + +// MARK: - 주차별 질문 조회 응답 +struct QuestionResponse: Codable, Identifiable { + let id: Int + let weekId: Int + let front: String + let back: String + + // 기존 뷰에서 .question, .answer를 쓰던 걸 유지하려면 computed property 제공 + var question: String { front } + var answer: String { back } +} diff --git a/Sources/ViewModels/Audio/AudioStream.swift b/Sources/ViewModels/Audio/AudioStream.swift index 9c17fb2..3695eb5 100644 --- a/Sources/ViewModels/Audio/AudioStream.swift +++ b/Sources/ViewModels/Audio/AudioStream.swift @@ -1,3 +1,9 @@ +// AudioStream.swift +// Lecture2Quiz +// +// Created by 바견규 on 4/27/25. +// + import AVFoundation class AudioStreamer { @@ -6,48 +12,49 @@ class AudioStreamer { private var inputFormat: AVAudioFormat? private var isPaused: Bool = false private var audioWebSocket: AudioWebSocket? - private var converter: AVAudioConverter? + private var partialBuffer = Data() // 🔄 남은 청크 보관 + private var isStreaming: Bool = false + // WhisperLive 설정에 맞춘 포맷 - private var bufferSize: AVAudioFrameCount = 4096 + private var bufferSize: AVAudioFrameCount = 1600 // 100ms 기준 private var sampleRate: Double = 16000 private var channels: UInt32 = 1 + // 🔄 리샘플링을 위한 오디오 컨버터 + private var converter: AVAudioConverter? + init(webSocket: AudioWebSocket) { self.inputNode = engine.inputNode self.audioWebSocket = webSocket + + // 💡 리샘플링 포맷 설정 + let inputFormat = inputNode.outputFormat(forBus: 0) + print("🔍 입력 포맷: \(inputFormat)") + + // 🔄 WhisperLive가 기대하는 16kHz Int16 포맷 생성 + let outputFormat = AVAudioFormat(commonFormat: .pcmFormatInt16, + sampleRate: 16000, + channels: 1, + interleaved: true)! + // 🔄 오디오 변환기 생성 + self.converter = AVAudioConverter(from: inputFormat, to: outputFormat) + self.inputFormat = outputFormat } // MARK: - 오디오 세션 설정 func configureAudioSession() { let session = AVAudioSession.sharedInstance() do { - try session.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .defaultToSpeaker]) - try session.setActive(true) - - // 🔍 사용 가능한 오디오 입력 디바이스 탐색 - if let availableInputs = session.availableInputs { - for input in availableInputs { - print("🔎 입력 디바이스 발견: \(input.portType.rawValue)") - if input.portType == .bluetoothHFP || input.portType == .bluetoothLE { - try session.setPreferredInput(input) - print("🎧 에어팟이 입력 디바이스로 설정되었습니다.") - } - } - } - - // ✅ 실제 하드웨어 포맷 가져오기 - let inputSampleRate = session.sampleRate - let inputChannels = UInt32(session.inputNumberOfChannels) - print("🎙️ 설정된 샘플레이트: \(inputSampleRate)") - print("🎙️ 설정된 채널 수: \(inputChannels)") - - // ✅ 샘플레이트를 16000으로 변환하도록 설정 - let inputFormat = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: inputSampleRate, channels: inputChannels, interleaved: false)! - let outputFormat = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: 16000, channels: inputChannels, interleaved: false)! - - converter = AVAudioConverter(from: inputFormat, to: outputFormat) - + try session.setCategory(.playAndRecord, mode: .default, options: [.allowBluetooth, .defaultToSpeaker]) + try session.setPreferredSampleRate(48000) + try session.setPreferredInputNumberOfChannels(1) // Mono로 강제 설정 + try session.setMode(.videoChat) + try session.setActive(true, options: .notifyOthersOnDeactivation) + sampleRate = session.sampleRate + channels = UInt32(session.inputNumberOfChannels) + print("🎙️ 설정된 샘플레이트: \(sampleRate)") + print("🎙️ 설정된 채널 수: \(channels)") } catch { print("🔴 오디오 세션 설정 실패: \(error.localizedDescription)") } @@ -55,78 +62,152 @@ class AudioStreamer { // MARK: - 오디오 스트리밍 시작 func startStreaming() { + guard !isStreaming else { + print("⚠️ 이미 스트리밍 중입니다.") + return + } + configureAudioSession() - let format = inputNode.outputFormat(forBus: 0) - self.inputFormat = format - + let format = AVAudioFormat(commonFormat: .pcmFormatFloat32, + sampleRate: 48000, + channels: channels, + interleaved: true) - self.inputFormat = format + guard let hardwareFormat = format else { + print("⚠️ 오디오 포맷 생성 실패") + return + } + + self.inputFormat = hardwareFormat - inputNode.installTap(onBus: 0, bufferSize: bufferSize, format: format) { [weak self] buffer, _ in + inputNode.installTap(onBus: 0, bufferSize: bufferSize, format: hardwareFormat) { [weak self] buffer, _ in self?.processAudioBuffer(buffer) } do { try engine.start() + isStreaming = true // ✅ 스트리밍 상태 활성화 print("🎙️ AVAudioEngine 시작됨") } catch { print("🔴 AVAudioEngine 시작 실패: \(error.localizedDescription)") } } - // MARK: - 오디오 버퍼를 WebSocket으로 서버로 전송 + + // MARK: - 오디오 버퍼를 WebSocket으로 전송 func processAudioBuffer(_ buffer: AVAudioPCMBuffer) { - guard let converter = converter else { return } + guard let converter = self.converter else { + print("❌ 오디오 컨버터 생성 실패") + return + } - let outputBuffer = AVAudioPCMBuffer(pcmFormat: converter.outputFormat, frameCapacity: buffer.frameCapacity)! - var error: NSError? + // 🔍 **RMS 계산** + if let floatChannelData = buffer.floatChannelData { + let frameLength = Int(buffer.frameLength) + let channelDataValue = Array(UnsafeBufferPointer(start: floatChannelData.pointee, count: frameLength)) + + // 🔄 RMS 계산 + let rms = sqrt(channelDataValue.map { $0 * $0 }.reduce(0, +) / Float(frameLength)) + print("🔊 오디오 RMS 값: \(rms)") + + // 🔍 너무 작으면 경고 로그 출력 + if rms < 0.001 { + print("⚠️ 볼륨이 너무 작습니다.") + } + } - let inputBlock: AVAudioConverterInputBlock = { inNumPackets, outStatus in + let outputFormat = AVAudioFormat(commonFormat: .pcmFormatInt16, + sampleRate: 16000, + channels: 1, + interleaved: true)! + + guard let newBuffer = AVAudioPCMBuffer(pcmFormat: outputFormat, frameCapacity: 1600) else { + print("❌ PCM Buffer 생성 실패") + return + } + + let inputBlock: AVAudioConverterInputBlock = { _, outStatus in outStatus.pointee = .haveData return buffer } - converter.convert(to: outputBuffer, error: &error, withInputFrom: inputBlock) + var error: NSError? + converter.convert(to: newBuffer, error: &error, withInputFrom: inputBlock) if let error = error { - print("🔴 변환 중 에러: \(error.localizedDescription)") + print("❌ 오디오 변환 실패: \(error.localizedDescription)") return } - if let audioData = convertBufferTo16BitPCM(outputBuffer) { - print("🔄 PCM 데이터 전송 중...") - audioWebSocket?.sendDataToServer(audioData) - } else { - print("Error: Audio buffer 변환 실패") + print("📝 변환된 Buffer Frame Length: \(newBuffer.frameLength), Sample Rate: \(newBuffer.format.sampleRate)") + + if let audioData = convertToFloat32BytesLikePython(newBuffer) { + var completeData = partialBuffer + audioData + let chunkSize = 4096 + + while completeData.count >= chunkSize { + let chunk = completeData.prefix(chunkSize) + audioWebSocket?.sendDataToServer(chunk) + print("🔄 오디오 데이터 전송 성공: 4096 바이트") + completeData.removeFirst(chunkSize) + } + + partialBuffer = completeData } } - // MARK: - 32bit float PCM -> 16bit int PCM 변환 - func convertBufferTo16BitPCM(_ buffer: AVAudioPCMBuffer) -> Data? { - guard let floatChannelData = buffer.floatChannelData else { - print("floatChannelData is nil") + + + // MARK: - Python의 bytes_to_float_array 메소드와 유사하게 변환 + func convertToFloat32BytesLikePython(_ buffer: AVAudioPCMBuffer) -> Data? { + guard let int16ChannelData = buffer.int16ChannelData else { + print("❌ int16ChannelData is nil") return nil } - let channelPointer = floatChannelData.pointee let frameLength = Int(buffer.frameLength) - var pcmData = Data(capacity: frameLength * MemoryLayout.size) + let channelPointer = int16ChannelData.pointee + + // Int16 → Float32 변환 + var floatArray = [Float32](repeating: 0, count: frameLength) + for i in 0...size)) + let scaled = floatArray[i] * gain + floatArray[i] = min(max(scaled, -1.0), 1.0) } - return pcmData + // Float32 배열 → Data 변환 + let floatData = Data(bytes: floatArray, count: frameLength * MemoryLayout.size) + + // 범위 로그 확인 + if let minVal = floatArray.min(), let maxVal = floatArray.max() { + print("🎚️ 정규화 후 Float32 값 범위: \(minVal)...\(maxVal)") + } + + print("🔄 Python 스타일로 Float32로 변환 완료 - \(floatData.count) bytes") + return floatData } + // MARK: - 오디오 스트리밍 일시 정지 func pauseStreaming() { guard !isPaused else { return } inputNode.removeTap(onBus: 0) isPaused = true + print("⏸️ 오디오 스트리밍 일시 정지됨") } // MARK: - 오디오 스트리밍 재개 @@ -141,13 +222,22 @@ class AudioStreamer { self?.processAudioBuffer(buffer) } isPaused = false + print("▶️ 오디오 스트리밍 재개됨") } // MARK: - 오디오 스트리밍 중지 func stopStreaming() { + guard isStreaming else { + print("⚠️ 이미 중지된 상태입니다.") + return + } + inputNode.removeTap(onBus: 0) engine.stop() print("🛑 AVAudioEngine 중지됨") + + // ❌ WebSocket 종료 제거 (ViewModel에서 수행) + isStreaming = false } -} +} diff --git a/Sources/ViewModels/Audio/AudioWebSocket.swift b/Sources/ViewModels/Audio/AudioWebSocket.swift index 485afbf..0f57449 100644 --- a/Sources/ViewModels/Audio/AudioWebSocket.swift +++ b/Sources/ViewModels/Audio/AudioWebSocket.swift @@ -1,159 +1,240 @@ -// AudioWebSocket.swift -// Lecture2Quiz -// -// Created by 바견규 on 5/12/25. -// - -import Foundation - -class AudioWebSocket: NSObject, URLSessionWebSocketDelegate { - private var webSocketTask: URLSessionWebSocketTask? - private var urlSession: URLSession! - private let host: String - private let port: Int - private var retryCount = 0 - private let maxRetries = 3 - private var uid: String - - init(host: String, port: Int) { - self.host = host - self.port = port - self.uid = UUID().uuidString // 고유 식별자 생성 - super.init() - - self.urlSession = URLSession( - configuration: .default, - delegate: self, - delegateQueue: .main - ) - connect() - } + // AudioWebSocket.swift + // Lecture2Quiz + + import Foundation + + class AudioWebSocket: NSObject, URLSessionWebSocketDelegate { + private var webSocketTask: URLSessionWebSocketTask? + private var urlSession: URLSession! + private let host: String + private let port: Int + private var retryCount = 0 + private let maxRetries = 3 + private var uid: String + private let modelSize: String + private var pingTimer: Timer? + private var processedTexts = Set() + + var onServerReady: (() -> Void)? + var onTranscriptionReceived: ((String) -> Void)? - // MARK: - WebSocket 연결 (재연결 지원) - private func connect() { - guard retryCount <= maxRetries else { - print("최대 재연결 시도 횟수 초과") - return + init(host: String, port: Int, modelSize: String = "small") { + self.host = host + self.port = port + self.uid = UUID().uuidString + self.modelSize = modelSize + super.init() + + self.urlSession = URLSession( + configuration: .default, + delegate: self, + delegateQueue: .main + ) + connect() } - - let socketURL = "wss://\(host)" - - guard let url = URL(string: socketURL) else { - print("잘못된 URL: \(socketURL)") - return + + private func connect() { + guard retryCount <= maxRetries else { + print("❌ 최대 재연결 시도 초과") + return + } + + let socketURL = port == 443 || port == 80 + ? "wss://\(host)" + : "wss://\(host):\(port)" + + guard let url = URL(string: socketURL) else { + print("❌ 잘못된 URL: \(socketURL)") + return + } + + webSocketTask = urlSession.webSocketTask(with: url) + webSocketTask?.resume() + print("📡 WebSocket 연결 시도: \(socketURL)") + listen() + sendInitialJSON() + startPing() } - - webSocketTask = urlSession.webSocketTask(with: url) - webSocketTask?.resume() - print("WebSocket 연결 시도: \(socketURL)") - sendInitialJSON() // ➡️ 처음 연결 시 JSON 전송 - listen() - } - // MARK: - 첫 JSON 전송 - private func sendInitialJSON() { - // JSON Payload 생성 - let jsonPayload: [String: Any] = [ - "uid": uid, - "language": "ko", - "task": "transcribe", - "model": "tiny", - "use_vad": false, - "max_clients": 4, - "max_connection_time": 600 - ] - - do { - let jsonData = try JSONSerialization.data(withJSONObject: jsonPayload, options: []) - let jsonString = String(data: jsonData, encoding: .utf8) ?? "" - print("📡 전송 JSON: \(jsonString)") - - // ✅ 전송 - webSocketTask?.send(.string(jsonString)) { [weak self] error in + private func sendInitialJSON() { + let jsonPayload: [String: Any] = [ + "uid": uid, + "language": "ko", + "task": "transcribe", + "model": modelSize, + "use_vad": true, + "max_clients": 4, + "max_connection_time": 600 + ] + + do { + let jsonData = try JSONSerialization.data(withJSONObject: jsonPayload, options: []) + let jsonString = String(data: jsonData, encoding: .utf8) ?? "" + print("📱 통신 JSON: \(jsonString)") + + webSocketTask?.send(.string(jsonString)) { [weak self] error in + if let error = error { + print("❌ JSON 전송 실패: \(error.localizedDescription)") + self?.reconnect() + } else { + print("✅ JSON 전송 성공") + } + } + } catch { + print("❌ JSON 직렬화 실패: \(error.localizedDescription)") + } + } + + func sendDataToServer(_ data: Data) { + guard isConnected else { + print("⚠️ 연결 안 됨 - 데이터 전송 생략") + reconnect() + return + } + + webSocketTask?.send(.data(data)) { [weak self] error in if let error = error { - print("JSON 전송 실패: \(error.localizedDescription)") + print("❌ 전송 실패: \(error.localizedDescription)") self?.reconnect() } else { - print("✅ JSON 전송 성공") + print("📀 파일 전송 성공: \(data.count) bytes") } } - } catch { - print("❌ JSON 직렬화 실패: \(error.localizedDescription)") } - } - // MARK: - 데이터 전송 - func sendDataToServer(_ data: Data) { - guard isConnected else { - print("전송 실패: 연결되지 않음") - reconnect() - return + internal var isConnected: Bool { + webSocketTask?.state == .running } - webSocketTask?.send(.data(data)) { [weak self] error in - if let error = error { - print("전송 실패: \(error.localizedDescription)") - self?.reconnect() - } else { - print("🔄 오디오 데이터 전송 성공") + private func reconnect() { + retryCount += 1 + stopPing() + let delay = min(5.0, pow(2.0, Double(retryCount))) + + DispatchQueue.global().asyncAfter(deadline: .now() + delay) { [weak self] in + print("🚪 재연결 시도 (\(self?.retryCount ?? 0)/\(self?.maxRetries ?? 0))") + self?.connect() } } - } - // MARK: - 연결 상태 체크 - private var isConnected: Bool { - webSocketTask?.state == .running - } + private func listen() { + webSocketTask?.receive { [weak self] result in + switch result { + case .success(let message): + self?.handleMessage(message) + self?.listen() + case .failure(let error): + print("❌ 수신 오류: \(error.localizedDescription)") + self?.reconnect() + } + } + } - // MARK: - 재연결 로직 - private func reconnect() { - retryCount += 1 - let delay = min(5.0, pow(2.0, Double(retryCount))) - - DispatchQueue.global().asyncAfter(deadline: .now() + delay) { [weak self] in - print("재연결 시도 (\(self?.retryCount ?? 0)/\(self?.maxRetries ?? 0))") - self?.connect() + private func handleMessage(_ message: URLSessionWebSocketTask.Message) { + switch message { + case .data(let data): + print("📂 서버 받은 바이너리: \(data.count) bytes") + + case .string(let text): + print("💬 서버 받은 텍스트: \(text)") + + guard let data = text.data(using: .utf8) else { return } + + do { + if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] { + if let status = json["status"] as? String { + handleStatusMessage(status: status, message: json["message"] as? String) + return + } + + if let message = json["message"] as? String, message == "SERVER_READY" { + print("✅ 서버 준비 완료") + onServerReady?() + return + } + + if let segments = json["segments"] as? [[String: Any]] { + for segment in segments { + if let text = segment["text"] as? String { + if !processedTexts.contains(text) { + processedTexts.insert(text) + print("✅ 트랜스크립션 추가됨: \(text)") + onTranscriptionReceived?(text) + } + } + } + } + } + } catch { + print("❌ JSON 파싱 오류: \(error.localizedDescription)") + } + + @unknown default: + print("❓ 알 수 없는 메시지") + } } - } - // MARK: - 메시지 수신 대기 - private func listen() { - webSocketTask?.receive { [weak self] result in - switch result { - case .success(let message): - self?.handleMessage(message) - self?.listen() - case .failure(let error): - print("수신 오류: \(error.localizedDescription)") - self?.reconnect() + private func handleStatusMessage(status: String, message: String?) { + switch status { + case "WAIT": + print("⏳ 대기 중: \(message ?? "")") + case "ERROR": + print("❌ 오류: \(message ?? "")") + case "WARNING": + print("⚠️ 경고: \(message ?? "")") + default: + print("ℹ️ \(status): \(message ?? "")") + } + } + + func sendEndOfAudio() { + guard isConnected else { + print("⚠️ WebSocket 연결 안 됨 - 종료 전송 생략") + return + } + + webSocketTask?.send(.string("END_OF_AUDIO")) { error in + if let error = error { + print("❌ End Of Audio 전송 실패: \(error.localizedDescription)") + } else { + print("✅ End Of Audio 전송 완료") + } } } - } - // MARK: - 메시지 처리 - private func handleMessage(_ message: URLSessionWebSocketTask.Message) { - switch message { - case .data(let data): - print("서버에서 받은 바이너리 데이터: \(data.count) bytes") - case .string(let text): - print("서버에서 받은 텍스트: \(text)") - @unknown default: - print("알 수 없는 메시지 타입") + + func closeConnection() { + stopPing() + webSocketTask?.cancel(with: .normalClosure, reason: nil) + retryCount = maxRetries + print("📤 WebSocket 종료 요청 완료") } - } - // MARK: - 연결 종료 처리 - func urlSession(_ session: URLSession, - webSocketTask: URLSessionWebSocketTask, - didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, - reason: Data?) { - print("연결 종료. 코드: \(closeCode.rawValue)") - reconnect() - } + private func startPing() { + stopPing() + pingTimer = Timer.scheduledTimer(withTimeInterval: 15.0, repeats: true) { [weak self] _ in + self?.webSocketTask?.sendPing { error in + if let error = error { + print("❌ Ping 전송 실패: \(error.localizedDescription)") + } else { + print("📱 Ping 전송 성공") + } + } + } + RunLoop.main.add(pingTimer!, forMode: .common) + } - // MARK: - 수동 연결 종료 - func closeConnection() { - webSocketTask?.cancel(with: .normalClosure, reason: nil) - print("WebSocket 연결 종료 요청") + private func stopPing() { + pingTimer?.invalidate() + pingTimer = nil + } + + func urlSession(_ session: URLSession, + webSocketTask: URLSessionWebSocketTask, + didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, + reason: Data?) { + print("📴 WebSocket 닫힘 - 코드: \(closeCode.rawValue), 이유: \(String(data: reason ?? Data(), encoding: .utf8) ?? "없음")") + stopPing() + reconnect() + } } -} diff --git a/Sources/ViewModels/Audio/RecordingViewModel.swift b/Sources/ViewModels/Audio/RecordingViewModel.swift index ec67146..11cb65a 100644 --- a/Sources/ViewModels/Audio/RecordingViewModel.swift +++ b/Sources/ViewModels/Audio/RecordingViewModel.swift @@ -7,55 +7,96 @@ import AVFoundation import Combine +import Moya class AudioViewModel: ObservableObject { @Published var isRecording = false @Published var isPaused = false @Published var timeLabel = "00:00" - + @Published var transcriptionList: [String] = [] + @Published var isLoading = false + @Published var finalScript: String = "" + + private var finalTextTimer: Timer? + private var finalTextDeadline: Date? + private var audioStreamer: AudioStreamer? private var audioWebSocket: AudioWebSocket? private var timer: Timer? private var elapsedTime: Int = 0 - - init() { - // WebSocket 서버 주소와 포트를 설정하여 AudioStreamer와 WebSocket을 초기화 - audioWebSocket = AudioWebSocket(host: "whisperlive-cpu-620597935007.us-central1.run.app", port: 443) - audioStreamer = AudioStreamer(webSocket: audioWebSocket!) - } - + + init() {} + func startRecording() { - isRecording = true - isPaused = false - timeLabel = "00:00" - elapsedTime = 0 + // 서버 URL 준비 + guard let audioAPIUrl = Bundle.main.object(forInfoDictionaryKey: "AudioAPI_URL") as? String else { + fatalError("❌ xcconfig에서 'AudioAPI_URL'을 찾을 수 없습니다.") + } - // 타이머 시작 - startTimer() - - // 오디오 스트리밍 시작 - audioStreamer?.startStreaming() + // WebSocket 초기화 + audioWebSocket = AudioWebSocket(host: audioAPIUrl, port: 443) + audioStreamer = AudioStreamer(webSocket: audioWebSocket!) + + isLoading = true // ✅ 서버 준비 기다리는 중 + + // 텍스트 수신 콜백(중복 제거) + audioWebSocket?.onTranscriptionReceived = { [weak self] text in + DispatchQueue.main.async { + guard let self = self else { return } + + if self.transcriptionList.last != text { + self.transcriptionList.append(text) + } + } + } + + // 서버가 준비됐을 때 녹음 시작 + audioWebSocket?.onServerReady = { [weak self] in + guard let self = self else { return } + DispatchQueue.main.async { + self.isLoading = false + self.isRecording = true + self.isPaused = false + self.timeLabel = "00:00" + self.elapsedTime = 0 + self.startTimer() + self.audioStreamer?.startStreaming() + } + } + + // WebSocket 연결은 AudioWebSocket 초기화 시 자동으로 이뤄져야 함 } - + func pauseRecording() { isPaused = true audioStreamer?.pauseStreaming() timer?.invalidate() } - + func resumeRecording() { isPaused = false audioStreamer?.resumeStreaming() startTimer() } - + func stopRecording() { isRecording = false - audioStreamer?.stopStreaming() + isPaused = false timer?.invalidate() + + audioStreamer?.stopStreaming() + audioWebSocket?.sendEndOfAudio() + + // 콜백을 제거해서 더 이상 transcription을 받지 않게 한다 + audioWebSocket?.onTranscriptionReceived = nil + + // 즉시 WebSocket 종료 (15초 대기 없이) + audioWebSocket?.closeConnection() } + + private func startTimer() { timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in self.elapsedTime += 1 @@ -64,4 +105,31 @@ class AudioViewModel: ObservableObject { self.timeLabel = String(format: "%02d:%02d", minutes, seconds) } } + + func finalizeTranscription() { + isLoading = false + finalScript = transcriptionList.joined(separator: " ") + print("📝 최종 스크립트:\n\(finalScript)") + } + + func postTranscript(to weekId: Int, type: String = "RECORDING") { + let content = finalScript.trimmingCharacters(in: .whitespacesAndNewlines) + let provider = MoyaProvider() + + let payload: [String: Any] = [ + "weekId": weekId, + "content": content, + "type": type + ] + + provider.request(.submitTranscript(weekId: weekId, content: content, type: type)) { result in + switch result { + case .success(let response): + print("✅ 전송 성공: \(response.statusCode)") + case .failure(let error): + print("❌ 전송 실패: \(error)") + } + } + + } } diff --git a/Sources/ViewModels/Audio/SubmitTranscriptViewModel.swift b/Sources/ViewModels/Audio/SubmitTranscriptViewModel.swift new file mode 100644 index 0000000..b69c644 --- /dev/null +++ b/Sources/ViewModels/Audio/SubmitTranscriptViewModel.swift @@ -0,0 +1,87 @@ +// +// SubmitTranscriptViewModel.swift +// Lecture2Quiz +// +// Created by 바견규 on 5/25/25. +// + +import Foundation +import Moya + +class SubmitTranscriptViewModel: ObservableObject { + @Published var folders: [CourseResponseByUserID] = [] + @Published var selectedCourseId: Int? + @Published var selectedWeekId: Int? + @Published var showAddWeekAlert = false + @Published var newWeekTitle: String = "" + + let userId: Int = 1 // TODO: 실제 로그인된 사용자 ID로 교체 + private let provider = MoyaProvider() + + var selectedCourse: CourseResponseByUserID? { + folders.first { $0.id == selectedCourseId } + } + + var selectedWeek: WeekResponseByUserID? { + selectedCourse?.weeks.first { $0.id == selectedWeekId } + } + + func fetchFolders() { + provider.request(.getUserCourses(userId: userId)) { [weak self] result in + switch result { + case .success(let response): + do { + let decoded = try JSONDecoder().decode([CourseResponseByUserID].self, from: response.data) + DispatchQueue.main.async { + self?.folders = decoded + + // 선택된 course나 week가 사라졌을 경우 대비 + if let courseId = self?.selectedCourseId, + !decoded.contains(where: { $0.id == courseId }) { + self?.selectedCourseId = nil + self?.selectedWeekId = nil + } + } + } catch { + print(" 파싱 오류: \(error)") + } + case .failure(let error): + print(" 요청 실패: \(error)") + } + } + } + + func addWeek(to course: CourseResponseByUserID, completion: @escaping () -> Void) { + guard !newWeekTitle.trimmingCharacters(in: .whitespaces).isEmpty else { return } + let newWeekNumber = (course.weeks.map { $0.id }.max() ?? 0) + 1 + provider.request(.createWeek(courseId: course.id, title: newWeekTitle, weekNumber: newWeekNumber)) { [weak self] result in + switch result { + case .success: + DispatchQueue.main.async { + self?.newWeekTitle = "" + completion() + } + case .failure(let error): + print(" 주차 생성 실패: \(error)") + } + } + } + + func submitTranscript(content: String, completion: @escaping (Bool) -> Void) { + guard let weekId = selectedWeekId else { + completion(false) + return + } + + provider.request(.submitTranscript(weekId: weekId, content: content, type: "RECORDING")) { result in + switch result { + case .success(let response): + print(" 저장 성공: \(response.statusCode)") + completion(true) + case .failure(let error): + print(" 저장 실패: \(error)") + completion(false) + } + } + } +} diff --git a/Sources/ViewModels/FileViewModel.swift b/Sources/ViewModels/FileViewModel.swift deleted file mode 100644 index cfd15d5..0000000 --- a/Sources/ViewModels/FileViewModel.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// FileViewModel.swift -// Lecture2Quiz -// -// Created by 바견규 on 3/29/25. -// - -import Foundation - -class FolderViewModel: ObservableObject { - @Published var folders: [AudioFolder] = [] - - init() { - // 예시 폴더 2개 - folders = [ - AudioFolder(id: UUID(), name: "소프트웨어 프로젝트", recordings: []), - AudioFolder(id: UUID(), name: "프로그래밍 언어", recordings: []) - ] - } - - func addFolder(name: String) { - let newFolder = AudioFolder(id: UUID(), name: name, recordings: []) - folders.append(newFolder) - } -} diff --git a/Sources/ViewModels/Folder/FileViewModel.swift b/Sources/ViewModels/Folder/FileViewModel.swift new file mode 100644 index 0000000..b706107 --- /dev/null +++ b/Sources/ViewModels/Folder/FileViewModel.swift @@ -0,0 +1,62 @@ +// +// FileViewModel.swift +// Lecture2Quiz +// +// Created by 바견규 on 3/29/25. +// + +import Foundation +import Moya + +class FolderViewModel: ObservableObject { + @Published var folders: [CourseResponseByUserID] = [] + @Published var isLoading: Bool = false // 로딩 상태 추가 + + private let provider = MoyaProvider() + + func fetchFolders(userId: Int) { + isLoading = true // 요청 시작 시 로딩 시작 + provider.request(.getUserCourses(userId: userId)) { result in + DispatchQueue.main.async { + self.isLoading = false // 요청 완료 시 로딩 종료 + } + switch result { + case .success(let response): + do { + if let json = String(data: response.data, encoding: .utf8) { + print("📦 실제 서버 응답:\n\(json)") + } + self.folders = try JSONDecoder().decode([CourseResponseByUserID].self, from: response.data) + } catch { + print("❌ 파싱 오류: \(error)") + } + case .failure(let error): + print("❌ 요청 실패: \(error)") + } + } + } + + func addFolder(name: String, userId: Int) { + provider.request(.createCourse(userId: userId, title: name, description: "")) { result in + switch result { + case .success: + self.fetchFolders(userId: userId) + print("수업 생성 성공 userId: \(userId), name: \(name)") + case .failure(let error): + print("❌ 수업 생성 실패: \(error)") + } + } + } + + func createWeek(courseId: Int, title: String, weekNumber: Int, completion: @escaping () -> Void) { + provider.request(.createWeek(courseId: courseId, title: title, weekNumber: weekNumber)) { result in + switch result { + case .success(let response): + print("✅ 주차 생성 완료: \(response.statusCode)") + completion() + case .failure(let error): + print("❌ 주차 생성 실패: \(error)") + } + } + } +} diff --git a/Sources/ViewModels/Folder/TextListViewModel.swift b/Sources/ViewModels/Folder/TextListViewModel.swift new file mode 100644 index 0000000..b51e4a5 --- /dev/null +++ b/Sources/ViewModels/Folder/TextListViewModel.swift @@ -0,0 +1,55 @@ +import Foundation +import Moya + +class TextListViewModel: ObservableObject { + @Published var texts: [WeekTextResponse] = [] + @Published var isLoading = false + @Published var showActionSheet = false + + private let weekId: Int + private let provider = MoyaProvider() + private let onDeleteSuccess: () -> Void + + init(weekId: Int, onDeleteSuccess: @escaping () -> Void) { + self.weekId = weekId + self.onDeleteSuccess = onDeleteSuccess + fetchTexts() + } + + func fetchTexts() { + isLoading = true + provider.request(.getWeekText(weekId: weekId)) { [weak self] result in + DispatchQueue.main.async { + self?.isLoading = false + switch result { + case .success(let response): + do { + self?.texts = try JSONDecoder().decode([WeekTextResponse].self, from: response.data) + } catch { + print("❌ 텍스트 파싱 오류: \(error)") + } + case .failure(let error): + print("❌ 텍스트 요청 실패: \(error)") + } + } + } + } + + func deleteWeek() { + provider.request(.deleteWeek(weekId: weekId)) { [weak self] result in + DispatchQueue.main.async { + switch result { + case .success(let response): + if (200..<300).contains(response.statusCode) { + print("✅ 주차 삭제 성공") + self?.onDeleteSuccess() + } else { + print("⚠️ 삭제 실패: \(response.statusCode)") + } + case .failure(let error): + print("❌ 주차 삭제 실패: \(error)") + } + } + } + } +} diff --git a/Sources/ViewModels/Folder/TextViewModel.swift b/Sources/ViewModels/Folder/TextViewModel.swift new file mode 100644 index 0000000..45d341c --- /dev/null +++ b/Sources/ViewModels/Folder/TextViewModel.swift @@ -0,0 +1,139 @@ +// +// TextViewModel.swift +// Lecture2Quiz +// +// Created by 바견규 on 5/25/25. +// + +import Foundation +import Moya + +class TextViewModel: ObservableObject { + enum Tab { + case script, sumary, keyword + } + + @Published var selectedTab: TextTopTab = .script + @Published var text: String + @Published var sumary: String? + @Published var keywords: [String]? + @Published var isEditing: Bool = false + @Published var isShowingActionSheet: Bool = false + + let id: Int + private let provider = MoyaProvider() + + init(text: String, sumary: String?, id: Int) { + self.text = text + self.sumary = sumary + self.id = id + + if sumary == nil { + generateSummary() + } + + fetchKeywords() + } + + // MARK: - 요약 생성 요청 및 반영 + func generateSummary() { + provider.request(.summarizeText(textId: id)) { [weak self] result in + switch result { + case .success: + self?.fetchSummary() + case .failure(let error): + print("❌ 요약 생성 실패: \(error)") + } + } + } + + func fetchSummary() { + provider.request(.getTextById(id: id)) { [weak self] result in + switch result { + case .success(let response): + do { + let decoded = try JSONDecoder().decode(TextDetailResponse.self, from: response.data) + DispatchQueue.main.async { + self?.sumary = decoded.summation + } + } catch { + print("❌ 요약 디코딩 실패: \(error)") + if let fallback = String(data: response.data, encoding: .utf8) { + print("📦 서버 응답 본문:\n\(fallback)") + } + } + + case .failure(let error): + print("❌ 요약 fetch 실패: \(error)") + } + } + } + + // MARK: - 키워드 생성 요청 및 반영 + func fetchKeywords() { + provider.request(.getKeywords(textId: id)) { [weak self] result in + switch result { + case .success(let response): + do { + let decoded = try JSONDecoder().decode([String].self, from: response.data) + DispatchQueue.main.async { + if decoded.isEmpty { + self?.generateKeywords() + } else { + self?.keywords = decoded.map { $0 } + } + } + } catch { + print("❌ 키워드 파싱 실패: \(error)") + } + case .failure(let error): + print("❌ 키워드 fetch 실패: \(error)") + } + } + } + + func generateKeywords() { + provider.request(.createKeyword(textId: id)) { [weak self] result in + switch result { + case .success: + self?.fetchKeywords() + case .failure(let error): + print("❌ 키워드 생성 실패: \(error)") + } + } + } + + func deleteText(onComplete: (() -> Void)? = nil) { + provider.request(.deleteText(textId: id)) { [weak self] result in + switch result { + case .success: + print("✅ 텍스트 삭제 성공") + DispatchQueue.main.async { + onComplete?() + } + case .failure(let error): + print("❌ 텍스트 삭제 실패: \(error)") + } + } + } + + func refreshText() { + provider.request(.getTextById(id: id)) { [weak self] result in + switch result { + case .success(let response): + do { + let decoded = try JSONDecoder().decode(TextDetailResponse.self, from: response.data) + DispatchQueue.main.async { + self?.text = decoded.content + self?.sumary = decoded.summation + } + } catch { + print("❌ 텍스트 리프레시 디코딩 실패: \(error)") + } + case .failure(let error): + print("❌ 텍스트 리프레시 실패: \(error)") + } + } + } + +} diff --git a/Sources/ViewModels/Folder/WeekViewModel.swift b/Sources/ViewModels/Folder/WeekViewModel.swift new file mode 100644 index 0000000..182df18 --- /dev/null +++ b/Sources/ViewModels/Folder/WeekViewModel.swift @@ -0,0 +1,41 @@ +// +// WeekViewModel.swift +// Lecture2Quiz +// +// Created by 바견규 on 5/30/25. +// + +import Foundation +import SwiftUI +import Moya + +class WeekListViewModel: ObservableObject { + @Published var course: CourseResponseByUserID + @Published var isShowingActionSheet: Bool = false + @Published var isDeleting: Bool = false + + private let provider = MoyaProvider() + var onDeleteSuccess: () -> Void + + init(course: CourseResponseByUserID, onDeleteSuccess: @escaping () -> Void) { + self.course = course + self.onDeleteSuccess = onDeleteSuccess + } + + func deleteCourse() { + isDeleting = true + provider.request(.deleteCourse(courseId: course.id)) { [weak self] result in + guard let self = self else { return } + DispatchQueue.main.async { + self.isDeleting = false + switch result { + case .success(let response): + print("삭제 성공: \(response.statusCode)") + self.onDeleteSuccess() + case .failure(let error): + print("삭제 실패: \(error.localizedDescription)") + } + } + } + } +} diff --git a/Sources/ViewModels/QuizCardViewModel.swift b/Sources/ViewModels/Quiz/QuizCardViewModel.swift similarity index 63% rename from Sources/ViewModels/QuizCardViewModel.swift rename to Sources/ViewModels/Quiz/QuizCardViewModel.swift index 62660c7..cdaae6a 100644 --- a/Sources/ViewModels/QuizCardViewModel.swift +++ b/Sources/ViewModels/Quiz/QuizCardViewModel.swift @@ -7,19 +7,18 @@ import Foundation -class QuizViewModel: ObservableObject { +class QuizCardViewModel: ObservableObject { @Published var cards: [QuizCard] = [] @Published var currentIndex: Int = 0 @Published var correct: [UUID] = [] @Published var wrong: [UUID] = [] + var quizViewModel: QuizViewModel? + + var onAllAnswered: (() -> Void)? + var onAnswer: ((Int, Bool) -> Void)? - init() { - // 예시 카드 - cards = [ - QuizCard(question: "Swift의 UI 프레임워크는?", answer: "SwiftUI"), - QuizCard(question: "애플의 OS는?", answer: "iOS"), - QuizCard(question: "iPhone은 어느 회사 제품?", answer: "Apple") - ] + init(cards: [QuizCard] = []) { + self.cards = cards } var currentCard: QuizCard? { @@ -29,12 +28,20 @@ class QuizViewModel: ObservableObject { func swipeCard(isCorrect: Bool) { guard let currentCard = currentCard else { return } + if isCorrect { correct.append(currentCard.id) } else { wrong.append(currentCard.id) } + + onAnswer?(currentIndex, isCorrect) currentIndex += 1 + + if currentIndex >= cards.count { + // ✅ 마지막 카드 넘긴 후에만 호출됨 + onAllAnswered?() + } } func restart() { @@ -44,3 +51,4 @@ class QuizViewModel: ObservableObject { } } + diff --git a/Sources/ViewModels/Quiz/QuizRecordViewModel.swift b/Sources/ViewModels/Quiz/QuizRecordViewModel.swift new file mode 100644 index 0000000..e94204d --- /dev/null +++ b/Sources/ViewModels/Quiz/QuizRecordViewModel.swift @@ -0,0 +1,94 @@ +// +// QuizRecordViewModel.swift +// Lecture2Quiz +// +// Created by 바견규 on 5/28/25. +// + +// QuizRecordViewModel.swift +import Foundation +import Moya + +class QuizRecordViewModel: ObservableObject { + @Published var sessions: [QuizSessionSummary] = [] + @Published var selectedSessionDetail: QuizSessionDetailResponse? = nil + @Published var selectedQuizDetailForSession: QuizSessionDetailResponse? = nil + @Published var currentSessionId: Int? = nil + + private let quizProvider = MoyaProvider() + private let userId = 1 + + // 전체 세션 조회 + func fetchQuizSessions() { + quizProvider.request(.getUserQuizSessions(userId: userId)) { [weak self] result in + switch result { + case .success(let response): + do { + self?.sessions = try JSONDecoder().decode([QuizSessionSummary].self, from: response.data) + } catch { + print("❌ 세션 목록 디코딩 실패: \(error)") + } + case .failure(let error): + print("❌ 세션 목록 조회 실패: \(error)") + } + } + } + + // 세션 상세 조회 + func fetchSessionDetail(sessionId: Int, useForSheet: Bool = true, completion: (() -> Void)? = nil) { + quizProvider.request(.getQuizSessionDetail(sessionId: sessionId)) { [weak self] result in + switch result { + case .success(let response): + do { + let detail = try JSONDecoder().decode(QuizSessionDetailResponse.self, from: response.data) + DispatchQueue.main.async { + if useForSheet { + self?.selectedSessionDetail = detail + } else { + self?.selectedQuizDetailForSession = detail + } + self?.currentSessionId = detail.id + completion?() + } + } catch { + print("❌ 세션 디코딩 실패: \(error)") + completion?() + } + case .failure(let error): + print("❌ 세션 요청 실패: \(error)") + completion?() + } + } + } + + + // 답변 전송 + func sendAnswer(answer: String, completion: (() -> Void)? = nil) { + guard let sessionId = currentSessionId else { + print("❌ 세션 ID 없음") + completion?() + return + } + + quizProvider.request(.answerQuizSession(sessionId: sessionId, userAnswer: answer)) { result in + switch result { + case .success(let response): + print("✅ 답변 전송 성공: \(response.statusCode)") + case .failure(let error): + print("❌ 답변 전송 실패: \(error)") + } + completion?() + } + } + + // 세션 종료 + func completeQuizSession() { + guard let sessionId = currentSessionId else { return } + quizProvider.request(.completeQuizSession(sessionId: sessionId)) { result in + if case let .failure(error) = result { + print("❌ 세션 완료 실패: \(error)") + } + print("✅ 퀴즈 세션 완료") + } + } +} diff --git a/Sources/ViewModels/Quiz/QuizViewModel.swift b/Sources/ViewModels/Quiz/QuizViewModel.swift new file mode 100644 index 0000000..1cd63b4 --- /dev/null +++ b/Sources/ViewModels/Quiz/QuizViewModel.swift @@ -0,0 +1,299 @@ +// +// QuizViewModel.swift +// Lecture2Quiz +// +// Created by 바견규 on 5/26/25. +// + +import Foundation +import Moya +import SwiftUI + +class QuizViewModel: ObservableObject { + @Published var selectedTab: QuizTopTab = .WeekQuestion + + // MARK: - 수업 및 주차 + @Published var courses: [CourseResponseByUserID] = [] + @Published var weeks: [WeekResponseByUserID] = [] + + // MARK: - 퀴즈 목록 및 주차별 퀴즈 존재 여부 + @Published var quizzes: [QuizSummary] = [] + @Published var weekQuizExist: [Int: Bool] = [:] + + // MARK: - 퀴즈 세션용 + @Published var selectedQuizDetailForSheet: QuizDetailResponse? + @Published var selectedQuizDetailForSession: QuizDetailResponse? + + @Published var currentSessionId: Int? + @Published var quizCards: [QuizCard] = [] + + // MARK: - 퀴즈 만들기용 + @Published var selectedCourseId: Int? = nil + @Published var selectedWeekIds: Set = [] + // 해당 수업의 주차만 가져오는 computed property + var filteredWeeks: [WeekResponseByUserID] { + guard let courseId = selectedCourseId else { return [] } + return weeks.filter { $0.courseId == courseId } + } + + private let courseProvider = MoyaProvider() + private let quizProvider = MoyaProvider() + private let userId = 1 + + + // MARK: - 수업 목록 불러오기 (모든 수업의 주차 반영) + func fetchCourses() { + courseProvider.request(.getUserCourses(userId: userId)) { [weak self] result in + switch result { + case .success(let response): + do { + let courses = try JSONDecoder().decode([CourseResponseByUserID].self, from: response.data) + DispatchQueue.main.async { + self?.courses = courses + let allWeeks = courses.flatMap { $0.weeks } + self?.weeks = allWeeks + } + } catch { + print("❌ 수업 디코딩 실패: \(error)") + } + case .failure(let error): + print("❌ 수업 조회 실패: \(error)") + } + } + } + + + // MARK: - 퀴즈 전체 조회 + func fetchAllQuizzes() { + quizProvider.request(.getQuizzes(userId: userId)) { [weak self] result in + switch result { + case .success(let response): + do { + let decoded = try JSONDecoder().decode([QuizSummary].self, from: response.data) + DispatchQueue.main.async { + self?.quizzes = decoded + } + } catch { + print("❌ 퀴즈 디코딩 실패: \(error)") + print(String(data: response.data, encoding: .utf8) ?? "응답 디버깅 실패") + } + case .failure(let error): + print("❌ 퀴즈 조회 실패: \(error)") + } + } + } + + + // MARK: - 퀴즈 상세 조회 + func fetchQuizDetail(id: Int, useForSheet: Bool, completion: @escaping () -> Void) { + quizProvider.request(.getQuizDetail(id: id)) { [weak self] result in + switch result { + case .success(let response): + print("📦 Raw Detail Response:", String(data: response.data, encoding: .utf8) ?? "N/A") + do { + let detail = try JSONDecoder().decode(QuizDetailResponse.self, from: response.data) + DispatchQueue.main.async { + if useForSheet { + self?.selectedQuizDetailForSheet = detail + } else { + self?.selectedQuizDetailForSession = detail + } + completion() + } + } catch { + print("❌ 상세 디코딩 실패: \(error)") + completion() + } + case .failure(let error): + print("❌ 상세 요청 실패: \(error)") + completion() + } + } + } + + + // MARK: - 퀴즈 세션 시작 + func startQuizSession(quizId: Int, completion: @escaping (Bool) -> Void) { + quizProvider.request(.startQuizSession(quizId: quizId, userId: userId)) { [weak self] result in + switch result { + case .success(let response): + let success = (200...299).contains(response.statusCode) + guard success else { + print("❌ 퀴즈 세션 시작 실패: \(response.statusCode)") + print(String(data: response.data, encoding: .utf8) ?? "") + DispatchQueue.main.async { + self?.quizCards = [] + completion(false) + } + return + } + + do { + // ✅ 세션 ID만 파싱 + let sessionId = try JSONDecoder().decode(Int.self, from: response.data) + self?.currentSessionId = sessionId + print("✅ 퀴즈 세션 시작 성공 (sessionId: \(sessionId))") + + // ✅ 카드 생성을 위해 퀴즈 상세 요청 + self?.fetchQuizDetail(id: quizId, useForSheet: false) { + let cards = self?.selectedQuizDetailForSession?.questions.map { + QuizCard(question: $0.front, answer: $0.back) + } ?? [] + + DispatchQueue.main.async { + self?.quizCards = cards + completion(true) + } + } + + } catch { + print("❌ 세션 응답 디코딩 실패: \(error)") + completion(false) + } + + case .failure(let error): + print("❌ 세션 요청 실패: \(error)") + DispatchQueue.main.async { + self?.quizCards = [] + completion(false) + } + } + } + } + + + + + + + + + + // MARK: - 카드 넘길 때 답변 전송 + func sendAnswer(answer: String, completion: (() -> Void)? = nil) { + guard let sessionId = currentSessionId else { return } + print("📤 답변 전송 시작 (sessionId: \(sessionId), answer: \(answer))") + + quizProvider.request(.answerQuizSession(sessionId: sessionId, userAnswer: answer)) { result in + switch result { + case .success(let response): + print("✅ 답변 전송 성공: \(response.statusCode)") + case .failure(let error): + print("❌ 답변 전송 실패: \(error)") + } + completion?() + } + } + + + // MARK: - 세션 완료 처리 + func completeQuizSession() { + guard let sessionId = currentSessionId else { return } + quizProvider.request(.completeQuizSession(sessionId: sessionId)) { result in + if case let .failure(error) = result { + print("❌ 세션 완료 실패: \(error)") + } + print("퀴즈 세션 완료") + } + } + + // MARK: - 퀴즈 시작 + 카드 셋팅 + 뷰 전환 트리거 + func startQuizAndShowDeck(quizId: Int, quizCardViewModel: QuizCardViewModel, showDeck: Binding) { + startQuizSession(quizId: quizId) { [weak self] success in + guard success, let self = self else { return } + + // 카드 셋업하고 덱 보여주기 + quizCardViewModel.cards = self.quizCards + showDeck.wrappedValue = true + } + } + + + + // MARK: - 주차 기반 퀴즈 생성 + func createQuiz(for weekIds: [Int], courseTitle: String, questionCount: Int = 5) { + let request = QuizAPI.createQuiz( + userId: userId, + title: "\(courseTitle) 퀴즈", + description: "weeks: \(weekIds)", + weekIds: weekIds, + quizType: "AUTO", + questionCount: questionCount + ) + + quizProvider.request(request) { [weak self] result in + switch result { + case .success(let response): + print("✅ 퀴즈 생성 응답:", response.statusCode) + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + self?.fetchAllQuizzes() + } + + case .failure(let error): + print("❌ 퀴즈 생성 실패: \(error)") + } + } + } + + // MARK: - 필터링된 주차 계산(질문이 하나라도 있는 주차만 사용) + func getWeeksWithQuestions(for courseId: Int, completion: @escaping ([WeekResponseByUserID]) -> Void) { + guard let course = courses.first(where: { $0.id == courseId }) else { + completion([]) + return + } + + let group = DispatchGroup() + var result: [WeekResponseByUserID] = [] + + for week in course.weeks { + group.enter() + quizProvider.request(.getWeekQuestions(weekId: week.id)) { response in + defer { group.leave() } + + switch response { + case .success(let res): + if let questions = try? JSONDecoder().decode([QuestionResponse].self, from: res.data), !questions.isEmpty { + result.append(week) + } + case .failure: + break + } + } + } + + group.notify(queue: .main) { + completion(result) + } + } + + func selectedCourseChanged(to courseId: Int) { + selectedCourseId = courseId + if let course = courses.first(where: { $0.id == courseId }) { + weeks = course.weeks + } else { + weeks = [] + } + } + + func deleteQuiz(id: Int, completion: @escaping () -> Void) { + quizProvider.request(.deleteQuiz(id: id)) { [weak self] result in + switch result { + case .success(let response): + if (200...299).contains(response.statusCode) { + print("✅ 퀴즈 삭제 성공") + DispatchQueue.main.async { + self?.fetchAllQuizzes() + completion() + } + } else { + print("❌ 퀴즈 삭제 실패: \(response.statusCode)") + } + case .failure(let error): + print("❌ 퀴즈 삭제 요청 실패: \(error)") + } + } + } + + +} + diff --git a/Sources/ViewModels/Quiz/WeekQuestionViewModel.swift b/Sources/ViewModels/Quiz/WeekQuestionViewModel.swift new file mode 100644 index 0000000..d11f4e4 --- /dev/null +++ b/Sources/ViewModels/Quiz/WeekQuestionViewModel.swift @@ -0,0 +1,93 @@ +// +// WeekQuestionViewModel.swift +// Lecture2Quiz +// +// Created by 바견규 on 5/27/25. +// + + +import Foundation +import Moya + +class WeekQuestionViewModel: ObservableObject { + @Published var courses: [CourseResponseByUserID] = [] + @Published var selectedCourseId: Int? + @Published var weeks: [WeekResponseByUserID] = [] + @Published var questionsPerWeek: [Int: [QuestionResponse]] = [:] // weekId → 질문 배열 + + var selectedCourseTitle: String? { //수업 선택 picker용 + if let id = selectedCourseId { + return courses.first(where: { $0.id == id })?.title + } + return nil + } + + private let courseProvider = MoyaProvider() + private let quizProvider = MoyaProvider() + private let userId = 1 + + func fetchCourses() { + courseProvider.request(.getUserCourses(userId: userId)) { [weak self] result in + switch result { + case .success(let response): + do { + let courses = try JSONDecoder().decode([CourseResponseByUserID].self, from: response.data) + DispatchQueue.main.async { + self?.courses = courses + self?.selectedCourseId = courses.first?.id + self?.weeks = courses.first?.weeks ?? [] + } + } catch { + print("❌ 수업 디코딩 실패: \(error)") + } + case .failure(let error): + print("❌ 수업 조회 실패: \(error)") + } + } + } + + func selectCourse(id: Int) { + selectedCourseId = id + if let course = courses.first(where: { $0.id == id }) { + weeks = course.weeks + } + } + + func fetchQuestions(for weekId: Int) { + quizProvider.request(.getWeekQuestions(weekId: weekId)) { [weak self] result in + switch result { + case .success(let response): + do { + let questions = try JSONDecoder().decode([QuestionResponse].self, from: response.data) + DispatchQueue.main.async { + self?.questionsPerWeek[weekId] = questions + } + } catch { + print(String(data: response.data, encoding: .utf8) ?? "응답 출력 실패") + print("❌ 질문 디코딩 실패: \(error)") + } + case .failure(let error): + print("❌ 질문 조회 실패: \(error)") + } + } + } + + func generateQuestions(for weekId: Int, minCount: Int = 3) { + quizProvider.request(.generateQuestions(weekId: weekId, minQuestionCount: minCount)) { [weak self] result in + switch result { + case .success(let response): + do { + let result = try JSONDecoder().decode(GenerateQuestionsResponse.self, from: response.data) + print("✅ 생성된 질문 ID: \(result.questionIds)") + self?.fetchQuestions(for: weekId) + } catch { + print(String(data: response.data, encoding: .utf8) ?? "응답 출력 실패") + print("❌ 질문 생성 응답 디코딩 실패: \(error)") + } + case .failure(let error): + print("❌ 질문 생성 실패: \(error)") + } + } + } +} + diff --git a/Sources/Views/Class/FileListView.swift b/Sources/Views/Class/FileListView.swift new file mode 100644 index 0000000..a70b22a --- /dev/null +++ b/Sources/Views/Class/FileListView.swift @@ -0,0 +1,124 @@ +// +// FileListView.swift +// Lecture2Quiz +// +// Created by 바견규 on 3/29/25. +// + +import SwiftUI + +struct FolderListView: View { + @StateObject private var viewModel = FolderViewModel() + @State private var isSelected: Bool = true + @State private var showAddFolderAlert = false + @State private var newFolderName = "" + + let userId: Int = 1 // TODO: 실제 로그인한 사용자 ID로 바꾸세요 + + var body: some View { + GeometryReader { geo in + NavigationStack { + VStack { + UpperView() + Spacer().frame(height: geo.size.height * 0.05) + + defaultFolder(isSelected: $isSelected) { + showAddFolderAlert = true + } + + if isSelected { + if viewModel.isLoading { + ProgressView("폴더 불러오는 중...") + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 30) + } else { + ForEach(viewModel.folders) { folder in + NavigationLink(destination: WeekListView(course: folder, onDeleteSuccess: { + viewModel.fetchFolders(userId: userId) + })) { + Folder(folderName: folder.title) + .padding(.leading) + } + } + } + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .padding() + .alert("새 폴더 생성", isPresented: $showAddFolderAlert) { + TextField("수업 이름", text: $newFolderName) + Button("추가") { + let trimmed = newFolderName.trimmingCharacters(in: .whitespaces) + if !trimmed.isEmpty { + viewModel.addFolder(name: trimmed, userId: userId) + newFolderName = "" + } + } + Button("취소", role: .cancel) { + newFolderName = "" + } + } + .onAppear { + viewModel.fetchFolders(userId: userId) + } + } + } +} + +struct UpperView: View { + var body: some View { + HStack { + Text("폴더") + .font(.system(size: 28, weight: .bold)) + Spacer() + } + .padding(.horizontal) + } +} + +struct defaultFolder: View { + @Binding var isSelected: Bool + var onAddFolder: () -> Void + + var body: some View { + HStack { + Image(systemName: "tray.2") + .font(.system(size: 18, weight: .semibold)) + Text("기본 폴더") + .font(.system(size: 18, weight: .semibold)) + Spacer() + Button(action: onAddFolder) { + Image(systemName: "plus") + .foregroundColor(.gray) + } + Button(action: { + isSelected.toggle() + }) { + Image(systemName: isSelected ? "chevron.up" : "chevron.down") + .foregroundColor(.gray) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +struct Folder: View { + let folderName: String + + var body: some View { + HStack { + Image(systemName: "folder") + .foregroundColor(.black) + Text(folderName) + .foregroundColor(.black) + } + .font(.system(size: 18, weight: .semibold)) + .padding(.vertical, 2) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +#Preview { + FolderListView() +} diff --git a/Sources/Views/Class/Week/CustomCourseActionSheet.swift b/Sources/Views/Class/Week/CustomCourseActionSheet.swift new file mode 100644 index 0000000..a2670a0 --- /dev/null +++ b/Sources/Views/Class/Week/CustomCourseActionSheet.swift @@ -0,0 +1,62 @@ +// +// CustomCourseActionSheet.swift +// Lecture2Quiz +// +// Created by 바견규 on 5/30/25. +// + +import SwiftUI + +struct CustomCourseActionSheet: View { + var onDelete: () -> Void + var onCancel: () -> Void + var actionStr:String + + var body: some View { + VStack(spacing: 10) { + VStack(spacing: 0) { + Button(role: .destructive) { + onDelete() + } label: { + Text(actionStr) + .frame(maxWidth: .infinity) + .frame(height: 55) + .background(Color.white) + } + + Divider() + + Button { + onCancel() + } label: { + Text("취소") + .frame(maxWidth: .infinity) + .frame(height: 55) + .background(Color.white) + } + } + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .shadow(radius: 5) + .padding(.horizontal, 16) + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black.opacity(0.001)) // 투명 배경으로 만들기 + .ignoresSafeArea() + } +} +#Preview { + CustomCourseActionSheet( + onDelete: { + print("🗑️ 삭제 프리뷰") + }, + onCancel: { + print("❌ 취소 프리뷰") + }, + actionStr: "삭제" + ) + .previewLayout(.sizeThatFits) + .padding() + .background(Color.gray.opacity(0.2)) +} diff --git a/Sources/Views/Class/Week/TextList/Text Detail/EditTextView.swift b/Sources/Views/Class/Week/TextList/Text Detail/EditTextView.swift new file mode 100644 index 0000000..d311455 --- /dev/null +++ b/Sources/Views/Class/Week/TextList/Text Detail/EditTextView.swift @@ -0,0 +1,64 @@ +import SwiftUI +import Moya + +struct EditTextView: View { + let originalText: String + let textId: Int + let originalType: String + var onEditCompleted: (() -> Void)? = nil + + @Environment(\.dismiss) private var dismiss + @State private var editedText: String + @State private var isLoading = false + + private let provider = MoyaProvider() + + init(originalText: String, textId: Int, originalType: String, onEditCompleted: (() -> Void)? = nil) { + self.originalText = originalText + self.textId = textId + self.originalType = originalType + self.onEditCompleted = onEditCompleted + _editedText = State(initialValue: originalText) + } + + var body: some View { + NavigationStack { + VStack { + TextEditor(text: $editedText) + .padding() + .frame(maxHeight: .infinity) + + if isLoading { + ProgressView("수정 중...") + .padding() + .frame(maxWidth: .infinity) + } else { + Button("수정 완료") { + isLoading = true + provider.request(.updateText(textId: textId, content: editedText, type: originalType)) { result in + DispatchQueue.main.async { + isLoading = false + switch result { + case .success: + onEditCompleted?() + dismiss() + case .failure(let error): + print("❌ 수정 실패: \(error)") + // 오류 표시를 원한다면 Alert 처리도 가능 + } + } + } + } + .padding() + .frame(maxWidth: .infinity) + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(10) + .padding() + } + } + .navigationTitle("텍스트 수정") + .navigationBarTitleDisplayMode(.inline) + } + } +} diff --git a/Sources/Views/Class/Week/TextList/Text Detail/TextActionSheet.swift b/Sources/Views/Class/Week/TextList/Text Detail/TextActionSheet.swift new file mode 100644 index 0000000..9826878 --- /dev/null +++ b/Sources/Views/Class/Week/TextList/Text Detail/TextActionSheet.swift @@ -0,0 +1,38 @@ +// +// TextActionSheet.swift +// Lecture2Quiz +// +// Created by 바견규 on 5/30/25. +// + +import SwiftUI + +struct CustomTextActionSheet: View { + var onEdit: () -> Void + var onDelete: () -> Void + var onCancel: () -> Void + + var body: some View { + VStack(spacing: 0) { + Button(action: onEdit) { + Text("텍스트 수정") + .frame(maxWidth: .infinity) + .padding() + } + Divider() + Button(role: .destructive, action: onDelete) { + Text("텍스트 삭제") + .frame(maxWidth: .infinity) + .padding() + } + Divider() + Button(action: onCancel) { + Text("취소") + .frame(maxWidth: .infinity) + .padding() + } + + Spacer().frame(height: 20) // 하단 여백 + } + } +} diff --git a/Sources/Views/Class/Week/TextList/Text Detail/TextDetailTabView.swift b/Sources/Views/Class/Week/TextList/Text Detail/TextDetailTabView.swift new file mode 100644 index 0000000..1260aab --- /dev/null +++ b/Sources/Views/Class/Week/TextList/Text Detail/TextDetailTabView.swift @@ -0,0 +1,77 @@ +// +// TextDetailTabView.swift +// Lecture2Quiz +// +// Created by 바견규 on 5/25/25. +// + +import SwiftUI + +enum TextTopTab: String, CaseIterable { + case script = "음성 기록" + case sumary = "요약" + case keyword = "키워드" +} + +struct TextDetailTabView: View { + @Namespace private var animation + @ObservedObject var viewModel: TextViewModel + + var body: some View { + VStack(spacing: 0) { + tabBar + separatorLine + } + } + + // ✅ 탭 바 부분 분리 + private var tabBar: some View { + HStack(spacing: 20) { + ForEach(TextTopTab.allCases, id: \.self) { tab in + tabItem(for: tab) + } + } + .padding(.top) + .shadow(color: Color.black.opacity(0.1), radius: 2, y: 1) + } + + // ✅ 탭 항목 개별 뷰 분리 + private func tabItem(for tab: TextTopTab) -> some View { + let isSelected = viewModel.selectedTab == tab + + return VStack(spacing: 4) { + Button(action: { + withAnimation(.easeInOut) { + viewModel.selectedTab = tab + } + }) { + Text(tab.rawValue) + .font(Font.Pretend.pretendardBold(size: 16)) + .foregroundColor(isSelected ? .black : .gray) + } + .padding() + + if isSelected { + Capsule() + .fill(Color.green) + .frame(height: 3) + .matchedGeometryEffect(id: "underline", in: animation) + } else { + Color.clear.frame(height: 3) + } + } + .padding(.bottom, -10) + } + + // ✅ 하단 구분선 + private var separatorLine: some View { + ZStack(alignment: .leading) { + Color.white.opacity(0.01) + .frame(height: 3) + .shadow(color: Color.black.opacity(0.3), radius: 2, y: 1) + } + .frame(height: 3) + } +} + + diff --git a/Sources/Views/Class/Week/TextList/Text Detail/TextDetailView.swift b/Sources/Views/Class/Week/TextList/Text Detail/TextDetailView.swift new file mode 100644 index 0000000..04cc35d --- /dev/null +++ b/Sources/Views/Class/Week/TextList/Text Detail/TextDetailView.swift @@ -0,0 +1,129 @@ +// +// TextDetailView.swift +// Lecture2Quiz +// +// Created by 바견규 on 5/25/25. +// + + +import SwiftUI + +struct TextDetailView: View { + @Namespace private var animation + @StateObject private var viewModel: TextViewModel + @Environment(\.dismiss) private var dismiss + + @State private var isEditing = false + + init(text: String, sumary: String? = nil, id: Int) { + _viewModel = StateObject(wrappedValue: TextViewModel(text: text, sumary: sumary, id: id)) + } + + var body: some View { + VStack(spacing: 0) { + TextDetailTabView(viewModel: viewModel) + + Group { + switch viewModel.selectedTab { + case .script: + ScrollView { + Text(viewModel.text) + .frame(maxWidth: .infinity, alignment: .leading) + } + case .sumary: + if let summary = viewModel.sumary { + ScrollView { + Text(summary) + .frame(maxWidth: .infinity, alignment: .leading) + } + } else { + ProgressView("요약 불러오는 중...") + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + case .keyword: + if let keywords = viewModel.keywords { + if keywords.isEmpty { + Text("키워드 없음") + } else { + VStack(alignment: .leading, spacing: 8) { + ForEach(keywords, id: \.self) { keyword in + Text("• \(keyword)") + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } else { + ProgressView("키워드 불러오는 중...") + } + } + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .navigationTitle("텍스트 상세") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + viewModel.isShowingActionSheet = true + } label: { + Image(systemName: "ellipsis") + .rotationEffect(.degrees(90)) + } + .foregroundColor(.black) + } + } + .sheet(isPresented: $viewModel.isShowingActionSheet) { + CustomTextActionSheet( + onEdit: { + viewModel.isShowingActionSheet = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + isEditing = true + } + }, + onDelete: { + viewModel.deleteText { + dismiss() + } + }, + onCancel: { + viewModel.isShowingActionSheet = false + } + ) + .presentationDetents([.height(220)]) + .presentationDragIndicator(.visible) + } + .sheet(isPresented: $isEditing) { + EditTextView( + originalText: viewModel.text, + textId: viewModel.id, + originalType: "String", + onEditCompleted: { + viewModel.refreshText() + } + ) + } + } +} + + + + +#Preview { + TextDetailView( + text: "이것은 대본입니다. 강의 내용을 여기에 입력하세요.", + sumary: "이것은 요약입니다. 핵심 내용을 간략히 정리한 내용입니다.", + id: 1 + ) +} + + + + +#Preview { + TextDetailView( + text: "이것은 대본입니다. 강의 내용을 여기에 입력하세요.", + sumary: "이것은 요약입니다. 핵심 내용을 간략히 정리한 내용입니다.", + id: 1 + ) +} diff --git a/Sources/Views/Class/Week/TextList/TextListView.swift b/Sources/Views/Class/Week/TextList/TextListView.swift new file mode 100644 index 0000000..ae33121 --- /dev/null +++ b/Sources/Views/Class/Week/TextList/TextListView.swift @@ -0,0 +1,151 @@ +// +// TextListView.swift +// Lecture2Quiz +// +// Created by 바견규 on 5/25/25. +// + +import SwiftUI +import Moya + +struct TextListView: View { + let weekId: Int + let courseTitle: String + let weekTitle: String + + @StateObject private var viewModel: TextListViewModel + @Environment(\.dismiss) private var dismiss + + init(weekId: Int, courseTitle: String, weekTitle: String, onDeleteSuccess: @escaping () -> Void) { + self.weekId = weekId + self.courseTitle = courseTitle + self.weekTitle = weekTitle + _viewModel = StateObject(wrappedValue: TextListViewModel( + weekId: weekId, + onDeleteSuccess: onDeleteSuccess + )) + } + + var body: some View { + VStack(alignment: .leading) { + if viewModel.isLoading { + ProgressView("텍스트 불러오는 중...") + .frame(maxWidth: .infinity, alignment: .center) + } else if viewModel.texts.isEmpty { + Text("📭 텍스트가 없습니다.") + .foregroundColor(.gray) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 50) + } else { + ScrollView { + VStack(spacing: 16) { + HStack { + Text(weekTitle) + .font(.title) + .bold() + Spacer() + Button { + viewModel.showActionSheet = true + } label: { + Image(systemName: "ellipsis") + .rotationEffect(.degrees(90)) // 세로로 ... + .foregroundColor(.primary) + .padding() + } + } + .padding(.top) + ForEach(viewModel.texts) { text in + NavigationLink { + TextDetailView( + text: text.content, + sumary: text.summation, + id: text.id + ) + } label: { + HStack { + Text("\(courseTitle) - \(weekTitle) - #\(text.id)") + .font(.body) + .foregroundColor(.primary) + Spacer() + } + .padding() + .frame(maxWidth: .infinity) + .background(Color(.systemGray6)) + .cornerRadius(12) + .shadow(color: Color.black.opacity(0.05), radius: 4, x: 0, y: 2) + } + } + Spacer() + } + .padding(.horizontal) + } + } + } + .padding(.top) + .navigationTitle("텍스트 목록") + .navigationBarTitleDisplayMode(.inline) + .sheet(isPresented: $viewModel.showActionSheet) { + CustomCourseActionSheet( + onDelete: { + viewModel.deleteWeek() + }, + onCancel: { + viewModel.showActionSheet = false + }, + actionStr: "주차 삭제" + ) + .presentationDetents([.height(140)]) + .presentationDragIndicator(.visible) + .padding(.top, 24) + } + } +} + +let mockTexts: [WeekTextResponse] = [ + WeekTextResponse(id: 1, weekId: 10, content: "본문 1", summation: "요약 1"), + WeekTextResponse(id: 2, weekId: 10, content: "본문 2", summation: "요약 2") +] + +struct TextListPreviewWrapper: View { + @StateObject private var viewModel = TextListViewModel( + weekId: 10, + onDeleteSuccess: { + print("삭제됨 (Preview)") + } + ) + + var body: some View { + NavigationStack { + VStack { + ScrollView { + VStack(spacing: 16) { + ForEach(viewModel.texts) { text in + HStack { + Text("프로그래밍 언어 - 1주차 - #\(text.id)") + Spacer() + } + .padding() + .frame(maxWidth: .infinity) + .background(Color(.systemGray6)) + .cornerRadius(12) + } + } + .padding() + } + } + .navigationTitle("텍스트 목록") + .onAppear { + viewModel.texts = mockTexts // 강제 주입 + viewModel.isLoading = false + } + } + } +} + +#Preview { + TextListPreviewWrapper() +} + + + + diff --git a/Sources/Views/Class/Week/Week.swift b/Sources/Views/Class/Week/Week.swift new file mode 100644 index 0000000..1ccfca9 --- /dev/null +++ b/Sources/Views/Class/Week/Week.swift @@ -0,0 +1,133 @@ +// +// Week.swift +// Lecture2Quiz +// +// Created by 바견규 on 5/25/25. +// + +import SwiftUI + +struct WeekListView: View { + @StateObject private var viewModel: WeekListViewModel + + init(course: CourseResponseByUserID, onDeleteSuccess: @escaping () -> Void) { + _viewModel = StateObject(wrappedValue: WeekListViewModel(course: course, onDeleteSuccess: onDeleteSuccess)) + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text(viewModel.course.title) + .font(.title) + .bold() + Spacer() + Button { + viewModel.isShowingActionSheet = true + } label: { + Image(systemName: "ellipsis") + .rotationEffect(.degrees(90)) // 세로로 ... + .foregroundColor(.primary) + .padding() + } + } + .padding(.top) + + if viewModel.course.weeks.isEmpty { + Text("등록된 주차가 없습니다.") + .font(.subheadline) + .foregroundColor(.gray) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 100) + } else { + ForEach(viewModel.course.weeks) { week in + WeekRowView( + week: week, + courseTitle: viewModel.course.title, + onDeleteSuccess: viewModel.onDeleteSuccess + ) + } + } + } + .padding(.horizontal) + } + .navigationTitle("주차 목록") + .navigationBarTitleDisplayMode(.inline) + .sheet(isPresented: $viewModel.isShowingActionSheet) { + CustomCourseActionSheet( + onDelete: { + viewModel.deleteCourse() + }, + onCancel: { + viewModel.isShowingActionSheet = false + }, + actionStr: "수업 삭제" + ) + .presentationDetents([.height(140)]) + .presentationDragIndicator(.visible) + .padding(.top, 24) + } + } +} + + + + struct WeekRowView: View { + let week: WeekResponseByUserID + let courseTitle: String + let onDeleteSuccess: () -> Void + + var body: some View { + NavigationLink( + destination: TextListView( + weekId: week.id, + courseTitle: courseTitle, + weekTitle: week.title, + onDeleteSuccess: onDeleteSuccess + ) + ) { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("🗓️ \(week.title)") + .font(.headline) + .foregroundColor(.primary) + Spacer() + } + Text("Week ID: \(week.id)") + .font(.caption) + .foregroundColor(.secondary) + } + .padding() + .background(Color(.systemGray6)) + .cornerRadius(12) + .shadow(color: Color.black.opacity(0.05), radius: 4, x: 0, y: 2) + } + } + } + + + + + + // Preview용 Mock 데이터 + // 📦 Mock 데이터 + let mockCourse = CourseResponseByUserID( + id: 1, + title: "프로그래밍 언어", + description: "프로그래밍 언어 수업입니다.", + weeks: [ + WeekResponseByUserID(id: 101, courseId: 1, title: "1주차 - 변수와 자료형"), + WeekResponseByUserID(id: 102, courseId: 1, title: "2주차 - 제어문"), + WeekResponseByUserID(id: 103, courseId: 1, title: "3주차 - 함수") + ] + ) + + // 🧪 Preview + #Preview { + NavigationStack { + WeekListView(course: mockCourse) { + print("삭제 성공 (Preview)") + } + } + } + diff --git a/Sources/Views/FileListView.swift b/Sources/Views/FileListView.swift deleted file mode 100644 index 9dfaa94..0000000 --- a/Sources/Views/FileListView.swift +++ /dev/null @@ -1,135 +0,0 @@ -// -// FileListView.swift -// Lecture2Quiz -// -// Created by 바견규 on 3/29/25. -// - -import SwiftUI - -struct FolderListView: View { - @StateObject private var viewModel = FolderViewModel() - @State private var isSelected: Bool = true - @State private var showAddFolderAlert = false - @State private var newFolderName = "" - - var body: some View { - GeometryReader { geo in - VStack(){ - - UpperView() // 네비게이션 바 - Spacer().frame(height: geo.size.height * 0.05) - // 기본 폴더 - defaultFolder(isSelected: $isSelected) { - showAddFolderAlert = true - } - if isSelected { - ForEach (viewModel.folders) { folder in - Folder(folderName: folder.name) - .padding(.leading) - } - } - }.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .padding() - .alert("새 폴더 생성", isPresented: $showAddFolderAlert) { - TextField("폴더 이름", text: $newFolderName) - Button("추가") { - if !newFolderName.trimmingCharacters(in: .whitespaces).isEmpty { - viewModel.addFolder(name: newFolderName) - newFolderName = "" - } - } - Button("취소", role: .cancel) { - newFolderName = "" - } - } - - } - } - -} - - -struct UpperView: View { - - var body: some View { - HStack{ - Text("폴더") - .font(Font.Pretend.pretendardExtraBold(size: 28)) - Spacer() - Button(action:{print("로그아웃")}){Image("logout")} - .frame(width: 35,height: 35) - } - .padding(.horizontal) - } -} - -struct defaultFolder: View { - @Binding var isSelected: Bool - var onAddFolder: () -> Void // ⬅️ 외부에서 액션 주입 - - var body: some View { - HStack{ - Image(systemName: "tray.2") - .font(Font.Pretend.pretendardBold(size: 18)) - Text("기본 폴더") - .font(Font.Pretend.pretendardBold(size: 18)) - Spacer() - Button(action: { - onAddFolder() - }){ - Image(systemName: "plus") - .font(Font.Pretend.pretendardRegular(size: 18)) - .foregroundStyle(Color.gray) - } - - if isSelected { - Button(action: { - isSelected.toggle() - }){ - Image(systemName: "chevron.up") - .font(Font.Pretend.pretendardRegular(size: 18)) - .foregroundStyle(Color.gray) - } - }else{ - Button(action: { - isSelected.toggle() - }){ - Image(systemName: "chevron.down") - .font(Font.Pretend.pretendardRegular(size: 18)) - .foregroundStyle(Color.gray) - } - } - - } - .frame(maxWidth: .infinity, alignment: .leading) - - } -} - - -struct Folder: View{ - let folderName: String - - var body: some View { - HStack { - Button(action: { - print("Selected folder: \(folderName)") - }, label: { - Image(systemName:"folder") - .foregroundStyle(Color.black) - .font(Font.Pretend.pretendardSemiBold(size: 18)) - Text(folderName) - .foregroundStyle(Color.black) - .font(Font.Pretend.pretendardSemiBold(size: 18)) - }) - .padding(.vertical, 2) - } - .frame(maxWidth: .infinity, alignment: .leading) - - } -} - -#Preview { - FolderListView() -} diff --git a/Sources/Views/HomeView.swift b/Sources/Views/Home/HomeView.swift similarity index 94% rename from Sources/Views/HomeView.swift rename to Sources/Views/Home/HomeView.swift index 31fdac4..7629311 100644 --- a/Sources/Views/HomeView.swift +++ b/Sources/Views/Home/HomeView.swift @@ -94,11 +94,13 @@ } } - RecordingModal { - withAnimation { - showRecordingModal = false + RecordingModal( + onDismiss: { + withAnimation { + showRecordingModal = false + } } - } + ) .zIndex(1) } diff --git a/Sources/Views/Lecture2QuizTabView.swift b/Sources/Views/Lecture2QuizTabView.swift index ee06739..e0ad0cb 100644 --- a/Sources/Views/Lecture2QuizTabView.swift +++ b/Sources/Views/Lecture2QuizTabView.swift @@ -19,7 +19,7 @@ struct TableView: View { FolderListView() .tag("Class") - QuizDeckView() + QuizMainView() .tag("Quiz") Text("OtherView") @@ -35,7 +35,8 @@ struct TableView: View { } .padding() .background(Color.white) - } + }.navigationBarBackButtonHidden(true) + .interactiveDismissDisabled(true) } @ViewBuilder @@ -58,5 +59,7 @@ struct TableView: View { } #Preview { - + NavigationStack { + TableView() + } } diff --git a/Sources/Views/Quiz/Quiz/CreationQuizSheetView.swift b/Sources/Views/Quiz/Quiz/CreationQuizSheetView.swift new file mode 100644 index 0000000..47ebc33 --- /dev/null +++ b/Sources/Views/Quiz/Quiz/CreationQuizSheetView.swift @@ -0,0 +1,85 @@ +// +// CreationQuizSheetView.swift +// Lecture2Quiz +// +// Created by 바견규 on 5/28/25. +// + +import SwiftUI + +struct CreateQuizSheetView: View { + @ObservedObject var viewModel: QuizViewModel + @Environment(\.dismiss) var dismiss + + @State private var selectedCourseId: Int? + @State private var selectedWeekIds: Set = [] + @State private var questionCount: Int = 5 + @State private var filteredWeeks: [WeekResponseByUserID] = [] + + var body: some View { + NavigationStack { + Form { + // ✅ 수업 선택 + Section(header: Text("수업 선택")) { + Picker("수업", selection: $selectedCourseId) { + ForEach(viewModel.courses) { course in + Text(course.title).tag(Optional(course.id)) + } + } + .onChange(of: selectedCourseId) { oldValue, newValue in + if let id = newValue { + viewModel.getWeeksWithQuestions(for: id) { weeks in + filteredWeeks = weeks + selectedWeekIds = [] // 선택 초기화 + } + } + } + + } + + // ✅ 질문이 있는 주차만 표시 + if !filteredWeeks.isEmpty { + Section(header: Text("주차 선택 (질문 있음)")) { + ForEach(filteredWeeks) { week in + Toggle(week.title, isOn: Binding( + get: { selectedWeekIds.contains(week.id) }, + set: { isOn in + if isOn { + selectedWeekIds.insert(week.id) + } else { + selectedWeekIds.remove(week.id) + } + } + )) + } + } + } + + // ✅ 문항 수 + Section(header: Text("문항 수")) { + Stepper(value: $questionCount, in: 1...20) { + Text("\(questionCount)문항") + } + } + + // ✅ 생성 버튼 + Section { + Button("퀴즈 생성") { + if let courseId = selectedCourseId { + viewModel.createQuiz( + for: Array(selectedWeekIds), + courseTitle: viewModel.courses.first(where: { $0.id == courseId })?.title ?? "", + questionCount: questionCount + ) + dismiss() + } + } + .disabled(selectedWeekIds.isEmpty || selectedCourseId == nil) + } + } + .navigationTitle("퀴즈 생성") + .navigationBarTitleDisplayMode(.inline) + } + } +} + diff --git a/Sources/Views/QuizCard.swift b/Sources/Views/Quiz/Quiz/QuizCard.swift similarity index 100% rename from Sources/Views/QuizCard.swift rename to Sources/Views/Quiz/Quiz/QuizCard.swift diff --git a/Sources/Views/Quiz/Quiz/QuizDeckView.swift b/Sources/Views/Quiz/Quiz/QuizDeckView.swift new file mode 100644 index 0000000..8aa2174 --- /dev/null +++ b/Sources/Views/Quiz/Quiz/QuizDeckView.swift @@ -0,0 +1,117 @@ + // + // QuizDeckView.swift + // Lecture2Quiz + // + // Created by 바견규 on 3/29/25. + // + + import SwiftUI + + struct QuizDeckView: View { + @StateObject var viewModel: QuizCardViewModel + @Binding var isPresented: Bool + + var body: some View { + ZStack { + if viewModel.currentIndex >= viewModel.cards.count { + VStack { + VStack(spacing: 20) { + Image(systemName: "checkmark.seal.fill") + .resizable() + .scaledToFit() + .frame(width: 80, height: 80) + .foregroundColor(.green) + + Text("퀴즈 완료!") + .font(.title) + .fontWeight(.bold) + + HStack(spacing: 40) { + VStack { + Text("정답") + .font(.subheadline) + .foregroundColor(.gray) + Text("\(viewModel.correct.count)") + .font(.title2) + .fontWeight(.semibold) + .foregroundColor(.green) + } + + VStack { + Text("오답") + .font(.subheadline) + .foregroundColor(.gray) + Text("\(viewModel.wrong.count)") + .font(.title2) + .fontWeight(.semibold) + .foregroundColor(.red) + } + } + + Button(action: { + isPresented = false + }) { + Text("닫기") + .frame(maxWidth: .infinity) + .padding() + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(10) + } + .padding(.horizontal) + } + .padding() + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color.white) + .shadow(radius: 5) + ) + .padding() + + } + } else { + ForEach(viewModel.cards.indices.reversed(), id: \.self) { index in + if index >= viewModel.currentIndex { + let card = viewModel.cards[index] + QuizCardView(card: card) { isCorrect in + viewModel.swipeCard(isCorrect: isCorrect) + } + .padding() + .zIndex(Double(viewModel.cards.count - index)) + .animation(.easeInOut, value: viewModel.currentIndex) + } + } + } + } + .background(Color.gray.opacity(0.1).ignoresSafeArea()) + } + } + + + struct QuizDeckViewWrapper: View { + @ObservedObject var viewModel: QuizViewModel + @Binding var isPresented: Bool + + var body: some View { + let cardVM = QuizCardViewModel(cards: viewModel.quizCards) + + cardVM.onAnswer = { index, isCorrect in + viewModel.sendAnswer(answer: isCorrect ? "O" : "X") { + let isLast = index == viewModel.quizCards.count - 1 + if isLast { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + viewModel.completeQuizSession() + viewModel.quizCards = [] + isPresented = false + } + } + } + } + + return QuizDeckView(viewModel: cardVM, isPresented: $isPresented) + } + } + + + + diff --git a/Sources/Views/Quiz/Quiz/QuizDetailSheet.swift b/Sources/Views/Quiz/Quiz/QuizDetailSheet.swift new file mode 100644 index 0000000..6310262 --- /dev/null +++ b/Sources/Views/Quiz/Quiz/QuizDetailSheet.swift @@ -0,0 +1,66 @@ +// +// QuizDetailSheet.swift +// Lecture2Quiz +// +// Created by 바견규 on 5/27/25. +// + +import SwiftUI + +struct QuizDetailSheet: View { + let detail: QuizDetailResponse + @ObservedObject var viewModel: QuizViewModel + @Environment(\.dismiss) private var dismiss + + @State private var isDeleting = false + + var body: some View { + NavigationStack { + VStack(alignment: .leading, spacing: 16) { + Text(detail.title) + .font(.title) + .bold() + + Text(detail.description) + .font(.body) + + HStack { + Text("문항 수:") + .fontWeight(.semibold) + Text("\(detail.totalQuestions)") + } + + HStack { + Text("퀴즈 유형:") + .fontWeight(.semibold) + Text(detail.quizType) + } + + if isDeleting { + ProgressView("삭제 중...") + .frame(maxWidth: .infinity) + .padding() + } else { + Button(role: .destructive) { + isDeleting = true + viewModel.deleteQuiz(id: detail.id) { + isDeleting = false + dismiss() + } + } label: { + Text("퀴즈 삭제") + .frame(maxWidth: .infinity) + .padding() + } + .background(Color.red.opacity(0.1)) + .cornerRadius(8) + } + + Spacer() + } + .padding() + .navigationTitle("퀴즈 상세") + .navigationBarTitleDisplayMode(.inline) + } + } +} diff --git a/Sources/Views/Quiz/Quiz/QuizView.swift b/Sources/Views/Quiz/Quiz/QuizView.swift new file mode 100644 index 0000000..a588b7a --- /dev/null +++ b/Sources/Views/Quiz/Quiz/QuizView.swift @@ -0,0 +1,88 @@ +// +// QuizView.swift +// Lecture2Quiz +// +// Created by 바견규 on 5/26/25. +// + +import SwiftUI + +struct QuizView: View { + @ObservedObject var viewModel: QuizViewModel + @State private var showCreateQuizSheet = false + @State private var isDeckPresented = false + + var body: some View { + VStack(spacing: 16) { + + + HStack { + Spacer() + Button(action:{showCreateQuizSheet = true}, label: { + Text("➕ 퀴즈 생성") + .font(Font.Pretend.pretendardBold(size: 18)) + .foregroundColor(.black) + }) + .padding(.trailing) + } + + + if viewModel.quizzes.isEmpty { + Spacer() + ProgressView("퀴즈를 불러오는 중...") + Spacer() + } else { + List(viewModel.quizzes, id: \.id) { quiz in + VStack(alignment: .leading, spacing: 8) { + Text(quiz.title) + .font(.headline) + + Text(quiz.description) + .font(.caption) + .foregroundColor(.gray) + + HStack { + // 정보 보기 + Button("정보 보기") { + viewModel.fetchQuizDetail(id: quiz.id, useForSheet: true) { } + } + + Spacer() + + // 퀴즈 풀기 + Button("퀴즈 풀기") { + viewModel.fetchQuizDetail(id: quiz.id, useForSheet: false) { + viewModel.startQuizSession(quizId: quiz.id) { success in + if success { + isDeckPresented = true + } else { + print("❌ 세션 시작 실패") + } + } + } + } + .buttonStyle(.borderedProminent) + } + } + .padding(.vertical, 6) + } + } + } + .padding(.top) + .onAppear { + viewModel.fetchAllQuizzes() + if viewModel.courses.isEmpty { + viewModel.fetchCourses() + } + } + .sheet(isPresented: $showCreateQuizSheet) { + CreateQuizSheetView(viewModel: viewModel) + } + .sheet(item: $viewModel.selectedQuizDetailForSheet) { detail in + QuizDetailSheet(detail: detail, viewModel: viewModel) + } + .fullScreenCover(isPresented: $isDeckPresented) { + QuizDeckViewWrapper(viewModel: viewModel, isPresented: $isDeckPresented) + } + } +} diff --git a/Sources/Views/Quiz/QuizMainView.swift b/Sources/Views/Quiz/QuizMainView.swift new file mode 100644 index 0000000..1b2546f --- /dev/null +++ b/Sources/Views/Quiz/QuizMainView.swift @@ -0,0 +1,38 @@ +// +// QuizMainView.swift +// Lecture2Quiz +// +// Created by 바견규 on 5/26/25. +// + +import SwiftUI + +struct QuizMainView: View { + @Namespace private var animation + @StateObject private var quizViewModel: QuizViewModel = QuizViewModel() + @StateObject private var weekQuestionViewModel: WeekQuestionViewModel = WeekQuestionViewModel() + + var body: some View { + VStack(spacing: 0) { + QuizTopTabView(viewModel: quizViewModel) + + Group { + switch quizViewModel.selectedTab { + case .WeekQuestion: + WeekQuestionView(viewModel: weekQuestionViewModel) + case .Quiz: + QuizView(viewModel: quizViewModel) + case .QuizRecord: + QuizRecordView() + } + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .navigationBarTitleDisplayMode(.inline) + } +} + + + + diff --git a/Sources/Views/Quiz/QuizSession/QuizRecordView.swift b/Sources/Views/Quiz/QuizSession/QuizRecordView.swift new file mode 100644 index 0000000..3c782ba --- /dev/null +++ b/Sources/Views/Quiz/QuizSession/QuizRecordView.swift @@ -0,0 +1,165 @@ +// +// QuizRecordView.swift +// Lecture2Quiz +// +// Created by 바견규 on 5/27/25. +// + +import SwiftUI + +struct QuizRecordView: View { + @StateObject private var viewModel = QuizRecordViewModel() + @State private var showQuizDeck = false + @State private var cardVM = QuizCardViewModel() + + var body: some View { + NavigationStack { + List { + ForEach(viewModel.sessions) { session in + VStack(alignment: .leading, spacing: 8) { + Text(session.quizTitle) + .font(.headline) + HStack { + Text("시작 시간: \(session.startedAt ?? "시간 정보 없음")") + .font(.caption) + .foregroundColor(.gray) + + Spacer() + + if session.completed { + Button("기록 보기") { + viewModel.fetchSessionDetail(sessionId: session.id) + } + .buttonStyle(.borderedProminent) + .tint(.black) + .foregroundColor(.white) + + } else { + Button("이어서 풀기") { + handleResumeSession(for: session) + } + .buttonStyle(.borderedProminent) + .tint(.orange) + .foregroundColor(.white) + } + } + } + .padding(.vertical, 6) + } + } + .navigationTitle("퀴즈 기록") + .onAppear { + viewModel.fetchQuizSessions() + } + .sheet(item: $viewModel.selectedSessionDetail) { detail in + QuizSessionDetailSheet(detail: detail) + } + .fullScreenCover(isPresented: $showQuizDeck, onDismiss: { + DispatchQueue.main.async { + viewModel.fetchQuizSessions() + } + }) { + QuizDeckView(viewModel: cardVM, isPresented: $showQuizDeck) + } + } + } + + // MARK: - 이어서 풀기 로직 분리 + private func handleResumeSession(for session: QuizSessionSummary) { + viewModel.fetchSessionDetail(sessionId: session.id, useForSheet: false) { + guard let current = viewModel.selectedQuizDetailForSession?.currentQuestion else { + print("❌ currentQuestion 없음") + return + } + + cardVM = QuizCardViewModel(cards: [ + QuizCard(question: current.front, answer: current.back) + ]) + + cardVM.onAnswer = handleAnswer(sessionId: session.id) + + + showQuizDeck = true + + cardVM.onAllAnswered = { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.8) { + viewModel.completeQuizSession() + showQuizDeck = false + } + } + } + } + + + // MARK: - 답변 처리 로직 분리 + private func handleAnswer(sessionId: Int) -> (Int, Bool) -> Void { + return { index, isCorrect in + viewModel.sendAnswer(answer: isCorrect ? "O" : "X") { + viewModel.fetchSessionDetail(sessionId: sessionId, useForSheet: false) { + if let next = viewModel.selectedQuizDetailForSession?.currentQuestion { + cardVM.cards = [ + QuizCard(question: next.front, answer: next.back) + ] + + } + + } + } + + } + } +} + + + + + +// MARK: - QuizSessionSummary 프리뷰용 더미 모델 +let sampleSessions: [QuizSessionSummary] = [ + QuizSessionSummary(id: 1, quizTitle: "Swift 기초 퀴즈", completed: true, startedAt: "2025-05-25 14:30"), + QuizSessionSummary(id: 2, quizTitle: "iOS 아키텍처", completed: false, startedAt: "2025-05-26 10:00") +] + +let sampleDetail = QuizSessionDetailResponse( + id: 1, + quizId: 101, + quizTitle: "Swift 기초 퀴즈", + quizDescription: "Swift와 SwiftUI에 대한 기초 개념 퀴즈입니다.", + totalQuestions: 2, + currentQuestionIndex: 1, + currentQuestion: QuizSessionQuestion( + id: 2, + weekId: 10, + front: "SwiftUI에서 상태값을 관리하는 속성 래퍼는?", + back: "@State를 사용하여 상태 관리를 수행합니다." + ), + completed: false, + score: nil, + totalQuestionsAnswered: 1, + totalCorrectAnswers: 1, + userAnswers: [ + UserAnswer( + id: 1, + questionId: 1, + questionFront: "Swift의 옵셔널 바인딩 키워드는?", + userAnswer: "if let", + correctAnswer: "if let", + isCorrect: true, + answeredAt: "2025-05-28T12:34:56" + ) + ], + createdAt: "2025-05-28T12:30:00", + completedAt: nil +) + +// MARK: - QuizRecordView 프리뷰 +#Preview { + QuizRecordView() +} + +// MARK: - QuizSessionDetailSheet 프리뷰 +#Preview { + QuizSessionDetailSheet(detail: sampleDetail) +} + + diff --git a/Sources/Views/Quiz/QuizSession/QuizSessionSheet.swift b/Sources/Views/Quiz/QuizSession/QuizSessionSheet.swift new file mode 100644 index 0000000..e3aaddf --- /dev/null +++ b/Sources/Views/Quiz/QuizSession/QuizSessionSheet.swift @@ -0,0 +1,42 @@ +// +// QuizSessionSheet.swift +// Lecture2Quiz +// +// Created by 바견규 on 5/28/25. +// + +import SwiftUI + +struct QuizSessionDetailSheet: View { + let detail: QuizSessionDetailResponse + + var body: some View { + NavigationStack { + List { + ForEach(detail.userAnswers.indices, id: \.self) { i in + let ua = detail.userAnswers[i] + VStack(alignment: .leading, spacing: 6) { + Text("Q\(i+1). \(ua.questionFront)") + .font(.headline) + + Text("정답: \(ua.correctAnswer)") + .font(.subheadline) + + Text("내 답변: \(ua.userAnswer)") + .font(.subheadline) + .foregroundColor(ua.userAnswer == ua.correctAnswer ? .green : .red) + } + .padding(.vertical, 6) + } + } + .navigationTitle("기록 상세") + } + } +} + + +extension Array { + subscript(safe index: Int) -> Element? { + return indices.contains(index) ? self[index] : nil + } +} diff --git a/Sources/Views/Quiz/QuizTopTab.swift b/Sources/Views/Quiz/QuizTopTab.swift new file mode 100644 index 0000000..e5148bf --- /dev/null +++ b/Sources/Views/Quiz/QuizTopTab.swift @@ -0,0 +1,85 @@ +// +// QuizTopTab.swift +// Lecture2Quiz +// +// Created by 바견규 on 5/26/25. +// + + +import SwiftUI + +enum QuizTopTab: String, CaseIterable { + case WeekQuestion = "주차 질문" + case Quiz = "퀴즈" + case QuizRecord = "퀴즈 기록" +} + +struct QuizTopTabView: View { + @Namespace private var animation + @ObservedObject var viewModel: QuizViewModel + + var body: some View { + VStack(spacing: 0) { + QuiztabBar + QuizseparatorLine + } + } + + // 탭 바 부분 분리 + private var QuiztabBar: some View { + HStack(spacing: 20) { + ForEach(QuizTopTab.allCases, id: \.self) { tab in + QuiztabItem(for: tab) + } + } + .padding(.top) + .shadow(color: Color.black.opacity(0.1), radius: 2, y: 1) + } + + // 탭 항목 개별 뷰 분리 + private func QuiztabItem(for tab: QuizTopTab) -> some View { + let isSelected = viewModel.selectedTab == tab + + return VStack(spacing: 4) { + Button(action: { + withAnimation(.easeInOut) { + viewModel.selectedTab = tab + } + }) { + Text(tab.rawValue) + .font(Font.Pretend.pretendardBold(size: 16)) + .foregroundColor(isSelected ? .black : .gray) + } + .padding() + + if isSelected { + Capsule() + .fill(Color.green) + .frame(height: 3) + .matchedGeometryEffect(id: "underline", in: animation) + } else { + Color.clear.frame(height: 3) + } + } + .padding(.bottom, -10) + } + + // 하단 구분선 + private var QuizseparatorLine: some View { + ZStack(alignment: .leading) { + Color.white.opacity(0.01) + .frame(height: 3) + .shadow(color: Color.black.opacity(0.3), radius: 2, y: 1) + } + .frame(height: 3) + } +} + + +#Preview("주차 질문 탭") { + QuizTopTabView(viewModel: { + let vm = QuizViewModel() + vm.selectedTab = .WeekQuestion + return vm + }()) +} diff --git a/Sources/Views/Quiz/WeekQuestion/WeekQuestionView.swift b/Sources/Views/Quiz/WeekQuestion/WeekQuestionView.swift new file mode 100644 index 0000000..f735439 --- /dev/null +++ b/Sources/Views/Quiz/WeekQuestion/WeekQuestionView.swift @@ -0,0 +1,138 @@ +// +// WeekQuestionView.swift +// Lecture2Quiz +// +// Created by 바견규 on 5/26/25. +// + +import SwiftUI + +struct WeekQuestionView: View { + @ObservedObject var viewModel: WeekQuestionViewModel + @State private var showMinCountPrompt: Int? = nil + @State private var minQuestionCount: Int = 3 + + var body: some View { + ScrollView { + VStack(spacing: 16) { + // 수업 선택 + HStack { + Text("수업을 선택해주세요.") + .foregroundColor(.black) + + Spacer() + + Menu { + Picker("수업 선택", selection: $viewModel.selectedCourseId) { + ForEach(viewModel.courses, id: \.id) { course in + Text(course.title).tag(Optional(course.id)) + } + } + } label: { + HStack(spacing: 4) { + Text(viewModel.selectedCourseTitle ?? "선택") + .foregroundColor(.gray) + Image(systemName: "chevron.down") + .foregroundColor(.black) + } + } + } + .padding() + .background(Color(.systemGray6)) + .cornerRadius(12) + .padding(.horizontal) + .onChange(of: viewModel.selectedCourseId) { + if let id = viewModel.selectedCourseId { + viewModel.selectCourse(id: id) + } + } + + // 주차별 질문 목록 + LazyVStack(spacing: 12) { + ForEach(viewModel.weeks, id: \ .id) { week in + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Week \(week.title)") + .font(.headline) + Spacer() + + if let questions = viewModel.questionsPerWeek[week.id] { + if questions.isEmpty { + Button("질문 생성") { + showMinCountPrompt = week.id + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.black) + .foregroundColor(.white) + .cornerRadius(8) + } else { + Button("질문 조회") { + viewModel.fetchQuestions(for: week.id) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.black) + .foregroundColor(.white) + .cornerRadius(8) + } + } else { + // 아직 조회되지 않은 상태 + Button("질문 조회") { + viewModel.fetchQuestions(for: week.id) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.black) + .foregroundColor(.white) + .cornerRadius(8) + } + } + + if let questions = viewModel.questionsPerWeek[week.id], !questions.isEmpty { + ForEach(questions) { question in + VStack(alignment: .leading, spacing: 4) { + Text("Q. \(question.question)") + .fontWeight(.semibold) + Text("A. \(question.answer)") + .foregroundColor(.secondary) + } + .padding(8) + .background(Color(.systemGray6)) + .cornerRadius(8) + } + } + } + .padding() + .background(Color.white) + .cornerRadius(12) + .shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1) + .padding(.horizontal) + } + } + } + } + .background(Color(.systemGroupedBackground).ignoresSafeArea()) + .onAppear { + viewModel.fetchCourses() + } + .alert("질문 개수 입력", isPresented: Binding( + get: { showMinCountPrompt != nil }, + set: { if !$0 { showMinCountPrompt = nil } } + )) { + TextField("예: 3", value: $minQuestionCount, formatter: NumberFormatter()) + Button("생성") { + if let weekId = showMinCountPrompt { + viewModel.generateQuestions(for: weekId, minCount: minQuestionCount) + showMinCountPrompt = nil + } + } + Button("취소", role: .cancel) { + showMinCountPrompt = nil + } + } message: { + Text("생성할 최소 질문 수를 입력하세요.") + } + } +} + diff --git a/Sources/Views/QuizDeckView.swift b/Sources/Views/QuizDeckView.swift deleted file mode 100644 index b4c484d..0000000 --- a/Sources/Views/QuizDeckView.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// QuizDeckView.swift -// Lecture2Quiz -// -// Created by 바견규 on 3/29/25. -// - -import SwiftUI - -struct QuizDeckView: View { - @StateObject private var viewModel = QuizViewModel() - - var body: some View { - ZStack { - if viewModel.currentIndex >= viewModel.cards.count { - VStack { - Text("🎉 퀴즈 완료!") - .font(.largeTitle) - .padding() - Text("정답: \(viewModel.correct.count)") - Text("오답: \(viewModel.wrong.count)") - - Button("다시 시작") { - viewModel.restart() - } - .padding() - .background(Color.green) - .foregroundColor(.white) - .clipShape(Capsule()) - } - } else { - ForEach(viewModel.cards.indices.reversed(), id: \.self) { index in - if index >= viewModel.currentIndex { - let card = viewModel.cards[index] - QuizCardView(card: card) { isCorrect in - viewModel.swipeCard(isCorrect: isCorrect) - } - .padding() - .zIndex(Double(viewModel.cards.count - index)) - .animation(.easeInOut, value: viewModel.currentIndex) - } - } - } - } - .background(Color.gray.opacity(0.1).ignoresSafeArea()) - } -} - - - -#Preview { - QuizDeckView() -} diff --git a/Sources/Views/RecordingModal.swift b/Sources/Views/RecordingModal.swift deleted file mode 100644 index 070136f..0000000 --- a/Sources/Views/RecordingModal.swift +++ /dev/null @@ -1,93 +0,0 @@ -// -// RecordingModal.swift -// Lecture2Quiz -// -// Created by 바견규 on 4/5/25. -// - -import SwiftUI - -struct RecordingModal: View { - var onDismiss: () -> Void - @StateObject var recordingViewModel = AudioViewModel() - @GestureState private var dragOffset = CGSize.zero - - var body: some View { - VStack { - Capsule() - .fill(Color.gray.opacity(0.5)) - .frame(width: 40, height: 6) - .padding(.top, 8) - - HStack { - Spacer() - // 녹음 중이라면 Stop 버튼 추가 - if recordingViewModel.isRecording { - Button("녹음 종료") { - recordingViewModel.stopRecording() - onDismiss() - } - .font(.headline) - .padding() - .foregroundColor(.gray) - } - } - - Spacer() - // 타임 레이블 - Text(recordingViewModel.timeLabel) - .font(Font.Pretend.pretendardMedium(size: 40)) - - // 녹음 시작/중단/재개 버튼 - Button(action: { - if recordingViewModel.isRecording { - if recordingViewModel.isPaused { - recordingViewModel.resumeRecording() - } else { - recordingViewModel.pauseRecording() - } - } else { - recordingViewModel.startRecording() - } - }, label: { - Image(systemName: recordingViewModel.isRecording ? - (recordingViewModel.isPaused ? "play.circle.fill" : "pause.circle.fill") : "mic.circle.fill") - .font(.system(size: 50)) - .foregroundStyle(.black) - .padding(.bottom, 40) - }) - } - .frame(maxWidth: .infinity) - .background(Color(.systemBackground)) - .cornerRadius(20) - .shadow(radius: 10) - .offset(y: dragOffset.height) - .gesture( - DragGesture() - .updating($dragOffset) { value, state, _ in - if value.translation.height > 0 { - state = value.translation - } - } - .onEnded { value in - if value.translation.height > 100 { - onDismiss() - } - } - ) - .transition(.move(edge: .bottom)) - .animation(.easeOut, value: dragOffset) - } -} - - - -#Preview { - var showRecordingModal = true - RecordingModal { - withAnimation { - showRecordingModal = false - } - } - .zIndex(1) -} diff --git a/Sources/Views/kakao/LoginView.swift b/Sources/Views/kakao/LoginView.swift new file mode 100644 index 0000000..896eada --- /dev/null +++ b/Sources/Views/kakao/LoginView.swift @@ -0,0 +1,76 @@ +// +// LoginView.swift +// Starbucks +// +// Created by 박현규 on 3/18/25. +// + +import SwiftUI +import KakaoSDKUser +import KakaoSDKCommon + +struct MainLoginView: View { + @State private var isLoggedIn = false + var body: some View { + NavigationStack{ + VStack{ + kakaoLoginView(isLoggedIn: $isLoggedIn) + } + .navigationDestination(isPresented: $isLoggedIn) { + TableView() + } + .navigationTitle("Login") + } + } +} + +struct kakaoLoginView: View { + @Binding var isLoggedIn: Bool + + var body: some View { + + Button(action: { + // 카카오 로그인 요청 + if (UserApi.isKakaoTalkLoginAvailable()) { + UserApi.shared.loginWithKakaoTalk { (oauthToken, error) in + if let error = error { + print(error) + } else if let oauthToken = oauthToken{ + print("카카오톡 로그인 성공") + print(oauthToken) + isLoggedIn = true + } + } + } else { + UserApi.shared.loginWithKakaoAccount { (oauthToken, error) in + if let error = error { + print(error) + } else if let oauthToken = oauthToken{ + print("카카오 계정 로그인 성공") + print(oauthToken) + isLoggedIn = true + } + } + } + }, label: { + Image("kakaoLogo") + Text("카카오 로그인") + .frame(width: 301, height: 45) + .foregroundStyle(Color.black) + .frame(maxWidth: .infinity) + .font(Font.Pretend.pretendardMedium(size: 16)) + }) + .frame(width: 306, height: 45) + .buttonStyle(.borderedProminent) // 버튼 스타일 적용 + .tint(Color(hex: "#FEE500")) // 버튼 색상 적용 + .fixedSize(horizontal: false, vertical: true) + + + } +} + + +#Preview { + MainLoginView() + +} diff --git a/Sources/Views/kakao/kakaoSDKApp.swift b/Sources/Views/kakao/kakaoSDKApp.swift new file mode 100644 index 0000000..b816b36 --- /dev/null +++ b/Sources/Views/kakao/kakaoSDKApp.swift @@ -0,0 +1,29 @@ +// +// kakaoSDKApp.swift +// Lecture2Quiz +// +// Created by 바견규 on 5/19/25. +// + +//kakaoSDKApp.swift +import SwiftUI +import KakaoSDKCommon +import KakaoSDKAuth + +@main +struct kakaoSDKApp: App { + init() { + let KakaoApiKey = Bundle.main.object(forInfoDictionaryKey: "Kakao_AppKey") as? String ?? "" + KakaoSDK.initSDK(appKey: KakaoApiKey) + } + + var body: some Scene { + WindowGroup { + MainLoginView().onOpenURL(perform: { url in + if (AuthApi.isKakaoTalkLoginUrl(url)) { + AuthController.handleOpenUrl(url: url) + } + }) + } + } +} diff --git a/Sources/Views/recording Modal/RecordingModal.swift b/Sources/Views/recording Modal/RecordingModal.swift new file mode 100644 index 0000000..92f49c5 --- /dev/null +++ b/Sources/Views/recording Modal/RecordingModal.swift @@ -0,0 +1,125 @@ +import SwiftUI + +struct RecordingModal: View { + var onDismiss: () -> Void + @StateObject private var recordingViewModel = AudioViewModel() + @GestureState private var dragOffset = CGSize.zero + @State private var modalPosition: CGFloat = 0 + + // 수업/주차 선택용 상태값 + @State private var showSubmitModal = false + + // 화면의 위치 설정 + private let midPosition: CGFloat = 0 + private let bottomPosition: CGFloat = UIScreen.main.bounds.height * 0.5 + + var body: some View { + VStack(spacing: 0) { + Capsule() + .fill(Color.gray.opacity(0.5)) + .frame(width: 40, height: 6) + .padding(.top, 8) + + HStack { + Spacer() + if recordingViewModel.isRecording { + Button("녹음 종료") { + recordingViewModel.stopRecording() + recordingViewModel.finalizeTranscription() + showSubmitModal = true + } + .font(.headline) + .padding() + .foregroundColor(.gray) + } + } + + // ✅ 스크롤 가능한 텍스트 영역 + ScrollView { + VStack(spacing: 8) { + ForEach(recordingViewModel.transcriptionList.indices, id: \.self) { index in + Text(recordingViewModel.transcriptionList[index]) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.gray.opacity(0.1)) + .cornerRadius(8) + } + } + .padding(.horizontal) + } + + Divider() + .padding(.top, 8) + + // ✅ 고정된 하단 컨트롤 + VStack(spacing: 16) { + Text(recordingViewModel.timeLabel) + .font(.system(size: 40)) + + Button(action: { + if recordingViewModel.isRecording { + if recordingViewModel.isPaused { + recordingViewModel.resumeRecording() + } else { + recordingViewModel.pauseRecording() + } + } else { + recordingViewModel.startRecording() + } + }) { + Image(systemName: recordingViewModel.isRecording ? + (recordingViewModel.isPaused ? "play.circle.fill" : "pause.circle.fill") : "mic.circle.fill") + .font(.system(size: 50)) + .foregroundStyle(.black) + } + } + .padding(.bottom, 40) + } + .frame(maxWidth: .infinity) + .background(Color(.systemBackground)) + .cornerRadius(20) + .shadow(radius: 10) + .offset(y: modalPosition + dragOffset.height) + .gesture( + DragGesture() + .updating($dragOffset) { value, state, _ in + if value.translation.height > 0 { + state = value.translation + } + } + .onEnded { value in + withAnimation { + if value.translation.height > 150 { + modalPosition = bottomPosition + } else { + modalPosition = midPosition + } + } + } + ) + .transition(.move(edge: .bottom)) + .animation(.easeOut, value: dragOffset) + .onAppear { + modalPosition = midPosition + } + .overlay( + Group { + if recordingViewModel.isLoading { + ZStack { + Color.black.opacity(0.4).ignoresSafeArea() + ProgressView("처리 중입니다...") + .padding() + .background(Color.white) + .cornerRadius(10) + } + } + } + ) + .sheet(isPresented: $showSubmitModal) { + SubmitTranscriptView(finalContent: recordingViewModel.finalScript) { + onDismiss() + } + } + } +} + diff --git a/Sources/Views/recording Modal/SubmitTranscriptView.swift b/Sources/Views/recording Modal/SubmitTranscriptView.swift new file mode 100644 index 0000000..5e09b84 --- /dev/null +++ b/Sources/Views/recording Modal/SubmitTranscriptView.swift @@ -0,0 +1,91 @@ +// +// SubmitTranscriptView.swift +// Lecture2Quiz +// +// Created by 바견규 on 5/25/25. +// +import SwiftUI +import Moya + +struct SubmitTranscriptView: View { + let finalContent: String + var onSubmitCompleted: () -> Void + + @Environment(\.dismiss) private var dismiss + @StateObject private var viewModel = SubmitTranscriptViewModel() + + var body: some View { + NavigationStack { + Form { + // 수업 선택 + Section(header: Text("수업 선택")) { + Picker("수업", selection: $viewModel.selectedCourseId) { + // nil을 명시적으로 처리 + Text("수업을 선택하세요").tag(Optional(nil)) + ForEach(viewModel.folders, id: \.id) { course in + Text(course.title).tag(Optional(course.id)) + } + } + } + + // 주차 선택 + if let selectedCourse = viewModel.selectedCourse { + Section(header: Text("주차 선택")) { + Picker("주차", selection: $viewModel.selectedWeekId) { + // nil을 명시적으로 처리 + Text("주차를 선택하세요").tag(Optional(nil)) + ForEach(selectedCourse.weeks, id: \.id) { week in + Text(week.title).tag(Optional(week.id)) + } + } + + Button("➕ 새 주차 추가") { + viewModel.showAddWeekAlert = true + } + .alert("새 주차 이름", isPresented: $viewModel.showAddWeekAlert) { + TextField("예: 3주차 - 반복문", text: $viewModel.newWeekTitle) + Button("추가") { + viewModel.addWeek(to: selectedCourse) { + viewModel.fetchFolders() + } + } + Button("취소", role: .cancel) {} + } + } + } + + // 저장 버튼 + Button("저장하기") { + viewModel.submitTranscript(content: finalContent) { success in + if success { + dismiss() + onSubmitCompleted() + } + } + } + .disabled(viewModel.selectedWeekId == nil) + } + .navigationTitle("녹음 저장") + .onAppear { + viewModel.fetchFolders() + } + } + } +} + +#Preview { + SubmitTranscriptView(finalContent: "이것은 예시 녹음 내용입니다.") { + print("✅ 전송 완료 후 동작") + } +} + + + +#Preview { + SubmitTranscriptView(finalContent: "이것은 예시 녹음 내용입니다.") { + print("✅ 전송 완료 후 동작") + } +} + + +