diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index 22c102f3..b717b3e5 100644 --- a/Modules/Sources/APIClient/APIClient.swift +++ b/Modules/Sources/APIClient/APIClient.swift @@ -92,6 +92,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 -> DeviceSpecifications + // STREAMS public var connectionState: @Sendable () -> AsyncStream = { .finished } public var notificationStream: @Sendable () -> AsyncStream = { .finished } @@ -563,6 +566,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: { @@ -723,6 +734,9 @@ extension APIClient: DependencyKey { searchUsers: { _ in return .mock }, + deviceSpecifications: { _, _ in + return .mock + }, connectionState: { return .finished }, diff --git a/Modules/Sources/AnalyticsClient/Events/ProfileEvent.swift b/Modules/Sources/AnalyticsClient/Events/ProfileEvent.swift index 44a58d7d..5bc2f603 100644 --- a/Modules/Sources/AnalyticsClient/Events/ProfileEvent.swift +++ b/Modules/Sources/AnalyticsClient/Events/ProfileEvent.swift @@ -17,6 +17,7 @@ public enum ProfileEvent: Event { case reputationTapped case searchTopicsTapped case searchRepliesTapped + case deviceButtonTapped(String) case userLoaded(Int) case userLoadingFailed case achievementTapped @@ -33,6 +34,8 @@ public enum ProfileEvent: Event { switch self { case let .userLoaded(userId): return ["userId": String(userId)] + case let .deviceButtonTapped(tag): + return ["deviceTag": tag] case let .curatedTopicTapped(topicId): return ["topicId": String(topicId)] default: diff --git a/Modules/Sources/AppFeature/AppFeature.swift b/Modules/Sources/AppFeature/AppFeature.swift index 405118b9..08a484f5 100644 --- a/Modules/Sources/AppFeature/AppFeature.swift +++ b/Modules/Sources/AppFeature/AppFeature.swift @@ -36,6 +36,7 @@ import ToastClient import Combine import SearchResultFeature import CacheClient +import DeviceSpecificationsFeature @Reducer public struct AppFeature: Reducer, Sendable { @@ -637,6 +638,8 @@ public struct AppFeature: Reducer, Sendable { screen = .articles(.article(ArticleFeature.State(articlePreview: preview, scrollToId: scrollToId))) case let .announcement(id): screen = .forum(.announcement(AnnouncementFeature.State(id: id))) + case let .device(tag, subTag): + screen = .devDB(.specifications(DeviceSpecificationsFeature.State(tag: tag, subTag: subTag))) case let .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 c7fdbe45..50637f26 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 @@ -31,6 +32,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) @@ -45,6 +47,11 @@ public enum Path { case article(ArticleFeature) } + @Reducer + public enum DevDB { + case specifications(DeviceSpecificationsFeature) + } + @Reducer public enum Profile { case profile(ProfileFeature) @@ -84,6 +91,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 {} @@ -97,6 +105,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) @@ -135,6 +146,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 91d81771..d00b19fb 100644 --- a/Modules/Sources/AppFeature/Navigation/StackTab.swift +++ b/Modules/Sources/AppFeature/Navigation/StackTab.swift @@ -32,6 +32,7 @@ import ReputationFeature import AuthFeature import SearchFeature import SearchResultFeature +import DeviceSpecificationsFeature @Reducer public struct StackTab: Reducer, Sendable { @@ -138,6 +139,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) @@ -184,6 +188,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 { @@ -288,6 +305,8 @@ 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(.openTopic(id))): state.path.append(.forum(.topic(TopicFeature.State(topicId: id)))) @@ -461,7 +480,10 @@ public struct StackTab: Reducer, Sendable { case let .search(options: options): state.path.append(.search(.searchResult(SearchResultFeature.State(search: options)))) - case let .article(id: id, title: title, imageUrl: imageUrl, scrollToId: scrollToId): + 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, scrollToId): let preview = ArticlePreview.outerDeeplink(id: id, imageUrl: imageUrl, title: title) state.path.append(.articles(.article(ArticleFeature.State(articlePreview: preview, scrollToId: scrollToId)))) } diff --git a/Modules/Sources/DeeplinkHandler/DeeplinkHandler.swift b/Modules/Sources/DeeplinkHandler/DeeplinkHandler.swift index 3304d89f..479926b6 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 { @@ -124,6 +125,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..a288ab96 --- /dev/null +++ b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsFeature.swift @@ -0,0 +1,213 @@ +// +// DeviceSpecificationsFeature.swift +// ForPDA +// +// Created by Xialtal on 23.12.25. +// + +import Foundation +import ComposableArchitecture +import APIClient +import Models +import ToastClient +import PasteboardClient +import GalleryFeature + +@Reducer +public struct DeviceSpecificationsFeature: Reducer, Sendable { + + public init() {} + + // MARK: - Localization + + 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) + } + + // MARK: - Destination + + @Reducer + public enum Destination: Hashable { + case gallery + + @ReducerCaseIgnored + case longEntry(DeviceSpecifications.Specification.Entry) + } + + // MARK: - State + + @ObservableState + public struct State: Equatable { + @Presents public var destination: Destination.State? + + @Shared(.userSession) var userSession + + public let tag: String + public let subTag: String? + + var specifications: DeviceSpecifications? + + var isLoading = false + var isMyDeviceLoading = false + var isDevicesLimit = false + + var selectedHeaderImageId = 0 + + var isUserAuthorized: Bool { + return userSession != nil + } + + public init( + tag: String, + subTag: String? + ) { + self.tag = tag + self.subTag = subTag + } + } + + // MARK: - Action + + 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) + case longEntryButtonTapped(DeviceSpecifications.Specification.Entry) + case longEntryCloseButtonTapped + } + + case `internal`(Internal) + public enum Internal { + case loadSpecifications + case specificationsResponse(Result) + case markAsMyDeviceResponse(Result) + } + + case delegate(Delegate) + public enum Delegate { + case openDevice(tag: String, subTag: String) + } + } + + // MARK: - Dependencies + + @Dependency(\.apiClient) private var apiClient + @Dependency(\.pasteboardClient) private var pasteboardClient + @Dependency(\.toastClient) private var toastClient + + // MARK: - Body + + public var body: some Reducer { + BindingReducer() + + Reduce { state, action in + switch action { + case .view(.onAppear): + return .send(.internal(.loadSpecifications)) + + case let .view(.contextMenu(action)): + switch action { + case .copyLink: + 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)) + } + } + + 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))) + + 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 + 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))): + if let specifications = state.specifications, status { + state.specifications?.isMyDevice = !specifications.isMyDevice + state.isMyDeviceLoading = false + return .none + } + state.isDevicesLimit = true + state.isMyDeviceLoading = false + return .run { _ in + let message = ToastMessage(text: Localization.devicesLimitError, isError: true) + await toastClient.showToast(message) + } + + case let .internal(.markAsMyDeviceResponse(.failure(error))): + print(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 + let respone = try await apiClient.deviceSpecifications( + tag: tag, + subTag: subTag ?? "" + ) + await send(.internal(.specificationsResponse(.success(respone)))) + } catch: { error, send in + await send(.internal(.specificationsResponse(.failure(error)))) + } + + 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, .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 new file mode 100644 index 00000000..b10b6e12 --- /dev/null +++ b/Modules/Sources/DeviceSpecificationsFeature/DeviceSpecificationsScreen.swift @@ -0,0 +1,323 @@ +// +// DeviceSpecificationsScreen.swift +// ForPDA +// +// Created by Xialtal on 23.12.25. +// + +import SwiftUI +import ComposableArchitecture +import SharedUI +import Models +import NukeUI +import GalleryFeature + +@ViewAction(for: DeviceSpecificationsFeature.self) +public struct DeviceSpecificationsScreen: View { + + // MARK: - Properties + + @Perception.Bindable public var store: StoreOf + @Environment(\.tintColor) private var tintColor + + // 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 { + List { + Header(specifications) + + ForEach(specifications.specifications) { specification in + SpecificationSection(specification) + } + } + ._listSectionSpacing(28) + .scrollContentBackground(.hidden) + } + } + .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 + ) + } + } + .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) + } + } + .overlay { + if store.isLoading { + PDALoader() + .frame(width: 24, height: 24) + } + } + .toolbar { + ToolbarItem { + OptionsMenu() + } + } + .onAppear { + send(.onAppear) + } + } + } + + // MARK: - My Device Button + + @ViewBuilder + private func MyDeviceButton(_ myDevice: Bool) -> some View { + Button { + send(.markAsMyDeviceButtonTapped(myDevice)) + } label: { + if store.isMyDeviceLoading { + 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 || store.isDevicesLimit) + .frame(height: 48) + .padding(.vertical, 8) + .padding(.horizontal, 16) + .background(Color(.Background.primary)) + .animation(.default, value: store.isMyDeviceLoading) + } + + // MARK: - Header + + @ViewBuilder + private func Header(_ specs: DeviceSpecifications) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text(verbatim: "\(specs.vendorName) \(specs.deviceName) \(specs.editionName)") + .font(.title2) + .fontWeight(.bold) + .foregroundStyle(Color(.Labels.primary)) + + HeaderImages(specs.images) + + HeaderEditions(specs.editions) + } + .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: [DeviceSpecifications.DeviceImage]) -> some View { + HStack(spacing: 8) { + ForEach(Array(images.enumerated()), id: \.element) { index, 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() + .onTapGesture { + send(.headerImageTapped(index)) + } + } + } + } + + @ViewBuilder + private func HeaderEditions(_ editions: [DeviceSpecifications.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: DeviceSpecifications.Specification) -> some View { + Section { + ForEach(spec.entries, id: \.name) { entry in + Row(entry: entry) { + send(.longEntryButtonTapped(entry)) + } + } + } 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 + + @ViewBuilder + 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) { + Text(verbatim: entry.name) + .font(.body) + .foregroundStyle(Color(.Labels.primary)) + + Spacer(minLength: 8) + + 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.primary)) + + 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()) + } + } + } + .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + .buttonStyle(.plain) + .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 { + return if let specifications = store.specifications { + "\(specifications.deviceName) \(specifications.editionName)" + } else { + String(localized: "Loading...", bundle: .module) + } + } +} + +// 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 { + NavigationStack { + DeviceSpecificationsScreen( + store: Store( + initialState: DeviceSpecificationsFeature.State( + tag: "forpda", + subTag: nil + ), + ) { + DeviceSpecificationsFeature() + } + ) + } + .environment(\.tintColor, Color(.Theme.primary)) +} 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 +} diff --git a/Modules/Sources/DeviceSpecificationsFeature/Resources/Localizable.xcstrings b/Modules/Sources/DeviceSpecificationsFeature/Resources/Localizable.xcstrings new file mode 100644 index 00000000..aa44ecad --- /dev/null +++ b/Modules/Sources/DeviceSpecificationsFeature/Resources/Localizable.xcstrings @@ -0,0 +1,76 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "Copy Link" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скопировать ссылку" + } + } + } + }, + "Link copied" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ссылка скопирована" + } + } + } + }, + "Loading..." : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузка…" + } + } + } + }, + "Mark as My Device" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сделать моим устройством" + } + } + } + }, + "My Device" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Моё устройство" + } + } + } + }, + "Unable to change device status" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка изменения статуса устройства" + } + } + } + }, + "You can add a maximum of 5 devices" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нельзя добавить более 5 устройств" + } + } + } + } + }, + "version" : "1.1" +} \ No newline at end of file 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/DeviceSpecifications.swift b/Modules/Sources/Models/DevDB/DeviceSpecifications.swift new file mode 100644 index 00000000..ff8be184 --- /dev/null +++ b/Modules/Sources/Models/DevDB/DeviceSpecifications.swift @@ -0,0 +1,141 @@ +// +// DeviceSpecificationsResponse.swift +// ForPDA +// +// Created by Xialtal on 14.12.25. +// + +import Foundation + +public struct DeviceSpecifications: Sendable, Equatable { + 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 var isMyDevice: Bool + + public struct DeviceImage: Sendable, Equatable, Hashable { + 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, Equatable { + public let name: String + public let subTag: String + + public init(name: String, subTag: String) { + self.name = name + self.subTag = subTag + } + } + + public struct Specification: Sendable, Equatable, Identifiable { + public let id: Int + public let title: String + public var entries: [Entry] + + public struct Entry: Sendable, Equatable, Hashable { + 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: [Entry]) { + 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], + isMyDevice: Bool + ) { + 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 + self.isMyDevice = isMyDevice + } +} + +public extension DeviceSpecifications { + static let mock = DeviceSpecifications( + 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: [ + .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: [ + .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)..." + ) + ] + ) + ], + isMyDevice: true + ) +} 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..ebc43cd1 --- /dev/null +++ b/Modules/Sources/ParsingClient/Parsers/DevDBParser.swift @@ -0,0 +1,107 @@ +// +// 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) -> DeviceSpecifications { + 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]], + let isMyDevice = array[safe: 11] as? Int else { + throw ParsingError.failedToCastFields + } + + return DeviceSpecifications( + 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), + isMyDevice: isMyDevice == 1 + ) + } + + // MARK: - Images + + 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, + 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) -> [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 { + throw ParsingError.failedToCastFields + } + + editions.append(.init(name: name, subTag: subTag)) + } + return editions + } + + // MARK: - Specifications + + 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 { + 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(.init(name: title, value: value)) + } + } + return specs + } +} diff --git a/Modules/Sources/ParsingClient/ParsingClient.swift b/Modules/Sources/ParsingClient/ParsingClient.swift index d457eb82..b1c1bfe9 100644 --- a/Modules/Sources/ParsingClient/ParsingClient.swift +++ b/Modules/Sources/ParsingClient/ParsingClient.swift @@ -60,6 +60,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 -> DeviceSpecifications } // MARK: - Dependency Key @@ -158,6 +161,9 @@ extension ParsingClient: DependencyKey { }, parseQmsChat: { response in return try QMSChatParser.parse(from: response) + }, + parseDeviceSpecifications: { response in + return try DevDBParser.parse(from: response) } ) } diff --git a/Modules/Sources/ProfileFeature/Analytics/ProfileFeature+Analytics.swift b/Modules/Sources/ProfileFeature/Analytics/ProfileFeature+Analytics.swift index e82eed0c..6bc9d315 100644 --- a/Modules/Sources/ProfileFeature/Analytics/ProfileFeature+Analytics.swift +++ b/Modules/Sources/ProfileFeature/Analytics/ProfileFeature+Analytics.swift @@ -56,6 +56,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 d0149600..36a02fc7 100644 --- a/Modules/Sources/ProfileFeature/ProfileFeature.swift +++ b/Modules/Sources/ProfileFeature/ProfileFeature.swift @@ -80,6 +80,7 @@ public struct ProfileFeature: Reducer, Sendable { case reputationButtonTapped case searchTopicsButtonTapped case searchRepliesButtonTapped + case deviceButtonTapped(String) case curatedTopicButtonTapped(Int) case deeplinkTapped(URL, ProfileDeeplinkType) } @@ -101,6 +102,7 @@ public struct ProfileFeature: Reducer, Sendable { case openSettings case openHistory case openMentions + case openDevice(String) case openTopic(Int) case openReputation(Int) case openSearch(SearchResult) @@ -146,6 +148,9 @@ public struct ProfileFeature: Reducer, Sendable { } ) + 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 a496c800..3a59fd84 100644 --- a/Modules/Sources/ProfileFeature/ProfileScreen.swift +++ b/Modules/Sources/ProfileFeature/ProfileScreen.swift @@ -66,7 +66,7 @@ public struct ProfileScreen: View { LoggingSegment(user: user) } } - .listSectionSpacingBackport(28) + ._listSectionSpacing(28) .scrollContentBackground(.hidden) } else { PDALoader() @@ -183,15 +183,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) } } @@ -366,24 +366,9 @@ 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) - } + Row(title: LocalizedStringKey(device.name), type: .navigation(.indicator(device.main))) { + send(.deviceButtonTapped(device.id)) } - .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) - .buttonStyle(.plain) - .frame(height: 60) } } header: { SectionHeader(title: "Devices List") @@ -556,9 +541,14 @@ public struct ProfileScreen: View { case basic case basicNavigation 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 @@ -601,17 +591,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) @@ -686,26 +688,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 + } + } +} diff --git a/Project.swift b/Project.swift index 6636553c..702677ea 100644 --- a/Project.swift +++ b/Project.swift @@ -41,6 +41,7 @@ let project = Project( .Internal.CacheClient, .Internal.DeeplinkHandler, .Internal.DeveloperFeature, + .Internal.DeviceSpecificationsFeature, .Internal.FavoritesFeature, .Internal.FavoritesRootFeature, .Internal.ForumFeature, @@ -201,6 +202,18 @@ let project = Project( .SPM.TCA ] ), + + .feature( + name: "DeviceSpecificationsFeature", + dependencies: [ + .Internal.APIClient, + .Internal.GalleryFeature, + .Internal.Models, + .Internal.SharedUI, + .Internal.ToastClient, + .SPM.TCA + ] + ), .feature( name: "FavoritesFeature", @@ -1029,6 +1042,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 FormFeature = TargetDependency.target(name: "FormFeature")