Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 28 additions & 4 deletions Paxo/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
}
Expand Down
29 changes: 28 additions & 1 deletion Paxo/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ enum ResultDisplayMode: String, CaseIterable, Codable, Identifiable {
var displayName: String {
switch self {
case .panel: return "패널 (정답 + 해설)"
case .toast: return "토스트 (정답만 잠깐)"
case .toast: return "토스트 (정답만)"
}
}
}
Expand All @@ -161,13 +161,40 @@ struct SolveResult: Identifiable, Codable, Equatable {
var answer: String?
var answerContext: AnswerContext?
var explanation: String?
var explanationNeedsReview: Bool?
var imageFileName: String?
var correctness: SolveCorrectness?

init(preset: SubjectPreset) {
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 =
Expand Down
18 changes: 18 additions & 0 deletions Paxo/UI/AppleSignInButton.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
10 changes: 1 addition & 9 deletions Paxo/UI/MenuContentView.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import AuthenticationServices
import SwiftUI

/// 캡처와 기록 확인을 메뉴 안에서 이어가도록 홈 화면을 구성한다.
Expand Down Expand Up @@ -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)
Expand Down
79 changes: 79 additions & 0 deletions Paxo/UI/ResultView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -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 {
Expand Down
28 changes: 23 additions & 5 deletions Paxo/UI/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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)
Expand All @@ -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)
}
Expand Down
Loading
Loading