diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index b717b3e5..f69a24e6 100644 --- a/Modules/Sources/APIClient/APIClient.swift +++ b/Modules/Sources/APIClient/APIClient.swift @@ -93,6 +93,8 @@ public struct APIClient: Sendable { public var searchUsers: @Sendable (_ request: SearchUsersRequest) async throws -> SearchUsersResponse // DevDB + 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 // STREAMS @@ -568,6 +570,19 @@ 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, + 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 +749,12 @@ extension APIClient: DependencyKey { searchUsers: { _ in return .mock }, + deviceBrands: { _ 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/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/AppFeature/AppFeature.swift b/Modules/Sources/AppFeature/AppFeature.swift index 08a484f5..f98f52a3 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 DeviceTypeFeature @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(goTo): + screen = switch goTo { + case .index: + .devDB(.type(DeviceTypeFeature.State(content: .index))) + 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): + .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/Path.swift b/Modules/Sources/AppFeature/Navigation/Path.swift index 50637f26..aff13965 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 DeviceTypeFeature import FavoritesRootFeature import FavoritesFeature import ForumFeature @@ -49,6 +50,7 @@ public enum Path { @Reducer public enum DevDB { + case type(DeviceTypeFeature) case specifications(DeviceSpecificationsFeature) } @@ -149,6 +151,10 @@ extension Path { @MainActor @ViewBuilder private static func DevDBViews(_ store: Store) -> some View { switch store.case { + 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 d00b19fb..8fc9c691 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 DeviceTypeFeature @Reducer public struct StackTab: Reducer, Sendable { @@ -192,6 +193,15 @@ public struct StackTab: Reducer, Sendable { private func handleDevDBPathNavigation(action: Path.DevDB.Action, state: inout State) -> Effect { switch action { + case let .type(.delegate(.openDevice(tag))): + state.path.append(.devDB(.specifications(DeviceSpecificationsFeature.State(tag: tag, subTag: nil)))) + + 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))))) + case let .specifications(.delegate(.openDevice(tag, subTag))): state.path.append(.devDB(.specifications(DeviceSpecificationsFeature.State(tag: tag, subTag: subTag)))) @@ -480,8 +490,17 @@ 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: + state.path.append(.devDB(.type(DeviceTypeFeature.State(content: .index)))) + 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): + 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..50dcba3c 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,16 +129,23 @@ 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 + if let type = DeviceType(rawValue: url.pathComponents[2]) { // /devdb/phones + return .device(.vendorsList(type)) } else { // /devdb/apple_iphone_13 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)) } + } else if url.pathComponents.count == 2, url.pathComponents[1] == "devdb" { + return .device(.index) } } 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 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 new file mode 100644 index 00000000..7a3ad496 --- /dev/null +++ b/Modules/Sources/DeviceTypeFeature/DeviceTypeFeature.swift @@ -0,0 +1,154 @@ +// +// DeviceVendorFeature.swift +// ForPDA +// +// Created by Xialtal on 2.04.26. +// + +import Foundation +import ComposableArchitecture +import APIClient +import Models +import ToastClient +import AnalyticsClient + +@Reducer +public struct DeviceTypeFeature: Reducer, Sendable { + + public init() {} + + // MARK: - Category + + public enum CategorySelection: Int, Equatable { + case all + case actual + } + + // MARK: - State + + @ObservableState + public struct State: Equatable { + public let content: DeviceTypeContent + + var vendor: DeviceVendor? + var vendorsList: DeviceVendorsList? + + var categorySelection: CategorySelection = .actual + var isLoading = false + + public init( + content: DeviceTypeContent + ) { + self.content = content + } + } + + // MARK: - Action + + public enum Action: ViewAction { + case view(View) + public enum View { + case onAppear + case deviceButtonTapped(String) + case typeButtonTapped(DeviceType) + case vendorButtonTapped(String, DeviceType) + case changeCategoryButtonTapped(CategorySelection) + } + + case `internal`(Internal) + public enum Internal { + case loadVendorsList(DeviceType) + case vendorsListResponse(Result) + + case loadVendor(String, DeviceType) + case vendorResponse(Result) + } + + case delegate(Delegate) + public enum Delegate { + case openVendorsList(DeviceType) + case openDevice(tag: String) + case openVendor(String, DeviceType) + } + } + + // MARK: - Dependencies + + @Dependency(\.apiClient) private var apiClient + @Dependency(\.analyticsClient) private var analyticsClient + @Dependency(\.openURL) var openURL + @Dependency(\.toastClient) var toastClient + + // MARK: - Body + + public var body: some Reducer { + Reduce { state, action in + switch action { + case .view(.onAppear): + switch state.content { + case .vendorsList(let type): + return .send(.internal(.loadVendorsList(type))) + case .vendor(let name, let type): + return .send(.internal(.loadVendor(name, type))) + case .index: + break + } + return .none + + case let .view(.deviceButtonTapped(tag)): + return .send(.delegate(.openDevice(tag: tag))) + + case let .view(.typeButtonTapped(type)): + return .send(.delegate(.openVendorsList(type))) + + case let .view(.vendorButtonTapped(name, type)): + return .send(.delegate(.openVendor(name, type))) + + case let .view(.changeCategoryButtonTapped(category)): + state.categorySelection = category + return .none + + case let .internal(.loadVendorsList(type)): + state.isLoading = true + return .run { send in + let response = try await apiClient.deviceBrands(type: type) + await send(.internal(.vendorsListResponse(.success(response)))) + } catch: { error, send in + await send(.internal(.vendorsListResponse(.failure(error)))) + } + + case let .internal(.vendorsListResponse(.success(response))): + state.vendorsList = response + state.isLoading = false + return .none + + case let .internal(.loadVendor(name, type)): + state.isLoading = true + return .run { 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 .internal(.vendorResponse(.failure(let error))), + .internal(.vendorsListResponse(.failure(let error))): + state.isLoading = false + analyticsClient.capture(error) + return .run { _ in + await toastClient.showToast(.whoopsSomethingWentWrong) + } + + case .delegate: + return .none + } + } + + Analytics() + } +} diff --git a/Modules/Sources/DeviceTypeFeature/DeviceTypeScreen.swift b/Modules/Sources/DeviceTypeFeature/DeviceTypeScreen.swift new file mode 100644 index 00000000..f9df2145 --- /dev/null +++ b/Modules/Sources/DeviceTypeFeature/DeviceTypeScreen.swift @@ -0,0 +1,373 @@ +// +// DeviceTypeScreen.swift +// ForPDA +// +// Created by Xialtal on 2.04.26. +// + +import SwiftUI +import ComposableArchitecture +import Models +import SharedUI +import NukeUI +import SFSafeSymbols + +@ViewAction(for: DeviceTypeFeature.self) +public struct DeviceTypeScreen: 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 { + ZStack { + Color(.Background.primary) + .ignoresSafeArea() + + List { + switch store.content { + case .index: + DeviceTypes() + case .vendorsList: + if let vendors = store.vendorsList { + VendorsList(vendors) + } + case .vendor: + if let vendor = store.vendor { + Vendor(vendor) + } + } + } + .scrollContentBackground(.hidden) + } + .navigationTitle(Text(navigationTitleText())) + .background(Color(.Background.primary)) + .overlay { + if store.isLoading { + PDALoader() + .frame(width: 24, height: 24) + } + } + .onAppear { + send(.onAppear) + } + } + } + + // MARK: - Device Types + + @ViewBuilder + private func DeviceTypes() -> some View { + Section { + ForEach(DeviceType.allCases) { type in + Row(symbol: type.icon, title: .localized(type.title)) { + send(.typeButtonTapped(type)) + } + } + } + .listRowBackground(Color(.Background.teritary)) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + } + + // MARK: - Vendors + + @ViewBuilder + private func VendorsList(_ vendors: DeviceVendorsList) -> some View { + Header( + actualCount: vendors.actualCount, + allCount: vendors.vendors.count + ) + + VendorsInfo(vendors.vendors, type: vendors.type) + } + + @ViewBuilder + private func VendorsInfo(_ vendors: [DeviceVendorsList.VendorInfo], type: DeviceType) -> some View { + Section { + ForEach(vendors) { vendor in + WithPerceptionTracking { + if store.categorySelection == .all { + Row(title: .text(vendor.name)) { + send(.vendorButtonTapped(vendor.tag, type)) + } + } else if store.categorySelection == .actual, vendor.isActual { + Row(title: .text(vendor.name)) { + send(.vendorButtonTapped(vendor.tag, 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.devices.count + ) + + VendorDevices(vendor.devices) + } + + // MARK: - Products + + @ViewBuilder + private func VendorDevices(_ devices: [DeviceVendor.DeviceInfo]) -> some View { + Section { + ForEach(devices) { device in + WithPerceptionTracking { + if store.categorySelection == .all { + VendorDeviceInfoRow(device) + } else if store.categorySelection == .actual, device.isActual { + VendorDeviceInfoRow(device) + } + } + } + } + .listRowBackground(Color(.Background.teritary)) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + } + + @ViewBuilder + private func VendorDeviceInfoRow(_ device: DeviceVendor.DeviceInfo) -> some View { + VStack(alignment: .leading, spacing: 12) { + Button { + send(.deviceButtonTapped(device.tag)) + } label: { + Text(verbatim: device.name) + .font(.title2) + .fontWeight(.bold) + .foregroundStyle(Color(.Labels.primary)) + } + .buttonStyle(.plain) + + HStack(spacing: 16) { + LazyImage(url: device.imageUrl) { state in + Group { + if let image = state.image { + image + .resizable() + .scaledToFit() + .frame(width: 74, height: 74) + } else { + Color(.systemBackground) + } + } + .skeleton(with: state.isLoading, shape: .rectangle) + } + .padding(.top, 8) + .frame(maxHeight: .infinity, alignment: .top) + + VendorDeviceSpecifications(device.entries) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 19) + .background( + Color(.Background.teritary) + .clipShape(RoundedRectangle(cornerRadius: 10)) + ) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 8, trailing: 0)) + } + + @ViewBuilder + private func VendorDeviceSpecifications(_ specifications: [DeviceVendor.DeviceInfo.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 { + 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) + } + } + + // 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 + + private enum RowTitle { + case text(String) + case localized(LocalizedStringKey) + } + + @ViewBuilder + private func Row(symbol: SFSymbol? = nil, title: RowTitle, 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) + } + + 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) + + 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 + 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 switch store.content { + case .index: + String(localized: "Devices", bundle: .module) + case .vendorsList: + if let vendorsList = store.vendorsList { + vendorsList.typeName + } else { + String(localized: "Loading...", bundle: .module) + } + case .vendor: + if let vendor = store.vendor { + "\(vendor.name) (\(vendor.categoryName))" + } else { + String(localized: "Loading...", bundle: .module) + } + } + } +} + +// MARK: - Previews + +#Preview("Index") { + NavigationStack { + DeviceTypeScreen( + store: Store( + initialState: DeviceTypeFeature.State( + content: .index + ) + ) { + DeviceTypeFeature() + } + ) + } +} + +#Preview("Phone Brands") { + NavigationStack { + DeviceTypeScreen( + store: Store( + initialState: DeviceTypeFeature.State( + content: .vendorsList(.phone) + ) + ) { + DeviceTypeFeature() + } + ) + } +} + +#Preview("Phone Vendor") { + NavigationStack { + DeviceTypeScreen( + store: Store( + initialState: DeviceTypeFeature.State( + content: .vendor("apple", type: .phone) + ) + ) { + DeviceTypeFeature() + } + ) + } +} diff --git a/Modules/Sources/DeviceTypeFeature/Models/DeviceTypeContent.swift b/Modules/Sources/DeviceTypeFeature/Models/DeviceTypeContent.swift new file mode 100644 index 00000000..53f6a2af --- /dev/null +++ b/Modules/Sources/DeviceTypeFeature/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 vendorsList(DeviceType) + case vendor(String, type: DeviceType) +} diff --git a/Modules/Sources/DeviceTypeFeature/Models/Extensions/DeviceType+Extension.swift b/Modules/Sources/DeviceTypeFeature/Models/Extensions/DeviceType+Extension.swift new file mode 100644 index 00000000..a8a7f1d8 --- /dev/null +++ b/Modules/Sources/DeviceTypeFeature/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/DeviceTypeFeature/Resources/Localizable.xcstrings b/Modules/Sources/DeviceTypeFeature/Resources/Localizable.xcstrings new file mode 100644 index 00000000..7d812d22 --- /dev/null +++ b/Modules/Sources/DeviceTypeFeature/Resources/Localizable.xcstrings @@ -0,0 +1,106 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "Actual" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Актуальные" + } + } + } + }, + "All" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Всего" + } + } + } + }, + "Devices" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Устройства" + } + } + } + }, + "E-Books" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Эл. книги" + } + } + } + }, + "Loading..." : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузка…" + } + } + } + }, + "Pads" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Планшеты" + } + } + } + }, + "Phones" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Телефоны" + } + } + } + }, + "Show actual" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать актуальные" + } + } + } + }, + "Show all" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать все" + } + } + } + }, + "Smart Watch" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Смарт часы" + } + } + } + } + }, + "version" : "1.1" +} \ No newline at end of file diff --git a/Modules/Sources/Models/DevDB/DeviceGoTo.swift b/Modules/Sources/Models/DevDB/DeviceGoTo.swift new file mode 100644 index 00000000..ec9556d0 --- /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 vendorsList(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 dd1ce62b..ebc3211a 100644 --- a/Modules/Sources/Models/DevDB/DeviceType.swift +++ b/Modules/Sources/Models/DevDB/DeviceType.swift @@ -5,10 +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" - case unknown + + public var id: String { + self.rawValue + } } diff --git a/Modules/Sources/Models/DevDB/DeviceVendor.swift b/Modules/Sources/Models/DevDB/DeviceVendor.swift new file mode 100644 index 00000000..cb9ec922 --- /dev/null +++ b/Modules/Sources/Models/DevDB/DeviceVendor.swift @@ -0,0 +1,123 @@ +// +// DeviceVendor.swift +// ForPDA +// +// Created by Xialtal on 2.04.26. +// + +import Foundation + +public struct DeviceVendor: Sendable, Equatable { + public let type: DeviceType + public let name: String + public let code: String + public let categoryName: String + public let devices: [DeviceInfo] + + public var actualCount: Int { + return devices.count(where: { $0.isActual }) + } + + public struct DeviceInfo: Sendable, Identifiable, Equatable { + 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, Equatable { + 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, + devices: [DeviceInfo] + ) { + self.type = type + self.name = name + self.code = code + self.categoryName = categoryName + self.devices = devices + } +} + +public extension DeviceVendor { + static let mock = DeviceVendor( + type: .phone, + name: "Apple", + code: "apple", + categoryName: "Смартфоны", + devices: [ + .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/Models/DevDB/DeviceVendorsList.swift b/Modules/Sources/Models/DevDB/DeviceVendorsList.swift new file mode 100644 index 00000000..979e5d35 --- /dev/null +++ b/Modules/Sources/Models/DevDB/DeviceVendorsList.swift @@ -0,0 +1,76 @@ +// +// DeviceBrands.swift +// ForPDA +// +// Created by Xialtal on 3.04.26. +// + +public struct DeviceVendorsList: Sendable, Equatable { + public let type: DeviceType + public let typeName: String + public let vendors: [VendorInfo] + + public var actualCount: Int { + return vendors.count(where: { $0.isActual }) + } + + public struct VendorInfo: 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: [VendorInfo] + ) { + self.type = type + self.typeName = typeName + self.vendors = brands + } +} + +public extension DeviceVendorsList { + static let mock = DeviceVendorsList( + 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 ebc43cd1..e71e659f 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,120 @@ public struct DevDBParser { ) } - // MARK: - Images + // MARK: - Device Brands Response + + public static func parseDeviceBrands(from string: String) throws(ParsingError) -> DeviceVendorsList { + 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 DeviceVendorsList( + type: DeviceType(rawValue: type)!, + typeName: typeName, + brands: try parseDeviceVendorsList(brandsRaw) + ) + } + + // MARK: - Device Vendors List + + 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, + 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 { + 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: 5] as? String, + let code = array[safe: 4] as? String, + let devicesRaw = array[safe: 6] as? [[Any]] else { + throw ParsingError.failedToCastFields + } + + return DeviceVendor( + type: DeviceType(rawValue: type)!, + name: name, + code: code, + categoryName: categoryName, + devices: try parseVendorDevices(devicesRaw) + ) + } + + // MARK: - Vendor Products + + 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, + let url = product[safe: 2] as? String, + let isActual = product[safe: 3] as? Int, + let entriesRaw = product[4] as? [[Any]] else { + throw ParsingError.failedToCastFields + } + + devices.append(.init( + tag: tag, + name: name, + imageUrl: URL(string: url)!, + entries: try parseVendorDeviceEntry(entriesRaw), + isActual: isActual != 0 + )) + } + return devices + } + + // MARK: - Vendor Device 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 { + 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 +181,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 +196,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..e51251a1 100644 --- a/Modules/Sources/ParsingClient/ParsingClient.swift +++ b/Modules/Sources/ParsingClient/ParsingClient.swift @@ -62,6 +62,8 @@ public struct ParsingClient: Sendable { public var parseQmsChat: @Sendable (_ response: String) async throws -> QMSChat // DevDB + 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 } @@ -162,6 +164,12 @@ 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) + }, parseDeviceSpecifications: { response in return try DevDBParser.parse(from: response) } diff --git a/Project.swift b/Project.swift index 702677ea..d950af53 100644 --- a/Project.swift +++ b/Project.swift @@ -42,6 +42,7 @@ let project = Project( .Internal.DeeplinkHandler, .Internal.DeveloperFeature, .Internal.DeviceSpecificationsFeature, + .Internal.DeviceTypeFeature, .Internal.FavoritesFeature, .Internal.FavoritesRootFeature, .Internal.ForumFeature, @@ -214,6 +215,19 @@ let project = Project( .SPM.TCA ] ), + + .feature( + name: "DeviceTypeFeature", + dependencies: [ + .Internal.AnalyticsClient, + .Internal.APIClient, + .Internal.Models, + .Internal.SharedUI, + .Internal.ToastClient, + .SPM.SFSafeSymbols, + .SPM.TCA + ] + ), .feature( name: "FavoritesFeature", @@ -1043,6 +1057,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 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")