diff --git a/Codive/Core/Resources/TextLiteral.swift b/Codive/Core/Resources/TextLiteral.swift index 93238b90..c772aa3c 100644 --- a/Codive/Core/Resources/TextLiteral.swift +++ b/Codive/Core/Resources/TextLiteral.swift @@ -153,6 +153,8 @@ enum TextLiteral { static let totalCount = "총" static let countUnit = "개" static let sortAll = "전체" + static let account = "계정" + static let hashtag = "해시태그" } enum Notification { @@ -160,6 +162,13 @@ enum TextLiteral { static let read = "읽음" static let notRead = "읽지 않음" static let noNewNoti = "새로운 알림이 없습니다." + static let feedType = "게시글" + static let commentType = "댓글" + static let reportTitle = "신고 접수 안내" + static func reportBody1(_ target: String) -> String { + "회원님의 \(target)이 운영 정책 위반으로 신고되었습니다." + } + static let reportBody2 = "확인 및 조치는 영업일 기준 3~5일정도 소요됩니다." } enum LookBook { diff --git a/Codive/Features/Closet/Presentation/View/myCloth/MyClosetView.swift b/Codive/Features/Closet/Presentation/View/myCloth/MyClosetView.swift index 7d4d822b..7509c362 100644 --- a/Codive/Features/Closet/Presentation/View/myCloth/MyClosetView.swift +++ b/Codive/Features/Closet/Presentation/View/myCloth/MyClosetView.swift @@ -44,10 +44,14 @@ struct MyClosetView: View { } } : .none ) - - CustomSearchBar(text: $viewModel.searchText, type: .normal) - .padding(.horizontal, 20) - .padding(.vertical, 10) + CustomSearchBar( + text: $viewModel.searchText, + type: .normal + ) { + hideKeyboard() + } + .padding(.horizontal, 20) + .padding(.vertical, 10) mainCategoryTab diff --git a/Codive/Features/Notification/Data/DataSources/NotificationDataSource.swift b/Codive/Features/Notification/Data/DataSources/NotificationDataSource.swift index 9c35b2a6..ff54519c 100644 --- a/Codive/Features/Notification/Data/DataSources/NotificationDataSource.swift +++ b/Codive/Features/Notification/Data/DataSources/NotificationDataSource.swift @@ -14,35 +14,58 @@ final class NotificationDataSource { func fetchNotifications() -> [NotificationEntity] { return [ NotificationEntity( - id: 1, - imageUrl: "https://picsum.photos/id/237/200/200", - message: "홍길동님이 회원님의 옷장을 팔로우하기 시작했습니다.", - isRead: false + notificationId: 1, + notificationImageUrl: "https://picsum.photos/id/237/200/200", + notificationContent: "홍길동님이 회원님의 옷장을 팔로우하기 시작했습니다.", + redirectInfo: "user_123", + redirectType: .member, + readStatus: .unread, + createdAt: "2025-11-18T10:00:00" ), NotificationEntity( - id: 2, - imageUrl: nil, - message: "김철수님이 새로운 게시물을 업로드했습니다.", - isRead: false + notificationId: 2, + notificationImageUrl: nil, + notificationContent: "1년 전 오늘의 기록을 확인해보세요.", + redirectInfo: "post_456", + redirectType: .history, + readStatus: .unread, + createdAt: "2025-11-18T11:00:00" ), NotificationEntity( - id: 3, - imageUrl: "invalid_url", - message: "이영희님이 회원님의 게시물에 좋아요를 눌렀습니다.", - isRead: false + notificationId: 3, + notificationImageUrl: "https://picsum.photos/id/100/200/200", + notificationContent: "내일은 비가 올 예정입니다. 우산을 챙기세요!", + redirectInfo: "seoul", + redirectType: .weather, + readStatus: .read, + createdAt: "2025-11-18T12:00:00" ), NotificationEntity( - id: 4, - imageUrl: "https://picsum.photos/id/100/200/200", - message: "박민수님이 회원님의 댓글에 답글을 달았습니다.", - isRead: true + notificationId: 4, + notificationImageUrl: nil, + notificationContent: "홍길동님이 팔로우를 취소했습니다.", + redirectInfo: "user_123", + redirectType: .member, + readStatus: .unread, + createdAt: "2025-11-18T10:00:00" ), NotificationEntity( - id: 5, - imageUrl: nil, - message: "코디 추천 시즌 이벤트가 시작되었습니다.", - isRead: true + notificationId: 6, + notificationImageUrl: nil, + notificationContent: "내일은 비가 올 예정입니다. 우산을 챙기세요!", + redirectInfo: "Busan", + redirectType: .weather, + readStatus: .unread, + createdAt: "2025-11-18T12:00:00" ) ] } + + func fetchReportStatus() -> ReportEntity { + return ReportEntity(isReported: true, reportType: .feed) + } + + func patchNotificationRead(notificationId: Int) async throws { + print("서버에 알림 \(notificationId)번 읽음 처리 요청 전송") + } } diff --git a/Codive/Features/Notification/Data/Repositories/NotificationRepositoryImpl.swift b/Codive/Features/Notification/Data/Repositories/NotificationRepositoryImpl.swift index 46483b88..032b07ee 100644 --- a/Codive/Features/Notification/Data/Repositories/NotificationRepositoryImpl.swift +++ b/Codive/Features/Notification/Data/Repositories/NotificationRepositoryImpl.swift @@ -18,4 +18,12 @@ final class NotificationRepositoryImpl: NotificationRepository { func fetchNotifications() -> [NotificationEntity] { return datasource.fetchNotifications() } + + func fetchReportStatus() -> ReportEntity { + return datasource.fetchReportStatus() + } + + func markNotificationAsRead(request: NotificationReadRequestEntity) async throws { + try await datasource.patchNotificationRead(notificationId: request.notificationId) + } } diff --git a/Codive/Features/Notification/Domain/Entities/NotificationEntity.swift b/Codive/Features/Notification/Domain/Entities/NotificationEntity.swift index cfd714bf..c720c5a7 100644 --- a/Codive/Features/Notification/Domain/Entities/NotificationEntity.swift +++ b/Codive/Features/Notification/Domain/Entities/NotificationEntity.swift @@ -7,9 +7,45 @@ import Foundation -struct NotificationEntity: Identifiable { - let id: Int - let imageUrl: String? - let message: String - let isRead: Bool +/// 알림 유형 +enum RedirectType: String, Codable { + case member = "MEMBER" + case history = "HISTORY" + case weather = "WEATHER" +} + +/// 알림 읽음/읽지 않음 유형 +enum ReadStatus: String, Codable { + case read = "READ" + case unread = "UNREAD" +} + +/// 알림 목록 조회 api +struct NotificationEntity: Codable, Identifiable { + let notificationId: Int + let notificationImageUrl: String? + let notificationContent: String + let redirectInfo: String + let redirectType: RedirectType + var readStatus: ReadStatus + let createdAt: String + + var id: Int { notificationId } +} + +/// 신고 접수 유형 +enum ReportType: String, Codable { + case feed = "FEED" + case comment = "COMMENT" +} + +/// 신고 접수 안내 api +struct ReportEntity: Codable { + let isReported: Bool + let reportType: ReportType? +} + +/// 알림 읽음 처리 request api +struct NotificationReadRequestEntity: Codable { + let notificationId: Int } diff --git a/Codive/Features/Notification/Domain/Protocols/NotificationRepository.swift b/Codive/Features/Notification/Domain/Protocols/NotificationRepository.swift index 6238b6a6..eb3395be 100644 --- a/Codive/Features/Notification/Domain/Protocols/NotificationRepository.swift +++ b/Codive/Features/Notification/Domain/Protocols/NotificationRepository.swift @@ -7,4 +7,6 @@ protocol NotificationRepository { func fetchNotifications() -> [NotificationEntity] + func fetchReportStatus() -> ReportEntity + func markNotificationAsRead(request: NotificationReadRequestEntity) async throws } diff --git a/Codive/Features/Notification/Domain/UseCases/NotificationUseCase.swift b/Codive/Features/Notification/Domain/UseCases/NotificationUseCase.swift index ec3ce227..2690eec9 100644 --- a/Codive/Features/Notification/Domain/UseCases/NotificationUseCase.swift +++ b/Codive/Features/Notification/Domain/UseCases/NotificationUseCase.swift @@ -18,4 +18,13 @@ final class NotificationUseCase { func fetchNotifications() -> [NotificationEntity] { return repository.fetchNotifications() } + + func fetchReportStatus() -> ReportEntity { + return repository.fetchReportStatus() + } + + func markNotificationAsRead(notificationId: Int) async throws { + let request = NotificationReadRequestEntity(notificationId: notificationId) + try await repository.markNotificationAsRead(request: request) + } } diff --git a/Codive/Features/Notification/Presentation/Component/NotificationRow.swift b/Codive/Features/Notification/Presentation/Component/NotificationRow.swift index cd8a173e..a40915e3 100644 --- a/Codive/Features/Notification/Presentation/Component/NotificationRow.swift +++ b/Codive/Features/Notification/Presentation/Component/NotificationRow.swift @@ -8,28 +8,37 @@ import SwiftUI struct NotificationRow: View { - // MARK: - Properties - let profileImageUrl: String? - let message: String + let entity: NotificationEntity - // MARK: - Constants private let profileImageSize: CGFloat = 36 - private let messageFont = Font.codive_body2_regular - private let messageColor = Color.Codive.grayscale1 + + // MARK: - Asset Logic + /// 타입별 전용 에셋 이미지 이름 반환 + private var typeSpecificImageName: String? { + switch entity.redirectType { + case .history: + return "history" + case .weather: + return "weather" + case .member: + return nil + } + } var body: some View { HStack(spacing: 15) { - if let urlString = profileImageUrl, let url = URL(string: urlString) { + if let imageName = typeSpecificImageName { + Image(imageName) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: profileImageSize, height: profileImageSize) + .clipShape(Circle()) + } else if let urlString = entity.notificationImageUrl, let url = URL(string: urlString) { AsyncImage(url: url) { phase in if let image = phase.image { - image - .resizable() - .aspectRatio(contentMode: .fill) + image.resizable().aspectRatio(contentMode: .fill) } else if phase.error != nil { - Image(systemName: "person.circle.fill") - .resizable() - .aspectRatio(contentMode: .fill) - .foregroundStyle(Color.gray.opacity(0.3)) + defaultImage // URL 에러 시 기본 이미지 } else { ProgressView() } @@ -37,37 +46,59 @@ struct NotificationRow: View { .frame(width: profileImageSize, height: profileImageSize) .clipShape(Circle()) } else { - Image(systemName: "person.circle.fill") - .resizable() - .aspectRatio(contentMode: .fill) - .foregroundStyle(Color.gray.opacity(0.3)) - .frame(width: profileImageSize, height: profileImageSize) - .clipShape(Circle()) + defaultImage // 이미지 URL이 nil인 경우 } - Text(message) - .font(messageFont) - .foregroundStyle(messageColor) + Text(entity.notificationContent) + .font(Font.codive_body2_regular) + .foregroundStyle(Color.Codive.grayscale1) Spacer() } } + + private var defaultImage: some View { + Image(systemName: "person.circle.fill") + .resizable() + .aspectRatio(contentMode: .fill) + .foregroundStyle(Color.gray.opacity(0.3)) + .frame(width: profileImageSize, height: profileImageSize) + .clipShape(Circle()) + } } // MARK: - Preview #Preview { VStack(spacing: 16) { - NotificationRow( - profileImageUrl: "https://picsum.photos/id/237/200/200", - message: "홍길동님이 회원님의 옷장을 팔로우하기 시작했습니다." - ) - NotificationRow( - profileImageUrl: nil, - message: "김철수님이 새로운 게시물을 업로드했습니다." - ) - NotificationRow( - profileImageUrl: "invalid_url", - message: "이영희님이 회원님의 게시물에 좋아요를 눌렀습니다." - ) + NotificationRow(entity: NotificationEntity( + notificationId: 1, + notificationImageUrl: "https://picsum.photos/id/237/200/200", + notificationContent: "홍길동님이 회원님의 옷장을 팔로우하기 시작했습니다.", + redirectInfo: "user_123", + redirectType: .member, + readStatus: .unread, + createdAt: "2025-11-18T10:00:00" + )) + + NotificationRow(entity: NotificationEntity( + notificationId: 2, + notificationImageUrl: nil, + notificationContent: "김철수님이 새로운 게시물을 업로드했습니다.", + redirectInfo: "post_456", + redirectType: .history, + readStatus: .unread, + createdAt: "2025-11-18T11:00:00" + )) + + NotificationRow(entity: NotificationEntity( + notificationId: 3, + notificationImageUrl: "invalid_url", + notificationContent: "내일은 비 소식이 있습니다. 우산을 준비하세요!", + redirectInfo: "seoul", + redirectType: .weather, + readStatus: .read, + createdAt: "2025-11-18T12:00:00" + )) } + .padding() } diff --git a/Codive/Features/Notification/Presentation/Component/ReportSubmissionGuide.swift b/Codive/Features/Notification/Presentation/Component/ReportSubmissionGuide.swift new file mode 100644 index 00000000..b2509f4f --- /dev/null +++ b/Codive/Features/Notification/Presentation/Component/ReportSubmissionGuide.swift @@ -0,0 +1,69 @@ +// +// ReportSubmissionGuide.swift +// Codive +// +// Created by 한금준 on 12/30/25. +// + +import SwiftUI + +struct ReportSubmissionGuide: View { + let reportType: ReportType + + private var reportTargetText: String { + switch reportType { + case .feed: + return TextLiteral.Notification.feedType + case .comment: + return TextLiteral.Notification.commentType + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .center, spacing: 8) { + Image("warning") + .resizable() + .renderingMode(.template) + .frame(width: 24, height: 24) + .foregroundColor(Color.Codive.point1) + + Text(TextLiteral.Notification.reportTitle) + .font(.codive_body1_medium) + .foregroundColor(Color.Codive.grayscale1) + + Spacer() + } + + VStack(alignment: .leading, spacing: 4) { + Text(TextLiteral.Notification.reportBody1(reportTargetText)) + .font(.codive_body2_regular) + .foregroundColor(Color.Codive.grayscale1) + + Text(TextLiteral.Notification.reportBody2) + .font(.codive_body2_regular) + .foregroundColor(Color.Codive.grayscale1) + } + } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 20) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.Codive.point4) + ) + } +} + +// MARK: - Preview + +#Preview { + VStack { + ReportSubmissionGuide(reportType: .comment) + .padding(.horizontal, 20) + + ReportSubmissionGuide(reportType: .feed) + .padding(.horizontal, 20) + } +} diff --git a/Codive/Features/Notification/Presentation/View/NotificationView.swift b/Codive/Features/Notification/Presentation/View/NotificationView.swift index 030c17b5..4e0cefa1 100644 --- a/Codive/Features/Notification/Presentation/View/NotificationView.swift +++ b/Codive/Features/Notification/Presentation/View/NotificationView.swift @@ -25,7 +25,11 @@ struct NotificationView: View { } ScrollView { - VStack { + VStack(spacing: 0) { + if viewModel.isReported, let type = viewModel.reportType { + ReportSubmissionGuide(reportType: type) + } + // MARK: - Notification Sections notificationSection( title: TextLiteral.Notification.notRead, @@ -63,14 +67,18 @@ struct NotificationView: View { .foregroundStyle(Color.Codive.grayscale3) Spacer() } - .padding(.top, 32) + .padding(.top, 20) VStack(spacing: 16) { ForEach(notifications) { item in - NotificationRow( - profileImageUrl: item.imageUrl, - message: item.message - ) + NotificationRow(entity: item) + .contentShape(Rectangle()) // 투명한 영역도 탭이 되도록 설정 + .onTapGesture { + if item.readStatus == .unread { + viewModel.markAsRead(notificationId: item.notificationId) + } + // 페이지 이동 로직 호출 구간 + } } } .padding(.top, 12) diff --git a/Codive/Features/Notification/Presentation/ViewModel/NotificationViewModel.swift b/Codive/Features/Notification/Presentation/ViewModel/NotificationViewModel.swift index a18f9d8a..55c96ee9 100644 --- a/Codive/Features/Notification/Presentation/ViewModel/NotificationViewModel.swift +++ b/Codive/Features/Notification/Presentation/ViewModel/NotificationViewModel.swift @@ -16,6 +16,10 @@ final class NotificationViewModel: ObservableObject { @Published var unreadNotifications: [NotificationEntity] = [] @Published var readNotifications: [NotificationEntity] = [] + @Published var isReported: Bool = false + @Published var reportType: ReportType? + @Published var readErrorMessage: String? + // MARK: - Initializer init(navigationRouter: NavigationRouter, useCase: NotificationUseCase) { self.navigationRouter = navigationRouter @@ -24,11 +28,33 @@ final class NotificationViewModel: ObservableObject { // MARK: - Methods func loadData() { - let allNotifications = useCase.fetchNotifications() + updateNotificationLists(useCase.fetchNotifications()) - // isRead 상태를 기준으로 필터링 - self.unreadNotifications = allNotifications.filter { !$0.isRead } - self.readNotifications = allNotifications.filter { $0.isRead } + let reportStatus = useCase.fetchReportStatus() + self.isReported = reportStatus.isReported + self.reportType = reportStatus.reportType + } + + func markAsRead(notificationId: Int) { + Task { + do { + try await useCase.markNotificationAsRead(notificationId: notificationId) + + if let index = unreadNotifications.firstIndex(where: { $0.notificationId == notificationId }) { + var readItem = unreadNotifications.remove(at: index) + readItem.readStatus = .read + readNotifications.insert(readItem, at: 0) + } + } catch { + readErrorMessage = "알림 읽음 처리에 실패했어요. 잠시 후 다시 시도해 주세요." + print("알림 읽음 처리 실패: \(error)") + } + } + } + + private func updateNotificationLists(_ all: [NotificationEntity]) { + self.unreadNotifications = all.filter { $0.readStatus == .unread } + self.readNotifications = all.filter { $0.readStatus == .read } } // MARK: - Navigation diff --git a/Codive/Features/Search/Data/DataSources/SearchDataSource.swift b/Codive/Features/Search/Data/DataSources/SearchDataSource.swift index 5b21c32d..b1f3c60e 100644 --- a/Codive/Features/Search/Data/DataSources/SearchDataSource.swift +++ b/Codive/Features/Search/Data/DataSources/SearchDataSource.swift @@ -9,7 +9,7 @@ import Foundation final class SearchDataSource { - // MARK: - Fetch Methods + // MARK: - Fetch Methods (기존) func fetchUserName() -> SearchEntity { return SearchEntity(username: "코디브") @@ -17,9 +17,9 @@ final class SearchDataSource { func fetchRecentSearchTags() -> [SearchTagEntity] { return [ - SearchTagEntity(id: 1, text: "드뮤어룩"), + SearchTagEntity(id: 1, text: "겨울"), SearchTagEntity(id: 2, text: "한강룩"), - SearchTagEntity(id: 3, text: "여름 원피스"), + SearchTagEntity(id: 3, text: "한금준"), SearchTagEntity(id: 4, text: "패션이 좋아요") ] } @@ -55,7 +55,26 @@ final class SearchDataSource { } } - // MARK: - Private Methods + // MARK: - Fetch Methods (계정용 추가) + + /// 검색어를 기준으로 유저 목록을 필터링해서 반환 + func fetchUsers(query: String) -> [SimpleUser] { + let allUsers = getAllUsers() + + // 전체 or 빈 문자열이면 전부 리턴 + if query.isEmpty || query == "전체" { + return allUsers + } + + let lowercasedQuery = query.lowercased() + + return allUsers.filter { user in + user.nickname.lowercased().contains(lowercasedQuery) + || user.handle.lowercased().contains(lowercasedQuery) + } + } + + // MARK: - Private Methods (공통) private func createDate(year: Int, month: Int, day: Int) -> Date { var components = DateComponents() @@ -65,6 +84,8 @@ final class SearchDataSource { return Calendar.current.date(from: components) ?? Date() } + // MARK: - Private Methods (게시글 더미) + private func getAllPosts() -> [PostEntity] { return [ PostEntity( @@ -105,4 +126,35 @@ final class SearchDataSource { ) ] } + + // MARK: - Private Methods (유저 더미) + + private func getAllUsers() -> [SimpleUser] { + return [ + SimpleUser( + userId: 1, + nickname: "코디브 공식", + handle: "@codive_official", + avatarURL: URL(string: "https://picsum.photos/id/200/80/80") + ), + SimpleUser( + userId: 2, + nickname: "한금준", + handle: "@geumjoon", + avatarURL: URL(string: "https://picsum.photos/id/201/80/80") + ), + SimpleUser( + userId: 3, + nickname: "드뮤어룩 장인", + handle: "@demure_master", + avatarURL: URL(string: "https://picsum.photos/id/202/80/80") + ), + SimpleUser( + userId: 4, + nickname: "한강러버", + handle: "@hanriver_lover", + avatarURL: nil + ) + ] + } } diff --git a/Codive/Features/Search/Data/Repositories/SearchRepositoryImpl.swift b/Codive/Features/Search/Data/Repositories/SearchRepositoryImpl.swift index cd866f82..4e324d4e 100644 --- a/Codive/Features/Search/Data/Repositories/SearchRepositoryImpl.swift +++ b/Codive/Features/Search/Data/Repositories/SearchRepositoryImpl.swift @@ -31,4 +31,8 @@ final class SearchRepositoryImpl: SearchRepository { func fetchPosts(query: String) -> [PostEntity] { return datasource.fetchPosts(query: query) } + + func fetchUsers(query: String) -> [SimpleUser] { + return datasource.fetchUsers(query: query) + } } diff --git a/Codive/Features/Search/Domain/Protocols/SearchRepository.swift b/Codive/Features/Search/Domain/Protocols/SearchRepository.swift index 1fa80d83..7b8fc9dd 100644 --- a/Codive/Features/Search/Domain/Protocols/SearchRepository.swift +++ b/Codive/Features/Search/Domain/Protocols/SearchRepository.swift @@ -10,4 +10,5 @@ protocol SearchRepository { func fetchRecentSearchTags() -> [SearchTagEntity] func fetchRecommendedNews() -> [NewsEntity] func fetchPosts(query: String) -> [PostEntity] + func fetchUsers(query: String) -> [SimpleUser] } diff --git a/Codive/Features/Search/Domain/UseCases/SearchUseCase.swift b/Codive/Features/Search/Domain/UseCases/SearchUseCase.swift index 497e3256..0290d4ef 100644 --- a/Codive/Features/Search/Domain/UseCases/SearchUseCase.swift +++ b/Codive/Features/Search/Domain/UseCases/SearchUseCase.swift @@ -31,4 +31,8 @@ final class SearchUseCase { func fetchPosts(query: String) -> [PostEntity] { return repository.fetchPosts(query: query) } + + func fetchUsers(query: String) -> [SimpleUser] { + return repository.fetchUsers(query: query) + } } diff --git a/Codive/Features/Search/Presentation/View/HashtagView.swift b/Codive/Features/Search/Presentation/View/HashtagView.swift new file mode 100644 index 00000000..33457ab9 --- /dev/null +++ b/Codive/Features/Search/Presentation/View/HashtagView.swift @@ -0,0 +1,52 @@ +// +// HashtagView.swift +// Codive +// +// Created by 한금준 on 12/31/25. +// + +import SwiftUI + +// MARK: - Hashtag View +struct HashtagView: View { + @ObservedObject var viewModel: SearchResultViewModel + + // MARK: - Computed Properties + private var sortOptionsString: [String] { + viewModel.sortOptions.map { $0.displayName } + } + + // MARK: - Body + var body: some View { + VStack { + HStack { + Text("\(TextLiteral.Search.totalCount) \(viewModel.posts.count)\(TextLiteral.Search.countUnit)") + .font(Font.codive_body2_medium) + .foregroundStyle(Color.Codive.grayscale3) + Spacer() + + SortOption( + mainText: TextLiteral.Search.sortAll, + options: sortOptionsString, + selectedOption: $viewModel.currentSort + ) + .zIndex(10) + } + .padding(.top, 18) + .zIndex(10) + + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 11) { + ForEach(viewModel.posts) { post in + PostCard( + postImageUrl: post.postImageUrl, + profileImageUrl: post.profileImageUrl, + nickname: post.nickname + ) + } + } + .padding(.top, 18) + .zIndex(1) + } + .padding(.bottom, 20) + } +} diff --git a/Codive/Features/Search/Presentation/View/SearchResultSegmentControl.swift b/Codive/Features/Search/Presentation/View/SearchResultSegmentControl.swift new file mode 100644 index 00000000..7ecaef7c --- /dev/null +++ b/Codive/Features/Search/Presentation/View/SearchResultSegmentControl.swift @@ -0,0 +1,63 @@ +// +// SearchResultSegmentControl.swift +// Codive +// +// Created by 한금준 on 12/31/25. +// + +import SwiftUI + +// MARK: - Segment Type +enum SearchResultSegment { + case account + case hashtag +} + +// MARK: - Segment Control +struct SearchResultSegmentControl: View { + @Binding var selectedSegment: SearchResultSegment + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 0) { + segmentItem(title: TextLiteral.Search.account, segment: .account) + segmentItem(title: TextLiteral.Search.hashtag, segment: .hashtag) + } + + Rectangle() + .frame(height: 2) + .foregroundStyle(Color.Codive.grayscale6) + } + } + + @ViewBuilder + private func segmentItem(title: String, segment: SearchResultSegment) -> some View { + Button { + selectedSegment = segment + } label: { + VStack(spacing: 6) { + Text(title) + .font( + selectedSegment == segment + ? .codive_body1_medium + : .codive_body1_regular + ) + .foregroundStyle( + selectedSegment == segment + ? Color.Codive.grayscale1 + : Color.Codive.grayscale4 + ) + + Rectangle() + .frame(height: 2) + .foregroundStyle( + selectedSegment == segment + ? Color.Codive.point1 + : .clear + ) + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.plain) + } +} diff --git a/Codive/Features/Search/Presentation/View/SearchResultView.swift b/Codive/Features/Search/Presentation/View/SearchResultView.swift index 7bfcf547..ed404b59 100644 --- a/Codive/Features/Search/Presentation/View/SearchResultView.swift +++ b/Codive/Features/Search/Presentation/View/SearchResultView.swift @@ -10,6 +10,7 @@ import SwiftUI struct SearchResultView: View { // MARK: - Properties @StateObject private var viewModel: SearchResultViewModel + @State private var selectedSegment: SearchResultSegment = .account // MARK: - Computed Properties private var sortOptionsString: [String] { @@ -29,51 +30,56 @@ struct SearchResultView: View { type: .withBackButton { viewModel.handleBackTap() } - ) + ) { + viewModel.executeNewSearch(query: viewModel.searchBarText) + hideKeyboard() + } + .padding(.horizontal, 20) .zIndex(1) .onSubmit { viewModel.executeNewSearch(query: viewModel.searchBarText) + hideKeyboard() } + SearchResultSegmentControl(selectedSegment: $selectedSegment) + .padding(.top, 8) + ScrollView { - VStack { - HStack { - Text("\(TextLiteral.Search.totalCount) \(viewModel.posts.count)\(TextLiteral.Search.countUnit)") - .font(Font.codive_body2_medium) - .foregroundStyle(Color.Codive.grayscale3) - Spacer() - - SortOption( - mainText: TextLiteral.Search.sortAll, - options: sortOptionsString, - selectedOption: $viewModel.currentSort - ) - .zIndex(10) - } - .padding(.top, 18) - .zIndex(10) - - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 11) { - ForEach(viewModel.posts) { post in - PostCard( - postImageUrl: post.postImageUrl, - profileImageUrl: post.profileImageUrl, - nickname: post.nickname - ) + // MARK: - Account Result List + if selectedSegment == .account { + VStack(spacing: 0) { + ForEach(viewModel.users, id: \.userId) { user in + CustomUserRow( + user: user, + buttonStyle: .none + ) { + // 버튼 동작 + } } } .padding(.top, 18) - .zIndex(1) + } else { + // MARK: - Hashtag Result Grid + HashtagView(viewModel: viewModel) + .padding(.horizontal, 20) } - .padding(.bottom, 20) + } + .contentShape(Rectangle()) + .onTapGesture { + hideKeyboard() } } .navigationBarHidden(true) - .background(Color.white.ignoresSafeArea(.all)) - .padding(.horizontal, 20) + .background( + Color.white + .ignoresSafeArea(.all) + .onTapGesture { + hideKeyboard() + } + ) // MARK: - Data Loading Trigger .onAppear { - viewModel.loadPosts() + viewModel.loadInitialData() } } } diff --git a/Codive/Features/Search/Presentation/View/SearchView.swift b/Codive/Features/Search/Presentation/View/SearchView.swift index 9429dfbc..b24113e4 100644 --- a/Codive/Features/Search/Presentation/View/SearchView.swift +++ b/Codive/Features/Search/Presentation/View/SearchView.swift @@ -25,7 +25,10 @@ struct SearchView: View { type: .withBackButton { viewModel.handleBackTap() } - ) + ) { + viewModel.executeSearch(query: searchText) + hideKeyboard() + } .onSubmit { viewModel.executeSearch(query: searchText) } @@ -63,11 +66,19 @@ struct SearchView: View { .padding(.top, 8) } else { ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 16) { + HStack(spacing: 10) { ForEach(viewModel.recentSearchTags) { tag in - SearchTagView(text: tag.text) { - viewModel.deleteTag(tag: tag) + Button { + self.searchText = tag.text + viewModel.handleTagTap(tag: tag) + } label: { + SearchTagView(text: tag.text) { + viewModel.deleteTag(tag: tag) + } + .padding(.vertical, 2) + .padding(.horizontal, 2) } + .buttonStyle(PlainButtonStyle()) } } } @@ -98,10 +109,20 @@ struct SearchView: View { }.padding(.top, 8) } .padding(.bottom, 20) + .contentShape(Rectangle()) + .onTapGesture { + hideKeyboard() + } } } .navigationBarHidden(true) - .background(Color.white.ignoresSafeArea(.all)) + .background( + Color.white + .ignoresSafeArea(.all) + .onTapGesture { + hideKeyboard() + } + ) .padding(.horizontal, 20) // MARK: - Data Loading Trigger .onAppear { diff --git a/Codive/Features/Search/Presentation/ViewModel/SearchResultViewModel.swift b/Codive/Features/Search/Presentation/ViewModel/SearchResultViewModel.swift index 748ba127..0c574931 100644 --- a/Codive/Features/Search/Presentation/ViewModel/SearchResultViewModel.swift +++ b/Codive/Features/Search/Presentation/ViewModel/SearchResultViewModel.swift @@ -14,9 +14,11 @@ final class SearchResultViewModel: ObservableObject { private let navigationRouter: NavigationRouter private let useCase: SearchUseCase private var allPosts: [PostEntity] = [] + private var allUsers: [SimpleUser] = [] private var initialQuery: String @Published var posts: [PostEntity] = [] + @Published var users: [SimpleUser] = [] @Published var currentSort: String = "전체" @Published var searchBarText: String @@ -64,6 +66,11 @@ final class SearchResultViewModel: ObservableObject { } // MARK: - Public Methods + + func loadInitialData() { + loadPosts() + loadUsers() + } func loadPosts() { self.allPosts = useCase.fetchPosts(query: self.initialQuery) @@ -71,6 +78,12 @@ final class SearchResultViewModel: ObservableObject { self.applySorting(newSort: self.currentSort) } + func loadUsers() { + self.allUsers = useCase.fetchUsers(query: self.initialQuery) + self.users = self.allUsers + print("유저 로딩 완료: \(self.users.count)명") + } + func executeNewSearch(query: String) { let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) if trimmedQuery.isEmpty { @@ -80,10 +93,11 @@ final class SearchResultViewModel: ObservableObject { self.initialQuery = trimmedQuery self.currentSort = "전체" + loadPosts() + loadUsers() - navigationRouter.navigate(to: .searchResult(query: trimmedQuery)) - print("새로운 검색 실행: \(trimmedQuery)") + print("현재 페이지에서 검색 결과 갱신: \(trimmedQuery)") } // MARK: - Navigation diff --git a/Codive/Features/Search/Presentation/ViewModel/SearchViewModel.swift b/Codive/Features/Search/Presentation/ViewModel/SearchViewModel.swift index 077d558d..5b06c72b 100644 --- a/Codive/Features/Search/Presentation/ViewModel/SearchViewModel.swift +++ b/Codive/Features/Search/Presentation/ViewModel/SearchViewModel.swift @@ -38,7 +38,7 @@ final class SearchViewModel: ObservableObject { print("검색어를 입력해 주세요.") return } - // query를 전달하도록 수정 + navigationRouter.navigate(to: .searchResult(query: trimmedQuery)) print("검색 실행: \(trimmedQuery). SearchResultView로 이동 필요.") } @@ -59,6 +59,10 @@ final class SearchViewModel: ObservableObject { self.recentSearchTags = [] } + func handleTagTap(tag: SearchTagEntity) { + executeSearch(query: tag.text) + } + // MARK: - Navigation func handleBackTap() { navigationRouter.navigateBack() diff --git a/Codive/Resources/Icons.xcassets/Icon_folder/heart_off_black.imageset/Contents.json b/Codive/Resources/Icons.xcassets/Icon_folder/heart_off_black.imageset/Contents.json index af4ef580..028b337e 100644 --- a/Codive/Resources/Icons.xcassets/Icon_folder/heart_off_black.imageset/Contents.json +++ b/Codive/Resources/Icons.xcassets/Icon_folder/heart_off_black.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "하트.pdf", + "filename" : "heart_off_black.pdf", "idiom" : "universal" } ], diff --git "a/Codive/Resources/Icons.xcassets/Icon_folder/heart_off_black.imageset/\355\225\230\355\212\270.pdf" b/Codive/Resources/Icons.xcassets/Icon_folder/heart_off_black.imageset/heart_off_black.pdf similarity index 100% rename from "Codive/Resources/Icons.xcassets/Icon_folder/heart_off_black.imageset/\355\225\230\355\212\270.pdf" rename to Codive/Resources/Icons.xcassets/Icon_folder/heart_off_black.imageset/heart_off_black.pdf diff --git a/Codive/Resources/Icons.xcassets/Icon_folder/history.imageset/Contents.json b/Codive/Resources/Icons.xcassets/Icon_folder/history.imageset/Contents.json new file mode 100644 index 00000000..ccfd3436 --- /dev/null +++ b/Codive/Resources/Icons.xcassets/Icon_folder/history.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "history.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Codive/Resources/Icons.xcassets/Icon_folder/history.imageset/history.png b/Codive/Resources/Icons.xcassets/Icon_folder/history.imageset/history.png new file mode 100644 index 00000000..4a18d2b5 Binary files /dev/null and b/Codive/Resources/Icons.xcassets/Icon_folder/history.imageset/history.png differ diff --git a/Codive/Resources/Icons.xcassets/Icon_folder/weather.imageset/Contents.json b/Codive/Resources/Icons.xcassets/Icon_folder/weather.imageset/Contents.json new file mode 100644 index 00000000..fc647c71 --- /dev/null +++ b/Codive/Resources/Icons.xcassets/Icon_folder/weather.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "weather.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Codive/Resources/Icons.xcassets/Icon_folder/weather.imageset/weather.png b/Codive/Resources/Icons.xcassets/Icon_folder/weather.imageset/weather.png new file mode 100644 index 00000000..e048d964 Binary files /dev/null and b/Codive/Resources/Icons.xcassets/Icon_folder/weather.imageset/weather.png differ diff --git a/Codive/Shared/DesignSystem/ClothSheet/CustomProductBottomSheet.swift b/Codive/Shared/DesignSystem/ClothSheet/CustomProductBottomSheet.swift index d2bb188d..d160442f 100644 --- a/Codive/Shared/DesignSystem/ClothSheet/CustomProductBottomSheet.swift +++ b/Codive/Shared/DesignSystem/ClothSheet/CustomProductBottomSheet.swift @@ -30,7 +30,9 @@ struct CustomProductBottomSheet: View { CustomSearchBar( text: $searchText, type: .normal - ) + ) { + hideKeyboard() + } .padding(.horizontal, 20) // 카테고리 태그 diff --git a/Codive/Shared/DesignSystem/Inputs/CustomSearchBar.swift b/Codive/Shared/DesignSystem/Inputs/CustomSearchBar.swift index aa1105bc..2eddf0b0 100644 --- a/Codive/Shared/DesignSystem/Inputs/CustomSearchBar.swift +++ b/Codive/Shared/DesignSystem/Inputs/CustomSearchBar.swift @@ -15,6 +15,7 @@ enum SearchBarType { struct CustomSearchBar: View { @Binding var text: String var type: SearchBarType = .normal + var onSearch: () -> Void var body: some View { HStack { @@ -42,8 +43,11 @@ struct CustomSearchBar: View { .foregroundStyle(Color.Codive.grayscale1) } - Image(systemName: "magnifyingglass") - .foregroundStyle(Color.Codive.main1) + Button(action: onSearch) { + Image(systemName: "magnifyingglass") + .foregroundStyle(Color.Codive.main1) + .padding(4) + } } .padding(.horizontal, 12) .padding(.vertical, 12) @@ -70,18 +74,25 @@ struct StatefulPreviewWrapper: View { #Preview { VStack(spacing: 16) { // 일반 타입 - StatefulPreviewWrapper("") { - CustomSearchBar(text: $0, type: .normal) + StatefulPreviewWrapper("") { $text in + CustomSearchBar( + text: $text, + type: .normal + ) { + print("검색어: \(text)") + } } // 뒤로가기 버튼 타입 - StatefulPreviewWrapper("") { + StatefulPreviewWrapper("") { $text in CustomSearchBar( - text: $0, + text: $text, type: .withBackButton { print("뒤로가기 버튼 눌림") } - ) + ) { + print("검색 실행: \(text)") + } } } .padding(.horizontal, 20) diff --git a/Codive/Shared/DesignSystem/UIHelpers/View+CustomCornerRadius.swift b/Codive/Shared/DesignSystem/UIHelpers/View+CustomCornerRadius.swift index 326daccb..42473d60 100644 --- a/Codive/Shared/DesignSystem/UIHelpers/View+CustomCornerRadius.swift +++ b/Codive/Shared/DesignSystem/UIHelpers/View+CustomCornerRadius.swift @@ -11,4 +11,8 @@ extension View { func customCornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { clipShape(RoundedCorner(radius: radius, corners: corners)) } + + func hideKeyboard() { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } } diff --git a/Codive/Shared/DesignSystem/Views/CustomUserRow.swift b/Codive/Shared/DesignSystem/Views/CustomUserRow.swift index 8fc402c3..3c178a06 100644 --- a/Codive/Shared/DesignSystem/Views/CustomUserRow.swift +++ b/Codive/Shared/DesignSystem/Views/CustomUserRow.swift @@ -10,23 +10,59 @@ import SwiftUI enum CustomUserRowButtonStyle { case primary // 채워진 스타일 (e.g. 팔로우) case secondary // 테두리 스타일 (e.g. 팔로잉, 차단 해제) + case none // 버튼이 없는 상태 } struct CustomUserRow: View { let user: SimpleUser - let buttonTitle: String + let buttonTitle: String? let buttonStyle: CustomUserRowButtonStyle let action: () -> Void + + // MARK: - Convenience Initializers + + /// 버튼이 있는 경우 (팔로우 / 팔로잉 / 차단 해제 등) + init( + user: SimpleUser, + buttonTitle: String, + buttonStyle: CustomUserRowButtonStyle, + action: @escaping () -> Void + ) { + self.user = user + self.buttonTitle = buttonTitle + self.buttonStyle = buttonStyle + self.action = action + } + + /// 버튼이 없는 경우 + init( + user: SimpleUser, + buttonStyle: CustomUserRowButtonStyle = .none, + action: @escaping () -> Void + ) { + self.user = user + self.buttonTitle = nil + self.buttonStyle = buttonStyle + self.action = action + } + // MARK: - Body + var body: some View { HStack { // 아바타 AsyncImage(url: user.avatarURL) { phase in switch phase { - case .success(let img): img.resizable().scaledToFill() - case .empty: Color.Codive.grayscale4 - case .failure: Color.Codive.grayscale4 - @unknown default: Color.Codive.grayscale4 + case .success(let img): + img + .resizable() + .scaledToFill() + case .empty: + Color.Codive.grayscale4 + case .failure: + Color.Codive.grayscale4 + @unknown default: + Color.Codive.grayscale4 } } .frame(width: 40, height: 40) @@ -45,20 +81,22 @@ struct CustomUserRow: View { Spacer() - Button(action: action) { - Text(buttonTitle) - .font(.codive_body2_medium) - .foregroundStyle(buttonStyle == .primary ? .white : Color.Codive.main0) - .frame(minWidth: 76, minHeight: 32) - .background(buttonStyle == .primary ? Color.Codive.main0 : .white) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(buttonStyle == .secondary ? Color.Codive.main0 : .clear, lineWidth: 1) - ) - .multilineTextAlignment(.center) + if buttonStyle != .none { + Button(action: action) { + Text(buttonTitle ?? "") + .font(.codive_body2_medium) + .foregroundStyle(buttonStyle == .primary ? .white : Color.Codive.main0) + .frame(minWidth: 76, minHeight: 32) + .background(buttonStyle == .primary ? Color.Codive.main0 : .white) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(buttonStyle == .secondary ? Color.Codive.main0 : .clear, lineWidth: 1) + ) + .multilineTextAlignment(.center) + } + .buttonStyle(.plain) } - .buttonStyle(.plain) } .padding(.horizontal, 20) } @@ -76,5 +114,6 @@ struct CustomUserRow: View { CustomUserRow(user: dummyUser, buttonTitle: "팔로우", buttonStyle: .primary) { } CustomUserRow(user: dummyUser, buttonTitle: "팔로잉", buttonStyle: .secondary) { } CustomUserRow(user: dummyUser, buttonTitle: "차단 해제", buttonStyle: .secondary) { } + CustomUserRow(user: dummyUser, buttonStyle: .none) { } } }