From e9c33291c8b8a083757335515dae56f12c3e183e Mon Sep 17 00:00:00 2001 From: YESEONG LEE Date: Sun, 16 Aug 2026 13:02:12 +0900 Subject: [PATCH] =?UTF-8?q?[#84]=20refactor:=20=EC=A0=95=EB=A0=AC=EC=9D=84?= =?UTF-8?q?=20(=EA=B8=B0=EC=A4=80=20=C3=97=20=EB=B0=A9=ED=96=A5)=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=A1=9C=20=EC=9E=AC=EC=84=A4=EA=B3=84,=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=20=EB=A9=94=EB=89=B4=EB=A5=BC=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20Menu=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SecretQuery.Sort가 recentlyAdded/oldestFirst/expiringSoon/nameAscending/ nameDescending 5케이스로 기준과 방향을 한데 묶고 있어 3기준×2방향 조합 중 하나(만료 내림차순)를 표현할 수 없었다. key(time/expiry/name)와 direction(ascending/descending)을 독립된 축으로 분리했다. - time 기준을 updatedAt으로 통일 — 목록 행에 표시되는 날짜와 정렬 기준이 달랐던 문제(기존엔 createdAt 기준)를 해소했다. - SwiftData SortDescriptor는 옵셔널을 nil < .some으로만 비교해 만료 오름차순에서 만료일 없는 항목이 맨 앞에 오던 문제가 있었다. InMemorySecretQueryFilter가 방향과 무관하게 nil을 뒤로 보내는 후처리를 맡는다. - 커스텀 오버레이(ZStack + Color.clear + 손으로 그린 SortMenuRow)가 목록 컬럼 밖 클릭에 안 닫히던 버그의 원인이었다. DVTitleBar를 시스템 Menu 기반으로 바꿔 바깥 클릭·ESC·포커스 상실 처리를 OS에 위임했다. 메뉴 안은 Picker(.inline) 2개(기준/방향, divider로 구분)로 구성했다. - DVData에 테스트 타깃이 없어 sortDescriptors를 직접 검증할 수 없었다. in-memory SwiftData 컨테이너로 6개 조합의 실제 fetch 결과를 확인하는 테스트 타깃을 새로 만들었다. # Conflicts: # Projects/DVData/Project.swift --- Projects/DVData/Project.swift | 2 +- .../Secret/InMemorySecretQueryFilter.swift | 58 ++++++-- .../Secret/SecretFetchDescriptorBuilder.swift | 32 ++--- .../Secret/SecretSortingTests.swift | 125 ++++++++++++++++++ .../Sources/DVTitleBarPreviewView.swift | 7 +- .../Sources/Components/DVTitleBar.swift | 23 ++-- .../Repository/Model/SecretQuery.swift | 35 ++++- .../Core/Repository/SecretQueryTests.swift | 32 +++++ .../Sources/Dependencies/SecretClient.swift | 34 +++-- .../SecretList/SecretListFeature.swift | 2 +- .../Features/SecretList/SecretListView.swift | 124 ++++++----------- .../SecretList/SecretListFeatureTests.swift | 11 +- 12 files changed, 335 insertions(+), 150 deletions(-) create mode 100644 Projects/DVData/Tests/RepositoryImpl/Secret/SecretSortingTests.swift diff --git a/Projects/DVData/Project.swift b/Projects/DVData/Project.swift index de85d0d0..6ef18ae8 100644 --- a/Projects/DVData/Project.swift +++ b/Projects/DVData/Project.swift @@ -15,9 +15,9 @@ let project = Project.project( ), .tests( name: "DVDataTests", + sources: ["Tests/**"], dependencies: [ .data(), - .domain(), ] ), ] diff --git a/Projects/DVData/Sources/RepositoryImpl/Secret/InMemorySecretQueryFilter.swift b/Projects/DVData/Sources/RepositoryImpl/Secret/InMemorySecretQueryFilter.swift index 9a58d347..4a1936eb 100644 --- a/Projects/DVData/Sources/RepositoryImpl/Secret/InMemorySecretQueryFilter.swift +++ b/Projects/DVData/Sources/RepositoryImpl/Secret/InMemorySecretQueryFilter.swift @@ -16,7 +16,7 @@ enum InMemorySecretQueryFilter { let filtered = secrets .filter { matchesSearchText(query.searchText, secret: $0) } .filter { matchesExpiry(query.collection, secret: $0, referenceDate: referenceDate) } - return sortedByNameIfNeeded(filtered, sort: query.sort) + return applySortIfNeeded(filtered, sort: query.sort) } /// `.all`/`.liked`(Star)는 이미 만료된 Secret을 보여주지 않는다 — 만료된 항목은 Expired 탭에서만 보인다. @@ -38,22 +38,58 @@ enum InMemorySecretQueryFilter { return expiresAt >= referenceDate } + /// `Array.sorted`는 안정 정렬이라, SwiftData 단계의 updatedAt tie-break 순서가 + /// 동률(같은 이름, 같은 만료일 없음)일 때 그대로 유지된다. + private static func applySortIfNeeded( + _ secrets: [DVDomain.Secret], + sort: SecretQuery.Sort + ) -> [DVDomain.Secret] { + switch sort.key { + case .name: + return sortedByName(secrets, direction: sort.direction) + case .expiry: + return sortedByExpiry(secrets, direction: sort.direction) + case .time: + // SwiftData `SortDescriptor(\.updatedAt)`로 이미 원하는 순서로 왔다. + return secrets + } + } + /// SwiftData `SortDescriptor(\.name)`는 Unicode 코드포인트 순서(대소문자 구분)로만 비교해 /// 한국어·영어가 섞인 이름을 사람이 기대하는 순서로 정렬하지 못한다. /// `localizedStandardCompare`로 대소문자 무시·로케일 인식 비교를 적용한다. - /// `Array.sorted`는 안정 정렬이라, SwiftData 단계의 updatedAt tie-break 순서는 이름이 같을 때 그대로 유지된다. - private static func sortedByNameIfNeeded( + private static func sortedByName( _ secrets: [DVDomain.Secret], - sort: SecretQuery.Sort + direction: SecretQuery.Sort.Direction ) -> [DVDomain.Secret] { - switch sort { - case .nameAscending: - return secrets.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } - case .nameDescending: - return secrets.sorted { $0.name.localizedStandardCompare($1.name) == .orderedDescending } - case .recentlyAdded, .oldestFirst, .expiringSoon: - return secrets + secrets.sorted { + let order = $0.name.localizedStandardCompare($1.name) + switch direction { + case .ascending: return order == .orderedAscending + case .descending: return order == .orderedDescending + } + } + } + + /// SwiftData `SortDescriptor`는 옵셔널을 `nil < .some`으로만 비교해, 오름차순에서 + /// 만료일 없는 Secret이 맨 앞에 온다. 방향과 무관하게 항상 뒤로 보내려면 SwiftData가 + /// 만들어준 순서를 무시하고 여기서 다시 정렬해야 한다. + private static func sortedByExpiry( + _ secrets: [DVDomain.Secret], + direction: SecretQuery.Sort.Direction + ) -> [DVDomain.Secret] { + let withExpiry = secrets.filter { $0.expiresAt != nil } + let withoutExpiry = secrets.filter { $0.expiresAt == nil } + let sorted = withExpiry.sorted { lhs, rhs in + guard let lhsExpiresAt = lhs.expiresAt, let rhsExpiresAt = rhs.expiresAt else { + return false + } + switch direction { + case .ascending: return lhsExpiresAt < rhsExpiresAt + case .descending: return lhsExpiresAt > rhsExpiresAt + } } + return sorted + withoutExpiry } private static func matchesSearchText(_ searchText: String?, secret: DVDomain.Secret) -> Bool { diff --git a/Projects/DVData/Sources/RepositoryImpl/Secret/SecretFetchDescriptorBuilder.swift b/Projects/DVData/Sources/RepositoryImpl/Secret/SecretFetchDescriptorBuilder.swift index 56cd139c..27c39145 100644 --- a/Projects/DVData/Sources/RepositoryImpl/Secret/SecretFetchDescriptorBuilder.swift +++ b/Projects/DVData/Sources/RepositoryImpl/Secret/SecretFetchDescriptorBuilder.swift @@ -141,33 +141,25 @@ enum SecretFetchDescriptorBuilder { } } + /// 만료일(`.expiry`) 정렬에서 `nil`이 방향과 무관하게 뒤로 가야 하는 요구사항은 + /// SwiftData `SortDescriptor`(옵셔널을 `nil < .some`으로만 비교) 하나로는 표현할 수 없다. + /// 여기서는 SwiftData가 다룰 수 있는 만큼만 정렬하고, `InMemorySecretQueryFilter`가 + /// nil을 뒤로 보내는 후처리를 맡는다. private static func sortDescriptors( from sort: SecretQuery.Sort ) -> [SortDescriptor] { - switch sort { - case .recentlyAdded: + let order: SortOrder = sort.direction == .ascending ? .forward : .reverse + switch sort.key { + case .time: + return [SortDescriptor(\.updatedAt, order: order)] + case .expiry: return [ - SortDescriptor(\.createdAt, order: .reverse), + SortDescriptor(\.expiresAt, order: order), SortDescriptor(\.updatedAt, order: .reverse), ] - case .oldestFirst: + case .name: return [ - SortDescriptor(\.createdAt, order: .forward), - SortDescriptor(\.updatedAt, order: .forward), - ] - case .expiringSoon: - return [ - SortDescriptor(\.expiresAt, order: .forward), - SortDescriptor(\.updatedAt, order: .reverse), - ] - case .nameAscending: - return [ - SortDescriptor(\.name, order: .forward), - SortDescriptor(\.updatedAt, order: .reverse), - ] - case .nameDescending: - return [ - SortDescriptor(\.name, order: .reverse), + SortDescriptor(\.name, order: order), SortDescriptor(\.updatedAt, order: .reverse), ] } diff --git a/Projects/DVData/Tests/RepositoryImpl/Secret/SecretSortingTests.swift b/Projects/DVData/Tests/RepositoryImpl/Secret/SecretSortingTests.swift new file mode 100644 index 00000000..9950f9a3 --- /dev/null +++ b/Projects/DVData/Tests/RepositoryImpl/Secret/SecretSortingTests.swift @@ -0,0 +1,125 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation +import SwiftData +import Testing + +import DVDomain + +@testable import DVData + +/// `SecretQuery.Sort`의 6개 조합(time/expiry/name × ascending/descending)이 실제 SwiftData +/// fetch 결과에 반영되는지 검증한다. `SecretFetchDescriptorBuilder`(SwiftData 레벨 정렬)와 +/// `InMemorySecretQueryFilter`(name·expiry 후처리 정렬)를 합친 최종 결과를 본다 — +/// 사용자가 실제로 관찰하는 순서가 이 둘의 조합이기 때문이다. +@Suite("Secret 정렬") +struct SecretSortingTests { + + // MARK: - time + + @Test("time 오름차순은 updatedAt이 오래된 순") + func timeAscending() async throws { + let repository = try await makeRepository() + let old = try await repository.seed(name: "Old", updatedAt: .reference) + let new = try await repository.seed(name: "New", updatedAt: .reference.addingTimeInterval(3600)) + + let result = try await repository.fetch(SecretQuery(sort: .init(key: .time, direction: .ascending))) + + #expect(result.map(\.id) == [old.id, new.id]) + } + + @Test("time 내림차순은 updatedAt이 최신인 순 — 기존 recentlyAdded와 동일하다") + func timeDescending() async throws { + let repository = try await makeRepository() + let old = try await repository.seed(name: "Old", updatedAt: .reference) + let new = try await repository.seed(name: "New", updatedAt: .reference.addingTimeInterval(3600)) + + let result = try await repository.fetch(SecretQuery(sort: .init(key: .time, direction: .descending))) + + #expect(result.map(\.id) == [new.id, old.id]) + } + + // MARK: - expiry + + @Test("expiry 오름차순은 만료가 가까운 순, 만료일 없는 항목은 맨 뒤로 간다") + func expiryAscending() async throws { + let repository = try await makeRepository() + let soon = try await repository.seed(name: "Soon", expiresAt: .reference.addingTimeInterval(86_400)) + let later = try await repository.seed(name: "Later", expiresAt: .reference.addingTimeInterval(2 * 86_400)) + let never = try await repository.seed(name: "Never", expiresAt: nil) + + let result = try await repository.fetch(SecretQuery(sort: .init(key: .expiry, direction: .ascending))) + + #expect(result.map(\.id) == [soon.id, later.id, never.id]) + } + + @Test("expiry 내림차순은 만료가 먼 순, 만료일 없는 항목은 여전히 맨 뒤로 간다") + func expiryDescending() async throws { + let repository = try await makeRepository() + let soon = try await repository.seed(name: "Soon", expiresAt: .reference.addingTimeInterval(86_400)) + let later = try await repository.seed(name: "Later", expiresAt: .reference.addingTimeInterval(2 * 86_400)) + let never = try await repository.seed(name: "Never", expiresAt: nil) + + let result = try await repository.fetch(SecretQuery(sort: .init(key: .expiry, direction: .descending))) + + #expect(result.map(\.id) == [later.id, soon.id, never.id]) + } + + // MARK: - name + + @Test("name 오름차순은 로케일 인식 오름차순(A→Z)") + func nameAscending() async throws { + let repository = try await makeRepository() + let banana = try await repository.seed(name: "Banana") + let apple = try await repository.seed(name: "Apple") + + let result = try await repository.fetch(SecretQuery(sort: .init(key: .name, direction: .ascending))) + + #expect(result.map(\.id) == [apple.id, banana.id]) + } + + @Test("name 내림차순은 로케일 인식 내림차순(Z→A)") + func nameDescending() async throws { + let repository = try await makeRepository() + let banana = try await repository.seed(name: "Banana") + let apple = try await repository.seed(name: "Apple") + + let result = try await repository.fetch(SecretQuery(sort: .init(key: .name, direction: .descending))) + + #expect(result.map(\.id) == [banana.id, apple.id]) + } + + // MARK: - Helpers + + private func makeRepository() async throws -> SecretRepositoryImpl { + let schema = Schema.appSchema + let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + let container = try ModelContainer(for: schema, configurations: [configuration]) + return SecretRepositoryImpl(modelContainer: container) + } +} + +private extension Date { + static let reference = Date(timeIntervalSince1970: 1_800_000_000) +} + +private extension SecretRepositoryImpl { + /// 정렬 검증에 필요한 필드만 지정하고 나머지는 고정값으로 채운 Secret을 저장한다. + @discardableResult + func seed( + name: String, + updatedAt: Date = .reference, + expiresAt: Date? = nil + ) async throws -> DVDomain.Secret { + let secret = DVDomain.Secret( + id: UUID(), + name: name, + secretType: .apiKeyToken, + expiresAt: expiresAt, + createdAt: .reference, + updatedAt: updatedAt, + payload: DVDomain.SecretPayload(encryptedData: Data(), keyTag: "test", schemaVersion: 1) + ) + return try await create(secret) + } +} diff --git a/Projects/DVDesign/SampleApp/Sources/DVTitleBarPreviewView.swift b/Projects/DVDesign/SampleApp/Sources/DVTitleBarPreviewView.swift index 5ffb5f18..daf1fcca 100644 --- a/Projects/DVDesign/SampleApp/Sources/DVTitleBarPreviewView.swift +++ b/Projects/DVDesign/SampleApp/Sources/DVTitleBarPreviewView.swift @@ -18,16 +18,15 @@ struct DVTitleBarPreviewView: View { DVTitleBar( titleText: "All", searchText: $searchText, - onSortTapped: {} + sortMenuContent: { AnyView(Text("Sort menu content")) } ) .frame(width: 280) .background(Color.white) } - previewSection("Expired") { + previewSection("정렬 없음") { DVTitleBar( titleText: "Expired", - searchText: .constant(""), - onSortTapped: {} + searchText: .constant("") ) .frame(width: 280) .background(Color.white) diff --git a/Projects/DVDesign/Sources/Components/DVTitleBar.swift b/Projects/DVDesign/Sources/Components/DVTitleBar.swift index f85b184b..b9fd0fb6 100644 --- a/Projects/DVDesign/Sources/Components/DVTitleBar.swift +++ b/Projects/DVDesign/Sources/Components/DVTitleBar.swift @@ -9,21 +9,22 @@ public struct DVTitleBar: View { public let titleText: String public let searchText: Binding public let searchPromptText: String - public let onSortTapped: (() -> Void)? + /// 정렬 버튼을 누르면 펼쳐질 메뉴 내용. `nil`이면 정렬 버튼 자체를 그리지 않는다. + /// 클로저가 반환하는 뷰가 시스템 `Menu` 안에 들어가므로, 바깥 클릭·ESC·포커스 상실 처리는 시스템이 담당한다. + public let sortMenuContent: (() -> AnyView)? // MARK: - Init - /// - Parameter onSortTapped: 정렬 버튼 탭 핸들러. `nil`이면 정렬 버튼 자체를 그리지 않는다. public init( titleText: String, searchText: Binding, searchPromptText: String = "Search", - onSortTapped: (() -> Void)? = nil + sortMenuContent: (() -> AnyView)? = nil ) { self.titleText = titleText self.searchText = searchText self.searchPromptText = searchPromptText - self.onSortTapped = onSortTapped + self.sortMenuContent = sortMenuContent } // MARK: - Body @@ -47,20 +48,24 @@ extension DVTitleBar { .dvFont(.headingXL) .foregroundStyle(Color.dv(.gray900)) Spacer() - if let onSortTapped { - sortButton(action: onSortTapped) + if let sortMenuContent { + sortMenu(content: sortMenuContent) } } .padding(.vertical, 4) } - private func sortButton(action: @escaping () -> Void) -> some View { - Button(action: action) { + private func sortMenu(content: () -> AnyView) -> some View { + Menu { + content() + } label: { Image(systemName: "arrow.up.arrow.down") .dvFont(.bodyXL) .foregroundStyle(Color.dv(.gray800)) } - .buttonStyle(.plain) + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() } private var searchField: some View { diff --git a/Projects/DVDomain/Sources/Repository/Model/SecretQuery.swift b/Projects/DVDomain/Sources/Repository/Model/SecretQuery.swift index ff345250..e69bc2c0 100644 --- a/Projects/DVDomain/Sources/Repository/Model/SecretQuery.swift +++ b/Projects/DVDomain/Sources/Repository/Model/SecretQuery.swift @@ -65,11 +65,34 @@ extension SecretQuery { } } - public enum Sort: Equatable, Sendable { - case recentlyAdded - case oldestFirst - case expiringSoon - case nameAscending - case nameDescending + /// 정렬 기준(`key`)과 방향(`direction`)을 독립된 축으로 표현한다. + /// + /// 이전엔 `recentlyAdded`/`oldestFirst`/`expiringSoon`/`nameAscending`/`nameDescending`처럼 + /// 기준과 방향을 한 케이스에 묶어뒀다. 3기준 × 2방향 = 6개 조합 중 5개만 존재했고, + /// 특히 "만료 내림차순(만료 늦은 순)"을 표현할 방법이 없었다. + public struct Sort: Equatable, Sendable { + public enum Key: Equatable, Sendable { + /// `updatedAt` 기준. 목록 행에 표시되는 날짜와 같은 필드를 써야 사용자가 보는 순서와 정렬 기준이 일치한다. + case time + /// `expiresAt` 기준. 만료일이 없는 Secret은 방향과 무관하게 항상 뒤로 보낸다 — 소비처(정렬 구현부)의 책임. + case expiry + case name + } + + public enum Direction: Equatable, Sendable { + case ascending + case descending + } + + public var key: Key + public var direction: Direction + + public init(key: Key, direction: Direction) { + self.key = key + self.direction = direction + } + + /// 기존 `recentlyAdded`와 동일한 기본 정렬 — 최근 수정 순. + public static let recentlyAdded = Sort(key: .time, direction: .descending) } } diff --git a/Projects/DVDomain/Tests/Core/Repository/SecretQueryTests.swift b/Projects/DVDomain/Tests/Core/Repository/SecretQueryTests.swift index 19907949..bf8069c6 100644 --- a/Projects/DVDomain/Tests/Core/Repository/SecretQueryTests.swift +++ b/Projects/DVDomain/Tests/Core/Repository/SecretQueryTests.swift @@ -80,4 +80,36 @@ struct SecretQueryTests { #expect(today.addingTimeInterval((windowDays - 1) * 86_400) < windowEnd) #expect(today.addingTimeInterval((windowDays + 1) * 86_400) > windowEnd) } + + // MARK: - Sort + + @Test("recentlyAdded는 time 기준 내림차순과 동일하다") + func recentlyAddedIsTimeDescending() { + #expect(SecretQuery.Sort.recentlyAdded == SecretQuery.Sort(key: .time, direction: .descending)) + } + + @Test("key와 direction이 하나라도 다르면 다른 Sort다") + func sortDistinguishesByKeyAndDirection() { + let timeAscending = SecretQuery.Sort(key: .time, direction: .ascending) + let timeDescending = SecretQuery.Sort(key: .time, direction: .descending) + let expiryAscending = SecretQuery.Sort(key: .expiry, direction: .ascending) + + #expect(timeAscending != timeDescending) + #expect(timeAscending != expiryAscending) + } + + @Test("3기준 × 2방향의 6개 조합을 모두 표현할 수 있다 — 기존 5케이스 enum이 못 만들던 만료 내림차순도 포함") + func allSixCombinationsAreExpressible() { + let keys: [SecretQuery.Sort.Key] = [.time, .expiry, .name] + let directions: [SecretQuery.Sort.Direction] = [.ascending, .descending] + + let combinations = keys.flatMap { key in + directions.map { direction in SecretQuery.Sort(key: key, direction: direction) } + } + + #expect(combinations.count == 6) + #expect(Set(combinations.map { "\($0.key)-\($0.direction)" }).count == 6) + // 기존 enum에서 표현 불가능했던 조합: 만료 내림차순(만료 늦은 순). + #expect(combinations.contains(SecretQuery.Sort(key: .expiry, direction: .descending))) + } } diff --git a/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift b/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift index a15e90be..63955c7c 100644 --- a/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift +++ b/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift @@ -195,17 +195,29 @@ private extension SecretClient { } static func sorted(_ secrets: [Secret], by sort: SecretQuery.Sort) -> [Secret] { - switch sort { - case .recentlyAdded: - return secrets.sorted { $0.createdAt > $1.createdAt } - case .oldestFirst: - return secrets.sorted { $0.createdAt < $1.createdAt } - case .expiringSoon: - return secrets.sorted { ($0.expiresAt ?? .distantFuture) < ($1.expiresAt ?? .distantFuture) } - case .nameAscending: - return secrets.sorted { $0.name < $1.name } - case .nameDescending: - return secrets.sorted { $0.name > $1.name } + switch sort.key { + case .time: + switch sort.direction { + case .ascending: return secrets.sorted { $0.updatedAt < $1.updatedAt } + case .descending: return secrets.sorted { $0.updatedAt > $1.updatedAt } + } + case .expiry: + // 만료일 없는 항목은 방향과 무관하게 맨 뒤로 — 실제 InMemorySecretQueryFilter와 같은 규칙. + let withExpiry = secrets.filter { $0.expiresAt != nil } + let withoutExpiry = secrets.filter { $0.expiresAt == nil } + let sortedByExpiry = withExpiry.sorted { lhs, rhs in + guard let lhsExpiresAt = lhs.expiresAt, let rhsExpiresAt = rhs.expiresAt else { return false } + switch sort.direction { + case .ascending: return lhsExpiresAt < rhsExpiresAt + case .descending: return lhsExpiresAt > rhsExpiresAt + } + } + return sortedByExpiry + withoutExpiry + case .name: + switch sort.direction { + case .ascending: return secrets.sorted { $0.name < $1.name } + case .descending: return secrets.sorted { $0.name > $1.name } + } } } } diff --git a/Projects/DVPresentation/Sources/Features/SecretList/SecretListFeature.swift b/Projects/DVPresentation/Sources/Features/SecretList/SecretListFeature.swift index 98dd18fb..8cb62dd1 100644 --- a/Projects/DVPresentation/Sources/Features/SecretList/SecretListFeature.swift +++ b/Projects/DVPresentation/Sources/Features/SecretList/SecretListFeature.swift @@ -40,7 +40,7 @@ public struct SecretListFeature { return SecretQuery( collection: .expiringWindow(from: referenceDate), searchText: normalizedSearchText, - sort: .expiringSoon + sort: SecretQuery.Sort(key: .expiry, direction: .ascending) ) case .notice: // predicate가 이미 "지나지 않음 + window 이내"를 전부 검사하므로 window 변환이 필요 없다. diff --git a/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift b/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift index 968d0ccf..b1b0dc82 100644 --- a/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift +++ b/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift @@ -14,7 +14,6 @@ struct SecretListView: View { // MARK: - Properties @Bindable var store: StoreOf - @State private var isSortMenuPresented = false // MARK: - Body @@ -35,37 +34,21 @@ struct SecretListView: View { extension SecretListView { private var content: some View { - ZStack(alignment: .topTrailing) { - VStack(alignment: .leading, spacing: 12) { - DVTitleBar( - titleText: titleText, - searchText: searchTextBinding, - onSortTapped: showsSort ? { isSortMenuPresented.toggle() } : nil - ) - .padding(.horizontal, 12) - - switch store.secretsState { - case .failed: - errorView - case .loaded(let secrets) where secrets.isEmpty: - emptyView - default: - list - } - } - - if isSortMenuPresented { - Color.clear - .contentShape(Rectangle()) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onTapGesture { isSortMenuPresented = false } - - sortMenu - .background(.regularMaterial) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .shadow(color: Color(nsColor: .shadowColor).opacity(0.15), radius: 16, y: 6) - .padding(.top, 56) - .padding(.trailing, 24) + VStack(alignment: .leading, spacing: 12) { + DVTitleBar( + titleText: titleText, + searchText: searchTextBinding, + sortMenuContent: showsSort ? { AnyView(sortMenuContent) } : nil + ) + .padding(.horizontal, 12) + + switch store.secretsState { + case .failed: + errorView + case .loaded(let secrets) where secrets.isEmpty: + emptyView + default: + list } } } @@ -151,27 +134,38 @@ extension SecretListView { } } - private var sortMenu: some View { - VStack(alignment: .leading, spacing: 2) { - sortMenuRow(.recentlyAdded, title: "Recently Added") - sortMenuRow(.oldestFirst, title: "Oldest First") - sortMenuRow(.expiringSoon, title: "Expiring Soon") + /// divider 위: 정렬 기준(시간/만료/이름). divider 아래: 방향(오름/내림차순). + /// `Menu`가 바깥 클릭·ESC·포커스 상실 처리를 대신하므로 이 화면은 두 축의 값만 계산하면 된다. + @ViewBuilder + private var sortMenuContent: some View { + Picker("Sort by", selection: sortKeyBinding) { + Text("Time").tag(SecretQuery.Sort.Key.time) + Text("Expiry").tag(SecretQuery.Sort.Key.expiry) + Text("Name").tag(SecretQuery.Sort.Key.name) + } + .pickerStyle(.inline) - Divider() - .padding(.vertical, 4) + Divider() - sortMenuRow(.nameAscending, title: "Name (A to Z)") - sortMenuRow(.nameDescending, title: "Name (Z to A)") + Picker("Direction", selection: sortDirectionBinding) { + Text("Ascending").tag(SecretQuery.Sort.Direction.ascending) + Text("Descending").tag(SecretQuery.Sort.Direction.descending) } - .padding(6) - .frame(width: 220) + .pickerStyle(.inline) } - private func sortMenuRow(_ value: SecretQuery.Sort, title: String) -> some View { - SortMenuRow(title: title, isSelected: store.sort == value) { - store.send(.didSelectSort(value)) - isSortMenuPresented = false - } + private var sortKeyBinding: Binding { + Binding( + get: { store.sort.key }, + set: { store.send(.didSelectSort(SecretQuery.Sort(key: $0, direction: store.sort.direction))) } + ) + } + + private var sortDirectionBinding: Binding { + Binding( + get: { store.sort.direction }, + set: { store.send(.didSelectSort(SecretQuery.Sort(key: store.sort.key, direction: $0))) } + ) } private var errorView: some View { @@ -244,40 +238,6 @@ extension SecretListView { set: { store.send(.didChangeSearchText($0)) } ) } - - /// 체크마크 + 호버 하이라이트를 갖는 네이티브 메뉴 스타일 행. 이 화면 전용 — 재사용 필요해지면 DVDesign으로 승격. - private struct SortMenuRow: View { - - let title: String - let isSelected: Bool - let action: () -> Void - - @State private var isHovered = false - - var body: some View { - Button(action: action) { - HStack(spacing: 6) { - Image(systemName: "checkmark") - .dvFont(.bodyMD) - .opacity(isSelected ? 1 : 0) - Text(title) - .dvFont(.bodyMD) - Spacer(minLength: 8) - } - .foregroundStyle(isHovered ? Color(nsColor: .alternateSelectedControlTextColor) : Color.dv(.gray900)) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .frame(maxWidth: .infinity, alignment: .leading) - .background { - RoundedRectangle(cornerRadius: 8) - .fill(isHovered ? Color(nsColor: .selectedContentBackgroundColor) : Color.clear) - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .onHover { isHovered = $0 } - } - } } // MARK: - ExpiryBucket diff --git a/Projects/DVPresentation/Tests/SecretList/SecretListFeatureTests.swift b/Projects/DVPresentation/Tests/SecretList/SecretListFeatureTests.swift index 2fee9dee..38eb5e5e 100644 --- a/Projects/DVPresentation/Tests/SecretList/SecretListFeatureTests.swift +++ b/Projects/DVPresentation/Tests/SecretList/SecretListFeatureTests.swift @@ -107,16 +107,17 @@ struct SecretListFeatureTests { @Test("didSelectSort는 디바운스 없이 즉시 재조회한다") func sortRefetchesImmediately() async { let secret = makeSecret(name: "GitHub API Key") + let nameAscending = SecretQuery.Sort(key: .name, direction: .ascending) let store = TestStore(initialState: SecretListFeature.State()) { SecretListFeature() } withDependencies: { $0.secretClient.fetchByQuery = { query in - query.sort == .nameAscending ? [secret] : [] + query.sort == nameAscending ? [secret] : [] } } - await store.send(.didSelectSort(.nameAscending)) { - $0.sort = .nameAscending + await store.send(.didSelectSort(nameAscending)) { + $0.sort = nameAscending } await store.receive(.secretsResponse(.success([secret]))) { $0.secretsState = .loaded([secret]) @@ -131,7 +132,7 @@ struct SecretListFeatureTests { #expect(state.secretsState != .loaded([])) } - @Test("expired collection의 query는 referenceDate를 확장 창만큼 밀고 expiringSoon 정렬을 강제한다") + @Test("expired collection의 query는 referenceDate를 확장 창만큼 밀고 만료 오름차순 정렬을 강제한다") func expiredQueryWidensWindow() { let today = Date(timeIntervalSince1970: 0) let state = SecretListFeature.State(collection: .expired(referenceDate: today)) @@ -146,7 +147,7 @@ struct SecretListFeatureTests { TimeInterval(SecretQuery.Collection.expiringSoonWindowDays) * 86_400 ) #expect(windowEnd == expectedWindowEnd) - #expect(query.sort == .expiringSoon) + #expect(query.sort == SecretQuery.Sort(key: .expiry, direction: .ascending)) } @Test("notice collection의 query는 collection을 그대로 쓰고 expiringSoon 정렬을 강제한다")