From efc29fd2a1194ddf2ab7e624db3723ea91f7a73f Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 23 Dec 2025 20:20:40 +0300 Subject: [PATCH 01/25] Add device specifications endpoint --- Modules/Sources/APIClient/APIClient.swift | 14 ++ .../DevDB/DeviceSpecificationsResponse.swift | 127 ++++++++++++++++++ Modules/Sources/Models/DevDB/DeviceType.swift | 14 ++ .../ParsingClient/Parsers/DevDBParser.swift | 105 +++++++++++++++ .../Sources/ParsingClient/ParsingClient.swift | 6 + 5 files changed, 266 insertions(+) create mode 100644 Modules/Sources/Models/DevDB/DeviceSpecificationsResponse.swift create mode 100644 Modules/Sources/Models/DevDB/DeviceType.swift create mode 100644 Modules/Sources/ParsingClient/Parsers/DevDBParser.swift diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index 656a5f47..ab7c7485 100644 --- a/Modules/Sources/APIClient/APIClient.swift +++ b/Modules/Sources/APIClient/APIClient.swift @@ -85,6 +85,9 @@ public struct APIClient: Sendable { public var search: @Sendable (_ request: SearchRequest) async throws -> SearchResponse public var searchUsers: @Sendable (_ request: SearchUsersRequest) async throws -> SearchUsersResponse + // DevDB + public var deviceSpecifications: @Sendable (_ tag: String, _ subTag: String) async throws -> DeviceSpecificationsResponse + // STREAMS public var connectionState: @Sendable () -> AsyncStream = { .finished } public var notificationStream: @Sendable () -> AsyncStream = { .finished } @@ -511,6 +514,14 @@ extension APIClient: DependencyKey { return try await parser.parseSearchUsers(response) }, + // MARK: - Device Specs + + deviceSpecifications: { tag, subTag in + let command = DeviceCommand.entry(tag: tag, subTag: subTag) + let response = try await api.send(command) + return try await parser.parseDeviceSpecifications(response: response) + }, + // MARK: - Streams connectionState: { @@ -659,6 +670,9 @@ extension APIClient: DependencyKey { searchUsers: { _ in return .mock }, + deviceSpecifications: { _, _ in + return .mock + }, connectionState: { return .finished }, diff --git a/Modules/Sources/Models/DevDB/DeviceSpecificationsResponse.swift b/Modules/Sources/Models/DevDB/DeviceSpecificationsResponse.swift new file mode 100644 index 00000000..a5b78ece --- /dev/null +++ b/Modules/Sources/Models/DevDB/DeviceSpecificationsResponse.swift @@ -0,0 +1,127 @@ +// +// DeviceSpecificationsResponse.swift +// ForPDA +// +// Created by Xialtal on 14.12.25. +// + +import Foundation + +public struct DeviceSpecificationsResponse: Sendable { + public let tag: String + public let type: DeviceType + public let vendorName: String + public let deviceName: String + public let editionName: String + public let categoryName: String + public let images: [DeviceImage] + public let editions: [Edition] + public let specifications: [Specification] + + public struct DeviceImage: Sendable { + public let url: URL + public let fullUrl: URL + public let isFront: Bool + + public init(url: URL, fullUrl: URL, isFront: Bool) { + self.url = url + self.fullUrl = fullUrl + self.isFront = isFront + } + } + + public struct Edition: Sendable { + public let name: String + public let subTag: String + + public init(name: String, subTag: String) { + self.name = name + self.subTag = subTag + } + } + + public struct Specification: Sendable { + public let id: Int + public let title: String + public var entries: [(name: String, value: String)] + + public init(id: Int, title: String, entries: [(String, String)]) { + self.id = id + self.title = title + self.entries = entries + } + } + + public init( + tag: String, + type: DeviceType, + vendorName: String, + deviceName: String, + editionName: String, + categoryName: String, + images: [DeviceImage], + editions: [Edition], + specifications: [Specification] + ) { + self.tag = tag + self.type = type + self.vendorName = vendorName + self.deviceName = deviceName + self.editionName = editionName + self.categoryName = categoryName + self.images = images + self.editions = editions + self.specifications = specifications + } +} + +public extension DeviceSpecificationsResponse { + static let mock = DeviceSpecificationsResponse( + tag: "apple", + type: .phone, + vendorName: "Apple", + deviceName: "iPhone 13", + editionName: "Edition", + categoryName: "Смартфоны", + images: [ + .init( + url: URL(string: "https://4pda.to/static/img/db/img61570d87d79de1.70561421p.jpg?_=1633095064")!, + fullUrl: URL(string: "https://4pda.to/static/img/db/img61570d87d79de1.70561421n.jpg?_=1633095064")!, + isFront: true + ), + .init( + url: URL(string: "https://4pda.to/static/img/db/img61570d889f0d21.00610039p.jpg?_=1633095076")!, + fullUrl: URL(string: "https://4pda.to/static/img/db/img61570d889f0d21.00610039n.jpg?_=1633095076")!, + isFront: false + ) + ], + editions: [ + .init( + name: "pro", + subTag: "iPhone 13 Pro" + ) + ], + specifications: [ + .init( + id: 0, + title: "Общее", + entries: [ + (name: "Производитель:", value: "Apple"), + (name: "Модель:", value: "iPhone 13"), + (name: "Операционная система:", value: "iOS 15, iOS 16, iOS 17, iOS 18") + ] + ), + .init( + id: 17, + title: "Коммуникации", + entries: [ + (name: "Bluetooth:", value: "5.0"), + ( + name: "Телефон:", + value: "5G , GSM (850, 900, 1800, 1900), LTE (700 (12/17/28), 800 (20), 850 (5/26), 900 (8)..." + ) + ] + ) + ] + ) +} diff --git a/Modules/Sources/Models/DevDB/DeviceType.swift b/Modules/Sources/Models/DevDB/DeviceType.swift new file mode 100644 index 00000000..dd1ce62b --- /dev/null +++ b/Modules/Sources/Models/DevDB/DeviceType.swift @@ -0,0 +1,14 @@ +// +// DeviceType.swift +// ForPDA +// +// Created by Xialtal on 14.12.25. +// + +public enum DeviceType: String, Sendable { + case phone = "phones" + case ebook = "ebook" + case pad = "pad" + case smartWatch = "smartwatch" + case unknown +} diff --git a/Modules/Sources/ParsingClient/Parsers/DevDBParser.swift b/Modules/Sources/ParsingClient/Parsers/DevDBParser.swift new file mode 100644 index 00000000..ae298e18 --- /dev/null +++ b/Modules/Sources/ParsingClient/Parsers/DevDBParser.swift @@ -0,0 +1,105 @@ +// +// DevDBParser.swift +// ForPDA +// +// Created by Xialtal on 14.12.25. +// + +import Foundation +import Models + +public struct DevDBParser { + + // MARK: - Device Specs Response + + public static func parse(from string: String) throws(ParsingError) -> DeviceSpecificationsResponse { + 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 tag = array[safe: 4] as? String, + let vendorName = array[safe: 5] as? String, + let deviceName = array[safe: 6] as? String, + let editionName = array[safe: 7] as? String, + let editionsRaw = array[safe: 8] as? [[Any]], + let imagesRaw = array[safe: 9] as? [[Any]], + let specsRaw = array[safe: 10] as? [[Any]] else { + throw ParsingError.failedToCastFields + } + + return DeviceSpecificationsResponse( + tag: tag, + type: DeviceType(rawValue: type) ?? .unknown, + vendorName: vendorName, + deviceName: deviceName, + editionName: editionName, + categoryName: categoryName, + images: try parseDeviceImages(imagesRaw), + editions: try parseDeviceEditions(editionsRaw), + specifications: try parseDeviceSpecifications(specsRaw) + ) + } + + // MARK: - Images + + private static func parseDeviceImages(_ imagesRaw: [[Any]]) throws(ParsingError) -> [DeviceSpecificationsResponse.DeviceImage] { + var images: [DeviceSpecificationsResponse.DeviceImage] = [] + for image in imagesRaw { + guard let isDeviceFront = image[safe: 0] as? Int, + let url = image[safe: 1] as? String, + let fullUrl = image[safe: 2] as? String else { + throw ParsingError.failedToCastFields + } + + images.append(.init( + url: URL(string: url)!, + fullUrl: URL(string: fullUrl)!, + isFront: isDeviceFront == 1 + )) + } + return images + } + + // MARK: - Editions + + private static func parseDeviceEditions(_ editionsRaw: [[Any]]) throws(ParsingError) -> [DeviceSpecificationsResponse.Edition] { + var editions: [DeviceSpecificationsResponse.Edition] = [] + for edition in editionsRaw { + guard let name = edition[safe: 0] as? String, + let subTag = edition[safe: 1] as? String else { + throw ParsingError.failedToCastFields + } + + editions.append(.init(name: name, subTag: subTag)) + } + return editions + } + + // MARK: - Specifications + + private static func parseDeviceSpecifications(_ specsRaw: [[Any]]) throws(ParsingError) -> [DeviceSpecificationsResponse.Specification] { + var specs: [DeviceSpecificationsResponse.Specification] = [] + for (index, spec) in specsRaw.enumerated() { + guard let specType = spec[safe: 0] as? Int, + let title = spec[safe: 2] as? String else { + throw ParsingError.failedToCastFields + } + + if specType == 0 { // category + specs.append(.init(id: index, title: title, entries: [])) + } else { + guard let value = spec[safe: 4] as? String else { + throw ParsingError.failedToCastFields + } + specs[specs.count - 1].entries.append((title, value)) + } + } + return specs + } +} diff --git a/Modules/Sources/ParsingClient/ParsingClient.swift b/Modules/Sources/ParsingClient/ParsingClient.swift index 615b8f15..ef89625e 100644 --- a/Modules/Sources/ParsingClient/ParsingClient.swift +++ b/Modules/Sources/ParsingClient/ParsingClient.swift @@ -55,6 +55,9 @@ public struct ParsingClient: Sendable { public var parseQmsList: @Sendable (_ response: String) async throws -> QMSList public var parseQmsUser: @Sendable (_ response: String) async throws -> QMSUser public var parseQmsChat: @Sendable (_ response: String) async throws -> QMSChat + + // DevDB + public var parseDeviceSpecifications: @Sendable (_ response: String) async throws -> DeviceSpecificationsResponse } // MARK: - Dependency Key @@ -138,6 +141,9 @@ extension ParsingClient: DependencyKey { }, parseQmsChat: { response in return try QMSChatParser.parse(from: response) + }, + parseDeviceSpecifications: { response in + return try DevDBParser.parse(from: response) } ) } From 62bdd6853c88cd739e02f36757b9ab7da3c0b5d6 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 23 Dec 2025 21:01:28 +0300 Subject: [PATCH 02/25] Add field to device specs model --- .../Models/DevDB/DeviceSpecificationsResponse.swift | 8 ++++++-- Modules/Sources/ParsingClient/Parsers/DevDBParser.swift | 6 ++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Modules/Sources/Models/DevDB/DeviceSpecificationsResponse.swift b/Modules/Sources/Models/DevDB/DeviceSpecificationsResponse.swift index a5b78ece..9844d6a1 100644 --- a/Modules/Sources/Models/DevDB/DeviceSpecificationsResponse.swift +++ b/Modules/Sources/Models/DevDB/DeviceSpecificationsResponse.swift @@ -17,6 +17,7 @@ public struct DeviceSpecificationsResponse: Sendable { public let images: [DeviceImage] public let editions: [Edition] public let specifications: [Specification] + public let isMyDevice: Bool public struct DeviceImage: Sendable { public let url: URL @@ -61,7 +62,8 @@ public struct DeviceSpecificationsResponse: Sendable { categoryName: String, images: [DeviceImage], editions: [Edition], - specifications: [Specification] + specifications: [Specification], + isMyDevice: Bool ) { self.tag = tag self.type = type @@ -72,6 +74,7 @@ public struct DeviceSpecificationsResponse: Sendable { self.images = images self.editions = editions self.specifications = specifications + self.isMyDevice = isMyDevice } } @@ -122,6 +125,7 @@ public extension DeviceSpecificationsResponse { ) ] ) - ] + ], + isMyDevice: true ) } diff --git a/Modules/Sources/ParsingClient/Parsers/DevDBParser.swift b/Modules/Sources/ParsingClient/Parsers/DevDBParser.swift index ae298e18..cef66277 100644 --- a/Modules/Sources/ParsingClient/Parsers/DevDBParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/DevDBParser.swift @@ -29,7 +29,8 @@ public struct DevDBParser { let editionName = array[safe: 7] as? String, let editionsRaw = array[safe: 8] as? [[Any]], let imagesRaw = array[safe: 9] as? [[Any]], - let specsRaw = array[safe: 10] as? [[Any]] else { + let specsRaw = array[safe: 10] as? [[Any]], + let isMyDevice = array[safe: 11] as? Int else { throw ParsingError.failedToCastFields } @@ -42,7 +43,8 @@ public struct DevDBParser { categoryName: categoryName, images: try parseDeviceImages(imagesRaw), editions: try parseDeviceEditions(editionsRaw), - specifications: try parseDeviceSpecifications(specsRaw) + specifications: try parseDeviceSpecifications(specsRaw), + isMyDevice: isMyDevice == 1 ) } From 6be8cd8cd275646cf19ac3bfc29701c665bd7d6c Mon Sep 17 00:00:00 2001 From: Xialtal Date: Thu, 25 Dec 2025 17:11:20 +0300 Subject: [PATCH 03/25] [WIP] DevDB Specifications --- Modules/Sources/AppFeature/AppFeature.swift | 3 + .../Sources/AppFeature/Navigation/Path.swift | 20 ++++ .../AppFeature/Navigation/StackTab.swift | 23 +++++ .../DeeplinkHandler/DeeplinkHandler.swift | 18 ++++ .../DeviceSpecificationsFeature.swift | 96 +++++++++++++++++++ .../DeviceSpecificationsScreen.swift | 70 ++++++++++++++ .../Resources/Localizable.xcstrings | 9 ++ .../DevDB/DeviceSpecificationsResponse.swift | 32 ++++--- .../ParsingClient/Parsers/DevDBParser.swift | 2 +- Project.swift | 12 +++ 10 files changed, 273 insertions(+), 12 deletions(-) create mode 100644 Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsFeature.swift create mode 100644 Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift create mode 100644 Modules/Sources/DeviceSpecificationsFeature/Resources/Localizable.xcstrings diff --git a/Modules/Sources/AppFeature/AppFeature.swift b/Modules/Sources/AppFeature/AppFeature.swift index 27e3aaf4..953ef0f8 100644 --- a/Modules/Sources/AppFeature/AppFeature.swift +++ b/Modules/Sources/AppFeature/AppFeature.swift @@ -35,6 +35,7 @@ import NotificationsClient import ToastClient import Combine import SearchResultFeature +import DeviceSpecificationsFeature @Reducer public struct AppFeature: Reducer, Sendable { @@ -601,6 +602,8 @@ public struct AppFeature: Reducer, Sendable { screen = .articles(.article(ArticleFeature.State(articlePreview: preview))) 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 .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 2e9b20d7..30b52a2a 100644 --- a/Modules/Sources/AppFeature/Navigation/Path.swift +++ b/Modules/Sources/AppFeature/Navigation/Path.swift @@ -11,6 +11,7 @@ import AnnouncementFeature import ArticleFeature import ArticlesListFeature import DeveloperFeature +import DeviceSpecificationsFeature import FavoritesRootFeature import FavoritesFeature import ForumFeature @@ -30,6 +31,7 @@ import AuthFeature @Reducer public enum Path { case articles(Articles.Body = Articles.body) + case devDB(DevDB.Body = DevDB.body) case favorites(FavoritesFeature) case forum(Forum.Body = Forum.body) case profile(Profile.Body = Profile.body) @@ -44,6 +46,11 @@ public enum Path { case article(ArticleFeature) } + @Reducer + public enum DevDB { + case specifications(DeviceSpecificationsFeature) + } + @Reducer public enum Profile { case profile(ProfileFeature) @@ -82,6 +89,7 @@ public enum Path { extension Path.State: Equatable {} extension Path.Articles.State: Equatable {} +extension Path.DevDB.State: Equatable {} extension Path.Profile.State: Equatable {} extension Path.Forum.State: Equatable {} extension Path.Settings.State: Equatable {} @@ -95,6 +103,9 @@ extension Path { case let .articles(path): ArticlesViews(path) + case let .devDB(path): + DevDBViews(path) + case let .favorites(store): FavoritesScreen(store: store) .tracking(for: FavoritesScreen.self) @@ -133,6 +144,15 @@ extension Path { } } + @MainActor @ViewBuilder + private static func DevDBViews(_ store: Store) -> some View { + switch store.case { + case let .specifications(store): + DeviceSpecificationsScreen(store: store) + .tracking(for: DeviceSpecificationsScreen.self) + } + } + @MainActor @ViewBuilder private static func ProfileViews(_ store: Store) -> some View { switch store.case { diff --git a/Modules/Sources/AppFeature/Navigation/StackTab.swift b/Modules/Sources/AppFeature/Navigation/StackTab.swift index 558f3f13..b00f94c3 100644 --- a/Modules/Sources/AppFeature/Navigation/StackTab.swift +++ b/Modules/Sources/AppFeature/Navigation/StackTab.swift @@ -30,6 +30,7 @@ import ReputationFeature import AuthFeature import SearchFeature import SearchResultFeature +import DeviceSpecificationsFeature @Reducer public struct StackTab: Reducer, Sendable { @@ -111,6 +112,9 @@ public struct StackTab: Reducer, Sendable { case let .articles(action): return handleArticlesPathNavigation(action: action, state: &state) + case let .devDB(action): + return handleDevDBPathNavigation(action: action, state: &state) + case let .favorites(action): return handleFavoritesPathNavigation(action: action, state: &state) @@ -157,6 +161,19 @@ public struct StackTab: Reducer, Sendable { return .none } + // MARK: - DevDB + + private func handleDevDBPathNavigation(action: Path.DevDB.Action, state: inout State) -> Effect { + switch action { + case let .specifications(.delegate(.openDevice(tag, subTag))): + state.path.append(.devDB(.specifications(DeviceSpecificationsFeature.State(tag: tag, subTag: subTag)))) + + default: + break + } + return .none + } + // MARK: - Favorites private func handleFavoritesPathNavigation(action: FavoritesFeature.Action, state: inout State) -> Effect { @@ -255,6 +272,9 @@ public struct StackTab: Reducer, Sendable { case .profile(.delegate(.openSettings)): state.path.append(.settings(.settings(SettingsFeature.State()))) + case let .profile(.delegate(.openDevice(tag))): + state.path.append(.devDB(.specifications(DeviceSpecificationsFeature.State(tag: tag, subTag: "")))) + case let .profile(.delegate(.handleUrl(url))): return handleDeeplink(url: url, state: &state) @@ -430,6 +450,9 @@ 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 .article(id: id, title: title, imageUrl: imageUrl): let preview = ArticlePreview.outerDeeplink(id: id, imageUrl: imageUrl, title: title) state.path.append(.articles(.article(ArticleFeature.State(articlePreview: preview)))) diff --git a/Modules/Sources/DeeplinkHandler/DeeplinkHandler.swift b/Modules/Sources/DeeplinkHandler/DeeplinkHandler.swift index afeb3b48..8555c830 100644 --- a/Modules/Sources/DeeplinkHandler/DeeplinkHandler.swift +++ b/Modules/Sources/DeeplinkHandler/DeeplinkHandler.swift @@ -19,6 +19,7 @@ public enum Deeplink { case user(id: Int) case qms(id: Int) case search(SearchResult) + case device(tag: String, subTag: String) } public struct DeeplinkHandler { @@ -126,6 +127,23 @@ public struct DeeplinkHandler { guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { throw .noUrlComponents(in: url) } + // devdb + + if url.pathComponents.contains("devdb") { + if url.pathComponents.count == 4 { // /devdb/phones/apple + // TODO: vendor deeplink + } else if url.pathComponents.count == 3, !url.pathComponents[2].isEmpty { + if let _ = DeviceType(rawValue: url.pathComponents[2]) { // /devdb/phones + // TODO: deviceType deeplink + } 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) + } + } + } + guard let queryItems = components.queryItems else { throw .noQueryItems(in: url) } // site search diff --git a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsFeature.swift b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsFeature.swift new file mode 100644 index 00000000..9f868766 --- /dev/null +++ b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsFeature.swift @@ -0,0 +1,96 @@ +// +// DeviceSpecificationsFeature.swift +// ForPDA +// +// Created by Xialtal on 23.12.25. +// + +import Foundation +import ComposableArchitecture +import APIClient +import Models + +@Reducer +public struct DeviceSpecificationsFeature: Reducer, Sendable { + + public init() {} + + // MARK: - State + + @ObservableState + public struct State: Equatable { + + public let tag: String + public let subTag: String? + + var specifications: DeviceSpecificationsResponse? + + var isLoading = false + + public init( + tag: String, + subTag: String? + ) { + self.tag = tag + self.subTag = subTag + } + } + + // MARK: - Action + + public enum Action: ViewAction { + case view(View) + public enum View { + case onAppear + case loadSpecifications + + case editionButtonTapped(String) + } + + case `internal`(Internal) + public enum Internal { + case specificationsResponse(DeviceSpecificationsResponse) + } + + case delegate(Delegate) + public enum Delegate { + case openDevice(tag: String, subTag: String) + } + } + + // MARK: - Dependencies + + @Dependency(\.apiClient) private var apiClient + + // MARK: - Body + + public var body: some Reducer { + Reduce { state, action in + switch action { + case .view(.onAppear): + return .send(.view(.loadSpecifications)) + + case let .view(.editionButtonTapped(subTag)): + return .send(.delegate(.openDevice(tag: state.tag, subTag: subTag))) + + case .view(.loadSpecifications): + state.isLoading = true + return .run { [tag = state.tag, subTag = state.subTag] send in + let respone = try await apiClient.deviceSpecifications( + tag: tag, + subTag: subTag ?? "" + ) + await send(.internal(.specificationsResponse(respone))) + } + + case let .internal(.specificationsResponse(response)): + state.specifications = response + state.isLoading = false + return .none + + case .delegate: + return .none + } + } + } +} diff --git a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift new file mode 100644 index 00000000..a05246e7 --- /dev/null +++ b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift @@ -0,0 +1,70 @@ +// +// DeviceSpecificationsScreen.swift +// ForPDA +// +// Created by Xialtal on 23.12.25. +// + +import SwiftUI +import ComposableArchitecture +import SharedUI + +@ViewAction(for: DeviceSpecificationsFeature.self) +public struct DeviceSpecificationsScreen: View { + + // MARK: - Properties + + @Perception.Bindable public var store: StoreOf + + // MARK: - Init + + public init(store: StoreOf) { + self.store = store + } + + // MARK: - Body + + public var body: some View { + WithPerceptionTracking { + ZStack { + Color(.Background.primary) + .ignoresSafeArea() + + if let specifications = store.specifications, !store.isLoading { + Text(specifications.deviceName) + } + } + .navigationTitle(Text("DevDB", bundle: .module)) + .navigationBarTitleDisplayMode(.inline) + .background(Color(.Background.primary)) + .overlay { + if store.isLoading { + PDALoader() + .frame(width: 24, height: 24) + } + } + .onAppear { + send(.onAppear) + } + } + } + +} + +// MARK: - Preview + +#Preview { + NavigationStack { + DeviceSpecificationsScreen( + store: Store( + initialState: DeviceSpecificationsFeature.State( + tag: "forpda", + subTag: nil + ), + ) { + DeviceSpecificationsFeature() + } + ) + } + .environment(\.tintColor, Color(.Theme.primary)) +} diff --git a/Modules/Sources/DeviceSpecificationsFeature/Resources/Localizable.xcstrings b/Modules/Sources/DeviceSpecificationsFeature/Resources/Localizable.xcstrings new file mode 100644 index 00000000..a21ea832 --- /dev/null +++ b/Modules/Sources/DeviceSpecificationsFeature/Resources/Localizable.xcstrings @@ -0,0 +1,9 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "DevDB" : { + + } + }, + "version" : "1.1" +} \ No newline at end of file diff --git a/Modules/Sources/Models/DevDB/DeviceSpecificationsResponse.swift b/Modules/Sources/Models/DevDB/DeviceSpecificationsResponse.swift index 9844d6a1..3e98cf54 100644 --- a/Modules/Sources/Models/DevDB/DeviceSpecificationsResponse.swift +++ b/Modules/Sources/Models/DevDB/DeviceSpecificationsResponse.swift @@ -7,7 +7,7 @@ import Foundation -public struct DeviceSpecificationsResponse: Sendable { +public struct DeviceSpecificationsResponse: Sendable, Equatable { public let tag: String public let type: DeviceType public let vendorName: String @@ -19,7 +19,7 @@ public struct DeviceSpecificationsResponse: Sendable { public let specifications: [Specification] public let isMyDevice: Bool - public struct DeviceImage: Sendable { + public struct DeviceImage: Sendable, Equatable { public let url: URL public let fullUrl: URL public let isFront: Bool @@ -31,7 +31,7 @@ public struct DeviceSpecificationsResponse: Sendable { } } - public struct Edition: Sendable { + public struct Edition: Sendable, Equatable { public let name: String public let subTag: String @@ -41,12 +41,22 @@ public struct DeviceSpecificationsResponse: Sendable { } } - public struct Specification: Sendable { + public struct Specification: Sendable, Equatable { public let id: Int public let title: String - public var entries: [(name: String, value: String)] + public var entries: [SpecificationEntry] - public init(id: Int, title: String, entries: [(String, String)]) { + public struct SpecificationEntry: Sendable, Equatable { + public let name: String + public let value: String + + public init(name: String, value: String) { + self.name = name + self.value = value + } + } + + public init(id: Int, title: String, entries: [SpecificationEntry]) { self.id = id self.title = title self.entries = entries @@ -109,17 +119,17 @@ public extension DeviceSpecificationsResponse { id: 0, title: "Общее", entries: [ - (name: "Производитель:", value: "Apple"), - (name: "Модель:", value: "iPhone 13"), - (name: "Операционная система:", value: "iOS 15, iOS 16, iOS 17, iOS 18") + .init(name: "Производитель:", value: "Apple"), + .init(name: "Модель:", value: "iPhone 13"), + .init(name: "Операционная система:", value: "iOS 15, iOS 16, iOS 17, iOS 18") ] ), .init( id: 17, title: "Коммуникации", entries: [ - (name: "Bluetooth:", value: "5.0"), - ( + .init(name: "Bluetooth:", value: "5.0"), + .init( name: "Телефон:", value: "5G , GSM (850, 900, 1800, 1900), LTE (700 (12/17/28), 800 (20), 850 (5/26), 900 (8)..." ) diff --git a/Modules/Sources/ParsingClient/Parsers/DevDBParser.swift b/Modules/Sources/ParsingClient/Parsers/DevDBParser.swift index cef66277..a9138fb5 100644 --- a/Modules/Sources/ParsingClient/Parsers/DevDBParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/DevDBParser.swift @@ -99,7 +99,7 @@ public struct DevDBParser { guard let value = spec[safe: 4] as? String else { throw ParsingError.failedToCastFields } - specs[specs.count - 1].entries.append((title, value)) + specs[specs.count - 1].entries.append(.init(name: title, value: value)) } } return specs diff --git a/Project.swift b/Project.swift index 04f98f92..fc046b16 100644 --- a/Project.swift +++ b/Project.swift @@ -35,6 +35,7 @@ let project = Project( .Internal.CacheClient, .Internal.DeeplinkHandler, .Internal.DeveloperFeature, + .Internal.DeviceSpecificationsFeature, .Internal.FavoritesFeature, .Internal.FavoritesRootFeature, .Internal.ForumFeature, @@ -178,6 +179,16 @@ let project = Project( .SPM.TCA ] ), + + .feature( + name: "DeviceSpecificationsFeature", + dependencies: [ + .Internal.APIClient, + .Internal.Models, + .Internal.SharedUI, + .SPM.TCA + ] + ), .feature( name: "FavoritesFeature", @@ -910,6 +921,7 @@ extension TargetDependency.Internal { static let BookmarksFeature = TargetDependency.target(name: "BookmarksFeature") static let DeeplinkHandler = TargetDependency.target(name: "DeeplinkHandler") static let DeveloperFeature = TargetDependency.target(name: "DeveloperFeature") + static let DeviceSpecificationsFeature = TargetDependency.target(name: "DeviceSpecificationsFeature") static let FavoritesFeature = TargetDependency.target(name: "FavoritesFeature") static let FavoritesRootFeature = TargetDependency.target(name: "FavoritesRootFeature") static let ForumFeature = TargetDependency.target(name: "ForumFeature") From 29172e3664d9dafc06cf992ed0dd1fc2b8cb7b3e Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 27 Dec 2025 22:37:55 +0300 Subject: [PATCH 04/25] Make profile devices clickable --- .../AnalyticsClient/Events/ProfileEvent.swift | 3 ++ .../Analytics/ProfileFeature+Analytics.swift | 3 ++ .../ProfileFeature/ProfileFeature.swift | 5 ++++ .../ProfileFeature/ProfileScreen.swift | 30 +++++++++++-------- 4 files changed, 28 insertions(+), 13 deletions(-) diff --git a/Modules/Sources/AnalyticsClient/Events/ProfileEvent.swift b/Modules/Sources/AnalyticsClient/Events/ProfileEvent.swift index 9b87bc71..dc4c7118 100644 --- a/Modules/Sources/AnalyticsClient/Events/ProfileEvent.swift +++ b/Modules/Sources/AnalyticsClient/Events/ProfileEvent.swift @@ -16,6 +16,7 @@ public enum ProfileEvent: Event { case reputationTapped case searchTopicsTapped case searchRepliesTapped + case deviceButtonTapped(String) case userLoaded(Int) case userLoadingFailed case achievementTapped @@ -30,6 +31,8 @@ public enum ProfileEvent: Event { switch self { case let .userLoaded(userId): return ["userId": String(userId)] + case let .deviceButtonTapped(tag): + return ["deviceTag": tag] default: return nil } diff --git a/Modules/Sources/ProfileFeature/Analytics/ProfileFeature+Analytics.swift b/Modules/Sources/ProfileFeature/Analytics/ProfileFeature+Analytics.swift index dd6c8cda..9330c15a 100644 --- a/Modules/Sources/ProfileFeature/Analytics/ProfileFeature+Analytics.swift +++ b/Modules/Sources/ProfileFeature/Analytics/ProfileFeature+Analytics.swift @@ -50,6 +50,9 @@ extension ProfileFeature { case .view(.searchRepliesButtonTapped): analyticsClient.log(ProfileEvent.searchRepliesTapped) + case .view(.deviceButtonTapped(let tag)): + analyticsClient.log(ProfileEvent.deviceButtonTapped(tag)) + case .view(.deeplinkTapped(_, let type)): switch type { case .about: diff --git a/Modules/Sources/ProfileFeature/ProfileFeature.swift b/Modules/Sources/ProfileFeature/ProfileFeature.swift index e96f6901..1d3409e5 100644 --- a/Modules/Sources/ProfileFeature/ProfileFeature.swift +++ b/Modules/Sources/ProfileFeature/ProfileFeature.swift @@ -76,6 +76,7 @@ public struct ProfileFeature: Reducer, Sendable { case reputationButtonTapped case searchTopicsButtonTapped case searchRepliesButtonTapped + case deviceButtonTapped(String) case deeplinkTapped(URL, ProfileDeeplinkType) } @@ -94,6 +95,7 @@ public struct ProfileFeature: Reducer, Sendable { case openQms case openSettings case openHistory + case openDevice(String) case openReputation(Int) case openSearch(SearchResult) case handleUrl(URL) @@ -126,6 +128,9 @@ public struct ProfileFeature: Reducer, Sendable { await send(.internal(.userResponse(.failure(error)))) } + case let .view(.deviceButtonTapped(tag)): + return .send(.delegate(.openDevice(tag))) + case .view(.historyButtonTapped): return .send(.delegate(.openHistory)) diff --git a/Modules/Sources/ProfileFeature/ProfileScreen.swift b/Modules/Sources/ProfileFeature/ProfileScreen.swift index 37c414f2..8b75aa98 100644 --- a/Modules/Sources/ProfileFeature/ProfileScreen.swift +++ b/Modules/Sources/ProfileFeature/ProfileScreen.swift @@ -348,19 +348,23 @@ public struct ProfileScreen: View { private func DevicesSection(devices: [User.Device]) -> some View { Section { ForEach(devices) { device in - HStack(spacing: 0) { - Text(device.name) - .font(.body) - .foregroundStyle(Color(.Labels.primary)) - - Spacer(minLength: 8) - - if device.main { - Circle() - .font(.title2) - .foregroundStyle(tintColor) - .frame(width: 8) - .padding(.trailing, 12) + Button { + send(.deviceButtonTapped(device.id)) + } label: { + HStack(spacing: 0) { + Text(device.name) + .font(.body) + .foregroundStyle(Color(.Labels.primary)) + + Spacer(minLength: 8) + + if device.main { + Circle() + .font(.title2) + .foregroundStyle(tintColor) + .frame(width: 8) + .padding(.trailing, 12) + } } } .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) From 03f1bd735d6a34d3e4c114a696c54719e3bc843f Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 27 Mar 2026 12:03:11 +0300 Subject: [PATCH 05/25] Post-merge fix --- Modules/Sources/AppFeature/Navigation/StackTab.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/AppFeature/Navigation/StackTab.swift b/Modules/Sources/AppFeature/Navigation/StackTab.swift index 11bb28ee..1fed14d4 100644 --- a/Modules/Sources/AppFeature/Navigation/StackTab.swift +++ b/Modules/Sources/AppFeature/Navigation/StackTab.swift @@ -481,7 +481,7 @@ public struct StackTab: Reducer, Sendable { case let .device(tag, subTag): state.path.append(.devDB(.specifications(DeviceSpecificationsFeature.State(tag: tag, subTag: subTag)))) - case let .article(id: id, title: title, imageUrl: imageUrl): + case let .article(id: id, title: title, imageUrl: imageUrl, scrollToId): let preview = ArticlePreview.outerDeeplink(id: id, imageUrl: imageUrl, title: title) state.path.append(.articles(.article(ArticleFeature.State(articlePreview: preview, scrollToId: scrollToId)))) } From a3730d1c701c1094b52abed6af4fceeed3783d0d Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 27 Mar 2026 12:03:18 +0300 Subject: [PATCH 06/25] Extract listSectionSpacing backport to SharedUI --- .../ProfileFeature/ProfileScreen.swift | 22 +------------- .../Backports/ListSectionSpacing.swift | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+), 21 deletions(-) create mode 100644 Modules/Sources/SharedUI/Backports/ListSectionSpacing.swift diff --git a/Modules/Sources/ProfileFeature/ProfileScreen.swift b/Modules/Sources/ProfileFeature/ProfileScreen.swift index 8005d674..6bbec8d9 100644 --- a/Modules/Sources/ProfileFeature/ProfileScreen.swift +++ b/Modules/Sources/ProfileFeature/ProfileScreen.swift @@ -60,7 +60,7 @@ public struct ProfileScreen: View { AchievementsSegment(user: user) } } - .listSectionSpacingBackport(28) + ._listSectionSpacing(28) .scrollContentBackground(.hidden) } else { PDALoader() @@ -638,26 +638,6 @@ private extension Date { } } -private extension View { - func listSectionSpacingBackport(_ value: CGFloat) -> some View { - self.modifier(ListSectionSpacing(value: value)) - } -} - -private struct ListSectionSpacing: ViewModifier { - - var value: CGFloat - - func body(content: Content) -> some View { - if #available(iOS 17.0, *) { - content - .listSectionSpacing(value) - } else { - content - } - } -} - extension User { var signatureAttributed: NSAttributedString? { guard let signature, !signature.isEmpty else { return nil } diff --git a/Modules/Sources/SharedUI/Backports/ListSectionSpacing.swift b/Modules/Sources/SharedUI/Backports/ListSectionSpacing.swift new file mode 100644 index 00000000..4e5e1b06 --- /dev/null +++ b/Modules/Sources/SharedUI/Backports/ListSectionSpacing.swift @@ -0,0 +1,29 @@ +// +// ListSectionSpacing.swift +// ForPDA +// +// Created by Xialtal on 27.03.26. +// + +import SwiftUI + +public extension View { + @available(iOS, deprecated: 17.0, message: "Use native listSectionSpacing instead") + func _listSectionSpacing(_ value: CGFloat) -> some View { + self.modifier(ListSectionSpacing(value: value)) + } +} + +private struct ListSectionSpacing: ViewModifier { + + var value: CGFloat + + func body(content: Content) -> some View { + if #available(iOS 17.0, *) { + content + .listSectionSpacing(value) + } else { + content + } + } +} From 8c50c4916754a32a4911b9661b72bf82c1c61f9c Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 27 Mar 2026 12:05:14 +0300 Subject: [PATCH 07/25] [WIP] Device Specifications --- .../DeviceSpecificationsScreen.swift | 117 +++++++++++++++++- .../DevDB/DeviceSpecificationsResponse.swift | 2 +- 2 files changed, 117 insertions(+), 2 deletions(-) diff --git a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift index a05246e7..aa44493e 100644 --- a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift +++ b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift @@ -8,6 +8,7 @@ import SwiftUI import ComposableArchitecture import SharedUI +import Models @ViewAction(for: DeviceSpecificationsFeature.self) public struct DeviceSpecificationsScreen: View { @@ -31,7 +32,15 @@ public struct DeviceSpecificationsScreen: View { .ignoresSafeArea() if let specifications = store.specifications, !store.isLoading { - Text(specifications.deviceName) + List { + Header(specifications) + + ForEach(specifications.specifications) { specification in + SpecificationSection(specification) + } + } + ._listSectionSpacing(28) + .scrollContentBackground(.hidden) } } .navigationTitle(Text("DevDB", bundle: .module)) @@ -49,6 +58,112 @@ public struct DeviceSpecificationsScreen: View { } } + // MARK: - Header + + @ViewBuilder + private func Header(_ specs: DeviceSpecificationsResponse) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text(verbatim: "\(specs.vendorName) \(specs.deviceName)") + .font(.title2) + .fontWeight(.bold) + .foregroundStyle(Color(.Labels.primary)) + + HeaderImages(specs.images) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 19) + .background( + Color(.Background.teritary) + .clipShape(RoundedRectangle(cornerRadius: 10)) + ) + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + } + + @ViewBuilder + private func HeaderImages(_ images: [DeviceSpecificationsResponse.DeviceImage]) -> some View { + HStack(spacing: 8) { + ForEach(images, id: \.url.hashValue) { image in + LazyImage(url: image.url) { state in + Group { + if let image = state.image { + image.resizable().scaledToFill() + } else { + Color(.systemBackground) + } + } + .skeleton(with: state.isLoading, shape: .rectangle) + } + .frame(width: 37, height: 75) + .clipped() + } + } + } + + private func SpecificationSection(_ spec: DeviceSpecificationsResponse.Specification) -> some View { + Section { + ForEach(spec.entries, id: \.name) { entry in + Row(title: entry.name, type: .description(entry.value)) + } + } header: { + SectionHeader(title: spec.title) + } + .listRowBackground(Color(.Background.teritary)) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + } + + // MARK: - Section Header + + @ViewBuilder + private func SectionHeader(title: String) -> some View { + Text(verbatim: title) + .font(.subheadline) + .foregroundStyle(Color(.Labels.teritary)) + .textCase(nil) + .offset(x: 16) + .padding(.bottom, 4) + } + + // MARK: - Row + + enum RowType { + case description(String) + case bigDescription(String) + } + + @ViewBuilder + private func Row(title: String, type: RowType, action: @escaping () -> Void = {}) -> some View { + HStack(spacing: 0) { // Hacky HStack to enable tap animations + Button { + action() + } label: { + HStack(spacing: 0) { + Text(verbatim: title) + .font(.body) + .foregroundStyle(Color(.Labels.primary)) + + Spacer(minLength: 8) + + switch type { + case let .description(text): + Text(verbatim: text) + .font(.body) + .foregroundStyle(Color(.Labels.teritary)) + + case let .bigDescription(text): + Text(verbatim: text) + .font(.body) + .foregroundStyle(Color(.Labels.teritary)) + } + } + .contentShape(Rectangle()) + } + } + .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + .buttonStyle(.plain) + .frame(height: 60) + } } // MARK: - Preview diff --git a/Modules/Sources/Models/DevDB/DeviceSpecificationsResponse.swift b/Modules/Sources/Models/DevDB/DeviceSpecificationsResponse.swift index 3e98cf54..63a9836f 100644 --- a/Modules/Sources/Models/DevDB/DeviceSpecificationsResponse.swift +++ b/Modules/Sources/Models/DevDB/DeviceSpecificationsResponse.swift @@ -41,7 +41,7 @@ public struct DeviceSpecificationsResponse: Sendable, Equatable { } } - public struct Specification: Sendable, Equatable { + public struct Specification: Sendable, Equatable, Identifiable { public let id: Int public let title: String public var entries: [SpecificationEntry] From 70640676aa349925cc7df086773065cefaa5186b Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 27 Mar 2026 12:45:19 +0300 Subject: [PATCH 08/25] Add error handling to device specifications --- .../DeviceSpecificationsFeature.swift | 26 ++++++++++++++----- Project.swift | 1 + 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsFeature.swift b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsFeature.swift index 9f868766..2b227461 100644 --- a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsFeature.swift +++ b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsFeature.swift @@ -9,6 +9,7 @@ import Foundation import ComposableArchitecture import APIClient import Models +import ToastClient @Reducer public struct DeviceSpecificationsFeature: Reducer, Sendable { @@ -42,14 +43,14 @@ public struct DeviceSpecificationsFeature: Reducer, Sendable { case view(View) public enum View { case onAppear - case loadSpecifications case editionButtonTapped(String) } case `internal`(Internal) public enum Internal { - case specificationsResponse(DeviceSpecificationsResponse) + case loadSpecifications + case specificationsResponse(Result) } case delegate(Delegate) @@ -61,6 +62,7 @@ public struct DeviceSpecificationsFeature: Reducer, Sendable { // MARK: - Dependencies @Dependency(\.apiClient) private var apiClient + @Dependency(\.toastClient) private var toastClient // MARK: - Body @@ -68,26 +70,38 @@ public struct DeviceSpecificationsFeature: Reducer, Sendable { Reduce { state, action in switch action { case .view(.onAppear): - return .send(.view(.loadSpecifications)) + return .send(.internal(.loadSpecifications)) case let .view(.editionButtonTapped(subTag)): return .send(.delegate(.openDevice(tag: state.tag, subTag: subTag))) - case .view(.loadSpecifications): + case let .view(.markAsMyDeviceButtonTapped(myDevice)): + return .none + + case .internal(.loadSpecifications): state.isLoading = true return .run { [tag = state.tag, subTag = state.subTag] send in let respone = try await apiClient.deviceSpecifications( tag: tag, subTag: subTag ?? "" ) - await send(.internal(.specificationsResponse(respone))) + await send(.internal(.specificationsResponse(.success(respone)))) + } catch: { error, send in + await send(.internal(.specificationsResponse(.failure(error)))) } - case let .internal(.specificationsResponse(response)): + case let .internal(.specificationsResponse(.success(response))): state.specifications = response state.isLoading = false return .none + case let .internal(.specificationsResponse(.failure(error))): + print(error) + state.isLoading = false + return .run { _ in + await toastClient.showToast(.whoopsSomethingWentWrong) + } + case .delegate: return .none } diff --git a/Project.swift b/Project.swift index dfa8bdab..07b32d1b 100644 --- a/Project.swift +++ b/Project.swift @@ -209,6 +209,7 @@ let project = Project( .Internal.APIClient, .Internal.Models, .Internal.SharedUI, + .Internal.ToastClient, .SPM.TCA ] ), From 3fed48c3a1821026a807758b9cbd11e34df85874 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 27 Mar 2026 12:49:29 +0300 Subject: [PATCH 09/25] Add navigationTitle for DeviceSpecifications --- .../DeviceSpecificationsScreen.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift index aa44493e..8bf201d7 100644 --- a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift +++ b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift @@ -43,7 +43,7 @@ public struct DeviceSpecificationsScreen: View { .scrollContentBackground(.hidden) } } - .navigationTitle(Text("DevDB", bundle: .module)) + .navigationTitle(Text(store.specifications?.deviceName ?? String(localized: "Loading...", bundle: .module))) .navigationBarTitleDisplayMode(.inline) .background(Color(.Background.primary)) .overlay { From 20926871ec8b6beaa6c885ca7662823e3f29adc9 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 27 Mar 2026 13:06:15 +0300 Subject: [PATCH 10/25] Add device status change for DeviceSpecifications --- .../DeviceSpecificationsFeature.swift | 37 ++++++++++++ .../DeviceSpecificationsScreen.swift | 56 +++++++++++++++++++ .../Resources/Localizable.xcstrings | 41 +++++++++++++- .../DevDB/DeviceSpecificationsResponse.swift | 2 +- 4 files changed, 133 insertions(+), 3 deletions(-) diff --git a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsFeature.swift b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsFeature.swift index 2b227461..4be4108f 100644 --- a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsFeature.swift +++ b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsFeature.swift @@ -16,10 +16,17 @@ public struct DeviceSpecificationsFeature: Reducer, Sendable { public init() {} + // MARK: - Localizations + + public enum Localization { + static let changeDeviceStatusError = LocalizedStringResource("Unable to change device status", bundle: .module) + } + // MARK: - State @ObservableState public struct State: Equatable { + @Shared(.userSession) var userSession public let tag: String public let subTag: String? @@ -27,6 +34,11 @@ public struct DeviceSpecificationsFeature: Reducer, Sendable { var specifications: DeviceSpecificationsResponse? var isLoading = false + var isMyDeviceLoading = false + + var isUserAuthorized: Bool { + return userSession != nil + } public init( tag: String, @@ -45,12 +57,14 @@ public struct DeviceSpecificationsFeature: Reducer, Sendable { case onAppear case editionButtonTapped(String) + case markAsMyDeviceButtonTapped(Bool) } case `internal`(Internal) public enum Internal { case loadSpecifications case specificationsResponse(Result) + case markAsMyDeviceResponse(Result) } case delegate(Delegate) @@ -76,8 +90,31 @@ public struct DeviceSpecificationsFeature: Reducer, Sendable { return .send(.delegate(.openDevice(tag: state.tag, subTag: subTag))) case let .view(.markAsMyDeviceButtonTapped(myDevice)): + guard let session = state.userSession else { return .none } + return .run { [fullTag = state.tag] send in + let status = try await apiClient.updateUserDevice( + userId: session.userId, + action: myDevice ? .remove : .add, + fullTag: fullTag, + isPrimary: false + ) + await send(.internal(.markAsMyDeviceResponse(.success(status)))) + } catch: { error, send in + await send(.internal(.markAsMyDeviceResponse(.failure(error)))) + } + + case let .internal(.markAsMyDeviceResponse(.success(status))): + state.specifications?.isMyDevice = status + state.isMyDeviceLoading = false return .none + case let .internal(.markAsMyDeviceResponse(.failure(error))): + state.isMyDeviceLoading = false + return .run { _ in + let message = ToastMessage(text: Localization.changeDeviceStatusError, isError: true) + await toastClient.showToast(message) + } + case .internal(.loadSpecifications): state.isLoading = true return .run { [tag = state.tag, subTag = state.subTag] send in diff --git a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift index 8bf201d7..207c9489 100644 --- a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift +++ b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift @@ -16,6 +16,7 @@ public struct DeviceSpecificationsScreen: View { // MARK: - Properties @Perception.Bindable public var store: StoreOf + @Environment(\.tintColor) private var tintColor // MARK: - Init @@ -46,6 +47,11 @@ public struct DeviceSpecificationsScreen: View { .navigationTitle(Text(store.specifications?.deviceName ?? String(localized: "Loading...", bundle: .module))) .navigationBarTitleDisplayMode(.inline) .background(Color(.Background.primary)) + .safeAreaInset(edge: .bottom) { + if let specifications = store.specifications, store.isUserAuthorized { + MyDeviceButton(specifications.isMyDevice) + } + } .overlay { if store.isLoading { PDALoader() @@ -58,6 +64,37 @@ public struct DeviceSpecificationsScreen: View { } } + // MARK: - My Device Button + + @ViewBuilder + private func MyDeviceButton(_ myDevice: Bool) -> some View { + Button { + send(.markAsMyDeviceButtonTapped(!myDevice)) + } label: { + if false { + ProgressView() + .progressViewStyle(.circular) + .frame(maxWidth: .infinity) + .padding(8) + } else { + HStack { + Image(systemSymbol: myDevice ? .checkmarkCircleFill : .circle) + + Text(myDevice ? "My Device" : "Mark as My Device", bundle: .module) + } + .padding(8) + .frame(maxWidth: .infinity) + } + } + ._buttonStyle(myDevice ? .borderedProminent : .bordered) + .tint(tintColor) + .disabled(store.isMyDeviceLoading) + .frame(height: 48) + .padding(.vertical, 8) + .padding(.horizontal, 16) + .background(Color(.Background.primary)) + } + // MARK: - Header @ViewBuilder @@ -166,6 +203,25 @@ public struct DeviceSpecificationsScreen: View { } } +// MARK: - Extensions + +private extension Button { + enum ButtonStyle { + case bordered + case borderedProminent + } + + @ViewBuilder + func _buttonStyle(_ style: ButtonStyle) -> some View { + switch style { + case .bordered: + self.buttonStyle(BorderedButtonStyle()) + case .borderedProminent: + self.buttonStyle(BorderedProminentButtonStyle()) + } + } +} + // MARK: - Preview #Preview { diff --git a/Modules/Sources/DeviceSpecificationsFeature/Resources/Localizable.xcstrings b/Modules/Sources/DeviceSpecificationsFeature/Resources/Localizable.xcstrings index a21ea832..5b3eeb99 100644 --- a/Modules/Sources/DeviceSpecificationsFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/DeviceSpecificationsFeature/Resources/Localizable.xcstrings @@ -1,8 +1,45 @@ { "sourceLanguage" : "en", "strings" : { - "DevDB" : { - + "Loading..." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузка…" + } + } + } + }, + "Mark as My Device" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сделать моим устройством" + } + } + } + }, + "My Device" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Моё устройство" + } + } + } + }, + "Unable to change device status" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка изменения статуса устройства" + } + } + } } }, "version" : "1.1" diff --git a/Modules/Sources/Models/DevDB/DeviceSpecificationsResponse.swift b/Modules/Sources/Models/DevDB/DeviceSpecificationsResponse.swift index 63a9836f..9ce38262 100644 --- a/Modules/Sources/Models/DevDB/DeviceSpecificationsResponse.swift +++ b/Modules/Sources/Models/DevDB/DeviceSpecificationsResponse.swift @@ -17,7 +17,7 @@ public struct DeviceSpecificationsResponse: Sendable, Equatable { public let images: [DeviceImage] public let editions: [Edition] public let specifications: [Specification] - public let isMyDevice: Bool + public var isMyDevice: Bool public struct DeviceImage: Sendable, Equatable { public let url: URL From e3cc37111939c79d4e116012fe351fa6998e579d Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 27 Mar 2026 13:38:59 +0300 Subject: [PATCH 11/25] Improve navigationTitle for DeviceSpecifications --- .../DeviceSpecificationsScreen.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift index 207c9489..38a2b013 100644 --- a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift +++ b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift @@ -44,7 +44,7 @@ public struct DeviceSpecificationsScreen: View { .scrollContentBackground(.hidden) } } - .navigationTitle(Text(store.specifications?.deviceName ?? String(localized: "Loading...", bundle: .module))) + .navigationTitle(Text(navigationTitleText())) .navigationBarTitleDisplayMode(.inline) .background(Color(.Background.primary)) .safeAreaInset(edge: .bottom) { @@ -201,6 +201,16 @@ public struct DeviceSpecificationsScreen: View { .buttonStyle(.plain) .frame(height: 60) } + + // MARK: - Helpers + + private func navigationTitleText() -> String { + return if let specifications = store.specifications { + "\(specifications.deviceName) \(specifications.editionName)" + } else { + String(localized: "Loading...", bundle: .module) + } + } } // MARK: - Extensions From 35ad0ccb860f6fa0f34258dde03191b8bd49e65f Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 27 Mar 2026 13:39:12 +0300 Subject: [PATCH 12/25] Fix localizable --- .../Resources/Localizable.xcstrings | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/Modules/Sources/DeviceSpecificationsFeature/Resources/Localizable.xcstrings b/Modules/Sources/DeviceSpecificationsFeature/Resources/Localizable.xcstrings index 5b3eeb99..95da1391 100644 --- a/Modules/Sources/DeviceSpecificationsFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/DeviceSpecificationsFeature/Resources/Localizable.xcstrings @@ -1,9 +1,19 @@ { "sourceLanguage" : "en", "strings" : { + "Link copied" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ссылка скопирована" + } + } + } + }, "Loading..." : { "localizations" : { - "en" : { + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Загрузка…" @@ -13,7 +23,7 @@ }, "Mark as My Device" : { "localizations" : { - "en" : { + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Сделать моим устройством" @@ -23,7 +33,7 @@ }, "My Device" : { "localizations" : { - "en" : { + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Моё устройство" @@ -33,7 +43,7 @@ }, "Unable to change device status" : { "localizations" : { - "en" : { + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Ошибка изменения статуса устройства" From 358d1c9b6f8be68e749a11a71c7b4e11a8dc6b05 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 27 Mar 2026 13:39:47 +0300 Subject: [PATCH 13/25] [WIP] DeviceSpecifications --- .../DeviceSpecificationsFeature.swift | 17 +++++++++++++++++ .../DeviceSpecificationsContextMenuAction.swift | 11 +++++++++++ 2 files changed, 28 insertions(+) create mode 100644 Modules/Sources/DeviceSpecificationsFeature/Models/DeviceSpecificationsContextMenuAction.swift diff --git a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsFeature.swift b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsFeature.swift index 4be4108f..7286393e 100644 --- a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsFeature.swift +++ b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsFeature.swift @@ -10,6 +10,7 @@ import ComposableArchitecture import APIClient import Models import ToastClient +import PasteboardClient @Reducer public struct DeviceSpecificationsFeature: Reducer, Sendable { @@ -19,6 +20,7 @@ public struct DeviceSpecificationsFeature: Reducer, Sendable { // MARK: - Localizations public enum Localization { + static let linkCopied = LocalizedStringResource("Link copied", bundle: .module) static let changeDeviceStatusError = LocalizedStringResource("Unable to change device status", bundle: .module) } @@ -56,6 +58,8 @@ public struct DeviceSpecificationsFeature: Reducer, Sendable { public enum View { case onAppear + case contextMenu(DeviceSpecificationsContextMenuAction) + case editionButtonTapped(String) case markAsMyDeviceButtonTapped(Bool) } @@ -76,6 +80,7 @@ public struct DeviceSpecificationsFeature: Reducer, Sendable { // MARK: - Dependencies @Dependency(\.apiClient) private var apiClient + @Dependency(\.pasteboardClient) private var pasteboardClient @Dependency(\.toastClient) private var toastClient // MARK: - Body @@ -86,6 +91,17 @@ public struct DeviceSpecificationsFeature: Reducer, Sendable { case .view(.onAppear): return .send(.internal(.loadSpecifications)) + case let .view(.contextMenu(action)): + guard let specifications = state.specifications else { return .none } + switch action { + case .copyLink: + let tag = "\(state.tag)\(state.subTag != nil ? ":\(state.subTag!)" : "")" + pasteboardClient.copy("https://4pda.to/devdb/\(specifications.tag)_\(tag)") + return .run { _ in + await toastClient.showToast(ToastMessage(text: Localization.linkCopied, haptic: .success)) + } + } + case let .view(.editionButtonTapped(subTag)): return .send(.delegate(.openDevice(tag: state.tag, subTag: subTag))) @@ -109,6 +125,7 @@ public struct DeviceSpecificationsFeature: Reducer, Sendable { return .none case let .internal(.markAsMyDeviceResponse(.failure(error))): + print(error) state.isMyDeviceLoading = false return .run { _ in let message = ToastMessage(text: Localization.changeDeviceStatusError, isError: true) diff --git a/Modules/Sources/DeviceSpecificationsFeature/Models/DeviceSpecificationsContextMenuAction.swift b/Modules/Sources/DeviceSpecificationsFeature/Models/DeviceSpecificationsContextMenuAction.swift new file mode 100644 index 00000000..384c8682 --- /dev/null +++ b/Modules/Sources/DeviceSpecificationsFeature/Models/DeviceSpecificationsContextMenuAction.swift @@ -0,0 +1,11 @@ +// +// DeviceSpecificationsContextMenuAction.swift +// ForPDA +// +// Created by Xialtal on 27.03.26. +// + +public enum DeviceSpecificationsContextMenuAction { + case copyLink + // case addToBookmarks +} From d15d83d8bd4c31e59d6b6d6e6a90a31dad244703 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 27 Mar 2026 13:49:02 +0300 Subject: [PATCH 14/25] Fix device editions parser --- Modules/Sources/ParsingClient/Parsers/DevDBParser.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Modules/Sources/ParsingClient/Parsers/DevDBParser.swift b/Modules/Sources/ParsingClient/Parsers/DevDBParser.swift index a9138fb5..8874d001 100644 --- a/Modules/Sources/ParsingClient/Parsers/DevDBParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/DevDBParser.swift @@ -73,8 +73,8 @@ public struct DevDBParser { private static func parseDeviceEditions(_ editionsRaw: [[Any]]) throws(ParsingError) -> [DeviceSpecificationsResponse.Edition] { var editions: [DeviceSpecificationsResponse.Edition] = [] for edition in editionsRaw { - guard let name = edition[safe: 0] as? String, - let subTag = edition[safe: 1] as? String else { + guard let subTag = edition[safe: 0] as? String, + let name = edition[safe: 1] as? String else { throw ParsingError.failedToCastFields } From 53664288be11d388baa4b837cda71bad729c348b Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 27 Mar 2026 13:49:37 +0300 Subject: [PATCH 15/25] Improve device name in header for DeviceSpecifications --- .../DeviceSpecificationsScreen.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift index 38a2b013..e95537c7 100644 --- a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift +++ b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift @@ -100,7 +100,7 @@ public struct DeviceSpecificationsScreen: View { @ViewBuilder private func Header(_ specs: DeviceSpecificationsResponse) -> some View { VStack(alignment: .leading, spacing: 12) { - Text(verbatim: "\(specs.vendorName) \(specs.deviceName)") + Text(verbatim: "\(specs.vendorName) \(specs.deviceName) \(specs.editionName)") .font(.title2) .fontWeight(.bold) .foregroundStyle(Color(.Labels.primary)) From b966970178af78a52cf71ad0a35a1736b45e678f Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 27 Mar 2026 13:49:53 +0300 Subject: [PATCH 16/25] Add device editions to DeviceSpecifications --- .../DeviceSpecificationsScreen.swift | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift index e95537c7..3c706d34 100644 --- a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift +++ b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift @@ -106,6 +106,8 @@ public struct DeviceSpecificationsScreen: View { .foregroundStyle(Color(.Labels.primary)) HeaderImages(specs.images) + + HeaderEditions(specs.editions) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 12) @@ -138,6 +140,22 @@ public struct DeviceSpecificationsScreen: View { } } + @ViewBuilder + private func HeaderEditions(_ editions: [DeviceSpecificationsResponse.Edition]) -> some View { + VStack(spacing: 6) { + ForEach(editions, id: \.name) { edition in + Button { + send(.editionButtonTapped(edition.subTag)) + } label: { + Text(verbatim: edition.name) + .foregroundStyle(Color(.Labels.teritary)) + } + } + } + } + + // MARK: - Specification Section + private func SpecificationSection(_ spec: DeviceSpecificationsResponse.Specification) -> some View { Section { ForEach(spec.entries, id: \.name) { entry in From a5ace137dcb35a86a9479989954f3490a5c3a318 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 27 Mar 2026 14:00:56 +0300 Subject: [PATCH 17/25] Add context menu to DeviceSpecifications --- .../DeviceSpecificationsFeature.swift | 6 +++--- .../DeviceSpecificationsScreen.swift | 19 +++++++++++++++++++ .../Resources/Localizable.xcstrings | 10 ++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsFeature.swift b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsFeature.swift index 7286393e..a1df8804 100644 --- a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsFeature.swift +++ b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsFeature.swift @@ -92,11 +92,10 @@ public struct DeviceSpecificationsFeature: Reducer, Sendable { return .send(.internal(.loadSpecifications)) case let .view(.contextMenu(action)): - guard let specifications = state.specifications else { return .none } switch action { case .copyLink: - let tag = "\(state.tag)\(state.subTag != nil ? ":\(state.subTag!)" : "")" - pasteboardClient.copy("https://4pda.to/devdb/\(specifications.tag)_\(tag)") + let subTag = "\(state.subTag != nil ? ":\(state.subTag!)" : "")" + pasteboardClient.copy("https://4pda.to/devdb/\(state.tag)\(subTag)") return .run { _ in await toastClient.showToast(ToastMessage(text: Localization.linkCopied, haptic: .success)) } @@ -145,6 +144,7 @@ public struct DeviceSpecificationsFeature: Reducer, Sendable { } case let .internal(.specificationsResponse(.success(response))): + customDump(response) state.specifications = response state.isLoading = false return .none diff --git a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift index 3c706d34..e255bce5 100644 --- a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift +++ b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift @@ -58,6 +58,11 @@ public struct DeviceSpecificationsScreen: View { .frame(width: 24, height: 24) } } + .toolbar { + ToolbarItem { + OptionsMenu() + } + } .onAppear { send(.onAppear) } @@ -220,6 +225,20 @@ public struct DeviceSpecificationsScreen: View { .frame(height: 60) } + // MARK: - Options Menu + + @ViewBuilder + private func OptionsMenu() -> some View { + Menu { + ContextButton(text: LocalizedStringResource("Copy Link", bundle: .module), symbol: .docOnDoc) { + send(.contextMenu(.copyLink)) + } + } label: { + Image(systemSymbol: .ellipsisCircle) + .foregroundStyle(tintColor) + } + } + // MARK: - Helpers private func navigationTitleText() -> String { diff --git a/Modules/Sources/DeviceSpecificationsFeature/Resources/Localizable.xcstrings b/Modules/Sources/DeviceSpecificationsFeature/Resources/Localizable.xcstrings index 95da1391..d74d0a8f 100644 --- a/Modules/Sources/DeviceSpecificationsFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/DeviceSpecificationsFeature/Resources/Localizable.xcstrings @@ -1,6 +1,16 @@ { "sourceLanguage" : "en", "strings" : { + "Copy Link" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скопировать ссылку" + } + } + } + }, "Link copied" : { "localizations" : { "ru" : { From cf0f1cae966f6de90c6002d71b32e2327f372be4 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 27 Mar 2026 14:10:17 +0300 Subject: [PATCH 18/25] Fix device update request --- .../DeviceSpecificationsScreen.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift index e255bce5..f4c45c8e 100644 --- a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift +++ b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift @@ -74,7 +74,7 @@ public struct DeviceSpecificationsScreen: View { @ViewBuilder private func MyDeviceButton(_ myDevice: Bool) -> some View { Button { - send(.markAsMyDeviceButtonTapped(!myDevice)) + send(.markAsMyDeviceButtonTapped(myDevice)) } label: { if false { ProgressView() From 4da308828324cebd31042840f5c2b95f0fab23d3 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 27 Mar 2026 14:10:33 +0300 Subject: [PATCH 19/25] Add device update animations --- .../DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift index f4c45c8e..184df83f 100644 --- a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift +++ b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift @@ -98,6 +98,7 @@ public struct DeviceSpecificationsScreen: View { .padding(.vertical, 8) .padding(.horizontal, 16) .background(Color(.Background.primary)) + .animation(.default, value: store.isMyDeviceLoading) } // MARK: - Header From 17e78aa1870552d9924e851a2d80ed9813db5456 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 27 Mar 2026 14:11:49 +0300 Subject: [PATCH 20/25] Fix device update progress spinner --- .../DeviceSpecificationsScreen.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift index 184df83f..af844328 100644 --- a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift +++ b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift @@ -76,7 +76,7 @@ public struct DeviceSpecificationsScreen: View { Button { send(.markAsMyDeviceButtonTapped(myDevice)) } label: { - if false { + if store.isMyDeviceLoading { ProgressView() .progressViewStyle(.circular) .frame(maxWidth: .infinity) From ae4d079c175e35c0691aaccb019ed73856426e73 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 27 Mar 2026 14:39:52 +0300 Subject: [PATCH 21/25] Add device limit check --- .../DeviceSpecificationsFeature.swift | 15 ++++++++++++--- .../DeviceSpecificationsScreen.swift | 2 +- .../Resources/Localizable.xcstrings | 10 ++++++++++ 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsFeature.swift b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsFeature.swift index a1df8804..09774509 100644 --- a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsFeature.swift +++ b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsFeature.swift @@ -21,6 +21,7 @@ public struct DeviceSpecificationsFeature: Reducer, Sendable { public enum Localization { static let linkCopied = LocalizedStringResource("Link copied", bundle: .module) + static let devicesLimitError = LocalizedStringResource("You can add a maximum of 5 devices", bundle: .module) static let changeDeviceStatusError = LocalizedStringResource("Unable to change device status", bundle: .module) } @@ -37,6 +38,7 @@ public struct DeviceSpecificationsFeature: Reducer, Sendable { var isLoading = false var isMyDeviceLoading = false + var isDevicesLimit = false var isUserAuthorized: Bool { return userSession != nil @@ -119,9 +121,17 @@ public struct DeviceSpecificationsFeature: Reducer, Sendable { } case let .internal(.markAsMyDeviceResponse(.success(status))): - state.specifications?.isMyDevice = status + if let specifications = state.specifications, status { + state.specifications?.isMyDevice = !specifications.isMyDevice + state.isMyDeviceLoading = false + return .none + } + state.isDevicesLimit = true state.isMyDeviceLoading = false - return .none + return .run { _ in + let message = ToastMessage(text: Localization.devicesLimitError, isError: true) + await toastClient.showToast(message) + } case let .internal(.markAsMyDeviceResponse(.failure(error))): print(error) @@ -144,7 +154,6 @@ public struct DeviceSpecificationsFeature: Reducer, Sendable { } case let .internal(.specificationsResponse(.success(response))): - customDump(response) state.specifications = response state.isLoading = false return .none diff --git a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift index af844328..8774116d 100644 --- a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift +++ b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift @@ -93,7 +93,7 @@ public struct DeviceSpecificationsScreen: View { } ._buttonStyle(myDevice ? .borderedProminent : .bordered) .tint(tintColor) - .disabled(store.isMyDeviceLoading) + .disabled(store.isMyDeviceLoading || store.isDevicesLimit) .frame(height: 48) .padding(.vertical, 8) .padding(.horizontal, 16) diff --git a/Modules/Sources/DeviceSpecificationsFeature/Resources/Localizable.xcstrings b/Modules/Sources/DeviceSpecificationsFeature/Resources/Localizable.xcstrings index d74d0a8f..aa44ecad 100644 --- a/Modules/Sources/DeviceSpecificationsFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/DeviceSpecificationsFeature/Resources/Localizable.xcstrings @@ -60,6 +60,16 @@ } } } + }, + "You can add a maximum of 5 devices" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нельзя добавить более 5 устройств" + } + } + } } }, "version" : "1.1" From 55e16f220dcd6792acb75221107f62f7d60cd7ba Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 27 Mar 2026 15:06:15 +0300 Subject: [PATCH 22/25] Add images gallery to DeviceSpecifications --- .../DeviceSpecificationsFeature.swift | 33 +++++++++++++++++-- .../DeviceSpecificationsScreen.swift | 15 ++++++++- .../DevDB/DeviceSpecificationsResponse.swift | 2 +- Project.swift | 1 + 4 files changed, 46 insertions(+), 5 deletions(-) diff --git a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsFeature.swift b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsFeature.swift index 09774509..9184bd12 100644 --- a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsFeature.swift +++ b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsFeature.swift @@ -11,13 +11,14 @@ import APIClient import Models import ToastClient import PasteboardClient +import GalleryFeature @Reducer public struct DeviceSpecificationsFeature: Reducer, Sendable { public init() {} - // MARK: - Localizations + // MARK: - Localization public enum Localization { static let linkCopied = LocalizedStringResource("Link copied", bundle: .module) @@ -25,10 +26,19 @@ public struct DeviceSpecificationsFeature: Reducer, Sendable { static let changeDeviceStatusError = LocalizedStringResource("Unable to change device status", bundle: .module) } + // MARK: - Destination + + @Reducer + public enum Destination { + case gallery + } + // MARK: - State @ObservableState public struct State: Equatable { + @Presents public var destination: Destination.State? + @Shared(.userSession) var userSession public let tag: String @@ -40,6 +50,8 @@ public struct DeviceSpecificationsFeature: Reducer, Sendable { var isMyDeviceLoading = false var isDevicesLimit = false + var selectedHeaderImageId = 0 + var isUserAuthorized: Bool { return userSession != nil } @@ -55,13 +67,18 @@ public struct DeviceSpecificationsFeature: Reducer, Sendable { // MARK: - Action - public enum Action: ViewAction { + public enum Action: BindableAction, ViewAction { + case binding(BindingAction) + case destination(PresentationAction) + case view(View) public enum View { case onAppear case contextMenu(DeviceSpecificationsContextMenuAction) + case headerImageTapped(Int) + case editionButtonTapped(String) case markAsMyDeviceButtonTapped(Bool) } @@ -88,6 +105,8 @@ public struct DeviceSpecificationsFeature: Reducer, Sendable { // MARK: - Body public var body: some Reducer { + BindingReducer() + Reduce { state, action in switch action { case .view(.onAppear): @@ -103,6 +122,11 @@ public struct DeviceSpecificationsFeature: Reducer, Sendable { } } + case let .view(.headerImageTapped(id)): + state.selectedHeaderImageId = id + state.destination = .gallery + return .none + case let .view(.editionButtonTapped(subTag)): return .send(.delegate(.openDevice(tag: state.tag, subTag: subTag))) @@ -165,9 +189,12 @@ public struct DeviceSpecificationsFeature: Reducer, Sendable { await toastClient.showToast(.whoopsSomethingWentWrong) } - case .delegate: + case .delegate, .destination, .binding: return .none } } + .ifLet(\.$destination, action: \.destination) } } + +extension DeviceSpecificationsFeature.Destination.State: Equatable {} diff --git a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift index 8774116d..59c71e3b 100644 --- a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift +++ b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift @@ -9,6 +9,8 @@ import SwiftUI import ComposableArchitecture import SharedUI import Models +import NukeUI +import GalleryFeature @ViewAction(for: DeviceSpecificationsFeature.self) public struct DeviceSpecificationsScreen: View { @@ -47,6 +49,14 @@ public struct DeviceSpecificationsScreen: View { .navigationTitle(Text(navigationTitleText())) .navigationBarTitleDisplayMode(.inline) .background(Color(.Background.primary)) + .fullScreenCover(isPresented: Binding($store.destination.gallery)) { + WithPerceptionTracking { + TabViewGallery( + gallery: store.specifications?.images.map{ $0.fullUrl } ?? [], + selectedImageID: store.selectedHeaderImageId + ) + } + } .safeAreaInset(edge: .bottom) { if let specifications = store.specifications, store.isUserAuthorized { MyDeviceButton(specifications.isMyDevice) @@ -129,7 +139,7 @@ public struct DeviceSpecificationsScreen: View { @ViewBuilder private func HeaderImages(_ images: [DeviceSpecificationsResponse.DeviceImage]) -> some View { HStack(spacing: 8) { - ForEach(images, id: \.url.hashValue) { image in + ForEach(Array(images.enumerated()), id: \.element) { index, image in LazyImage(url: image.url) { state in Group { if let image = state.image { @@ -142,6 +152,9 @@ public struct DeviceSpecificationsScreen: View { } .frame(width: 37, height: 75) .clipped() + .onTapGesture { + send(.headerImageTapped(index)) + } } } } diff --git a/Modules/Sources/Models/DevDB/DeviceSpecificationsResponse.swift b/Modules/Sources/Models/DevDB/DeviceSpecificationsResponse.swift index 9ce38262..241f77c6 100644 --- a/Modules/Sources/Models/DevDB/DeviceSpecificationsResponse.swift +++ b/Modules/Sources/Models/DevDB/DeviceSpecificationsResponse.swift @@ -19,7 +19,7 @@ public struct DeviceSpecificationsResponse: Sendable, Equatable { public let specifications: [Specification] public var isMyDevice: Bool - public struct DeviceImage: Sendable, Equatable { + public struct DeviceImage: Sendable, Equatable, Hashable { public let url: URL public let fullUrl: URL public let isFront: Bool diff --git a/Project.swift b/Project.swift index 07b32d1b..702677ea 100644 --- a/Project.swift +++ b/Project.swift @@ -207,6 +207,7 @@ let project = Project( name: "DeviceSpecificationsFeature", dependencies: [ .Internal.APIClient, + .Internal.GalleryFeature, .Internal.Models, .Internal.SharedUI, .Internal.ToastClient, From 4ddf5e7482fb7fe031fcf285684a0c5ab5567a8c Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 27 Mar 2026 15:21:10 +0300 Subject: [PATCH 23/25] Improve devices section in profile --- .../ProfileFeature/ProfileScreen.swift | 64 +++++++++---------- 1 file changed, 31 insertions(+), 33 deletions(-) diff --git a/Modules/Sources/ProfileFeature/ProfileScreen.swift b/Modules/Sources/ProfileFeature/ProfileScreen.swift index 6bbec8d9..6367e30c 100644 --- a/Modules/Sources/ProfileFeature/ProfileScreen.swift +++ b/Modules/Sources/ProfileFeature/ProfileScreen.swift @@ -177,15 +177,15 @@ public struct ProfileScreen: View { private func NavigationSection() -> some View { if store.shouldShowToolbarButtons { Section { - Row(symbol: .person2, title: "QMS", type: .navigation(badge: store.qmsBadgeCount)) { + Row(symbol: .person2, title: "QMS", type: .navigation(.badge(store.qmsBadgeCount))) { send(.qmsButtonTapped) } - Row(symbol: .at, title: "Mentions", type: .navigation(badge: store.mentionsBadgeCount)) { + Row(symbol: .at, title: "Mentions", type: .navigation(.badge(store.mentionsBadgeCount))) { send(.mentionsButtonTapped) } - Row(symbol: .clockArrowCirclepath, title: "History", type: .navigation(badge: 0)) { + Row(symbol: .clockArrowCirclepath, title: "History", type: .navigation(.badge(0))) { send(.historyButtonTapped) } } @@ -350,28 +350,9 @@ public struct ProfileScreen: View { private func DevicesSection(devices: [User.Device]) -> some View { Section { ForEach(devices) { device in - Button { + Row(title: LocalizedStringKey(device.name), type: .navigation(.indicator(device.main))) { send(.deviceButtonTapped(device.id)) - } label: { - HStack(spacing: 0) { - Text(device.name) - .font(.body) - .foregroundStyle(Color(.Labels.primary)) - - Spacer(minLength: 8) - - if device.main { - Circle() - .font(.title2) - .foregroundStyle(tintColor) - .frame(width: 8) - .padding(.trailing, 12) - } - } } - .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) - .buttonStyle(.plain) - .frame(height: 60) } } header: { SectionHeader(title: "Devices List") @@ -513,9 +494,14 @@ public struct ProfileScreen: View { enum RowType { case basic case description(String) - case navigation(badge: Int) + case navigation(NavigationRowType) case navigationDescription(String) case localizedDescription(LocalizedStringKey) + + enum NavigationRowType { + case badge(Int) + case indicator(Bool) + } } @ViewBuilder @@ -553,17 +539,29 @@ public struct ProfileScreen: View { .font(.body) .foregroundStyle(Color(.Labels.teritary)) - case let .navigation(badgeCount): - if badgeCount <= 0 { - Image(systemSymbol: .chevronRight) - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(Color(.Labels.quintuple)) - } else { - EmptyView() - .badge(badgeCount) - ._badgeProminence(.increased) + case let .navigation(type): + switch type { + case .badge(let badgeCount): + if badgeCount > 0 { + EmptyView() + .badge(badgeCount) + ._badgeProminence(.increased) + } + + case .indicator(let show): + if show { + Circle() + .font(.title2) + .foregroundStyle(tintColor) + .frame(width: 8) + .padding(.trailing, 12) + } } + Image(systemSymbol: .chevronRight) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(Color(.Labels.quintuple)) + case let .navigationDescription(text): Text(text) .font(.body) From 419b35e1f83fee12f2f1544e214256f836b7ccdb Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 27 Mar 2026 16:32:46 +0300 Subject: [PATCH 24/25] Improve UI for long entry --- .../DeviceSpecificationsFeature.swift | 15 +++- .../DeviceSpecificationsScreen.swift | 61 ++++++++----- .../DeviceSpecificationLongEntryView.swift | 85 +++++++++++++++++++ .../DevDB/DeviceSpecificationsResponse.swift | 2 +- 4 files changed, 141 insertions(+), 22 deletions(-) create mode 100644 Modules/Sources/DeviceSpecificationsFeature/Views/DeviceSpecificationLongEntryView.swift diff --git a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsFeature.swift b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsFeature.swift index 9184bd12..30073424 100644 --- a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsFeature.swift +++ b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsFeature.swift @@ -29,8 +29,11 @@ public struct DeviceSpecificationsFeature: Reducer, Sendable { // MARK: - Destination @Reducer - public enum Destination { + public enum Destination: Hashable { case gallery + + @ReducerCaseIgnored + case longEntry(DeviceSpecificationsResponse.Specification.SpecificationEntry) } // MARK: - State @@ -81,6 +84,8 @@ public struct DeviceSpecificationsFeature: Reducer, Sendable { case editionButtonTapped(String) case markAsMyDeviceButtonTapped(Bool) + case longEntryButtonTapped(DeviceSpecificationsResponse.Specification.SpecificationEntry) + case longEntryCloseButtonTapped } case `internal`(Internal) @@ -130,6 +135,14 @@ public struct DeviceSpecificationsFeature: Reducer, Sendable { case let .view(.editionButtonTapped(subTag)): return .send(.delegate(.openDevice(tag: state.tag, subTag: subTag))) + case let .view(.longEntryButtonTapped(entry)): + state.destination = .longEntry(entry) + return .none + + case .view(.longEntryCloseButtonTapped): + state.destination = nil + return .none + case let .view(.markAsMyDeviceButtonTapped(myDevice)): guard let session = state.userSession else { return .none } return .run { [fullTag = state.tag] send in diff --git a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift index 59c71e3b..c54d06a5 100644 --- a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift +++ b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift @@ -57,6 +57,15 @@ public struct DeviceSpecificationsScreen: View { ) } } + .sheet(item: $store.destination.longEntry, id: \.self) { entry in + NavigationStack { + DeviceSpecificationLongEntryView(title: entry.name, content: entry.value) { + send(.longEntryCloseButtonTapped) + } + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } + } .safeAreaInset(edge: .bottom) { if let specifications = store.specifications, store.isUserAuthorized { MyDeviceButton(specifications.isMyDevice) @@ -178,7 +187,9 @@ public struct DeviceSpecificationsScreen: View { private func SpecificationSection(_ spec: DeviceSpecificationsResponse.Specification) -> some View { Section { ForEach(spec.entries, id: \.name) { entry in - Row(title: entry.name, type: .description(entry.value)) + Row(entry: entry) { + send(.longEntryButtonTapped(entry)) + } } } header: { SectionHeader(title: spec.title) @@ -201,37 +212,47 @@ public struct DeviceSpecificationsScreen: View { // MARK: - Row - enum RowType { - case description(String) - case bigDescription(String) - } - @ViewBuilder - private func Row(title: String, type: RowType, action: @escaping () -> Void = {}) -> some View { + private func Row(entry: DeviceSpecificationsResponse.Specification.SpecificationEntry, action: @escaping () -> Void = {}) -> some View { HStack(spacing: 0) { // Hacky HStack to enable tap animations - Button { - action() - } label: { + ViewThatFits(in: .vertical) { HStack(spacing: 0) { - Text(verbatim: title) + Text(verbatim: entry.name) .font(.body) .foregroundStyle(Color(.Labels.primary)) Spacer(minLength: 8) - switch type { - case let .description(text): - Text(verbatim: text) + Text(verbatim: entry.value) + .font(.body) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) + .foregroundStyle(Color(.Labels.teritary)) + } + .contentShape(Rectangle()) + + Button { + action() + } label: { + HStack(spacing: 0) { + Text(verbatim: entry.name) .font(.body) - .foregroundStyle(Color(.Labels.teritary)) + .foregroundStyle(Color(.Labels.primary)) - case let .bigDescription(text): - Text(verbatim: text) - .font(.body) - .foregroundStyle(Color(.Labels.teritary)) + Spacer(minLength: 8) + + HStack(spacing: 4) { + Text(verbatim: entry.value) + .font(.body) + .foregroundStyle(Color(.Labels.teritary)) + + Image(systemSymbol: .chevronRight) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(Color(.Labels.quintuple)) + } } + .contentShape(Rectangle()) } - .contentShape(Rectangle()) } } .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) diff --git a/Modules/Sources/DeviceSpecificationsFeature/Views/DeviceSpecificationLongEntryView.swift b/Modules/Sources/DeviceSpecificationsFeature/Views/DeviceSpecificationLongEntryView.swift new file mode 100644 index 00000000..0706080f --- /dev/null +++ b/Modules/Sources/DeviceSpecificationsFeature/Views/DeviceSpecificationLongEntryView.swift @@ -0,0 +1,85 @@ +// +// DeviceSpecificationLongEntryView.swift +// ForPDA +// +// Created by Xialtal on 27.03.26. +// + +import SwiftUI +import SharedUI + +struct DeviceSpecificationLongEntryView: View { + + private let title: String + private let content: String + private let onCancelButtonTapped: () -> Void + + init( + title: String, + content: String, + onCancelButtonTapped: @escaping () -> Void + ) { + self.title = title + self.content = content + self.onCancelButtonTapped = onCancelButtonTapped + } + + var body: some View { + ScrollView { + Text(verbatim: content) + .padding(.top, 24) + } + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, alignment: .leading) + .background { + if !isLiquidGlass { + Color(.Background.primary) + } + } + ._toolbarTitleDisplayMode(.inline) + .modifier(NavigationTitle(title: title)) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + onCancelButtonTapped() + } label: { + if isLiquidGlass { + Image(systemSymbol: .xmark) + } else { + Image(systemSymbol: .xmark) + .font(.caption2) + .fontWeight(.bold) + .foregroundStyle(Color(.Labels.teritary)) + .frame(width: 30, height: 30) + .background( + Circle() + .fill(Color(.Background.quaternary)) + .clipShape(Circle()) + ) + } + } + } + } + } + + @available(iOS, deprecated: 26.0) + private struct NavigationTitle: ViewModifier { + let title: String + + func body(content: Content) -> some View { + if isLiquidGlass { + content + .navigationTitle(Text(verbatim: title)) + } else { + content + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Text(verbatim: title) + .font(.title3) + .fontWeight(.semibold) + } + } + } + } + } +} diff --git a/Modules/Sources/Models/DevDB/DeviceSpecificationsResponse.swift b/Modules/Sources/Models/DevDB/DeviceSpecificationsResponse.swift index 241f77c6..f4f0aa18 100644 --- a/Modules/Sources/Models/DevDB/DeviceSpecificationsResponse.swift +++ b/Modules/Sources/Models/DevDB/DeviceSpecificationsResponse.swift @@ -46,7 +46,7 @@ public struct DeviceSpecificationsResponse: Sendable, Equatable { public let title: String public var entries: [SpecificationEntry] - public struct SpecificationEntry: Sendable, Equatable { + public struct SpecificationEntry: Sendable, Equatable, Hashable { public let name: String public let value: String From c77a64da6e4645923bbcc11daf8c28921b07bf9a Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 27 Mar 2026 16:36:59 +0300 Subject: [PATCH 25/25] Improve namings --- Modules/Sources/APIClient/APIClient.swift | 2 +- .../DeviceSpecificationsFeature.swift | 8 ++++---- .../DeviceSpecificationsScreen.swift | 10 +++++----- ...Response.swift => DeviceSpecifications.swift} | 12 ++++++------ .../ParsingClient/Parsers/DevDBParser.swift | 16 ++++++++-------- .../Sources/ParsingClient/ParsingClient.swift | 2 +- 6 files changed, 25 insertions(+), 25 deletions(-) rename Modules/Sources/Models/DevDB/{DeviceSpecificationsResponse.swift => DeviceSpecifications.swift} (91%) diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index 0d72bfd3..b717b3e5 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 deviceSpecifications: @Sendable (_ tag: String, _ subTag: String) async throws -> DeviceSpecificationsResponse + public var deviceSpecifications: @Sendable (_ tag: String, _ subTag: String) async throws -> DeviceSpecifications // STREAMS public var connectionState: @Sendable () -> AsyncStream = { .finished } diff --git a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsFeature.swift b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsFeature.swift index 30073424..a288ab96 100644 --- a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsFeature.swift +++ b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsFeature.swift @@ -33,7 +33,7 @@ public struct DeviceSpecificationsFeature: Reducer, Sendable { case gallery @ReducerCaseIgnored - case longEntry(DeviceSpecificationsResponse.Specification.SpecificationEntry) + case longEntry(DeviceSpecifications.Specification.Entry) } // MARK: - State @@ -47,7 +47,7 @@ public struct DeviceSpecificationsFeature: Reducer, Sendable { public let tag: String public let subTag: String? - var specifications: DeviceSpecificationsResponse? + var specifications: DeviceSpecifications? var isLoading = false var isMyDeviceLoading = false @@ -84,14 +84,14 @@ public struct DeviceSpecificationsFeature: Reducer, Sendable { case editionButtonTapped(String) case markAsMyDeviceButtonTapped(Bool) - case longEntryButtonTapped(DeviceSpecificationsResponse.Specification.SpecificationEntry) + case longEntryButtonTapped(DeviceSpecifications.Specification.Entry) case longEntryCloseButtonTapped } case `internal`(Internal) public enum Internal { case loadSpecifications - case specificationsResponse(Result) + case specificationsResponse(Result) case markAsMyDeviceResponse(Result) } diff --git a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift index c54d06a5..b10b6e12 100644 --- a/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift +++ b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift @@ -123,7 +123,7 @@ public struct DeviceSpecificationsScreen: View { // MARK: - Header @ViewBuilder - private func Header(_ specs: DeviceSpecificationsResponse) -> some View { + private func Header(_ specs: DeviceSpecifications) -> some View { VStack(alignment: .leading, spacing: 12) { Text(verbatim: "\(specs.vendorName) \(specs.deviceName) \(specs.editionName)") .font(.title2) @@ -146,7 +146,7 @@ public struct DeviceSpecificationsScreen: View { } @ViewBuilder - private func HeaderImages(_ images: [DeviceSpecificationsResponse.DeviceImage]) -> some View { + private func HeaderImages(_ images: [DeviceSpecifications.DeviceImage]) -> some View { HStack(spacing: 8) { ForEach(Array(images.enumerated()), id: \.element) { index, image in LazyImage(url: image.url) { state in @@ -169,7 +169,7 @@ public struct DeviceSpecificationsScreen: View { } @ViewBuilder - private func HeaderEditions(_ editions: [DeviceSpecificationsResponse.Edition]) -> some View { + private func HeaderEditions(_ editions: [DeviceSpecifications.Edition]) -> some View { VStack(spacing: 6) { ForEach(editions, id: \.name) { edition in Button { @@ -184,7 +184,7 @@ public struct DeviceSpecificationsScreen: View { // MARK: - Specification Section - private func SpecificationSection(_ spec: DeviceSpecificationsResponse.Specification) -> some View { + private func SpecificationSection(_ spec: DeviceSpecifications.Specification) -> some View { Section { ForEach(spec.entries, id: \.name) { entry in Row(entry: entry) { @@ -213,7 +213,7 @@ public struct DeviceSpecificationsScreen: View { // MARK: - Row @ViewBuilder - private func Row(entry: DeviceSpecificationsResponse.Specification.SpecificationEntry, action: @escaping () -> Void = {}) -> some View { + private func Row(entry: DeviceSpecifications.Specification.Entry, action: @escaping () -> Void = {}) -> some View { HStack(spacing: 0) { // Hacky HStack to enable tap animations ViewThatFits(in: .vertical) { HStack(spacing: 0) { diff --git a/Modules/Sources/Models/DevDB/DeviceSpecificationsResponse.swift b/Modules/Sources/Models/DevDB/DeviceSpecifications.swift similarity index 91% rename from Modules/Sources/Models/DevDB/DeviceSpecificationsResponse.swift rename to Modules/Sources/Models/DevDB/DeviceSpecifications.swift index f4f0aa18..ff8be184 100644 --- a/Modules/Sources/Models/DevDB/DeviceSpecificationsResponse.swift +++ b/Modules/Sources/Models/DevDB/DeviceSpecifications.swift @@ -7,7 +7,7 @@ import Foundation -public struct DeviceSpecificationsResponse: Sendable, Equatable { +public struct DeviceSpecifications: Sendable, Equatable { public let tag: String public let type: DeviceType public let vendorName: String @@ -44,9 +44,9 @@ public struct DeviceSpecificationsResponse: Sendable, Equatable { public struct Specification: Sendable, Equatable, Identifiable { public let id: Int public let title: String - public var entries: [SpecificationEntry] + public var entries: [Entry] - public struct SpecificationEntry: Sendable, Equatable, Hashable { + public struct Entry: Sendable, Equatable, Hashable { public let name: String public let value: String @@ -56,7 +56,7 @@ public struct DeviceSpecificationsResponse: Sendable, Equatable { } } - public init(id: Int, title: String, entries: [SpecificationEntry]) { + public init(id: Int, title: String, entries: [Entry]) { self.id = id self.title = title self.entries = entries @@ -88,8 +88,8 @@ public struct DeviceSpecificationsResponse: Sendable, Equatable { } } -public extension DeviceSpecificationsResponse { - static let mock = DeviceSpecificationsResponse( +public extension DeviceSpecifications { + static let mock = DeviceSpecifications( tag: "apple", type: .phone, vendorName: "Apple", diff --git a/Modules/Sources/ParsingClient/Parsers/DevDBParser.swift b/Modules/Sources/ParsingClient/Parsers/DevDBParser.swift index 8874d001..ebc43cd1 100644 --- a/Modules/Sources/ParsingClient/Parsers/DevDBParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/DevDBParser.swift @@ -12,7 +12,7 @@ public struct DevDBParser { // MARK: - Device Specs Response - public static func parse(from string: String) throws(ParsingError) -> DeviceSpecificationsResponse { + public static func parse(from string: String) throws(ParsingError) -> DeviceSpecifications { guard let data = string.data(using: .utf8) else { throw ParsingError.failedToCreateDataFromString } @@ -34,7 +34,7 @@ public struct DevDBParser { throw ParsingError.failedToCastFields } - return DeviceSpecificationsResponse( + return DeviceSpecifications( tag: tag, type: DeviceType(rawValue: type) ?? .unknown, vendorName: vendorName, @@ -50,8 +50,8 @@ public struct DevDBParser { // MARK: - Images - private static func parseDeviceImages(_ imagesRaw: [[Any]]) throws(ParsingError) -> [DeviceSpecificationsResponse.DeviceImage] { - var images: [DeviceSpecificationsResponse.DeviceImage] = [] + private static func parseDeviceImages(_ imagesRaw: [[Any]]) throws(ParsingError) -> [DeviceSpecifications.DeviceImage] { + var images: [DeviceSpecifications.DeviceImage] = [] for image in imagesRaw { guard let isDeviceFront = image[safe: 0] as? Int, let url = image[safe: 1] as? String, @@ -70,8 +70,8 @@ public struct DevDBParser { // MARK: - Editions - private static func parseDeviceEditions(_ editionsRaw: [[Any]]) throws(ParsingError) -> [DeviceSpecificationsResponse.Edition] { - var editions: [DeviceSpecificationsResponse.Edition] = [] + private static func parseDeviceEditions(_ editionsRaw: [[Any]]) throws(ParsingError) -> [DeviceSpecifications.Edition] { + var editions: [DeviceSpecifications.Edition] = [] for edition in editionsRaw { guard let subTag = edition[safe: 0] as? String, let name = edition[safe: 1] as? String else { @@ -85,8 +85,8 @@ public struct DevDBParser { // MARK: - Specifications - private static func parseDeviceSpecifications(_ specsRaw: [[Any]]) throws(ParsingError) -> [DeviceSpecificationsResponse.Specification] { - var specs: [DeviceSpecificationsResponse.Specification] = [] + private static func parseDeviceSpecifications(_ specsRaw: [[Any]]) throws(ParsingError) -> [DeviceSpecifications.Specification] { + var specs: [DeviceSpecifications.Specification] = [] for (index, spec) in specsRaw.enumerated() { guard let specType = spec[safe: 0] as? Int, let title = spec[safe: 2] as? String else { diff --git a/Modules/Sources/ParsingClient/ParsingClient.swift b/Modules/Sources/ParsingClient/ParsingClient.swift index 56c27343..b1c1bfe9 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 parseDeviceSpecifications: @Sendable (_ response: String) async throws -> DeviceSpecificationsResponse + public var parseDeviceSpecifications: @Sendable (_ response: String) async throws -> DeviceSpecifications } // MARK: - Dependency Key