From d897850528efa239c0148030f619008ab1bae85b Mon Sep 17 00:00:00 2001 From: hyeseong Date: Fri, 16 Oct 2026 11:43:31 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=ED=92=80=EC=9D=B4=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=EC=9D=98=20=EC=A0=95=EB=8B=B5=EA=B3=BC=20=ED=95=B4?= =?UTF-8?q?=EC=84=A4=20=EC=88=98=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Paxo/AppState.swift | 32 ++++++++-- Paxo/Models.swift | 27 +++++++++ Paxo/UI/ResultView.swift | 79 ++++++++++++++++++++++++ PaxoTests/SolveResultEditTests.swift | 90 ++++++++++++++++++++++++++++ 4 files changed, 224 insertions(+), 4 deletions(-) create mode 100644 PaxoTests/SolveResultEditTests.swift 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..deab179 100644 --- a/Paxo/Models.swift +++ b/Paxo/Models.swift @@ -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/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/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) + } +} From 2e024f562a3dc0fcbce438059d7554a0d14664ea Mon Sep 17 00:00:00 2001 From: hyeseong Date: Fri, 16 Oct 2026 11:44:11 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=EC=84=A4=EC=A0=95=EC=97=90?= =?UTF-8?q?=EC=84=9C=20Apple=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Paxo/UI/AppleSignInButton.swift | 18 ++++++++++++++++++ Paxo/UI/MenuContentView.swift | 10 +--------- Paxo/UI/SettingsView.swift | 4 ++-- 3 files changed, 21 insertions(+), 11 deletions(-) create mode 100644 Paxo/UI/AppleSignInButton.swift 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/SettingsView.swift b/Paxo/UI/SettingsView.swift index 3fdfc4a..e0a628f 100644 --- a/Paxo/UI/SettingsView.swift +++ b/Paxo/UI/SettingsView.swift @@ -36,14 +36,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) From 0d56559faad6b89cb18bda47b863ab735f2052d0 Mon Sep 17 00:00:00 2001 From: hyeseong Date: Fri, 16 Oct 2026 11:44:29 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=ED=86=A0=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20=ED=91=9C=EC=8B=9C=20=EB=AC=B8=EA=B5=AC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Paxo/Models.swift | 2 +- Paxo/UI/SettingsView.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Paxo/Models.swift b/Paxo/Models.swift index deab179..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 "토스트 (정답만)" } } } diff --git a/Paxo/UI/SettingsView.swift b/Paxo/UI/SettingsView.swift index e0a628f..6b6df3f 100644 --- a/Paxo/UI/SettingsView.swift +++ b/Paxo/UI/SettingsView.swift @@ -58,7 +58,7 @@ struct SettingsView: View { } Text( appState.resultDisplayMode == .toast - ? "정답만 잠깐 떴다 사라집니다. 해설은 생성하지 않아요." + ? "정답만 생성하고 해설은 생성하지 않습니다." : "정답과 해설을 창으로 보여줍니다." ) .font(.caption) From 8786e6494450b180678e4cc75fe4be0670a161f3 Mon Sep 17 00:00:00 2001 From: hyeseong Date: Fri, 16 Oct 2026 12:06:38 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=EA=B2=B0=EA=B3=BC=20=EA=B8=80?= =?UTF-8?q?=EC=9E=90=20=ED=81=AC=EA=B8=B0=20=EB=B3=80=EA=B2=BD=20=EC=A4=91?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=20=EB=B0=9C=ED=96=89=20=EA=B2=BD=EA=B3=A0?= =?UTF-8?q?=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Paxo/UI/SettingsView.swift | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/Paxo/UI/SettingsView.swift b/Paxo/UI/SettingsView.swift index 6b6df3f..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 { @@ -64,7 +82,7 @@ struct SettingsView: View { .font(.caption) .foregroundStyle(.secondary) - Picker("결과 글자 크기", selection: $appState.resultFontSize) { + Picker("결과 글자 크기", selection: $selectedResultFontSize) { ForEach(ResultFontSize.allCases) { size in Text(size.displayName).tag(size) }