diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index e73ce80e..5d95b0e7 100644 --- a/Modules/Sources/APIClient/APIClient.swift +++ b/Modules/Sources/APIClient/APIClient.swift @@ -80,6 +80,10 @@ public struct APIClient: Sendable { public var loadQMSUser: @Sendable (_ id: Int) async throws -> QMSUser public var loadQMSChat: @Sendable (_ id: Int) async throws -> QMSChat public var sendQMSMessage: @Sendable (_ chatId: Int, _ message: String) async throws -> Void + + // Search + public var startSearch: @Sendable (_ request: SearchRequest) async throws -> SearchResponse + public var members: @Sendable (_ request: MembersRequest) async throws -> MembersResponse // STREAMS public var connectionState: @Sendable () -> AsyncStream = { .finished } @@ -462,6 +466,33 @@ extension APIClient: DependencyKey { let _ = try await api.send(QMSCommand.Message.send(data: request)) // Returns chatId + new messageId }, + + // MARK: - Search + + startSearch: { request in + let command = SearchCommand.content( + on: request.on.toPDAPISearchOn(), + authorId: request.authorId, + text: request.text, + sort: request.sort.toPDAPISearchSort(), + offset: request.offset + ) + let response = try await api.send(command) + let status = Int(response.getResponseStatus()) + + print("Status \(String(describing: status))") + return try await parser.parseSearch(response: response) + }, + members: { request in + let command = SearchCommand.members( + term: request.term, + offset: request.offset, + number: request.number + ) + let response = try await api.send(command) + let status = Int(response.getResponseStatus()) + return try await parser.parseMembers(response: response) + }, // MARK: - Streams @@ -604,6 +635,12 @@ extension APIClient: DependencyKey { }, sendQMSMessage: { _, _ in + }, + startSearch: { _ in + return SearchResponse(metadata: [], publications: []) + }, + members: { _ in + return MembersResponse(metadata: [], members: []) }, connectionState: { return .finished diff --git a/Modules/Sources/APIClient/Requests/MembersRequest.swift b/Modules/Sources/APIClient/Requests/MembersRequest.swift new file mode 100644 index 00000000..2e8b5a61 --- /dev/null +++ b/Modules/Sources/APIClient/Requests/MembersRequest.swift @@ -0,0 +1,28 @@ +// +// MembersRequest.swift +// ForPDA +// +// Created by Рустам Ойтов on 29.10.2025. +// + +import PDAPI +import Models + +public struct MembersRequest: Sendable, Equatable { + + public let term: String + public let offset: Int + public let number: Int + + public init( + term: String, + offset: Int, + number: Int + ) { + self.term = term + self.offset = offset + self.number = number + } +} + + diff --git a/Modules/Sources/APIClient/Requests/SearchRequest.swift b/Modules/Sources/APIClient/Requests/SearchRequest.swift new file mode 100644 index 00000000..8332c5a6 --- /dev/null +++ b/Modules/Sources/APIClient/Requests/SearchRequest.swift @@ -0,0 +1,89 @@ +// +// SearchRequest.swift +// ForPDA +// +// Created by Рустам Ойтов on 18.08.2025. +// + +import PDAPI +import Models + +public struct SearchRequest: Sendable, Equatable { + + public let on: SearchOn + public let authorId: Int? + public let text: String + public let sort: SearchSort + public let offset: Int + + public enum SearchOn: Sendable, Equatable { + case site + case forum(id: Int?, sIn: ForumSearchIn, asTopics: Bool = false) + case topic(id: Int) + } + + public enum ForumSearchIn : Sendable { + case all + case posts + case titles + } + + public enum SearchSort: Sendable { + case dateDescSort + case dateAscSort + case relevance + } + + public init( + on: SearchOn, + authorId: Int?, + text: String, + sort: SearchSort, + offset: Int + ) { + self.on = on + self.authorId = authorId + self.text = text + self.sort = sort + self.offset = offset + } +} + +extension SearchRequest.SearchOn { + func toPDAPISearchOn() -> SearchCommand.SearchOn { + switch self { + case .site: + return .site + case let .forum(id: id, sIn: sIn, asTopics: asTopics): + return .forum(id: id, sIn: sIn.toPDAPIForumSearchIn(), asTopics: asTopics) + case let .topic(id: id): + return .topic(id: id) + } + } +} + +extension SearchRequest.ForumSearchIn { + func toPDAPIForumSearchIn() -> SearchCommand.ForumSearchIn { + switch self { + case .all: + return .all + case .posts: + return .posts + case .titles: + return .titles + } + } +} + +extension SearchRequest.SearchSort { + func toPDAPISearchSort() -> SearchCommand.SearchSort { + switch self { + case .dateAscSort: + return .dateAscSort + case .dateDescSort: + return .dateDescSort + case .relevance: + return .ascSort + } + } +} diff --git a/Modules/Sources/AppFeature/AppFeature.swift b/Modules/Sources/AppFeature/AppFeature.swift index 5f1a4edf..0fc1c9a8 100644 --- a/Modules/Sources/AppFeature/AppFeature.swift +++ b/Modules/Sources/AppFeature/AppFeature.swift @@ -22,6 +22,7 @@ import ProfileFeature import QMSListFeature import QMSFeature import ReputationFeature +import SearchFeature import SettingsFeature import NotificationsFeature import DeveloperFeature @@ -56,6 +57,7 @@ public struct AppFeature: Reducer, Sendable { public var favoritesTab: StackTab.State public var forumTab: StackTab.State public var profileFlow: ProfileFlow.State + public var searchTab: StackTab.State @Presents public var auth: AuthFeature.State? @Presents public var logStore: LogStoreFeature.State? @@ -86,6 +88,7 @@ public struct AppFeature: Reducer, Sendable { articlesTab: StackTab.State = StackTab.State(root: .articles(.articlesList(ArticlesListFeature.State()))), favoritesTab: StackTab.State = StackTab.State(root: .favorites(FavoritesRootFeature.State())), forumTab: StackTab.State = StackTab.State(root: .forum(.forumList(ForumsListFeature.State()))), + searchTab: StackTab.State = StackTab.State(root: .search(SearchFeature.State())), auth: AuthFeature.State? = nil, alert: AlertState? = nil, selectedTab: AppTab = .articles, @@ -98,7 +101,8 @@ public struct AppFeature: Reducer, Sendable { self.articlesTab = articlesTab self.favoritesTab = favoritesTab self.forumTab = forumTab - + self.searchTab = searchTab + if let session = _userSession.wrappedValue { self.profileFlow = .loggedIn(StackTab.State(root: .profile(.profile(ProfileFeature.State(userId: session.userId))))) } else { @@ -130,6 +134,7 @@ public struct AppFeature: Reducer, Sendable { case favoritesTab(StackTab.Action) case forumTab(StackTab.Action) case profileFlow(ProfileFlow.Action) + case searchTab(StackTab.Action) case auth(PresentationAction) case logStore(PresentationAction) @@ -198,6 +203,10 @@ public struct AppFeature: Reducer, Sendable { ProfileFlow.body } + Scope(state: \.searchTab, action: \.searchTab) { + StackTab() + } + // Authorization actions interceptor Reduce { state, action in switch action { @@ -493,6 +502,7 @@ public struct AppFeature: Reducer, Sendable { case let .articlesTab(.delegate(.showTabBar(show))), let .favoritesTab(.delegate(.showTabBar(show))), let .forumTab(.delegate(.showTabBar(show))), + let .searchTab(.delegate(.showTabBar(show))), let .profileFlow(.loggedIn(.delegate(.showTabBar(show)))), let .profileFlow(.loggedOut(.delegate(.showTabBar(show)))): state.showTabBar = show @@ -501,13 +511,14 @@ public struct AppFeature: Reducer, Sendable { case let .articlesTab(.delegate(.switchTab(to: tab))), let .favoritesTab(.delegate(.switchTab(to: tab))), let .forumTab(.delegate(.switchTab(to: tab))), + let .searchTab(.delegate(.switchTab(to: tab))), let .profileFlow(.loggedIn(.delegate(.switchTab(to: tab)))), let .profileFlow(.loggedOut(.delegate(.switchTab(to: tab)))): state.previousTab = state.selectedTab state.selectedTab = tab return .none - case .articlesTab, .favoritesTab, .forumTab, .profileFlow: + case .articlesTab, .favoritesTab, .forumTab, .profileFlow, .searchTab: return .none } } @@ -549,6 +560,9 @@ public struct AppFeature: Reducer, Sendable { case .forum: state.forumTab.path.removeAll() + + case .search: + state.forumTab.path.removeAll() case .profile: switch state.profileFlow { @@ -577,7 +591,7 @@ public struct AppFeature: Reducer, Sendable { private func removeNotifications(_ state: inout State) -> Effect { return .run { [tab = state.selectedTab] _ in switch tab { - case .articles, .forum, .profile: + case .articles, .forum, .profile, .search: break case .favorites: await notificationsClient.removeNotifications(categories: [.forum, .topic]) @@ -619,6 +633,7 @@ public struct AppFeature: Reducer, Sendable { case .articles: state.articlesTab.path.append(element) case .favorites: state.favoritesTab.path.append(element) case .forum: state.forumTab.path.append(element) + case .search: state.searchTab.path.append(element) case .profile: switch state.profileFlow { case var .loggedIn(flow): diff --git a/Modules/Sources/AppFeature/AppView.swift b/Modules/Sources/AppFeature/AppView.swift index 593f6632..701b5ca5 100644 --- a/Modules/Sources/AppFeature/AppView.swift +++ b/Modules/Sources/AppFeature/AppView.swift @@ -24,6 +24,7 @@ import ProfileFeature import QMSFeature import QMSListFeature import SettingsFeature +import SearchFeature import SFSafeSymbols import SharedUI import SwiftUI @@ -135,6 +136,15 @@ struct LiquidTabView: View { ) { ProfileTab(store: store.scope(state: \.profileFlow, action: \.profileFlow)) } + + Tab( + AppTab.search.title, + systemSymbol: AppTab.search.iconSymbol, + value: .search +// role: .search + ) { + StackTabView(store: store.scope(state: \.searchTab, action: \.searchTab)) + } } .tabBarMinimizeBehavior(store.appSettings.hideTabBarOnScroll ? .onScrollDown : .never) .if(store.appSettings.experimentalFloatingNavigation) { content in @@ -160,6 +170,8 @@ struct LiquidTabView: View { case let .loggedIn(store), let .loggedOut(store): Page(for: store) } + case .search: + Page(for: store.scope(state: \.searchTab, action: \.searchTab)) } } @@ -223,6 +235,9 @@ struct OldTabView: View { ProfileTab(store: store.scope(state: \.profileFlow, action: \.profileFlow)) .tag(AppTab.profile) + + StackTabView(store: store.scope(state: \.searchTab, action: \.searchTab)) + .tag(AppTab.search) } Group { diff --git a/Modules/Sources/AppFeature/Navigation/Path.swift b/Modules/Sources/AppFeature/Navigation/Path.swift index 25b2f4a7..f09ddda3 100644 --- a/Modules/Sources/AppFeature/Navigation/Path.swift +++ b/Modules/Sources/AppFeature/Navigation/Path.swift @@ -20,6 +20,7 @@ import ProfileFeature import QMSFeature import QMSListFeature import ReputationFeature +import SearchFeature import SettingsFeature import TopicFeature import AuthFeature @@ -30,6 +31,7 @@ public enum Path { case favorites(FavoritesRootFeature) case forum(Forum.Body = Forum.body) case profile(Profile.Body = Profile.body) + case search(SearchFeature) case settings(Settings.Body = Settings.body) case qms(QMS.Body = QMS.body) case auth(AuthFeature) @@ -55,6 +57,11 @@ public enum Path { case topic(TopicFeature) } + @Reducer(state: .equatable) + public enum Search { + case search(SearchFeature) + } + @Reducer(state: .equatable) public enum Settings { case settings(SettingsFeature) @@ -87,6 +94,10 @@ extension Path { case let .forum(path): ForumViews(path) + case let .search(store): + SearchScreen(store: store) + .tracking(for: SearchScreen.self) + case let .settings(path): SettingsViews(path) diff --git a/Modules/Sources/AppFeature/Navigation/StackTab.swift b/Modules/Sources/AppFeature/Navigation/StackTab.swift index e575d9b7..55c57d88 100644 --- a/Modules/Sources/AppFeature/Navigation/StackTab.swift +++ b/Modules/Sources/AppFeature/Navigation/StackTab.swift @@ -21,6 +21,7 @@ import ForumFeature import TopicFeature import FavoritesRootFeature import ProfileFeature +import SearchFeature import AnnouncementFeature import HistoryFeature import QMSListFeature @@ -116,6 +117,9 @@ public struct StackTab: Reducer, Sendable { case let .profile(action): return handleProfilePathNavigation(action: action, state: &state) + case let .search(action): + return handleSearchPathNavigation(action: action, state: &state) + case let .settings(action): return handleSettingsPathNavigation(action: action, state: &state) @@ -252,6 +256,16 @@ public struct StackTab: Reducer, Sendable { return .none } + // MARK: - Search + + private func handleSearchPathNavigation(action: SearchFeature.Action, state: inout State) -> Effect { + switch action { + default: + break + } + return .none + } + // MARK: - Settings private func handleSettingsPathNavigation(action: Path.Settings.Action, state: inout State) -> Effect { diff --git a/Modules/Sources/AppFeature/Resources/Localizable.xcstrings b/Modules/Sources/AppFeature/Resources/Localizable.xcstrings index a1b958b0..9dc79552 100644 --- a/Modules/Sources/AppFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/AppFeature/Resources/Localizable.xcstrings @@ -21,6 +21,17 @@ } } }, + "Search" : { + "extractionState" : "stale", + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поиск" + } + } + } + }, "Unable to open link" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/Models/App/AppTab.swift b/Modules/Sources/Models/App/AppTab.swift index a5386be8..8b676ffa 100644 --- a/Modules/Sources/Models/App/AppTab.swift +++ b/Modules/Sources/Models/App/AppTab.swift @@ -13,7 +13,7 @@ public enum AppTab: Int, CaseIterable, Sendable, Codable { case favorites case forum case profile - + case search public var iconSymbol: SFSymbol { switch self { case .articles: @@ -24,6 +24,8 @@ public enum AppTab: Int, CaseIterable, Sendable, Codable { return .bubbleLeftAndBubbleRight case .profile: return .personCropCircle + case .search: + return .magnifyingglass } } @@ -37,6 +39,8 @@ public enum AppTab: Int, CaseIterable, Sendable, Codable { return LocalizedStringResource("Forum", bundle: .module) case .profile: return LocalizedStringResource("Profile", bundle: .module) + case .search: + return LocalizedStringResource("Search", bundle: .module) } } } diff --git a/Modules/Sources/Models/Resources/Localizable.xcstrings b/Modules/Sources/Models/Resources/Localizable.xcstrings index 27dabf51..b697bd00 100644 --- a/Modules/Sources/Models/Resources/Localizable.xcstrings +++ b/Modules/Sources/Models/Resources/Localizable.xcstrings @@ -100,6 +100,9 @@ } } } + }, + "Search" : { + }, "Today, %@" : { "localizations" : { diff --git a/Modules/Sources/Models/Search/Members.swift b/Modules/Sources/Models/Search/Members.swift new file mode 100644 index 00000000..0c7cd935 --- /dev/null +++ b/Modules/Sources/Models/Search/Members.swift @@ -0,0 +1,40 @@ +// +// Members.swift +// ForPDA +// +// Created by Рустам Ойтов on 29.10.2025. +// + +import Foundation + +public struct MembersResponse: Sendable, Hashable, Decodable { + public let metadata: [Int] + public let members: [Member] + + public init( + metadata: [Int], + members: [Member] + ) { + self.metadata = metadata + self.members = members + } +} + +public struct Member: Sendable, Hashable, Decodable { + public let id: Int + public let nickname: String + public let unknown3: Int + public let avatarUrl: String + + public init( + id: Int, + nickname: String, + unknown3: Int, + avatarUrl: String + ) { + self.id = id + self.nickname = nickname + self.unknown3 = unknown3 + self.avatarUrl = avatarUrl + } +} diff --git a/Modules/Sources/Models/Search/Search.swift b/Modules/Sources/Models/Search/Search.swift new file mode 100644 index 00000000..ca661bd5 --- /dev/null +++ b/Modules/Sources/Models/Search/Search.swift @@ -0,0 +1,73 @@ +// +// Search.swift +// ForPDA +// +// Created by Рустам Ойтов on 17.08.2025. +// + +import Foundation + +public struct SearchResponse: Sendable, Hashable, Decodable { + public let metadata: [Int] + public let publications: [Publication] + + public init(metadata: [Int], publications: [Publication]) { + self.metadata = metadata + self.publications = publications + } +} + +public struct Publication: Sendable, Hashable, Decodable { + public let unknownValue1: Int + public let unknownValue2: Int + public let unknownValue3: Int + public let postName: String + public let messageId: Int + public let unknownValue4: Int + public let unknownValue5: Int + public let unknownValue6: Int + public let authorName: String + public let unknownValue7: Int + public let authorReputation: Int + public let date: Date + public let text: String + public let authorAvatar: String + public let signatureAuthor: String + public let unknownValue10: Int + + public init( + unknownValue1: Int, + unknownValue2: Int, + unknownValue3: Int, + postName: String, + messageId: Int, + unknownValue4: Int, + unknownValue5: Int, + authorName: String, + unknownValue6: Int, + unknownValue7: Int, + authorReputation: Int, + date: Date, + text: String, + authorAvatar: String, + signatureAuthor: String, + unknownValue10: Int + ) { + self.unknownValue1 = unknownValue1 + self.unknownValue2 = unknownValue2 + self.unknownValue3 = unknownValue3 + self.postName = postName + self.messageId = messageId + self.unknownValue4 = unknownValue4 + self.unknownValue5 = unknownValue5 + self.authorName = authorName + self.unknownValue6 = unknownValue6 + self.unknownValue7 = unknownValue7 + self.authorReputation = authorReputation + self.date = date + self.text = text + self.authorAvatar = authorAvatar + self.signatureAuthor = signatureAuthor + self.unknownValue10 = unknownValue10 + } +} diff --git a/Modules/Sources/ParsingClient/Parsers/MembersParser.swift b/Modules/Sources/ParsingClient/Parsers/MembersParser.swift new file mode 100644 index 00000000..6c79baad --- /dev/null +++ b/Modules/Sources/ParsingClient/Parsers/MembersParser.swift @@ -0,0 +1,61 @@ +// +// MembersParser.swift +// ForPDA +// +// Created by Рустам Ойтов on 29.10.2025. +// + +import Foundation +import Models +import ComposableArchitecture + +public struct MembersParser { + + // MARK: - parse + + public static func parse(from string: String) throws(ParsingError) -> MembersResponse { + guard let data = string.data(using: .utf8) else { + throw ParsingError.failedToCreateDataFromString + } + + guard let array = try? JSONSerialization.jsonObject(with: data, options: []) as? [Any] else { + throw ParsingError.failedToCastDataToAny + } + + guard let meta0 = array[safe: 0] as? Int, + let meta1 = array[safe: 1] as? Int, + let meta2 = array[safe: 2] as? Int, + let membersArray = array[safe: 3] as? [[Any]] else { + throw ParsingError.failedToCastFields + } + + return MembersResponse( + metadata: [meta0, meta1, meta2], + members: try parseMembers(membersArray) + ) + } + + // MARK: - parse members + + private static func parseMembers(_ rawMembers: [[Any]]) throws(ParsingError) -> [Member] { + var members: [Member] = [] + + for memberRaw in rawMembers { + guard let id = memberRaw[safe: 0] as? Int, + let name = memberRaw[safe: 1] as? String, + let groupId = memberRaw[safe: 2] as? Int, + let avatarUrl = memberRaw[safe: 3] as? String else { + throw ParsingError.failedToCastFields + } + + let member = Member( + id: id, + nickname: name.convertCodes(), + unknown3: groupId, + avatarUrl: avatarUrl + ) + members.append(member) + } + return members + } +} diff --git a/Modules/Sources/ParsingClient/Parsers/SearchParser.swift b/Modules/Sources/ParsingClient/Parsers/SearchParser.swift new file mode 100644 index 00000000..0f1c9020 --- /dev/null +++ b/Modules/Sources/ParsingClient/Parsers/SearchParser.swift @@ -0,0 +1,84 @@ +// +// SearchParser.swift +// ForPDA +// +// Created by Рустам Ойтов on 19.08.2025. +// + +import Foundation +import Models +import ComposableArchitecture + +public struct SearchParser { + + // MARK: - SearchResponse + + public static func parse(from string: String) throws(ParsingError) -> SearchResponse { + guard let data = string.data(using: .utf8) else { + throw ParsingError.failedToCreateDataFromString + } + + guard let array = try? JSONSerialization.jsonObject(with: data, options: []) as? [Any] else { + throw ParsingError.failedToCastDataToAny + } + + guard let metadata0 = array[safe: 0] as? Int, + let metadata1 = array[safe: 1] as? Int, + let metadata2 = array[safe: 2] as? Int, + let publications = array[safe: 3] as? [[Any]] else { + throw ParsingError.failedToCastFields + } + + return SearchResponse( + metadata: [metadata0, metadata1, metadata2], + publications: try parseSearch(publications) + ) + } + + // MARK: - Publications + + private static func parseSearch(_ publicationsRaw: [[Any]]) throws(ParsingError) -> [Publication] { + var publications: [Publication] = [] + for publication in publicationsRaw { + guard let unknownValue1 = publication[safe: 0] as? Int, + let unknownValue2 = publication[safe: 1] as? Int, + let unknownValue3 = publication[safe: 2] as? Int, + let postName = publication[safe: 3] as? String, + let messageId = publication[safe: 4] as? Int, + let unknownValue4 = publication[safe: 5] as? Int, + let unknownValue5 = publication[safe: 6] as? Int, + let authorName = publication[safe: 7] as? String, + let unknownValue6 = publication[safe: 8] as? Int, + let unknownValue7 = publication[safe: 9] as? Int, + let authorReputation = publication[safe: 10] as? Int, + let timestamp = publication[safe: 11] as? TimeInterval, + let text = publication[safe: 12] as? String, + let authorAvatar = publication[safe: 13] as? String, + let signatureAuthor = publication[safe: 14] as? String, + let unknownValue10 = publication[safe: 16] as? Int else { + throw ParsingError.failedToCastFields + } + + let publication = Publication( + unknownValue1: unknownValue1, + unknownValue2: unknownValue2, + unknownValue3: unknownValue3, + postName: postName.convertCodes(), + messageId: messageId, + unknownValue4: unknownValue4, + unknownValue5: unknownValue5, + authorName: authorName.convertCodes(), + unknownValue6: unknownValue6, + unknownValue7: unknownValue7, + authorReputation: authorReputation, + date: Date(timeIntervalSince1970: timestamp), + text: text.convertCodes(), + authorAvatar: authorAvatar, + signatureAuthor: signatureAuthor.convertCodes(), + unknownValue10: unknownValue10 + ) + publications.append(publication) + } + return publications + } +} diff --git a/Modules/Sources/ParsingClient/ParsingClient.swift b/Modules/Sources/ParsingClient/ParsingClient.swift index 1bba3c86..9dc91a5c 100644 --- a/Modules/Sources/ParsingClient/ParsingClient.swift +++ b/Modules/Sources/ParsingClient/ParsingClient.swift @@ -40,6 +40,10 @@ public struct ParsingClient: Sendable { public var parsePostPreview: @Sendable (_ response: String) async throws -> PostPreview public var parsePostSendResponse: @Sendable (_ response: String) async throws -> PostSendResponse + //Search + public var parseSearch: @Sendable (_ response: String) async throws -> SearchResponse + public var parseMembers: @Sendable (_ response: String) async throws -> MembersResponse + // Write Form public var parseWriteForm: @Sendable (_ response: String) async throws -> [WriteFormFieldType] @@ -110,6 +114,12 @@ extension ParsingClient: DependencyKey { parsePostSendResponse: { response in return try TopicParser.parsePostSendResponse(from: response) }, + parseSearch: { response in + return try SearchParser.parse(from: response) + }, + parseMembers: { response in + return try MembersParser.parse(from: response) + }, parseWriteForm: { response in return try WriteFormParser.parse(from: response) }, diff --git a/Modules/Sources/SearchFeature/SearchFeature.swift b/Modules/Sources/SearchFeature/SearchFeature.swift new file mode 100644 index 00000000..f1ac1263 --- /dev/null +++ b/Modules/Sources/SearchFeature/SearchFeature.swift @@ -0,0 +1,163 @@ +// +// SearchFeature.swift +// ForPDA +// +// Created by Рустам Ойтов on 17.08.2025. +// + +import Foundation +import ComposableArchitecture +import APIClient +import Models + +@Reducer +public struct SearchFeature: Reducer, Sendable { + + public init() {} + + // MARK: - State + + @ObservableState + public struct State: Equatable { + var searchText = "" + var toggleRes = false + var nicknameAuthor = "" + var authorId: Int? = nil + var whereSearch = "Everywhere" + var sortBy = "Relevance(matching the query)" + var whereSerchForum = "Everywhere" + var showMembers = false + var members: [Member] = [] + + public init() {} + } + + // MARK: - Action + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + + case view(View) + public enum View { + case onAppear + case startSearch + case additionalHidenToggle + case searchAuthorName(String) + case selectUser(Int, String) + } + + case `internal`(Internal) + public enum Internal { + case search(SearchRequest) + case addMembers(MembersResponse) + } + } + + // MARK: - Dependencies + + @Dependency(\.apiClient) private var apiClient + + // MARK: - Body + + public var body: some Reducer { + BindingReducer() + + Reduce { state, action in + switch action { + + case .view(.startSearch): + return .send(.internal(.search(formatData( + searchText: state.searchText, + isTopicFormat: state.toggleRes, + nicknameAuthor: state.nicknameAuthor, + authorId: state.authorId, + whereSearch: state.whereSearch, + whereSearchForum: state.whereSerchForum, + sortBy: state.sortBy + )))) + + case let .view(.searchAuthorName(nickname)): + return .run { send in + let request = MembersRequest(term: nickname, offset: 10, number: 3) + let result = try await apiClient.members(request: request) + await send(.internal(.addMembers(result))) + } + + case let .view(.selectUser(id, nickname)): + state.nicknameAuthor = nickname + state.authorId = id + state.showMembers = false + return .none + + case let .internal(.search(request)): + return .run { send in + let result = try await apiClient.startSearch(request: request) + print(result.publications) + } + + case let .internal(.addMembers(data)): + state.members = data.members + print("from internal = \(state.members)") + state.showMembers = !data.members.isEmpty + print("from internal = \(state.showMembers)") + return .none + + case .binding: + return .none + + default: + return .none + } + } + } + + private func formatData( + searchText: String, + isTopicFormat: Bool, + nicknameAuthor: String, + authorId: Int?, + whereSearch: String, + whereSearchForum: String, + sortBy: String + ) -> SearchRequest { + + let searchIn: SearchRequest.ForumSearchIn + let searchOn: SearchRequest.SearchOn + + switch whereSearchForum { + case "Everywhere": + searchIn = .all + case "In topic titles only": + searchIn = .titles + case "Only in messages": + searchIn = .posts + default: + searchIn = .all + } + + switch whereSearch { + case "Everywhere": + searchOn = .site + case "On the forum": + searchOn = .forum(id: nil, sIn: searchIn, asTopics: isTopicFormat) + case "On the site": + searchOn = .site + default: + searchOn = .site + } + + let sort: SearchRequest.SearchSort + switch sortBy { + case "Relevance(matching the query)": + sort = .relevance + case "Date (newest to oldest)": + sort = .dateAscSort + case "Date (oldest to newest)": + sort = .dateDescSort + default: + sort = .relevance + } + + return SearchRequest(on: searchOn, authorId: authorId, text: searchText, sort: sort, offset: 10) + } +} diff --git a/Modules/Sources/SearchFeature/SearchScreen.swift b/Modules/Sources/SearchFeature/SearchScreen.swift new file mode 100644 index 00000000..f43813c5 --- /dev/null +++ b/Modules/Sources/SearchFeature/SearchScreen.swift @@ -0,0 +1,270 @@ +// +// SearchScreen.swift +// ForPDA +// +// Created by Рустам Ойтов on 17.08.2025. +// + +import SwiftUI +import SharedUI +import ComposableArchitecture + +@ViewAction(for: SearchFeature.self) +public struct SearchScreen: View { + @Perception.Bindable public var store: StoreOf + @State private var additionalHidden = true + + public init(store: StoreOf) { + self.store = store + } + + // MARK: - body + + public var body: some View { + WithPerceptionTracking { + ZStack { + Color(.Background.primary) + .ignoresSafeArea() + NavigationStack { + ScrollView { + VStack(spacing: 0) { + RowFilters( + name: "Search", + values: [ + "Everywhere", + "On the forum", + "On the site" + ], + selectedValue: $store.whereSearch + ) + .padding(.horizontal, 16) + + if !additionalHidden { + additionalFilters() + + if store.showMembers { + ForEach(store.members, id: \.id) { member in + memberRow(id: member.id, name: member.nickname) + } + } + } + showParametersButton() + } + } + .background(Color(.Background.primary)) + .navigationTitle("Search") + .searchable(text: $store.searchText) + .onSubmit(of: .search) { + send(.startSearch) + } + + } + } + } + } + + // MARK: - row filters + + @ViewBuilder + private func RowFilters(name: String, values: [String], selectedValue: Binding) -> some View { + if name == "Nickname" { + authorNicknameFilter() + } else if values.isEmpty { + viewTopicFilter(name: name) + } else { + menuFilter(name: name, values: values, selectedValue: selectedValue) + } + } + + // MARK: - author nickname filter + + @ViewBuilder + private func authorNicknameFilter() -> some View { + VStack(alignment: .leading, spacing: 0) { + Text("Nickname author") + .foregroundStyle(Color(.Labels.teritary)) + .font(.footnote) + .fontWeight(.semibold) + .padding(.bottom, 6) + + HStack(spacing: 0) { + TextField("Input...", text: $store.nicknameAuthor) + .padding(.horizontal, 12) + .textFieldStyle(.plain) + .frame(height: 52) + .background(Color(.Background.teritary)) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .overlay( + RoundedRectangle(cornerRadius: 14) + .stroke(Color(.Separator.primary), lineWidth: 0.33) + ) + .onChange(of: store.nicknameAuthor) { text in + send(.searchAuthorName(text)) + } + } + } + .padding(.bottom, 11) + } + + // MARK: - view topic filter + + @ViewBuilder + private func viewTopicFilter(name: String) -> some View { + HStack(spacing: 0) { + Text(name) + .foregroundStyle(.primary) + .font(.body) + .padding(.leading, 16) + .padding(.vertical, 19) + + Spacer() + + Toggle(isOn: $store.toggleRes) {} + .padding(.trailing, 16) + } + .background(Color(.Background.teritary)) + } + + // MARK: - additional filters + + @ViewBuilder + private func additionalFilters() -> some View { + Group { + RowFilters( + name: "Sort", + values: [ + "Relevance(matching the query)", + "Date (newest to oldest)", + "Date (oldest to newest)" + ], + selectedValue: $store.sortBy + ) + + RowFilters( + name: "Result in view topic", + values: [], + selectedValue: .constant("") + ) + + RowFilters( + name: "Search the forum", + values: [ + "Everywhere", + "In posts", + "In titles" + ], + selectedValue: $store.whereSerchForum + ) + .padding(.bottom, 28) + + RowFilters( + name: "Nickname", + values: [], + selectedValue: .constant("") + ) + } + .padding(.horizontal, 16) + .transition(.opacity) + } + + // MARK: - menu filter + + @ViewBuilder + private func menuFilter(name: String, values: [String], selectedValue: Binding) -> some View { + HStack(spacing: 0) { + Text(name) + .foregroundStyle(.primary) + .font(.body) + .padding(.leading, 16) + .padding(.vertical, 19) + + Spacer() + + Menu { + ForEach(values, id: \.self) { value in + Button { + selectedValue.wrappedValue = value + print("\(value) tapped") + } label: { + HStack { + Text(value) + if selectedValue.wrappedValue == value { + Image(systemName: "checkmark") + } + } + } + } + } label: { + Text(selectedValue.wrappedValue) + .foregroundStyle(Color(.Labels.quaternary)) + + Image(systemName: "chevron.compact.up.chevron.compact.down") + .foregroundStyle(Color(.Labels.quaternary)) + .padding(.trailing, 16) + } + } + .background(Color(.Background.teritary)) + } + + // MARK: - member row + + @ViewBuilder + private func memberRow(id: Int, name: String) -> some View { + Button { + print("user \(id) was tapped") + send(.selectUser(id, name)) + } label: { + HStack { + Text(name) + .foregroundStyle(Color(.Labels.primary)) + .font(.body) + .lineLimit(1) + .padding(.horizontal, 28) + + Spacer() + } + .padding(.vertical, 20) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.Background.teritary)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(.horizontal, 16) + } + + // MARK: - show parameters button + + @ViewBuilder + private func showParametersButton() -> some View { + Button { + withAnimation(.easeInOut) { + additionalHidden.toggle() + } + } label: { + HStack(spacing: 0) { + Text(additionalHidden ? "More parameters" : "Fewer parameters") + .foregroundStyle(Color(.Labels.teritary)) + .font(.callout) + .padding(.trailing, 8) + + Image(systemSymbol: .chevronDown) + .foregroundStyle(Color(.Labels.teritary)) + .font(.body) + .rotationEffect(.degrees(additionalHidden ? 0 : -180)) + } + .padding(.top, additionalHidden ? 28 : 16) + } + } +} + +// MARK: - Preview + +#Preview { + SearchScreen( + store: Store( + initialState: SearchFeature.State(), + ) { + SearchFeature() + } + ) +} diff --git a/Project.swift b/Project.swift index 14514bb2..84c2c2e0 100644 --- a/Project.swift +++ b/Project.swift @@ -49,6 +49,7 @@ let project = Project( .Internal.QMSListFeature, .Internal.ReputationChangeFeature, .Internal.ReputationFeature, + .Internal.SearchFeature, .Internal.SettingsFeature, .Internal.TCAExtensions, .Internal.ToastClient, @@ -365,6 +366,16 @@ let project = Project( ] ), + .feature( + name: "SearchFeature", + dependencies: [ + .Internal.APIClient, + .Internal.Models, + .Internal.SharedUI, + .SPM.TCA, + ] + ), + .feature( name: "SettingsFeature", dependencies: [ @@ -882,6 +893,7 @@ extension TargetDependency.Internal { static let QMSListFeature = TargetDependency.target(name: "QMSListFeature") static let ReputationChangeFeature = TargetDependency.target(name: "ReputationChangeFeature") static let ReputationFeature = TargetDependency.target(name: "ReputationFeature") + static let SearchFeature = TargetDependency.target(name: "SearchFeature") static let SettingsFeature = TargetDependency.target(name: "SettingsFeature") static let TopicBuilder = TargetDependency.target(name: "TopicBuilder") static let TopicFeature = TargetDependency.target(name: "TopicFeature")