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("✅ 전송 완료 후 동작")
+ }
+}
+
+
+