diff --git a/Paxo/AppState.swift b/Paxo/AppState.swift index ef600e9..d79e4dc 100644 --- a/Paxo/AppState.swift +++ b/Paxo/AppState.swift @@ -355,6 +355,27 @@ final class AppState: ObservableObject { upsertHistory() } + @discardableResult + func updateHistoryAnswer(_ answer: String, for id: UUID) -> Bool { + guard !isSolving, var result = current, result.id == id, + history.contains(where: { $0.id == id }), result.replaceAnswer(with: answer) + else { return false } + current = result + upsertHistory() + return true + } + + @discardableResult + func updateHistoryExplanation(_ explanation: String, for id: UUID) -> Bool { + guard !isSolving, var result = current, result.id == id, + history.contains(where: { $0.id == id }), result.replaceExplanation(with: explanation) + else { return false } + current = result + phase = .done + upsertHistory() + return true + } + func requestExplanation() { if let current, let startedAt = pendingExplanationStarts[current.id] { waitingSince = startedAt @@ -694,7 +715,7 @@ final class AppState: ObservableObject { pendingExplanationStarts[solveID] = nil requestGate.finish(requestID) apply(generated) - saveExplanation(generated.text, for: solveID) + saveExplanation(generated.text, for: solveID, basedOnAnswer: answer) } catch { pendingExplanationStarts[solveID] = nil requestGate.finish(requestID) @@ -706,14 +727,17 @@ final class AppState: ObservableObject { } /// 해설을 그 풀이 기록에 남긴다. 지금 보고 있는 풀이면 화면에도 바로 보인다. - private func saveExplanation(_ text: String, for solveID: UUID) { + private func saveExplanation(_ text: String, for solveID: UUID, basedOnAnswer: String) { guard current?.id == solveID else { guard let index = history.firstIndex(where: { $0.id == solveID }) else { return } - history[index].explanation = text + guard history[index].acceptGeneratedExplanation(text, basedOnAnswer: basedOnAnswer) else { return } historyStore.save(history) return } - current?.explanation = text + guard var result = current, result.acceptGeneratedExplanation(text, basedOnAnswer: basedOnAnswer) else { + return + } + current = result if phase == .solvingExplanation { waitingSince = nil } diff --git a/Paxo/Models.swift b/Paxo/Models.swift index ed3de5f..c82f4bd 100644 --- a/Paxo/Models.swift +++ b/Paxo/Models.swift @@ -136,7 +136,7 @@ enum ResultDisplayMode: String, CaseIterable, Codable, Identifiable { var displayName: String { switch self { case .panel: return "패널 (정답 + 해설)" - case .toast: return "토스트 (정답만 잠깐)" + case .toast: return "토스트 (정답만)" } } } @@ -161,6 +161,7 @@ struct SolveResult: Identifiable, Codable, Equatable { var answer: String? var answerContext: AnswerContext? var explanation: String? + var explanationNeedsReview: Bool? var imageFileName: String? var correctness: SolveCorrectness? @@ -168,6 +169,32 @@ struct SolveResult: Identifiable, Codable, Equatable { self.preset = preset } + mutating func replaceAnswer(with text: String) -> Bool { + let value = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { return false } + if answer != value { + answer = value + answerContext = nil + if explanation != nil { explanationNeedsReview = true } + } + return true + } + + mutating func replaceExplanation(with text: String) -> Bool { + let value = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { return false } + explanation = value + explanationNeedsReview = false + return true + } + + mutating func acceptGeneratedExplanation(_ text: String, basedOnAnswer: String) -> Bool { + guard explanation == nil else { return false } + explanation = text + explanationNeedsReview = answer != basedOnAnswer + return true + } + var explanationSummary: String { guard let explanation else { return "저장된 해설이 없습니다." } let summary = diff --git a/Paxo/UI/AppleSignInButton.swift b/Paxo/UI/AppleSignInButton.swift new file mode 100644 index 0000000..99b16e0 --- /dev/null +++ b/Paxo/UI/AppleSignInButton.swift @@ -0,0 +1,18 @@ +import AuthenticationServices +import SwiftUI + +/// 로그인 위치가 달라도 동일한 인증 준비 상태와 요청을 사용한다. +struct AppleSignInButton: View { + @EnvironmentObject private var appState: AppState + + var body: some View { + SignInWithAppleButton( + .continue, + onRequest: appState.configureAppleSignIn, + onCompletion: appState.handleAppleSignIn + ) + .signInWithAppleButtonStyle(.black) + .frame(height: 36) + .disabled(!appState.isAppleSignInReady) + } +} diff --git a/Paxo/UI/MenuContentView.swift b/Paxo/UI/MenuContentView.swift index 7c05b0d..c79342e 100644 --- a/Paxo/UI/MenuContentView.swift +++ b/Paxo/UI/MenuContentView.swift @@ -1,4 +1,3 @@ -import AuthenticationServices import SwiftUI /// 캡처와 기록 확인을 메뉴 안에서 이어가도록 홈 화면을 구성한다. @@ -84,14 +83,7 @@ struct MenuContentView: View { .foregroundStyle(.secondary) } - SignInWithAppleButton( - .continue, - onRequest: appState.configureAppleSignIn, - onCompletion: appState.handleAppleSignIn - ) - .signInWithAppleButtonStyle(.black) - .frame(height: 36) - .disabled(!appState.isAppleSignInReady) + AppleSignInButton() if case .failed(let message) = appState.authenticationPhase { Text(message) diff --git a/Paxo/UI/ResultView.swift b/Paxo/UI/ResultView.swift index 8053ac3..3b34549 100644 --- a/Paxo/UI/ResultView.swift +++ b/Paxo/UI/ResultView.swift @@ -7,6 +7,9 @@ struct ResultView: View { @State private var copiedField: String? @State private var showsProblemImage = false @State private var showsSavedExplanation = false + @State private var editField: ResultEditField? + @State private var editDraft = "" + @State private var editingResultID: UUID? var onBack: (() -> Void)? = nil var backTitle = "최근 풀이로" var isHistory = false @@ -48,6 +51,11 @@ struct ResultView: View { showsProblemImage = false copiedField = nil showsSavedExplanation = appState.historyShowsExplanation + editField = nil + editingResultID = nil + } + .sheet(item: $editField) { field in + editor(for: field) } } @@ -106,6 +114,10 @@ struct ResultView: View { if let answer = result.answer { copyButton(text: answer, field: "answer") } + if isHistory { + Button("정답 수정") { beginEdit(.answer, result: result) } + .disabled(isSolving) + } } Text(result.answer ?? "—") .font(appState.resultFontSize.answerFont) @@ -188,6 +200,11 @@ struct ResultView: View { @ViewBuilder private func explanationSection(_ result: SolveResult) -> some View { if let explanation = result.explanation { + if result.explanationNeedsReview == true { + Label("정답을 수정했어요. 기존 해설이 새 정답과 맞는지 확인해주세요.", systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.orange) + } if isHistory { DisclosureGroup(isExpanded: $showsSavedExplanation) { savedExplanation(explanation) @@ -212,6 +229,18 @@ struct ResultView: View { Label("해설 다시 시도", systemImage: "arrow.clockwise") } } + } else if isHistory { + VStack(alignment: .leading, spacing: 8) { + Button("해설 입력") { beginEdit(.explanation, result: result) } + .disabled(isSolving) + if appState.canRequestExplanation { + Button { + appState.requestExplanation() + } label: { + Label("해설 보기", systemImage: "text.book.closed") + } + } + } } else if appState.canRequestExplanation { Button { appState.requestExplanation() @@ -233,11 +262,54 @@ struct ResultView: View { .foregroundStyle(.secondary) Spacer() copyButton(text: explanation, field: "explanation") + if isHistory, let result = appState.current { + Button("해설 수정") { beginEdit(.explanation, result: result) } + .disabled(isSolving) + } } ExplanationView(text: explanation, fontSize: appState.resultFontSize) } } + private func beginEdit(_ field: ResultEditField, result: SolveResult) { + editingResultID = result.id + editDraft = field == .answer ? result.answer ?? "" : result.explanation ?? "" + editField = field + } + + private func editor(for field: ResultEditField) -> some View { + VStack(alignment: .leading, spacing: 16) { + Text(field == .answer ? "정답 수정" : "해설 수정") + .font(.title3.bold()) + if field == .answer { + TextField("정답", text: $editDraft) + .textFieldStyle(.roundedBorder) + } else { + TextEditor(text: $editDraft) + .frame(minHeight: 220) + .border(Color.secondary.opacity(0.25)) + } + HStack { + Spacer() + Button("취소") { editField = nil } + Button("저장") { saveEdit(field) } + .buttonStyle(.borderedProminent) + .disabled(editDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .padding(20) + .frame(width: 480) + } + + private func saveEdit(_ field: ResultEditField) { + guard let editingResultID else { return } + let saved = + field == .answer + ? appState.updateHistoryAnswer(editDraft, for: editingResultID) + : appState.updateHistoryExplanation(editDraft, for: editingResultID) + if saved { editField = nil } + } + private func copyButton(text: String, field: String) -> some View { Button { NSPasteboard.general.clearContents() @@ -272,6 +344,13 @@ struct ResultView: View { } } +private enum ResultEditField: String, Identifiable { + case answer + case explanation + + var id: String { rawValue } +} + /// 화살표와 제목을 같은 버튼으로 묶어 클릭 위치에 따른 동작 차이를 없앤다. private struct ResultDisclosureGroupStyle: DisclosureGroupStyle { func makeBody(configuration: Configuration) -> some View { diff --git a/Paxo/UI/SettingsView.swift b/Paxo/UI/SettingsView.swift index 3fdfc4a..37bdc81 100644 --- a/Paxo/UI/SettingsView.swift +++ b/Paxo/UI/SettingsView.swift @@ -6,6 +6,7 @@ struct SettingsView: View { @EnvironmentObject private var appState: AppState @EnvironmentObject private var store: StoreManager @State private var recentHistoryLimitText = "" + @State private var selectedResultFontSize: ResultFontSize = .medium var body: some View { TabView { @@ -14,10 +15,27 @@ struct SettingsView: View { } .frame(width: 460, height: 660) .paxoWindowSurface() - .onAppear { recentHistoryLimitText = String(appState.recentHistoryLimit) } + .onAppear { + recentHistoryLimitText = String(appState.recentHistoryLimit) + selectedResultFontSize = appState.resultFontSize + } .onChange(of: appState.recentHistoryLimit) { recentHistoryLimitText = String(appState.recentHistoryLimit) } + .onChange(of: appState.resultFontSize) { + if selectedResultFontSize != appState.resultFontSize { + selectedResultFontSize = appState.resultFontSize + } + } + .onChange(of: selectedResultFontSize) { + let selection = selectedResultFontSize + // Picker 갱신 중 ObservableObject를 발행하지 않도록 다음 실행 주기에 반영한다. + DispatchQueue.main.async { + if selectedResultFontSize == selection, appState.resultFontSize != selection { + appState.resultFontSize = selection + } + } + } } private var generalSettings: some View { @@ -36,14 +54,14 @@ struct SettingsView: View { Text(message) .font(.caption) .foregroundStyle(.red) + AppleSignInButton() Button("로그인 다시 준비") { appState.retryAppleSignInPreparation() } case .preparing, .signingIn: ProgressView("Apple 로그인을 확인하는 중…") case .signedOut: - Text("메뉴 막대의 Paxo 메뉴에서 Apple로 로그인해주세요.") - .foregroundStyle(.secondary) + AppleSignInButton() } Text("이름과 이메일은 요청하거나 저장하지 않습니다.") .font(.caption) @@ -58,13 +76,13 @@ struct SettingsView: View { } Text( appState.resultDisplayMode == .toast - ? "정답만 잠깐 떴다 사라집니다. 해설은 생성하지 않아요." + ? "정답만 생성하고 해설은 생성하지 않습니다." : "정답과 해설을 창으로 보여줍니다." ) .font(.caption) .foregroundStyle(.secondary) - Picker("결과 글자 크기", selection: $appState.resultFontSize) { + Picker("결과 글자 크기", selection: $selectedResultFontSize) { ForEach(ResultFontSize.allCases) { size in Text(size.displayName).tag(size) } diff --git a/PaxoTests/SolveResultEditTests.swift b/PaxoTests/SolveResultEditTests.swift new file mode 100644 index 0000000..75b861c --- /dev/null +++ b/PaxoTests/SolveResultEditTests.swift @@ -0,0 +1,90 @@ +import Foundation +import Testing + +@testable import Paxo + +struct SolveResultEditTests { + @Test func answerEditKeepsExplanationAndClearsAIContext() { + var result = SolveResult(preset: .general) + result.answer = "③" + result.answerContext = AnswerContext(questionType: .multipleChoice, choices: [1, 2, 3], selectedChoices: [3]) + result.explanation = "기존 해설" + + let saved = result.replaceAnswer(with: " ② ") + #expect(saved) + #expect(result.answer == "②") + #expect(result.answerContext == nil) + #expect(result.explanation == "기존 해설") + #expect(result.explanationNeedsReview == true) + } + + @Test func explanationCanBeAddedAndEditedIndependently() { + var result = SolveResult(preset: .general) + result.answer = "42" + result.explanationNeedsReview = true + + let added = result.replaceExplanation(with: " 첫 해설 ") + #expect(added) + #expect(result.explanation == "첫 해설") + #expect(result.answer == "42") + #expect(result.explanationNeedsReview == false) + let updated = result.replaceExplanation(with: "수정한 해설") + #expect(updated) + #expect(result.explanation == "수정한 해설") + } + + @Test func blankEditsDoNotChangeRecord() { + var result = SolveResult(preset: .general) + result.answer = "③" + result.explanation = "기존 해설" + let original = result + + let savedAnswer = result.replaceAnswer(with: " \n ") + let savedExplanation = result.replaceExplanation(with: "\t") + #expect(!savedAnswer) + #expect(!savedExplanation) + #expect(result == original) + } + + @Test func delayedAIExplanationDoesNotOverwriteManualEdit() { + var result = SolveResult(preset: .general) + result.answer = "수정한 정답" + let added = result.replaceExplanation(with: "직접 쓴 해설") + #expect(added) + + let accepted = result.acceptGeneratedExplanation("늦게 온 AI 해설", basedOnAnswer: "이전 정답") + #expect(!accepted) + #expect(result.explanation == "직접 쓴 해설") + #expect(result.explanationNeedsReview == false) + } + + @Test func delayedAIExplanationForOldAnswerNeedsReview() { + var result = SolveResult(preset: .general) + result.answer = "수정한 정답" + + let accepted = result.acceptGeneratedExplanation("이전 정답의 AI 해설", basedOnAnswer: "이전 정답") + #expect(accepted) + #expect(result.explanationNeedsReview == true) + } + + @Test func editedRecordPersistsAndLegacyRecordDecodes() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = HistoryStore(baseURL: directory) + var edited = SolveResult(preset: .general) + edited.answer = "③" + edited.explanation = "기존 해설" + let saved = edited.replaceAnswer(with: "②") + #expect(saved) + store.save([edited]) + + let restored = try #require(store.load().first) + #expect(restored.answer == "②") + #expect(restored.explanation == "기존 해설") + #expect(restored.explanationNeedsReview == true) + + let legacy = SolveResult(preset: .general) + let decoded = try JSONDecoder().decode(SolveResult.self, from: JSONEncoder().encode(legacy)) + #expect(decoded.explanationNeedsReview == nil) + } +}