diff --git a/Projects/Core/DoriDesignSystem/Sources/DoriNavigationBar.swift b/Projects/Core/DoriDesignSystem/Sources/DoriNavigationBar.swift index 11e42d0..91f5649 100644 --- a/Projects/Core/DoriDesignSystem/Sources/DoriNavigationBar.swift +++ b/Projects/Core/DoriDesignSystem/Sources/DoriNavigationBar.swift @@ -32,7 +32,7 @@ public struct DoriNavigationBarConfig { public enum CenterItem { case none case title(String) - case searchField(text: Binding, placeholder: String) + case searchField(text: Binding, placeholder: String, onClear: (@MainActor () -> Void)? = nil) } public enum TrailingItem { @@ -131,6 +131,17 @@ public struct DoriNavigationBar: View { .background(.doriWhite) } + // MARK: - Helpers + + private var searchFieldLeadingPadding: CGFloat { + if case .none = config.leading { return 8 } + return 52 // 8pt outer leading + 44pt back button + } + + private var searchFieldTrailingPadding: CGFloat { + 16 + CGFloat(config.trailing.count) * 44 // 16pt outer trailing + 44pt per trailing item + } + // MARK: - Leading @ViewBuilder @@ -163,16 +174,27 @@ public struct DoriNavigationBar: View { .foregroundStyle(.doriBlack) .frame(maxWidth: .infinity) - case .searchField(let text, let placeholder): - TextField(placeholder, text: text) - .pretendard(.body(.r3)) - .padding(.horizontal, 12) - .frame(height: 36) - .background( - RoundedRectangle(cornerRadius: 8) - .fill(.grey100) - ) - .padding(.horizontal, 8) + case .searchField(let text, let placeholder, let onClear): + HStack { + TextField(placeholder, text: text) + .pretendard(.body(.sb3)) + .foregroundStyle(.doriBlack) + + if !text.wrappedValue.isEmpty, let onClear { + Button(action: onClear) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.grey400) + } + } + } + .padding(.horizontal, 12) + .frame(height: 36) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(.grey100) + ) + .padding(.leading, searchFieldLeadingPadding) + .padding(.trailing, searchFieldTrailingPadding) } } @@ -187,6 +209,7 @@ public struct DoriNavigationBar: View { case .iconButton(let image, let action): Button(action: action) { image + .foregroundStyle(.doriBlack) .frame(width: 44, height: 44) .contentShape(Rectangle()) } diff --git a/Projects/Feature/AddDori/Sources/Views/Page1NameTypeView.swift b/Projects/Feature/AddDori/Sources/Views/Page1NameTypeView.swift index ecd557a..c238fef 100644 --- a/Projects/Feature/AddDori/Sources/Views/Page1NameTypeView.swift +++ b/Projects/Feature/AddDori/Sources/Views/Page1NameTypeView.swift @@ -85,11 +85,12 @@ struct Page1NameTypeView: View { Array(store.searchResults.enumerated()), id: \.offset ) { _, partner in - PartnerSearchResultRow(searchQuery: $store.searchQuery.sending(\.searchQueryChanged), partner: partner) - .contentShape(Rectangle()) - .onTapGesture { - store.send(.partnerSelected(partner)) - } + PartnerSearchResultRow(searchQuery: $store.searchQuery.sending(\.searchQueryChanged), partner: partner + ) + .contentShape(Rectangle()) + .onTapGesture { + store.send(.partnerSelected(partner)) + } } } } diff --git a/Projects/Feature/AddDori/Sources/Views/PartnerSearchResultRow.swift b/Projects/Feature/AddDori/Sources/Views/PartnerSearchResultRow.swift index dec7a68..636cf03 100644 --- a/Projects/Feature/AddDori/Sources/Views/PartnerSearchResultRow.swift +++ b/Projects/Feature/AddDori/Sources/Views/PartnerSearchResultRow.swift @@ -10,11 +10,16 @@ import DoriDesignSystem import DoriNetwork import DoriCore -struct PartnerSearchResultRow: View { - @Binding var searchQuery: String - let partner: Dori +public struct PartnerSearchResultRow: View { + @Binding public var searchQuery: String + public let partner: Dori - var body: some View { + public init(searchQuery: Binding, partner: Dori) { + self._searchQuery = searchQuery + self.partner = partner + } + + public var body: some View { HStack(spacing: 6) { HStack(spacing: 6) { Text(highlightedName) diff --git a/Projects/Feature/History/Sources/DoriList/DoriListFeature.swift b/Projects/Feature/History/Sources/DoriList/DoriListFeature.swift index 629406f..c65c5f5 100644 --- a/Projects/Feature/History/Sources/DoriList/DoriListFeature.swift +++ b/Projects/Feature/History/Sources/DoriList/DoriListFeature.swift @@ -46,11 +46,13 @@ public struct DoriListFeature { case refresh case fabTapped case partnerTapped(PartnerSummary) + case searchTapped case delegate(Delegate) public enum Delegate: Equatable, Sendable { case partnerTapped(PartnerSummary) case fabTapped + case searchTapped } } @@ -101,6 +103,9 @@ public struct DoriListFeature { case .fabTapped: return .send(.delegate(.fabTapped)) + case .searchTapped: + return .send(.delegate(.searchTapped)) + case .delegate: return .none } diff --git a/Projects/Feature/History/Sources/DoriList/DoriListView.swift b/Projects/Feature/History/Sources/DoriList/DoriListView.swift index 39ba4cc..8a7d961 100644 --- a/Projects/Feature/History/Sources/DoriList/DoriListView.swift +++ b/Projects/Feature/History/Sources/DoriList/DoriListView.swift @@ -42,7 +42,17 @@ public struct DoriListView: View { .padding(20) } .background(.grey100) - .doriNavigationBar(.titleWithActions("내역")) + .doriNavigationBar( + .titleWithActions( + "내역", + trailing: [ + .iconButton( + image: Image(systemName: "magnifyingglass"), + action: { store.send(.searchTapped) } + ) + ] + ) + ) .refreshable { store.send(.refresh) } diff --git a/Projects/Feature/History/Sources/HistoryAPIClient.swift b/Projects/Feature/History/Sources/HistoryAPIClient.swift index 75e1c6d..f4a46b5 100644 --- a/Projects/Feature/History/Sources/HistoryAPIClient.swift +++ b/Projects/Feature/History/Sources/HistoryAPIClient.swift @@ -205,7 +205,7 @@ extension HistoryAPIClient: DependencyKey { ] }, searchPartners: { query in - [ + let allPartners: [Dori] = [ Dori( doriId: 1, userId: 1, @@ -219,8 +219,37 @@ extension HistoryAPIClient: DependencyKey { isVisited: true, memo: "", createdAt: "2026-02-17T09:00:00" + ), + Dori( + doriId: 100, + userId: 1, + partnerId: 100, + direction: .judori, + partnerName: "조카 1", + relationship: "가족", + eventType: "생일", + amount: 50_000, + eventDate: "2025-05-01", + isVisited: true, + memo: "", + createdAt: "2026-02-17T09:00:00" + ), + Dori( + doriId: 101, + userId: 1, + partnerId: 101, + direction: .baddori, + partnerName: "조카1", + relationship: "친구", + eventType: "결혼식", + amount: 100_000, + eventDate: "2025-08-20", + isVisited: true, + memo: "", + createdAt: "2026-02-17T09:00:00" ) - ].filter { $0.partnerName.contains(query) } + ] + return allPartners.filter { $0.partnerName.contains(query) } }, fetchPartnerDoriList: { partnerId in PartnerDoriList( diff --git a/Projects/Feature/History/Sources/HistoryFeature.swift b/Projects/Feature/History/Sources/HistoryFeature.swift index 0e82d82..a8c7d06 100644 --- a/Projects/Feature/History/Sources/HistoryFeature.swift +++ b/Projects/Feature/History/Sources/HistoryFeature.swift @@ -16,45 +16,49 @@ import FeatureAddDori @Reducer public struct HistoryFeature { public init() {} - + // MARK: - Path Reducer - + @Reducer public enum Path { case partnerHistory(PartnerDoriHistoryFeature) case partnerDoriDetail(PartnerDoriDetailFeature) case addDori(AddDoriFeature) case editDori(EditDoriFeature) + case search(SearchFeature) } - + // MARK: - State - + @ObservableState public struct State { public var doriList: DoriListFeature.State = .init() public var path = StackState() public init() {} } - + // MARK: - Action - + public enum Action { case doriList(DoriListFeature.Action) case path(StackActionOf) } - + // MARK: - Reducer - + public var body: some ReducerOf { Scope(state: \.doriList, action: \.doriList) { DoriListFeature() } - + +// Scope(state: \.doriSearch, action: \.doriSearch) { +// SearchFeature() +// } + Reduce { state, action in switch action { - - // MARK: DoriList delegate - + + // MARK: DoriList delegate case .doriList(.delegate(.partnerTapped(let partner))): state.path.append( .partnerHistory( @@ -66,51 +70,73 @@ public struct HistoryFeature { ) ) return .none - + case .doriList(.delegate(.fabTapped)): state.path.append(.addDori(AddDoriFeature.State())) return .none - + + case .doriList(.delegate(.searchTapped)): + state.path.append(.search(SearchFeature.State())) + return .none + case .doriList: return .none - - // MARK: Path delegate 처리 - - // PartnerDoriHistory에서 doriTapped → Detail push + + // MARK: Path delegate 처리 + + // PartnerDoriHistory에서 doriTapped → Detail push case .path(.element(id: _, action: .partnerHistory(.doriTapped(let dori)))): state.path.append(.partnerDoriDetail(PartnerDoriDetailFeature.State(dori: dori))) return .none - - // PartnerDoriHistory에서 전체 삭제 + + // PartnerDoriHistory에서 전체 삭제 case .path(.element(id: _, action: .partnerHistory(.delegate(.allDoriDeleted)))): state.path.removeAll() return .send(.doriList(.refresh)) - - // PartnerDoriDetail에서 editTapped → Edit push + + // PartnerDoriDetail에서 editTapped → Edit push case .path(.element(id: _, action: .partnerDoriDetail(.delegate(.editTapped(let dori))))): state.path.append(.editDori(EditDoriFeature.State(dori: dori))) return .none - - // PartnerDoriDetail에서 단건 삭제 → History로 복귀 + + // PartnerDoriDetail에서 단건 삭제 → History로 복귀 case .path(.element(id: _, action: .partnerDoriDetail(.delegate(.doriDeleted)))): state.path.removeLast() return .none - - // AddDori 완료 → 리스트로 복귀 + + // AddDori 완료 → 리스트로 복귀 case .path(.element(id: _, action: .addDori(.delegate(.doriCreated(_))))): state.path.removeAll() return .send(.doriList(.refresh)) - - // AddDori dismiss + + // AddDori dismiss case .path(.element(id: _, action: .addDori(.delegate(.dismissed)))): state.path.removeAll() return .none - - // EditDori 완료 → 리스트로 복귀 + + // EditDori 완료 → 리스트로 복귀 case .path(.element(id: _, action: .editDori(.delegate(.doriUpdated(_))))): state.path.removeAll() return .send(.doriList(.refresh)) + + // Search에서 partnerTapped → PartnerDoriHistory push + case .path(.element(id: _, action: .search(.delegate(.partnerTapped(let partner))))): + state.path.append( + .partnerHistory( + PartnerDoriHistoryFeature.State( + partnerId: partner.partnerId, + partnerName: partner.partnerName, + relationship: partner.relationship + ) + ) + ) + return .none + // Search dismiss → 리스트로 복귀 + case .path(.element(id: _, action: .search(.delegate(.dismissed)))): + state.path.removeLast() + return .none + case .path: return .none } @@ -123,11 +149,11 @@ public struct HistoryFeature { public struct HistoryView: View { @Bindable var store: StoreOf - + public init(store: StoreOf) { self.store = store } - + public var body: some View { NavigationStack( path: $store.scope(state: \.path, action: \.path) @@ -145,6 +171,8 @@ public struct HistoryView: View { AddDoriView(store: addDoriStore) case .editDori(let editDoriStore): EditDoriView(store: editDoriStore) + case .search(let searchStore): + SearchView(store: searchStore) } } } diff --git a/Projects/Feature/History/Sources/PartnerDoriHistory/PartnerDoriHistoryView.swift b/Projects/Feature/History/Sources/PartnerDoriHistory/PartnerDoriHistoryView.swift index e6be823..d46d80f 100644 --- a/Projects/Feature/History/Sources/PartnerDoriHistory/PartnerDoriHistoryView.swift +++ b/Projects/Feature/History/Sources/PartnerDoriHistory/PartnerDoriHistoryView.swift @@ -62,28 +62,23 @@ public struct PartnerDoriHistoryView: View { Divider() - if store.doriByDate.isEmpty && !store.isLoading { - DoriEmptyView(.doriHistory) - .frame(height: 200) - } else { - ForEach(store.doriByDate, id: \.date) { group in - VStack(alignment: .leading, spacing: 8) { - DateHeaderView(date: group.date.parsedEventDate) - .padding(.horizontal) - - VStack(spacing: 0) { - ForEach(group.doris, id: \.doriId) { dori in - Button { - store.send(.doriTapped(dori)) - } label: { - TransactionRowView(dori: dori) - .padding(.horizontal) - } - .buttonStyle(.plain) + ForEach(store.doriByDate, id: \.date) { group in + VStack(alignment: .leading, spacing: 8) { + DateHeaderView(date: group.date.parsedEventDate) + .padding(.horizontal) + + VStack(spacing: 0) { + ForEach(group.doris, id: \.doriId) { dori in + Button { + store.send(.doriTapped(dori)) + } label: { + TransactionRowView(dori: dori) + .padding(.horizontal) } + .buttonStyle(.plain) } - .cornerRadius(10) } + .cornerRadius(10) } } } diff --git a/Projects/Feature/History/Sources/Search/SearchFeature.swift b/Projects/Feature/History/Sources/Search/SearchFeature.swift new file mode 100644 index 0000000..8e65c59 --- /dev/null +++ b/Projects/Feature/History/Sources/Search/SearchFeature.swift @@ -0,0 +1,97 @@ +// +// SearchFeature.swift +// Dori-iOS +// +// Created by 강동영 on 3/18/26. +// + +import ComposableArchitecture +import DoriCore + +@Reducer +public struct SearchFeature { + public init() {} + + private enum CancelID { + case search + } + + // MARK: - State + + @ObservableState + public struct State: Equatable, Sendable { + public var searchQuery: String = "" + public var searchResults: [Dori] = [] + public var isSearching: Bool = false + + public init() {} + } + + // MARK: - Action + + public enum Action: Equatable, Sendable { + case searchQueryChanged(String) + case searchResponse([Dori]) + case clearSearchTapped + case backTapped + case partnerTapped(Dori) + case delegate(Delegate) + + public enum Delegate: Equatable, Sendable { + case partnerTapped(Dori) + case dismissed + } + } + + // MARK: - Dependencies + + @Dependency(\.continuousClock) var clock + @Dependency(\.historyAPIClient) var apiClient + + // MARK: - Reducer + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case let .searchQueryChanged(query): + state.searchQuery = String(query.prefix(10)) + + guard !query.trimmingCharacters(in: .whitespaces).isEmpty else { + state.searchResults = [] + state.isSearching = false + return .cancel(id: CancelID.search) + } + + state.isSearching = true + let searchPartners = apiClient.searchPartners + let clock = self.clock + return .run { send in + try await clock.sleep(for: .milliseconds(300)) + let results = try await searchPartners(query) + await send(.searchResponse(results)) + } + .cancellable(id: CancelID.search, cancelInFlight: true) + + case let .searchResponse(results): + state.searchResults = results + state.isSearching = false + return .none + + case .clearSearchTapped: + state.searchQuery = "" + state.searchResults = [] + state.isSearching = false + return .cancel(id: CancelID.search) + + case .backTapped: + return .send(.delegate(.dismissed)) + + case let .partnerTapped(partner): + return .send(.delegate(.partnerTapped(partner))) + + case .delegate: + return .none + } + } + } +} diff --git a/Projects/Feature/History/Sources/Search/SearchView.swift b/Projects/Feature/History/Sources/Search/SearchView.swift new file mode 100644 index 0000000..d07d0da --- /dev/null +++ b/Projects/Feature/History/Sources/Search/SearchView.swift @@ -0,0 +1,151 @@ +// +// SearchView.swift +// Dori-iOS +// +// Created by 강동영 on 3/18/26. +// + +import SwiftUI +import ComposableArchitecture +import DoriDesignSystem +import DoriCore +import FeatureAddDori + +public struct SearchView: View { + @Bindable var store: StoreOf + @State private var localNameText: String = "" + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + contentView + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.doriWhite) + .onChange(of: localNameText) { _, newValue in + let truncated = String(newValue.prefix(10)) + if localNameText != truncated { + localNameText = truncated + } + store.send(.searchQueryChanged(truncated)) + } + .onChange(of: store.searchQuery) { _, newValue in + if localNameText != newValue { + localNameText = newValue + } + } + .doriNavigationBar( + DoriNavigationBarConfig( + leading: .backButton(action: { store.send(.backTapped) }), + center: .searchField( + text: $localNameText, + placeholder: "이름을 입력하세요", + onClear: { store.send(.clearSearchTapped) } + ), + trailing: [] + ) + ) + } + + @ViewBuilder + private var contentView: some View { + if store.searchQuery.trimmingCharacters(in: .whitespaces).isEmpty { + Spacer() + } else if store.searchResults.isEmpty && !store.isSearching { + DoriEmptyView(.doriHistory) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVStack(spacing: 34) { + ForEach( + Array(store.searchResults.enumerated()), + id: \.offset + ) { _, partner in + PartnerSearchResultRow( + searchQuery: $store.searchQuery.sending(\.searchQueryChanged), + partner: partner + ) + .contentShape(Rectangle()) + .onTapGesture { + store.send(.partnerTapped(partner)) + } + .padding(.horizontal, 16) + } + } + .padding(.vertical, 8) + } + } + } +} + +// MARK: - Preview + +#Preview("검색 결과 있음") { + let state: SearchFeature.State = { + var s = SearchFeature.State() + s.searchQuery = "조" + s.searchResults = [ + Dori( + doriId: 100, + userId: 1, + partnerId: 100, + direction: .judori, + partnerName: "조카 1", + relationship: "가족", + eventType: "생일", + amount: 50_000, + eventDate: "2025-05-01", + isVisited: true, + memo: "", + createdAt: "2026-02-17T09:00:00" + ), + Dori( + doriId: 101, + userId: 1, + partnerId: 101, + direction: .baddori, + partnerName: "조카1", + relationship: "친구", + eventType: "결혼식", + amount: 100_000, + eventDate: "2025-08-20", + isVisited: true, + memo: "", + createdAt: "2026-02-17T09:00:00" + ) + ] + return s + }() + + NavigationStack { + SearchView( + store: Store(initialState: state) { + SearchFeature() + } withDependencies: { + $0.historyAPIClient = .previewValue + } + ) + } +} + +#Preview("검색 결과 없음") { + let state: SearchFeature.State = { + var s = SearchFeature.State() + s.searchQuery = "조가" + s.searchResults = [] + return s + }() + + NavigationStack { + SearchView( + store: Store(initialState: state) { + SearchFeature() + } withDependencies: { + $0.historyAPIClient = .previewValue + } + ) + } +} diff --git a/Projects/Feature/History/Tests/SearchFeatureTests.swift b/Projects/Feature/History/Tests/SearchFeatureTests.swift new file mode 100644 index 0000000..56cecff --- /dev/null +++ b/Projects/Feature/History/Tests/SearchFeatureTests.swift @@ -0,0 +1,100 @@ +// +// SearchFeatureTests.swift +// Dori-iOS +// +// Created by 강동영 on 3/18/26. +// + +import Testing +import ComposableArchitecture +import DoriCore +@testable import FeatureHistory + +@MainActor +struct SearchFeatureTests { + + // MARK: - 검색 결과 검증용 mock 파트너 데이터 + + private static let mockPartners: [Dori] = [ + Dori( + doriId: 100, + userId: 1, + partnerId: 100, + direction: .judori, + partnerName: "조카 1", + relationship: "가족", + eventType: "생일", + amount: 50_000, + eventDate: "2025-05-01", + isVisited: true, + memo: "", + createdAt: "2026-02-17T09:00:00" + ), + Dori( + doriId: 101, + userId: 1, + partnerId: 101, + direction: .baddori, + partnerName: "조카1", + relationship: "친구", + eventType: "결혼식", + amount: 100_000, + eventDate: "2025-08-20", + isVisited: true, + memo: "", + createdAt: "2026-02-17T09:00:00" + ) + ] + + // MARK: - "조" 검색 → 조카 1(가족), 조카1(친구) 2개 반환 + + @Test + func testSearchJo_returnsTwoResults() async throws { + let results = Self.mockPartners.filter { $0.partnerName.contains("조") } + + #expect(results.count == 2) + #expect(results[0].partnerName == "조카 1") + #expect(results[0].relationship == "가족") + #expect(results[1].partnerName == "조카1") + #expect(results[1].relationship == "친구") + + // SearchFeature가 searchResponse로 결과를 state에 저장하는지 검증 + let store = TestStore(initialState: SearchFeature.State()) { + SearchFeature() + } + store.exhaustivity = .off + + await store.send(.searchResponse(results)) { + $0.searchResults = results + $0.isSearching = false + } + + #expect(store.state.searchResults.count == 2) + #expect(store.state.searchResults[0].partnerName == "조카 1") + #expect(store.state.searchResults[0].relationship == "가족") + #expect(store.state.searchResults[1].partnerName == "조카1") + #expect(store.state.searchResults[1].relationship == "친구") + } + + // MARK: - "조가" 검색 → 결과 없음 (DoriEmptyView(.doriHistory) 표시 조건) + + @Test + func testSearchJoGa_returnsEmptyResults() async throws { + let results = Self.mockPartners.filter { $0.partnerName.contains("조가") } + + #expect(results.isEmpty) + + // SearchFeature가 빈 searchResponse를 받으면 searchResults가 비어있는지 검증 + let store = TestStore(initialState: SearchFeature.State()) { + SearchFeature() + } + store.exhaustivity = .off + + await store.send(.searchResponse([])) { + $0.searchResults = [] + $0.isSearching = false + } + + #expect(store.state.searchResults.isEmpty) + } +}