From 4029501e004a81fe9d4df341de723a8b3db83a5b Mon Sep 17 00:00:00 2001 From: Xialtal Date: Thu, 2 Apr 2026 13:10:35 +0300 Subject: [PATCH 01/24] [WIP] Device Vendor --- .../DeviceVendorFeature.swift | 58 +++++++++++++++++++ .../DeviceVendorScreen.swift | 51 ++++++++++++++++ .../Resources/Localizable.xcstrings | 9 +++ Project.swift | 13 +++++ 4 files changed, 131 insertions(+) create mode 100644 Modules/Sources/DeviceVendorFeature/DeviceVendorFeature.swift create mode 100644 Modules/Sources/DeviceVendorFeature/DeviceVendorScreen.swift create mode 100644 Modules/Sources/DeviceVendorFeature/Resources/Localizable.xcstrings diff --git a/Modules/Sources/DeviceVendorFeature/DeviceVendorFeature.swift b/Modules/Sources/DeviceVendorFeature/DeviceVendorFeature.swift new file mode 100644 index 00000000..94e6c7a1 --- /dev/null +++ b/Modules/Sources/DeviceVendorFeature/DeviceVendorFeature.swift @@ -0,0 +1,58 @@ +// +// DeviceVendorFeature.swift +// ForPDA +// +// Created by Xialtal on 2.04.26. +// + +import Foundation +import ComposableArchitecture +import APIClient +import Models + +@Reducer +public struct DeviceVendorFeature: Reducer, Sendable { + + public init() {} + + // MARK: - State + + @ObservableState + public struct State: Equatable { + public let type: DeviceType + public let vendorName: String + + public init( + type: DeviceType, + vendorName: String + ) { + self.type = type + self.vendorName = vendorName + } + } + + // MARK: - Action + + public enum Action: ViewAction { + case view(View) + public enum View { + case onAppear + } + } + + // MARK: - Dependencies + + @Dependency(\.apiClient) private var apiClient + @Dependency(\.openURL) var openURL + + // MARK: - Body + + public var body: some Reducer { + Reduce { state, action in + switch action { + case .view(.onAppear): + return .none + } + } + } +} diff --git a/Modules/Sources/DeviceVendorFeature/DeviceVendorScreen.swift b/Modules/Sources/DeviceVendorFeature/DeviceVendorScreen.swift new file mode 100644 index 00000000..c3ca1a1d --- /dev/null +++ b/Modules/Sources/DeviceVendorFeature/DeviceVendorScreen.swift @@ -0,0 +1,51 @@ +// +// DeviceVendorScreen.swift +// ForPDA +// +// Created by Xialtal on 2.04.26. +// + +import SwiftUI +import ComposableArchitecture +import Models +import SharedUI + +@ViewAction(for: DeviceVendorFeature.self) +public struct DeviceVendorScreen: View { + + @Perception.Bindable public var store: StoreOf + @Environment(\.tintColor) private var tintColor + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + WithPerceptionTracking { + ScrollView { + Text("Vendor") + } + .background(Color(.Background.primary)) + .onAppear { + send(.onAppear) + } + } + } +} + +// MARK: - Previews + +#Preview { + NavigationStack { + DeviceVendorScreen( + store: Store( + initialState: DeviceVendorFeature.State( + type: .phone, + vendorName: "apple" + ) + ) { + DeviceVendorFeature() + } + ) + } +} diff --git a/Modules/Sources/DeviceVendorFeature/Resources/Localizable.xcstrings b/Modules/Sources/DeviceVendorFeature/Resources/Localizable.xcstrings new file mode 100644 index 00000000..9b843f40 --- /dev/null +++ b/Modules/Sources/DeviceVendorFeature/Resources/Localizable.xcstrings @@ -0,0 +1,9 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "Vendor" : { + + } + }, + "version" : "1.1" +} \ No newline at end of file diff --git a/Project.swift b/Project.swift index 702677ea..1372ccb5 100644 --- a/Project.swift +++ b/Project.swift @@ -42,6 +42,7 @@ let project = Project( .Internal.DeeplinkHandler, .Internal.DeveloperFeature, .Internal.DeviceSpecificationsFeature, + .Internal.DeviceVendorFeature, .Internal.FavoritesFeature, .Internal.FavoritesRootFeature, .Internal.ForumFeature, @@ -214,6 +215,17 @@ let project = Project( .SPM.TCA ] ), + + .feature( + name: "DeviceVendorFeature", + dependencies: [ + .Internal.APIClient, + .Internal.Models, + .Internal.SharedUI, + .Internal.ToastClient, + .SPM.TCA + ] + ), .feature( name: "FavoritesFeature", @@ -1043,6 +1055,7 @@ extension TargetDependency.Internal { static let DeeplinkHandler = TargetDependency.target(name: "DeeplinkHandler") static let DeveloperFeature = TargetDependency.target(name: "DeveloperFeature") static let DeviceSpecificationsFeature = TargetDependency.target(name: "DeviceSpecificationsFeature") + static let DeviceVendorFeature = TargetDependency.target(name: "DeviceVendorFeature") static let FavoritesFeature = TargetDependency.target(name: "FavoritesFeature") static let FavoritesRootFeature = TargetDependency.target(name: "FavoritesRootFeature") static let FormFeature = TargetDependency.target(name: "FormFeature") From aa7d7fa0461929ca93ce2ebe9103bec2745f38bb Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 3 Apr 2026 11:24:25 +0300 Subject: [PATCH 02/24] Add device vendor endpoint --- Modules/Sources/APIClient/APIClient.swift | 12 ++ .../Extensions/DeviceType+Extension.swift | 20 +++ Modules/Sources/Models/DevDB/DeviceType.swift | 1 - .../Sources/Models/DevDB/DeviceVendor.swift | 119 ++++++++++++++++++ .../ParsingClient/Parsers/DevDBParser.swift | 78 +++++++++++- .../Sources/ParsingClient/ParsingClient.swift | 4 + 6 files changed, 229 insertions(+), 5 deletions(-) create mode 100644 Modules/Sources/APIClient/Models/Extensions/DeviceType+Extension.swift create mode 100644 Modules/Sources/Models/DevDB/DeviceVendor.swift diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index b717b3e5..281c0811 100644 --- a/Modules/Sources/APIClient/APIClient.swift +++ b/Modules/Sources/APIClient/APIClient.swift @@ -93,6 +93,7 @@ public struct APIClient: Sendable { public var searchUsers: @Sendable (_ request: SearchUsersRequest) async throws -> SearchUsersResponse // DevDB + public var deviceVendor: @Sendable (_ name: String, _ type: DeviceType) async throws -> DeviceVendor public var deviceSpecifications: @Sendable (_ tag: String, _ subTag: String) async throws -> DeviceSpecifications // STREAMS @@ -568,6 +569,14 @@ extension APIClient: DependencyKey { // MARK: - Device Specs + deviceVendor: { name, type in + let command = DeviceCommand.vendor( + typeCode: type.transferType, + vendorCode: name + ) + let response = try await api.send(command) + return try await parser.parseDeviceVendor(response) + }, deviceSpecifications: { tag, subTag in let command = DeviceCommand.entry(tag: tag, subTag: subTag) let response = try await api.send(command) @@ -734,6 +743,9 @@ extension APIClient: DependencyKey { searchUsers: { _ in return .mock }, + deviceVendor: { _, _ in + return .mock + }, deviceSpecifications: { _, _ in return .mock }, diff --git a/Modules/Sources/APIClient/Models/Extensions/DeviceType+Extension.swift b/Modules/Sources/APIClient/Models/Extensions/DeviceType+Extension.swift new file mode 100644 index 00000000..819d6ec5 --- /dev/null +++ b/Modules/Sources/APIClient/Models/Extensions/DeviceType+Extension.swift @@ -0,0 +1,20 @@ +// +// DeviceType+Extension.swift +// ForPDA +// +// Created by Xialtal on 2.04.26. +// + +import PDAPI +import Models + +extension DeviceType { + var transferType: DeviceCommand.DeviceType { + switch self { + case .phone: .phone + case .ebook: .ebook + case .pad: .pad + case .smartWatch: .smartWatch + } + } +} diff --git a/Modules/Sources/Models/DevDB/DeviceType.swift b/Modules/Sources/Models/DevDB/DeviceType.swift index dd1ce62b..e0271602 100644 --- a/Modules/Sources/Models/DevDB/DeviceType.swift +++ b/Modules/Sources/Models/DevDB/DeviceType.swift @@ -10,5 +10,4 @@ public enum DeviceType: String, Sendable { case ebook = "ebook" case pad = "pad" case smartWatch = "smartwatch" - case unknown } diff --git a/Modules/Sources/Models/DevDB/DeviceVendor.swift b/Modules/Sources/Models/DevDB/DeviceVendor.swift new file mode 100644 index 00000000..a8f3d7e4 --- /dev/null +++ b/Modules/Sources/Models/DevDB/DeviceVendor.swift @@ -0,0 +1,119 @@ +// +// DeviceVendor.swift +// ForPDA +// +// Created by Xialtal on 2.04.26. +// + +import Foundation + +public struct DeviceVendor: Sendable { + public let type: DeviceType + public let name: String + public let code: String + public let categoryName: String + public let products: [Product] + + public struct Product: Sendable, Identifiable { + public let tag: String + public let name: String + public let imageUrl: URL + public var entries: [Entry] + public let isActual: Bool + + public var id: String { + return tag + } + + public struct Entry: Sendable { + public let name: String + public let value: String + + public init(name: String, value: String) { + self.name = name + self.value = value + } + } + + public init( + tag: String, + name: String, + imageUrl: URL, + entries: [Entry], + isActual: Bool + ) { + self.tag = tag + self.name = name + self.imageUrl = imageUrl + self.entries = entries + self.isActual = isActual + } + } + + public init( + type: DeviceType, + name: String, + code: String, + categoryName: String, + products: [Product] + ) { + self.type = type + self.name = name + self.code = code + self.categoryName = categoryName + self.products = products + } +} + +public extension DeviceVendor { + static let mock = DeviceVendor( + type: .phone, + name: "Apple", + code: "apple", + categoryName: "Смартфоны", + products: [ + .init( + tag: "apple_iphone_16e", + name: "iPhone 16e", + imageUrl: URL(string: "https://4pda.to/static/img/db/img6826433f673aa4.16450237p.jpg")!, + entries: [ + .init(name: "ОС:", value: "iOS 18"), + .init(name: "Процессор:", value: "Apple A18"), + .init(name: "Память:", value: "128/256/512 ГБ."), + .init(name: "Экран:", value: "Super Retina XDR OLED"), + .init(name: "Размер:", value: "6,1\" дюймов"), + .init(name: "Год выпуска:", value: "2025") + ], + isActual: true + ), + .init( + tag: "apple_iphone_17_pro", + name: "iPhone 17 Pro", + imageUrl: URL(string: "https://4pda.to/static/img/db/img68e84609640ae6.18217003p.jpg")!, + entries: [ + .init(name: "ОС:", value: "iOS 26"), + .init(name: "Процессор:", value: "Apple A19 Pro"), + .init(name: "Память:", value: "256/512/1024 ГБ."), + .init(name: "Экран:", value: "LTPO Super Retina XDR OLED"), + .init(name: "Размер:", value: "6,3\" дюймов"), + .init(name: "Год выпуска:", value: "2025") + ], + isActual: true + ), + .init( + tag: "apple_iphone_16", + name: "iPhone 16", + imageUrl: URL(string: "https://4pda.to/static/img/db/img673506cf586340.68404184p.jpg")!, + entries: [ + .init(name: "ОС:", value: "iOS 18"), + .init(name: "Процессор:", value: "Apple A18"), + .init(name: "Память:", value: "128/256/512/1024 ГБ."), + .init(name: "Экран:", value: "Super Retina XDR OLED"), + .init(name: "Размер:", value: "6,1\" дюймов"), + .init(name: "Год выпуска:", value: "2024") + ], + isActual: false + ), + ] + ) +} diff --git a/Modules/Sources/ParsingClient/Parsers/DevDBParser.swift b/Modules/Sources/ParsingClient/Parsers/DevDBParser.swift index ebc43cd1..83e91f9b 100644 --- a/Modules/Sources/ParsingClient/Parsers/DevDBParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/DevDBParser.swift @@ -36,7 +36,7 @@ public struct DevDBParser { return DeviceSpecifications( tag: tag, - type: DeviceType(rawValue: type) ?? .unknown, + type: DeviceType(rawValue: type)!, vendorName: vendorName, deviceName: deviceName, editionName: editionName, @@ -48,7 +48,77 @@ public struct DevDBParser { ) } - // MARK: - Images + // MARK: - Device Vendor Response + + public static func parseDeviceVendor(from string: String) throws(ParsingError) -> DeviceVendor { + 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 type = array[safe: 2] as? String, + let categoryName = array[safe: 3] as? String, + let name = array[safe: 4] as? String, + let code = array[safe: 5] as? String, + let productsRaw = array[safe: 8] as? [[Any]], + let imagesRaw = array[safe: 9] as? [[Any]], + let specsRaw = array[safe: 10] as? [[Any]], + let isMyDevice = array[safe: 11] as? Int else { + throw ParsingError.failedToCastFields + } + + return DeviceVendor( + type: DeviceType(rawValue: type)!, + name: name, + code: code, + categoryName: categoryName, + products: try parseVendorProducts(productsRaw) + ) + } + + // MARK: - Vendor Products + + private static func parseVendorProducts(_ productsRaw: [[Any]]) throws(ParsingError) -> [DeviceVendor.Product] { + var products: [DeviceVendor.Product] = [] + for product in productsRaw { + guard let tag = product[safe: 0] as? String, + let name = product[safe: 1] as? String, + let url = product[safe: 2] as? String, + let isActual = product[safe: 3] as? Int, + let entriesRaw = product[4] as? [[Any]] else { + throw ParsingError.failedToCastFields + } + + products.append(.init( + tag: tag, + name: name, + imageUrl: URL(string: url)!, + entries: try parseVendorProductEntry(entriesRaw), + isActual: isActual != 0 + )) + } + return products + } + + // MARK: - Vendor Product Entry + + private static func parseVendorProductEntry(_ entriesRaw: [[Any]]) throws(ParsingError) -> [DeviceVendor.Product.Entry] { + var entries: [DeviceVendor.Product.Entry] = [] + for entry in entriesRaw { + guard let name = entry[safe: 2] as? String, + let value = entry[safe: 4] as? String else { + throw ParsingError.failedToCastFields + } + + entries.append(.init(name: name, value: value)) + } + return entries + } + + // MARK: - Specification Images private static func parseDeviceImages(_ imagesRaw: [[Any]]) throws(ParsingError) -> [DeviceSpecifications.DeviceImage] { var images: [DeviceSpecifications.DeviceImage] = [] @@ -68,7 +138,7 @@ public struct DevDBParser { return images } - // MARK: - Editions + // MARK: - Specification Editions private static func parseDeviceEditions(_ editionsRaw: [[Any]]) throws(ParsingError) -> [DeviceSpecifications.Edition] { var editions: [DeviceSpecifications.Edition] = [] @@ -83,7 +153,7 @@ public struct DevDBParser { return editions } - // MARK: - Specifications + // MARK: - Device Specifications private static func parseDeviceSpecifications(_ specsRaw: [[Any]]) throws(ParsingError) -> [DeviceSpecifications.Specification] { var specs: [DeviceSpecifications.Specification] = [] diff --git a/Modules/Sources/ParsingClient/ParsingClient.swift b/Modules/Sources/ParsingClient/ParsingClient.swift index b1c1bfe9..d7752cef 100644 --- a/Modules/Sources/ParsingClient/ParsingClient.swift +++ b/Modules/Sources/ParsingClient/ParsingClient.swift @@ -62,6 +62,7 @@ public struct ParsingClient: Sendable { public var parseQmsChat: @Sendable (_ response: String) async throws -> QMSChat // DevDB + public var parseDeviceVendor: @Sendable (_ response: String) async throws -> DeviceVendor public var parseDeviceSpecifications: @Sendable (_ response: String) async throws -> DeviceSpecifications } @@ -162,6 +163,9 @@ extension ParsingClient: DependencyKey { parseQmsChat: { response in return try QMSChatParser.parse(from: response) }, + parseDeviceVendor: { response in + return try DevDBParser.parseDeviceVendor(from: response) + }, parseDeviceSpecifications: { response in return try DevDBParser.parse(from: response) } From 2b0be4f6566e881283d9413215185e40fbee7495 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 3 Apr 2026 12:20:05 +0300 Subject: [PATCH 03/24] [WIP] Device Vendor --- .../DeviceVendorFeature.swift | 46 ++++++ .../DeviceVendorScreen.swift | 140 +++++++++++++++++- .../Sources/Models/DevDB/DeviceVendor.swift | 10 +- 3 files changed, 192 insertions(+), 4 deletions(-) diff --git a/Modules/Sources/DeviceVendorFeature/DeviceVendorFeature.swift b/Modules/Sources/DeviceVendorFeature/DeviceVendorFeature.swift index 94e6c7a1..435c05e1 100644 --- a/Modules/Sources/DeviceVendorFeature/DeviceVendorFeature.swift +++ b/Modules/Sources/DeviceVendorFeature/DeviceVendorFeature.swift @@ -9,12 +9,20 @@ import Foundation import ComposableArchitecture import APIClient import Models +import ToastClient @Reducer public struct DeviceVendorFeature: Reducer, Sendable { public init() {} + // MARK: - Category + + public enum CategorySelection: Int, Equatable { + case all + case actual + } + // MARK: - State @ObservableState @@ -22,6 +30,11 @@ public struct DeviceVendorFeature: Reducer, Sendable { public let type: DeviceType public let vendorName: String + var vendor: DeviceVendor? + var categorySelection: CategorySelection = .actual + + var isLoading = false + public init( type: DeviceType, vendorName: String @@ -37,6 +50,13 @@ public struct DeviceVendorFeature: Reducer, Sendable { case view(View) public enum View { case onAppear + case changeCategoryButtonTapped(CategorySelection) + } + + case `internal`(Internal) + public enum Internal { + case loadVendor + case vendorResponse(Result) } } @@ -44,6 +64,7 @@ public struct DeviceVendorFeature: Reducer, Sendable { @Dependency(\.apiClient) private var apiClient @Dependency(\.openURL) var openURL + @Dependency(\.toastClient) var toastClient // MARK: - Body @@ -51,7 +72,32 @@ public struct DeviceVendorFeature: Reducer, Sendable { Reduce { state, action in switch action { case .view(.onAppear): + return .send(.internal(.loadVendor)) + + case let .view(.changeCategoryButtonTapped(category)): + state.categorySelection = category + return .none + + case .internal(.loadVendor): + state.isLoading = true + return .run { [name = state.vendorName, type = state.type] send in + let response = try await apiClient.deviceVendor(name: name, type: type) + await send(.internal(.vendorResponse(.success(response)))) + } catch: { error, send in + await send(.internal(.vendorResponse(.failure(error)))) + } + + case let .internal(.vendorResponse(.success(response))): + state.vendor = response + state.isLoading = false return .none + + case let .internal(.vendorResponse(.failure(error))): + print(error) + state.isLoading = false + return .run { _ in + await toastClient.showToast(.whoopsSomethingWentWrong) + } } } } diff --git a/Modules/Sources/DeviceVendorFeature/DeviceVendorScreen.swift b/Modules/Sources/DeviceVendorFeature/DeviceVendorScreen.swift index c3ca1a1d..50824ab2 100644 --- a/Modules/Sources/DeviceVendorFeature/DeviceVendorScreen.swift +++ b/Modules/Sources/DeviceVendorFeature/DeviceVendorScreen.swift @@ -9,6 +9,7 @@ import SwiftUI import ComposableArchitecture import Models import SharedUI +import NukeUI @ViewAction(for: DeviceVendorFeature.self) public struct DeviceVendorScreen: View { @@ -23,14 +24,151 @@ public struct DeviceVendorScreen: View { public var body: some View { WithPerceptionTracking { ScrollView { - Text("Vendor") + if let vendor = store.vendor { + VStack(spacing: 8) { + HStack(spacing: 12) { + InformationRow(title: "Actual", content: String(vendor.actualCount)) + + InformationRow(title: "All", content: String(vendor.products.count)) + } + + ChangeCategoryButton() + } + .padding(16) + + Products(vendor.products) + } } + .navigationTitle(Text(navigationTitleText())) .background(Color(.Background.primary)) + .overlay { + if store.isLoading { + PDALoader() + .frame(width: 24, height: 24) + } + } .onAppear { send(.onAppear) } } } + + // MARK: - Products + + @ViewBuilder + private func Products(_ products: [DeviceVendor.Product]) -> some View { + ForEach(products) { product in + if store.categorySelection == .all { + ProductRow(product) + } else if store.categorySelection == .actual, product.isActual { + ProductRow(product) + } + } + .padding(.horizontal, 16) + } + + @ViewBuilder + private func ProductRow(_ product: DeviceVendor.Product) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text(verbatim: product.name) + .font(.title2) + .fontWeight(.bold) + .foregroundStyle(Color(.Labels.primary)) + + HStack(spacing: 16) { + LazyImage(url: product.imageUrl) { state in + Group { + if let image = state.image { + image.resizable().scaledToFill() + } else { + Color(.systemBackground) + } + } + .skeleton(with: state.isLoading, shape: .rectangle) + } + .padding(.top, 16) + .frame(width: 74, height: 74) + .frame(maxHeight: .infinity, alignment: .top) + + ProductSpecifications(product.entries) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 19) + .background( + Color(.Background.teritary) + .clipShape(RoundedRectangle(cornerRadius: 10)) + ) + } + + @ViewBuilder + private func ProductSpecifications(_ specifications: [DeviceVendor.Product.Entry]) -> some View { + VStack(spacing: 6) { + ForEach(specifications, id: \.name) { specification in + HStack { + Text(verbatim: specification.name) + .foregroundStyle(Color(.Labels.teritary)) + + Spacer() + + Text(verbatim: specification.value) + .foregroundStyle(Color(.Labels.primary)) + } + .font(.subheadline) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + + // MARK: - Change Category Button + + @ViewBuilder + private func ChangeCategoryButton() -> some View { + Button { + send(.changeCategoryButtonTapped(store.categorySelection == .all ? .actual : .all)) + } label: { + Text(store.categorySelection == .all ? "Show actual" : "Show all", bundle: .module) + .padding(6) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .tint(tintColor) + .frame(height: 48) + .background(Color(.Background.primary)) + .animation(.default, value: store.categorySelection) + } + + // MARK: - Information Row + + @ViewBuilder + private func InformationRow(title: LocalizedStringKey, content: String) -> some View { + VStack { + Text(title, bundle: .module) + .font(.footnote) + .foregroundStyle(Color(.Labels.teritary)) + + Text(verbatim: content) + .font(.body) + .foregroundStyle(Color(.Labels.primary)) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(12) + .background( + Color(.Background.teritary) + .clipShape(RoundedRectangle(cornerRadius: 10)) + ) + } + + // MARK: - Helpers + + private func navigationTitleText() -> String { + return if let vendor = store.vendor { + "\(vendor.name) (\(vendor.categoryName))" + } else { + String(localized: "Loading...", bundle: .module) + } + } } // MARK: - Previews diff --git a/Modules/Sources/Models/DevDB/DeviceVendor.swift b/Modules/Sources/Models/DevDB/DeviceVendor.swift index a8f3d7e4..8b03fad9 100644 --- a/Modules/Sources/Models/DevDB/DeviceVendor.swift +++ b/Modules/Sources/Models/DevDB/DeviceVendor.swift @@ -7,14 +7,18 @@ import Foundation -public struct DeviceVendor: Sendable { +public struct DeviceVendor: Sendable, Equatable { public let type: DeviceType public let name: String public let code: String public let categoryName: String public let products: [Product] - public struct Product: Sendable, Identifiable { + public var actualCount: Int { + return products.count(where: { $0.isActual }) + } + + public struct Product: Sendable, Identifiable, Equatable { public let tag: String public let name: String public let imageUrl: URL @@ -25,7 +29,7 @@ public struct DeviceVendor: Sendable { return tag } - public struct Entry: Sendable { + public struct Entry: Sendable, Equatable { public let name: String public let value: String From 9e806499d3b6baace3aa328f6af859bd7ddc2bfd Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 3 Apr 2026 12:29:33 +0300 Subject: [PATCH 04/24] Add ability to open device from vendor screen --- Modules/Sources/AppFeature/Navigation/Path.swift | 2 ++ .../Sources/AppFeature/Navigation/StackTab.swift | 4 ++++ .../DeviceVendorFeature/DeviceVendorFeature.swift | 12 ++++++++++++ .../DeviceVendorFeature/DeviceVendorScreen.swift | 13 +++++++++---- 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/Modules/Sources/AppFeature/Navigation/Path.swift b/Modules/Sources/AppFeature/Navigation/Path.swift index 50637f26..3659d496 100644 --- a/Modules/Sources/AppFeature/Navigation/Path.swift +++ b/Modules/Sources/AppFeature/Navigation/Path.swift @@ -12,6 +12,7 @@ import ArticleFeature import ArticlesListFeature import DeveloperFeature import DeviceSpecificationsFeature +import DeviceVendorFeature import FavoritesRootFeature import FavoritesFeature import ForumFeature @@ -49,6 +50,7 @@ public enum Path { @Reducer public enum DevDB { + case vendor(DeviceVendorFeature) case specifications(DeviceSpecificationsFeature) } diff --git a/Modules/Sources/AppFeature/Navigation/StackTab.swift b/Modules/Sources/AppFeature/Navigation/StackTab.swift index d00b19fb..0e3ce8a4 100644 --- a/Modules/Sources/AppFeature/Navigation/StackTab.swift +++ b/Modules/Sources/AppFeature/Navigation/StackTab.swift @@ -33,6 +33,7 @@ import AuthFeature import SearchFeature import SearchResultFeature import DeviceSpecificationsFeature +import DeviceVendorFeature @Reducer public struct StackTab: Reducer, Sendable { @@ -192,6 +193,9 @@ public struct StackTab: Reducer, Sendable { private func handleDevDBPathNavigation(action: Path.DevDB.Action, state: inout State) -> Effect { switch action { + case let .vendor(.delegate(.openDevice(tag))): + state.path.append(.devDB(.specifications(DeviceSpecificationsFeature.State(tag: tag, subTag: nil)))) + case let .specifications(.delegate(.openDevice(tag, subTag))): state.path.append(.devDB(.specifications(DeviceSpecificationsFeature.State(tag: tag, subTag: subTag)))) diff --git a/Modules/Sources/DeviceVendorFeature/DeviceVendorFeature.swift b/Modules/Sources/DeviceVendorFeature/DeviceVendorFeature.swift index 435c05e1..75a9dabc 100644 --- a/Modules/Sources/DeviceVendorFeature/DeviceVendorFeature.swift +++ b/Modules/Sources/DeviceVendorFeature/DeviceVendorFeature.swift @@ -50,6 +50,7 @@ public struct DeviceVendorFeature: Reducer, Sendable { case view(View) public enum View { case onAppear + case productButtonTapped(String) case changeCategoryButtonTapped(CategorySelection) } @@ -58,6 +59,11 @@ public struct DeviceVendorFeature: Reducer, Sendable { case loadVendor case vendorResponse(Result) } + + case delegate(Delegate) + public enum Delegate { + case openDevice(tag: String) + } } // MARK: - Dependencies @@ -74,6 +80,9 @@ public struct DeviceVendorFeature: Reducer, Sendable { case .view(.onAppear): return .send(.internal(.loadVendor)) + case let .view(.productButtonTapped(tag)): + return .send(.delegate(.deviceTapped(tag: tag))) + case let .view(.changeCategoryButtonTapped(category)): state.categorySelection = category return .none @@ -98,6 +107,9 @@ public struct DeviceVendorFeature: Reducer, Sendable { return .run { _ in await toastClient.showToast(.whoopsSomethingWentWrong) } + + case .delegate: + return .none } } } diff --git a/Modules/Sources/DeviceVendorFeature/DeviceVendorScreen.swift b/Modules/Sources/DeviceVendorFeature/DeviceVendorScreen.swift index 50824ab2..4867f632 100644 --- a/Modules/Sources/DeviceVendorFeature/DeviceVendorScreen.swift +++ b/Modules/Sources/DeviceVendorFeature/DeviceVendorScreen.swift @@ -70,10 +70,15 @@ public struct DeviceVendorScreen: View { @ViewBuilder private func ProductRow(_ product: DeviceVendor.Product) -> some View { VStack(alignment: .leading, spacing: 12) { - Text(verbatim: product.name) - .font(.title2) - .fontWeight(.bold) - .foregroundStyle(Color(.Labels.primary)) + Button { + send(.productButtonTapped(product.tag)) + } label: { + Text(verbatim: product.name) + .font(.title2) + .fontWeight(.bold) + .foregroundStyle(Color(.Labels.primary)) + } + .buttonStyle(.plain) HStack(spacing: 16) { LazyImage(url: product.imageUrl) { state in From 9ae2ee9a47b517d0e376f43c9b890a06904e3e18 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 3 Apr 2026 12:40:50 +0300 Subject: [PATCH 05/24] Fix build --- Modules/Sources/AppFeature/Navigation/Path.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Modules/Sources/AppFeature/Navigation/Path.swift b/Modules/Sources/AppFeature/Navigation/Path.swift index 3659d496..5e14d63f 100644 --- a/Modules/Sources/AppFeature/Navigation/Path.swift +++ b/Modules/Sources/AppFeature/Navigation/Path.swift @@ -151,6 +151,10 @@ extension Path { @MainActor @ViewBuilder private static func DevDBViews(_ store: Store) -> some View { switch store.case { + case let .vendor(store): + DeviceVendorScreen(store: store) + .tracking(for: DeviceVendorScreen.self) + case let .specifications(store): DeviceSpecificationsScreen(store: store) .tracking(for: DeviceSpecificationsScreen.self) From b0432d27576a9bf74b2287155dcbc5280c6aea42 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 3 Apr 2026 13:08:59 +0300 Subject: [PATCH 06/24] Fix device vendor parser --- Modules/Sources/ParsingClient/Parsers/DevDBParser.swift | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/Modules/Sources/ParsingClient/Parsers/DevDBParser.swift b/Modules/Sources/ParsingClient/Parsers/DevDBParser.swift index 83e91f9b..6c3e683b 100644 --- a/Modules/Sources/ParsingClient/Parsers/DevDBParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/DevDBParser.swift @@ -61,12 +61,9 @@ public struct DevDBParser { guard let type = array[safe: 2] as? String, let categoryName = array[safe: 3] as? String, - let name = array[safe: 4] as? String, - let code = array[safe: 5] as? String, - let productsRaw = array[safe: 8] as? [[Any]], - let imagesRaw = array[safe: 9] as? [[Any]], - let specsRaw = array[safe: 10] as? [[Any]], - let isMyDevice = array[safe: 11] as? Int else { + let name = array[safe: 5] as? String, + let code = array[safe: 4] as? String, + let productsRaw = array[safe: 6] as? [[Any]] else { throw ParsingError.failedToCastFields } From 11bc01ac73f7c9f1cc46416569ce030c654e5102 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 3 Apr 2026 13:10:12 +0300 Subject: [PATCH 07/24] Improve localizable --- .../Resources/Localizable.xcstrings | 51 ++++++++++++++++++- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/Modules/Sources/DeviceVendorFeature/Resources/Localizable.xcstrings b/Modules/Sources/DeviceVendorFeature/Resources/Localizable.xcstrings index 9b843f40..1b1a03d0 100644 --- a/Modules/Sources/DeviceVendorFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/DeviceVendorFeature/Resources/Localizable.xcstrings @@ -1,8 +1,55 @@ { "sourceLanguage" : "en", "strings" : { - "Vendor" : { - + "Actual" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Актуальные" + } + } + } + }, + "All" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Всего" + } + } + } + }, + "Loading..." : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузка…" + } + } + } + }, + "Show actual" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать актуальные" + } + } + } + }, + "Show all" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать все" + } + } + } } }, "version" : "1.1" From 607e116bb7af97eace09e2dcbe84579337e206e5 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 3 Apr 2026 13:22:42 +0300 Subject: [PATCH 08/24] Fix perception warning --- .../DeviceVendorScreen.swift | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/Modules/Sources/DeviceVendorFeature/DeviceVendorScreen.swift b/Modules/Sources/DeviceVendorFeature/DeviceVendorScreen.swift index 4867f632..6a992500 100644 --- a/Modules/Sources/DeviceVendorFeature/DeviceVendorScreen.swift +++ b/Modules/Sources/DeviceVendorFeature/DeviceVendorScreen.swift @@ -58,13 +58,15 @@ public struct DeviceVendorScreen: View { @ViewBuilder private func Products(_ products: [DeviceVendor.Product]) -> some View { ForEach(products) { product in - if store.categorySelection == .all { - ProductRow(product) - } else if store.categorySelection == .actual, product.isActual { - ProductRow(product) + WithPerceptionTracking { + if store.categorySelection == .all { + ProductRow(product) + } else if store.categorySelection == .actual, product.isActual { + ProductRow(product) + } } + .padding(.horizontal, 16) } - .padding(.horizontal, 16) } @ViewBuilder @@ -130,18 +132,20 @@ public struct DeviceVendorScreen: View { @ViewBuilder private func ChangeCategoryButton() -> some View { - Button { - send(.changeCategoryButtonTapped(store.categorySelection == .all ? .actual : .all)) - } label: { - Text(store.categorySelection == .all ? "Show actual" : "Show all", bundle: .module) - .padding(6) - .frame(maxWidth: .infinity) + WithPerceptionTracking { + Button { + send(.changeCategoryButtonTapped(store.categorySelection == .all ? .actual : .all)) + } label: { + Text(store.categorySelection == .all ? "Show actual" : "Show all", bundle: .module) + .padding(6) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .tint(tintColor) + .frame(height: 48) + .background(Color(.Background.primary)) + .animation(.default, value: store.categorySelection) } - .buttonStyle(.bordered) - .tint(tintColor) - .frame(height: 48) - .background(Color(.Background.primary)) - .animation(.default, value: store.categorySelection) } // MARK: - Information Row From 3e99209788279f35e19e93b75d683dbed7bc7a04 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 3 Apr 2026 13:29:58 +0300 Subject: [PATCH 09/24] Add device vendor deeplink support --- Modules/Sources/AppFeature/AppFeature.swift | 14 ++++++++++++-- .../Sources/AppFeature/Navigation/StackTab.swift | 11 +++++++++-- .../Sources/DeeplinkHandler/DeeplinkHandler.swift | 11 ++++++++--- .../DeviceVendorFeature/DeviceVendorFeature.swift | 2 +- Modules/Sources/Models/DevDB/DeviceGoTo.swift | 13 +++++++++++++ 5 files changed, 43 insertions(+), 8 deletions(-) create mode 100644 Modules/Sources/Models/DevDB/DeviceGoTo.swift diff --git a/Modules/Sources/AppFeature/AppFeature.swift b/Modules/Sources/AppFeature/AppFeature.swift index 08a484f5..fe3ceaa1 100644 --- a/Modules/Sources/AppFeature/AppFeature.swift +++ b/Modules/Sources/AppFeature/AppFeature.swift @@ -37,6 +37,7 @@ import Combine import SearchResultFeature import CacheClient import DeviceSpecificationsFeature +import DeviceVendorFeature @Reducer public struct AppFeature: Reducer, Sendable { @@ -638,8 +639,17 @@ public struct AppFeature: Reducer, Sendable { screen = .articles(.article(ArticleFeature.State(articlePreview: preview, scrollToId: scrollToId))) case let .announcement(id): screen = .forum(.announcement(AnnouncementFeature.State(id: id))) - case let .device(tag, subTag): - screen = .devDB(.specifications(DeviceSpecificationsFeature.State(tag: tag, subTag: subTag))) + case let .device(type): + switch type { + case .index: + return .none + case .type(let type): + return .none + case .vendor(let vendorName, let type): + screen = .devDB(.vendor(DeviceVendorFeature.State(type: type, vendorName: vendorName))) + case .device(let tag, let subTag): + screen = .devDB(.specifications(DeviceSpecificationsFeature.State(tag: tag, subTag: subTag))) + } case let .topic(id, goTo): screen = .forum(.topic(TopicFeature.State(topicId: id!, goTo: goTo))) case let .forum(id, page): diff --git a/Modules/Sources/AppFeature/Navigation/StackTab.swift b/Modules/Sources/AppFeature/Navigation/StackTab.swift index 0e3ce8a4..996a997f 100644 --- a/Modules/Sources/AppFeature/Navigation/StackTab.swift +++ b/Modules/Sources/AppFeature/Navigation/StackTab.swift @@ -484,8 +484,15 @@ public struct StackTab: Reducer, Sendable { case let .search(options: options): state.path.append(.search(.searchResult(SearchResultFeature.State(search: options)))) - case let .device(tag, subTag): - state.path.append(.devDB(.specifications(DeviceSpecificationsFeature.State(tag: tag, subTag: subTag)))) + case let .device(goTo): + switch goTo { + case .index: break + case .type(let type): break + case .vendor(let vendorName, let type): + state.path.append(.devDB(.vendor(DeviceVendorFeature.State(type: type, vendorName: vendorName)))) + case .device(let tag, let subTag): + state.path.append(.devDB(.specifications(DeviceSpecificationsFeature.State(tag: tag, subTag: subTag)))) + } case let .article(id: id, title: title, imageUrl: imageUrl, scrollToId): let preview = ArticlePreview.outerDeeplink(id: id, imageUrl: imageUrl, title: title) diff --git a/Modules/Sources/DeeplinkHandler/DeeplinkHandler.swift b/Modules/Sources/DeeplinkHandler/DeeplinkHandler.swift index 479926b6..6018f025 100644 --- a/Modules/Sources/DeeplinkHandler/DeeplinkHandler.swift +++ b/Modules/Sources/DeeplinkHandler/DeeplinkHandler.swift @@ -19,7 +19,7 @@ public enum Deeplink { case user(id: Int) case qms(id: Int) case search(SearchResult) - case device(tag: String, subTag: String) + case device(DeviceGoTo) } public struct DeeplinkHandler { @@ -129,7 +129,12 @@ public struct DeeplinkHandler { if url.pathComponents.contains("devdb") { if url.pathComponents.count == 4 { // /devdb/phones/apple - // TODO: vendor deeplink + guard let type = DeviceType(rawValue: String(url.pathComponents[2])) else { + throw .unknownType(type: "DeviceType", for: url.absoluteString) + } + let tag = String(url.pathComponents[3]) + + return .device(.vendor(tag: tag, type: type)) } else if url.pathComponents.count == 3, !url.pathComponents[2].isEmpty { if let _ = DeviceType(rawValue: url.pathComponents[2]) { // /devdb/phones // TODO: deviceType deeplink @@ -137,7 +142,7 @@ public struct DeeplinkHandler { let tags = url.pathComponents[2].components(separatedBy: ":") let subTag = tags.first == tags.last ? "" : tags.last! - return .device(tag: tags.first!, subTag: subTag) + return .device(.device(tag: tags.first!, subTag: subTag)) } } } diff --git a/Modules/Sources/DeviceVendorFeature/DeviceVendorFeature.swift b/Modules/Sources/DeviceVendorFeature/DeviceVendorFeature.swift index 75a9dabc..426052d3 100644 --- a/Modules/Sources/DeviceVendorFeature/DeviceVendorFeature.swift +++ b/Modules/Sources/DeviceVendorFeature/DeviceVendorFeature.swift @@ -81,7 +81,7 @@ public struct DeviceVendorFeature: Reducer, Sendable { return .send(.internal(.loadVendor)) case let .view(.productButtonTapped(tag)): - return .send(.delegate(.deviceTapped(tag: tag))) + return .send(.delegate(.openDevice(tag: tag))) case let .view(.changeCategoryButtonTapped(category)): state.categorySelection = category diff --git a/Modules/Sources/Models/DevDB/DeviceGoTo.swift b/Modules/Sources/Models/DevDB/DeviceGoTo.swift new file mode 100644 index 00000000..cd8d17c5 --- /dev/null +++ b/Modules/Sources/Models/DevDB/DeviceGoTo.swift @@ -0,0 +1,13 @@ +// +// DeviceGoTo.swift +// ForPDA +// +// Created by Xialtal on 3.04.26. +// + +public enum DeviceGoTo { + case index + case type(DeviceType) + case vendor(tag: String, type: DeviceType) + case device(tag: String, subTag: String?) +} From c58f3bcbeefaa0d85a025f9d5d09ac61176b81fe Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 3 Apr 2026 20:02:23 +0300 Subject: [PATCH 10/24] [WIP] DevDB --- Modules/Sources/AppFeature/AppFeature.swift | 8 +- .../AppFeature/Navigation/StackTab.swift | 8 +- .../DeviceVendorFeature.swift | 50 +++-- .../DeviceVendorScreen.swift | 180 +++++++++++++++--- .../Models/DeviceTypeContent.swift | 14 ++ .../Extensions/DeviceType+Extension.swift | 39 ++++ .../Resources/Localizable.xcstrings | 53 ++++++ .../Sources/Models/DevDB/DeviceBrands.swift | 10 + Modules/Sources/Models/DevDB/DeviceGoTo.swift | 2 +- Modules/Sources/Models/DevDB/DeviceType.swift | 6 +- 10 files changed, 325 insertions(+), 45 deletions(-) create mode 100644 Modules/Sources/DeviceVendorFeature/Models/DeviceTypeContent.swift create mode 100644 Modules/Sources/DeviceVendorFeature/Models/Extensions/DeviceType+Extension.swift create mode 100644 Modules/Sources/Models/DevDB/DeviceBrands.swift diff --git a/Modules/Sources/AppFeature/AppFeature.swift b/Modules/Sources/AppFeature/AppFeature.swift index fe3ceaa1..5ac2e6c0 100644 --- a/Modules/Sources/AppFeature/AppFeature.swift +++ b/Modules/Sources/AppFeature/AppFeature.swift @@ -642,11 +642,11 @@ public struct AppFeature: Reducer, Sendable { case let .device(type): switch type { case .index: - return .none - case .type(let type): - return .none + screen = .devDB(.vendor(DeviceVendorFeature.State(content: .index))) + case .brands(let type): + screen = .devDB(.vendor(DeviceVendorFeature.State(content: .brands(type)))) case .vendor(let vendorName, let type): - screen = .devDB(.vendor(DeviceVendorFeature.State(type: type, vendorName: vendorName))) + screen = .devDB(.vendor(DeviceVendorFeature.State(content: .vendor(vendorName, type: type)))) case .device(let tag, let subTag): screen = .devDB(.specifications(DeviceSpecificationsFeature.State(tag: tag, subTag: subTag))) } diff --git a/Modules/Sources/AppFeature/Navigation/StackTab.swift b/Modules/Sources/AppFeature/Navigation/StackTab.swift index 996a997f..88326935 100644 --- a/Modules/Sources/AppFeature/Navigation/StackTab.swift +++ b/Modules/Sources/AppFeature/Navigation/StackTab.swift @@ -486,10 +486,12 @@ public struct StackTab: Reducer, Sendable { case let .device(goTo): switch goTo { - case .index: break - case .type(let type): break + case .index: + state.path.append(.devDB(.vendor(DeviceVendorFeature.State(content: .index)))) + case .brands(let type): + state.path.append(.devDB(.vendor(DeviceVendorFeature.State(content: .brands(type))))) case .vendor(let vendorName, let type): - state.path.append(.devDB(.vendor(DeviceVendorFeature.State(type: type, vendorName: vendorName)))) + state.path.append(.devDB(.vendor(DeviceVendorFeature.State(content: .vendor(vendorName, type: type))))) case .device(let tag, let subTag): state.path.append(.devDB(.specifications(DeviceSpecificationsFeature.State(tag: tag, subTag: subTag)))) } diff --git a/Modules/Sources/DeviceVendorFeature/DeviceVendorFeature.swift b/Modules/Sources/DeviceVendorFeature/DeviceVendorFeature.swift index 426052d3..740966c7 100644 --- a/Modules/Sources/DeviceVendorFeature/DeviceVendorFeature.swift +++ b/Modules/Sources/DeviceVendorFeature/DeviceVendorFeature.swift @@ -27,20 +27,18 @@ public struct DeviceVendorFeature: Reducer, Sendable { @ObservableState public struct State: Equatable { - public let type: DeviceType - public let vendorName: String + public let content: DeviceTypeContent + var brands: DeviceBrands? var vendor: DeviceVendor? - var categorySelection: CategorySelection = .actual + var categorySelection: CategorySelection = .actual var isLoading = false public init( - type: DeviceType, - vendorName: String + content: DeviceTypeContent ) { - self.type = type - self.vendorName = vendorName + self.content = content } } @@ -51,18 +49,25 @@ public struct DeviceVendorFeature: Reducer, Sendable { public enum View { case onAppear case productButtonTapped(String) + case typeButtonTapped(DeviceType) + case vendorButtonTapped(String, DeviceType) case changeCategoryButtonTapped(CategorySelection) } case `internal`(Internal) public enum Internal { - case loadVendor + case loadBrands(DeviceType) + case brandsResponse(Result) + + case loadVendor(String, DeviceType) case vendorResponse(Result) } case delegate(Delegate) public enum Delegate { + case openBrands(DeviceType) case openDevice(tag: String) + case openVendor(String, DeviceType) } } @@ -78,18 +83,41 @@ public struct DeviceVendorFeature: Reducer, Sendable { Reduce { state, action in switch action { case .view(.onAppear): - return .send(.internal(.loadVendor)) + switch state.content { + case .brands(let type): + return .send(.internal(.loadBrands(type))) + case .vendor(let name, let type): + return .send(.internal(.loadVendor(name, type))) + case .index: + break + } + return .none case let .view(.productButtonTapped(tag)): return .send(.delegate(.openDevice(tag: tag))) + case let .view(.typeButtonTapped(type)): + return .send(.delegate(.openBrands(type))) + + case let .view(.vendorButtonTapped(name, type)): + return .send(.delegate(.openVendor(name, type))) + case let .view(.changeCategoryButtonTapped(category)): state.categorySelection = category return .none - case .internal(.loadVendor): + case let .internal(.loadBrands(type)): + return .none + + case let .internal(.brandsResponse(.success(response))): + return .none + + case let .internal(.brandsResponse(.failure(error))): + return .none + + case let .internal(.loadVendor(name, type)): state.isLoading = true - return .run { [name = state.vendorName, type = state.type] send in + return .run { send in let response = try await apiClient.deviceVendor(name: name, type: type) await send(.internal(.vendorResponse(.success(response)))) } catch: { error, send in diff --git a/Modules/Sources/DeviceVendorFeature/DeviceVendorScreen.swift b/Modules/Sources/DeviceVendorFeature/DeviceVendorScreen.swift index 6a992500..c7cb80a7 100644 --- a/Modules/Sources/DeviceVendorFeature/DeviceVendorScreen.swift +++ b/Modules/Sources/DeviceVendorFeature/DeviceVendorScreen.swift @@ -10,6 +10,7 @@ import ComposableArchitecture import Models import SharedUI import NukeUI +import SFSafeSymbols @ViewAction(for: DeviceVendorFeature.self) public struct DeviceVendorScreen: View { @@ -23,21 +24,26 @@ public struct DeviceVendorScreen: View { public var body: some View { WithPerceptionTracking { - ScrollView { - if let vendor = store.vendor { - VStack(spacing: 8) { - HStack(spacing: 12) { - InformationRow(title: "Actual", content: String(vendor.actualCount)) - - InformationRow(title: "All", content: String(vendor.products.count)) + ZStack { + Color(.Background.primary) + .ignoresSafeArea() + + List { + switch store.content { + case .index: + DeviceTypes() + case .brands: + if let brands = store.brands { + //Vendor(vendor) + Text("brands") + } + case .vendor: + if let vendor = store.vendor { + Vendor(vendor) } - - ChangeCategoryButton() } - .padding(16) - - Products(vendor.products) } + .scrollContentBackground(.hidden) } .navigationTitle(Text(navigationTitleText())) .background(Color(.Background.primary)) @@ -53,16 +59,60 @@ public struct DeviceVendorScreen: View { } } + // MARK: - Device Types + + @ViewBuilder + private func DeviceTypes() -> some View { + Section { + ForEach(DeviceType.allCases) { type in + Row(symbol: type.icon, title: type.title, type: .navigation) { + send(.typeButtonTapped(type)) + } + } + } + .listRowBackground(Color(.Background.teritary)) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + } + + // MARK: - Vendor + + @ViewBuilder + private func Vendor(_ vendor: DeviceVendor) -> some View { + Header( + actualCount: vendor.actualCount, + allCount: vendor.products.count + ) + + VendorProducts(vendor.products) + } + + // MARK: - Header + + @ViewBuilder + private func Header(actualCount: Int, allCount: Int) -> some View { + VStack(spacing: 8) { + HStack(spacing: 12) { + InformationRow(title: "Actual", content: String(actualCount)) + + InformationRow(title: "All", content: String(allCount)) + } + + ChangeCategoryButton() + } + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + } + // MARK: - Products @ViewBuilder - private func Products(_ products: [DeviceVendor.Product]) -> some View { + private func VendorProducts(_ products: [DeviceVendor.Product]) -> some View { ForEach(products) { product in WithPerceptionTracking { if store.categorySelection == .all { - ProductRow(product) + VendorProductRow(product) } else if store.categorySelection == .actual, product.isActual { - ProductRow(product) + VendorProductRow(product) } } .padding(.horizontal, 16) @@ -70,7 +120,7 @@ public struct DeviceVendorScreen: View { } @ViewBuilder - private func ProductRow(_ product: DeviceVendor.Product) -> some View { + private func VendorProductRow(_ product: DeviceVendor.Product) -> some View { VStack(alignment: .leading, spacing: 12) { Button { send(.productButtonTapped(product.tag)) @@ -97,7 +147,7 @@ public struct DeviceVendorScreen: View { .frame(width: 74, height: 74) .frame(maxHeight: .infinity, alignment: .top) - ProductSpecifications(product.entries) + VendorProductSpecifications(product.entries) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -110,7 +160,7 @@ public struct DeviceVendorScreen: View { } @ViewBuilder - private func ProductSpecifications(_ specifications: [DeviceVendor.Product.Entry]) -> some View { + private func VendorProductSpecifications(_ specifications: [DeviceVendor.Product.Entry]) -> some View { VStack(spacing: 6) { ForEach(specifications, id: \.name) { specification in HStack { @@ -148,6 +198,52 @@ public struct DeviceVendorScreen: View { } } + // MARK: - Row + + enum RowType { + case basic + case navigation + } + + @ViewBuilder + private func Row(symbol: SFSymbol? = nil, title: LocalizedStringKey, type: RowType, action: @escaping () -> Void = {}) -> some View { + HStack(spacing: 0) { // Hacky HStack to enable tap animations + Button { + action() + } label: { + HStack(spacing: 0) { + if let symbol { + Image(systemSymbol: symbol) + .font(.title2) + .foregroundStyle(tintColor) + .frame(width: 36) + .padding(.trailing, 12) + } + + Text(title, bundle: .module) + .font(.body) + .foregroundStyle(Color(.Labels.primary)) + + Spacer(minLength: 8) + + switch type { + case .basic: + EmptyView() + + case .navigation: + Image(systemSymbol: .chevronRight) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(Color(.Labels.quintuple)) + } + } + .contentShape(Rectangle()) + } + } + .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + .buttonStyle(.plain) + .frame(height: 60) + } + // MARK: - Information Row @ViewBuilder @@ -172,23 +268,57 @@ public struct DeviceVendorScreen: View { // MARK: - Helpers private func navigationTitleText() -> String { - return if let vendor = store.vendor { - "\(vendor.name) (\(vendor.categoryName))" - } else { - String(localized: "Loading...", bundle: .module) + return switch store.content { + case .index: + String(localized: "Devices", bundle: .module) + case .brands: + String("Now emppty for brands") + case .vendor: + if let vendor = store.vendor { + "\(vendor.name) (\(vendor.categoryName))" + } else { + String(localized: "Loading...", bundle: .module) + } } } } // MARK: - Previews -#Preview { +#Preview("Index") { + NavigationStack { + DeviceVendorScreen( + store: Store( + initialState: DeviceVendorFeature.State( + content: .index + ) + ) { + DeviceVendorFeature() + } + ) + } +} + +#Preview("Phone Brands") { + NavigationStack { + DeviceVendorScreen( + store: Store( + initialState: DeviceVendorFeature.State( + content: .brands(.phone) + ) + ) { + DeviceVendorFeature() + } + ) + } +} + +#Preview("Phone Vendor") { NavigationStack { DeviceVendorScreen( store: Store( initialState: DeviceVendorFeature.State( - type: .phone, - vendorName: "apple" + content: .vendor("apple", type: .phone) ) ) { DeviceVendorFeature() diff --git a/Modules/Sources/DeviceVendorFeature/Models/DeviceTypeContent.swift b/Modules/Sources/DeviceVendorFeature/Models/DeviceTypeContent.swift new file mode 100644 index 00000000..8ff8430e --- /dev/null +++ b/Modules/Sources/DeviceVendorFeature/Models/DeviceTypeContent.swift @@ -0,0 +1,14 @@ +// +// DeviceTypeContent.swift +// ForPDA +// +// Created by Xialtal on 3.04.26. +// + +import Models + +public enum DeviceTypeContent: Equatable { + case index + case brands(DeviceType) + case vendor(String, type: DeviceType) +} diff --git a/Modules/Sources/DeviceVendorFeature/Models/Extensions/DeviceType+Extension.swift b/Modules/Sources/DeviceVendorFeature/Models/Extensions/DeviceType+Extension.swift new file mode 100644 index 00000000..a8a7f1d8 --- /dev/null +++ b/Modules/Sources/DeviceVendorFeature/Models/Extensions/DeviceType+Extension.swift @@ -0,0 +1,39 @@ +// +// DeviceType+Extension.swift +// ForPDA +// +// Created by Xialtal on 3.04.26. +// + +import SwiftUI +import Models +import SFSafeSymbols + +extension DeviceType { + var title: LocalizedStringKey { + switch self { + case .phone: "Phones" + case .ebook: "E-Books" + case .pad: "Pads" + case .smartWatch: "Smart Watch" + } + } + + var icon: SFSymbol { + if #available(iOS 17.0, *) { + switch self { + case .phone: .smartphone + case .ebook: .bookPages + case .pad: .ipadSizes + case .smartWatch: .applewatch + } + } else { + switch self { + case .phone: .phone + case .ebook: .book + case .pad: .ipadLandscape + case .smartWatch: .applewatch + } + } + } +} diff --git a/Modules/Sources/DeviceVendorFeature/Resources/Localizable.xcstrings b/Modules/Sources/DeviceVendorFeature/Resources/Localizable.xcstrings index 1b1a03d0..544aec67 100644 --- a/Modules/Sources/DeviceVendorFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/DeviceVendorFeature/Resources/Localizable.xcstrings @@ -21,6 +21,29 @@ } } }, + "brands" : { + + }, + "Devices" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Устройства" + } + } + } + }, + "E-Books" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Эл. книги" + } + } + } + }, "Loading..." : { "localizations" : { "ru" : { @@ -31,6 +54,26 @@ } } }, + "Pads" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Планшеты" + } + } + } + }, + "Phones" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Телефоны" + } + } + } + }, "Show actual" : { "localizations" : { "ru" : { @@ -50,6 +93,16 @@ } } } + }, + "Smart Watch" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Смарт часы" + } + } + } } }, "version" : "1.1" diff --git a/Modules/Sources/Models/DevDB/DeviceBrands.swift b/Modules/Sources/Models/DevDB/DeviceBrands.swift new file mode 100644 index 00000000..08f1b996 --- /dev/null +++ b/Modules/Sources/Models/DevDB/DeviceBrands.swift @@ -0,0 +1,10 @@ +// +// DeviceBrands.swift +// ForPDA +// +// Created by Xialtal on 3.04.26. +// + +public struct DeviceBrands: Sendable, Equatable { + +} diff --git a/Modules/Sources/Models/DevDB/DeviceGoTo.swift b/Modules/Sources/Models/DevDB/DeviceGoTo.swift index cd8d17c5..f4a4dfd2 100644 --- a/Modules/Sources/Models/DevDB/DeviceGoTo.swift +++ b/Modules/Sources/Models/DevDB/DeviceGoTo.swift @@ -7,7 +7,7 @@ public enum DeviceGoTo { case index - case type(DeviceType) + case brands(DeviceType) case vendor(tag: String, type: DeviceType) case device(tag: String, subTag: String?) } diff --git a/Modules/Sources/Models/DevDB/DeviceType.swift b/Modules/Sources/Models/DevDB/DeviceType.swift index e0271602..ebc3211a 100644 --- a/Modules/Sources/Models/DevDB/DeviceType.swift +++ b/Modules/Sources/Models/DevDB/DeviceType.swift @@ -5,9 +5,13 @@ // Created by Xialtal on 14.12.25. // -public enum DeviceType: String, Sendable { +public enum DeviceType: String, Sendable, CaseIterable, Identifiable { case phone = "phones" case ebook = "ebook" case pad = "pad" case smartWatch = "smartwatch" + + public var id: String { + self.rawValue + } } From 8730f5dcbf96ce68462368ba0ee22ac2d6c9e5e0 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 3 Apr 2026 20:16:21 +0300 Subject: [PATCH 11/24] Add device brands endpoint --- Modules/Sources/APIClient/APIClient.swift | 9 +++ .../Sources/Models/DevDB/DeviceBrands.swift | 62 +++++++++++++++++++ .../ParsingClient/Parsers/DevDBParser.swift | 46 ++++++++++++++ .../Sources/ParsingClient/ParsingClient.swift | 4 ++ 4 files changed, 121 insertions(+) diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index 281c0811..feb55392 100644 --- a/Modules/Sources/APIClient/APIClient.swift +++ b/Modules/Sources/APIClient/APIClient.swift @@ -93,6 +93,7 @@ public struct APIClient: Sendable { public var searchUsers: @Sendable (_ request: SearchUsersRequest) async throws -> SearchUsersResponse // DevDB + public var deviceBrands: @Sendable (_ type: DeviceType) async throws -> DeviceBrands public var deviceVendor: @Sendable (_ name: String, _ type: DeviceType) async throws -> DeviceVendor public var deviceSpecifications: @Sendable (_ tag: String, _ subTag: String) async throws -> DeviceSpecifications @@ -569,6 +570,11 @@ extension APIClient: DependencyKey { // MARK: - Device Specs + deviceBrands: { type in + let command = DeviceCommand.type(typeCode: type.transferType) + let response = try await api.send(command) + return try await parser.parseDeviceBrands(response) + }, deviceVendor: { name, type in let command = DeviceCommand.vendor( typeCode: type.transferType, @@ -743,6 +749,9 @@ extension APIClient: DependencyKey { searchUsers: { _ in return .mock }, + deviceBrands: { _ in + return .mock + }, deviceVendor: { _, _ in return .mock }, diff --git a/Modules/Sources/Models/DevDB/DeviceBrands.swift b/Modules/Sources/Models/DevDB/DeviceBrands.swift index 08f1b996..9dce24b4 100644 --- a/Modules/Sources/Models/DevDB/DeviceBrands.swift +++ b/Modules/Sources/Models/DevDB/DeviceBrands.swift @@ -6,5 +6,67 @@ // public struct DeviceBrands: Sendable, Equatable { + public let type: DeviceType + public let typeName: String + public let brands: [Brand] + public struct Brand: Sendable, Equatable, Identifiable { + public let tag: String + public let name: String + public let devicesCount: Int + public let isActual: Bool + + public var id: String { + return tag + } + + public init( + tag: String, + name: String, + devicesCount: Int, + isActual: Bool + ) { + self.tag = tag + self.name = name + self.devicesCount = devicesCount + self.isActual = isActual + } + } + + public init( + type: DeviceType, + typeName: String, + brands: [Brand] + ) { + self.type = type + self.typeName = typeName + self.brands = brands + } +} + +public extension DeviceBrands { + static let mock = DeviceBrands( + type: .phone, + typeName: "Смартфоны", + brands: [ + .init( + tag: "apple", + name: "Apple", + devicesCount: 17, + isActual: true + ), + .init( + tag: "xiaomi", + name: "Xiaomi", + devicesCount: 9, + isActual: true + ), + .init( + tag: "alcatel", + name: "Alcatel", + devicesCount: 12, + isActual: false + ) + ] + ) } diff --git a/Modules/Sources/ParsingClient/Parsers/DevDBParser.swift b/Modules/Sources/ParsingClient/Parsers/DevDBParser.swift index 6c3e683b..4e494b5d 100644 --- a/Modules/Sources/ParsingClient/Parsers/DevDBParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/DevDBParser.swift @@ -48,6 +48,52 @@ public struct DevDBParser { ) } + // MARK: - Device Brands Response + + public static func parseDeviceBrands(from string: String) throws(ParsingError) -> DeviceBrands { + 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 type = array[safe: 2] as? String, + let typeName = array[safe: 3] as? String, + let brandsRaw = array[safe: 4] as? [[Any]] else { + throw ParsingError.failedToCastFields + } + + return DeviceBrands( + type: DeviceType(rawValue: type)!, + typeName: typeName, + brands: try parseDeviceBrands(brandsRaw) + ) + } + + // MARK: - Device Brands + + private static func parseDeviceBrands(_ brandsRaw: [[Any]]) throws(ParsingError) -> [DeviceBrands.Brand] { + var brands: [DeviceBrands.Brand] = [] + for brand in brandsRaw { + guard let tag = brand[safe: 0] as? String, + let name = brand[safe: 1] as? String, + let devicesCount = brand[safe: 2] as? Int, + let isActual = brand[safe: 3] as? Int else { + throw ParsingError.failedToCastFields + } + + brands.append(.init( + tag: tag, + name: name, + devicesCount: devicesCount, + isActual: isActual != 0 + )) + } + return brands + } + // MARK: - Device Vendor Response public static func parseDeviceVendor(from string: String) throws(ParsingError) -> DeviceVendor { diff --git a/Modules/Sources/ParsingClient/ParsingClient.swift b/Modules/Sources/ParsingClient/ParsingClient.swift index d7752cef..3b2ce046 100644 --- a/Modules/Sources/ParsingClient/ParsingClient.swift +++ b/Modules/Sources/ParsingClient/ParsingClient.swift @@ -62,6 +62,7 @@ public struct ParsingClient: Sendable { public var parseQmsChat: @Sendable (_ response: String) async throws -> QMSChat // DevDB + public var parseDeviceBrands: @Sendable (_ response: String) async throws -> DeviceBrands public var parseDeviceVendor: @Sendable (_ response: String) async throws -> DeviceVendor public var parseDeviceSpecifications: @Sendable (_ response: String) async throws -> DeviceSpecifications } @@ -163,6 +164,9 @@ extension ParsingClient: DependencyKey { parseQmsChat: { response in return try QMSChatParser.parse(from: response) }, + parseDeviceBrands: { response in + return try DevDBParser.parseDeviceBrands(from: response) + }, parseDeviceVendor: { response in return try DevDBParser.parseDeviceVendor(from: response) }, From 18be0bcb1d5221804355b26c61d9aa602730f292 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 3 Apr 2026 20:52:40 +0300 Subject: [PATCH 12/24] [WIP] DevDB --- .../DeviceVendorFeature.swift | 16 +++- .../DeviceVendorScreen.swift | 94 ++++++++++++++----- .../Resources/Localizable.xcstrings | 3 - 3 files changed, 79 insertions(+), 34 deletions(-) diff --git a/Modules/Sources/DeviceVendorFeature/DeviceVendorFeature.swift b/Modules/Sources/DeviceVendorFeature/DeviceVendorFeature.swift index 740966c7..7f20d21c 100644 --- a/Modules/Sources/DeviceVendorFeature/DeviceVendorFeature.swift +++ b/Modules/Sources/DeviceVendorFeature/DeviceVendorFeature.swift @@ -107,12 +107,17 @@ public struct DeviceVendorFeature: Reducer, Sendable { return .none case let .internal(.loadBrands(type)): - return .none + state.isLoading = true + return .run { send in + let response = try await apiClient.deviceBrands(type: type) + await send(.internal(.brandsResponse(.success(response)))) + } catch: { error, send in + await send(.internal(.brandsResponse(.failure(error)))) + } case let .internal(.brandsResponse(.success(response))): - return .none - - case let .internal(.brandsResponse(.failure(error))): + state.brands = response + state.isLoading = false return .none case let .internal(.loadVendor(name, type)): @@ -129,7 +134,8 @@ public struct DeviceVendorFeature: Reducer, Sendable { state.isLoading = false return .none - case let .internal(.vendorResponse(.failure(error))): + case .internal(.vendorResponse(.failure(let error))), + .internal(.brandsResponse(.failure(let error))): print(error) state.isLoading = false return .run { _ in diff --git a/Modules/Sources/DeviceVendorFeature/DeviceVendorScreen.swift b/Modules/Sources/DeviceVendorFeature/DeviceVendorScreen.swift index c7cb80a7..693ddd31 100644 --- a/Modules/Sources/DeviceVendorFeature/DeviceVendorScreen.swift +++ b/Modules/Sources/DeviceVendorFeature/DeviceVendorScreen.swift @@ -34,8 +34,7 @@ public struct DeviceVendorScreen: View { DeviceTypes() case .brands: if let brands = store.brands { - //Vendor(vendor) - Text("brands") + Brands(brands) } case .vendor: if let vendor = store.vendor { @@ -74,49 +73,68 @@ public struct DeviceVendorScreen: View { .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) } - // MARK: - Vendor + // MARK: - Brands @ViewBuilder - private func Vendor(_ vendor: DeviceVendor) -> some View { + private func Brands(_ brands: DeviceBrands) -> some View { Header( - actualCount: vendor.actualCount, - allCount: vendor.products.count + actualCount: brands.actualCount, + allCount: brands.brands.count ) - VendorProducts(vendor.products) + BrandsList(brands.brands, type: brands.type) } - // MARK: - Header - @ViewBuilder - private func Header(actualCount: Int, allCount: Int) -> some View { - VStack(spacing: 8) { - HStack(spacing: 12) { - InformationRow(title: "Actual", content: String(actualCount)) - - InformationRow(title: "All", content: String(allCount)) + private func BrandsList(_ brands: [DeviceBrands.Brand], type: DeviceType) -> some View { + Section { + ForEach(brands) { brand in + WithPerceptionTracking { + if store.categorySelection == .all { + Row(title: LocalizedStringKey(brand.name), type: .navigation) { + send(.vendorButtonTapped(brand.tag, type)) + } + } else if store.categorySelection == .actual, brand.isActual { + Row(title: LocalizedStringKey(brand.name), type: .navigation) { + send(.vendorButtonTapped(brand.tag, type)) + } + } + } } - - ChangeCategoryButton() } - .listRowBackground(Color.clear) + .listRowBackground(Color(.Background.teritary)) .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) } + // MARK: - Vendor + + @ViewBuilder + private func Vendor(_ vendor: DeviceVendor) -> some View { + Header( + actualCount: vendor.actualCount, + allCount: vendor.products.count + ) + + VendorProducts(vendor.products) + } + // MARK: - Products @ViewBuilder private func VendorProducts(_ products: [DeviceVendor.Product]) -> some View { - ForEach(products) { product in - WithPerceptionTracking { - if store.categorySelection == .all { - VendorProductRow(product) - } else if store.categorySelection == .actual, product.isActual { - VendorProductRow(product) + Section { + ForEach(products) { product in + WithPerceptionTracking { + if store.categorySelection == .all { + VendorProductRow(product) + } else if store.categorySelection == .actual, product.isActual { + VendorProductRow(product) + } } } - .padding(.horizontal, 16) } + .listRowBackground(Color(.Background.teritary)) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) } @ViewBuilder @@ -157,6 +175,9 @@ public struct DeviceVendorScreen: View { Color(.Background.teritary) .clipShape(RoundedRectangle(cornerRadius: 10)) ) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 8, trailing: 0)) } @ViewBuilder @@ -198,6 +219,23 @@ public struct DeviceVendorScreen: View { } } + // MARK: - Header + + @ViewBuilder + private func Header(actualCount: Int, allCount: Int) -> some View { + VStack(spacing: 8) { + HStack(spacing: 12) { + InformationRow(title: "Actual", content: String(actualCount)) + + InformationRow(title: "All", content: String(allCount)) + } + + ChangeCategoryButton() + } + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + } + // MARK: - Row enum RowType { @@ -272,7 +310,11 @@ public struct DeviceVendorScreen: View { case .index: String(localized: "Devices", bundle: .module) case .brands: - String("Now emppty for brands") + if let brand = store.brands { + brand.typeName + } else { + String(localized: "Loading...", bundle: .module) + } case .vendor: if let vendor = store.vendor { "\(vendor.name) (\(vendor.categoryName))" diff --git a/Modules/Sources/DeviceVendorFeature/Resources/Localizable.xcstrings b/Modules/Sources/DeviceVendorFeature/Resources/Localizable.xcstrings index 544aec67..7d812d22 100644 --- a/Modules/Sources/DeviceVendorFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/DeviceVendorFeature/Resources/Localizable.xcstrings @@ -20,9 +20,6 @@ } } } - }, - "brands" : { - }, "Devices" : { "localizations" : { From dbd61a04085ca8d924a4e14f7df433b80bbc1b79 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 3 Apr 2026 20:53:00 +0300 Subject: [PATCH 13/24] Fix build --- Modules/Sources/Models/DevDB/DeviceBrands.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Modules/Sources/Models/DevDB/DeviceBrands.swift b/Modules/Sources/Models/DevDB/DeviceBrands.swift index 9dce24b4..c6555389 100644 --- a/Modules/Sources/Models/DevDB/DeviceBrands.swift +++ b/Modules/Sources/Models/DevDB/DeviceBrands.swift @@ -10,6 +10,10 @@ public struct DeviceBrands: Sendable, Equatable { public let typeName: String public let brands: [Brand] + public var actualCount: Int { + return brands.count(where: { $0.isActual }) + } + public struct Brand: Sendable, Equatable, Identifiable { public let tag: String public let name: String From a4695ababef235516758241b207b96ac1a7f3758 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 3 Apr 2026 21:05:09 +0300 Subject: [PATCH 14/24] [WIP] DevDB --- Modules/Sources/AppFeature/AppFeature.swift | 12 ++++---- .../Sources/AppFeature/Navigation/Path.swift | 12 ++++---- .../AppFeature/Navigation/StackTab.swift | 10 +++---- .../DeviceTypeFeature.swift} | 2 +- .../DeviceTypeScreen.swift} | 28 +++++++++---------- .../Models/DeviceTypeContent.swift | 0 .../Extensions/DeviceType+Extension.swift | 0 .../Resources/Localizable.xcstrings | 0 Project.swift | 7 +++-- 9 files changed, 36 insertions(+), 35 deletions(-) rename Modules/Sources/{DeviceVendorFeature/DeviceVendorFeature.swift => DeviceTypeFeature/DeviceTypeFeature.swift} (98%) rename Modules/Sources/{DeviceVendorFeature/DeviceVendorScreen.swift => DeviceTypeFeature/DeviceTypeScreen.swift} (94%) rename Modules/Sources/{DeviceVendorFeature => DeviceTypeFeature}/Models/DeviceTypeContent.swift (100%) rename Modules/Sources/{DeviceVendorFeature => DeviceTypeFeature}/Models/Extensions/DeviceType+Extension.swift (100%) rename Modules/Sources/{DeviceVendorFeature => DeviceTypeFeature}/Resources/Localizable.xcstrings (100%) diff --git a/Modules/Sources/AppFeature/AppFeature.swift b/Modules/Sources/AppFeature/AppFeature.swift index 5ac2e6c0..73b73fae 100644 --- a/Modules/Sources/AppFeature/AppFeature.swift +++ b/Modules/Sources/AppFeature/AppFeature.swift @@ -37,7 +37,7 @@ import Combine import SearchResultFeature import CacheClient import DeviceSpecificationsFeature -import DeviceVendorFeature +import DeviceTypeFeature @Reducer public struct AppFeature: Reducer, Sendable { @@ -639,14 +639,14 @@ public struct AppFeature: Reducer, Sendable { screen = .articles(.article(ArticleFeature.State(articlePreview: preview, scrollToId: scrollToId))) case let .announcement(id): screen = .forum(.announcement(AnnouncementFeature.State(id: id))) - case let .device(type): - switch type { + case let .device(goTo): + switch goTo { case .index: - screen = .devDB(.vendor(DeviceVendorFeature.State(content: .index))) + screen = .devDB(.type(DeviceTypeFeature.State(content: .index))) case .brands(let type): - screen = .devDB(.vendor(DeviceVendorFeature.State(content: .brands(type)))) + screen = .devDB(.type(DeviceTypeFeature.State(content: .brands(type)))) case .vendor(let vendorName, let type): - screen = .devDB(.vendor(DeviceVendorFeature.State(content: .vendor(vendorName, type: type)))) + screen = .devDB(.type(DeviceTypeFeature.State(content: .vendor(vendorName, type: type)))) case .device(let tag, let subTag): screen = .devDB(.specifications(DeviceSpecificationsFeature.State(tag: tag, subTag: subTag))) } diff --git a/Modules/Sources/AppFeature/Navigation/Path.swift b/Modules/Sources/AppFeature/Navigation/Path.swift index 5e14d63f..aff13965 100644 --- a/Modules/Sources/AppFeature/Navigation/Path.swift +++ b/Modules/Sources/AppFeature/Navigation/Path.swift @@ -12,7 +12,7 @@ import ArticleFeature import ArticlesListFeature import DeveloperFeature import DeviceSpecificationsFeature -import DeviceVendorFeature +import DeviceTypeFeature import FavoritesRootFeature import FavoritesFeature import ForumFeature @@ -50,7 +50,7 @@ public enum Path { @Reducer public enum DevDB { - case vendor(DeviceVendorFeature) + case type(DeviceTypeFeature) case specifications(DeviceSpecificationsFeature) } @@ -151,10 +151,10 @@ extension Path { @MainActor @ViewBuilder private static func DevDBViews(_ store: Store) -> some View { switch store.case { - case let .vendor(store): - DeviceVendorScreen(store: store) - .tracking(for: DeviceVendorScreen.self) - + case let .type(store): + DeviceTypeScreen(store: store) + .tracking(for: DeviceTypeScreen.self) + case let .specifications(store): DeviceSpecificationsScreen(store: store) .tracking(for: DeviceSpecificationsScreen.self) diff --git a/Modules/Sources/AppFeature/Navigation/StackTab.swift b/Modules/Sources/AppFeature/Navigation/StackTab.swift index 88326935..aef92232 100644 --- a/Modules/Sources/AppFeature/Navigation/StackTab.swift +++ b/Modules/Sources/AppFeature/Navigation/StackTab.swift @@ -33,7 +33,7 @@ import AuthFeature import SearchFeature import SearchResultFeature import DeviceSpecificationsFeature -import DeviceVendorFeature +import DeviceTypeFeature @Reducer public struct StackTab: Reducer, Sendable { @@ -193,7 +193,7 @@ public struct StackTab: Reducer, Sendable { private func handleDevDBPathNavigation(action: Path.DevDB.Action, state: inout State) -> Effect { switch action { - case let .vendor(.delegate(.openDevice(tag))): + case let .type(.delegate(.openDevice(tag))): state.path.append(.devDB(.specifications(DeviceSpecificationsFeature.State(tag: tag, subTag: nil)))) case let .specifications(.delegate(.openDevice(tag, subTag))): @@ -487,11 +487,11 @@ public struct StackTab: Reducer, Sendable { case let .device(goTo): switch goTo { case .index: - state.path.append(.devDB(.vendor(DeviceVendorFeature.State(content: .index)))) + state.path.append(.devDB(.type(DeviceTypeFeature.State(content: .index)))) case .brands(let type): - state.path.append(.devDB(.vendor(DeviceVendorFeature.State(content: .brands(type))))) + state.path.append(.devDB(.type(DeviceTypeFeature.State(content: .brands(type))))) case .vendor(let vendorName, let type): - state.path.append(.devDB(.vendor(DeviceVendorFeature.State(content: .vendor(vendorName, type: type))))) + state.path.append(.devDB(.type(DeviceTypeFeature.State(content: .vendor(vendorName, type: type))))) case .device(let tag, let subTag): state.path.append(.devDB(.specifications(DeviceSpecificationsFeature.State(tag: tag, subTag: subTag)))) } diff --git a/Modules/Sources/DeviceVendorFeature/DeviceVendorFeature.swift b/Modules/Sources/DeviceTypeFeature/DeviceTypeFeature.swift similarity index 98% rename from Modules/Sources/DeviceVendorFeature/DeviceVendorFeature.swift rename to Modules/Sources/DeviceTypeFeature/DeviceTypeFeature.swift index 7f20d21c..5f0f93ac 100644 --- a/Modules/Sources/DeviceVendorFeature/DeviceVendorFeature.swift +++ b/Modules/Sources/DeviceTypeFeature/DeviceTypeFeature.swift @@ -12,7 +12,7 @@ import Models import ToastClient @Reducer -public struct DeviceVendorFeature: Reducer, Sendable { +public struct DeviceTypeFeature: Reducer, Sendable { public init() {} diff --git a/Modules/Sources/DeviceVendorFeature/DeviceVendorScreen.swift b/Modules/Sources/DeviceTypeFeature/DeviceTypeScreen.swift similarity index 94% rename from Modules/Sources/DeviceVendorFeature/DeviceVendorScreen.swift rename to Modules/Sources/DeviceTypeFeature/DeviceTypeScreen.swift index 693ddd31..29bb1192 100644 --- a/Modules/Sources/DeviceVendorFeature/DeviceVendorScreen.swift +++ b/Modules/Sources/DeviceTypeFeature/DeviceTypeScreen.swift @@ -1,5 +1,5 @@ // -// DeviceVendorScreen.swift +// DeviceTypeScreen.swift // ForPDA // // Created by Xialtal on 2.04.26. @@ -12,13 +12,13 @@ import SharedUI import NukeUI import SFSafeSymbols -@ViewAction(for: DeviceVendorFeature.self) -public struct DeviceVendorScreen: View { +@ViewAction(for: DeviceTypeFeature.self) +public struct DeviceTypeScreen: View { - @Perception.Bindable public var store: StoreOf + @Perception.Bindable public var store: StoreOf @Environment(\.tintColor) private var tintColor - public init(store: StoreOf) { + public init(store: StoreOf) { self.store = store } @@ -329,13 +329,13 @@ public struct DeviceVendorScreen: View { #Preview("Index") { NavigationStack { - DeviceVendorScreen( + DeviceTypeScreen( store: Store( - initialState: DeviceVendorFeature.State( + initialState: DeviceTypeFeature.State( content: .index ) ) { - DeviceVendorFeature() + DeviceTypeFeature() } ) } @@ -343,13 +343,13 @@ public struct DeviceVendorScreen: View { #Preview("Phone Brands") { NavigationStack { - DeviceVendorScreen( + DeviceTypeScreen( store: Store( - initialState: DeviceVendorFeature.State( + initialState: DeviceTypeFeature.State( content: .brands(.phone) ) ) { - DeviceVendorFeature() + DeviceTypeFeature() } ) } @@ -357,13 +357,13 @@ public struct DeviceVendorScreen: View { #Preview("Phone Vendor") { NavigationStack { - DeviceVendorScreen( + DeviceTypeScreen( store: Store( - initialState: DeviceVendorFeature.State( + initialState: DeviceTypeFeature.State( content: .vendor("apple", type: .phone) ) ) { - DeviceVendorFeature() + DeviceTypeFeature() } ) } diff --git a/Modules/Sources/DeviceVendorFeature/Models/DeviceTypeContent.swift b/Modules/Sources/DeviceTypeFeature/Models/DeviceTypeContent.swift similarity index 100% rename from Modules/Sources/DeviceVendorFeature/Models/DeviceTypeContent.swift rename to Modules/Sources/DeviceTypeFeature/Models/DeviceTypeContent.swift diff --git a/Modules/Sources/DeviceVendorFeature/Models/Extensions/DeviceType+Extension.swift b/Modules/Sources/DeviceTypeFeature/Models/Extensions/DeviceType+Extension.swift similarity index 100% rename from Modules/Sources/DeviceVendorFeature/Models/Extensions/DeviceType+Extension.swift rename to Modules/Sources/DeviceTypeFeature/Models/Extensions/DeviceType+Extension.swift diff --git a/Modules/Sources/DeviceVendorFeature/Resources/Localizable.xcstrings b/Modules/Sources/DeviceTypeFeature/Resources/Localizable.xcstrings similarity index 100% rename from Modules/Sources/DeviceVendorFeature/Resources/Localizable.xcstrings rename to Modules/Sources/DeviceTypeFeature/Resources/Localizable.xcstrings diff --git a/Project.swift b/Project.swift index 1372ccb5..44156fe4 100644 --- a/Project.swift +++ b/Project.swift @@ -42,7 +42,7 @@ let project = Project( .Internal.DeeplinkHandler, .Internal.DeveloperFeature, .Internal.DeviceSpecificationsFeature, - .Internal.DeviceVendorFeature, + .Internal.DeviceTypeFeature, .Internal.FavoritesFeature, .Internal.FavoritesRootFeature, .Internal.ForumFeature, @@ -217,12 +217,13 @@ let project = Project( ), .feature( - name: "DeviceVendorFeature", + name: "DeviceTypeFeature", dependencies: [ .Internal.APIClient, .Internal.Models, .Internal.SharedUI, .Internal.ToastClient, + .SPM.SFSafeSymbols, .SPM.TCA ] ), @@ -1055,7 +1056,7 @@ extension TargetDependency.Internal { static let DeeplinkHandler = TargetDependency.target(name: "DeeplinkHandler") static let DeveloperFeature = TargetDependency.target(name: "DeveloperFeature") static let DeviceSpecificationsFeature = TargetDependency.target(name: "DeviceSpecificationsFeature") - static let DeviceVendorFeature = TargetDependency.target(name: "DeviceVendorFeature") + static let DeviceTypeFeature = TargetDependency.target(name: "DeviceTypeFeature") static let FavoritesFeature = TargetDependency.target(name: "FavoritesFeature") static let FavoritesRootFeature = TargetDependency.target(name: "FavoritesRootFeature") static let FormFeature = TargetDependency.target(name: "FormFeature") From b41e867fa475abba8f937afaa41a11d0c33ab3bf Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 3 Apr 2026 21:10:48 +0300 Subject: [PATCH 15/24] Fix device path navigation --- Modules/Sources/AppFeature/Navigation/StackTab.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Modules/Sources/AppFeature/Navigation/StackTab.swift b/Modules/Sources/AppFeature/Navigation/StackTab.swift index aef92232..071bee9b 100644 --- a/Modules/Sources/AppFeature/Navigation/StackTab.swift +++ b/Modules/Sources/AppFeature/Navigation/StackTab.swift @@ -196,6 +196,12 @@ public struct StackTab: Reducer, Sendable { case let .type(.delegate(.openDevice(tag))): state.path.append(.devDB(.specifications(DeviceSpecificationsFeature.State(tag: tag, subTag: nil)))) + case let .type(.delegate(.openBrands(type))): + state.path.append(.devDB(.type(DeviceTypeFeature.State(content: .brands(type))))) + + case let .type(.delegate(.openVendor(code, type))): + state.path.append(.devDB(.type(DeviceTypeFeature.State(content: .vendor(code, type: type))))) + case let .specifications(.delegate(.openDevice(tag, subTag))): state.path.append(.devDB(.specifications(DeviceSpecificationsFeature.State(tag: tag, subTag: subTag)))) From e07e560be5ea6e498bbadc039d82dbada35649b8 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 3 Apr 2026 21:11:39 +0300 Subject: [PATCH 16/24] Improve device goTo deeplink code --- Modules/Sources/AppFeature/AppFeature.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Modules/Sources/AppFeature/AppFeature.swift b/Modules/Sources/AppFeature/AppFeature.swift index 73b73fae..10dbecc9 100644 --- a/Modules/Sources/AppFeature/AppFeature.swift +++ b/Modules/Sources/AppFeature/AppFeature.swift @@ -640,15 +640,15 @@ public struct AppFeature: Reducer, Sendable { case let .announcement(id): screen = .forum(.announcement(AnnouncementFeature.State(id: id))) case let .device(goTo): - switch goTo { + screen = switch goTo { case .index: - screen = .devDB(.type(DeviceTypeFeature.State(content: .index))) + .devDB(.type(DeviceTypeFeature.State(content: .index))) case .brands(let type): - screen = .devDB(.type(DeviceTypeFeature.State(content: .brands(type)))) + .devDB(.type(DeviceTypeFeature.State(content: .brands(type)))) case .vendor(let vendorName, let type): - screen = .devDB(.type(DeviceTypeFeature.State(content: .vendor(vendorName, type: type)))) + .devDB(.type(DeviceTypeFeature.State(content: .vendor(vendorName, type: type)))) case .device(let tag, let subTag): - screen = .devDB(.specifications(DeviceSpecificationsFeature.State(tag: tag, subTag: subTag))) + .devDB(.specifications(DeviceSpecificationsFeature.State(tag: tag, subTag: subTag))) } case let .topic(id, goTo): screen = .forum(.topic(TopicFeature.State(topicId: id!, goTo: goTo))) From 204c23e5e77194c7a8625ef29eb95a982af63045 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 3 Apr 2026 21:12:12 +0300 Subject: [PATCH 17/24] Implement full deeplink support for devdb --- Modules/Sources/DeeplinkHandler/DeeplinkHandler.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Modules/Sources/DeeplinkHandler/DeeplinkHandler.swift b/Modules/Sources/DeeplinkHandler/DeeplinkHandler.swift index 6018f025..60465ea9 100644 --- a/Modules/Sources/DeeplinkHandler/DeeplinkHandler.swift +++ b/Modules/Sources/DeeplinkHandler/DeeplinkHandler.swift @@ -136,14 +136,16 @@ public struct DeeplinkHandler { return .device(.vendor(tag: tag, type: type)) } else if url.pathComponents.count == 3, !url.pathComponents[2].isEmpty { - if let _ = DeviceType(rawValue: url.pathComponents[2]) { // /devdb/phones - // TODO: deviceType deeplink + if let type = DeviceType(rawValue: url.pathComponents[2]) { // /devdb/phones + return .device(.brands(type)) } else { // /devdb/apple_iphone_13 let tags = url.pathComponents[2].components(separatedBy: ":") let subTag = tags.first == tags.last ? "" : tags.last! return .device(.device(tag: tags.first!, subTag: subTag)) } + } else if url.pathComponents.count == 2, url.pathComponents[1] == "devdb" { + return .device(.index) } } From f7d7fdfa170e7aface851c30ca396d62e3ed6be0 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 3 Apr 2026 22:03:05 +0300 Subject: [PATCH 18/24] Improve namings for devdb --- Modules/Sources/APIClient/APIClient.swift | 2 +- Modules/Sources/AppFeature/AppFeature.swift | 4 +- .../AppFeature/Navigation/StackTab.swift | 8 +-- .../DeeplinkHandler/DeeplinkHandler.swift | 2 +- .../DeviceTypeFeature/DeviceTypeFeature.swift | 26 ++++---- .../DeviceTypeFeature/DeviceTypeScreen.swift | 66 +++++++++---------- .../Models/DeviceTypeContent.swift | 2 +- Modules/Sources/Models/DevDB/DeviceGoTo.swift | 2 +- .../Sources/Models/DevDB/DeviceVendor.swift | 12 ++-- ...ceBrands.swift => DeviceVendorsList.swift} | 16 ++--- .../ParsingClient/Parsers/DevDBParser.swift | 32 ++++----- .../Sources/ParsingClient/ParsingClient.swift | 2 +- 12 files changed, 87 insertions(+), 87 deletions(-) rename Modules/Sources/Models/DevDB/{DeviceBrands.swift => DeviceVendorsList.swift} (80%) diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index feb55392..f69a24e6 100644 --- a/Modules/Sources/APIClient/APIClient.swift +++ b/Modules/Sources/APIClient/APIClient.swift @@ -93,7 +93,7 @@ public struct APIClient: Sendable { public var searchUsers: @Sendable (_ request: SearchUsersRequest) async throws -> SearchUsersResponse // DevDB - public var deviceBrands: @Sendable (_ type: DeviceType) async throws -> DeviceBrands + public var deviceBrands: @Sendable (_ type: DeviceType) async throws -> DeviceVendorsList public var deviceVendor: @Sendable (_ name: String, _ type: DeviceType) async throws -> DeviceVendor public var deviceSpecifications: @Sendable (_ tag: String, _ subTag: String) async throws -> DeviceSpecifications diff --git a/Modules/Sources/AppFeature/AppFeature.swift b/Modules/Sources/AppFeature/AppFeature.swift index 10dbecc9..f98f52a3 100644 --- a/Modules/Sources/AppFeature/AppFeature.swift +++ b/Modules/Sources/AppFeature/AppFeature.swift @@ -643,8 +643,8 @@ public struct AppFeature: Reducer, Sendable { screen = switch goTo { case .index: .devDB(.type(DeviceTypeFeature.State(content: .index))) - case .brands(let type): - .devDB(.type(DeviceTypeFeature.State(content: .brands(type)))) + case .vendorsList(let type): + .devDB(.type(DeviceTypeFeature.State(content: .vendorsList(type)))) case .vendor(let vendorName, let type): .devDB(.type(DeviceTypeFeature.State(content: .vendor(vendorName, type: type)))) case .device(let tag, let subTag): diff --git a/Modules/Sources/AppFeature/Navigation/StackTab.swift b/Modules/Sources/AppFeature/Navigation/StackTab.swift index 071bee9b..8fc9c691 100644 --- a/Modules/Sources/AppFeature/Navigation/StackTab.swift +++ b/Modules/Sources/AppFeature/Navigation/StackTab.swift @@ -196,8 +196,8 @@ public struct StackTab: Reducer, Sendable { case let .type(.delegate(.openDevice(tag))): state.path.append(.devDB(.specifications(DeviceSpecificationsFeature.State(tag: tag, subTag: nil)))) - case let .type(.delegate(.openBrands(type))): - state.path.append(.devDB(.type(DeviceTypeFeature.State(content: .brands(type))))) + case let .type(.delegate(.openVendorsList(type))): + state.path.append(.devDB(.type(DeviceTypeFeature.State(content: .vendorsList(type))))) case let .type(.delegate(.openVendor(code, type))): state.path.append(.devDB(.type(DeviceTypeFeature.State(content: .vendor(code, type: type))))) @@ -494,8 +494,8 @@ public struct StackTab: Reducer, Sendable { switch goTo { case .index: state.path.append(.devDB(.type(DeviceTypeFeature.State(content: .index)))) - case .brands(let type): - state.path.append(.devDB(.type(DeviceTypeFeature.State(content: .brands(type))))) + case .vendorsList(let type): + state.path.append(.devDB(.type(DeviceTypeFeature.State(content: .vendorsList(type))))) case .vendor(let vendorName, let type): state.path.append(.devDB(.type(DeviceTypeFeature.State(content: .vendor(vendorName, type: type))))) case .device(let tag, let subTag): diff --git a/Modules/Sources/DeeplinkHandler/DeeplinkHandler.swift b/Modules/Sources/DeeplinkHandler/DeeplinkHandler.swift index 60465ea9..50dcba3c 100644 --- a/Modules/Sources/DeeplinkHandler/DeeplinkHandler.swift +++ b/Modules/Sources/DeeplinkHandler/DeeplinkHandler.swift @@ -137,7 +137,7 @@ public struct DeeplinkHandler { return .device(.vendor(tag: tag, type: type)) } else if url.pathComponents.count == 3, !url.pathComponents[2].isEmpty { if let type = DeviceType(rawValue: url.pathComponents[2]) { // /devdb/phones - return .device(.brands(type)) + return .device(.vendorsList(type)) } else { // /devdb/apple_iphone_13 let tags = url.pathComponents[2].components(separatedBy: ":") let subTag = tags.first == tags.last ? "" : tags.last! diff --git a/Modules/Sources/DeviceTypeFeature/DeviceTypeFeature.swift b/Modules/Sources/DeviceTypeFeature/DeviceTypeFeature.swift index 5f0f93ac..fd4f26ac 100644 --- a/Modules/Sources/DeviceTypeFeature/DeviceTypeFeature.swift +++ b/Modules/Sources/DeviceTypeFeature/DeviceTypeFeature.swift @@ -29,8 +29,8 @@ public struct DeviceTypeFeature: Reducer, Sendable { public struct State: Equatable { public let content: DeviceTypeContent - var brands: DeviceBrands? var vendor: DeviceVendor? + var vendorsList: DeviceVendorsList? var categorySelection: CategorySelection = .actual var isLoading = false @@ -56,8 +56,8 @@ public struct DeviceTypeFeature: Reducer, Sendable { case `internal`(Internal) public enum Internal { - case loadBrands(DeviceType) - case brandsResponse(Result) + case loadVendorsList(DeviceType) + case vendorsListResponse(Result) case loadVendor(String, DeviceType) case vendorResponse(Result) @@ -65,7 +65,7 @@ public struct DeviceTypeFeature: Reducer, Sendable { case delegate(Delegate) public enum Delegate { - case openBrands(DeviceType) + case openVendorsList(DeviceType) case openDevice(tag: String) case openVendor(String, DeviceType) } @@ -84,8 +84,8 @@ public struct DeviceTypeFeature: Reducer, Sendable { switch action { case .view(.onAppear): switch state.content { - case .brands(let type): - return .send(.internal(.loadBrands(type))) + case .vendorsList(let type): + return .send(.internal(.loadVendorsList(type))) case .vendor(let name, let type): return .send(.internal(.loadVendor(name, type))) case .index: @@ -97,7 +97,7 @@ public struct DeviceTypeFeature: Reducer, Sendable { return .send(.delegate(.openDevice(tag: tag))) case let .view(.typeButtonTapped(type)): - return .send(.delegate(.openBrands(type))) + return .send(.delegate(.openVendorsList(type))) case let .view(.vendorButtonTapped(name, type)): return .send(.delegate(.openVendor(name, type))) @@ -106,17 +106,17 @@ public struct DeviceTypeFeature: Reducer, Sendable { state.categorySelection = category return .none - case let .internal(.loadBrands(type)): + case let .internal(.loadVendorsList(type)): state.isLoading = true return .run { send in let response = try await apiClient.deviceBrands(type: type) - await send(.internal(.brandsResponse(.success(response)))) + await send(.internal(.vendorsListResponse(.success(response)))) } catch: { error, send in - await send(.internal(.brandsResponse(.failure(error)))) + await send(.internal(.vendorsListResponse(.failure(error)))) } - case let .internal(.brandsResponse(.success(response))): - state.brands = response + case let .internal(.vendorsListResponse(.success(response))): + state.vendorsList = response state.isLoading = false return .none @@ -135,7 +135,7 @@ public struct DeviceTypeFeature: Reducer, Sendable { return .none case .internal(.vendorResponse(.failure(let error))), - .internal(.brandsResponse(.failure(let error))): + .internal(.vendorsListResponse(.failure(let error))): print(error) state.isLoading = false return .run { _ in diff --git a/Modules/Sources/DeviceTypeFeature/DeviceTypeScreen.swift b/Modules/Sources/DeviceTypeFeature/DeviceTypeScreen.swift index 29bb1192..420703a3 100644 --- a/Modules/Sources/DeviceTypeFeature/DeviceTypeScreen.swift +++ b/Modules/Sources/DeviceTypeFeature/DeviceTypeScreen.swift @@ -32,9 +32,9 @@ public struct DeviceTypeScreen: View { switch store.content { case .index: DeviceTypes() - case .brands: - if let brands = store.brands { - Brands(brands) + case .vendorsList: + if let vendors = store.vendorsList { + VendorsList(vendors) } case .vendor: if let vendor = store.vendor { @@ -73,30 +73,30 @@ public struct DeviceTypeScreen: View { .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) } - // MARK: - Brands + // MARK: - Vendors @ViewBuilder - private func Brands(_ brands: DeviceBrands) -> some View { + private func VendorsList(_ vendors: DeviceVendorsList) -> some View { Header( - actualCount: brands.actualCount, - allCount: brands.brands.count + actualCount: vendors.actualCount, + allCount: vendors.vendors.count ) - BrandsList(brands.brands, type: brands.type) + VendorsInfo(vendors.vendors, type: vendors.type) } @ViewBuilder - private func BrandsList(_ brands: [DeviceBrands.Brand], type: DeviceType) -> some View { + private func VendorsInfo(_ vendors: [DeviceVendorsList.VendorInfo], type: DeviceType) -> some View { Section { - ForEach(brands) { brand in + ForEach(vendors) { vendor in WithPerceptionTracking { if store.categorySelection == .all { - Row(title: LocalizedStringKey(brand.name), type: .navigation) { - send(.vendorButtonTapped(brand.tag, type)) + Row(title: LocalizedStringKey(vendor.name), type: .navigation) { + send(.vendorButtonTapped(vendor.tag, type)) } - } else if store.categorySelection == .actual, brand.isActual { - Row(title: LocalizedStringKey(brand.name), type: .navigation) { - send(.vendorButtonTapped(brand.tag, type)) + } else if store.categorySelection == .actual, vendor.isActual { + Row(title: LocalizedStringKey(vendor.name), type: .navigation) { + send(.vendorButtonTapped(vendor.tag, type)) } } } @@ -112,23 +112,23 @@ public struct DeviceTypeScreen: View { private func Vendor(_ vendor: DeviceVendor) -> some View { Header( actualCount: vendor.actualCount, - allCount: vendor.products.count + allCount: vendor.devices.count ) - VendorProducts(vendor.products) + VendorDevices(vendor.devices) } // MARK: - Products @ViewBuilder - private func VendorProducts(_ products: [DeviceVendor.Product]) -> some View { + private func VendorDevices(_ devices: [DeviceVendor.DeviceInfo]) -> some View { Section { - ForEach(products) { product in + ForEach(devices) { device in WithPerceptionTracking { if store.categorySelection == .all { - VendorProductRow(product) - } else if store.categorySelection == .actual, product.isActual { - VendorProductRow(product) + VendorDeviceInfoRow(device) + } else if store.categorySelection == .actual, device.isActual { + VendorDeviceInfoRow(device) } } } @@ -138,12 +138,12 @@ public struct DeviceTypeScreen: View { } @ViewBuilder - private func VendorProductRow(_ product: DeviceVendor.Product) -> some View { + private func VendorDeviceInfoRow(_ device: DeviceVendor.DeviceInfo) -> some View { VStack(alignment: .leading, spacing: 12) { Button { - send(.productButtonTapped(product.tag)) + send(.productButtonTapped(device.tag)) } label: { - Text(verbatim: product.name) + Text(verbatim: device.name) .font(.title2) .fontWeight(.bold) .foregroundStyle(Color(.Labels.primary)) @@ -151,10 +151,10 @@ public struct DeviceTypeScreen: View { .buttonStyle(.plain) HStack(spacing: 16) { - LazyImage(url: product.imageUrl) { state in + LazyImage(url: device.imageUrl) { state in Group { if let image = state.image { - image.resizable().scaledToFill() + image.resizable().frame(width: 74, height: 74).scaledToFit() } else { Color(.systemBackground) } @@ -165,7 +165,7 @@ public struct DeviceTypeScreen: View { .frame(width: 74, height: 74) .frame(maxHeight: .infinity, alignment: .top) - VendorProductSpecifications(product.entries) + VendorDeviceSpecifications(device.entries) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -181,7 +181,7 @@ public struct DeviceTypeScreen: View { } @ViewBuilder - private func VendorProductSpecifications(_ specifications: [DeviceVendor.Product.Entry]) -> some View { + private func VendorDeviceSpecifications(_ specifications: [DeviceVendor.DeviceInfo.Entry]) -> some View { VStack(spacing: 6) { ForEach(specifications, id: \.name) { specification in HStack { @@ -309,9 +309,9 @@ public struct DeviceTypeScreen: View { return switch store.content { case .index: String(localized: "Devices", bundle: .module) - case .brands: - if let brand = store.brands { - brand.typeName + case .vendorsList: + if let vendorsList = store.vendorsList { + vendorsList.typeName } else { String(localized: "Loading...", bundle: .module) } @@ -346,7 +346,7 @@ public struct DeviceTypeScreen: View { DeviceTypeScreen( store: Store( initialState: DeviceTypeFeature.State( - content: .brands(.phone) + content: .vendorsList(.phone) ) ) { DeviceTypeFeature() diff --git a/Modules/Sources/DeviceTypeFeature/Models/DeviceTypeContent.swift b/Modules/Sources/DeviceTypeFeature/Models/DeviceTypeContent.swift index 8ff8430e..53f6a2af 100644 --- a/Modules/Sources/DeviceTypeFeature/Models/DeviceTypeContent.swift +++ b/Modules/Sources/DeviceTypeFeature/Models/DeviceTypeContent.swift @@ -9,6 +9,6 @@ import Models public enum DeviceTypeContent: Equatable { case index - case brands(DeviceType) + case vendorsList(DeviceType) case vendor(String, type: DeviceType) } diff --git a/Modules/Sources/Models/DevDB/DeviceGoTo.swift b/Modules/Sources/Models/DevDB/DeviceGoTo.swift index f4a4dfd2..ec9556d0 100644 --- a/Modules/Sources/Models/DevDB/DeviceGoTo.swift +++ b/Modules/Sources/Models/DevDB/DeviceGoTo.swift @@ -7,7 +7,7 @@ public enum DeviceGoTo { case index - case brands(DeviceType) + case vendorsList(DeviceType) case vendor(tag: String, type: DeviceType) case device(tag: String, subTag: String?) } diff --git a/Modules/Sources/Models/DevDB/DeviceVendor.swift b/Modules/Sources/Models/DevDB/DeviceVendor.swift index 8b03fad9..cb9ec922 100644 --- a/Modules/Sources/Models/DevDB/DeviceVendor.swift +++ b/Modules/Sources/Models/DevDB/DeviceVendor.swift @@ -12,13 +12,13 @@ public struct DeviceVendor: Sendable, Equatable { public let name: String public let code: String public let categoryName: String - public let products: [Product] + public let devices: [DeviceInfo] public var actualCount: Int { - return products.count(where: { $0.isActual }) + return devices.count(where: { $0.isActual }) } - public struct Product: Sendable, Identifiable, Equatable { + public struct DeviceInfo: Sendable, Identifiable, Equatable { public let tag: String public let name: String public let imageUrl: URL @@ -59,13 +59,13 @@ public struct DeviceVendor: Sendable, Equatable { name: String, code: String, categoryName: String, - products: [Product] + devices: [DeviceInfo] ) { self.type = type self.name = name self.code = code self.categoryName = categoryName - self.products = products + self.devices = devices } } @@ -75,7 +75,7 @@ public extension DeviceVendor { name: "Apple", code: "apple", categoryName: "Смартфоны", - products: [ + devices: [ .init( tag: "apple_iphone_16e", name: "iPhone 16e", diff --git a/Modules/Sources/Models/DevDB/DeviceBrands.swift b/Modules/Sources/Models/DevDB/DeviceVendorsList.swift similarity index 80% rename from Modules/Sources/Models/DevDB/DeviceBrands.swift rename to Modules/Sources/Models/DevDB/DeviceVendorsList.swift index c6555389..979e5d35 100644 --- a/Modules/Sources/Models/DevDB/DeviceBrands.swift +++ b/Modules/Sources/Models/DevDB/DeviceVendorsList.swift @@ -5,16 +5,16 @@ // Created by Xialtal on 3.04.26. // -public struct DeviceBrands: Sendable, Equatable { +public struct DeviceVendorsList: Sendable, Equatable { public let type: DeviceType public let typeName: String - public let brands: [Brand] + public let vendors: [VendorInfo] public var actualCount: Int { - return brands.count(where: { $0.isActual }) + return vendors.count(where: { $0.isActual }) } - public struct Brand: Sendable, Equatable, Identifiable { + public struct VendorInfo: Sendable, Equatable, Identifiable { public let tag: String public let name: String public let devicesCount: Int @@ -40,16 +40,16 @@ public struct DeviceBrands: Sendable, Equatable { public init( type: DeviceType, typeName: String, - brands: [Brand] + brands: [VendorInfo] ) { self.type = type self.typeName = typeName - self.brands = brands + self.vendors = brands } } -public extension DeviceBrands { - static let mock = DeviceBrands( +public extension DeviceVendorsList { + static let mock = DeviceVendorsList( type: .phone, typeName: "Смартфоны", brands: [ diff --git a/Modules/Sources/ParsingClient/Parsers/DevDBParser.swift b/Modules/Sources/ParsingClient/Parsers/DevDBParser.swift index 4e494b5d..e71e659f 100644 --- a/Modules/Sources/ParsingClient/Parsers/DevDBParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/DevDBParser.swift @@ -50,7 +50,7 @@ public struct DevDBParser { // MARK: - Device Brands Response - public static func parseDeviceBrands(from string: String) throws(ParsingError) -> DeviceBrands { + public static func parseDeviceBrands(from string: String) throws(ParsingError) -> DeviceVendorsList { guard let data = string.data(using: .utf8) else { throw ParsingError.failedToCreateDataFromString } @@ -65,17 +65,17 @@ public struct DevDBParser { throw ParsingError.failedToCastFields } - return DeviceBrands( + return DeviceVendorsList( type: DeviceType(rawValue: type)!, typeName: typeName, - brands: try parseDeviceBrands(brandsRaw) + brands: try parseDeviceVendorsList(brandsRaw) ) } - // MARK: - Device Brands + // MARK: - Device Vendors List - private static func parseDeviceBrands(_ brandsRaw: [[Any]]) throws(ParsingError) -> [DeviceBrands.Brand] { - var brands: [DeviceBrands.Brand] = [] + private static func parseDeviceVendorsList(_ brandsRaw: [[Any]]) throws(ParsingError) -> [DeviceVendorsList.VendorInfo] { + var brands: [DeviceVendorsList.VendorInfo] = [] for brand in brandsRaw { guard let tag = brand[safe: 0] as? String, let name = brand[safe: 1] as? String, @@ -109,7 +109,7 @@ public struct DevDBParser { let categoryName = array[safe: 3] as? String, let name = array[safe: 5] as? String, let code = array[safe: 4] as? String, - let productsRaw = array[safe: 6] as? [[Any]] else { + let devicesRaw = array[safe: 6] as? [[Any]] else { throw ParsingError.failedToCastFields } @@ -118,14 +118,14 @@ public struct DevDBParser { name: name, code: code, categoryName: categoryName, - products: try parseVendorProducts(productsRaw) + devices: try parseVendorDevices(devicesRaw) ) } // MARK: - Vendor Products - private static func parseVendorProducts(_ productsRaw: [[Any]]) throws(ParsingError) -> [DeviceVendor.Product] { - var products: [DeviceVendor.Product] = [] + private static func parseVendorDevices(_ productsRaw: [[Any]]) throws(ParsingError) -> [DeviceVendor.DeviceInfo] { + var devices: [DeviceVendor.DeviceInfo] = [] for product in productsRaw { guard let tag = product[safe: 0] as? String, let name = product[safe: 1] as? String, @@ -135,21 +135,21 @@ public struct DevDBParser { throw ParsingError.failedToCastFields } - products.append(.init( + devices.append(.init( tag: tag, name: name, imageUrl: URL(string: url)!, - entries: try parseVendorProductEntry(entriesRaw), + entries: try parseVendorDeviceEntry(entriesRaw), isActual: isActual != 0 )) } - return products + return devices } - // MARK: - Vendor Product Entry + // MARK: - Vendor Device Entry - private static func parseVendorProductEntry(_ entriesRaw: [[Any]]) throws(ParsingError) -> [DeviceVendor.Product.Entry] { - var entries: [DeviceVendor.Product.Entry] = [] + private static func parseVendorDeviceEntry(_ entriesRaw: [[Any]]) throws(ParsingError) -> [DeviceVendor.DeviceInfo.Entry] { + var entries: [DeviceVendor.DeviceInfo.Entry] = [] for entry in entriesRaw { guard let name = entry[safe: 2] as? String, let value = entry[safe: 4] as? String else { diff --git a/Modules/Sources/ParsingClient/ParsingClient.swift b/Modules/Sources/ParsingClient/ParsingClient.swift index 3b2ce046..e51251a1 100644 --- a/Modules/Sources/ParsingClient/ParsingClient.swift +++ b/Modules/Sources/ParsingClient/ParsingClient.swift @@ -62,7 +62,7 @@ public struct ParsingClient: Sendable { public var parseQmsChat: @Sendable (_ response: String) async throws -> QMSChat // DevDB - public var parseDeviceBrands: @Sendable (_ response: String) async throws -> DeviceBrands + public var parseDeviceBrands: @Sendable (_ response: String) async throws -> DeviceVendorsList public var parseDeviceVendor: @Sendable (_ response: String) async throws -> DeviceVendor public var parseDeviceSpecifications: @Sendable (_ response: String) async throws -> DeviceSpecifications } From 563365e04b34c1acda98b249a32148995491870c Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 4 Apr 2026 16:20:11 +0300 Subject: [PATCH 19/24] Fix device image for devdb vendor screen --- Modules/Sources/DeviceTypeFeature/DeviceTypeScreen.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Modules/Sources/DeviceTypeFeature/DeviceTypeScreen.swift b/Modules/Sources/DeviceTypeFeature/DeviceTypeScreen.swift index 420703a3..bb9b42c7 100644 --- a/Modules/Sources/DeviceTypeFeature/DeviceTypeScreen.swift +++ b/Modules/Sources/DeviceTypeFeature/DeviceTypeScreen.swift @@ -154,15 +154,17 @@ public struct DeviceTypeScreen: View { LazyImage(url: device.imageUrl) { state in Group { if let image = state.image { - image.resizable().frame(width: 74, height: 74).scaledToFit() + image + .resizable() + .scaledToFit() + .frame(width: 74, height: 74) } else { Color(.systemBackground) } } .skeleton(with: state.isLoading, shape: .rectangle) } - .padding(.top, 16) - .frame(width: 74, height: 74) + .padding(.top, 8) .frame(maxHeight: .infinity, alignment: .top) VendorDeviceSpecifications(device.entries) From 743569e4651c08b005fe3e5440de9b8e57e72f8f Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 4 Apr 2026 16:20:41 +0300 Subject: [PATCH 20/24] Fix device images size for device specifications --- .../DeviceSpecificationsScreen.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift index b10b6e12..5fc0d443 100644 --- a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift +++ b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift @@ -147,25 +147,26 @@ public struct DeviceSpecificationsScreen: View { @ViewBuilder private func HeaderImages(_ images: [DeviceSpecifications.DeviceImage]) -> some View { - HStack(spacing: 8) { + HStack(spacing: 0) { ForEach(Array(images.enumerated()), id: \.element) { index, image in LazyImage(url: image.url) { state in Group { if let image = state.image { - image.resizable().scaledToFill() + image.resizable().scaledToFit() } else { Color(.systemBackground) } } .skeleton(with: state.isLoading, shape: .rectangle) } - .frame(width: 37, height: 75) + .frame(width: 75, height: 75) .clipped() .onTapGesture { send(.headerImageTapped(index)) } } } + .padding(.top, 8) } @ViewBuilder From 8b9fc995db8f779ffb9bf64bfd4e32df64425c2d Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 4 Apr 2026 16:27:12 +0300 Subject: [PATCH 21/24] Improve namings for DeviceTypeFeature --- Modules/Sources/DeviceTypeFeature/DeviceTypeFeature.swift | 4 ++-- Modules/Sources/DeviceTypeFeature/DeviceTypeScreen.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Modules/Sources/DeviceTypeFeature/DeviceTypeFeature.swift b/Modules/Sources/DeviceTypeFeature/DeviceTypeFeature.swift index fd4f26ac..1cb3801a 100644 --- a/Modules/Sources/DeviceTypeFeature/DeviceTypeFeature.swift +++ b/Modules/Sources/DeviceTypeFeature/DeviceTypeFeature.swift @@ -48,7 +48,7 @@ public struct DeviceTypeFeature: Reducer, Sendable { case view(View) public enum View { case onAppear - case productButtonTapped(String) + case deviceButtonTapped(String) case typeButtonTapped(DeviceType) case vendorButtonTapped(String, DeviceType) case changeCategoryButtonTapped(CategorySelection) @@ -93,7 +93,7 @@ public struct DeviceTypeFeature: Reducer, Sendable { } return .none - case let .view(.productButtonTapped(tag)): + case let .view(.deviceButtonTapped(tag)): return .send(.delegate(.openDevice(tag: tag))) case let .view(.typeButtonTapped(type)): diff --git a/Modules/Sources/DeviceTypeFeature/DeviceTypeScreen.swift b/Modules/Sources/DeviceTypeFeature/DeviceTypeScreen.swift index bb9b42c7..78d4f3d3 100644 --- a/Modules/Sources/DeviceTypeFeature/DeviceTypeScreen.swift +++ b/Modules/Sources/DeviceTypeFeature/DeviceTypeScreen.swift @@ -141,7 +141,7 @@ public struct DeviceTypeScreen: View { private func VendorDeviceInfoRow(_ device: DeviceVendor.DeviceInfo) -> some View { VStack(alignment: .leading, spacing: 12) { Button { - send(.productButtonTapped(device.tag)) + send(.deviceButtonTapped(device.tag)) } label: { Text(verbatim: device.name) .font(.title2) From 25c92e7eec17a384f4e1704271e2e807b4372cb5 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 4 Apr 2026 16:31:51 +0300 Subject: [PATCH 22/24] Improve & cleanup Row in DeviceTypeScreen --- .../DeviceTypeFeature/DeviceTypeScreen.swift | 43 ++++++++++--------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/Modules/Sources/DeviceTypeFeature/DeviceTypeScreen.swift b/Modules/Sources/DeviceTypeFeature/DeviceTypeScreen.swift index 78d4f3d3..2d04e4c9 100644 --- a/Modules/Sources/DeviceTypeFeature/DeviceTypeScreen.swift +++ b/Modules/Sources/DeviceTypeFeature/DeviceTypeScreen.swift @@ -64,7 +64,7 @@ public struct DeviceTypeScreen: View { private func DeviceTypes() -> some View { Section { ForEach(DeviceType.allCases) { type in - Row(symbol: type.icon, title: type.title, type: .navigation) { + Row(symbol: type.icon, title: .localized(type.title)) { send(.typeButtonTapped(type)) } } @@ -91,11 +91,11 @@ public struct DeviceTypeScreen: View { ForEach(vendors) { vendor in WithPerceptionTracking { if store.categorySelection == .all { - Row(title: LocalizedStringKey(vendor.name), type: .navigation) { + Row(title: .text(vendor.name)) { send(.vendorButtonTapped(vendor.tag, type)) } } else if store.categorySelection == .actual, vendor.isActual { - Row(title: LocalizedStringKey(vendor.name), type: .navigation) { + Row(title: .text(vendor.name)) { send(.vendorButtonTapped(vendor.tag, type)) } } @@ -238,15 +238,15 @@ public struct DeviceTypeScreen: View { .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) } - // MARK: - Row - - enum RowType { - case basic - case navigation + public enum RowTitle { + case text(String) + case localized(LocalizedStringKey) } + // MARK: - Row + @ViewBuilder - private func Row(symbol: SFSymbol? = nil, title: LocalizedStringKey, type: RowType, action: @escaping () -> Void = {}) -> some View { + private func Row(symbol: SFSymbol? = nil, title: RowTitle, action: @escaping () -> Void = {}) -> some View { HStack(spacing: 0) { // Hacky HStack to enable tap animations Button { action() @@ -260,21 +260,22 @@ public struct DeviceTypeScreen: View { .padding(.trailing, 12) } - Text(title, bundle: .module) - .font(.body) - .foregroundStyle(Color(.Labels.primary)) + Group { + switch title { + case .text(let title): + Text(verbatim: title) + case .localized(let title): + Text(title, bundle: .module) + } + } + .font(.body) + .foregroundStyle(Color(.Labels.primary)) Spacer(minLength: 8) - switch type { - case .basic: - EmptyView() - - case .navigation: - Image(systemSymbol: .chevronRight) - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(Color(.Labels.quintuple)) - } + Image(systemSymbol: .chevronRight) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(Color(.Labels.quintuple)) } .contentShape(Rectangle()) } From c43f0b54ab786e580c91752519b74dac660ef5b5 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 7 Apr 2026 17:47:46 +0300 Subject: [PATCH 23/24] Improve marks in DeviceTypeScreen --- Modules/Sources/DeviceTypeFeature/DeviceTypeScreen.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Modules/Sources/DeviceTypeFeature/DeviceTypeScreen.swift b/Modules/Sources/DeviceTypeFeature/DeviceTypeScreen.swift index 2d04e4c9..f9df2145 100644 --- a/Modules/Sources/DeviceTypeFeature/DeviceTypeScreen.swift +++ b/Modules/Sources/DeviceTypeFeature/DeviceTypeScreen.swift @@ -238,13 +238,13 @@ public struct DeviceTypeScreen: View { .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) } - public enum RowTitle { + // MARK: - Row + + private enum RowTitle { case text(String) case localized(LocalizedStringKey) } - // MARK: - Row - @ViewBuilder private func Row(symbol: SFSymbol? = nil, title: RowTitle, action: @escaping () -> Void = {}) -> some View { HStack(spacing: 0) { // Hacky HStack to enable tap animations From 4c436e1c91ca2dfd8e8aadcc616edba83165a076 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Mon, 13 Apr 2026 15:24:42 +0300 Subject: [PATCH 24/24] Add analytics to DeviceTypeFeature --- .../Events/DeviceTypeEvent.swift | 28 ++++++++++++++ .../Analytics/FormFeature+Analytics.swift | 38 +++++++++++++++++++ .../DeviceTypeFeature/DeviceTypeFeature.swift | 6 ++- Project.swift | 1 + 4 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 Modules/Sources/AnalyticsClient/Events/DeviceTypeEvent.swift create mode 100644 Modules/Sources/DeviceTypeFeature/Analytics/FormFeature+Analytics.swift diff --git a/Modules/Sources/AnalyticsClient/Events/DeviceTypeEvent.swift b/Modules/Sources/AnalyticsClient/Events/DeviceTypeEvent.swift new file mode 100644 index 00000000..7a619e47 --- /dev/null +++ b/Modules/Sources/AnalyticsClient/Events/DeviceTypeEvent.swift @@ -0,0 +1,28 @@ +// +// DeviceTypeEvent.swift +// ForPDA +// +// Created by Xialtal on 13.04.26. +// + +public enum DeviceTypeEvent: Event { + + case typeTapped(String) + case deviceTapped(String) + case vendorTapped(String, type: String) + + public var name: String { + return "DeviceType " + eventName(for: self).inProperCase + } + + public var properties: [String: String]? { + switch self { + case .deviceTapped(let tag): + return ["tag": name] + case .typeTapped(let type): + return ["type": type] + case .vendorTapped(let name, let type): + return ["type": type, "name": name] + } + } +} diff --git a/Modules/Sources/DeviceTypeFeature/Analytics/FormFeature+Analytics.swift b/Modules/Sources/DeviceTypeFeature/Analytics/FormFeature+Analytics.swift new file mode 100644 index 00000000..657d908d --- /dev/null +++ b/Modules/Sources/DeviceTypeFeature/Analytics/FormFeature+Analytics.swift @@ -0,0 +1,38 @@ +// +// DeviceTypeFeature+Analytics.swift +// ForPDA +// +// Created by Xialtal on 13.04.2026. +// + +import ComposableArchitecture +import AnalyticsClient + +extension DeviceTypeFeature { + + struct Analytics: Reducer { + typealias State = DeviceTypeFeature.State + typealias Action = DeviceTypeFeature.Action + + @Dependency(\.analyticsClient) var analytics + + var body: some Reducer { + Reduce { state, action in + switch action { + case .view(.deviceButtonTapped(let tag)): + analytics.log(DeviceTypeEvent.deviceTapped(tag)) + + case .view(.typeButtonTapped(let type)): + analytics.log(DeviceTypeEvent.typeTapped(type.rawValue)) + + case .view(.vendorButtonTapped(let name, let type)): + analytics.log(DeviceTypeEvent.vendorTapped(name, type: type.rawValue)) + + case .delegate, .internal, .view: + break + } + return .none + } + } + } +} diff --git a/Modules/Sources/DeviceTypeFeature/DeviceTypeFeature.swift b/Modules/Sources/DeviceTypeFeature/DeviceTypeFeature.swift index 1cb3801a..7a3ad496 100644 --- a/Modules/Sources/DeviceTypeFeature/DeviceTypeFeature.swift +++ b/Modules/Sources/DeviceTypeFeature/DeviceTypeFeature.swift @@ -10,6 +10,7 @@ import ComposableArchitecture import APIClient import Models import ToastClient +import AnalyticsClient @Reducer public struct DeviceTypeFeature: Reducer, Sendable { @@ -74,6 +75,7 @@ public struct DeviceTypeFeature: Reducer, Sendable { // MARK: - Dependencies @Dependency(\.apiClient) private var apiClient + @Dependency(\.analyticsClient) private var analyticsClient @Dependency(\.openURL) var openURL @Dependency(\.toastClient) var toastClient @@ -136,8 +138,8 @@ public struct DeviceTypeFeature: Reducer, Sendable { case .internal(.vendorResponse(.failure(let error))), .internal(.vendorsListResponse(.failure(let error))): - print(error) state.isLoading = false + analyticsClient.capture(error) return .run { _ in await toastClient.showToast(.whoopsSomethingWentWrong) } @@ -146,5 +148,7 @@ public struct DeviceTypeFeature: Reducer, Sendable { return .none } } + + Analytics() } } diff --git a/Project.swift b/Project.swift index 44156fe4..d950af53 100644 --- a/Project.swift +++ b/Project.swift @@ -219,6 +219,7 @@ let project = Project( .feature( name: "DeviceTypeFeature", dependencies: [ + .Internal.AnalyticsClient, .Internal.APIClient, .Internal.Models, .Internal.SharedUI,