diff --git a/.DS_Store b/.DS_Store index bb8af5b..52ee457 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e482448 --- /dev/null +++ b/.gitignore @@ -0,0 +1,70 @@ +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride +# Icon must end with two +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### Xcode ### +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +build/ +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +### Xcode Patch ### +*.xcodeproj/* +!*.xcodeproj/project.pbxproj +!*.xcodeproj/xcshareddata/ +!*.xcworkspace/contents.xcworkspacedata +/*.gcno + +### Projects ### +*.xcodeproj +*.xcworkspace +*.xcconfig + +### Tuist derived files ### +graph.dot +Derived/ + +### Tuist managed dependencies ### +Tuist/.build \ No newline at end of file diff --git a/.package.resolved b/.package.resolved new file mode 100644 index 0000000..b596d21 --- /dev/null +++ b/.package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "0a9a0565c23645eacdcd0d177a036429567ab510b257a7f2538ea8bb318bf8d2", + "pins" : [ + { + "identity" : "alamofire", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Alamofire/Alamofire.git", + "state" : { + "revision" : "513364f870f6bfc468f9d2ff0a95caccc10044c5", + "version" : "5.10.2" + } + }, + { + "identity" : "kakao-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kakao/kakao-ios-sdk", + "state" : { + "revision" : "787763e335949dfad6b721aa04771e22d04e1493", + "version" : "2.24.2" + } + } + ], + "version" : 3 +} diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 4d4da11..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 0361Team - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/Lecture2Quiz/.Project.swift.swp b/Lecture2Quiz/.Project.swift.swp new file mode 100644 index 0000000..0ccac60 Binary files /dev/null and b/Lecture2Quiz/.Project.swift.swp differ diff --git a/Lecture2Quiz/.gitignore b/Lecture2Quiz/.gitignore new file mode 100644 index 0000000..24b244f --- /dev/null +++ b/Lecture2Quiz/.gitignore @@ -0,0 +1,70 @@ +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### Xcode ### +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +build/ +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +### Xcode Patch ### +*.xcodeproj/* +!*.xcodeproj/project.pbxproj +!*.xcodeproj/xcshareddata/ +!*.xcworkspace/contents.xcworkspacedata +/*.gcno + +### Projects ### +*.xcodeproj +*.xcworkspace + +### Tuist derived files ### +graph.dot +Derived/ + +### Tuist managed dependencies ### +Tuist/.build \ No newline at end of file diff --git a/Lecture2Quiz/Project.swift b/Lecture2Quiz/Project.swift new file mode 100644 index 0000000..5cbe451 --- /dev/null +++ b/Lecture2Quiz/Project.swift @@ -0,0 +1,65 @@ +import ProjectDescription + +let project = Project( + name: "Lecture2Quiz", + packages: [ + .package(url: "https://github.com/kakao/kakao-ios-sdk", .upToNextMajor(from: "2.13.0")), + .package(url: "https://github.com/Moya/Moya.git", .exact("15.0.0")) + ], + // ✅ 아래처럼 settings도 추가해야 함 + settings: .settings( + configurations: [ + .debug(name: "SecretOnly", xcconfig: .relativeToRoot("../Lecture2Quiz/Configuration/Secret.xcconfig")) + ] + ), + targets: [ + .target( + name: "Lecture2Quiz", + destinations: .iOS, + product: .app, + bundleId: "io.tuist.Lecture2Quiz", + infoPlist: .extendingDefault( + with: [ + // 1️⃣ 런치 스크린 설정 + "UILaunchScreen": [ + "UIColorName": "", + "UIImageName": "" + ], + + // 2️⃣ 마이크 권한 + "NSMicrophoneUsageDescription": "앱이 녹음을 위해 마이크를 사용합니다.", + + // 3️⃣ 네트워크 권한 (ws:// 프로토콜 허용) + "NSAppTransportSecurity": [ + "NSAllowsArbitraryLoads": true + ], + + // ✅ Secret.xcconfig에서 가져올 값들 + "API_URL": "$(API_URL)", + "Kakao_AppKey": "$(Kakao_AppKey)", + "AudioAPI_URL": "$(AudioAPI_URL)" + ] + ), + sources: ["Lecture2Quiz/Sources/**"], + resources: ["Lecture2Quiz/Resources/**"], + dependencies: [ + .package(product: "KakaoSDKCommon"), + .package(product: "KakaoSDKAuth"), + .package(product: "KakaoSDKUser"), + .package(product: "Moya") + ] + ), + .target( + name: "Lecture2QuizTests", + destinations: .iOS, + product: .unitTests, + bundleId: "io.tuist.Lecture2QuizTests", + infoPlist: .default, + sources: ["Lecture2Quiz/Tests/**"], + resources: [], + dependencies: [ + .target(name: "Lecture2Quiz") + ] + ) + ] +) diff --git a/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/Lecture2Quiz/Resources/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from Resources/Assets.xcassets/AccentColor.colorset/Contents.json rename to Lecture2Quiz/Resources/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/1024.png b/Lecture2Quiz/Resources/Assets.xcassets/AppIcon.appiconset/1024.png similarity index 100% rename from Resources/Assets.xcassets/AppIcon.appiconset/1024.png rename to Lecture2Quiz/Resources/Assets.xcassets/AppIcon.appiconset/1024.png diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/120.png b/Lecture2Quiz/Resources/Assets.xcassets/AppIcon.appiconset/120.png similarity index 100% rename from Resources/Assets.xcassets/AppIcon.appiconset/120.png rename to Lecture2Quiz/Resources/Assets.xcassets/AppIcon.appiconset/120.png diff --git a/Lecture2Quiz/Resources/Assets.xcassets/AppIcon.appiconset/152.png b/Lecture2Quiz/Resources/Assets.xcassets/AppIcon.appiconset/152.png new file mode 100644 index 0000000..2743a99 Binary files /dev/null and b/Lecture2Quiz/Resources/Assets.xcassets/AppIcon.appiconset/152.png differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/167.png b/Lecture2Quiz/Resources/Assets.xcassets/AppIcon.appiconset/167.png similarity index 100% rename from Resources/Assets.xcassets/AppIcon.appiconset/167.png rename to Lecture2Quiz/Resources/Assets.xcassets/AppIcon.appiconset/167.png diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/180.png b/Lecture2Quiz/Resources/Assets.xcassets/AppIcon.appiconset/180.png similarity index 100% rename from Resources/Assets.xcassets/AppIcon.appiconset/180.png rename to Lecture2Quiz/Resources/Assets.xcassets/AppIcon.appiconset/180.png diff --git a/Lecture2Quiz/Resources/Assets.xcassets/AppIcon.appiconset/76.png b/Lecture2Quiz/Resources/Assets.xcassets/AppIcon.appiconset/76.png new file mode 100644 index 0000000..6628e46 Binary files /dev/null and b/Lecture2Quiz/Resources/Assets.xcassets/AppIcon.appiconset/76.png differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Lecture2Quiz/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 96% rename from Resources/Assets.xcassets/AppIcon.appiconset/Contents.json rename to Lecture2Quiz/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json index f3fb326..20f98fd 100644 --- a/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Lecture2Quiz/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -73,11 +73,13 @@ "size" : "40x40" }, { + "filename" : "76.png", "idiom" : "ipad", "scale" : "1x", "size" : "76x76" }, { + "filename" : "152.png", "idiom" : "ipad", "scale" : "2x", "size" : "76x76" diff --git a/Resources/Assets.xcassets/Contents.json b/Lecture2Quiz/Resources/Assets.xcassets/Contents.json similarity index 100% rename from Resources/Assets.xcassets/Contents.json rename to Lecture2Quiz/Resources/Assets.xcassets/Contents.json diff --git a/Lecture2Quiz/Resources/Assets.xcassets/Logo_Lecture2Quiz.imageset/Contents.json b/Lecture2Quiz/Resources/Assets.xcassets/Logo_Lecture2Quiz.imageset/Contents.json new file mode 100644 index 0000000..de538e7 --- /dev/null +++ b/Lecture2Quiz/Resources/Assets.xcassets/Logo_Lecture2Quiz.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Logo_Lecture2Quiz.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Lecture2Quiz/Resources/Assets.xcassets/Logo_Lecture2Quiz.imageset/Logo_Lecture2Quiz.png b/Lecture2Quiz/Resources/Assets.xcassets/Logo_Lecture2Quiz.imageset/Logo_Lecture2Quiz.png new file mode 100644 index 0000000..36f827d Binary files /dev/null and b/Lecture2Quiz/Resources/Assets.xcassets/Logo_Lecture2Quiz.imageset/Logo_Lecture2Quiz.png differ diff --git a/Resources/Assets.xcassets/homeIcon.imageset/Contents.json b/Lecture2Quiz/Resources/Assets.xcassets/homeIcon.imageset/Contents.json similarity index 100% rename from Resources/Assets.xcassets/homeIcon.imageset/Contents.json rename to Lecture2Quiz/Resources/Assets.xcassets/homeIcon.imageset/Contents.json diff --git a/Resources/Assets.xcassets/homeIcon.imageset/ic_round-home.svg b/Lecture2Quiz/Resources/Assets.xcassets/homeIcon.imageset/ic_round-home.svg similarity index 100% rename from Resources/Assets.xcassets/homeIcon.imageset/ic_round-home.svg rename to Lecture2Quiz/Resources/Assets.xcassets/homeIcon.imageset/ic_round-home.svg diff --git a/Resources/Assets.xcassets/kakaoLogo.imageset/Contents.json b/Lecture2Quiz/Resources/Assets.xcassets/kakaoLogo.imageset/Contents.json similarity index 100% rename from Resources/Assets.xcassets/kakaoLogo.imageset/Contents.json rename to Lecture2Quiz/Resources/Assets.xcassets/kakaoLogo.imageset/Contents.json diff --git a/Resources/Assets.xcassets/kakaoLogo.imageset/kakaoLogo.pdf b/Lecture2Quiz/Resources/Assets.xcassets/kakaoLogo.imageset/kakaoLogo.pdf similarity index 100% rename from Resources/Assets.xcassets/kakaoLogo.imageset/kakaoLogo.pdf rename to Lecture2Quiz/Resources/Assets.xcassets/kakaoLogo.imageset/kakaoLogo.pdf diff --git a/Resources/Assets.xcassets/questionmark.text.page.fill.imageset/Contents.json b/Lecture2Quiz/Resources/Assets.xcassets/questionmark.text.page.fill.imageset/Contents.json similarity index 100% rename from Resources/Assets.xcassets/questionmark.text.page.fill.imageset/Contents.json rename to Lecture2Quiz/Resources/Assets.xcassets/questionmark.text.page.fill.imageset/Contents.json diff --git a/Resources/Assets.xcassets/questionmark.text.page.fill.imageset/questionmark.text.page.fill.png b/Lecture2Quiz/Resources/Assets.xcassets/questionmark.text.page.fill.imageset/questionmark.text.page.fill.png similarity index 100% rename from Resources/Assets.xcassets/questionmark.text.page.fill.imageset/questionmark.text.page.fill.png rename to Lecture2Quiz/Resources/Assets.xcassets/questionmark.text.page.fill.imageset/questionmark.text.page.fill.png diff --git a/Resources/Pretendard-Black.otf b/Lecture2Quiz/Resources/Pretendard-Black.otf similarity index 100% rename from Resources/Pretendard-Black.otf rename to Lecture2Quiz/Resources/Pretendard-Black.otf diff --git a/Resources/Pretendard-Bold.otf b/Lecture2Quiz/Resources/Pretendard-Bold.otf similarity index 100% rename from Resources/Pretendard-Bold.otf rename to Lecture2Quiz/Resources/Pretendard-Bold.otf diff --git a/Resources/Pretendard-ExtraBold.otf b/Lecture2Quiz/Resources/Pretendard-ExtraBold.otf similarity index 100% rename from Resources/Pretendard-ExtraBold.otf rename to Lecture2Quiz/Resources/Pretendard-ExtraBold.otf diff --git a/Resources/Pretendard-ExtraLight.otf b/Lecture2Quiz/Resources/Pretendard-ExtraLight.otf similarity index 100% rename from Resources/Pretendard-ExtraLight.otf rename to Lecture2Quiz/Resources/Pretendard-ExtraLight.otf diff --git a/Resources/Pretendard-Light.otf b/Lecture2Quiz/Resources/Pretendard-Light.otf similarity index 100% rename from Resources/Pretendard-Light.otf rename to Lecture2Quiz/Resources/Pretendard-Light.otf diff --git a/Resources/Pretendard-Medium.otf b/Lecture2Quiz/Resources/Pretendard-Medium.otf similarity index 100% rename from Resources/Pretendard-Medium.otf rename to Lecture2Quiz/Resources/Pretendard-Medium.otf diff --git a/Resources/Pretendard-Regular.otf b/Lecture2Quiz/Resources/Pretendard-Regular.otf similarity index 100% rename from Resources/Pretendard-Regular.otf rename to Lecture2Quiz/Resources/Pretendard-Regular.otf diff --git a/Resources/Pretendard-SemiBold.otf b/Lecture2Quiz/Resources/Pretendard-SemiBold.otf similarity index 100% rename from Resources/Pretendard-SemiBold.otf rename to Lecture2Quiz/Resources/Pretendard-SemiBold.otf diff --git a/Resources/Pretendard-Thin.otf b/Lecture2Quiz/Resources/Pretendard-Thin.otf similarity index 100% rename from Resources/Pretendard-Thin.otf rename to Lecture2Quiz/Resources/Pretendard-Thin.otf diff --git a/Resources/Preview Content/Preview Assets.xcassets/Contents.json b/Lecture2Quiz/Resources/Preview Content/Preview Assets.xcassets/Contents.json similarity index 100% rename from Resources/Preview Content/Preview Assets.xcassets/Contents.json rename to Lecture2Quiz/Resources/Preview Content/Preview Assets.xcassets/Contents.json diff --git a/Sources/CourseAPI/CourseAPI.swift b/Lecture2Quiz/Sources/CourseAPI/CourseAPI.swift similarity index 99% rename from Sources/CourseAPI/CourseAPI.swift rename to Lecture2Quiz/Sources/CourseAPI/CourseAPI.swift index dbec132..8a08f56 100644 --- a/Sources/CourseAPI/CourseAPI.swift +++ b/Lecture2Quiz/Sources/CourseAPI/CourseAPI.swift @@ -90,7 +90,7 @@ extension CourseAPI: TargetType { case .deleteCourse(let courseId): return "/v1/course/\(courseId)" case .deleteWeek(let weekId): - return "/api/weeks/\(weekId)" + return "/weeks/\(weekId)" case .deleteText(let textId), .updateText(let textId, _, _): return "/v1/texts/\(textId)" diff --git a/Sources/CourseAPI/CouseResponse.swift b/Lecture2Quiz/Sources/CourseAPI/CouseResponse.swift similarity index 100% rename from Sources/CourseAPI/CouseResponse.swift rename to Lecture2Quiz/Sources/CourseAPI/CouseResponse.swift diff --git a/Sources/Fonts.swift b/Lecture2Quiz/Sources/Fonts.swift similarity index 100% rename from Sources/Fonts.swift rename to Lecture2Quiz/Sources/Fonts.swift diff --git a/Sources/LoginAPI/LoginAPI.swift b/Lecture2Quiz/Sources/LoginAPI/LoginAPI.swift similarity index 100% rename from Sources/LoginAPI/LoginAPI.swift rename to Lecture2Quiz/Sources/LoginAPI/LoginAPI.swift diff --git a/Sources/LoginAPI/LoginResponse.swift b/Lecture2Quiz/Sources/LoginAPI/LoginResponse.swift similarity index 100% rename from Sources/LoginAPI/LoginResponse.swift rename to Lecture2Quiz/Sources/LoginAPI/LoginResponse.swift diff --git a/Sources/Models/ColorHexExtend.swift b/Lecture2Quiz/Sources/Models/ColorHexExtend.swift similarity index 100% rename from Sources/Models/ColorHexExtend.swift rename to Lecture2Quiz/Sources/Models/ColorHexExtend.swift diff --git a/Sources/Models/FileModel.swift b/Lecture2Quiz/Sources/Models/FileModel.swift similarity index 100% rename from Sources/Models/FileModel.swift rename to Lecture2Quiz/Sources/Models/FileModel.swift diff --git a/Sources/Models/QuizCardModel.swift b/Lecture2Quiz/Sources/Models/QuizCardModel.swift similarity index 100% rename from Sources/Models/QuizCardModel.swift rename to Lecture2Quiz/Sources/Models/QuizCardModel.swift diff --git a/Sources/QuizAPI/QuizAPI.swift b/Lecture2Quiz/Sources/QuizAPI/QuizAPI.swift similarity index 91% rename from Sources/QuizAPI/QuizAPI.swift rename to Lecture2Quiz/Sources/QuizAPI/QuizAPI.swift index 254f4c2..c009317 100644 --- a/Sources/QuizAPI/QuizAPI.swift +++ b/Lecture2Quiz/Sources/QuizAPI/QuizAPI.swift @@ -42,6 +42,10 @@ enum QuizAPI { // 퀴즈 삭제 case deleteQuiz(id: Int) + + // 퀴즈 세션 삭제(다수) + case deleteQuizSessions(sessionIds: [Int]) + } extension QuizAPI: TargetType { @@ -74,6 +78,9 @@ extension QuizAPI: TargetType { return "/v1/quiz-sessions/\(sessionId)" case .deleteQuiz(let id): return "/v1/quizzes/\(id)" + case .deleteQuizSessions: + return "/v1/quiz-sessions/batch" + } } @@ -83,7 +90,7 @@ extension QuizAPI: TargetType { return .post case .getQuizzes, .getWeekQuestions, .getQuizDetail, .getUserQuizSessions, .getQuizSessionDetail: return .get - case .deleteQuiz: + case .deleteQuiz, .deleteQuizSessions: return .delete } } @@ -120,6 +127,10 @@ extension QuizAPI: TargetType { case .deleteQuiz: return .requestPlain + case .deleteQuizSessions(let sessionIds): + let body = ["sessionIds": sessionIds] + return .requestParameters(parameters: body, encoding: JSONEncoding.default) + default: return .requestPlain // GET 요청 또는 바디 없는 POST } diff --git a/Lecture2Quiz/Sources/QuizAPI/QuizResponse.swift b/Lecture2Quiz/Sources/QuizAPI/QuizResponse.swift new file mode 100644 index 0000000..a728c54 --- /dev/null +++ b/Lecture2Quiz/Sources/QuizAPI/QuizResponse.swift @@ -0,0 +1,147 @@ + // + // 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 } + } + + + // MARK: - 퀴즈 세션 삭제 (다수) + struct QuizSessionDeleteResponse: Codable { + let totalRequested: Int + let successCount: Int + let failureCount: Int + let deletedSessionIds: [Int] + let failures: [QuizSessionDeleteFailure] + } + + struct QuizSessionDeleteFailure: Codable { + let sessionId: Int + let reason: String + let errorCode: String + } diff --git a/Sources/ViewModels/Audio/AudioStream.swift b/Lecture2Quiz/Sources/ViewModels/Audio/AudioStream.swift similarity index 95% rename from Sources/ViewModels/Audio/AudioStream.swift rename to Lecture2Quiz/Sources/ViewModels/Audio/AudioStream.swift index 3695eb5..227993c 100644 --- a/Sources/ViewModels/Audio/AudioStream.swift +++ b/Lecture2Quiz/Sources/ViewModels/Audio/AudioStream.swift @@ -16,7 +16,7 @@ class AudioStreamer { private var isStreaming: Bool = false - // WhisperLive 설정에 맞춘 포맷 + // WhisperLive 설정에 맞춘 포맷2 private var bufferSize: AVAudioFrameCount = 1600 // 100ms 기준 private var sampleRate: Double = 16000 private var channels: UInt32 = 1 @@ -178,15 +178,17 @@ class AudioStreamer { // ✅ RMS 계산 let rms = sqrt(floatArray.map { $0 * $0 }.reduce(0, +) / Float(frameLength)) - let targetRMS: Float32 = 0.1 - let gain = targetRMS / max(rms, 0.0001) + let targetRMS: Float32 = 0.25 + let gain = targetRMS / max(rms, 0.00001) // 더 작은 소리도 증폭 대상에 포함 print("🎛️ RMS: \(rms), 적용 gain: \(gain)") - // ✅ RMS 정규화 + 클리핑 + // ✅ 정규화 및 soft clipping 적용 for i in 0.. Void)? var onTranscriptionReceived: ((String) -> Void)? - init(host: String, port: Int, modelSize: String = "small") { + init(host: String, port: Int, modelSize: String = "medium") { self.host = host self.port = port self.uid = UUID().uuidString @@ -154,14 +154,14 @@ } 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) - } - } + do { + let segmentJson: [String: Any] = ["segments": segments] + let data = try JSONSerialization.data(withJSONObject: segmentJson, options: []) + let jsonString = String(data: data, encoding: .utf8)! + onTranscriptionReceived?(jsonString) // ✅ 전체 JSON 전달 + print("텍스트 전달: \(jsonString)") + } catch { + print("❌ segments JSON 직렬화 실패: \(error.localizedDescription)") } } } diff --git a/Lecture2Quiz/Sources/ViewModels/Audio/RecordingViewModel.swift b/Lecture2Quiz/Sources/ViewModels/Audio/RecordingViewModel.swift new file mode 100644 index 0000000..8fe362a --- /dev/null +++ b/Lecture2Quiz/Sources/ViewModels/Audio/RecordingViewModel.swift @@ -0,0 +1,187 @@ +// +// RecordingViewModel.swift +// Lecture2Quiz +// +// Created by 바견규 on 4/27/25. +// + +import AVFoundation +import Combine +import Moya + +struct TranscriptionSegment: Identifiable, Equatable { + var id = UUID() + var start: Double + var end: Double + var text: String + var completed: Bool +} + +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 + + // ✅ segment 단위 관리 + private var segments: [TranscriptionSegment] = [] + + init() {} + + func startRecording() { + guard let audioAPIUrl = Bundle.main.object(forInfoDictionaryKey: "AudioAPI_URL") as? String else { + fatalError("❌ xcconfig에서 'AudioAPI_URL'을 찾을 수 없습니다.") + } + + audioWebSocket = AudioWebSocket(host: audioAPIUrl, port: 443) + audioStreamer = AudioStreamer(webSocket: audioWebSocket!) + + isLoading = true + + audioWebSocket?.onTranscriptionReceived = { [weak self] text in + self?.handleRawTranscriptionJSON(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() + } + } + } + + func pauseRecording() { + isPaused = true + audioStreamer?.pauseStreaming() + timer?.invalidate() + } + + func resumeRecording() { + isPaused = false + audioStreamer?.resumeStreaming() + startTimer() + } + + func stopRecording() { + isRecording = false + isPaused = false + timer?.invalidate() + + audioStreamer?.stopStreaming() + audioWebSocket?.sendEndOfAudio() + audioWebSocket?.onTranscriptionReceived = nil + audioWebSocket?.closeConnection() + } + + private func startTimer() { + timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in + self.elapsedTime += 1 + let minutes = self.elapsedTime / 60 + let seconds = self.elapsedTime % 60 + self.timeLabel = String(format: "%02d:%02d", minutes, seconds) + } + } + + // ✅ 최종 텍스트 처리 + func finalizeTranscription() { + isLoading = false + let completedText = segments + .filter { $0.completed } + .map { $0.text.trimmingCharacters(in: .whitespaces) } + .joined(separator: " ") + finalScript = completedText + print("📝 최종 스크립트:\n\(finalScript)") + } + + func postTranscript(to weekId: Int, type: String = "RECORDING") { + let content = finalScript.trimmingCharacters(in: .whitespacesAndNewlines) + let provider = MoyaProvider() + + 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)") + } + } + } + + // ✅ 서버로부터 받은 JSON 문자열 처리 + func handleRawTranscriptionJSON(_ jsonString: String) { + let trimmed = jsonString.trimmingCharacters(in: .whitespacesAndNewlines) + guard let data = trimmed.data(using: .utf8) else { return } + + if trimmed.hasPrefix("{") { + do { + if let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let segmentDicts = dict["segments"] as? [[String: Any]] { + + for item in segmentDicts { + guard let startStr = item["start"] as? String, + let endStr = item["end"] as? String, + let text = item["text"] as? String, + let completed = item["completed"] as? Bool, + let start = Double(startStr), + let end = Double(endStr) else { continue } + + let newSegment = TranscriptionSegment(start: start, end: end, text: text, completed: completed) + + // start 기준으로 덮어쓰기 또는 append + if let index = self.segments.firstIndex(where: { $0.start == start }) { + self.segments[index] = newSegment + } else { + self.segments.append(newSegment) + } + } + + DispatchQueue.main.async { + let completedTexts = self.segments + .filter { $0.completed } + .sorted(by: { $0.start < $1.start }) + .map { $0.text.trimmingCharacters(in: .whitespaces) } + + let pendingText = self.segments + .filter { !$0.completed } + .sorted(by: { $0.start < $1.start }) + .map { $0.text.trimmingCharacters(in: .whitespaces) } + .last ?? "" + + self.transcriptionList = completedTexts + (pendingText.isEmpty ? [] : [pendingText]) + self.finalScript = self.transcriptionList.joined(separator: " ") + } + } + } catch { + print("❌ JSON 파싱 오류: \(error)") + } + } else { + // 일반 텍스트 처리 + DispatchQueue.main.async { + if self.transcriptionList.last != trimmed { + self.transcriptionList.append(trimmed) + self.finalScript = self.transcriptionList.joined(separator: " ") + } + } + } + } + + + +} diff --git a/Sources/ViewModels/Audio/SubmitTranscriptViewModel.swift b/Lecture2Quiz/Sources/ViewModels/Audio/SubmitTranscriptViewModel.swift similarity index 57% rename from Sources/ViewModels/Audio/SubmitTranscriptViewModel.swift rename to Lecture2Quiz/Sources/ViewModels/Audio/SubmitTranscriptViewModel.swift index b69c644..212a751 100644 --- a/Sources/ViewModels/Audio/SubmitTranscriptViewModel.swift +++ b/Lecture2Quiz/Sources/ViewModels/Audio/SubmitTranscriptViewModel.swift @@ -14,8 +14,9 @@ class SubmitTranscriptViewModel: ObservableObject { @Published var selectedWeekId: Int? @Published var showAddWeekAlert = false @Published var newWeekTitle: String = "" + @Published var isLoading: Bool = false - let userId: Int = 1 // TODO: 실제 로그인된 사용자 ID로 교체 + let userId: Int = Int(KeychainHelper.shared.read(forKey: "userId")!)! // TODO: 실제 로그인된 사용자 ID로 교체 private let provider = MoyaProvider() var selectedCourse: CourseResponseByUserID? { @@ -27,42 +28,47 @@ class SubmitTranscriptViewModel: ObservableObject { } func fetchFolders() { + isLoading = true 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 { + DispatchQueue.main.async { + self?.isLoading = false + switch result { + case .success(let response): + do { + let decoded = try JSONDecoder().decode([CourseResponseByUserID].self, from: response.data) 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)") } - } catch { - print(" 파싱 오류: \(error)") + case .failure(let error): + 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 + isLoading = true provider.request(.createWeek(courseId: course.id, title: newWeekTitle, weekNumber: newWeekNumber)) { [weak self] result in - switch result { - case .success: - DispatchQueue.main.async { + DispatchQueue.main.async { + self?.isLoading = false + switch result { + case .success: self?.newWeekTitle = "" completion() + case .failure(let error): + print(" 주차 생성 실패: \(error)") } - case .failure(let error): - print(" 주차 생성 실패: \(error)") } } } @@ -73,14 +79,18 @@ class SubmitTranscriptViewModel: ObservableObject { 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) + isLoading = true + provider.request(.submitTranscript(weekId: weekId, content: content, type: "RECORDING")) { [weak self] result in + DispatchQueue.main.async { + self?.isLoading = false + switch result { + case .success(let response): + print(" 저장 성공: \(response.statusCode)") + completion(true) + case .failure(let error): + print(" 저장 실패: \(error)") + completion(false) + } } } } diff --git a/Sources/ViewModels/Folder/FileViewModel.swift b/Lecture2Quiz/Sources/ViewModels/Folder/FileViewModel.swift similarity index 100% rename from Sources/ViewModels/Folder/FileViewModel.swift rename to Lecture2Quiz/Sources/ViewModels/Folder/FileViewModel.swift diff --git a/Sources/ViewModels/Folder/TextListViewModel.swift b/Lecture2Quiz/Sources/ViewModels/Folder/TextListViewModel.swift similarity index 89% rename from Sources/ViewModels/Folder/TextListViewModel.swift rename to Lecture2Quiz/Sources/ViewModels/Folder/TextListViewModel.swift index b51e4a5..64a5be0 100644 --- a/Sources/ViewModels/Folder/TextListViewModel.swift +++ b/Lecture2Quiz/Sources/ViewModels/Folder/TextListViewModel.swift @@ -4,6 +4,7 @@ import Moya class TextListViewModel: ObservableObject { @Published var texts: [WeekTextResponse] = [] @Published var isLoading = false + @Published var isDeleting = false @Published var showActionSheet = false private let weekId: Int @@ -35,14 +36,17 @@ class TextListViewModel: ObservableObject { } } - func deleteWeek() { + func deleteWeek(onSuccess: @escaping () -> Void = {}) { + isDeleting = true provider.request(.deleteWeek(weekId: weekId)) { [weak self] result in DispatchQueue.main.async { + self?.isDeleting = false switch result { case .success(let response): if (200..<300).contains(response.statusCode) { print("✅ 주차 삭제 성공") self?.onDeleteSuccess() + onSuccess() // ✅ 여기서 호출 } else { print("⚠️ 삭제 실패: \(response.statusCode)") } diff --git a/Sources/ViewModels/Folder/TextViewModel.swift b/Lecture2Quiz/Sources/ViewModels/Folder/TextViewModel.swift similarity index 89% rename from Sources/ViewModels/Folder/TextViewModel.swift rename to Lecture2Quiz/Sources/ViewModels/Folder/TextViewModel.swift index 45d341c..30d252b 100644 --- a/Sources/ViewModels/Folder/TextViewModel.swift +++ b/Lecture2Quiz/Sources/ViewModels/Folder/TextViewModel.swift @@ -18,6 +18,7 @@ class TextViewModel: ObservableObject { @Published var sumary: String? @Published var keywords: [String]? @Published var isEditing: Bool = false + @Published var isDeleting = false @Published var isShowingActionSheet: Bool = false let id: Int @@ -126,6 +127,8 @@ class TextViewModel: ObservableObject { DispatchQueue.main.async { self?.text = decoded.content self?.sumary = decoded.summation + self?.keywords = nil // 키워드 초기화 (옵션) + self?.fetchKeywords() // 키워드도 다시 받아오게 } } catch { print("❌ 텍스트 리프레시 디코딩 실패: \(error)") @@ -136,4 +139,14 @@ class TextViewModel: ObservableObject { } } + func deleteText(completion: @escaping () -> Void) { + isDeleting = true + provider.request(.deleteText(textId: id)) { [weak self] result in + DispatchQueue.main.async { + self?.isDeleting = false + completion() + } + } + } + } diff --git a/Lecture2Quiz/Sources/ViewModels/Folder/WeekViewModel.swift b/Lecture2Quiz/Sources/ViewModels/Folder/WeekViewModel.swift new file mode 100644 index 0000000..fa1ecc7 --- /dev/null +++ b/Lecture2Quiz/Sources/ViewModels/Folder/WeekViewModel.swift @@ -0,0 +1,63 @@ +// +// WeekViewModel.swift +// Lecture2Quiz +// +// Created by 바견규 on 5/30/25. +// + +import SwiftUI +import Moya + +class WeekListViewModel: ObservableObject { + @Published var course: CourseResponseByUserID + @Published var isShowingActionSheet: Bool = false + @Published var isDeleting: Bool = false + @Published var isLoading: Bool = false + + private let provider = MoyaProvider() + var onDeleteSuccess: () -> Void + + init(course: CourseResponseByUserID, onDeleteSuccess: @escaping () -> Void) { + self.course = course + self.onDeleteSuccess = onDeleteSuccess + } + + func deleteCourse(onSuccess: @escaping () -> Void) { + isDeleting = true + provider.request(.deleteCourse(courseId: course.id)) { [weak self] result in + DispatchQueue.main.async { + self?.isDeleting = false + switch result { + case .success(let response): + if (200..<300).contains(response.statusCode) { + print("✅ 수업 삭제 성공") + onSuccess() + } else { + print("⚠️ 수업 삭제 실패: \(response.statusCode)") + } + case .failure(let error): + print("❌ 수업 삭제 실패: \(error)") + } + } + } + } + + func fetchCourse() { + isLoading = true // ✅ 요청 시작 시 true + provider.request(.getCourseWeeks(courseId: course.id)) { [weak self] result in + DispatchQueue.main.async { + self?.isLoading = false + switch result { + case .success(let response): + do { + self?.course = try JSONDecoder().decode(CourseResponseByUserID.self, from: response.data) + } catch { + print("❌ 코스 디코딩 실패: \(error)") + } + case .failure(let error): + print("❌ 코스 요청 실패: \(error)") + } + } + } + } +} diff --git a/Sources/ViewModels/KeyChainHelper/KeyChainHelper.swift b/Lecture2Quiz/Sources/ViewModels/KeyChainHelper/KeyChainHelper.swift similarity index 100% rename from Sources/ViewModels/KeyChainHelper/KeyChainHelper.swift rename to Lecture2Quiz/Sources/ViewModels/KeyChainHelper/KeyChainHelper.swift diff --git a/Sources/ViewModels/Login/LoginViewModel.swift b/Lecture2Quiz/Sources/ViewModels/Login/LoginViewModel.swift similarity index 59% rename from Sources/ViewModels/Login/LoginViewModel.swift rename to Lecture2Quiz/Sources/ViewModels/Login/LoginViewModel.swift index f4fd55e..e0f8d90 100644 --- a/Sources/ViewModels/Login/LoginViewModel.swift +++ b/Lecture2Quiz/Sources/ViewModels/Login/LoginViewModel.swift @@ -12,9 +12,12 @@ import Moya class LoginViewModel: ObservableObject { @Published var isLoggedIn = false + @Published var isLoading: Bool = false + private let authProvider = MoyaProvider() - + func loginWithKakao() { + isLoading = true // 로딩 시작 if UserApi.isKakaoTalkLoginAvailable() { UserApi.shared.loginWithKakaoTalk { [weak self] (oauthToken, error) in self?.handleLoginResult(oauthToken: oauthToken, error: error) @@ -25,40 +28,39 @@ class LoginViewModel: ObservableObject { } } } - + private func handleLoginResult(oauthToken: OAuthToken?, error: Error?) { if let error = error { print("❌ 로그인 실패: \(error)") + isLoading = false return } - + guard let token = oauthToken else { print("❌ 토큰 없음") + isLoading = false return } - + print("✅ 로그인 성공, 토큰: \(token)") - - // 사용자 정보 조회 + UserApi.shared.me { [weak self] user, error in guard let self = self else { return } if let error = error { print("❌ 사용자 정보 조회 실패: \(error)") + self.isLoading = false return } - - + self.sendTokenToServer( accessToken: token.accessToken, refreshToken: token.refreshToken, expiresIn: Int64(token.expiresIn) ) } - - isLoggedIn = true // 뷰 전환 } - + private func sendTokenToServer(accessToken: String, refreshToken: String, expiresIn: Int64?) { let expires = expiresIn ?? 0 authProvider.request(.sendToken( @@ -66,29 +68,30 @@ class LoginViewModel: ObservableObject { refreshToken: refreshToken, expiresIn: expires )) { [weak self] result in - switch result { - case .success(let response): - print("✅ 서버 응답 코드: \(response.statusCode)") - let raw = String(data: response.data, encoding: .utf8) - print("📦 서버 원본 응답: \(raw ?? "파싱 실패")") - do { - let jwtResponse = try JSONDecoder().decode(JWTTokenResponse.self, from: response.data) - KeychainHelper.shared.save(jwtResponse.accessToken, forKey: "jwtToken") - KeychainHelper.shared.save(jwtResponse.name, forKey: "userName") - KeychainHelper.shared.save(jwtResponse.email, forKey: "userEmail") - KeychainHelper.shared.save("\(jwtResponse.userId)", forKey: "userId") - print("📦 JWT 저장 완료: \(jwtResponse.accessToken)") - - DispatchQueue.main.async { - self?.isLoggedIn = true + DispatchQueue.main.async { + guard let self = self else { return } + self.isLoading = false // 로딩 종료 + + switch result { + case .success(let response): + print("✅ 서버 응답 코드: \(response.statusCode)") + do { + let jwtResponse = try JSONDecoder().decode(JWTTokenResponse.self, from: response.data) + KeychainHelper.shared.save(jwtResponse.accessToken, forKey: "jwtToken") + KeychainHelper.shared.save(jwtResponse.name, forKey: "userName") + KeychainHelper.shared.save(jwtResponse.email, forKey: "userEmail") + KeychainHelper.shared.save("\(jwtResponse.userId)", forKey: "userId") + print("📦 JWT 저장 완료: \(jwtResponse.accessToken)") + self.isLoggedIn = true + } catch { + print("❌ JWT 응답 파싱 실패: \(error)") } - } catch { - print("❌ JWT 응답 파싱 실패: \(error)") + + case .failure(let error): + print("❌ 토큰 전송 실패: \(error)") } - - case .failure(let error): - print("❌ 토큰 전송 실패: \(error)") } } } } + diff --git a/Sources/ViewModels/Quiz/QuizCardViewModel.swift b/Lecture2Quiz/Sources/ViewModels/Quiz/QuizCardViewModel.swift similarity index 100% rename from Sources/ViewModels/Quiz/QuizCardViewModel.swift rename to Lecture2Quiz/Sources/ViewModels/Quiz/QuizCardViewModel.swift diff --git a/Lecture2Quiz/Sources/ViewModels/Quiz/QuizRecordViewModel.swift b/Lecture2Quiz/Sources/ViewModels/Quiz/QuizRecordViewModel.swift new file mode 100644 index 0000000..b356f75 --- /dev/null +++ b/Lecture2Quiz/Sources/ViewModels/Quiz/QuizRecordViewModel.swift @@ -0,0 +1,181 @@ +// +// 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 + @Published var isLoading: Bool = false + @Published var isDeleting: Bool = false + @Published var pendingAnswerCount = 0 + + private let quizProvider = MoyaProvider() + private let userId = Int(KeychainHelper.shared.read(forKey: "userId")!)! + + // 전체 세션 조회 + func fetchQuizSessions(completion: (() -> Void)? = nil) { + 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)") + } + completion?() // ✅ 콜백 호출 + } + } + + // 세션 상세 조회 + func fetchSessionDetail( + sessionId: Int, + useForSheet: Bool = true, + completion: ((QuizSessionDetailResponse?) -> Void)? = nil + ) { + isLoading = true + quizProvider.request(.getQuizSessionDetail(sessionId: sessionId)) { [weak self] result in + defer { self?.isLoading = false } + 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 + + // ✅ 디코딩된 detail을 넘김 + completion?(detail) + } + } catch { + print("❌ 세션 디코딩 실패: \(error)") + completion?(nil) + } + case .failure(let error): + print("❌ 세션 요청 실패: \(error)") + completion?(nil) + } + } + } + + + + // 답변 전송 + func sendAnswer(answer: String, completion: (() -> Void)? = nil) { + guard let sessionId = currentSessionId else { + print("❌ 세션 ID 없음") + completion?() + return + } + + pendingAnswerCount += 1 + print("⏳ pendingAnswerCount 증가 → \(pendingAnswerCount)") + + quizProvider.request(.answerQuizSession(sessionId: sessionId, userAnswer: answer)) { [weak self] result in + self?.pendingAnswerCount -= 1 + print("✅ pendingAnswerCount 감소 → \(self?.pendingAnswerCount ?? -1)") + + switch result { + case .success(let response): + print("✅ 답변 전송 성공: \(response.statusCode)") + case .failure(let error): + print("❌ 답변 전송 실패: \(error)") + } + completion?() + } + } + + + + // 세션 종료 + func completeQuizSession(completion: (() -> Void)? = nil) { + guard let sessionId = currentSessionId else { + completion?() + return + } + + quizProvider.request(.completeQuizSession(sessionId: sessionId)) { result in + if case let .failure(error) = result { + print("❌ 세션 완료 실패: \(error)") + } else { + print("✅ 퀴즈 세션 완료") + } + completion?() + } + } + + // 이어서 풀기 + func fetchQuizDetailAndResume(quizId: Int, fromIndex: Int, completion: @escaping ([QuizCard]) -> Void) { + quizProvider.request(.getQuizDetail(id: quizId)) { result in + switch result { + case .success(let response): + do { + let detail = try JSONDecoder().decode(QuizDetailResponse.self, from: response.data) + let questions = detail.questions + + // ⏩ currentQuestionIndex부터 남은 문제만 카드로 변환 + let remainingCards = questions + .dropFirst(fromIndex) + .map { QuizCard(question: $0.front, answer: $0.back) } + + completion(remainingCards) + } catch { + print("❌ 퀴즈 상세 디코딩 실패: \(error)") + completion([]) + } + + case .failure(let error): + print("❌ 퀴즈 상세 조회 실패: \(error)") + completion([]) + } + } + } + + func deleteQuizSessions(sessionIds: [Int], completion: @escaping () -> Void) { + + quizProvider.request(.deleteQuizSessions(sessionIds: sessionIds)) { result in + + switch result { + case .success(let response): + do { + let result = try JSONDecoder().decode(QuizSessionDeleteResponse.self, from: response.data) + print("✅ 삭제 완료된 ID 목록:", result.deletedSessionIds) + + DispatchQueue.main.async { + self.sessions.removeAll { session in + result.deletedSessionIds.contains(session.id) + } + completion() + } + + } catch { + print("❌ 응답 디코딩 실패:", error) + completion() + } + + case .failure(let error): + print("❌ 삭제 실패:", error) + completion() + } + } + } + + + +} diff --git a/Sources/ViewModels/Quiz/QuizViewModel.swift b/Lecture2Quiz/Sources/ViewModels/Quiz/QuizViewModel.swift similarity index 86% rename from Sources/ViewModels/Quiz/QuizViewModel.swift rename to Lecture2Quiz/Sources/ViewModels/Quiz/QuizViewModel.swift index 1cd63b4..ee420bf 100644 --- a/Sources/ViewModels/Quiz/QuizViewModel.swift +++ b/Lecture2Quiz/Sources/ViewModels/Quiz/QuizViewModel.swift @@ -5,6 +5,7 @@ // Created by 바견규 on 5/26/25. // + import Foundation import Moya import SwiftUI @@ -30,20 +31,25 @@ class QuizViewModel: ObservableObject { // MARK: - 퀴즈 만들기용 @Published var selectedCourseId: Int? = nil @Published var selectedWeekIds: Set = [] - // 해당 수업의 주차만 가져오는 computed property + + @Published var isLoading: Bool = false + 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 - + private let userId = Int(KeychainHelper.shared.read(forKey: "userId")!)! - // MARK: - 수업 목록 불러오기 (모든 수업의 주차 반영) + // MARK: - 수업 목록 불러오기 func fetchCourses() { + isLoading = true courseProvider.request(.getUserCourses(userId: userId)) { [weak self] result in + DispatchQueue.main.async { + self?.isLoading = false + } switch result { case .success(let response): do { @@ -62,10 +68,13 @@ class QuizViewModel: ObservableObject { } } - // MARK: - 퀴즈 전체 조회 func fetchAllQuizzes() { + isLoading = true quizProvider.request(.getQuizzes(userId: userId)) { [weak self] result in + DispatchQueue.main.async { + self?.isLoading = false + } switch result { case .success(let response): do { @@ -83,13 +92,15 @@ class QuizViewModel: ObservableObject { } } - // MARK: - 퀴즈 상세 조회 func fetchQuizDetail(id: Int, useForSheet: Bool, completion: @escaping () -> Void) { + isLoading = true quizProvider.request(.getQuizDetail(id: id)) { [weak self] result in + DispatchQueue.main.async { + self?.isLoading = false + } 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 { @@ -111,17 +122,16 @@ class QuizViewModel: ObservableObject { } } - // MARK: - 퀴즈 세션 시작 func startQuizSession(quizId: Int, completion: @escaping (Bool) -> Void) { + isLoading = true 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?.isLoading = false self?.quizCards = [] completion(false) } @@ -129,12 +139,9 @@ class QuizViewModel: ObservableObject { } 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) @@ -142,18 +149,20 @@ class QuizViewModel: ObservableObject { DispatchQueue.main.async { self?.quizCards = cards + self?.isLoading = false completion(true) } } - } catch { - print("❌ 세션 응답 디코딩 실패: \(error)") - completion(false) + DispatchQueue.main.async { + self?.isLoading = false + completion(false) + } } case .failure(let error): - print("❌ 세션 요청 실패: \(error)") DispatchQueue.main.async { + self?.isLoading = false self?.quizCards = [] completion(false) } @@ -161,14 +170,6 @@ class QuizViewModel: ObservableObject { } } - - - - - - - - // MARK: - 카드 넘길 때 답변 전송 func sendAnswer(answer: String, completion: (() -> Void)? = nil) { guard let sessionId = currentSessionId else { return } @@ -185,7 +186,6 @@ class QuizViewModel: ObservableObject { } } - // MARK: - 세션 완료 처리 func completeQuizSession() { guard let sessionId = currentSessionId else { return } @@ -201,17 +201,14 @@ class QuizViewModel: ObservableObject { 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: - 주차 기반 퀴즈 생성 + // MARK: - 퀴즈 생성 func createQuiz(for weekIds: [Int], courseTitle: String, questionCount: Int = 5) { + isLoading = true let request = QuizAPI.createQuiz( userId: userId, title: "\(courseTitle) 퀴즈", @@ -222,26 +219,30 @@ class QuizViewModel: ObservableObject { ) quizProvider.request(request) { [weak self] result in + DispatchQueue.main.async { + self?.isLoading = false + } 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: - 필터링된 주차 계산(질문이 하나라도 있는 주차만 사용) + // MARK: - 질문이 있는 주차만 필터링 func getWeeksWithQuestions(for courseId: Int, completion: @escaping ([WeekResponseByUserID]) -> Void) { guard let course = courses.first(where: { $0.id == courseId }) else { completion([]) return } + isLoading = true // ✅ 로딩 시작 + let group = DispatchGroup() var result: [WeekResponseByUserID] = [] @@ -262,25 +263,31 @@ class QuizViewModel: ObservableObject { } group.notify(queue: .main) { + self.isLoading = false // ✅ 로딩 종료 completion(result) } } + func selectedCourseChanged(to courseId: Int) { - selectedCourseId = courseId - if let course = courses.first(where: { $0.id == courseId }) { - weeks = course.weeks - } else { - weeks = [] - } + selectedCourseId = courseId + if let course = courses.first(where: { $0.id == courseId }) { + weeks = course.weeks + } else { + weeks = [] } - + } + + // MARK: - 퀴즈 삭제 func deleteQuiz(id: Int, completion: @escaping () -> Void) { + isLoading = true quizProvider.request(.deleteQuiz(id: id)) { [weak self] result in + DispatchQueue.main.async { + self?.isLoading = false + } switch result { case .success(let response): if (200...299).contains(response.statusCode) { - print("✅ 퀴즈 삭제 성공") DispatchQueue.main.async { self?.fetchAllQuizzes() completion() @@ -293,7 +300,5 @@ class QuizViewModel: ObservableObject { } } } - - } diff --git a/Lecture2Quiz/Sources/ViewModels/Quiz/WeekQuestionViewModel.swift b/Lecture2Quiz/Sources/ViewModels/Quiz/WeekQuestionViewModel.swift new file mode 100644 index 0000000..d8456a7 --- /dev/null +++ b/Lecture2Quiz/Sources/ViewModels/Quiz/WeekQuestionViewModel.swift @@ -0,0 +1,117 @@ +// +// WeekQuestionViewModel.swift +// Lecture2Quiz +// + +import Foundation +import Moya + +class WeekQuestionViewModel: ObservableObject { + @Published var courses: [CourseResponseByUserID] = [] + @Published var selectedCourseId: Int? + @Published var weeks: [WeekResponseByUserID] = [] + @Published var questionsPerWeek: [Int: [QuestionResponse]] = [:] + @Published var isLoading: Bool = false + @Published var isQuestionsVisible: [Int: Bool] = [:] + + + var selectedCourseTitle: String? { + 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 = Int(KeychainHelper.shared.read(forKey: "userId")!)! + + func fetchCourses() { + isLoading = true + courseProvider.request(.getUserCourses(userId: userId)) { [weak self] result in + DispatchQueue.main.async { + switch result { + case .success(let response): + do { + let courses = try JSONDecoder().decode([CourseResponseByUserID].self, from: response.data) + self?.courses = courses + self?.selectedCourseId = courses.first?.id + self?.weeks = courses.first?.weeks ?? [] + self?.fetchAllQuestions() + } catch { + print("❌ 수업 디코딩 실패: \(error)") + } + case .failure(let error): + print("❌ 수업 조회 실패: \(error)") + } + self?.isLoading = false + } + } + } + + func selectCourse(id: Int) { + selectedCourseId = id + if let course = courses.first(where: { $0.id == id }) { + weeks = course.weeks + fetchAllQuestions() + } + } + + func fetchAllQuestions() { + for week in weeks { + fetchQuestions(for: week.id) + } + } + + 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("❌ 질문 디코딩 실패: \(error)") + } + case .failure(let error): + print("❌ 질문 조회 실패: \(error)") + } + } + } + + func generateQuestions(for weekId: Int, minCount: Int = 3) { + isLoading = true + quizProvider.request(.generateQuestions(weekId: weekId, minQuestionCount: minCount)) { [weak self] result in + DispatchQueue.main.async { + self?.isLoading = false + switch result { + case .success(let response): + do { + let decoded = try JSONDecoder().decode(GenerateQuestionsResponse.self, from: response.data) + print("✅ 질문 생성 완료: \(decoded.questionIds)") + self?.fetchQuestions(for: weekId) // 생성 후 즉시 업데이트 + } catch { + print("❌ 질문 생성 응답 디코딩 실패: \(error)") + } + case .failure(let error): + print("❌ 질문 생성 실패: \(error)") + } + } + } + } + + func toggleQuestionVisibility(for weekId: Int) { + if isQuestionsVisible[weekId] == true { + isQuestionsVisible[weekId] = false + } else { + // 처음 누르는 경우엔 질문도 조회하도록 처리 + if questionsPerWeek[weekId] == nil { + fetchQuestions(for: weekId) + } + isQuestionsVisible[weekId] = true + } + } + +} diff --git a/Sources/Views/Class/FileListView.swift b/Lecture2Quiz/Sources/Views/Class/FileListView.swift similarity index 96% rename from Sources/Views/Class/FileListView.swift rename to Lecture2Quiz/Sources/Views/Class/FileListView.swift index fd86a16..1b59f40 100644 --- a/Sources/Views/Class/FileListView.swift +++ b/Lecture2Quiz/Sources/Views/Class/FileListView.swift @@ -13,7 +13,7 @@ struct FolderListView: View { @State private var showAddFolderAlert = false @State private var newFolderName = "" - let userId: Int = 1 // TODO: 실제 로그인한 사용자 ID로 바꾸세요 + let userId: Int = Int(KeychainHelper.shared.read(forKey: "userId")!)! // TODO: 실제 로그인한 사용자 ID로 바꾸세요 var body: some View { GeometryReader { geo in diff --git a/Sources/Views/Class/Week/CustomCourseActionSheet.swift b/Lecture2Quiz/Sources/Views/Class/Week/CustomCourseActionSheet.swift similarity index 100% rename from Sources/Views/Class/Week/CustomCourseActionSheet.swift rename to Lecture2Quiz/Sources/Views/Class/Week/CustomCourseActionSheet.swift diff --git a/Sources/Views/Class/Week/TextList/Text Detail/EditTextView.swift b/Lecture2Quiz/Sources/Views/Class/Week/TextList/Text Detail/EditTextView.swift similarity index 100% rename from Sources/Views/Class/Week/TextList/Text Detail/EditTextView.swift rename to Lecture2Quiz/Sources/Views/Class/Week/TextList/Text Detail/EditTextView.swift diff --git a/Sources/Views/Class/Week/TextList/Text Detail/FlowLayout.swift b/Lecture2Quiz/Sources/Views/Class/Week/TextList/Text Detail/FlowLayout.swift similarity index 100% rename from Sources/Views/Class/Week/TextList/Text Detail/FlowLayout.swift rename to Lecture2Quiz/Sources/Views/Class/Week/TextList/Text Detail/FlowLayout.swift diff --git a/Sources/Views/Class/Week/TextList/Text Detail/TextActionSheet.swift b/Lecture2Quiz/Sources/Views/Class/Week/TextList/Text Detail/TextActionSheet.swift similarity index 100% rename from Sources/Views/Class/Week/TextList/Text Detail/TextActionSheet.swift rename to Lecture2Quiz/Sources/Views/Class/Week/TextList/Text Detail/TextActionSheet.swift diff --git a/Sources/Views/Class/Week/TextList/Text Detail/TextDetailTabView.swift b/Lecture2Quiz/Sources/Views/Class/Week/TextList/Text Detail/TextDetailTabView.swift similarity index 100% rename from Sources/Views/Class/Week/TextList/Text Detail/TextDetailTabView.swift rename to Lecture2Quiz/Sources/Views/Class/Week/TextList/Text Detail/TextDetailTabView.swift diff --git a/Sources/Views/Class/Week/TextList/Text Detail/TextDetailView.swift b/Lecture2Quiz/Sources/Views/Class/Week/TextList/Text Detail/TextDetailView.swift similarity index 51% rename from Sources/Views/Class/Week/TextList/Text Detail/TextDetailView.swift rename to Lecture2Quiz/Sources/Views/Class/Week/TextList/Text Detail/TextDetailView.swift index f294766..39cef89 100644 --- a/Sources/Views/Class/Week/TextList/Text Detail/TextDetailView.swift +++ b/Lecture2Quiz/Sources/Views/Class/Week/TextList/Text Detail/TextDetailView.swift @@ -15,62 +15,73 @@ struct TextDetailView: View { @Environment(\.dismiss) private var dismiss @State private var isEditing = false - - init(text: String, sumary: String? = nil, id: Int) { + var onDeleteSuccess: (() -> Void)? = nil + + init(text: String, sumary: String? = nil, id: Int, onDeleteSuccess: (() -> Void)? = nil) { _viewModel = StateObject(wrappedValue: TextViewModel(text: text, sumary: sumary, id: id)) + self.onDeleteSuccess = onDeleteSuccess } 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) - .font(Font.Pretend.pretendardMedium(size: 17)) - } - case .sumary: - if let summary = viewModel.sumary { + ZStack { + VStack(spacing: 0) { + TextDetailTabView(viewModel: viewModel) + + Group { + switch viewModel.selectedTab { + case .script: ScrollView { - Markdown(viewModel.sumary ?? "") - .padding() + Text(viewModel.text) .frame(maxWidth: .infinity, alignment: .leading) .font(Font.Pretend.pretendardMedium(size: 17)) } - } else { - ProgressView("요약 불러오는 중...") - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) - } - case .keyword: - if let keywords = viewModel.keywords { - if keywords.isEmpty { - Text("키워드 없음") + case .sumary: + if let summary = viewModel.sumary { + ScrollView { + Markdown(viewModel.sumary ?? "") + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .font(Font.Pretend.pretendardMedium(size: 17)) + } } else { - VStack(alignment: .leading, spacing: 8) { - // 줄 바꿈 없는 경우 - FlowLayout(data: keywords, spacing: 8) { keyword in - Text(keyword) - .font(Font.Pretend.pretendardMedium(size: 18)) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Color.blue.opacity(0.1)) - .foregroundColor(.blue) - .clipShape(Capsule()) + 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) { + FlowLayout(data: keywords, spacing: 8) { keyword in + Text(keyword) + .font(Font.Pretend.pretendardMedium(size: 18)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.blue.opacity(0.1)) + .foregroundColor(.blue) + .clipShape(Capsule()) + } } - + .frame(maxWidth: .infinity, alignment: .leading) } - .frame(maxWidth: .infinity, alignment: .leading) + } else { + ProgressView("키워드 불러오는 중...") } - } else { - ProgressView("키워드 불러오는 중...") } } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // ✅ 삭제 중일 때 로딩 UI + if viewModel.isDeleting { + Color.black.opacity(0.4).ignoresSafeArea() + ProgressView("텍스트 삭제 중...") + .padding() + .background(Color.white) + .cornerRadius(12) } - .padding() - .frame(maxWidth: .infinity, maxHeight: .infinity) } .navigationTitle("텍스트 상세") .navigationBarTitleDisplayMode(.inline) @@ -96,6 +107,7 @@ struct TextDetailView: View { onDelete: { viewModel.deleteText { dismiss() + onDeleteSuccess?() } }, onCancel: { @@ -121,6 +133,7 @@ struct TextDetailView: View { + #Preview { TextDetailView( text: "이것은 대본입니다. 강의 내용을 여기에 입력하세요.", diff --git a/Lecture2Quiz/Sources/Views/Class/Week/TextList/TextListView.swift b/Lecture2Quiz/Sources/Views/Class/Week/TextList/TextListView.swift new file mode 100644 index 0000000..1e28541 --- /dev/null +++ b/Lecture2Quiz/Sources/Views/Class/Week/TextList/TextListView.swift @@ -0,0 +1,186 @@ +// +// 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 { + ZStack { + VStack(alignment: .leading) { + if viewModel.isLoading { + ProgressView("텍스트 불러오는 중...") + .frame(maxWidth: .infinity, alignment: .center) + } else if viewModel.texts.isEmpty { + HStack { + Text(weekTitle) + .font(.title) + .bold() + Spacer() + Button { + viewModel.showActionSheet = true + } label: { + Image(systemName: "ellipsis") + .rotationEffect(.degrees(90)) + .foregroundColor(.primary) + .padding() + } + } + .padding() + Spacer() + Text("📭 텍스트가 없습니다.") + .foregroundColor(.gray) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 50) + Spacer() + + } 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, + onDeleteSuccess: { + viewModel.fetchTexts() + } + ) + } 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) + + if viewModel.isDeleting { + Color.black.opacity(0.4).ignoresSafeArea() + ProgressView("주차 삭제 중...") + .padding() + .background(Color.white) + .cornerRadius(12) + } + } + .navigationTitle("텍스트 목록") + .navigationBarTitleDisplayMode(.inline) + .sheet(isPresented: $viewModel.showActionSheet) { + CustomCourseActionSheet( + onDelete: { + viewModel.deleteWeek { + dismiss() + } + }, + 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/Lecture2Quiz/Sources/Views/Class/Week/Week.swift b/Lecture2Quiz/Sources/Views/Class/Week/Week.swift new file mode 100644 index 0000000..eac60e9 --- /dev/null +++ b/Lecture2Quiz/Sources/Views/Class/Week/Week.swift @@ -0,0 +1,147 @@ +// +// Week.swift +// Lecture2Quiz +// +// Created by 바견규 on 5/25/25. +// + +import SwiftUI + +struct WeekListView: View { + @StateObject private var viewModel: WeekListViewModel + @Environment(\.dismiss) private var dismiss + + init(course: CourseResponseByUserID, onDeleteSuccess: @escaping () -> Void) { + _viewModel = StateObject(wrappedValue: WeekListViewModel(course: course, onDeleteSuccess: onDeleteSuccess)) + } + + var body: some View { + ZStack { + 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.isLoading { + ProgressView("주차 정보를 불러오는 중입니다...") + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 100) + } else 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.fetchCourse() // 삭제 후 최신 주차 목록 다시 불러오기 + } + ) + } + } + } + .padding(.horizontal) + } + + if viewModel.isDeleting { + Color.black.opacity(0.3) + .ignoresSafeArea() + ProgressView("삭제 중입니다...") + .padding() + .background(Color.white) + .cornerRadius(12) + } + + + } + .navigationTitle("주차 목록") + .navigationBarTitleDisplayMode(.inline) + .sheet(isPresented: $viewModel.isShowingActionSheet) { + CustomCourseActionSheet( + onDelete: { + viewModel.deleteCourse { + dismiss() // 삭제 후 닫기 + } + }, + 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 데이터 +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/Home/HomeView.swift b/Lecture2Quiz/Sources/Views/Home/HomeView.swift similarity index 86% rename from Sources/Views/Home/HomeView.swift rename to Lecture2Quiz/Sources/Views/Home/HomeView.swift index 10c12e9..4dcd98c 100644 --- a/Sources/Views/Home/HomeView.swift +++ b/Lecture2Quiz/Sources/Views/Home/HomeView.swift @@ -7,9 +7,19 @@ import SwiftUI +// +// HomeView.swift +// Lecture2Quiz +// +// Created by 바견규 on 4/5/25. +// + +import SwiftUI + struct HomeView: View { @Binding var selectedTab: String @State private var currentPage = 0 + @State private var showRecordingModal = false let banners: [Banner] = [ Banner(imageName: "banner1", title: "오늘의 퀴즈 한입 🧠", subtitle: "오늘도 한 문제 풀고 성장해요!"), @@ -24,9 +34,8 @@ struct HomeView: View { ScrollView { VStack(spacing: 0) { - // MARK: 배너 TabView(selection: $currentPage) { - ForEach(Array(banners.enumerated()), id: \.offset) { index, banner in + ForEach(Array(banners.enumerated()), id: \ .offset) { index, banner in BannerView(banner: banner) .padding(.horizontal, 16) .tag(index) @@ -36,9 +45,8 @@ struct HomeView: View { .frame(height: 200) .padding(.top, 24) - // MARK: 인디케이터 HStack(spacing: 8) { - ForEach(0.. Void diff --git a/Sources/Views/Lecture2QuizTabView.swift b/Lecture2Quiz/Sources/Views/Lecture2QuizTabView.swift similarity index 100% rename from Sources/Views/Lecture2QuizTabView.swift rename to Lecture2Quiz/Sources/Views/Lecture2QuizTabView.swift diff --git a/Sources/Views/Other/OtherView.swift b/Lecture2Quiz/Sources/Views/Other/OtherView.swift similarity index 100% rename from Sources/Views/Other/OtherView.swift rename to Lecture2Quiz/Sources/Views/Other/OtherView.swift diff --git a/Lecture2Quiz/Sources/Views/Quiz/Quiz/CreationQuizSheetView.swift b/Lecture2Quiz/Sources/Views/Quiz/Quiz/CreationQuizSheetView.swift new file mode 100644 index 0000000..61c9eee --- /dev/null +++ b/Lecture2Quiz/Sources/Views/Quiz/Quiz/CreationQuizSheetView.swift @@ -0,0 +1,99 @@ +// +// 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 { + ZStack { + 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는 createQuiz 내부에서 완료 후 호출되게 개선하는 것이 UX 좋음 + DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { + dismiss() + } + } + } + .disabled(selectedWeekIds.isEmpty || selectedCourseId == nil) + } + } + + // ✅ 전체화면 로딩 오버레이 + if viewModel.isLoading { + Color.black.opacity(0.3) + .ignoresSafeArea() + ProgressView("퀴즈 생성 중...") + .padding() + .background(Color.white) + .cornerRadius(12) + } + } + .navigationTitle("퀴즈 생성") + .navigationBarTitleDisplayMode(.inline) + } + } +} + diff --git a/Sources/Views/Quiz/Quiz/QuizCard.swift b/Lecture2Quiz/Sources/Views/Quiz/Quiz/QuizCard.swift similarity index 100% rename from Sources/Views/Quiz/Quiz/QuizCard.swift rename to Lecture2Quiz/Sources/Views/Quiz/Quiz/QuizCard.swift diff --git a/Sources/Views/Quiz/Quiz/QuizDeckView.swift b/Lecture2Quiz/Sources/Views/Quiz/Quiz/QuizDeckView.swift similarity index 100% rename from Sources/Views/Quiz/Quiz/QuizDeckView.swift rename to Lecture2Quiz/Sources/Views/Quiz/Quiz/QuizDeckView.swift diff --git a/Sources/Views/Quiz/Quiz/QuizDetailSheet.swift b/Lecture2Quiz/Sources/Views/Quiz/Quiz/QuizDetailSheet.swift similarity index 100% rename from Sources/Views/Quiz/Quiz/QuizDetailSheet.swift rename to Lecture2Quiz/Sources/Views/Quiz/Quiz/QuizDetailSheet.swift diff --git a/Lecture2Quiz/Sources/Views/Quiz/Quiz/QuizView.swift b/Lecture2Quiz/Sources/Views/Quiz/Quiz/QuizView.swift new file mode 100644 index 0000000..6a3cbb2 --- /dev/null +++ b/Lecture2Quiz/Sources/Views/Quiz/Quiz/QuizView.swift @@ -0,0 +1,101 @@ +// +// 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 { + ZStack { + VStack(spacing: 16) { + // ✅ 기존 UI 구성 그대로 + HStack { + Spacer() + Button(action:{showCreateQuizSheet = true}, label: { + Text("➕ 퀴즈 생성") + .font(Font.Pretend.pretendardBold(size: 18)) + .foregroundColor(.black) + }) + .padding(.trailing) + } + + if viewModel.quizzes.isEmpty { + Spacer() + Text("퀴즈가 없습니다.") + 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(action: { + viewModel.fetchQuizDetail(id: quiz.id, useForSheet: true) {} + }) { + Text("정보 보기") + .font(Font.Pretend.pretendardMedium(size: 14)) + } + + Spacer() + + Button("퀴즈 풀기") { + viewModel.fetchQuizDetail(id: quiz.id, useForSheet: false) { + viewModel.startQuizSession(quizId: quiz.id) { success in + if success { + isDeckPresented = true + } else { + print("❌ 세션 시작 실패") + } + } + } + } + .font(Font.Pretend.pretendardMedium(size: 16)) + .buttonStyle(.borderedProminent) + } + } + .padding(.vertical, 6) + } + } + } + .padding(.top) + + // ✅ 전체화면 로딩 오버레이 + if viewModel.isLoading { + Color.black.opacity(0.3) + .ignoresSafeArea() + ProgressView("로딩 중...") + .padding() + .background(Color.white) + .cornerRadius(12) + } + } + .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/Lecture2Quiz/Sources/Views/Quiz/QuizMainView.swift similarity index 100% rename from Sources/Views/Quiz/QuizMainView.swift rename to Lecture2Quiz/Sources/Views/Quiz/QuizMainView.swift diff --git a/Lecture2Quiz/Sources/Views/Quiz/QuizSession/QuizRecordView.swift b/Lecture2Quiz/Sources/Views/Quiz/QuizSession/QuizRecordView.swift new file mode 100644 index 0000000..bb46eb5 --- /dev/null +++ b/Lecture2Quiz/Sources/Views/Quiz/QuizSession/QuizRecordView.swift @@ -0,0 +1,268 @@ +// QuizRecordView.swift +// Lecture2Quiz + +import SwiftUI + +struct QuizRecordView: View { + @State private var showQuizDeck = false + @State private var isDeckReady = false + @State private var cardVM = QuizCardViewModel() + @State private var isAnswerSubmitting = false + @State private var selectedSessionIds: Set = [] + @State private var isDeleteMode = false + + @StateObject private var viewModel = QuizRecordViewModel() + + var body: some View { + NavigationStack { + ZStack { + if viewModel.isLoading { + VStack { + Spacer() + ProgressView("세션을 불러오는 중입니다...") + .progressViewStyle(CircularProgressViewStyle()) + Spacer() + } + } else { + VStack { + HStack { + Spacer() + Button(isDeleteMode ? "삭제 취소" : "삭제") { + isDeleteMode.toggle() + if !isDeleteMode { + selectedSessionIds.removeAll() + } + } + .foregroundColor(.red) + .font(Font.Pretend.pretendardMedium(size: 16)) + .padding() + } + + List { + ForEach(viewModel.sessions) { session in + VStack(alignment: .leading, spacing: 8) { + HStack { + if isDeleteMode { + Button(action: { + if selectedSessionIds.contains(session.id) { + selectedSessionIds.remove(session.id) + } else { + selectedSessionIds.insert(session.id) + } + }) { + Image(systemName: selectedSessionIds.contains(session.id) ? "checkmark.circle.fill" : "circle") + .foregroundColor(.blue) + } + } + Text(session.quizTitle) + .font(.headline) + } + HStack { + Spacer() + if session.completed { + Button("기록 보기") { + viewModel.fetchSessionDetail(sessionId: session.id) + } + .buttonStyle(.borderedProminent) + .tint(.black) + .foregroundColor(.white) + } else { + Button("이어서 풀기") { + handleResumeSession(for: session) + } + .buttonStyle(.borderedProminent) + .tint(.blue) + .foregroundColor(.white) + } + } + } + .padding(.vertical, 6) + } + } + + if isDeleteMode && !selectedSessionIds.isEmpty { + Button(action: { + viewModel.isDeleting = true + viewModel.deleteQuizSessions(sessionIds: Array(selectedSessionIds)) { + selectedSessionIds.removeAll() + isDeleteMode = false + viewModel.isDeleting = false + } + }) { + if viewModel.isDeleting { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + .frame(maxWidth: .infinity) + } else { + Text("선택한 세션 삭제") + .font(Font.Pretend.pretendardSemiBold(size: 16)) + .frame(maxWidth: .infinity) + } + } + .padding() + .foregroundColor(.white) + .background(Color.red) + .cornerRadius(8) + } + } + } + + if isAnswerSubmitting { + ZStack { + Color.black.opacity(0.4).ignoresSafeArea() + ProgressView("답변 저장 중...") + .padding() + .background(Color.white) + .cornerRadius(12) + } + } + } + .onAppear { + viewModel.isLoading = true + viewModel.fetchQuizSessions { + viewModel.isLoading = false + } + } + .sheet(item: $viewModel.selectedSessionDetail) { detail in + QuizSessionDetailSheet(detail: detail) + } + .fullScreenCover(isPresented: Binding( + get: { showQuizDeck && isDeckReady }, + set: { if !$0 { showQuizDeck = false; isDeckReady = false } } + ), onDismiss: { + viewModel.isLoading = true + viewModel.fetchQuizSessions { + viewModel.isLoading = false + } + }) { + QuizDeckView(viewModel: cardVM, isPresented: $showQuizDeck) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + isAnswerSubmitting = false + } + } + } + } + } + + private func handleResumeSession(for session: QuizSessionSummary) { + viewModel.isLoading = true + viewModel.fetchSessionDetail(sessionId: session.id, useForSheet: false) { detail in + guard let detail = detail else { + print(" 세션 정보 없음") + viewModel.isLoading = false + return + } + if detail.completed { + print(" 이미 완료된 세션입니다.") + viewModel.isLoading = false + return + } + viewModel.fetchQuizDetailAndResume(quizId: detail.quizId, fromIndex: detail.currentQuestionIndex) { cards in + DispatchQueue.main.async { + if cards.isEmpty { + print(" 남은 카드 없음") + viewModel.isLoading = false + return + } + cardVM = QuizCardViewModel(cards: cards) + cardVM.onAnswer = handleAnswer(sessionId: session.id) + cardVM.onAllAnswered = handleAllAnswered() + showQuizDeck = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + isDeckReady = true + } + } + } + } + } + + private func handleAnswer(sessionId: Int) -> (Int, Bool) -> Void { + return { index, isCorrect in + isAnswerSubmitting = true + viewModel.sendAnswer(answer: isCorrect ? "O" : "X") { + fetchSessionDetailWithRetry(sessionId: sessionId) { + guard let sessionDetail = viewModel.selectedQuizDetailForSession else { + print(" 세션 상세 없음") + isAnswerSubmitting = false + return + } + if sessionDetail.completed { + DispatchQueue.global().async { + while viewModel.pendingAnswerCount > 0 { + usleep(100_000) + } + DispatchQueue.main.async { + viewModel.completeQuizSession { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + isAnswerSubmitting = false + showQuizDeck = false + } + } + } + } + return + } + if let next = sessionDetail.currentQuestion { + cardVM = QuizCardViewModel(cards: [ + QuizCard(question: next.front, answer: next.back) + ]) + cardVM.onAnswer = handleAnswer(sessionId: sessionId) + cardVM.onAllAnswered = handleAllAnswered() + } else { + print("문제 없음, 세션 완료 유무: \(sessionDetail.completed)") + isAnswerSubmitting = false + } + } + } + } + } + + private func fetchSessionDetailWithRetry(sessionId: Int, retry: Int = 0, maxRetry: Int = 3, delay: Double = 0.3, completion: @escaping () -> Void) { + viewModel.fetchSessionDetail(sessionId: sessionId, useForSheet: false) { _ in + guard let detail = viewModel.selectedQuizDetailForSession else { + print(" 세션 상세 없음") + completion() + return + } + if detail.currentQuestion == nil && !detail.completed && retry < maxRetry { + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + fetchSessionDetailWithRetry(sessionId: sessionId, retry: retry + 1, completion: completion) + } + } else { + completion() + } + } + } + + private func handleAllAnswered() -> () -> Void { + return { + print(" onAllAnswered 추출됨") + isAnswerSubmitting = true + DispatchQueue.global().async { + let start = Date() + while viewModel.pendingAnswerCount > 0 { + if Date().timeIntervalSince(start) > 5 { + print("⛔️ 5초 초과 응답 대기, 강제 종료") + break + } + usleep(100_000) + } + DispatchQueue.main.async { + print(" 세션 종료 시도") + viewModel.completeQuizSession { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + print(" 세션 종료 완료, 데크 닫기") + isAnswerSubmitting = false + showQuizDeck = false + viewModel.isLoading = true + viewModel.fetchQuizSessions { + viewModel.isLoading = false + } + } + } + } + } + } + } +} diff --git a/Sources/Views/Quiz/QuizSession/QuizSessionSheet.swift b/Lecture2Quiz/Sources/Views/Quiz/QuizSession/QuizSessionSheet.swift similarity index 100% rename from Sources/Views/Quiz/QuizSession/QuizSessionSheet.swift rename to Lecture2Quiz/Sources/Views/Quiz/QuizSession/QuizSessionSheet.swift diff --git a/Sources/Views/Quiz/QuizTopTab.swift b/Lecture2Quiz/Sources/Views/Quiz/QuizTopTab.swift similarity index 100% rename from Sources/Views/Quiz/QuizTopTab.swift rename to Lecture2Quiz/Sources/Views/Quiz/QuizTopTab.swift diff --git a/Lecture2Quiz/Sources/Views/Quiz/WeekQuestion/WeekQuestionView.swift b/Lecture2Quiz/Sources/Views/Quiz/WeekQuestion/WeekQuestionView.swift new file mode 100644 index 0000000..bba3c32 --- /dev/null +++ b/Lecture2Quiz/Sources/Views/Quiz/WeekQuestion/WeekQuestionView.swift @@ -0,0 +1,143 @@ +// +// WeekQuestionView.swift +// Lecture2Quiz +// + +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 { + ZStack { + 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() + + let questions = viewModel.questionsPerWeek[week.id] + + if let questions = viewModel.questionsPerWeek[week.id], !questions.isEmpty { + Button(viewModel.isQuestionsVisible[week.id] == true ? "질문 숨기기" : "질문 조회") { + viewModel.toggleQuestionVisibility(for: week.id) + } + .buttonStyle(BlackButtonStyle()) + } else { + Button("질문 생성") { + showMinCountPrompt = week.id + } + .buttonStyle(BlackButtonStyle()) + } + } + + if viewModel.isQuestionsVisible[week.id] == true, + 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) + } + } + } + } + .disabled(viewModel.isLoading) + .blur(radius: viewModel.isLoading ? 3 : 0) + + if viewModel.isLoading { + Color.black.opacity(0.3).ignoresSafeArea() + ProgressView("로딩 중입니다...") + .padding() + .background(RoundedRectangle(cornerRadius: 12).fill(Color.white)) + .shadow(radius: 10) + } + } + .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("생성할 최소 질문 수를 입력하세요.") + } + } +} + +struct BlackButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.black) + .foregroundColor(.white) + .cornerRadius(8) + .opacity(configuration.isPressed ? 0.7 : 1) + } +} diff --git a/Lecture2Quiz/Sources/Views/SplashView.swift b/Lecture2Quiz/Sources/Views/SplashView.swift new file mode 100644 index 0000000..55e210a --- /dev/null +++ b/Lecture2Quiz/Sources/Views/SplashView.swift @@ -0,0 +1,30 @@ +// +// SplashView.swift +// Lecture2Quiz +// +// Created by 바견규 on 6/3/25. +// + +import SwiftUI + +struct SplashView: View { + var body: some View { + ZStack { + Color(.blue) // Assets에서 정의한 파란색 또는 Color.blue로 대체 가능 + .ignoresSafeArea() + + VStack { + Image("Logo_Lecture2Quiz") // 흰색 로고 이미지 + .resizable() + .scaledToFit() + .frame(width: 180, height: 180) + .padding(.bottom, 24) + + } + } + } +} + +#Preview { + SplashView() +} diff --git a/Sources/Views/kakao/LoginView.swift b/Lecture2Quiz/Sources/Views/kakao/LoginView.swift similarity index 67% rename from Sources/Views/kakao/LoginView.swift rename to Lecture2Quiz/Sources/Views/kakao/LoginView.swift index 440a977..0eec6e1 100644 --- a/Sources/Views/kakao/LoginView.swift +++ b/Lecture2Quiz/Sources/Views/kakao/LoginView.swift @@ -12,14 +12,26 @@ struct MainLoginView: View { var body: some View { NavigationStack { - VStack { - KakaoLoginButtonView(viewModel: viewModel) + ZStack { + VStack { + KakaoLoginButtonView(viewModel: viewModel) + } + + if viewModel.isLoading { + Color.black.opacity(0.3).ignoresSafeArea() + ProgressView("로그인 중입니다...") + .padding() + .background(RoundedRectangle(cornerRadius: 12).fill(Color.white)) + .shadow(radius: 10) + } } .navigationDestination(isPresented: $viewModel.isLoggedIn) { TableView() } - .navigationTitle("Login") + .navigationTitle(Text("Login")) } + + } } diff --git a/Lecture2Quiz/Sources/Views/kakao/RootView.swift b/Lecture2Quiz/Sources/Views/kakao/RootView.swift new file mode 100644 index 0000000..5f5d668 --- /dev/null +++ b/Lecture2Quiz/Sources/Views/kakao/RootView.swift @@ -0,0 +1,30 @@ +// +// RootView.swift +// Lecture2Quiz +// +// Created by 바견규 on 6/3/25. +// + +import SwiftUI + +struct RootView: View { + @State private var showSplash = true + + var body: some View { + Group { + if showSplash { + SplashView() + } else { + MainLoginView() + } + } + .onAppear { + // 2초 후 스플래시 종료 + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + withAnimation { + showSplash = false + } + } + } + } +} diff --git a/Lecture2Quiz/Sources/Views/kakao/kakaoSDKApp.swift b/Lecture2Quiz/Sources/Views/kakao/kakaoSDKApp.swift new file mode 100644 index 0000000..d6cca92 --- /dev/null +++ b/Lecture2Quiz/Sources/Views/kakao/kakaoSDKApp.swift @@ -0,0 +1,30 @@ +// +// 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 { + RootView() + .onOpenURL { url in + if AuthApi.isKakaoTalkLoginUrl(url) { + AuthController.handleOpenUrl(url: url) + } + } + } + } +} diff --git a/Sources/Views/recording Modal/RecordingModal.swift b/Lecture2Quiz/Sources/Views/recording Modal/RecordingModal.swift similarity index 94% rename from Sources/Views/recording Modal/RecordingModal.swift rename to Lecture2Quiz/Sources/Views/recording Modal/RecordingModal.swift index 92f49c5..f9ad96f 100644 --- a/Sources/Views/recording Modal/RecordingModal.swift +++ b/Lecture2Quiz/Sources/Views/recording Modal/RecordingModal.swift @@ -5,11 +5,11 @@ struct RecordingModal: View { @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 @@ -34,7 +34,6 @@ struct RecordingModal: View { } } - // ✅ 스크롤 가능한 텍스트 영역 ScrollView { VStack(spacing: 8) { ForEach(recordingViewModel.transcriptionList.indices, id: \.self) { index in @@ -43,6 +42,7 @@ struct RecordingModal: View { .frame(maxWidth: .infinity, alignment: .leading) .background(Color.gray.opacity(0.1)) .cornerRadius(8) + .font(Font.Pretend.pretendardSemiBold(size: 14)) } } .padding(.horizontal) @@ -51,7 +51,6 @@ struct RecordingModal: View { Divider() .padding(.top, 8) - // ✅ 고정된 하단 컨트롤 VStack(spacing: 16) { Text(recordingViewModel.timeLabel) .font(.system(size: 40)) @@ -90,9 +89,9 @@ struct RecordingModal: View { .onEnded { value in withAnimation { if value.translation.height > 150 { - modalPosition = bottomPosition + modalPosition = bottomPosition // 내려만 감 } else { - modalPosition = midPosition + modalPosition = midPosition // 다시 올라옴 } } } @@ -122,4 +121,3 @@ struct RecordingModal: View { } } } - diff --git a/Lecture2Quiz/Sources/Views/recording Modal/SubmitTranscriptView.swift b/Lecture2Quiz/Sources/Views/recording Modal/SubmitTranscriptView.swift new file mode 100644 index 0000000..cde5d7f --- /dev/null +++ b/Lecture2Quiz/Sources/Views/recording Modal/SubmitTranscriptView.swift @@ -0,0 +1,86 @@ +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 { + ZStack { + Form { + // 수업 선택 + Section(header: Text("수업 선택")) { + Picker("수업", selection: $viewModel.selectedCourseId) { + 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) { + 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) + } + .blur(radius: viewModel.isLoading ? 3 : 0) + .disabled(viewModel.isLoading) + + // 로딩 오버레이 + if viewModel.isLoading { + Color.black.opacity(0.3) + .ignoresSafeArea() + ProgressView("처리 중입니다...") + .padding() + .background(RoundedRectangle(cornerRadius: 12).fill(Color.white)) + .shadow(radius: 10) + } + } + .navigationTitle("녹음 저장") + .onAppear { + viewModel.fetchFolders() + } + } + } +} + +#Preview { + SubmitTranscriptView(finalContent: "이것은 예시 녹음 내용입니다.") { + print("✅ 전송 완료 후 동작") + } +} diff --git a/Tests/Lecture2QuizTests.swift b/Lecture2Quiz/Tests/Lecture2QuizTests.swift similarity index 100% rename from Tests/Lecture2QuizTests.swift rename to Lecture2Quiz/Tests/Lecture2QuizTests.swift diff --git a/Lecture2Quiz/Tuist.swift b/Lecture2Quiz/Tuist.swift new file mode 100644 index 0000000..9c1539d --- /dev/null +++ b/Lecture2Quiz/Tuist.swift @@ -0,0 +1,9 @@ +import ProjectDescription + +let tuist = Tuist( +// Create an account with "tuist auth login" and a project with "tuist project create" +// then uncomment the section below and set the project full-handle. +// * Read more: https://docs.tuist.io/guides/quick-start/gather-insights +// +// fullHandle: "{account_handle}/{project_handle}", +) diff --git a/Lecture2Quiz/Tuist/Package.swift b/Lecture2Quiz/Tuist/Package.swift new file mode 100644 index 0000000..edc8751 --- /dev/null +++ b/Lecture2Quiz/Tuist/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version: 6.0 +import PackageDescription + +#if TUIST + import struct ProjectDescription.PackageSettings + + let packageSettings = PackageSettings( + // Customize the product types for specific package product + // Default is .staticFramework + // productTypes: ["Alamofire": .framework,] + productTypes: [:] + ) +#endif + +let package = Package( + name: "Lecture2Quiz", + dependencies: [ + // Add your own dependencies here: + // .package(url: "https://github.com/Alamofire/Alamofire", from: "5.0.0"), + // You can read more about dependencies here: https://docs.tuist.io/documentation/tuist/dependencies + ] +) diff --git a/Project.swift b/Project.swift new file mode 100644 index 0000000..c3103ca --- /dev/null +++ b/Project.swift @@ -0,0 +1,67 @@ +import ProjectDescription + +let project = Project( + name: "Lecture2Quiz", + packages: [ + .package(url: "https://github.com/kakao/kakao-ios-sdk", .upToNextMajor(from: "2.13.0")), + .package(url: "https://github.com/Moya/Moya.git", .exact("15.0.0")), + .package(url: "https://github.com/gonzalezreal/MarkdownUI", .upToNextMajor(from: "1.0.0")) + ], + // ✅ 아래처럼 settings도 추가해야 함 + settings: .settings( + configurations: [ + .debug(name: "SecretOnly", xcconfig: .relativeToRoot("../iOS/Configuration/Secret.xcconfig")) + ] + ), + targets: [ + .target( + name: "Lecture2Quiz", + destinations: .iOS, + product: .app, + bundleId: "io.tuist.Lecture2Quiz", + infoPlist: .extendingDefault( + with: [ + // 1️⃣ 런치 스크린 설정 + "UILaunchScreen": [ + "UIColorName": "", + "UIImageName": "" + ], + + // 2️⃣ 마이크 권한 + "NSMicrophoneUsageDescription": "앱이 녹음을 위해 마이크를 사용합니다.", + + // 3️⃣ 네트워크 권한 (ws:// 프로토콜 허용) + "NSAppTransportSecurity": [ + "NSAllowsArbitraryLoads": true + ], + + // ✅ Secret.xcconfig에서 가져올 값들 + "API_URL": "$(API_URL)", + "Kakao_AppKey": "$(Kakao_AppKey)", + "AudioAPI_URL": "$(AudioAPI_URL)" + ] + ), + sources: ["Lecture2Quiz/Sources/**"], + resources: ["Lecture2Quiz/Resources/**"], + dependencies: [ + .package(product: "KakaoSDKCommon"), + .package(product: "KakaoSDKAuth"), + .package(product: "KakaoSDKUser"), + .package(product: "Moya"), + .package(product: "MarkdownUI") + ] + ), + .target( + name: "Lecture2QuizTests", + destinations: .iOS, + product: .unitTests, + bundleId: "io.tuist.Lecture2QuizTests", + infoPlist: .default, + sources: ["Lecture2Quiz/Tests/**"], + resources: [], + dependencies: [ + .target(name: "Lecture2Quiz") + ] + ) + ] +) diff --git a/README.md b/README.md deleted file mode 100644 index 65d06ba..0000000 --- a/README.md +++ /dev/null @@ -1 +0,0 @@ -# iOS \ No newline at end of file diff --git a/Sources/QuizAPI/QuizResponse.swift b/Sources/QuizAPI/QuizResponse.swift deleted file mode 100644 index 0eab9b3..0000000 --- a/Sources/QuizAPI/QuizResponse.swift +++ /dev/null @@ -1,131 +0,0 @@ -// -// 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/RecordingViewModel.swift b/Sources/ViewModels/Audio/RecordingViewModel.swift deleted file mode 100644 index 11cb65a..0000000 --- a/Sources/ViewModels/Audio/RecordingViewModel.swift +++ /dev/null @@ -1,135 +0,0 @@ -// -// RecordingViewModel.swift -// Lecture2Quiz -// -// Created by 바견규 on 4/27/25. -// - -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() {} - - func startRecording() { - // 서버 URL 준비 - guard let audioAPIUrl = Bundle.main.object(forInfoDictionaryKey: "AudioAPI_URL") as? String else { - fatalError("❌ xcconfig에서 'AudioAPI_URL'을 찾을 수 없습니다.") - } - - // 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 - 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 - let minutes = self.elapsedTime / 60 - let seconds = self.elapsedTime % 60 - 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/Folder/WeekViewModel.swift b/Sources/ViewModels/Folder/WeekViewModel.swift deleted file mode 100644 index 182df18..0000000 --- a/Sources/ViewModels/Folder/WeekViewModel.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// 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/Quiz/QuizRecordViewModel.swift b/Sources/ViewModels/Quiz/QuizRecordViewModel.swift deleted file mode 100644 index be90bfb..0000000 --- a/Sources/ViewModels/Quiz/QuizRecordViewModel.swift +++ /dev/null @@ -1,95 +0,0 @@ -// -// 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(completion: (() -> Void)? = nil) { - 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)") - } - completion?() // ✅ 콜백 호출 - } - } - - // 세션 상세 조회 - 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/WeekQuestionViewModel.swift b/Sources/ViewModels/Quiz/WeekQuestionViewModel.swift deleted file mode 100644 index d11f4e4..0000000 --- a/Sources/ViewModels/Quiz/WeekQuestionViewModel.swift +++ /dev/null @@ -1,93 +0,0 @@ -// -// 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/Week/TextList/TextListView.swift b/Sources/Views/Class/Week/TextList/TextListView.swift deleted file mode 100644 index ae33121..0000000 --- a/Sources/Views/Class/Week/TextList/TextListView.swift +++ /dev/null @@ -1,151 +0,0 @@ -// -// 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 deleted file mode 100644 index 1ccfca9..0000000 --- a/Sources/Views/Class/Week/Week.swift +++ /dev/null @@ -1,133 +0,0 @@ -// -// 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/Quiz/Quiz/CreationQuizSheetView.swift b/Sources/Views/Quiz/Quiz/CreationQuizSheetView.swift deleted file mode 100644 index 47ebc33..0000000 --- a/Sources/Views/Quiz/Quiz/CreationQuizSheetView.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// 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/Quiz/Quiz/QuizView.swift b/Sources/Views/Quiz/Quiz/QuizView.swift deleted file mode 100644 index 78bfcac..0000000 --- a/Sources/Views/Quiz/Quiz/QuizView.swift +++ /dev/null @@ -1,91 +0,0 @@ -// -// 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(action:{viewModel.fetchQuizDetail(id: quiz.id, useForSheet: true){}}, label: { - Text("정보 보기") - .font(Font.Pretend.pretendardMedium(size: 14)) - - }) - - Spacer() - - // 퀴즈 풀기 - Button("퀴즈 풀기") { - viewModel.fetchQuizDetail(id: quiz.id, useForSheet: false) { - viewModel.startQuizSession(quizId: quiz.id) { success in - if success { - isDeckPresented = true - } else { - print("❌ 세션 시작 실패") - } - } - } - } - .font(Font.Pretend.pretendardMedium(size: 16)) - .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/QuizSession/QuizRecordView.swift b/Sources/Views/Quiz/QuizSession/QuizRecordView.swift deleted file mode 100644 index ddecca1..0000000 --- a/Sources/Views/Quiz/QuizSession/QuizRecordView.swift +++ /dev/null @@ -1,181 +0,0 @@ -// -// 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() - @State private var isLoading = true // ✅ 로딩 상태 - - var body: some View { - NavigationStack { - Group { - if isLoading { - VStack { - Spacer() - ProgressView("세션을 불러오는 중입니다...") - .progressViewStyle(CircularProgressViewStyle()) - Spacer() - } - } else { - 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(.blue) - .foregroundColor(.white) - } - } - } - .padding(.vertical, 6) - } - } - } - } - .onAppear { - viewModel.fetchQuizSessions { - isLoading = false - } - } - .sheet(item: $viewModel.selectedSessionDetail) { detail in - QuizSessionDetailSheet(detail: detail) - } - .fullScreenCover(isPresented: $showQuizDeck, onDismiss: { - DispatchQueue.main.async { - isLoading = true - viewModel.fetchQuizSessions { - isLoading = false - } - } - }) { - 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/WeekQuestion/WeekQuestionView.swift b/Sources/Views/Quiz/WeekQuestion/WeekQuestionView.swift deleted file mode 100644 index f735439..0000000 --- a/Sources/Views/Quiz/WeekQuestion/WeekQuestionView.swift +++ /dev/null @@ -1,138 +0,0 @@ -// -// 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/SwiftUIView.swift b/Sources/Views/SwiftUIView.swift deleted file mode 100644 index dfb8d0b..0000000 --- a/Sources/Views/SwiftUIView.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// SwiftUIView.swift -// Lecture2Quiz -// -// Created by 바견규 on 4/5/25. -// - -import SwiftUI - -struct SwiftUIView: View { - var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) - } -} - -#Preview { - SwiftUIView() -} diff --git a/Sources/Views/kakao/kakaoSDKApp.swift b/Sources/Views/kakao/kakaoSDKApp.swift deleted file mode 100644 index b816b36..0000000 --- a/Sources/Views/kakao/kakaoSDKApp.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// 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/SubmitTranscriptView.swift b/Sources/Views/recording Modal/SubmitTranscriptView.swift deleted file mode 100644 index 5e09b84..0000000 --- a/Sources/Views/recording Modal/SubmitTranscriptView.swift +++ /dev/null @@ -1,91 +0,0 @@ -// -// 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("✅ 전송 완료 후 동작") - } -} - - - diff --git a/Tuist.swift b/Tuist.swift new file mode 100644 index 0000000..9c1539d --- /dev/null +++ b/Tuist.swift @@ -0,0 +1,9 @@ +import ProjectDescription + +let tuist = Tuist( +// Create an account with "tuist auth login" and a project with "tuist project create" +// then uncomment the section below and set the project full-handle. +// * Read more: https://docs.tuist.io/guides/quick-start/gather-insights +// +// fullHandle: "{account_handle}/{project_handle}", +) diff --git a/Tuist/Package.swift b/Tuist/Package.swift new file mode 100644 index 0000000..edc8751 --- /dev/null +++ b/Tuist/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version: 6.0 +import PackageDescription + +#if TUIST + import struct ProjectDescription.PackageSettings + + let packageSettings = PackageSettings( + // Customize the product types for specific package product + // Default is .staticFramework + // productTypes: ["Alamofire": .framework,] + productTypes: [:] + ) +#endif + +let package = Package( + name: "Lecture2Quiz", + dependencies: [ + // Add your own dependencies here: + // .package(url: "https://github.com/Alamofire/Alamofire", from: "5.0.0"), + // You can read more about dependencies here: https://docs.tuist.io/documentation/tuist/dependencies + ] +)