diff --git a/Paxo/AppState.swift b/Paxo/AppState.swift index d79e4dc..165c00c 100644 --- a/Paxo/AppState.swift +++ b/Paxo/AppState.swift @@ -23,6 +23,9 @@ final class AppState: ObservableObject { @Published private(set) var phase: SolvePhase = .idle @Published private(set) var current: SolveResult? @Published private(set) var history: [SolveResult] = [] + @Published private(set) var subjectCatalog = SubjectCatalog() + @Published private(set) var subjectCatalogReady = false + @Published private(set) var subjectError: String? /// 지금 기다리는 요청이 시작된 시각. 대기 화면이 경과 시간으로 안내 단계를 고른다. @Published private(set) var waitingSince: Date? @@ -58,11 +61,13 @@ final class AppState: ObservableObject { } var recentHistory: [SolveResult] { - Array(history.prefix(min(max(recentHistoryLimit, 1), 100))) + HistoryBrowser.recent(history, tier: serverTier, requestedCount: recentHistoryLimit) } - @Published var preset: SubjectPreset { - didSet { UserDefaults.standard.set(preset.rawValue, forKey: "preset") } + var historyDisplayLimit: Int { serverTier == .pro ? 100 : 10 } + + var visibleHistory: [SolveResult] { + HistoryBrowser.visible(history, tier: serverTier) } @Published var captureMode: CaptureMode { didSet { UserDefaults.standard.set(captureMode.rawValue, forKey: "captureMode") } @@ -162,11 +167,13 @@ final class AppState: ObservableObject { private let paywallWindow = PaywallWindowController() private let onboardingWindow = OnboardingWindowController() private let historyStore = HistoryStore() + private let subjectStore = SubjectStore() private let sessionManager = ProxySessionManager() /// 메모리 사용을 제한하고 오래된 문제 이미지는 필요할 때 로컬 기록에서 다시 읽는다. private var imageCache: [UUID: Data] = [:] private var thumbnailCache: [UUID: NSImage] = [:] private var imageCacheOrder: [UUID] = [] + private var activeHistoryResultID: UUID? private let requestGate = SolveRequestGate() private var activeTask: Task? /// 해설 요청이 날아가 있는 풀이와 그 시작 시각. 서버는 같은 해설을 두 번 만들지 않으므로 @@ -184,7 +191,6 @@ final class AppState: ObservableObject { showsRecentHistory = UserDefaults.standard.object(forKey: "showsRecentHistory") as? Bool ?? true historyShowsExplanation = UserDefaults.standard.object(forKey: "historyShowsExplanation") as? Bool ?? true recentHistoryLimit = min(max(UserDefaults.standard.object(forKey: "recentHistoryLimit") as? Int ?? 5, 1), 100) - preset = SubjectPreset(rawValue: UserDefaults.standard.string(forKey: "preset") ?? "") ?? .general captureMode = CaptureMode(rawValue: UserDefaults.standard.string(forKey: "captureMode") ?? "") ?? .region panelPosition = PanelPosition(rawValue: UserDefaults.standard.string(forKey: "panelPosition") ?? "") ?? .topRight @@ -211,6 +217,12 @@ final class AppState: ObservableObject { func start() { history = historyStore.load() + do { + subjectCatalog = try subjectStore.load(migrating: history) + subjectCatalogReady = true + } catch { + subjectError = "과목 정보를 불러오지 못했습니다. 앱을 다시 실행해주세요." + } #if DEBUG sessionManager.developmentProxyURL = proxyURL #endif @@ -320,7 +332,7 @@ final class AppState: ObservableObject { /// 패널 안의 홈에서 기록을 열 때는 현재 창을 닫지 않고 상세 화면으로 전환한다. @discardableResult func showFromHistory(_ item: SolveResult, presentPanel: Bool = true, dismissPanel: Bool = true) -> Bool { - guard !isSolving else { return false } + guard !isSolving, visibleHistory.contains(where: { $0.id == item.id }) else { return false } toastDismissTask?.cancel() // 페이드하는 동안 토스트 내용이 방금 연 기록의 답으로 바뀌어 보이지 않게 바로 닫는다 toast.dismiss(animated: false) @@ -328,6 +340,7 @@ final class AppState: ObservableObject { cacheImage(image, for: item.id) } current = item + activeHistoryResultID = item.id phase = item.explanation == nil ? .answerReady : .done if presentPanel { resultPanel.show(appState: self, on: Self.screenUnderMouse()) @@ -349,6 +362,53 @@ final class AppState: ObservableObject { return NSImage(data: data) } + var historySubjects: [HistorySubject] { + serverTier == .pro && subjectCatalogReady ? subjectCatalog.subjects : [] + } + + func subject(for resultID: UUID) -> HistorySubject? { + guard serverTier == .pro, subjectCatalogReady else { return nil } + return subjectCatalog.subject(for: resultID) + } + + @discardableResult + func createSubject(_ name: String) -> Bool { + updateSubjects { try $0.create(name) } + } + + @discardableResult + func renameSubject(_ id: UUID, to name: String) -> Bool { + updateSubjects { try $0.rename(id, to: name) } + } + + @discardableResult + func deleteSubject(_ id: UUID) -> Bool { + updateSubjects { try $0.delete(id) } + } + + @discardableResult + func assignSubject(_ subjectID: UUID?, to resultID: UUID) -> Bool { + guard history.contains(where: { $0.id == resultID }) else { return false } + return updateSubjects { try $0.assign(subjectID, to: resultID) } + } + + func clearSubjectError() { subjectError = nil } + + private func updateSubjects(_ change: (inout SubjectCatalog) throws -> Void) -> Bool { + guard serverTier == .pro, subjectCatalogReady else { return false } + do { + var updated = subjectCatalog + try change(&updated) + try subjectStore.save(updated) + subjectCatalog = updated + subjectError = nil + return true + } catch { + subjectError = (error as? LocalizedError)?.errorDescription ?? "과목 정보를 저장하지 못했습니다." + return false + } + } + func updateCorrectness(_ correctness: SolveCorrectness) { guard let result = current, result.answer != nil else { return } current?.correctness = result.correctness == correctness ? nil : correctness @@ -519,19 +579,33 @@ final class AppState: ObservableObject { authenticationPhase = .signedOut remainingToday = 0 serverTier = .free + enforceHistoryAccess() usageResetAt = nil } private func apply(_ session: ProxySession) { remainingToday = session.remainingToday serverTier = session.tier + enforceHistoryAccess() usageResetAt = session.resetAt } private func apply(_ result: GenerationResult) { if let remaining = result.remainingToday { remainingToday = remaining } if let resetAt = result.resetAt { usageResetAt = resetAt } - if let tier = result.tier { serverTier = tier } + if let tier = result.tier { + serverTier = tier + enforceHistoryAccess() + } + } + + private func enforceHistoryAccess() { + guard HistoryBrowser.shouldClose(activeResultID: activeHistoryResultID, items: history, tier: serverTier) + else { return } + activeHistoryResultID = nil + current = nil + phase = .idle + resultPanel.hide() } private func prepareAndRunSolve() async { @@ -598,7 +672,8 @@ final class AppState: ObservableObject { } lastCaptureScreen = capture.screen - let result = SolveResult(preset: preset) + let result = SolveResult(preset: .general) + activeHistoryResultID = nil cacheImage(capture.data, for: result.id) current = result waitingSince = Date() @@ -856,6 +931,18 @@ final class AppState: ObservableObject { history = Array(history.prefix(100)) } historyStore.save(history) + if subjectCatalogReady { + var updated = subjectCatalog + updated.prune(keeping: Set(history.map(\.id))) + if updated != subjectCatalog { + do { + try subjectStore.save(updated) + subjectCatalog = updated + } catch { + subjectError = "과목 정보를 저장하지 못했습니다." + } + } + } } private func errorMessage(from error: Error) -> String { diff --git a/Paxo/Models.swift b/Paxo/Models.swift index c82f4bd..7fc1931 100644 --- a/Paxo/Models.swift +++ b/Paxo/Models.swift @@ -40,7 +40,7 @@ enum ResultFontSize: String, CaseIterable, Codable, Identifiable { } } -/// 과목별 풀이 안내와 프롬프트 선택을 하나의 설정값으로 유지한다. +/// 예전 풀이 기록을 복원하고 생성 당시의 프롬프트 구분을 유지한다. enum SubjectPreset: String, CaseIterable, Codable, Identifiable { case general case math diff --git a/Paxo/Storage/HistoryBrowser.swift b/Paxo/Storage/HistoryBrowser.swift index 7109986..5c913db 100644 --- a/Paxo/Storage/HistoryBrowser.swift +++ b/Paxo/Storage/HistoryBrowser.swift @@ -2,13 +2,27 @@ import Foundation /// 날짜 경계는 기기의 달력을 사용해 자정과 시간대 변경에도 기록을 정확히 묶는다. enum HistoryBrowser { + static func visible(_ items: [SolveResult], tier: ProxyTier) -> [SolveResult] { + Array(items.sorted { $0.date > $1.date }.prefix(tier == .pro ? 100 : 10)) + } + + static func recent(_ items: [SolveResult], tier: ProxyTier, requestedCount: Int) -> [SolveResult] { + Array(visible(items, tier: tier).prefix(max(requestedCount, 1))) + } + + static func shouldClose(activeResultID: UUID?, items: [SolveResult], tier: ProxyTier) -> Bool { + guard let activeResultID else { return false } + return !visible(items, tier: tier).contains { $0.id == activeResultID } + } + static func filter( - _ items: [SolveResult], query: String, date: Date? = nil, calendar: Calendar = .current + _ items: [SolveResult], query: String, date: Date? = nil, + subjectName: (UUID) -> String? = { _ in nil }, calendar: Calendar = .current ) -> [SolveResult] { let terms = query.split(whereSeparator: { $0.isWhitespace }).map(String.init) return items.filter { item in if let date, !calendar.isDate(item.date, inSameDayAs: date) { return false } - let text = [item.answer ?? "", item.explanation ?? "", item.preset.displayName].joined(separator: " ") + let text = [item.answer ?? "", item.explanation ?? "", subjectName(item.id) ?? ""].joined(separator: " ") return terms.allSatisfy { text.range(of: $0, options: [.caseInsensitive, .diacriticInsensitive]) != nil } @@ -20,6 +34,18 @@ enum HistoryBrowser { .map { HistoryDay(date: $0.key, items: $0.value.sorted { $0.date > $1.date }) } .sorted { $0.date > $1.date } } + + static func subjects(_ items: [SolveResult], catalog: SubjectCatalog) -> [HistorySubjectGroup] { + let grouped = Dictionary(grouping: items) { catalog.subject(for: $0.id)?.id } + let assigned = catalog.subjects + .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + .compactMap { subject -> HistorySubjectGroup? in + guard let results = grouped[subject.id], !results.isEmpty else { return nil } + return HistorySubjectGroup(subjectID: subject.id, name: subject.name, items: results) + } + guard let unclassified = grouped[nil], !unclassified.isEmpty else { return assigned } + return assigned + [HistorySubjectGroup(subjectID: nil, name: "미분류", items: unclassified)] + } } struct HistoryDay: Identifiable { @@ -27,3 +53,10 @@ struct HistoryDay: Identifiable { let items: [SolveResult] var id: Date { date } } + +struct HistorySubjectGroup: Identifiable { + let subjectID: UUID? + let name: String + let items: [SolveResult] + var id: String { subjectID?.uuidString ?? "unclassified" } +} diff --git a/Paxo/Storage/SubjectStore.swift b/Paxo/Storage/SubjectStore.swift new file mode 100644 index 0000000..113f1e6 --- /dev/null +++ b/Paxo/Storage/SubjectStore.swift @@ -0,0 +1,128 @@ +import Foundation + +/// 기록의 과목 이름과 지정 관계를 풀이 본문과 별도로 보관한다. +struct HistorySubject: Identifiable, Codable, Equatable { + let id: UUID + var name: String + + init(id: UUID = UUID(), name: String) { + self.id = id + self.name = name + } +} + +struct SubjectCatalog: Codable, Equatable { + private(set) var subjects: [HistorySubject] = [] + private(set) var assignments: [String: UUID] = [:] + private(set) var didMigrateLegacyPresets = false + + static func migrated(from history: [SolveResult]) -> SubjectCatalog { + var catalog = SubjectCatalog() + for preset in [SubjectPreset.math, .licenseExam] { + let items = history.filter { $0.preset == preset } + guard !items.isEmpty else { continue } + let subject = HistorySubject(name: preset.displayName) + catalog.subjects.append(subject) + for item in items { + catalog.assignments[item.id.uuidString] = subject.id + } + } + catalog.didMigrateLegacyPresets = true + return catalog + } + + func subject(for resultID: UUID) -> HistorySubject? { + guard let id = assignments[resultID.uuidString] else { return nil } + return subjects.first { $0.id == id } + } + + mutating func create(_ name: String) throws { + let name = try validatedName(name) + subjects.append(HistorySubject(name: name)) + } + + mutating func rename(_ id: UUID, to name: String) throws { + guard let index = subjects.firstIndex(where: { $0.id == id }) else { + throw SubjectError.notFound + } + subjects[index].name = try validatedName(name, excluding: id) + } + + mutating func delete(_ id: UUID) throws { + guard subjects.contains(where: { $0.id == id }) else { throw SubjectError.notFound } + subjects.removeAll { $0.id == id } + assignments = assignments.filter { $0.value != id } + } + + mutating func assign(_ subjectID: UUID?, to resultID: UUID) throws { + if let subjectID { + guard subjects.contains(where: { $0.id == subjectID }) else { throw SubjectError.notFound } + assignments[resultID.uuidString] = subjectID + } else { + assignments.removeValue(forKey: resultID.uuidString) + } + } + + mutating func prune(keeping resultIDs: Set) { + let keys = Set(resultIDs.map(\.uuidString)) + assignments = assignments.filter { keys.contains($0.key) } + } + + private func validatedName(_ raw: String, excluding id: UUID? = nil) throws -> String { + let name = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty, name.count <= 30, name != "미분류" else { + throw SubjectError.invalidName + } + guard + !subjects.contains(where: { + $0.id != id + && $0.name.compare(name, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame + }) + else { throw SubjectError.duplicateName } + return name + } +} + +struct SubjectStore { + private let fileURL: URL + private let fileManager: FileManager + + init(baseURL: URL? = nil, fileManager: FileManager = .default) { + self.fileManager = fileManager + let directory = + baseURL + ?? fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("Paxo", isDirectory: true) + fileURL = directory.appendingPathComponent("subjects.json") + } + + func load(migrating history: [SolveResult]) throws -> SubjectCatalog { + if fileManager.fileExists(atPath: fileURL.path) { + return try JSONDecoder().decode(SubjectCatalog.self, from: Data(contentsOf: fileURL)) + } + let catalog = SubjectCatalog.migrated(from: history) + try save(catalog) + return catalog + } + + func save(_ catalog: SubjectCatalog) throws { + try fileManager.createDirectory( + at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true + ) + try JSONEncoder().encode(catalog).write(to: fileURL, options: .atomic) + } +} + +enum SubjectError: LocalizedError { + case invalidName + case duplicateName + case notFound + + var errorDescription: String? { + switch self { + case .invalidName: "과목 이름은 1~30자로 입력하고 '미분류'는 사용할 수 없습니다." + case .duplicateName: "이미 같은 이름의 과목이 있습니다." + case .notFound: "과목을 찾을 수 없습니다. 다시 선택해주세요." + } + } +} diff --git a/Paxo/UI/HistoryArchiveView.swift b/Paxo/UI/HistoryArchiveView.swift index e53cb07..c6ec0cd 100644 --- a/Paxo/UI/HistoryArchiveView.swift +++ b/Paxo/UI/HistoryArchiveView.swift @@ -5,10 +5,28 @@ struct HistoryArchiveView: View { @EnvironmentObject private var appState: AppState @State private var showsDetail = false @State private var selectedDate: Date? + @State private var selectedSubjectKey: String? + @State private var grouping: HistoryGrouping = .date + @State private var showsSubjectManager = false @State private var query = "" + private var rootMatches: [SolveResult] { + HistoryBrowser.filter(appState.visibleHistory, query: query) { id in + appState.subject(for: id)?.name + } + } + private var matchingItems: [SolveResult] { - HistoryBrowser.filter(appState.history, query: query, date: selectedDate) + rootMatches.filter { item in + if let selectedDate, !Calendar.current.isDate(item.date, inSameDayAs: selectedDate) { + return false + } + if let selectedSubjectKey { + let key = appState.subject(for: item.id)?.id.uuidString ?? "unclassified" + return key == selectedSubjectKey + } + return true + } } var body: some View { @@ -19,12 +37,22 @@ struct HistoryArchiveView: View { VStack(alignment: .leading, spacing: 12) { header searchField - Text("최근 100개의 풀이와 문제 이미지를 이 Mac에서 다시 볼 수 있습니다.") - .font(.caption) - .foregroundStyle(.secondary) + if appState.serverTier == .pro { + proControls + } + if let subjectError = appState.subjectError, appState.serverTier == .pro { + Text(subjectError).font(.caption).foregroundStyle(.red) + } + Text( + appState.serverTier == .pro + ? "최근 100개의 풀이와 문제 이미지를 이 Mac에서 다시 볼 수 있습니다." + : "무료 플랜에서는 최신 10개를 볼 수 있습니다. 이전 기록은 이 Mac에 보관되며 Pro에서 다시 볼 수 있습니다." + ) + .font(.caption) + .foregroundStyle(.secondary) if matchingItems.isEmpty { emptyState - } else if selectedDate != nil { + } else if selectedDate != nil || selectedSubjectKey != nil { HistoryListView( items: matchingItems, showsExplanation: appState.historyShowsExplanation, @@ -34,6 +62,8 @@ struct HistoryArchiveView: View { showsDetail = true } } + } else if grouping == .subject && appState.serverTier == .pro { + subjectList } else { dateList } @@ -43,15 +73,35 @@ struct HistoryArchiveView: View { } } .tint(PaxoStyle.brand) + .sheet(isPresented: $showsSubjectManager) { + SubjectManagementView().environmentObject(appState) + } + .onChange(of: appState.serverTier) { + if appState.serverTier != .pro { + grouping = .date + selectedDate = nil + selectedSubjectKey = nil + showsDetail = false + showsSubjectManager = false + } + } + .onChange(of: appState.subjectCatalog) { + if let selectedSubjectKey, selectedSubjectKey != "unclassified", + !appState.historySubjects.contains(where: { $0.id.uuidString == selectedSubjectKey }) + { + self.selectedSubjectKey = nil + } + } } private var header: some View { VStack(alignment: .leading, spacing: 10) { - if selectedDate != nil { + if selectedDate != nil || selectedSubjectKey != nil { Button { selectedDate = nil + selectedSubjectKey = nil } label: { - Label("날짜 목록으로", systemImage: "chevron.left") + Label(grouping == .date ? "날짜 목록으로" : "과목 목록으로", systemImage: "chevron.left") } .buttonStyle(.plain) .foregroundStyle(.secondary) @@ -60,6 +110,14 @@ struct HistoryArchiveView: View { if let selectedDate { Text(selectedDate, format: .dateTime.year().month().day()) .font(.title2.bold()) + } else if let selectedSubjectKey { + Text( + selectedSubjectKey == "unclassified" + ? "미분류" + : appState.historySubjects.first(where: { $0.id.uuidString == selectedSubjectKey })?.name + ?? "미분류" + ) + .font(.title2.bold()) } else { Text("풀이 기록").font(.title2.bold()) } @@ -74,7 +132,7 @@ struct HistoryArchiveView: View { private var searchField: some View { HStack(spacing: 8) { Image(systemName: "magnifyingglass").foregroundStyle(.secondary) - TextField("정답, 해설, 과목 검색", text: $query) + TextField(appState.serverTier == .pro ? "정답, 해설, 과목 검색" : "정답, 해설 검색", text: $query) .textFieldStyle(.plain) .accessibilityLabel("풀이 기록 검색") if !query.isEmpty { @@ -91,10 +149,26 @@ struct HistoryArchiveView: View { .background(Color.primary.opacity(0.05), in: RoundedRectangle(cornerRadius: 8)) } + private var proControls: some View { + HStack { + Picker("묶음", selection: $grouping) { + Text("날짜별").tag(HistoryGrouping.date) + Text("과목별").tag(HistoryGrouping.subject) + } + .pickerStyle(.segmented) + .onChange(of: grouping) { + selectedDate = nil + selectedSubjectKey = nil + } + Button("과목 관리") { showsSubjectManager = true } + .disabled(!appState.subjectCatalogReady) + } + } + private var dateList: some View { ScrollView { LazyVStack(spacing: 8) { - ForEach(HistoryBrowser.days(matchingItems)) { day in + ForEach(HistoryBrowser.days(rootMatches)) { day in Button { selectedDate = day.date } label: { @@ -126,6 +200,37 @@ struct HistoryArchiveView: View { } } + private var subjectList: some View { + ScrollView { + LazyVStack(spacing: 8) { + ForEach(HistoryBrowser.subjects(rootMatches, catalog: appState.subjectCatalog)) { group in + Button { + selectedSubjectKey = group.id + } label: { + HStack(spacing: 12) { + Image(systemName: "folder") + .font(.title2) + .foregroundStyle(PaxoStyle.brand) + .frame(width: 36) + VStack(alignment: .leading, spacing: 5) { + Text(group.name).font(.callout.weight(.semibold)) + Text("풀이 \(group.items.count)개") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer(minLength: 4) + Image(systemName: "chevron.right").foregroundStyle(.tertiary) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.primary.opacity(0.045), in: RoundedRectangle(cornerRadius: 10)) + } + .buttonStyle(.plain) + } + } + } + } + private var emptyState: some View { VStack(spacing: 10) { Image(systemName: query.isEmpty ? "calendar" : "magnifyingglass") @@ -144,3 +249,8 @@ struct HistoryArchiveView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } } + +private enum HistoryGrouping: String { + case date + case subject +} diff --git a/Paxo/UI/MenuContentView.swift b/Paxo/UI/MenuContentView.swift index c79342e..ecc2f70 100644 --- a/Paxo/UI/MenuContentView.swift +++ b/Paxo/UI/MenuContentView.swift @@ -18,6 +18,9 @@ struct MenuContentView: View { } } .onAppear { appState.refreshFreeRemaining() } + .onChange(of: appState.serverTier) { + if appState.serverTier != .pro { showsDetail = false } + } } private var menuContent: some View { @@ -133,10 +136,14 @@ struct MenuContentView: View { CGFloat(appState.recentHistory.count) * (appState.historyShowsExplanation ? 108 : 76), 270) ) - if appState.history.count > appState.recentHistory.count { - Text("전체 \(appState.history.count)개는 설정의 풀이 기록 탭에서 볼 수 있습니다.") - .font(.caption2) - .foregroundStyle(.secondary) + if appState.visibleHistory.count > appState.recentHistory.count { + Text( + appState.serverTier == .pro + ? "전체 \(appState.visibleHistory.count)개는 설정의 풀이 기록 탭에서 볼 수 있습니다." + : "무료 플랜의 최신 \(appState.visibleHistory.count)개는 설정의 풀이 기록 탭에서 볼 수 있습니다." + ) + .font(.caption2) + .foregroundStyle(.secondary) } } } diff --git a/Paxo/UI/PaywallView.swift b/Paxo/UI/PaywallView.swift index 467c98b..72e6c43 100644 --- a/Paxo/UI/PaywallView.swift +++ b/Paxo/UI/PaywallView.swift @@ -22,12 +22,17 @@ struct PaywallView: View { .multilineTextAlignment(.center) } else { Text( - "무료 풀이는 하루 \(UsagePolicy.dailyFreeLimit)회예요.\nPro는 비용 보호를 위해 하루 최대 \(UsagePolicy.dailyProLimit)회 제공돼요." + "무료 풀이는 하루 \(UsagePolicy.dailyFreeLimit)회, 기록 열람은 최신 10개까지예요.\nPro는 하루 최대 \(UsagePolicy.dailyProLimit)회와 기록 100개 열람, 과목 관리·과목별 보기를 제공해요." ) .font(.callout) .foregroundStyle(.secondary) .multilineTextAlignment(.center) + Text("Pro가 끝나도 이전 기록은 이 Mac에 최대 100개까지 보관됩니다.") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + if store.productLoadState == .idle || store.productLoadState == .loading { HStack(spacing: 8) { ProgressView().controlSize(.small) diff --git a/Paxo/UI/ResultView.swift b/Paxo/UI/ResultView.swift index 3b34549..22e31e2 100644 --- a/Paxo/UI/ResultView.swift +++ b/Paxo/UI/ResultView.swift @@ -10,6 +10,7 @@ struct ResultView: View { @State private var editField: ResultEditField? @State private var editDraft = "" @State private var editingResultID: UUID? + @State private var showsSubjectPicker = false var onBack: (() -> Void)? = nil var backTitle = "최근 풀이로" var isHistory = false @@ -57,6 +58,15 @@ struct ResultView: View { .sheet(item: $editField) { field in editor(for: field) } + .sheet(isPresented: $showsSubjectPicker) { + if let resultID = appState.current?.id { + SubjectPickerView(resultID: resultID) + .environmentObject(appState) + } + } + .onChange(of: appState.serverTier) { + if appState.serverTier != .pro { showsSubjectPicker = false } + } } private var isSolving: Bool { @@ -100,12 +110,24 @@ struct ResultView: View { if let result = appState.current { VStack(alignment: .leading, spacing: 12) { HStack { - Text(result.preset.displayName) Spacer() Text(result.date, format: .dateTime.year().month().day().hour().minute()) } .font(.caption) .foregroundStyle(.secondary) + if isHistory && appState.serverTier == .pro { + Button { + showsSubjectPicker = true + } label: { + if let subject = appState.subject(for: result.id) { + Label(subject.name, systemImage: "folder") + } else { + Label("과목 지정", systemImage: "plus.circle") + } + } + .buttonStyle(.bordered) + .disabled(isSolving || !appState.subjectCatalogReady) + } VStack(alignment: .leading, spacing: 4) { HStack { Text("정답") diff --git a/Paxo/UI/SettingsView.swift b/Paxo/UI/SettingsView.swift index 37bdc81..1c3d318 100644 --- a/Paxo/UI/SettingsView.swift +++ b/Paxo/UI/SettingsView.swift @@ -16,11 +16,14 @@ struct SettingsView: View { .frame(width: 460, height: 660) .paxoWindowSurface() .onAppear { - recentHistoryLimitText = String(appState.recentHistoryLimit) + recentHistoryLimitText = String(min(appState.recentHistoryLimit, appState.historyDisplayLimit)) selectedResultFontSize = appState.resultFontSize } .onChange(of: appState.recentHistoryLimit) { - recentHistoryLimitText = String(appState.recentHistoryLimit) + recentHistoryLimitText = String(min(appState.recentHistoryLimit, appState.historyDisplayLimit)) + } + .onChange(of: appState.serverTier) { + recentHistoryLimitText = String(min(appState.recentHistoryLimit, appState.historyDisplayLimit)) } .onChange(of: appState.resultFontSize) { if selectedResultFontSize != appState.resultFontSize { @@ -135,16 +138,6 @@ struct SettingsView: View { } } - /* 과목 프리셋은 재공개 전까지 설정에서 숨긴다. - Section("과목") { - Picker("과목 프리셋", selection: $appState.preset) { - ForEach(SubjectPreset.allCases) { preset in - Text(preset.displayName).tag(preset) - } - } - } - */ - Section("최근 풀이") { Toggle("메뉴에 최근 풀이 표시", isOn: $appState.showsRecentHistory) Picker("풀이 표시", selection: $appState.historyShowsExplanation) { @@ -157,9 +150,13 @@ struct SettingsView: View { .onDisappear { saveRecentHistoryLimit() } } .disabled(!appState.showsRecentHistory) - Text("메뉴에는 최신 풀이를 설정한 개수만큼 표시합니다. 이전 풀이는 풀이 기록 탭에서 볼 수 있습니다. 정답만 표시해도 저장된 해설은 기록을 열어 볼 수 있습니다.") - .font(.caption) - .foregroundStyle(.secondary) + Text( + appState.serverTier == .pro + ? "메뉴에는 최신 풀이를 설정한 개수만큼 표시합니다. 전체 100개는 풀이 기록 탭에서 볼 수 있습니다." + : "메뉴와 풀이 기록에서는 최신 10개까지 볼 수 있습니다. 이전 기록은 삭제되지 않으며 Pro에서 다시 볼 수 있습니다." + ) + .font(.caption) + .foregroundStyle(.secondary) } Section("캡처") { @@ -284,9 +281,12 @@ struct SettingsView: View { private func saveRecentHistoryLimit() { if let count = Int(recentHistoryLimitText.trimmingCharacters(in: .whitespacesAndNewlines)) { - appState.recentHistoryLimit = min(max(count, 1), 100) + let value = min(max(count, 1), appState.historyDisplayLimit) + if value != min(appState.recentHistoryLimit, appState.historyDisplayLimit) { + appState.recentHistoryLimit = value + } } - recentHistoryLimitText = String(appState.recentHistoryLimit) + recentHistoryLimitText = String(min(appState.recentHistoryLimit, appState.historyDisplayLimit)) } } diff --git a/Paxo/UI/SubjectManagementView.swift b/Paxo/UI/SubjectManagementView.swift new file mode 100644 index 0000000..4b13cf9 --- /dev/null +++ b/Paxo/UI/SubjectManagementView.swift @@ -0,0 +1,167 @@ +import SwiftUI + +/// Pro 사용자가 로컬 과목을 관리한다. +struct SubjectManagementView: View { + @EnvironmentObject private var appState: AppState + @Environment(\.dismiss) private var dismiss + @State private var editing: SubjectEditorMode? + @State private var deletingSubject: HistorySubject? + @State private var showsDeleteConfirmation = false + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("과목 관리").font(.title2.bold()) + Spacer() + Button("완료") { dismiss() } + } + Text("과목을 삭제해도 풀이 기록은 남고 미분류로 이동합니다.") + .font(.caption) + .foregroundStyle(.secondary) + List { + ForEach(appState.historySubjects) { subject in + HStack { + Text(subject.name) + Spacer() + Button("이름 수정") { editing = .rename(subject) } + Button("삭제", role: .destructive) { + deletingSubject = subject + showsDeleteConfirmation = true + } + } + } + } + Button { + editing = .create + } label: { + Label("새 과목 만들기", systemImage: "plus") + } + if let error = appState.subjectError { + Text(error).font(.caption).foregroundStyle(.red) + } + } + .padding(20) + .frame(width: 430, height: 380) + .sheet(item: $editing) { mode in + SubjectEditorView(mode: mode) + .environmentObject(appState) + } + .alert("과목을 삭제할까요?", isPresented: $showsDeleteConfirmation) { + Button("삭제", role: .destructive) { + if let deletingSubject { appState.deleteSubject(deletingSubject.id) } + deletingSubject = nil + } + Button("취소", role: .cancel) { deletingSubject = nil } + } message: { + Text("이 과목의 풀이 기록은 미분류로 이동하며 삭제되지 않습니다.") + } + .onChange(of: appState.serverTier) { + if appState.serverTier != .pro { dismiss() } + } + } +} + +enum SubjectEditorMode: Identifiable { + case create + case rename(HistorySubject) + + var id: String { + switch self { + case .create: "create" + case .rename(let subject): subject.id.uuidString + } + } + + var title: String { + switch self { + case .create: "새 과목" + case .rename: "과목 이름 수정" + } + } + + var initialName: String { + switch self { + case .create: "" + case .rename(let subject): subject.name + } + } +} + +private struct SubjectEditorView: View { + @EnvironmentObject private var appState: AppState + @Environment(\.dismiss) private var dismiss + let mode: SubjectEditorMode + @State private var name = "" + @State private var errorMessage: String? + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text(mode.title).font(.title2.bold()) + TextField("과목 이름", text: $name) + .textFieldStyle(.roundedBorder) + .onSubmit(save) + Text("1~30자 · 같은 이름은 사용할 수 없습니다.") + .font(.caption) + .foregroundStyle(.secondary) + if let errorMessage { + Text(errorMessage).font(.caption).foregroundStyle(.red) + } + HStack { + Spacer() + Button("취소") { dismiss() } + Button("저장", action: save) + .buttonStyle(.borderedProminent) + } + } + .padding(20) + .frame(width: 350) + .onAppear { name = mode.initialName } + .onChange(of: appState.serverTier) { + if appState.serverTier != .pro { dismiss() } + } + } + + private func save() { + let saved: Bool + switch mode { + case .create: saved = appState.createSubject(name) + case .rename(let subject): saved = appState.renameSubject(subject.id, to: name) + } + if saved { dismiss() } else { errorMessage = appState.subjectError } + } +} + +/// 기록 상세의 과목 선택은 로컬 기록에만 반영한다. +struct SubjectPickerView: View { + @EnvironmentObject private var appState: AppState + @Environment(\.dismiss) private var dismiss + let resultID: UUID + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("과목 지정").font(.title2.bold()) + Spacer() + Button("닫기") { dismiss() } + } + List { + Button("미분류") { select(nil) } + ForEach(appState.historySubjects) { subject in + Button(subject.name) { select(subject.id) } + } + } + if let error = appState.subjectError { + Text(error).font(.caption).foregroundStyle(.red) + } + } + .padding(20) + .frame(width: 330, height: 360) + .onChange(of: appState.serverTier) { + if appState.serverTier != .pro { dismiss() } + } + } + + private func select(_ subjectID: UUID?) { + if appState.assignSubject(subjectID, to: resultID) { dismiss() } + } +} diff --git a/Paxo/UI/WaitingView.swift b/Paxo/UI/WaitingView.swift index a5de760..bd47fcf 100644 --- a/Paxo/UI/WaitingView.swift +++ b/Paxo/UI/WaitingView.swift @@ -9,7 +9,7 @@ struct AnswerWaitingView: View { var body: some View { VStack(alignment: .leading, spacing: 14) { if let thumbnail = appState.currentThumbnail { - CaptureThumbnailCard(image: thumbnail, preset: appState.current?.preset ?? appState.preset) + CaptureThumbnailCard(image: thumbnail) } WaitingTimeline(since: appState.waitingSince, stage: WaitingStage.answerStage) { stage, elapsed in WaitingStageRow(stage: stage, showsCancel: elapsed.map(WaitingStage.allowsCancel) ?? false) @@ -228,7 +228,6 @@ private struct WaitingTimeline: View { /// "이 문제를 보고 있어요"를 눈으로 확인시켜 주는 캡처 미리보기 private struct CaptureThumbnailCard: View { let image: NSImage - let preset: SubjectPreset var body: some View { HStack(spacing: 10) { @@ -241,7 +240,7 @@ private struct CaptureThumbnailCard: View { Text("캡처한 문제") .font(.caption) .foregroundStyle(.secondary) - Text("\(preset.displayName) · 방금") + Text("방금") .font(.caption) .foregroundStyle(.tertiary) } diff --git a/PaxoTests/SubjectHistoryTests.swift b/PaxoTests/SubjectHistoryTests.swift new file mode 100644 index 0000000..c0f1ff7 --- /dev/null +++ b/PaxoTests/SubjectHistoryTests.swift @@ -0,0 +1,119 @@ +import Foundation +import Testing + +@testable import Paxo + +struct SubjectHistoryTests { + @Test func legacyPresetsMigrateOnceWithoutChangingHistory() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let historyStore = HistoryStore(baseURL: directory) + let subjectStore = SubjectStore(baseURL: directory) + let math = SolveResult(preset: .math) + let license = SolveResult(preset: .licenseExam) + let general = SolveResult(preset: .general) + historyStore.save([math, license, general]) + + var catalog = try subjectStore.load(migrating: historyStore.load()) + #expect(catalog.subject(for: math.id)?.name == "수학") + #expect(catalog.subject(for: license.id)?.name == "자격시험") + #expect(catalog.subject(for: general.id) == nil) + #expect(Set(historyStore.load().map(\.id)) == Set([math.id, license.id, general.id])) + + let mathID = try #require(catalog.subject(for: math.id)?.id) + try catalog.delete(mathID) + try subjectStore.save(catalog) + let reopened = try subjectStore.load(migrating: historyStore.load()) + #expect(reopened.subject(for: math.id) == nil) + #expect(reopened.subjects.count == 1) + #expect(historyStore.load().count == 3) + } + + @Test func createRenameAssignDeleteAndPersist() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = SubjectStore(baseURL: directory) + let resultID = UUID() + var catalog = try store.load(migrating: []) + try catalog.create(" 물리 ") + let subjectID = try #require(catalog.subjects.first?.id) + try catalog.assign(subjectID, to: resultID) + try catalog.rename(subjectID, to: "과학") + try catalog.create("영어") + let secondSubjectID = try #require(catalog.subjects.last?.id) + try catalog.assign(secondSubjectID, to: resultID) + #expect(catalog.subject(for: resultID)?.name == "영어") + try catalog.assign(subjectID, to: resultID) + try store.save(catalog) + var reopened = try store.load(migrating: []) + #expect(reopened.subject(for: resultID)?.name == "과학") + #expect(throws: SubjectError.self) { try reopened.create("과학") } + #expect(throws: SubjectError.self) { try reopened.create(" ") } + #expect(throws: SubjectError.self) { try reopened.create("미분류") } + try reopened.delete(subjectID) + try store.save(reopened) + #expect(try store.load(migrating: []).subject(for: resultID) == nil) + } + + @Test func freeVisibilityAndSearchCannotExposeOlderSubjects() throws { + let items = (0..<12).map { offset -> SolveResult in + var result = SolveResult(preset: .general) + result.date = Date(timeIntervalSince1970: TimeInterval(12 - offset)) + result.answer = "답 \(offset)" + return result + } + var catalog = SubjectCatalog.migrated(from: []) + try catalog.create("수학") + let subjectID = try #require(catalog.subjects.first?.id) + try catalog.assign(subjectID, to: items[11].id) + + #expect(HistoryBrowser.visible(items.reversed(), tier: .free).map(\.id) == items.prefix(10).map(\.id)) + #expect(HistoryBrowser.visible(items, tier: .free).count == 10) + #expect(HistoryBrowser.visible(items, tier: .pro).count == 12) + #expect(HistoryBrowser.recent(items, tier: .free, requestedCount: 100).count == 10) + #expect(HistoryBrowser.recent(items, tier: .pro, requestedCount: 5).count == 5) + let freeMatches = HistoryBrowser.filter(HistoryBrowser.visible(items, tier: .free), query: "수학") + #expect(freeMatches.isEmpty) + let proMatches = HistoryBrowser.filter( + items, query: "수학", + subjectName: { id in + catalog.subject(for: id)?.name + }) + #expect(proMatches.map(\.id) == [items[11].id]) + } + + @Test func downgradeClosesOnlyHistoryOutsideFreeRange() { + let items = (0..<12).map { offset -> SolveResult in + var result = SolveResult(preset: .general) + result.date = Date(timeIntervalSince1970: TimeInterval(12 - offset)) + return result + } + + #expect(!HistoryBrowser.shouldClose(activeResultID: nil, items: items, tier: .free)) + #expect(!HistoryBrowser.shouldClose(activeResultID: items[11].id, items: items, tier: .pro)) + #expect(HistoryBrowser.shouldClose(activeResultID: items[11].id, items: items, tier: .free)) + #expect(!HistoryBrowser.shouldClose(activeResultID: items[0].id, items: items, tier: .free)) + } + + @Test func groupingAndSubjectDeletionKeepRecordsAndImages() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let historyStore = HistoryStore(baseURL: directory) + let subjectStore = SubjectStore(baseURL: directory) + var first = SolveResult(preset: .general) + let second = SolveResult(preset: .general) + let image = Data("jpeg".utf8) + first.imageFileName = historyStore.saveImage(image, for: first.id) + historyStore.save([first, second]) + var catalog = try subjectStore.load(migrating: historyStore.load()) + try catalog.create("수학") + let subjectID = try #require(catalog.subjects.first?.id) + try catalog.assign(subjectID, to: first.id) + #expect(HistoryBrowser.subjects(historyStore.load(), catalog: catalog).map(\.name) == ["수학", "미분류"]) + try catalog.delete(subjectID) + try subjectStore.save(catalog) + #expect(HistoryBrowser.subjects(historyStore.load(), catalog: catalog).map(\.name) == ["미분류"]) + #expect(historyStore.load().count == 2) + #expect(historyStore.loadImage(for: first) == image) + } +} diff --git a/docs/domain.md b/docs/domain.md index df61579..562addf 100644 --- a/docs/domain.md +++ b/docs/domain.md @@ -12,7 +12,8 @@ | 빠른 채점 모드 | 정답만 강조하여 노출하며, 해설은 사용자가 "해설 보기"를 클릭할 때만 생성(Lazy Load)하는 모드. | 설정 (Settings) | | 토스트 모드 | 정답이 일정 시간 동안 토스트 알림으로 표시된 후 사라지는 모드. 해설은 생성하지 않는다. | `ToastController` | | 결과 위치 | 결과 패널이 렌더링되는 화면 내 위치 (총 7가지 프리셋 중 택 1). | `PanelPosition` | -| 히스토리 (History) | 과거 문제 풀이 기록. 최대 100개까지 정답·해설·날짜·자가 채점과 함께 보관하며, 풀이에 사용한 JPEG 문제 이미지는 사용자 기기의 앱 컨테이너에 저장한다. | `HistoryStore` | +| 히스토리 (History) | 과거 문제 풀이 기록. 최대 100개와 JPEG 문제 이미지를 이 Mac에 보관한다. 무료는 최신 10개만 날짜별로 열람하고, Pro는 최대 100개를 날짜·과목별로 열람한다. 구독이 끝나도 보관 중인 기록은 삭제하지 않는다. | `HistoryStore`, `HistoryArchiveView` | +| 과목 (Subject) | Pro 사용자가 풀이 기록에 직접 지정하는 로컬 분류. 이름과 기록의 지정 관계는 풀이 본문과 따로 보관하며 AI 프롬프트에는 반영하지 않는다. 과목을 삭제하면 기록은 미분류로 남는다. | `SubjectStore` | | 자가 채점 (Self-assessment) | 사용자가 자신의 풀이를 정답·오답으로 표시하는 로컬 기록. 같은 선택을 다시 누르면 미채점으로 돌아가며, 서버 사용량과 과금에는 영향을 주지 않는다. | `SolveCorrectness`, `AppState.updateCorrectness` | | 온보딩 (Onboarding) | 앱 최초 실행 시 제공되는 3단계 안내 플로우 (환영 → 화면 기록 권한 요청 → 시작). | `OnboardingView` |