From 37c509e265d86ad4dea89f69b979f7c85a30386d Mon Sep 17 00:00:00 2001 From: Xialtal Date: Wed, 28 May 2025 18:55:54 +0300 Subject: [PATCH 001/118] Fix unread dot in forum row --- Modules/Sources/SharedUI/ForumRow.swift | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/Modules/Sources/SharedUI/ForumRow.swift b/Modules/Sources/SharedUI/ForumRow.swift index 1ecba091..ad18f8e6 100644 --- a/Modules/Sources/SharedUI/ForumRow.swift +++ b/Modules/Sources/SharedUI/ForumRow.swift @@ -42,17 +42,12 @@ public struct ForumRow: View { Spacer(minLength: 0) if isUnread { - Button { - onAction(true) - } label: { - Image(systemSymbol: .circleFill) - .resizable() - .scaledToFit() - .frame(width: 10, height: 10) - .foregroundStyle(tintColor) - } - .buttonStyle(.plain) - .frame(maxWidth: 42, maxHeight: .infinity) + Image(systemSymbol: .circleFill) + .resizable() + .scaledToFit() + .frame(width: 10, height: 10) + .foregroundStyle(tintColor) + .frame(maxWidth: 42, maxHeight: .infinity) } } .padding(.vertical, 8) From a580ba974e9d0e7effebdf51f7413c2ef1dd705c Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 6 Jun 2025 20:33:09 +0300 Subject: [PATCH 002/118] Bump API version --- Tuist/Package.resolved | 6 +++--- Tuist/Package.swift | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index f2f02e99..c2883e61 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "097d1dd0481e0284ef3e8bc5cb23e8c130015b5fa794922bc9e3d218b6c8b305", + "originHash" : "be5cec13550a4400d8be4256816527a8dc477e3d04696c8e4b3d266956a698d2", "pins" : [ { "identity" : "activityindicatorview", @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/SubvertDev/PDAPI_SPM.git", "state" : { - "revision" : "09a546fd5f77212e72ca5e69101f71418f9b470e", - "version" : "0.4.3" + "revision" : "05ca72477225c19444427577c690b7abc28aa85d", + "version" : "0.4.4" } }, { diff --git a/Tuist/Package.swift b/Tuist/Package.swift index 1a78f5fe..462a2606 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -54,7 +54,7 @@ let package = Package( .package(url: "https://github.com/SubvertDev/AlertToast.git", revision: "d0f7d6b"), .package(url: "https://github.com/kirualex/SwiftyGif.git", from: "5.4.4"), .package(url: "https://github.com/ZhgChgLi/ZMarkupParser.git", from: "1.12.0"), - .package(url: "https://github.com/SubvertDev/PDAPI_SPM.git", exact: "0.4.3"), + .package(url: "https://github.com/SubvertDev/PDAPI_SPM.git", exact: "0.4.4"), .package(url: "https://github.com/SubvertDev/RichTextKit.git", branch: "main"), .package(url: "https://github.com/exyte/Chat.git", exact: "2.4.2"), // 2.5.0+ is iOS 17+ .package(url: "https://github.com/gohanlon/swift-memberwise-init-macro.git", from: "0.5.2") From 2e216036125dfc3b1f3abd609ea3767601b51e5d Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 6 Jun 2025 20:50:46 +0300 Subject: [PATCH 003/118] Topic and post templates draft --- Modules/Sources/APIClient/APIClient.swift | 32 ++- .../Requests/ForumTemplateRequest.swift | 34 --- .../AnalyticsClient/Events/TopicEvent.swift | 1 + .../Analytics/ForumFeature+Analytics.swift | 4 +- .../Sources/ForumFeature/ForumFeature.swift | 19 ++ .../Sources/ForumFeature/ForumScreen.swift | 14 ++ .../Models/ForumOptionContextMenuAction.swift | 1 + .../Resources/Localizable.xcstrings | 10 + .../Models/Common/ReportResponseType.swift | 2 - .../Models/Common/WriteFormFieldType.swift | 6 + .../Models/Common/WriteFormForType.swift | 8 +- .../Sources/Models/Common/WriteFormSend.swift | 1 + Modules/Sources/Models/Forum/Forum.swift | 4 + .../Sources/Models/Forum/TemplateSend.swift | 29 +++ Modules/Sources/Models/Forum/Topic.swift | 8 +- .../ParsingClient/Parsers/TopicParser.swift | 6 +- .../Parsers/WriteFormParser.swift | 60 +++++- .../Sources/ParsingClient/ParsingClient.swift | 8 + .../Analytics/TopicFeature+Analytics.swift | 2 + .../Models/TopicContextMenuAction.swift | 1 + .../Sources/TopicFeature/TopicFeature.swift | 11 + .../Sources/TopicFeature/TopicScreen.swift | 6 + .../Models/FormContentData.swift | 21 ++ .../Preview/FormPreviewFeature.swift | 50 +++-- .../WriteFormFeature/WriteFormFeature.swift | 203 +++++++++++++++--- .../WriteFormFeature/WriteFormScreen.swift | 14 +- .../WriteFormFeature/WriteFormView.swift | 173 ++++++++++----- 27 files changed, 577 insertions(+), 151 deletions(-) delete mode 100644 Modules/Sources/APIClient/Requests/ForumTemplateRequest.swift create mode 100644 Modules/Sources/Models/Forum/TemplateSend.swift create mode 100644 Modules/Sources/WriteFormFeature/Models/FormContentData.swift diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index eb6d7fd9..fe2e733a 100644 --- a/Modules/Sources/APIClient/APIClient.swift +++ b/Modules/Sources/APIClient/APIClient.swift @@ -47,7 +47,9 @@ public struct APIClient: Sendable { public var markReadForum: @Sendable (_ id: Int, _ isTopic: Bool) async throws -> Bool public var getAnnouncement: @Sendable (_ id: Int) async throws -> Announcement public var getTopic: @Sendable (_ id: Int, _ page: Int, _ perPage: Int) async throws -> Topic - public var getTemplate: @Sendable (_ request: ForumTemplateRequest, _ isTopic: Bool) async throws -> [WriteFormFieldType] + public var getTemplate: @Sendable (_ id: Int, _ isTopic: Bool) async throws -> [WriteFormFieldType] + public var previewTemplate: @Sendable (_ id: Int, _ content: String, _ isTopic: Bool) async throws -> PostPreview + public var sendTemplate: @Sendable (_ id: Int, _ content: String, _ isTopic: Bool) async throws -> TemplateSend public var getHistory: @Sendable (_ offset: Int, _ perPage: Int) async throws -> History public var previewPost: @Sendable (_ request: PostPreviewRequest) async throws -> PostPreview public var sendPost: @Sendable (_ request: PostRequest) async throws -> PostSend @@ -231,13 +233,29 @@ extension APIClient: DependencyKey { let response = try await api.get(ForumCommand.Topic.view(data: request)) return try await parser.parseTopic(response) }, - getTemplate: { request, isTopic in + getTemplate: { id, isTopic in let command = ForumCommand.template( - type: isTopic ? .topic(forumId: request.id) : .post(topicId: request.id), - action: request.action.transferType + type: isTopic ? .topic(forumId: id) : .post(topicId: id), + action: .get ) let response = try await api.get(command) return try await parser.parseWriteForm(response) + }, + previewTemplate: { id, content, isTopic in + let command = ForumCommand.template( + type: isTopic ? .topic(forumId: id) : .post(topicId: id), + action: .preview(content) + ) + let response = try await api.get(command) + return try await parser.parseTemplatePreview(response: response) + }, + sendTemplate: { id, content, isTopic in + let command = ForumCommand.template( + type: isTopic ? .topic(forumId: id) : .post(topicId: id), + action: .send(content) + ) + let response = try await api.get(command) + return try await parser.parseTemplateSend(response: response) }, getHistory: { offset, perPage in let response = try await api.get(MemberCommand.history(page: offset, perPage: perPage)) @@ -444,6 +462,12 @@ extension APIClient: DependencyKey { }, getTemplate: { _, _ in return [.mockTitle, .mockText, .mockEditor] + }, + previewTemplate: { _, _, _ in + return PostPreview(content: "Builded", attachmentIds: []) + }, + sendTemplate: { _, _, isTopic in + return .success(isTopic ? .topic(id: 0) : .post(PostSend(id: 0, topicId: 1, offset: 2))) }, getHistory: { _, _ in return .mock diff --git a/Modules/Sources/APIClient/Requests/ForumTemplateRequest.swift b/Modules/Sources/APIClient/Requests/ForumTemplateRequest.swift deleted file mode 100644 index 0b7dc530..00000000 --- a/Modules/Sources/APIClient/Requests/ForumTemplateRequest.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// ForumTemplateRequest.swift -// ForPDA -// -// Created by Xialtal on 15.03.25. -// - -import PDAPI - -public struct ForumTemplateRequest { - public let id: Int - public let action: TemplateAction - - public enum TemplateAction { - case get - case send([Any]) - case preview([Any]) - } - - public init(id: Int, action: TemplateAction) { - self.id = id - self.action = action - } -} - -extension ForumTemplateRequest.TemplateAction { - var transferType: ForumCommand.TemplateAction { - switch self { - case .get: return .get - case .preview(let data): return .preview(data) - case .send(let data): return .send(data) - } - } -} diff --git a/Modules/Sources/AnalyticsClient/Events/TopicEvent.swift b/Modules/Sources/AnalyticsClient/Events/TopicEvent.swift index 434d25eb..bfad8e83 100644 --- a/Modules/Sources/AnalyticsClient/Events/TopicEvent.swift +++ b/Modules/Sources/AnalyticsClient/Events/TopicEvent.swift @@ -19,6 +19,7 @@ public enum TopicEvent: Event { case menuGoToEnd case menuSetFavorite case menuWritePost + case menuWritePostWithTemplate case menuPostReply(Int) case menuPostEdit(Int) diff --git a/Modules/Sources/ForumFeature/Analytics/ForumFeature+Analytics.swift b/Modules/Sources/ForumFeature/Analytics/ForumFeature+Analytics.swift index a4619247..56587d91 100644 --- a/Modules/Sources/ForumFeature/Analytics/ForumFeature+Analytics.swift +++ b/Modules/Sources/ForumFeature/Analytics/ForumFeature+Analytics.swift @@ -19,7 +19,7 @@ extension ForumFeature { var body: some Reducer { Reduce { state, action in switch action { - case .onAppear, .pageNavigation, .delegate: + case .onAppear, .pageNavigation, .writeForm, .delegate: break case .onRefresh: @@ -39,6 +39,8 @@ extension ForumFeature { case let .contextOptionMenu(option): switch option { + case .createTopic: + break // TODO: Add case .sort: break // TODO: Add case .toBookmarks: diff --git a/Modules/Sources/ForumFeature/ForumFeature.swift b/Modules/Sources/ForumFeature/ForumFeature.swift index 312601b0..c10208f7 100644 --- a/Modules/Sources/ForumFeature/ForumFeature.swift +++ b/Modules/Sources/ForumFeature/ForumFeature.swift @@ -15,6 +15,7 @@ import PasteboardClient import PersistenceKeys import TCAExtensions import ToastClient +import WriteFormFeature @Reducer public struct ForumFeature: Reducer, Sendable { @@ -27,6 +28,7 @@ public struct ForumFeature: Reducer, Sendable { public struct State: Equatable { @Shared(.appSettings) var appSettings: AppSettings @Shared(.userSession) var userSession: UserSession? + @Presents var writeForm: WriteFormFeature.State? public var forumId: Int public var forumName: String? @@ -69,6 +71,8 @@ public struct ForumFeature: Reducer, Sendable { case contextTopicMenu(ForumTopicContextMenuAction, TopicInfo) case contextCommonMenu(ForumCommonContextMenuAction, Int, Bool) + case writeForm(PresentationAction) + case pageNavigation(PageNavigationFeature.Action) case _loadForum(offset: Int) @@ -115,6 +119,9 @@ public struct ForumFeature: Reducer, Sendable { case .pageNavigation: return .none + case .writeForm: + return .none + case let ._loadForum(offset): if !state.isRefreshing { state.isLoadingTopics = true @@ -131,6 +138,15 @@ public struct ForumFeature: Reducer, Sendable { case .contextOptionMenu(let action): switch action { + case .createTopic: + state.writeForm = WriteFormFeature.State( + formFor: .topic( + forumId: state.forumId, + content: "" + ) + ) + return .none + // TODO: sort, to bookmarks // TODO: Add analytics default: return .none @@ -230,6 +246,9 @@ public struct ForumFeature: Reducer, Sendable { return .none } } + .ifLet(\.$writeForm, action: \.writeForm) { + WriteFormFeature() + } Analytics() } diff --git a/Modules/Sources/ForumFeature/ForumScreen.swift b/Modules/Sources/ForumFeature/ForumScreen.swift index 1188bc3b..26723bc4 100644 --- a/Modules/Sources/ForumFeature/ForumScreen.swift +++ b/Modules/Sources/ForumFeature/ForumScreen.swift @@ -11,6 +11,7 @@ import PageNavigationFeature import SFSafeSymbols import SharedUI import Models +import WriteFormFeature public struct ForumScreen: View { @@ -58,6 +59,11 @@ public struct ForumScreen: View { .animation(.default, value: store.forum) .navigationTitle(Text(store.forumName ?? "Загрузка...")) .navigationBarTitleDisplayMode(.large) + .fullScreenCover(item: $store.scope(state: \.writeForm, action: \.writeForm)) { store in + NavigationStack { + WriteFormScreen(store: store) + } + } .toolbar { OptionsMenu() } @@ -73,6 +79,14 @@ public struct ForumScreen: View { private func OptionsMenu() -> some View { Menu { if let forum = store.forum { + if forum.canCreateTopic { + Section { + ContextButton(text: "Create Topic", symbol: .plusCircle, bundle: .module) { + store.send(.contextOptionMenu(.createTopic)) + } + } + } + CommonContextMenu( id: forum.id, isFavorite: forum.isFavorite, diff --git a/Modules/Sources/ForumFeature/Models/ForumOptionContextMenuAction.swift b/Modules/Sources/ForumFeature/Models/ForumOptionContextMenuAction.swift index ee5a0621..39d683bf 100644 --- a/Modules/Sources/ForumFeature/Models/ForumOptionContextMenuAction.swift +++ b/Modules/Sources/ForumFeature/Models/ForumOptionContextMenuAction.swift @@ -6,6 +6,7 @@ // public enum ForumOptionContextMenuAction { + case createTopic case sort case toBookmarks } diff --git a/Modules/Sources/ForumFeature/Resources/Localizable.xcstrings b/Modules/Sources/ForumFeature/Resources/Localizable.xcstrings index 864386cb..6be63623 100644 --- a/Modules/Sources/ForumFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/ForumFeature/Resources/Localizable.xcstrings @@ -31,6 +31,16 @@ } } }, + "Create Topic" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Создать тему" + } + } + } + }, "Go To End" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/Models/Common/ReportResponseType.swift b/Modules/Sources/Models/Common/ReportResponseType.swift index ad64df70..0698e660 100644 --- a/Modules/Sources/Models/Common/ReportResponseType.swift +++ b/Modules/Sources/Models/Common/ReportResponseType.swift @@ -5,8 +5,6 @@ // Created by Xialtal on 5.04.25. // -import SwiftUI - public enum ReportResponseType: Int, Sendable { case tooShort = 4 case success = 0 diff --git a/Modules/Sources/Models/Common/WriteFormFieldType.swift b/Modules/Sources/Models/Common/WriteFormFieldType.swift index 313ec990..dda77028 100644 --- a/Modules/Sources/Models/Common/WriteFormFieldType.swift +++ b/Modules/Sources/Models/Common/WriteFormFieldType.swift @@ -14,6 +14,7 @@ public enum WriteFormFieldType: Sendable, Equatable, Hashable { case checkboxList(FormField, _ options: [String]) public struct FormField: Sendable, Equatable, Hashable { + public let id: Int public let name: String public let description: String public let example: String @@ -29,12 +30,14 @@ public enum WriteFormFieldType: Sendable, Equatable, Hashable { } public init( + id: Int, name: String, description: String, example: String, flag: Int, defaultValue: String ) { + self.id = id self.name = name self.description = description self.example = example @@ -50,6 +53,7 @@ public extension WriteFormFieldType { static let mockText: WriteFormFieldType = .text( FormField( + id: 0, name: "Topic name", description: "Enter topic name.", example: "Starting from For, ends with PDA", @@ -60,6 +64,7 @@ public extension WriteFormFieldType { static let mockEditor: WriteFormFieldType = .editor( FormField( + id: 0, name: "Topic content", description: "This field contains topic [color=red]hat[/color] content.", example: "ForPDA Forever!", @@ -70,6 +75,7 @@ public extension WriteFormFieldType { static let mockEditorSimple: WriteFormFieldType = .editor( FormField( + id: 0, name: "", description: "", example: "Post text...", diff --git a/Modules/Sources/Models/Common/WriteFormForType.swift b/Modules/Sources/Models/Common/WriteFormForType.swift index 150d0e10..f31bc2c4 100644 --- a/Modules/Sources/Models/Common/WriteFormForType.swift +++ b/Modules/Sources/Models/Common/WriteFormForType.swift @@ -9,7 +9,7 @@ import Foundation public enum WriteFormForType: Sendable, Equatable { case report(id: Int, type: ReportType) - case topic(forumId: Int, content: [String]) + case topic(forumId: Int, content: String) case post(type: PostType, topicId: Int, content: PostContentType) public enum PostType: Sendable, Equatable { @@ -18,7 +18,11 @@ public enum WriteFormForType: Sendable, Equatable { } public enum PostContentType: Sendable, Equatable { - case template([String]) + case template(String) case simple(String, [Int]) } + + public var isTopic: Bool { + if case .topic = self { true } else { false } + } } diff --git a/Modules/Sources/Models/Common/WriteFormSend.swift b/Modules/Sources/Models/Common/WriteFormSend.swift index 07b91b13..19662481 100644 --- a/Modules/Sources/Models/Common/WriteFormSend.swift +++ b/Modules/Sources/Models/Common/WriteFormSend.swift @@ -8,4 +8,5 @@ public enum WriteFormSend: Sendable { case post(PostSend) case report(ReportResponseType) + case template(TemplateSend) } diff --git a/Modules/Sources/Models/Forum/Forum.swift b/Modules/Sources/Models/Forum/Forum.swift index 315a3d2d..5da93006 100644 --- a/Modules/Sources/Models/Forum/Forum.swift +++ b/Modules/Sources/Models/Forum/Forum.swift @@ -18,6 +18,10 @@ public struct Forum: Codable, Sendable, Hashable { public var topics: [TopicInfo] public let navigation: [ForumInfo] + public var canCreateTopic: Bool { + return flag & 64 > 0 + } + public var isFavorite: Bool { return (flag & 8) != 0 } diff --git a/Modules/Sources/Models/Forum/TemplateSend.swift b/Modules/Sources/Models/Forum/TemplateSend.swift new file mode 100644 index 00000000..dd896899 --- /dev/null +++ b/Modules/Sources/Models/Forum/TemplateSend.swift @@ -0,0 +1,29 @@ +// +// TemplateSend.swift +// ForPDA +// +// Created by Xialtal on 6.06.25. +// + +public enum TemplateSend: Sendable { + case success(TemplateSendType) + case error(TemplateSendError) + + public enum TemplateSendType: Sendable { + case topic(id: Int) + case post(PostSend) + } + + public enum TemplateSendError: Sendable { + case badParam + case sentToPremod + case fieldsError(String) + case status(Int) + } + + public var isError: Bool { + return if case .error = self { + true + } else { false } + } +} diff --git a/Modules/Sources/Models/Forum/Topic.swift b/Modules/Sources/Models/Forum/Topic.swift index f76391d1..c2110707 100644 --- a/Modules/Sources/Models/Forum/Topic.swift +++ b/Modules/Sources/Models/Forum/Topic.swift @@ -21,6 +21,7 @@ public struct Topic: Codable, Sendable, Identifiable, Hashable { public let postsCount: Int public let posts: [Post] public let navigation: [ForumInfo] + public let postTemplateName: String? public var canPost: Bool { return (flag & 64) != 0 && (flag & 16) == 0 @@ -79,7 +80,8 @@ public struct Topic: Codable, Sendable, Identifiable, Hashable { poll: Poll?, postsCount: Int, posts: [Post], - navigation: [ForumInfo] + navigation: [ForumInfo], + postTemplateName: String? ) { self.id = id self.name = name @@ -94,6 +96,7 @@ public struct Topic: Codable, Sendable, Identifiable, Hashable { self.postsCount = postsCount self.posts = posts self.navigation = navigation + self.postTemplateName = postTemplateName self.isFavorite = (flag & 8) != 0 } @@ -130,6 +133,7 @@ public extension Topic { ], navigation: [ ForumInfo(id: 1, name: "iOS - Apps", flag: 32) - ] + ], + postTemplateName: "New update" ) } diff --git a/Modules/Sources/ParsingClient/Parsers/TopicParser.swift b/Modules/Sources/ParsingClient/Parsers/TopicParser.swift index e78b7419..1076d338 100644 --- a/Modules/Sources/ParsingClient/Parsers/TopicParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/TopicParser.swift @@ -33,7 +33,8 @@ public struct TopicParser { let curatorName = array[safe: 11] as? String, let poll = array[safe: 12] as? [Any], let postsCount = array[safe: 13] as? Int, - let posts = array[safe: 14] as? [[Any]] else { + let posts = array[safe: 14] as? [[Any]], + let postTemplates = array[safe: 15] as? [String] else { throw ParsingError.failedToCastFields } @@ -50,7 +51,8 @@ public struct TopicParser { poll: try parsePoll(poll), postsCount: postsCount, posts: try parsePosts(posts), - navigation: ForumParser.parseNavigation(navigation) + navigation: ForumParser.parseNavigation(navigation), + postTemplateName: !postTemplates.isEmpty ? postTemplates[safe: 0] : nil ) } diff --git a/Modules/Sources/ParsingClient/Parsers/WriteFormParser.swift b/Modules/Sources/ParsingClient/Parsers/WriteFormParser.swift index 58f2cc43..d88dcc28 100644 --- a/Modules/Sources/ParsingClient/Parsers/WriteFormParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/WriteFormParser.swift @@ -26,9 +26,66 @@ public struct WriteFormParser { return try parseFormFields(fields) } + public static func parseTemplatePreview(from string: String) throws(ParsingError) -> PostPreview { + 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 template = array[safe: 2] as? [Any], + let content = template[safe: 2] as? String, + let attachmentIds = template[safe: 3] as? [Int] else { + throw ParsingError.failedToCastFields + } + + return PostPreview(content: content, attachmentIds: attachmentIds) + } + + public static func parseTemplateSend(from string: String) throws(ParsingError) -> TemplateSend { + 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 status = array[safe: 1] as? Int else { + throw ParsingError.failedToCastFields + } + + switch status { + case 0: + // if elements > 3 - response for post. + return if array.count > 3 { + .success(.post(try TopicParser.parsePostSend(from: string))) + } else { + .success(.topic(id: array[safe: 2] as! Int)) + } + + case 5: + guard let errors = array[safe: 2] as? [Any] else { + throw ParsingError.failedToCastFields + } + return .error(.fieldsError(errors.description)) + + case 3: + return .error(.badParam) + + case 4: + return .error(.sentToPremod) + + default: + return .error(.status(status)) + } + } + private static func parseFormFields(_ fieldsRaw: [[Any]]) throws(ParsingError)-> [WriteFormFieldType] { var formFields: [WriteFormFieldType] = [] - for field in fieldsRaw { + for (index, field) in fieldsRaw.enumerated() { guard let type = field[safe: 0] as? String, let name = field[safe: 1] as? String, let description = field[safe: 2] as? String, @@ -39,6 +96,7 @@ public struct WriteFormParser { } let content = WriteFormFieldType.FormField( + id: index, name: name, description: description, example: example, diff --git a/Modules/Sources/ParsingClient/ParsingClient.swift b/Modules/Sources/ParsingClient/ParsingClient.swift index cca84b2f..379917cc 100644 --- a/Modules/Sources/ParsingClient/ParsingClient.swift +++ b/Modules/Sources/ParsingClient/ParsingClient.swift @@ -37,6 +37,8 @@ public struct ParsingClient: Sendable { public var parseFavorites: @Sendable (_ response: String) async throws -> Favorite public var parseHistory: @Sendable (_ response: String) async throws -> History public var parsePostPreview: @Sendable (_ response: String) async throws -> PostPreview + public var parseTemplatePreview: @Sendable (_ response: String) async throws -> PostPreview + public var parseTemplateSend: @Sendable (_ response: String) async throws -> TemplateSend public var parsePostSend: @Sendable (_ response: String) async throws -> PostSend // Write Form @@ -103,6 +105,12 @@ extension ParsingClient: DependencyKey { parsePostPreview: { response in return try TopicParser.parsePostPreview(from: response) }, + parseTemplatePreview: { response in + return try WriteFormParser.parseTemplatePreview(from: response) + }, + parseTemplateSend: { response in + return try WriteFormParser.parseTemplateSend(from: response) + }, parsePostSend: { response in return try TopicParser.parsePostSend(from: response) }, diff --git a/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift b/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift index 5636dbd2..d92e4612 100644 --- a/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift +++ b/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift @@ -69,6 +69,8 @@ extension TopicFeature { analytics.log(TopicEvent.menuSetFavorite) case .writePost: analytics.log(TopicEvent.menuWritePost) + case .writePostWithTemplate: + analytics.log(TopicEvent.menuWritePostWithTemplate) } case let .internal(.loadTopic(offset: offset)): diff --git a/Modules/Sources/TopicFeature/Models/TopicContextMenuAction.swift b/Modules/Sources/TopicFeature/Models/TopicContextMenuAction.swift index b54de7bd..d346fe33 100644 --- a/Modules/Sources/TopicFeature/Models/TopicContextMenuAction.swift +++ b/Modules/Sources/TopicFeature/Models/TopicContextMenuAction.swift @@ -7,6 +7,7 @@ public enum TopicContextMenuAction { case writePost + case writePostWithTemplate case copyLink case openInBrowser case goToEnd diff --git a/Modules/Sources/TopicFeature/TopicFeature.swift b/Modules/Sources/TopicFeature/TopicFeature.swift index 39eab06d..fe8ebf73 100644 --- a/Modules/Sources/TopicFeature/TopicFeature.swift +++ b/Modules/Sources/TopicFeature/TopicFeature.swift @@ -212,6 +212,17 @@ public struct TopicFeature: Reducer, Sendable { state.destination = .writeForm(feature) return .none + case .writePostWithTemplate: + let feature = WriteFormFeature.State( + formFor: .post( + type: .new, + topicId: topic.id, + content: .template("") + ) + ) + state.destination = .writeForm(feature) + return .none + case .openInBrowser: let url = URL(string: "https://4pda.to/forum/index.php?showtopic=\(topic.id)")! return .run { _ in await open(url: url) } diff --git a/Modules/Sources/TopicFeature/TopicScreen.swift b/Modules/Sources/TopicFeature/TopicScreen.swift index ab3a0c90..dc4c23fe 100644 --- a/Modules/Sources/TopicFeature/TopicScreen.swift +++ b/Modules/Sources/TopicFeature/TopicScreen.swift @@ -105,6 +105,12 @@ public struct TopicScreen: View { ContextButton(text: "Write Post", symbol: .plusCircle, bundle: .module) { send(.contextMenu(.writePost)) } + + if let postTemplate = topic.postTemplateName { + ContextButton(text: LocalizedStringKey(postTemplate), symbol: .plusApp, bundle: .module) { + send(.contextMenu(.writePostWithTemplate)) + } + } } } diff --git a/Modules/Sources/WriteFormFeature/Models/FormContentData.swift b/Modules/Sources/WriteFormFeature/Models/FormContentData.swift new file mode 100644 index 00000000..47e22754 --- /dev/null +++ b/Modules/Sources/WriteFormFeature/Models/FormContentData.swift @@ -0,0 +1,21 @@ +// +// FormContentData.swift +// ForPDA +// +// Created by Xialtal on 25.05.25. +// + +public enum FormContentData: Equatable { + case text(String) + case dropdown(Int, String) + case uploadbox([File]) // TODO: .. + case checkbox([Int: Bool]) + + public struct File: Equatable { + let id: Int + let filename: String + let uploadError: Bool + let isRemoved: Bool + let isUploading: Bool + } +} diff --git a/Modules/Sources/WriteFormFeature/Preview/FormPreviewFeature.swift b/Modules/Sources/WriteFormFeature/Preview/FormPreviewFeature.swift index 17da933a..233a5992 100644 --- a/Modules/Sources/WriteFormFeature/Preview/FormPreviewFeature.swift +++ b/Modules/Sources/WriteFormFeature/Preview/FormPreviewFeature.swift @@ -19,7 +19,7 @@ public struct FormPreviewFeature: Reducer, Sendable { // MARK: - State @ObservableState - public struct State: Equatable { + public struct State: Equatable, Sendable { public let formType: WriteFormForType var contentTypes: [TopicTypeUI] = [] @@ -40,8 +40,9 @@ public struct FormPreviewFeature: Reducer, Sendable { case cancelButtonTapped + case _loadPreview(id: Int, content: String) case _loadSimplePreview(id: Int, content: String, attIds: [Int]) - case _simplePreviewResponse(Result) + case _previewResponse(Result) } // MARK: - Dependencies @@ -55,44 +56,61 @@ public struct FormPreviewFeature: Reducer, Sendable { Reduce { state, action in switch action { case .onAppear: - if case let .post(_, topicId, contentType) = state.formType { + switch state.formType { + case .topic(let forumId, let content): + return .send(._loadPreview(id: forumId, content: content)) + + case .post(_, let topicId, let contentType): switch contentType { case .simple(let content, let attachments): return .send(._loadSimplePreview(id: topicId, content: content, attIds: attachments)) - // TODO: Think about correct type. Should be Any? - case .template(_): return .none + case .template(let content): + return .send(._loadPreview(id: topicId, content: content)) } + + case .report(_, _): + // handling as .post + break } return .none case .cancelButtonTapped: return .run { _ in await dismiss() } - + + case let ._loadPreview(id, content): + state.isPreviewLoading = true + return .run { [isTopic = state.formType.isTopic] send in + let result = await Result { try await apiClient.previewTemplate( + id: id, + content: content, + isTopic: isTopic + )} + await send(._previewResponse(result)) + } catch: { error, send in + await send(._previewResponse(.failure(error))) + } + case let ._loadSimplePreview(id, content, attachments): state.isPreviewLoading = true - return .run { [ - topicId = id, - content = content, - attachments = attachments - ] send in + return .run { send in let result = await Result { try await apiClient.previewPost( request: PostPreviewRequest( id: 0, // TODO: until we not adding support to edit post. post: PostRequest( - topicId: topicId, + topicId: id, content: content, flag: 0, attachments: attachments ) ) )} - await send(._simplePreviewResponse(result)) + await send(._previewResponse(result)) } catch: { error, send in - await send(._simplePreviewResponse(.failure(error))) + await send(._previewResponse(.failure(error))) } - case let ._simplePreviewResponse(.success(preview)): + case let ._previewResponse(.success(preview)): state.contentTypes = TopicNodeBuilder( text: preview.content, attachments: [] ).build() @@ -103,7 +121,7 @@ public struct FormPreviewFeature: Reducer, Sendable { return .none - case let ._simplePreviewResponse(.failure(error)): + case let ._previewResponse(.failure(error)): // TODO: Toast? print(error) return .send(.cancelButtonTapped) diff --git a/Modules/Sources/WriteFormFeature/WriteFormFeature.swift b/Modules/Sources/WriteFormFeature/WriteFormFeature.swift index bf670994..5d5f29b5 100644 --- a/Modules/Sources/WriteFormFeature/WriteFormFeature.swift +++ b/Modules/Sources/WriteFormFeature/WriteFormFeature.swift @@ -24,7 +24,8 @@ public struct WriteFormFeature: Reducer, Sendable { public let formFor: WriteFormForType - var textContent = "" + var content: [Int: FormContentData] = [:] + var isEditReasonToggleSelected = false var editReasonContent = "" var canShowShowMark = false @@ -36,6 +37,16 @@ public struct WriteFormFeature: Reducer, Sendable { return false } + var isSubmitDisabled: Bool { + !isFieldsValid(fields: formFields, content: content) + } + + var textContent: String { + if content.count == 1, case .text(let content) = content[0] { + content + } else { buildContent(fields: content) } + } + var formFields: [WriteFormFieldType] = [] var isFormLoading = true @@ -55,7 +66,7 @@ public struct WriteFormFeature: Reducer, Sendable { case onAppear - case updateFieldContent(Int, String) + case updateContent(Int, FormContentData) case writeFormSent(WriteFormSend) @@ -67,6 +78,7 @@ public struct WriteFormFeature: Reducer, Sendable { case _loadForm(id: Int, isTopic: Bool) case _formResponse(Result<[WriteFormFieldType], any Error>) + case _templateResponse(Result) case _simplePostResponse(Result) case _reportResponse(Result) } @@ -96,13 +108,14 @@ public struct WriteFormFeature: Reducer, Sendable { } switch contentType { case .simple(let content, _): - state.textContent = content + state.content[0] = .text(content) return .send(._formResponse(.success([ .editor(.init( + id: 0, name: "", description: "", example: "", - flag: 0, + flag: 1, defaultValue: state.inPostEditingMode ? content : "" )) ]))) @@ -114,10 +127,11 @@ public struct WriteFormFeature: Reducer, Sendable { case .report: return .send(._formResponse(.success([ .editor(.init( + id: 0, name: "", description: "", example: "", - flag: 0, + flag: 1, defaultValue: "" )) ]))) @@ -126,9 +140,19 @@ public struct WriteFormFeature: Reducer, Sendable { case .publishButtonTapped: state.isPublishing = true switch state.formFor { + case .topic(let id, _), .post(type: .new, let id, content: .template(_)): + return .run { [isTopic = state.formFor.isTopic, content = state.textContent] send in + let result = await Result { try await apiClient.sendTemplate( + id: id, + content: content, + isTopic: isTopic + ) } + await send(._templateResponse(result)) + } + case .post(let type, let topicId, content: .simple(_, let attachments)): let flag = state.isShowMarkToggleSelected ? 4 : 0 - return .run { [topicId, attachments, reason = state.editReasonContent, content = state.textContent] send in + return .run { [reason = state.editReasonContent, content = state.textContent] send in switch type { case .new: let request = PostRequest( @@ -140,7 +164,7 @@ public struct WriteFormFeature: Reducer, Sendable { let result = await Result { try await apiClient.sendPost(request: request) } await send(._simplePostResponse(result)) - case .edit(postId: let postId): + case .edit(let postId): let request = PostEditRequest( postId: postId, reason: reason, @@ -157,7 +181,7 @@ public struct WriteFormFeature: Reducer, Sendable { } case .report(let id, let type): - return .run { [id = id, type = type, content = state.textContent] send in + return .run { [content = state.textContent] send in let request = ReportRequest(id: id, type: type, message: content) let result = await Result { try await apiClient.sendReport(request: request) } await send(._reportResponse(result)) @@ -171,37 +195,80 @@ public struct WriteFormFeature: Reducer, Sendable { return .none case .previewButtonTapped: - let topicId = if case .post(_, let topicId, _) = state.formFor { topicId } else { 0 } - let type = if case .post(let type, _, _) = state.formFor { type } else { WriteFormForType.PostType.new } - state.preview = FormPreviewFeature.State(formType: .post( - type: type, - topicId: topicId, - content: .simple(state.textContent, []) - )) + switch state.formFor { + case .topic(let forumId, _): + state.preview = FormPreviewFeature.State(formType: .topic( + forumId: forumId, + content: state.textContent + )) + + case .post(let type, let topicId, let content): + let data = if case .simple(_, let attachments) = content { + WriteFormForType.PostContentType.simple(state.textContent, attachments) + } else { + WriteFormForType.PostContentType.template(state.textContent) + } + state.preview = FormPreviewFeature.State(formType: .post( + type: type, + topicId: topicId, + content: data + )) + + case .report: + state.preview = FormPreviewFeature.State(formType: .post( + type: .new, + topicId: 0, + content: .simple(state.textContent, []) + )) + } return .none case .writeFormSent(let result): - if case let .report(status) = result { - // Not closing form if error. + // Not closing form if error. + switch result { + case .report(let status): if status.isError { return .none } + + case .template(let status): + if status.isError { + return .none + } + + // TODO: handle. + case .post: break } return .run { _ in await dismiss() } case .dismissButtonTapped: return .run { _ in await dismiss() } - case .updateFieldContent(_, let content): - state.textContent = content + case let .updateContent(fieldId, data): + switch data { + case .text(let content): + state.content[fieldId] = .text(content) + + case .dropdown(let id, let name): + state.content[fieldId] = .dropdown(id, name) + + case .uploadbox(let data): + // TODO: Implement + return .none + + case .checkbox(let data): + let new = if case .checkbox(let ndata) = state.content[fieldId] { + data.reduce(into: ndata) { result, entry in + result[entry.key] = entry.value + } + } else { data } + state.content[fieldId] = .checkbox(new) + } return .none case let ._loadForm(id, isTopic): - return .run { [id = id, isTopic = isTopic] send in - let result = await Result { try await apiClient.getTemplate( - request: ForumTemplateRequest(id: id, action: .get), - isTopic: isTopic - ) } + return .run { send in + let result = await Result { try await apiClient.getTemplate(id: id, isTopic: isTopic) } await send(._formResponse(result)) } catch: { error, send in await send(._formResponse(.failure(error))) @@ -211,6 +278,26 @@ public struct WriteFormFeature: Reducer, Sendable { state.formFields = form state.isFormLoading = false + + for (key, field) in form.enumerated() { + switch field { + case .title: + state.content[key] = .text("") + + case .text(let content), .editor(let content): + state.content[content.id] = .text(content.defaultValue) + + case .checkboxList(let content, _): + state.content[content.id] = .checkbox([0: false]) + + case .dropdown(let content, let options): + state.content[content.id] = .dropdown(0, options[0]) + + case .uploadbox(let content, _): + // TODO: Implement file upload. + state.content[content.id] = .uploadbox([]) + } + } return .none @@ -218,6 +305,14 @@ public struct WriteFormFeature: Reducer, Sendable { print(error) return .none + case let ._templateResponse(.success(result)): + return .send(.writeFormSent(.template(result))) + + case let ._templateResponse(.failure(error)): + state.isPublishing = false + print(error) + return .none + case let ._simplePostResponse(.success(post)): return .send(.writeFormSent(.post(PostSend( id: post.id, @@ -254,3 +349,63 @@ public struct WriteFormFeature: Reducer, Sendable { } } } + +// MARK: - Helpers + +private extension WriteFormFeature { + static func buildContent(fields: [Int: FormContentData]) -> String { + var request: [Any] = [] + for (_, field) in fields.sorted(by: { $0.0 < $1.0 }) { + switch field { + case .text(let content): + request.append(content) + + case .checkbox(let content): + request.append(content + .filter { $0.value == true } + .map { $0.key + 1 } ) + + case .dropdown(let id, _): + request.append(id + 1) + + case .uploadbox(_): + // TODO: Implement. + request.append([]) + } + } + return request.description + } + + static func isFieldsValid(fields: [WriteFormFieldType], content: [Int: FormContentData]) -> Bool { + for field in fields { + switch field { + case .title(_): continue + + case .text(let info), .editor(let info), .dropdown(let info, _), + .checkboxList(let info, _), .uploadbox(let info, _): + switch content[info.id] { + case .text(let data): + if info.isRequired && data.isEmpty { + return false + } + + case .uploadbox(let data): + if info.isRequired && data.isEmpty { + return false + } + + case .checkbox(let data): + if info.isRequired && data.isEmpty { + return false + } + + // always initialized with default value + case .dropdown: continue + + case .none: return false + } + } + } + return true + } +} diff --git a/Modules/Sources/WriteFormFeature/WriteFormScreen.swift b/Modules/Sources/WriteFormFeature/WriteFormScreen.swift index ef44b66c..d37291c2 100644 --- a/Modules/Sources/WriteFormFeature/WriteFormScreen.swift +++ b/Modules/Sources/WriteFormFeature/WriteFormScreen.swift @@ -65,7 +65,7 @@ public struct WriteFormScreen: View { .font(.body) .frame(width: 34, height: 22) } - .disabled(store.textContent.isEmptyAfterTrimming()) + .disabled(store.isSubmitDisabled) .disabled(store.isPublishing) } } @@ -83,11 +83,11 @@ public struct WriteFormScreen: View { VStack { WriteFormView( type: store.formFields[index], - onUpdateContent: { content in - if content != nil { - store.send(.updateFieldContent(index, content!)) - } - return store.textContent + onUpdateContent: { fieldId, data in + store.send(.updateContent(fieldId, data)) + }, + onFetchContent: { fieldId in + store.content[fieldId] ?? nil } ) } @@ -119,7 +119,7 @@ public struct WriteFormScreen: View { } .buttonStyle(.borderedProminent) .frame(height: 48) - .disabled(store.textContent.isEmptyAfterTrimming()) + .disabled(store.isSubmitDisabled) .disabled(store.isPublishing) Spacer() diff --git a/Modules/Sources/WriteFormFeature/WriteFormView.swift b/Modules/Sources/WriteFormFeature/WriteFormView.swift index dd845124..01c44b8a 100644 --- a/Modules/Sources/WriteFormFeature/WriteFormView.swift +++ b/Modules/Sources/WriteFormFeature/WriteFormView.swift @@ -15,17 +15,17 @@ struct WriteFormView: View { let type: WriteFormFieldType - let onUpdateContent: (String?) -> String // (String) -> Void?, - let onUpdateSelection: ((Int, String, Bool) -> Void)? + let onUpdateContent: (_ fieldId: Int, _ data: FormContentData) -> Void + let onFetchContent: (_ fieldId: Int) -> FormContentData? init( type: WriteFormFieldType, - onUpdateContent: @escaping (String?) -> String, - onUpdateSelection: ((Int, String, Bool) -> Void)? = nil + onUpdateContent: @escaping (Int, FormContentData) -> Void, + onFetchContent: @escaping (Int) -> FormContentData? ) { self.type = type self.onUpdateContent = onUpdateContent - self.onUpdateSelection = onUpdateSelection + self.onFetchContent = onFetchContent } var body: some View { @@ -34,8 +34,8 @@ struct WriteFormView: View { Section { Field( text: Binding( - get: { onUpdateContent(nil) }, - set: { _ = onUpdateContent($0) } + get: { getTextFieldContent(fieldId: content.id) }, + set: { onUpdateContent(content.id, .text($0)) } ), description: content.description, guideText: content.example @@ -67,8 +67,8 @@ struct WriteFormView: View { Section { Field( text: Binding( - get: { onUpdateContent(nil) }, - set: { _ = onUpdateContent($0) } + get: { getTextFieldContent(fieldId: content.id) }, + set: { onUpdateContent(content.id, .text($0)) } ), description: content.description, guideText: content.example, @@ -85,15 +85,14 @@ struct WriteFormView: View { VStack { HStack { Menu { - ForEach(options, id: \.self) { option in - // TODO: Implement Button + ForEach(options.indices, id: \.hashValue) { index in Button { - // callback - } label: { Text(option) } + onUpdateContent(content.id, .dropdown(index, options[index])) + } label: { Text(options[index]) } } } label: { HStack { - Text(options[0]) // FIXME: Fix. + Text(getDropdownSelectedName(fieldId: content.id)) .foregroundStyle(Color(.Labels.primary)) .padding(.leading, 16) @@ -125,12 +124,11 @@ struct WriteFormView: View { case .checkboxList(let content, let options): Section { VStack(spacing: 6) { - ForEach(options.indices, id: \.self) { index in + ForEach(options.indices, id: \.hashValue) { index in Toggle(isOn: Binding( - // FIXME: Now all checkboxes always false. Find the solution with getter. - get: { false }, + get: { isCheckBoxSelected(fieldId: content.id, checkboxId: index) }, set: { isSelected in - onUpdateSelection?(index, options[index], isSelected) + onUpdateContent(content.id, .checkbox([index: isSelected])) } )) { Text(options[index]) @@ -229,6 +227,29 @@ struct WriteFormView: View { } } +// MARK: - Helpers + +private extension WriteFormView { + + func getTextFieldContent(fieldId: Int) -> String { + return if case .text(let content) = onFetchContent(fieldId) { + content + } else { "" } + } + + func getDropdownSelectedName(fieldId: Int) -> String { + return if case .dropdown(_, let name) = onFetchContent(fieldId) { + name + } else { "" } + } + + func isCheckBoxSelected(fieldId: Int, checkboxId: Int) -> Bool { + return if case .checkbox(let data) = onFetchContent(fieldId) { + data[checkboxId] ?? false + } else { false } + } +} + // MARK: - CheckBox Toggle Style struct CheckBox: ToggleStyle { @@ -333,13 +354,20 @@ struct Field: View { #Preview("Write Form Text Preview") { VStack { - WriteFormView(type: .text(.init( - name: "Topic name", - description: "Set the topic name with some logic.", - example: "Example: How I can do not love ForPDA?", - flag: 1, - defaultValue: "" - )), onUpdateContent: { _ in "" }) + WriteFormView( + type: .text( + .init( + id: 0, + name: "Topic name", + description: "Set the topic name with some logic.", + example: "Example: How I can do not love ForPDA?", + flag: 1, + defaultValue: "" + ) + ), + onUpdateContent: { _, _ in }, + onFetchContent: { _ in .text("Some basic text") } + ) Color.white } @@ -350,9 +378,11 @@ struct Field: View { #Preview("Write Form Title Preview") { VStack { - WriteFormView(type: .title( - "[b]Absolute simple.[/b]" - ), onUpdateContent: { _ in "" }) + WriteFormView( + type: .title("[b]Absolute simple.[/b]"), + onUpdateContent: { _, _ in }, + onFetchContent: { _ in nil } + ) Color.white } @@ -363,13 +393,20 @@ struct Field: View { #Preview("Write Form Editor Preview") { VStack { - WriteFormView(type: .editor(.init( - name: "Topic name", - description: "Set the topic name with some logic.", - example: "Example: How I can do not love ForPDA?", - flag: 1, - defaultValue: "" - )), onUpdateContent: { _ in "" }) + WriteFormView( + type: .editor( + .init( + id: 0, + name: "Topic name", + description: "Set the topic name with some logic.", + example: "Example: How I can do not love ForPDA?", + flag: 1, + defaultValue: "" + ) + ), + onUpdateContent: { _, _ in }, + onFetchContent: { _ in .text("Some editor text") } + ) Color.white } @@ -380,13 +417,21 @@ struct Field: View { #Preview("Write Form Dropdown Preview") { VStack { - WriteFormView(type: .dropdown(.init( - name: "Device type", - description: "Select device type.", - example: "Example: Phone", - flag: 1, - defaultValue: "" - ), ["Phone", "SmartWatch"]), onUpdateContent: { _ in "" }) + WriteFormView( + type: .dropdown( + .init( + id: 0, + name: "Device type", + description: "Select device type.", + example: "Example: Phone", + flag: 1, + defaultValue: "" + ), + ["Phone", "SmartWatch"] + ), + onUpdateContent: { _, _ in }, + onFetchContent: { _ in .dropdown(0, "Phone") } + ) Color.white } @@ -397,13 +442,21 @@ struct Field: View { #Preview("Write Form CheckBox Preview") { VStack { - WriteFormView(type: .checkboxList(.init( - name: "", - description: "", - example: "", - flag: 1, - defaultValue: "" - ), ["I accept all"]), onUpdateContent: { _ in "" }) + WriteFormView( + type: .checkboxList( + .init( + id: 0, + name: "", + description: "", + example: "", + flag: 1, + defaultValue: "" + ), + ["I accept all"] + ), + onUpdateContent: { _, _ in }, + onFetchContent: { _ in .checkbox([0: true]) } + ) Color.white } @@ -414,13 +467,21 @@ struct Field: View { #Preview("Write Form UploadBox Preview") { VStack { - WriteFormView(type: .uploadbox(.init( - name: "Device photos", - description: "Upload device photos. Allowed formats JPG, GIF, PNG", - example: "", - flag: 1, - defaultValue: "" - ), ["jpg", "gif", "png"]), onUpdateContent: { _ in "" }) + WriteFormView( + type: .uploadbox( + .init( + id: 0, + name: "Device photos", + description: "Upload device photos. Allowed formats JPG, GIF, PNG", + example: "", + flag: 1, + defaultValue: "" + ), + ["jpg", "gif", "png"] + ), + onUpdateContent: { _, _ in }, + onFetchContent: { _ in .uploadbox([]) } + ) Color.white } From dac1e8c435b61120c26081ecbd0e218f86f23f8e Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 6 Jun 2025 21:57:07 +0300 Subject: [PATCH 004/118] Add toasts reflecting the result of creating a topic --- .../Sources/ForumFeature/ForumFeature.swift | 26 +++++++++++++++++ Modules/Sources/ToastClient/ToastClient.swift | 29 +++++++++++++++++-- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/Modules/Sources/ForumFeature/ForumFeature.swift b/Modules/Sources/ForumFeature/ForumFeature.swift index c10208f7..34221e9b 100644 --- a/Modules/Sources/ForumFeature/ForumFeature.swift +++ b/Modules/Sources/ForumFeature/ForumFeature.swift @@ -116,6 +116,26 @@ public struct ForumFeature: Reducer, Sendable { case let .pageNavigation(.offsetChanged(to: newOffset)): return .send(._loadForum(offset: newOffset)) + case let .writeForm(.presented(.writeFormSent(response))): + if case let .template(status) = response { + switch status { + case .success(.topic(let id)): + return .send(.delegate(.openTopic(id: id, name: "", goTo: .first))) + + case .error(let type): + return switch type { + case .badParam: showToast(.topicBadParameter) + case .status(let s): showToast(.topicStatus(s)) + case .fieldsError: showToast(.topicFieldsError) + case .sentToPremod: showToast(.topicSentToPremoderation) + } + + default: + return .none + } + } + return .none + case .pageNavigation: return .none @@ -253,6 +273,12 @@ public struct ForumFeature: Reducer, Sendable { Analytics() } + private func showToast(_ toast: ToastMessage) -> Effect { + return .run { _ in + await toastClient.showToast(toast) + } + } + // MARK: - Shared Logic private func reportFullyDisplayed(_ state: inout State) { diff --git a/Modules/Sources/ToastClient/ToastClient.swift b/Modules/Sources/ToastClient/ToastClient.swift index 8752aafd..14e8647f 100644 --- a/Modules/Sources/ToastClient/ToastClient.swift +++ b/Modules/Sources/ToastClient/ToastClient.swift @@ -53,6 +53,13 @@ public enum ToastMessage: Equatable, Sendable { case postNotFound case postDeleted + // Topics + case topicSent + case topicFieldsError + case topicBadParameter + case topicSentToPremoderation + case topicStatus(Int) + // Report case reportSent case reportTooShort @@ -69,6 +76,16 @@ public enum ToastMessage: Equatable, Sendable { return "Post not found" case .postDeleted: return "Post deleted" + case .topicSent: + return "Topic sent" + case .topicFieldsError: + return "There were errors in filling out the form" + case .topicBadParameter: + return "The server refused to create the topic (invalid parameter)" + case .topicSentToPremoderation: + return "Topic sent to pre-moderation" + case .topicStatus(let status): + return "Topic sending error. Status \(status)" case .reportSent: return "Report sent" case .reportTooShort: @@ -85,10 +102,14 @@ public enum ToastMessage: Equatable, Sendable { case .postNotFound, .reportTooShort, .reportSendError, + .topicStatus, + .topicBadParameter, + .topicFieldsError, + .topicSentToPremoderation, .whoopsSomethingWentWrong: return true - case .custom, .reportSent, .postDeleted: + case .custom, .reportSent, .postDeleted, .topicSent: return false } } @@ -101,10 +122,14 @@ public enum ToastMessage: Equatable, Sendable { case .postNotFound, .reportTooShort, .reportSendError, + .topicStatus, + .topicBadParameter, + .topicFieldsError, + .topicSentToPremoderation, .whoopsSomethingWentWrong: return .error - case .reportSent, .postDeleted: + case .reportSent, .postDeleted, .topicSent: return .success } } From 3cedca186febcef32780b5732d01c37d88f280a9 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 6 Jun 2025 22:17:58 +0300 Subject: [PATCH 005/118] Improve topic creation toasts --- .../Sources/ForumFeature/ForumFeature.swift | 5 +- .../Resources/Localizable.xcstrings | 50 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/Modules/Sources/ForumFeature/ForumFeature.swift b/Modules/Sources/ForumFeature/ForumFeature.swift index 34221e9b..780639b6 100644 --- a/Modules/Sources/ForumFeature/ForumFeature.swift +++ b/Modules/Sources/ForumFeature/ForumFeature.swift @@ -120,7 +120,10 @@ public struct ForumFeature: Reducer, Sendable { if case let .template(status) = response { switch status { case .success(.topic(let id)): - return .send(.delegate(.openTopic(id: id, name: "", goTo: .first))) + return .merge([ + showToast(.topicSent), + .send(.delegate(.openTopic(id: id, name: "", goTo: .first))) + ]) case .error(let type): return switch type { diff --git a/Modules/Sources/ToastClient/Resources/Localizable.xcstrings b/Modules/Sources/ToastClient/Resources/Localizable.xcstrings index b62a3508..5bf91b6e 100644 --- a/Modules/Sources/ToastClient/Resources/Localizable.xcstrings +++ b/Modules/Sources/ToastClient/Resources/Localizable.xcstrings @@ -51,6 +51,56 @@ } } }, + "The server refused to create the topic (invalid parameter)" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сервер отказал в создании темы (неверный параметр)" + } + } + } + }, + "There were errors in filling out the form" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "При заполнении формы допущены ошибки" + } + } + } + }, + "Topic sending error. Status %lld" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка создания темы. Статус %lld" + } + } + } + }, + "Topic sent" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тема создана" + } + } + } + }, + "Topic sent to pre-moderation" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тема отправлена на премодерацию" + } + } + } + }, "Whoops, something went wrong.." : { "localizations" : { "ru" : { From 28300f4b08829b27324bd418479b4ab006cf3256 Mon Sep 17 00:00:00 2001 From: Ilia Lubianoi Date: Sun, 15 Jun 2025 18:32:07 +0300 Subject: [PATCH 006/118] Post-merge fix --- .../Parsers/WriteFormParser.swift | 12 +++- .../WriteFormFeature/WriteFormFeature.swift | 64 ++++--------------- 2 files changed, 20 insertions(+), 56 deletions(-) diff --git a/Modules/Sources/ParsingClient/Parsers/WriteFormParser.swift b/Modules/Sources/ParsingClient/Parsers/WriteFormParser.swift index d88dcc28..c0f0d4cf 100644 --- a/Modules/Sources/ParsingClient/Parsers/WriteFormParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/WriteFormParser.swift @@ -60,10 +60,16 @@ public struct WriteFormParser { switch status { case 0: // if elements > 3 - response for post. - return if array.count > 3 { - .success(.post(try TopicParser.parsePostSend(from: string))) + if array.count > 3 { + let response = try TopicParser.parsePostSendResponse(from: string) + switch response { + case let .success(post): + return .success(.post(post)) + case let .failure(error): + throw .unknownStatus(error.rawValue) + } } else { - .success(.topic(id: array[safe: 2] as! Int)) + return .success(.topic(id: array[safe: 2] as! Int)) } case 5: diff --git a/Modules/Sources/WriteFormFeature/WriteFormFeature.swift b/Modules/Sources/WriteFormFeature/WriteFormFeature.swift index c11988d4..12104978 100644 --- a/Modules/Sources/WriteFormFeature/WriteFormFeature.swift +++ b/Modules/Sources/WriteFormFeature/WriteFormFeature.swift @@ -165,36 +165,6 @@ public struct WriteFormFeature: Reducer, Sendable { case .publishButtonTapped: state.isPublishing = true return .send(._publishPost(flag: .default)) - - case .previewButtonTapped: - let topicId = if case .post(_, let topicId, _) = state.formFor { topicId } else { 0 } - let type = if case .post(let type, _, _) = state.formFor { type } else { WriteFormForType.PostType.new } - state.destination = .preview( - FormPreviewFeature.State( - formType: .post( - type: type, - topicId: topicId, - content: .simple(state.textContent, []) - ) - ) - ) - return .none - - case .writeFormSent(let result): - if case let .report(status) = result { - // Not closing form if error. - if status.isError { - return .none - } - } - return .run { _ in await dismiss() } - - case .dismissButtonTapped: - return .run { _ in await dismiss() } - - case .updateFieldContent(_, let content): - state.textContent = content - return .none case let ._publishPost(flag: postTypeFlag): switch state.formFor { @@ -251,16 +221,10 @@ public struct WriteFormFeature: Reducer, Sendable { return .none } - case .preview: - return .none - case .previewButtonTapped: switch state.formFor { case .topic(let forumId, _): - state.preview = FormPreviewFeature.State(formType: .topic( - forumId: forumId, - content: state.textContent - )) + state.destination = .preview(FormPreviewFeature.State(formType: .topic(forumId: forumId, content: state.textContent))) case .post(let type, let topicId, let content): let data = if case .simple(_, let attachments) = content { @@ -268,18 +232,10 @@ public struct WriteFormFeature: Reducer, Sendable { } else { WriteFormForType.PostContentType.template(state.textContent) } - state.preview = FormPreviewFeature.State(formType: .post( - type: type, - topicId: topicId, - content: data - )) + state.destination = .preview(FormPreviewFeature.State(formType: .post(type: type, topicId: topicId, content: data))) case .report: - state.preview = FormPreviewFeature.State(formType: .post( - type: .new, - topicId: 0, - content: .simple(state.textContent, []) - )) + state.destination = .preview(FormPreviewFeature.State(formType: .post(type: .new, topicId: 0, content: .simple(state.textContent, [])))) } return .none @@ -408,12 +364,14 @@ public struct WriteFormFeature: Reducer, Sendable { print(error) return .none - case let ._simplePostResponse(.success(post)): - return .send(.writeFormSent(.post(PostSend( - id: post.id, - topicId: post.topicId, - offset: post.offset - )))) + case let ._simplePostResponse(.success(response)): + switch response { + case let .success(post): + return .send(.writeFormSent(.post(.success(PostSend(id: post.id, topicId: post.topicId, offset: post.offset))))) + case let .failure(error): + // TODO: ??? + print(error) + } case let ._simplePostResponse(.failure(error)): state.isPublishing = false From 9095c13244141eb245e63be998c30a8b47a20064 Mon Sep 17 00:00:00 2001 From: Ilia Lubianoi Date: Sun, 15 Jun 2025 18:50:03 +0300 Subject: [PATCH 007/118] Post-merge fix --- .../WriteFormFeature/WriteFormFeature.swift | 159 +++++++----------- 1 file changed, 64 insertions(+), 95 deletions(-) diff --git a/Modules/Sources/WriteFormFeature/WriteFormFeature.swift b/Modules/Sources/WriteFormFeature/WriteFormFeature.swift index fc5ae5b7..4fabe9f0 100644 --- a/Modules/Sources/WriteFormFeature/WriteFormFeature.swift +++ b/Modules/Sources/WriteFormFeature/WriteFormFeature.swift @@ -139,48 +139,39 @@ public struct WriteFormFeature: Reducer, Sendable { } switch contentType { case .simple(let content, _): -#warning("Отделить send") state.content[0] = .text(content) - return .send(._formResponse(.success([ - .editor(.init( - id: 0, - name: "", - description: "", - example: "", - flag: 1, - defaultValue: state.inPostEditingMode ? content : "" - )) - ]))) - - case .template: - return .send(._loadForm(id: topicId, isTopic: false)) - } - - case .report: - return .send(._formResponse(.success([ - .editor(.init( - id: 0, - name: "", - description: "", - example: "", - flag: 1, - defaultValue: "" - )) - return .send(.internal(.formResponse(.success([field])))) + let response: [WriteFormFieldType] = [ + .editor( + .init( + id: 0, + name: "", + description: "", + example: "", + flag: 1, + defaultValue: state.inPostEditingMode ? content : "" + ) + ) + ] + return .send(.internal(.formResponse(.success(response)))) case .template: return .send(.internal(.loadForm(id: topicId, isTopic: false))) } case .report: - let field = WriteFormFieldType.editor(.init( - name: "", - description: "", - example: "", - flag: 0, - defaultValue: "" - )) - return .send(.internal(.formResponse(.success([field])))) + let response: [WriteFormFieldType] = [ + .editor( + .init( + id: 0, + name: "", + description: "", + example: "", + flag: 1, + defaultValue: "" + ) + ) + ] + return .send(.internal(.formResponse(.success(response)))) } case .view(.publishButtonTapped): @@ -188,18 +179,25 @@ public struct WriteFormFeature: Reducer, Sendable { return .send(.internal(.publishPost(flag: .default))) case .view(.previewButtonTapped): - let topicId = if case .post(_, let topicId, _) = state.formFor { topicId } else { 0 } - let type = if case .post(let type, _, _) = state.formFor { type } else { WriteFormForType.PostType.new } - state.destination = .preview( - FormPreviewFeature.State( - formType: .post( - type: type, - topicId: topicId, - content: .simple(state.textContent, []) - ) - ) - ) + switch state.formFor { + case .topic(let forumId, _): + state.destination = .preview(FormPreviewFeature.State(formType: .topic(forumId: forumId, content: state.textContent))) + + case .post(let type, let topicId, let content): + let data = if case .simple(_, let attachments) = content { + WriteFormForType.PostContentType.simple(state.textContent, attachments) + } else { + WriteFormForType.PostContentType.template(state.textContent) + } + state.destination = .preview(FormPreviewFeature.State(formType: .post(type: type, topicId: topicId, content: data))) + + case .report: + state.destination = .preview(FormPreviewFeature.State(formType: .post(type: .new, topicId: 0, content: .simple(state.textContent, [])))) + } return .none + + case .view(.dismissButtonTapped): + return .run { _ in await dismiss() } case let .internal(.publishPost(flag: postTypeFlag)): switch state.formFor { @@ -210,7 +208,7 @@ public struct WriteFormFeature: Reducer, Sendable { content: content, isTopic: isTopic ) } - await send(._templateResponse(result)) + await send(.internal(.templateResponse(result))) } case .post(let type, let topicId, content: .simple(_, let attachments)): @@ -256,46 +254,7 @@ public struct WriteFormFeature: Reducer, Sendable { return .none } - case .previewButtonTapped: - switch state.formFor { - case .topic(let forumId, _): - state.destination = .preview(FormPreviewFeature.State(formType: .topic(forumId: forumId, content: state.textContent))) - - case .post(let type, let topicId, let content): - let data = if case .simple(_, let attachments) = content { - WriteFormForType.PostContentType.simple(state.textContent, attachments) - } else { - WriteFormForType.PostContentType.template(state.textContent) - } - state.destination = .preview(FormPreviewFeature.State(formType: .post(type: type, topicId: topicId, content: data))) - - case .report: - state.destination = .preview(FormPreviewFeature.State(formType: .post(type: .new, topicId: 0, content: .simple(state.textContent, [])))) - } - return .none - - case .writeFormSent(let result): - // Not closing form if error. - switch result { - case .report(let status): - if status.isError { - return .none - } - - case .template(let status): - if status.isError { - return .none - } - - // TODO: handle. - case .post: break - } - return .run { _ in await dismiss() } - - case .dismissButtonTapped: - return .run { _ in await dismiss() } - - case let .updateContent(fieldId, data): + case let .view(.updateContent(fieldId, data)): switch data { case .text(let content): state.content[fieldId] = .text(content) @@ -317,7 +276,7 @@ public struct WriteFormFeature: Reducer, Sendable { } return .none - case let .internal(loadForm(id, isTopic)): + case let .internal(.loadForm(id, isTopic)): return .run { send in let result = await Result { try await apiClient.getTemplate(id: id, isTopic: isTopic) } await send(.internal(.formResponse(result))) @@ -391,18 +350,18 @@ public struct WriteFormFeature: Reducer, Sendable { state.isPublishing = false return .none - case let ._templateResponse(.success(result)): - return .send(.writeFormSent(.template(result))) + case let .internal(.templateResponse(.success(result))): + return .send(.delegate(.writeFormSent(.template(result)))) - case let ._templateResponse(.failure(error)): + case let .internal(.templateResponse(.failure(error))): state.isPublishing = false print(error) return .none - case let ._simplePostResponse(.success(response)): + case let .internal(.simplePostResponse(.success(response))): switch response { case let .success(post): - return .send(.writeFormSent(.post(.success(PostSend(id: post.id, topicId: post.topicId, offset: post.offset))))) + return .send(.delegate(.writeFormSent(.post(.success(PostSend(id: post.id, topicId: post.topicId, offset: post.offset)))))) case let .failure(error): // TODO: ??? print(error) @@ -422,11 +381,21 @@ public struct WriteFormFeature: Reducer, Sendable { return .none case .delegate(.writeFormSent(let result)): - if case let .report(status) = result { - // Not closing form if error. + // Not closing form if error. + switch result { + case .report(let status): + if status.isError { + return .none + } + + case .template(let status): if status.isError { return .none } + + // TODO: handle. + case .post: + break } return .run { _ in await dismiss() } From 1568140baca3a3d68c59d3c0ee4fa6f60a24b7d7 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 15 Jun 2025 19:20:51 +0300 Subject: [PATCH 008/118] Fix form empty content on post reply --- Modules/Sources/WriteFormFeature/WriteFormFeature.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/WriteFormFeature/WriteFormFeature.swift b/Modules/Sources/WriteFormFeature/WriteFormFeature.swift index 4fabe9f0..b2f2112f 100644 --- a/Modules/Sources/WriteFormFeature/WriteFormFeature.swift +++ b/Modules/Sources/WriteFormFeature/WriteFormFeature.swift @@ -148,7 +148,7 @@ public struct WriteFormFeature: Reducer, Sendable { description: "", example: "", flag: 1, - defaultValue: state.inPostEditingMode ? content : "" + defaultValue: content ) ) ] From e901cdb45a5aaf4a4a9f40f6d7ee74753dfca37c Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 15 Jun 2025 19:25:27 +0300 Subject: [PATCH 009/118] Post-merge fix --- Modules/Sources/ForumFeature/ForumFeature.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/ForumFeature/ForumFeature.swift b/Modules/Sources/ForumFeature/ForumFeature.swift index 780639b6..40014ba3 100644 --- a/Modules/Sources/ForumFeature/ForumFeature.swift +++ b/Modules/Sources/ForumFeature/ForumFeature.swift @@ -116,7 +116,7 @@ public struct ForumFeature: Reducer, Sendable { case let .pageNavigation(.offsetChanged(to: newOffset)): return .send(._loadForum(offset: newOffset)) - case let .writeForm(.presented(.writeFormSent(response))): + case let .writeForm(.presented(.delegate(.writeFormSent(response)))): if case let .template(status) = response { switch status { case .success(.topic(let id)): From 6dea6a9ca74e2354d210ab83fcd5bd2e25d036f5 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 15 Jun 2025 21:00:32 +0300 Subject: [PATCH 010/118] Post-merge fix --- Project.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Project.swift b/Project.swift index 8d65a2a6..208c2874 100644 --- a/Project.swift +++ b/Project.swift @@ -211,6 +211,7 @@ let project = Project( .Internal.SharedUI, .Internal.TCAExtensions, .Internal.ToastClient, + .Internal.WriteFormFeature, .SPM.NukeUI, .SPM.TCA ] From ca97d42f5225ebdd14470ec2b178ed5755b178a0 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 19 Jul 2025 12:43:51 +0300 Subject: [PATCH 011/118] Bump API version --- Tuist/Package.resolved | 6 +++--- Tuist/Package.swift | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index c2883e61..58650f69 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "be5cec13550a4400d8be4256816527a8dc477e3d04696c8e4b3d266956a698d2", + "originHash" : "a76e6a42619a2395784b873acf01971e7b08edd75fa45804d9aa445cece55b89", "pins" : [ { "identity" : "activityindicatorview", @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/SubvertDev/PDAPI_SPM.git", "state" : { - "revision" : "05ca72477225c19444427577c690b7abc28aa85d", - "version" : "0.4.4" + "revision" : "7270f306c36f7ea49af8e5e0ea879d840c224cc8", + "version" : "0.5.0" } }, { diff --git a/Tuist/Package.swift b/Tuist/Package.swift index 462a2606..950a9e5c 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -54,7 +54,7 @@ let package = Package( .package(url: "https://github.com/SubvertDev/AlertToast.git", revision: "d0f7d6b"), .package(url: "https://github.com/kirualex/SwiftyGif.git", from: "5.4.4"), .package(url: "https://github.com/ZhgChgLi/ZMarkupParser.git", from: "1.12.0"), - .package(url: "https://github.com/SubvertDev/PDAPI_SPM.git", exact: "0.4.4"), + .package(url: "https://github.com/SubvertDev/PDAPI_SPM.git", exact: "0.5.0"), .package(url: "https://github.com/SubvertDev/RichTextKit.git", branch: "main"), .package(url: "https://github.com/exyte/Chat.git", exact: "2.4.2"), // 2.5.0+ is iOS 17+ .package(url: "https://github.com/gohanlon/swift-memberwise-init-macro.git", from: "0.5.2") From 80e90cf6625f0b3926754a8073a56cffeb239c57 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 19 Jul 2025 18:34:10 +0300 Subject: [PATCH 012/118] Add upload endpoint --- Modules/Sources/APIClient/APIClient.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index 0473ab90..4b231886 100644 --- a/Modules/Sources/APIClient/APIClient.swift +++ b/Modules/Sources/APIClient/APIClient.swift @@ -20,6 +20,7 @@ public struct APIClient: Sendable { // Common public var setLogResponses: @Sendable (_ type: ResponsesLogType) async -> Void public var connect: @Sendable () async throws -> Void + public var upload: @Sendable (_ filename: String, _ filehash: String, _ data: Data, _ isQms: Bool) async throws -> AsyncStream // Articles public var getArticlesList: @Sendable (_ offset: Int, _ amount: Int) async throws -> [ArticlePreview] @@ -102,6 +103,15 @@ extension APIClient: DependencyKey { try await api.connect(as: .anonymous) } }, + upload: { filename, filehash, data, isQms in + return await api.upload(data: UploadRequest( + fileName: filename, + fileSize: data.count, + fileData: data, + md5: filehash, + isQms: isQms + )) + }, // MARK: - Articles @@ -408,6 +418,9 @@ extension APIClient: DependencyKey { APIClient( setLogResponses: { _ in }, connect: { }, + upload: { _, _, _, _ in + return .finished + }, getArticlesList: { _, _ in return Array(repeating: .mock, count: 30) }, From 4d97e552bd2b3df2bb439518cb6d70166947ff04 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 19 Jul 2025 18:35:19 +0300 Subject: [PATCH 013/118] WIP --- .../Models/FormUploadEvent.swift | 14 + .../Resources/Localizable.xcstrings | 33 ++ .../UploadBox/UploadBoxFeature.swift | 55 ++++ .../UploadBox/UploadBoxView.swift | 298 ++++++++++++++++++ .../WriteFormFeature/WriteFormFeature.swift | 18 +- .../WriteFormFeature/WriteFormView.swift | 60 ++-- 6 files changed, 429 insertions(+), 49 deletions(-) create mode 100644 Modules/Sources/WriteFormFeature/Models/FormUploadEvent.swift create mode 100644 Modules/Sources/WriteFormFeature/UploadBox/UploadBoxFeature.swift create mode 100644 Modules/Sources/WriteFormFeature/UploadBox/UploadBoxView.swift diff --git a/Modules/Sources/WriteFormFeature/Models/FormUploadEvent.swift b/Modules/Sources/WriteFormFeature/Models/FormUploadEvent.swift new file mode 100644 index 00000000..1e324af2 --- /dev/null +++ b/Modules/Sources/WriteFormFeature/Models/FormUploadEvent.swift @@ -0,0 +1,14 @@ +// +// FormUploadEvent.swift +// ForPDA +// +// Created by Xialtal on 19.07.25. +// + +public enum FormUploadEvent { + case uploaded + case removed + case uploading + case uploadError + case selectError +} diff --git a/Modules/Sources/WriteFormFeature/Resources/Localizable.xcstrings b/Modules/Sources/WriteFormFeature/Resources/Localizable.xcstrings index c9646642..6e066d6d 100644 --- a/Modules/Sources/WriteFormFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/WriteFormFeature/Resources/Localizable.xcstrings @@ -1,6 +1,19 @@ { "sourceLanguage" : "en", "strings" : { + "" : { + + }, + "Add more" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить еще" + } + } + } + }, "Attach this post to previous one?" : { "localizations" : { "ru" : { @@ -219,6 +232,26 @@ } } }, + "Select from Files" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбрать из файлов" + } + } + } + }, + "Select from Gallery" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбрать из галлереи" + } + } + } + }, "Send report" : { "localizations" : { "en" : { diff --git a/Modules/Sources/WriteFormFeature/UploadBox/UploadBoxFeature.swift b/Modules/Sources/WriteFormFeature/UploadBox/UploadBoxFeature.swift new file mode 100644 index 00000000..f0884ec4 --- /dev/null +++ b/Modules/Sources/WriteFormFeature/UploadBox/UploadBoxFeature.swift @@ -0,0 +1,55 @@ +// +// UploadBoxFeature.swift +// ForPDA +// +// Created by Xialtal on 19.07.25. +// + +import Foundation +import ComposableArchitecture +import APIClient +import Models + +@Reducer +public struct UploadBoxFeature: Reducer, Sendable { + public init() {} + + // MARK: - State + + @ObservableState + public struct State: Equatable, Sendable { + +// public init( +// formType: WriteFormForType +// ) { +// self.formType = formType +// } + } + + // MARK: - Action + + public enum Action { + case onAppear + + case cancelButtonTapped + } + + // MARK: - Dependencies + + @Dependency(\.apiClient) private var apiClient + @Dependency(\.dismiss) var dismiss + + // MARK: - Body + + public var body: some Reducer { + Reduce { state, action in + switch action { + case .onAppear: + return .none + + case .cancelButtonTapped: + return .none + } + } + } +} diff --git a/Modules/Sources/WriteFormFeature/UploadBox/UploadBoxView.swift b/Modules/Sources/WriteFormFeature/UploadBox/UploadBoxView.swift new file mode 100644 index 00000000..f0898972 --- /dev/null +++ b/Modules/Sources/WriteFormFeature/UploadBox/UploadBoxView.swift @@ -0,0 +1,298 @@ +// +// UploadBoxView.swift +// ForPDA +// +// Created by Xialtal on 19.07.25. +// + +import SwiftUI +import ComposableArchitecture +import SharedUI +import Models +import PhotosUI + +public struct UploadBoxView: View { + private let content: WriteFormFieldType.FormField + private let allowedFileExtensions: [String] + + @State private var pickerItem: PhotosPickerItem? + + @State private var showFilePicker = false + @State private var showImagePicker = false + @State private var uploadOptionsShowing = false + + @State private var uploadboxFiles: [File] = [] + + private let onUploadFile: (_ id: Int, _ event: FormUploadEvent) -> Void + + public init( + _ content: WriteFormFieldType.FormField, + _ extensions: [String], + onUploadFile: @escaping (Int, FormUploadEvent) -> Void + ) { + self.content = content + self.allowedFileExtensions = extensions + self.onUploadFile = onUploadFile + } + + public var body: some View { + VStack(spacing: 6) { + HStack { + Header(title: content.name, required: content.isRequired) + + if !uploadboxFiles.isEmpty { + Button { + uploadOptionsShowing = true + } label: { + Label("Add more", systemSymbol: .plus) + .font(.footnote) + } + } + } + + if !uploadboxFiles.isEmpty { + ScrollView(.horizontal) { + HStack(spacing: 12) { + ForEach(uploadboxFiles) { file in + // TODO: Fix this... + file + //.frame(maxWidth: uploadboxFiles.count == 1 ? .infinity : 170) + } + } + } + } else { + Button { + uploadOptionsShowing = true + } label: { + VStack { + Image(systemSymbol: .docBadgePlus) + .font(.title) + .foregroundStyle(Color(.tintColor)) + .frame(width: 48, height: 48) + + Text("Select files...", bundle: .module) + .font(.body) + .foregroundColor(Color(.Labels.quaternary)) + } + .padding(.vertical, 15) + .padding(.horizontal, 12) + .frame(maxWidth: .infinity, minHeight: 144) + .background { + RoundedRectangle(cornerRadius: 14) + .fill(Color(.Background.teritary)) + } + .overlay { + RoundedRectangle(cornerRadius: 14) + .stroke( + Color(.tintColor), + style: StrokeStyle(lineWidth: 1, dash: [8]) + ) + } + } + } + + if !content.description.isEmpty { + DescriptionText(text: content.description) + } + } + .confirmationDialog("", isPresented: $uploadOptionsShowing, titleVisibility: .hidden) { + Button("Select from Gallery") { + showImagePicker = true + } + + Button("Select from Files") { + showFilePicker = true + } + } + .photosPicker(isPresented: $showImagePicker, selection: $pickerItem) + .fileImporter(isPresented: $showFilePicker, allowedContentTypes: [.item]) { result in + switch result { + case .success(let url): + let fileId = url.hashValue + .random(in: 1..<100) + uploadboxFiles.append(File( + id: fileId, + name: url.lastPathComponent, + type: .file, + isUploading: true, + onCancelButtonTapped: { + onUploadFile(fileId, .removed) + + //TODO: uploadboxFiles.remove(at: ...) + print("IMPLEMENT CANCELLATION!") + } + )) + onUploadFile(fileId, .uploading) + + case .failure(let error): + onUploadFile(0, .selectError) + } + } + .task(id: pickerItem) { + guard let image = try? await pickerItem?.loadTransferable(type: Image.self) else { + onUploadFile(0, .selectError) + return + } + let fileId = Int.random(in: 1..<100) + uploadboxFiles.append(File( + id: fileId, + name: "img-\(fileId)", + type: .image(image), + isUploading: false, + onCancelButtonTapped: { + onUploadFile(fileId, .removed) + + //TODO: uploadboxFiles.remove(at: ...) + print("IMPLEMENT CANCELLATION!") + } + )) + onUploadFile(fileId, .uploading) + // Drop "remembered" image. + pickerItem = nil + } + } + + // TODO: MAKE COMMON + + @ViewBuilder + private func DescriptionText(text: String) -> some View { + Text(text) + .font(.caption) + .foregroundStyle(Color(.Labels.teritary)) + .textCase(nil) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 16) + } + + // MARK: - Header + + @ViewBuilder + private func Header(title: String, required: Bool) -> some View { + HStack { + Text(title) + .font(.footnote) + .fontWeight(.semibold) + .foregroundStyle(Color(.Labels.teritary)) + .textCase(nil) + .overlay(alignment: .bottomTrailing) { + if required { + Text(verbatim: "*") + .font(.headline) + .offset(x: 8) + .foregroundStyle(.red) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +// MARK: - File View + +enum FileType: Equatable { + case file + case image(Image) +} + +struct File: View, Identifiable { + let id: Int + let name: String + let type: FileType + let onCancelButtonTapped: () -> Void + + @State var isUploading: Bool + + init( + id: Int, + name: String, + type: FileType, + isUploading: Bool, + onCancelButtonTapped: @escaping () -> Void + ) { + self.id = id + self.name = name + self.type = type + self.isUploading = isUploading + self.onCancelButtonTapped = onCancelButtonTapped + } + + var body: some View { + VStack { + if !isUploading { + if type == .file { + Image(systemSymbol: .doc) + .font(.title) + .foregroundStyle(Color(.tintColor)) + .frame(width: 48, height: 48) + + Text(name) + .lineLimit(2) + .font(.footnote) + .multilineTextAlignment(.center) + .foregroundColor(Color(.Labels.primary)) + } + } else { + ProgressView() + } + } + .padding(.vertical, 15) + .padding(.horizontal, 12) + .frame(minWidth: 170, maxWidth: .infinity, minHeight: 144) + .background { + RoundedRectangle(cornerRadius: 14) + .fill(Color(.Background.teritary)) + .overlay { + if !isUploading, case .image(let image) = type { + image.resizable() + .aspectRatio(contentMode: .fill) + .frame(minWidth: 170, maxWidth: .infinity, maxHeight: 144) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + } + } + .overlay(alignment: .topTrailing) { + Button { + onCancelButtonTapped() + } label: { + Image(systemSymbol: .xmark) + .font(.body) + .foregroundStyle(type == .file ? Color(.Labels.teritary) : Color(.Labels.primaryInvariably)) + .frame(width: 30, height: 30) + .background( + Circle() + .fill(Color(.Background.quaternary)) + .clipShape(Circle()) + ) + } + .padding(10) + } + } +} + +// MARK: - File View Preview + +#Preview("File View") { + VStack { + ScrollView(.horizontal) { + HStack(spacing: 12) { + File( + id: 0, + name: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, se", + type: .file, + isUploading: false, + onCancelButtonTapped: {} + ) + + File( + id: 1, + name: "IMG", + type: .image(Image(.Settings.lightThemeExample)), + isUploading: false, + onCancelButtonTapped: {} + ) + } + } + + Color.white + } + .padding(.horizontal, 16) +} diff --git a/Modules/Sources/WriteFormFeature/WriteFormFeature.swift b/Modules/Sources/WriteFormFeature/WriteFormFeature.swift index b2f2112f..0a902095 100644 --- a/Modules/Sources/WriteFormFeature/WriteFormFeature.swift +++ b/Modules/Sources/WriteFormFeature/WriteFormFeature.swift @@ -203,11 +203,7 @@ public struct WriteFormFeature: Reducer, Sendable { switch state.formFor { case .topic(let id, _), .post(type: .new, let id, content: .template(_)): return .run { [isTopic = state.formFor.isTopic, content = state.textContent] send in - let result = await Result { try await apiClient.sendTemplate( - id: id, - content: content, - isTopic: isTopic - ) } + let result = await Result { try await apiClient.sendTemplate(id, content, isTopic) } await send(.internal(.templateResponse(result))) } @@ -224,7 +220,7 @@ public struct WriteFormFeature: Reducer, Sendable { flag: newPostFlag, attachments: attachments ) - let result = await Result { try await apiClient.sendPost(request: request) } + let result = await Result { try await apiClient.sendPost(request) } await send(.internal(.simplePostResponse(result))) case .edit(let postId): @@ -238,7 +234,7 @@ public struct WriteFormFeature: Reducer, Sendable { attachments: attachments ) ) - let result = await Result { try await apiClient.editPost(request: request) } + let result = await Result { try await apiClient.editPost(request) } await send(.internal(.simplePostResponse(result))) } } @@ -246,7 +242,7 @@ public struct WriteFormFeature: Reducer, Sendable { case .report(let id, let type): return .run { [content = state.textContent] send in let request = ReportRequest(id: id, type: type, message: content) - let result = await Result { try await apiClient.sendReport(request: request) } + let result = await Result { try await apiClient.sendReport(request) } await send(.internal(.reportResponse(result))) } @@ -278,7 +274,7 @@ public struct WriteFormFeature: Reducer, Sendable { case let .internal(.loadForm(id, isTopic)): return .run { send in - let result = await Result { try await apiClient.getTemplate(id: id, isTopic: isTopic) } + let result = await Result { try await apiClient.getTemplate(id, isTopic) } await send(.internal(.formResponse(result))) } catch: { error, send in await send(.internal(.formResponse(.failure(error)))) @@ -337,9 +333,9 @@ public struct WriteFormFeature: Reducer, Sendable { let editorFlag: Int switch action { case .attach: - editorFlag = 1 + editorFlag = PostSendFlag.attach.rawValue case .doNotAttach: - editorFlag = 3 + editorFlag = PostSendFlag.doNotAttach.rawValue case .dismiss: return .run { _ in await dismiss() } } diff --git a/Modules/Sources/WriteFormFeature/WriteFormView.swift b/Modules/Sources/WriteFormFeature/WriteFormView.swift index 3ac7bd49..001f9c7a 100644 --- a/Modules/Sources/WriteFormFeature/WriteFormView.swift +++ b/Modules/Sources/WriteFormFeature/WriteFormView.swift @@ -21,6 +21,22 @@ struct WriteFormView: View { var body: some View { switch type { + case .uploadbox(let content, let extensions): + UploadBoxView(content, extensions, onUploadFile: { id, event in + switch event { + case .uploaded: + print("uploaded") + case .uploading: + print("UPLOADING") + case .uploadError: + print("uploadError") + case .selectError: + print("selectError") + case .removed: + print("removed") + } + }) + case .text(let content): Section { Field( @@ -146,44 +162,6 @@ struct WriteFormView: View { } header: { Header(title: content.name, required: content.isRequired) } - - case .uploadbox(let content, _ /* allowed extensions */): - VStack(spacing: 6) { - Header(title: content.name, required: content.isRequired) - - Button { - // TODO: Implement - } label: { - VStack { - Image(systemSymbol: .docBadgePlus) - .font(.title) - .foregroundStyle(Color(.tintColor)) - .frame(width: 48, height: 48) - - Text("Select files...", bundle: .module) - .font(.body) - .foregroundColor(Color(.Labels.quaternary)) - } - .padding(.vertical, 15) - .padding(.horizontal, 12) - .frame(maxWidth: .infinity, minHeight: 144) - .background { - RoundedRectangle(cornerRadius: 14) - .fill(Color(.Background.teritary)) - } - .overlay { - RoundedRectangle(cornerRadius: 14) - .stroke( - Color(.tintColor), - style: StrokeStyle(lineWidth: 1, dash: [8]) - ) - } - } - - if !content.description.isEmpty { - DescriptionText(text: content.description) - } - } } } @@ -236,6 +214,12 @@ private extension WriteFormView { } else { "" } } + func getUploadBoxFiles(fieldId: Int) -> [FormContentData.File] { + return if case .uploadbox(let files) = onFetchContent(fieldId) { + files + } else { [] } + } + func isCheckBoxSelected(fieldId: Int, checkboxId: Int) -> Bool { return if case .checkbox(let data) = onFetchContent(fieldId) { data[checkboxId] ?? false From 8e6b8e2afbe61b28eae2bba00d167d31a04bc348 Mon Sep 17 00:00:00 2001 From: Ilia Lubianoi Date: Wed, 13 Aug 2025 00:51:09 +0300 Subject: [PATCH 014/118] Fix big tokens parsing in forms --- .../Sources/BBBuilder/Tokenizator/String/BBTokenizer.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Modules/Sources/BBBuilder/Tokenizator/String/BBTokenizer.swift b/Modules/Sources/BBBuilder/Tokenizator/String/BBTokenizer.swift index ddabceba..f4b807d9 100644 --- a/Modules/Sources/BBBuilder/Tokenizator/String/BBTokenizer.swift +++ b/Modules/Sources/BBBuilder/Tokenizator/String/BBTokenizer.swift @@ -55,7 +55,7 @@ public struct BBTokenizer { if currentIndex < input.endIndex { let string = String(input[tagStartIndex.. Date: Wed, 13 Aug 2025 00:55:48 +0300 Subject: [PATCH 015/118] Update packages --- Tuist/Package.resolved | 14 +++++++------- Tuist/Package.swift | 6 +++--- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index 58650f69..1d526c19 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "a76e6a42619a2395784b873acf01971e7b08edd75fa45804d9aa445cece55b89", + "originHash" : "7a3724829c1fc3fe29b8d84a93303dc564ef99749abd5f8cbcd82afbc525fac4", "pins" : [ { "identity" : "activityindicatorview", @@ -96,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/PostHog/posthog-ios", "state" : { - "revision" : "d6fde6cea4fbb2f968f116aa04572c39297a8c2b", - "version" : "3.26.2" + "revision" : "6532c136877c44d7c96a351303122a5a7fc67c1a", + "version" : "3.30.0" } }, { @@ -114,8 +114,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/getsentry/sentry-cocoa.git", "state" : { - "revision" : "930b78a63f47549c81e6e63c9172584f7d3dfdd6", - "version" : "8.52.1" + "revision" : "156495496cb101e2f0a6b059f12dafcff1912197", + "version" : "8.54.0" } }, { @@ -177,8 +177,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-composable-architecture.git", "state" : { - "revision" : "6574de2396319a58e86e2178577268cb4aeccc30", - "version" : "1.20.2" + "revision" : "4c47829a080789cf20d82c64d8c27291352391d4", + "version" : "1.21.1" } }, { diff --git a/Tuist/Package.swift b/Tuist/Package.swift index 950a9e5c..ebfb3e7c 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -42,12 +42,12 @@ let package = Package( name: "ForPDA", dependencies: [ - .package(url: "https://github.com/pointfreeco/swift-composable-architecture.git", from: "1.20.2"), + .package(url: "https://github.com/pointfreeco/swift-composable-architecture.git", from: "1.21.1"), .package(url: "https://github.com/SFSafeSymbols/SFSafeSymbols.git", from: "6.2.0"), .package(url: "https://github.com/hyperoslo/Cache.git", from: "7.4.0"), .package(url: "https://github.com/kean/Nuke.git", from: "12.8.0"), - .package(url: "https://github.com/PostHog/posthog-ios.git", from: "3.26.2"), - .package(url: "https://github.com/getsentry/sentry-cocoa.git", from: "8.52.1"), + .package(url: "https://github.com/PostHog/posthog-ios.git", from: "3.30.0"), + .package(url: "https://github.com/getsentry/sentry-cocoa.git", from: "8.54.0"), .package(url: "https://github.com/CSolanaM/SkeletonUI.git", from: "2.0.2"), .package(url: "https://github.com/raymondjavaxx/SmoothGradient.git", branch: "main"), .package(url: "https://github.com/SvenTiigi/YouTubePlayerKit.git", from: "2.0.0"), From 1ee84c2aa2fa15769ebc3722b631a211563ec7bc Mon Sep 17 00:00:00 2001 From: Ilia Lubianoi Date: Wed, 13 Aug 2025 00:55:58 +0300 Subject: [PATCH 016/118] Fix Sharing tests crash --- Tuist/Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tuist/Package.swift b/Tuist/Package.swift index ebfb3e7c..d3c1d102 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -21,7 +21,7 @@ "OrderedCollections": .framework, "Perception": .framework, "PerceptionCore": .framework, - "Sharing": .framework, + // "Sharing": .framework, "Sharing1": .framework, "Sharing2": .framework, "SwiftNavigation": .framework, From 39fb4979e095d77626c28b55c3961938d6b1fcf5 Mon Sep 17 00:00:00 2001 From: Ilia Lubianoi Date: Wed, 13 Aug 2025 01:19:13 +0300 Subject: [PATCH 017/118] WIP --- .../Comments/CommentFeature.swift | 20 +- .../Comments/CommentsView.swift | 4 +- .../New/Fields/FormCheckBoxFeature.swift | 60 ++ .../New/Fields/FormDropdownFeature.swift | 150 +++++ .../New/Fields/FormEditorFeature.swift | 118 ++++ .../New/Fields/FormFieldConformable.swift | 20 + .../New/Fields/FormFieldFeature.swift | 203 +++++++ .../New/Fields/FormTextFieldFeature.swift | 118 ++++ .../New/Fields/FormTitleFeature.swift | 113 ++++ .../New/Fields/FormUploadBoxFeature.swift | 425 ++++++++++++++ .../Sources/FormFeature/New/FormFeature.swift | 487 ++++++++++++++++ .../Sources/FormFeature/New/FormScreen.swift | 226 ++++++++ .../New/Support/FormNodeBuilder.swift | 94 ++++ .../FormFeature/New/Support/FormType.swift | 63 +++ .../FormFeature/New/Views/CheckBox.swift | 35 ++ .../New/Views/EditReasonView.swift | 84 +++ .../FormFeature/New/Views/FieldNew.swift | 54 ++ .../Preview/FormPreviewFeature.swift | 0 .../Preview/FormPreviewView.swift | 0 .../Resources/Localizable.xcstrings | 59 +- .../Sources/ForumFeature/ForumFeature.swift | 14 +- .../Sources/ForumFeature/ForumScreen.swift | 4 +- Modules/Sources/Models/Auth/UserSession.swift | 14 +- .../Models/Common/WriteFormFieldType.swift | 97 +++- Modules/Sources/Models/Profile/User.swift | 83 ++- .../Sources/TopicFeature/TopicFeature.swift | 49 +- .../Sources/TopicFeature/TopicScreen.swift | 28 +- .../Models/FormContentData.swift | 21 - .../Models/FormUploadEvent.swift | 14 - .../UploadBox/UploadBoxFeature.swift | 55 -- .../UploadBox/UploadBoxView.swift | 298 ---------- .../WriteFormFeature/WriteFormFeature.swift | 522 ------------------ .../WriteFormFeature/WriteFormScreen.swift | 245 -------- .../WriteFormFeature/WriteFormView.swift | 490 ---------------- .../WriteFormFeatureTests.swift | 508 +++++++++++++++++ Project.swift | 22 +- 36 files changed, 3019 insertions(+), 1778 deletions(-) create mode 100644 Modules/Sources/FormFeature/New/Fields/FormCheckBoxFeature.swift create mode 100644 Modules/Sources/FormFeature/New/Fields/FormDropdownFeature.swift create mode 100644 Modules/Sources/FormFeature/New/Fields/FormEditorFeature.swift create mode 100644 Modules/Sources/FormFeature/New/Fields/FormFieldConformable.swift create mode 100644 Modules/Sources/FormFeature/New/Fields/FormFieldFeature.swift create mode 100644 Modules/Sources/FormFeature/New/Fields/FormTextFieldFeature.swift create mode 100644 Modules/Sources/FormFeature/New/Fields/FormTitleFeature.swift create mode 100644 Modules/Sources/FormFeature/New/Fields/FormUploadBoxFeature.swift create mode 100644 Modules/Sources/FormFeature/New/FormFeature.swift create mode 100644 Modules/Sources/FormFeature/New/FormScreen.swift create mode 100644 Modules/Sources/FormFeature/New/Support/FormNodeBuilder.swift create mode 100644 Modules/Sources/FormFeature/New/Support/FormType.swift create mode 100644 Modules/Sources/FormFeature/New/Views/CheckBox.swift create mode 100644 Modules/Sources/FormFeature/New/Views/EditReasonView.swift create mode 100644 Modules/Sources/FormFeature/New/Views/FieldNew.swift rename Modules/Sources/{WriteFormFeature => FormFeature}/Preview/FormPreviewFeature.swift (100%) rename Modules/Sources/{WriteFormFeature => FormFeature}/Preview/FormPreviewView.swift (100%) rename Modules/Sources/{WriteFormFeature => FormFeature}/Resources/Localizable.xcstrings (91%) delete mode 100644 Modules/Sources/WriteFormFeature/Models/FormContentData.swift delete mode 100644 Modules/Sources/WriteFormFeature/Models/FormUploadEvent.swift delete mode 100644 Modules/Sources/WriteFormFeature/UploadBox/UploadBoxFeature.swift delete mode 100644 Modules/Sources/WriteFormFeature/UploadBox/UploadBoxView.swift delete mode 100644 Modules/Sources/WriteFormFeature/WriteFormFeature.swift delete mode 100644 Modules/Sources/WriteFormFeature/WriteFormScreen.swift delete mode 100644 Modules/Sources/WriteFormFeature/WriteFormView.swift create mode 100644 Modules/Tests/FormFeatureTests/WriteFormFeatureTests.swift diff --git a/Modules/Sources/ArticleFeature/Comments/CommentFeature.swift b/Modules/Sources/ArticleFeature/Comments/CommentFeature.swift index 6a8df5ac..f07948e1 100644 --- a/Modules/Sources/ArticleFeature/Comments/CommentFeature.swift +++ b/Modules/Sources/ArticleFeature/Comments/CommentFeature.swift @@ -12,7 +12,7 @@ import PersistenceKeys import APIClient import Models import ToastClient -import WriteFormFeature +import FormFeature public enum CommentContextMenuOptions { case report @@ -29,7 +29,7 @@ public struct CommentFeature: Reducer, Sendable { @ObservableState public struct State: Equatable, Identifiable { @Presents public var alert: AlertState? - @Presents var writeForm: WriteFormFeature.State? + @Presents var writeForm: FormFeature.State? @Shared(.userSession) public var userSession: UserSession? public var id: Int { return comment.id } public var comment: Comment @@ -77,7 +77,7 @@ public struct CommentFeature: Reducer, Sendable { case replyButtonTapped case likeButtonTapped - case writeForm(PresentationAction) + case writeForm(PresentationAction) case _likeResult(Bool) case _timerTicked @@ -118,7 +118,7 @@ public struct CommentFeature: Reducer, Sendable { case let .profileTapped(id): return .send(.delegate(.commentHeaderTapped(id))) - case .writeForm(.presented(.delegate(.writeFormSent(let response)))): + case .writeForm(.presented(.delegate(.formSent(let response)))): if case let .report(result) = response { return switch result { case .error: showToast(.reportSendError) @@ -139,10 +139,12 @@ public struct CommentFeature: Reducer, Sendable { guard state.isAuthorized else { return .send(.delegate(.unauthorizedAction)) } - state.writeForm = WriteFormFeature.State(formFor: .report( - id: state.comment.id, - type: .comment - )) + state.writeForm = FormFeature.State( + type: .report( + id: state.comment.id, + type: .comment + ) + ) return .none case .hideButtonTapped: @@ -186,7 +188,7 @@ public struct CommentFeature: Reducer, Sendable { } } .ifLet(\.$writeForm, action: \.writeForm) { - WriteFormFeature() + FormFeature() } .ifLet(\.alert, action: \.alert) } diff --git a/Modules/Sources/ArticleFeature/Comments/CommentsView.swift b/Modules/Sources/ArticleFeature/Comments/CommentsView.swift index bf2df980..19e9857b 100644 --- a/Modules/Sources/ArticleFeature/Comments/CommentsView.swift +++ b/Modules/Sources/ArticleFeature/Comments/CommentsView.swift @@ -12,7 +12,7 @@ import NukeUI import SharedUI import SkeletonUI import SFSafeSymbols -import WriteFormFeature +import FormFeature // MARK: - Comments View @@ -120,7 +120,7 @@ struct CommentView: View { } .fullScreenCover(item: $store.scope(state: \.writeForm, action: \.writeForm)) { store in NavigationStack { - WriteFormScreen(store: store) + FormScreen(store: store) } } .background(Color(.Background.primary)) diff --git a/Modules/Sources/FormFeature/New/Fields/FormCheckBoxFeature.swift b/Modules/Sources/FormFeature/New/Fields/FormCheckBoxFeature.swift new file mode 100644 index 00000000..c38f8359 --- /dev/null +++ b/Modules/Sources/FormFeature/New/Fields/FormCheckBoxFeature.swift @@ -0,0 +1,60 @@ +// +// FormCheckBoxFeature.swift +// FormFeature +// +// Created by Ilia Lubianoi on 19.07.2025. +// + +#warning("todo") + +import SwiftUI +import ComposableArchitecture + +// MARK: - Feature + +@Reducer +public struct FormCheckBoxFeature: Reducer { + + // MARK: - State + + @ObservableState + public struct State: Equatable, FormFieldConformable { + public let id: Int + let flag: Int + + func getValue() -> String { + return "" + } + + func isValid() -> Bool { + return true + } + } + + // MARK: - Actions + + public enum Action: BindableAction { + case binding(BindingAction) + } + + // MARK: - Body + + public var body: some Reducer { + BindingReducer() + + Reduce { state, action in + return .none + } + } +} + +// MARK: - View + +struct FormCheckBoxRow: View { + + @Perception.Bindable var store: StoreOf + + var body: some View { + Text(verbatim: "CheckBox") + } +} diff --git a/Modules/Sources/FormFeature/New/Fields/FormDropdownFeature.swift b/Modules/Sources/FormFeature/New/Fields/FormDropdownFeature.swift new file mode 100644 index 00000000..4aa79802 --- /dev/null +++ b/Modules/Sources/FormFeature/New/Fields/FormDropdownFeature.swift @@ -0,0 +1,150 @@ +// +// FormDropdownFeature.swift +// FormFeature +// +// Created by Ilia Lubianoi on 19.07.2025. +// + +import SwiftUI +import ComposableArchitecture + +// MARK: - Feature + +@Reducer +public struct FormDropdownFeature: Reducer { + + // MARK: - State + + @ObservableState + public struct State: Equatable, FormFieldConformable { + public let id: Int + let title: String + let description: String + let flag: Int + let options: [String] + public var selectedOption: String + + public init( + id: Int, + title: String, + description: String, + flag: Int, + options: [String] + ) { + self.id = id + self.title = title + self.description = description + self.flag = flag + self.options = options + self.selectedOption = options.first ?? "" + } + + func getValue() -> String { + return selectedOption + } + + func isValid() -> Bool { + return !selectedOption.isEmpty + } + } + + // MARK: - Actions + + public enum Action: BindableAction, ViewAction { + case binding(BindingAction) + case view(View) + + public enum View { + case menuOptionSelected(String) + } + } + + // MARK: - Body + + public var body: some Reducer { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding: + break + + case let .view(.menuOptionSelected(selectedOption)): + state.selectedOption = selectedOption + } + return .none + } + } +} + +// MARK: - View + +@ViewAction(for: FormDropdownFeature.self) +struct FormDropdownRow: View { + + @Perception.Bindable var store: StoreOf + @Environment(\.tintColor) private var tintColor + + var body: some View { + WithPerceptionTracking { + FieldSection( + title: store.title, + description: store.description, + required: store.isRequired + ) { + WithPerceptionTracking { + Menu { + ForEach(store.options, id: \.self) { option in + Button { + send(.menuOptionSelected(option)) + } label: { + Text(option) + } + } + } label: { + HStack { + Text(store.selectedOption) + .font(.body) + .lineLimit(1) + .foregroundStyle(Color(.Labels.primary)) + .frame(maxWidth: .infinity, alignment: .leading) + + Image(systemSymbol: .chevronUpChevronDown) + .tint(tintColor) + } + .padding() + .background( + RoundedRectangle(cornerRadius: 14) + .fill(Color(.Background.teritary)) + ) + .overlay { + RoundedRectangle(cornerRadius: 14) + .strokeBorder(Color(.Separator.primary)) + } + } + .listRowBackground(Color(.Background.teritary)) + } + } + } + } +} + +// MARK: - Previews + +#Preview { + FormDropdownRow( + store: Store( + initialState: FormDropdownFeature.State( + id: 0, + title: "Update type", + description: "What do we publish?", + flag: 1, + options: ["New version", "Beta", "Modification", "Other"] + ) + ) { + FormDropdownFeature() + } + ) + .padding(.horizontal, 16) + .environment(\.tintColor, Color(.Theme.primary)) +} diff --git a/Modules/Sources/FormFeature/New/Fields/FormEditorFeature.swift b/Modules/Sources/FormFeature/New/Fields/FormEditorFeature.swift new file mode 100644 index 00000000..34e346ba --- /dev/null +++ b/Modules/Sources/FormFeature/New/Fields/FormEditorFeature.swift @@ -0,0 +1,118 @@ +// +// FormFormFeature.swift +// FormFeature +// +// Created by Ilia Lubianoi on 19.07.2025. +// + +import SwiftUI +import ComposableArchitecture + +// MARK: - Feature + +@Reducer +public struct FormEditorFeature: Reducer { + + // MARK: - State + + @ObservableState + public struct State: Equatable, FormFieldConformable { + public let id: Int + let title: String + let description: String + let placeholder: String + let flag: Int + public var text = "" + + public init( + id: Int, + title: String = "", + description: String = "", + placeholder: String = "", + flag: Int, + defaultText: String = "" + ) { + self.id = id + self.title = title + self.description = description + self.placeholder = placeholder + self.flag = flag + self.text = defaultText + } + + func getValue() -> String { + return text + } + + func isValid() -> Bool { + return !text.isEmpty + } + } + + // MARK: - Action + + public enum Action: BindableAction { + case binding(BindingAction) + } + + // MARK: - Body + + public var body: some Reducer { + BindingReducer() + + Reduce { state, action in + return .none + } + } +} + +// MARK: - View + +struct FormEditorRow: View { + + @Perception.Bindable var store: StoreOf + @FocusState.Binding var focusedField: Int? + + var body: some View { + WithPerceptionTracking { + FieldSection( + title: store.title, + description: store.description, + required: store.isRequired + ) { + WithPerceptionTracking { + Field( + id: store.id, + text: $store.text, + placeholder: store.placeholder, + isEditor: true, + focusedField: $focusedField + ) + } + } + } + } +} + +// MARK: - Previews + +@available(iOS 17, *) +#Preview { + @Previewable @FocusState var focusedField: Int? + + FormEditorRow( + store: Store( + initialState: FormEditorFeature.State( + id: 0, + title: "Editor Title", + description: "Editor Description", + placeholder: "Editor Placeholder", + flag: 1, + defaultText: "Editor Default Text" + ) + ) { + FormEditorFeature() + }, + focusedField: $focusedField + ) +} diff --git a/Modules/Sources/FormFeature/New/Fields/FormFieldConformable.swift b/Modules/Sources/FormFeature/New/Fields/FormFieldConformable.swift new file mode 100644 index 00000000..9e5421c2 --- /dev/null +++ b/Modules/Sources/FormFeature/New/Fields/FormFieldConformable.swift @@ -0,0 +1,20 @@ +// +// FormFieldConformable.swift +// FormFeature +// +// Created by Ilia Lubianoi on 20.07.2025. +// + +protocol FormFieldConformable: Identifiable { + var flag: Int { get } + var isRequired: Bool { get } + + func isValid() -> Bool + func getValue() -> String +} + +extension FormFieldConformable { + var isRequired: Bool { + return flag & 1 != 0 + } +} diff --git a/Modules/Sources/FormFeature/New/Fields/FormFieldFeature.swift b/Modules/Sources/FormFeature/New/Fields/FormFieldFeature.swift new file mode 100644 index 00000000..6fbacd3d --- /dev/null +++ b/Modules/Sources/FormFeature/New/Fields/FormFieldFeature.swift @@ -0,0 +1,203 @@ +// +// FormFieldFeature.swift +// FormFeature +// +// Created by Ilia Lubianoi on 19.07.2025. +// + +import SwiftUI +import ComposableArchitecture + +@Reducer +public struct FormFieldFeature: Reducer { + + // MARK: - State + + @ObservableState + public enum State: Equatable, Identifiable, FormFieldConformable { + var flag: Int { return -1 } + + case checkBox(FormCheckBoxFeature.State) + case dropdown(FormDropdownFeature.State) + case editor(FormEditorFeature.State) + case textField(FormTextFieldFeature.State) + case title(FormTitleFeature.State) + case uploadBox(FormUploadBoxFeature.State) + + public var id: Int { + switch self { + case .checkBox(let state): return state.id + case .dropdown(let state): return state.id + case .editor(let state): return state.id + case .textField(let state): return state.id + case .title(let state): return state.id + case .uploadBox(let state): return state.id + } + } + + func getValue() -> String { + switch self { + case .checkBox(let state): state.getValue() + case .dropdown(let state): state.getValue() + case .editor(let state): state.getValue() + case .textField(let state): state.getValue() + case .title(let state): state.getValue() + case .uploadBox(let state): state.getValue() + } + } + + func isValid() -> Bool { + switch self { + case .checkBox(let state): state.isValid() + case .dropdown(let state): state.isValid() + case .editor(let state): state.isValid() + case .textField(let state): state.isValid() + case .title(let state): state.isValid() + case .uploadBox(let state): state.isValid() + } + } + + func isRequired() -> Bool { + switch self { + case .checkBox(let state): state.isRequired + case .dropdown(let state): state.isRequired + case .editor(let state): state.isRequired + case .textField(let state): state.isRequired + case .title(let state): state.isRequired + case .uploadBox(let state): state.isRequired + } + } + } + + // MARK: - Actions + + public enum Action { + case checkBox(FormCheckBoxFeature.Action) + case dropdown(FormDropdownFeature.Action) + case editor(FormEditorFeature.Action) + case textField(FormTextFieldFeature.Action) + case title(FormTitleFeature.Action) + case uploadBox(FormUploadBoxFeature.Action) + } + + // MARK: - Body + + public var body: some Reducer { + Scope(state: \.checkBox, action: \.checkBox) { + FormCheckBoxFeature() + } + Scope(state: \.dropdown, action: \.dropdown) { + FormDropdownFeature() + } + Scope(state: \.editor, action: \.editor) { + FormEditorFeature() + } + Scope(state: \.textField, action: \.textField) { + FormTextFieldFeature() + } + Scope(state: \.title, action: \.title) { + FormTitleFeature() + } + Scope(state: \.uploadBox, action: \.uploadBox) { + FormUploadBoxFeature() + } + Reduce { state, action in + return .none + } + } +} + +// MARK: - Form Field Row + +struct FormFieldRow: View { + + @Perception.Bindable var store: StoreOf + @FocusState.Binding var focusedField: Int? + + var body: some View { + switch store.state { + case .checkBox: + if let store = store.scope(state: \.checkBox, action: \.checkBox) { + FormCheckBoxRow(store: store) + } + + case .dropdown: + if let store = store.scope(state: \.dropdown, action: \.dropdown) { + FormDropdownRow(store: store) + } + + case .editor: + if let store = store.scope(state: \.editor, action: \.editor) { + FormEditorRow(store: store, focusedField: $focusedField) + } + + case .textField: + if let store = store.scope(state: \.textField, action: \.textField) { + FormTextFieldRow(store: store, focusedField: $focusedField) + } + + case .title: + if let store = store.scope(state: \.title, action: \.title) { + FormTitleRow(store: store) + } + + case .uploadBox: + if let store = store.scope(state: \.uploadBox, action: \.uploadBox) { + FormUploadBoxRow(store: store) + } + } + } +} + +// MARK: - Form Field Header + +struct FieldSection: View { + + let title: String + let description: String + let required: Bool + let content: () -> Content + + init( + title: String, + description: String, + required: Bool, + @ViewBuilder content: @escaping () -> Content + ) { + self.title = title + self.description = description + self.required = required + self.content = content + } + + var body: some View { + VStack(spacing: 6) { + if !title.isEmpty { + Text(title) + .font(.footnote) + .fontWeight(.semibold) + .foregroundStyle(Color(.Labels.teritary)) + .textCase(nil) + .overlay(alignment: .bottomTrailing) { + if required { + Text(verbatim: "*") + .font(.headline) + .offset(x: 8) + .foregroundStyle(.red) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + content() + + if !description.isEmpty { + let nodes = FormNodeBuilder(text: description).build(isDescription: true) + ForEach(nodes, id: \.self) { node in + FormNodeView(node: node) + } + .padding(.leading, 16) + } + } + } +} diff --git a/Modules/Sources/FormFeature/New/Fields/FormTextFieldFeature.swift b/Modules/Sources/FormFeature/New/Fields/FormTextFieldFeature.swift new file mode 100644 index 00000000..6f2ce4c8 --- /dev/null +++ b/Modules/Sources/FormFeature/New/Fields/FormTextFieldFeature.swift @@ -0,0 +1,118 @@ +// +// FormTextFieldFeature.swift +// FormFeature +// +// Created by Ilia Lubianoi on 19.07.2025. +// + +import SwiftUI +import ComposableArchitecture + +// MARK: - Feature + +@Reducer +public struct FormTextFieldFeature: Reducer { + + // MARK: - State + + @ObservableState + public struct State: Equatable, FormFieldConformable { + public let id: Int + let title: String + let description: String + let placeholder: String + let flag: Int + public var text = "" + + public init( + id: Int, + title: String = "", + description: String = "", + placeholder: String = "", + flag: Int, + defaultText: String = "" + ) { + self.id = id + self.title = title + self.description = description + self.placeholder = placeholder + self.flag = flag + self.text = defaultText + } + + func getValue() -> String { + return text + } + + func isValid() -> Bool { + return !text.isEmpty + } + } + + // MARK: - Action + + public enum Action: BindableAction { + case binding(BindingAction) + } + + // MARK: - Body + + public var body: some Reducer { + BindingReducer() + + Reduce { state, action in + return .none + } + } +} + +// MARK: - View + +struct FormTextFieldRow: View { + + @Perception.Bindable var store: StoreOf + @FocusState.Binding var focusedField: Int? + + var body: some View { + WithPerceptionTracking { + FieldSection( + title: store.title, + description: store.description, + required: store.isRequired + ) { + WithPerceptionTracking { + Field( + id: store.id, + text: $store.text, + placeholder: store.placeholder, + isEditor: false, + focusedField: $focusedField + ) + } + } + } + } +} + +// MARK: - Previews + +@available(iOS 17, *) +#Preview { + @Previewable @FocusState var focusedField: Int? + + FormTextFieldRow( + store: Store( + initialState: FormTextFieldFeature.State( + id: 0, + title: "TextField Title", + description: "TextField Description", + placeholder: "TextField Placeholder", + flag: 1, + defaultText: "TextField Default Text" + ) + ) { + FormTextFieldFeature() + }, + focusedField: $focusedField + ) +} diff --git a/Modules/Sources/FormFeature/New/Fields/FormTitleFeature.swift b/Modules/Sources/FormFeature/New/Fields/FormTitleFeature.swift new file mode 100644 index 00000000..025e86e3 --- /dev/null +++ b/Modules/Sources/FormFeature/New/Fields/FormTitleFeature.swift @@ -0,0 +1,113 @@ +// +// FormTitleFeature.swift +// FormFeature +// +// Created by Ilia Lubianoi on 19.07.2025. +// + +import SwiftUI +import ComposableArchitecture + +// MARK: - Feature + +@Reducer +public struct FormTitleFeature: Reducer { + + // MARK: - State + + @ObservableState + public struct State: Equatable, FormFieldConformable { + public let id: Int + let text: String + let flag = 0 + + public init(id: Int, text: String) { + self.id = id + self.text = text + } + + var nodes: [FormNode] = [] + + func getValue() -> String { + return "\"\"" + } + + func isValid() -> Bool { + return true + } + } + + // MARK: - Actions + + public enum Action: BindableAction, ViewAction { + case binding(BindingAction) + case view(View) + + public enum View { + case onAppear + } + } + + // MARK: - Body + + public var body: some Reducer { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding: + break + + case .view(.onAppear): + state.nodes = FormNodeBuilder(text: state.text).build() + } + return .none + } + } +} + +// MARK: - View + +@ViewAction(for: FormTitleFeature.self) +struct FormTitleRow: View { + + @Perception.Bindable var store: StoreOf + + var body: some View { + WithPerceptionTracking { + VStack(spacing: 6) { + ForEach(store.nodes, id: \.self) { node in + FormNodeView(node: node) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .padding(.horizontal, 12) + .background { + RoundedRectangle(cornerRadius: 14) + .fill(Color(.Background.teritary)) + } + .onAppear { + send(.onAppear) + } + } + } +} + +// MARK: - Previews + +@available(iOS 17, *) +#Preview { + @Previewable @FocusState var focusedField: Int? + + FormTitleRow( + store: Store( + initialState: FormTitleFeature.State( + id: 0, + text: "Title Text" + ) + ) { + FormTitleFeature() + } + ) +} diff --git a/Modules/Sources/FormFeature/New/Fields/FormUploadBoxFeature.swift b/Modules/Sources/FormFeature/New/Fields/FormUploadBoxFeature.swift new file mode 100644 index 00000000..56ee04d2 --- /dev/null +++ b/Modules/Sources/FormFeature/New/Fields/FormUploadBoxFeature.swift @@ -0,0 +1,425 @@ +// +// FormUploadBoxFeature.swift +// FormFeature +// +// Created by Ilia Lubianoi on 19.07.2025. +// + +#warning("to do") + +import SwiftUI +import ComposableArchitecture +import PhotosUI + +// MARK: - Feature + +@Reducer +public struct FormUploadBoxFeature: Reducer { + + // MARK: - Helpers + + public struct File: Identifiable, Equatable { + + public enum FileType { + case file, image + } + + public let id: Int + let name: String + let type: FileType + let data: Data + + public init(id: Int, name: String, type: FileType, data: Data) { + self.id = id + self.name = name + self.type = type + self.data = data + } + } + + var isPreview: Bool { + return ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" + } + + // MARK: - Destination + + @Reducer(state: .equatable) + public enum Destination { + case confirmationDialog(ConfirmationDialogState) + case fileImporter + case photosPicker + + @CasePathable + public enum Dialog { + case gallery, files + } + } + + // MARK: - State + + @ObservableState + public struct State: Equatable, FormFieldConformable { + @Presents public var destination: Destination.State? + + public let id: Int + let title: String + let description: String + let flag: Int + let allowedExtensions: [String] + + var isLoading: Bool + public var files: [File] + + public init( + id: Int, + title: String, + description: String, + flag: Int, + allowedExtensions: [String], + isLoading: Bool = false, + files: [File] = [] + ) { + self.id = id + self.title = title + self.description = description + self.flag = flag + self.allowedExtensions = allowedExtensions + self.isLoading = isLoading + self.files = files + } + + func getValue() -> String { + return files.map { "[\($0.id),\($0.name)]" }.joined(separator: ",") + } + + func isValid() -> Bool { + return !files.isEmpty + } + } + + // MARK: - Actions + + public enum Action: BindableAction, ViewAction { + case binding(BindingAction) + case destination(PresentationAction) + + case view(View) + public enum View { + case selectFilesButtonTapped + case removeFileButtonTapped(File) + case addMoreButtonTapped + case photosPickerPhotoSelected(Data) + case fileImporterURLsRecieved([URL]) + } + + case `internal`(Internal) + public enum Internal { + case showFiles + } + + case delegate(Delegate) + public enum Delegate { + case filesHasBeenUploaded + } + } + + // MARK: - Body + + public var body: some Reducer { + BindingReducer() + + Reduce { + state, + action in + switch action { + case .binding: + break + + case .delegate: + break + + case .destination(.presented(.confirmationDialog(.files))): + if isPreview { + return .send(.view(.fileImporterURLsRecieved([]))) + } else { + state.destination = .fileImporter + } + + case .destination(.presented(.confirmationDialog(.gallery))): + if isPreview { + return .send(.view(.photosPickerPhotoSelected(Data()))) + } else { + state.destination = .photosPicker + } + + case .destination: + break + + case .view(.selectFilesButtonTapped), .view(.addMoreButtonTapped): + let dialogState = ConfirmationDialogState( + title: { TextState(verbatim: "") }, + actions: { + ButtonState(action: .gallery) { + TextState("Choose from Gallery", bundle: .module) + } + ButtonState(action: .files) { + TextState("Choose from Files", bundle: .module) + } + } + ) + state.destination = .confirmationDialog(dialogState) + + case let .view(.removeFileButtonTapped(file)): + state.files.removeAll(where: { $0.id == file.id }) + + case let .view(.photosPickerPhotoSelected(data)): + let file = File( + id: state.files.count, + name: UUID().uuidString, + type: .image, + data: data + ) + state.files.append(file) + + case let .view(.fileImporterURLsRecieved(urls)): + var urls = urls + if isPreview { + let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let fileURL = documentsURL.appending(path: "data.dat") + try! Data().write(to: fileURL) + urls.append(fileURL) + } + + for url in urls { + guard let data = try? Data(contentsOf: url) else { + print("Couldn't extract data from url: \(url)") + continue + } + let file = File( + id: state.files.count, + name: url.lastPathComponent, + type: .file, + data: data + ) + state.files.append(file) + } + + case .internal(.showFiles): + state.isLoading = false + return .send(.delegate(.filesHasBeenUploaded)) + } + + return .none + } + .ifLet(\.$destination, action: \.destination) + } +} + +// MARK: - View + +@ViewAction(for: FormUploadBoxFeature.self) +struct FormUploadBoxRow: View { + + // MARK: - Properties + + @Perception.Bindable var store: StoreOf + @Environment(\.tintColor) private var tintColor + @State private var pickerItem: PhotosPickerItem? + + // MARK: - Body + + var body: some View { + WithPerceptionTracking { + VStack(spacing: 6) { + FieldSection( + title: store.title, + description: store.description, + required: store.isRequired + ) { + WithPerceptionTracking { + if store.files.isEmpty { + UploadView() + } else { + FilesGrid() + } + } + } + .overlay(alignment: .topTrailing) { + if !store.files.isEmpty { + Button { + send(.addMoreButtonTapped) + } label: { + Label("Add more", systemSymbol: .plus) + .font(.footnote) + .tint(tintColor) + } + } + } + } + .confirmationDialog( + $store.scope( + state: \.destination?.confirmationDialog, + action: \.destination.confirmationDialog + ) + ) + .fileImporter( + isPresented: Binding($store.destination.fileImporter), + allowedContentTypes: [.png, .jpeg], + allowsMultipleSelection: true, + onCompletion: { result in + switch result { + case let .success(urls): + send(.fileImporterURLsRecieved(urls)) + case let .failure(error): + print("File importer error: \(error)") + #warning("Handle error") + } + } + ) + .photosPicker( + isPresented: Binding($store.destination.photosPicker), + selection: $pickerItem + ) + .task(id: pickerItem) { + guard let data = try? await pickerItem?.loadTransferable(type: Data.self) else { + return + } + if let pickerItem, let localID = pickerItem.itemIdentifier { + let result = PHAsset.fetchAssets(withLocalIdentifiers: [localID], options: nil) + if let asset = result.firstObject { + print("Got " + asset.debugDescription) + #warning("Check if its working") + } + } + send(.photosPickerPhotoSelected(data)) + } + .tint(tintColor) + } + } + + // MARK: - Upload View + + @ViewBuilder + private func UploadView() -> some View { + Button { + send(.selectFilesButtonTapped) + } label: { + VStack(spacing: 8) { + Image(systemSymbol: .docBadgePlus) + .font(.title) + .frame(width: 48, height: 48) + + Text("Select files...", bundle: .module) + .font(.body) + .foregroundColor(Color(.Labels.quaternary)) + } + .frame(maxWidth: .infinity, minHeight: 144) + .background { + RoundedRectangle(cornerRadius: 14) + .fill(Color(.Background.teritary)) + } + .overlay { + RoundedRectangle(cornerRadius: 14) + .strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [8])) + } + } + } + + // MARK: - Files Grid + + @ViewBuilder + private func FilesGrid() -> some View { + ScrollView(.horizontal) { + HStack(spacing: 12) { + ForEach(store.files) { file in + FileView(file) + } + } + } + .scrollIndicators(.hidden) + .animation(.default, value: store.files) + } + + // MARK: - File View + + @ViewBuilder + private func FileView(_ file: FormUploadBoxFeature.File) -> some View { + VStack(spacing: 0) { + Image(systemSymbol: file.type == .file ? .doc : .photo) + .font(.title) + .foregroundColor(tintColor) + .frame(width: 48, height: 48) + + Text(file.name) + .font(.footnote) + .foregroundStyle(Color(.Labels.primary)) + .lineLimit(2) + .multilineTextAlignment(.center) + } + .padding(.horizontal, 12) + .frame(minWidth: 144, maxWidth: 144, minHeight: 144) + .background( + RoundedRectangle(cornerRadius: 14) + .fill(Color(.Background.teritary)) + ) + .overlay(alignment: .topTrailing) { + Button { + send(.removeFileButtonTapped(file)) + } label: { + Circle() + .fill(Color(.Background.teritary)) + .frame(width: 28, height: 28) + .overlay { + Image(systemSymbol: .xmark) + .font(.body) + .foregroundStyle(Color(.Labels.teritary)) + } + .padding([.top, .trailing], 6) + } + .buttonStyle(.plain) + } + } +} + +// MARK: - Previews + +#Preview("Upload Box (Empty)") { + FormUploadBoxRow( + store: Store( + initialState: FormUploadBoxFeature.State( + id: 0, + title: "File skin", + description: "Supported formats: jpg, jpeg, gif, png", + flag: 1, + allowedExtensions: ["jpg", "jpeg", "gif", "png"] + ) + ) { + FormUploadBoxFeature() + } + ) + .padding(.horizontal, 16) + .environment(\.tintColor, Color(.Theme.primary)) +} + +#Preview("Upload Box (Filled, 3)") { + FormUploadBoxRow( + store: Store( + initialState: FormUploadBoxFeature.State( + id: 0, + title: "File skin", + description: "Supported formats: jpg, jpeg, gif, png", + flag: 1, + allowedExtensions: ["jpg", "jpeg", "gif", "png"], + files: [ + .init(id: 0, name: "File 1", type: .file, data: Data()), + .init(id: 1, name: "Image 1", type: .image, data: Data()), + .init(id: 2, name: "File 2", type: .file, data: Data()), + ] + ) + ) { + FormUploadBoxFeature() + } + ) + .padding(.horizontal, 16) + .environment(\.tintColor, Color(.Theme.primary)) +} diff --git a/Modules/Sources/FormFeature/New/FormFeature.swift b/Modules/Sources/FormFeature/New/FormFeature.swift new file mode 100644 index 00000000..46928e3f --- /dev/null +++ b/Modules/Sources/FormFeature/New/FormFeature.swift @@ -0,0 +1,487 @@ +// +// FormFeature.swift +// ForPDA +// +// Created by Ilia Lubianoi on 19.07.2025. +// + +import APIClient +import ComposableArchitecture +import Models + +// MARK: - Form Feature + +@Reducer +public struct FormFeature: Reducer, Sendable { + + public init() {} + + // MARK: - Helper Enums + + public enum PostSendFlag: Int, Sendable { + case `default` = 0 + case attach = 1 + case doNotAttach = 3 + } + + // MARK: - Destinations + + @Reducer(state: .equatable) + public enum Destination { + case preview(FormPreviewFeature) + case alert(AlertState) + + @CasePathable + public enum Alert { + case attach, doNotAttach, dismiss + } + } + + // MARK: - State + + @ObservableState + public struct State: Equatable { + @Presents public var destination: Destination.State? + + @Shared(.userSession) var userSession + + let type: FormType + public var rows: IdentifiedArrayOf = [] + public var focusedField: Int? + public var isFormLoading = false + public var isPublishing = false + var canShowShowMark = false + public var isEditingReasonEnabled = false + var isShowMarkEnabled = false + public var editReasonText = "" + + public var inPostEditingMode: Bool { + if case let .post(type, _, _) = type, case .edit = type { + return true + } + return false + } + + var isPreviewButtonDisabled: Bool { + return !rows.filter { $0.isRequired() }.allSatisfy { $0.isValid() } + } + + public var isPublishButtonDisabled: Bool { + return !rows.allSatisfy { $0.isValid() } || isPublishing + } + + var content: String { + if rows.count == 1, case let .editor(editorState) = rows.first { + return editorState.text + } else { + let values = rows.map { $0.getValue() } + return "[" + values.joined(separator: ",") + "]" + } + } + + public init(type: FormType) { + self.type = type + } + } + + // MARK: - Actions + + public enum Action: BindableAction, ViewAction { + case binding(BindingAction) + case destination(PresentationAction) + case rows(IdentifiedActionOf) + + case view(View) + public enum View { + case onAppear + case cancelButtonTapped + case previewButtonTapped + case publishButtonTapped + } + + case `internal`(Internal) + @CasePathable + public enum Internal { + case loadForm(id: Int, isTopic: Bool) + case formResponse(Result<[WriteFormFieldType], any Error>) + case reportResponse(Result) + case simplePostResponse(Result) + case templateResponse(Result) + case publishPost(flag: PostSendFlag) + } + + case delegate(Delegate) + @CasePathable + public enum Delegate { + case formSent(WriteFormSend) + } + } + + // MARK: - Dependencies + + @Dependency(\.apiClient) private var apiClient + @Dependency(\.analyticsClient) private var analyticsClient + @Dependency(\.cacheClient) private var cacheClient + @Dependency(\.dismiss) private var dismiss + + // MARK: - Body + + public var body: some Reducer { + BindingReducer() + + Reduce { + state, + action in + switch action { + case .binding(\.isEditingReasonEnabled): + if !state.isEditingReasonEnabled { + state.editReasonText = "" + state.isShowMarkEnabled = false + } + + case .binding: + break + + case let .destination(.presented(.alert(action))): + let editorFlag: Int + switch action { + case .attach: + editorFlag = PostSendFlag.attach.rawValue + case .doNotAttach: + editorFlag = PostSendFlag.doNotAttach.rawValue + case .dismiss: + return .run { _ in await dismiss() } + } + + return .send(.internal(.publishPost(flag: PostSendFlag(rawValue: editorFlag)!))) + + case .destination(.dismiss): + state.isPublishing = false + + case .destination: + break + + case let .delegate(.formSent(result)): + switch result { + case let .report(status): + if status.isError { + return .none + } + + case let .template(status): + if status.isError { + return .none + } + + case let .post(status): + #warning("handle") + break + } + return .run { _ in await dismiss() } + + case .delegate: + break + + case let .rows(action): + if case let .element(id: id, action: .uploadBox(.delegate(.filesHasBeenUploaded))) = action { + if case let .uploadBox(uploadBoxState) = state.rows[id: id] { + print("Files: \(uploadBoxState.files)") + } else { + fatalError("Non UploadBox state casted by action id") + } + } + break + + case .view(.onAppear): + switch state.type { + case let .post(type: _, topicId: topicId, content: content): + if state.inPostEditingMode, + let userId = state.userSession?.userId, + let user = cacheClient.getUser(userId), + user.canSetShowMarkOnPostEdit { + state.canShowShowMark = true + } + + switch content { + case let .simple(content, _): + let editorState = FormEditorFeature.State(id: 0, flag: 1, defaultText: content) + state.rows.append(.editor(editorState)) + state.focusedField = 0 + + case .template: + state.isFormLoading = true + return .send(.internal(.loadForm(id: topicId, isTopic: false))) + } + + case let .topic(forumId: forumId, content: _): + state.isFormLoading = true + return .send(.internal(.loadForm(id: forumId, isTopic: true))) + + case .report: + let editorState = FormEditorFeature.State(id: 0, flag: 1) + state.rows.append(.editor(editorState)) + state.focusedField = 0 + } + + case .view(.cancelButtonTapped): + return .run { _ in await dismiss() } + + case .view(.previewButtonTapped): + let previewState: FormPreviewFeature.State + switch state.type { + case let .post(type: type, topicId: topicId, content: content): + let content = if case .simple(_, let attachments) = content { + FormType.PostContentType.simple(state.content, attachments) + } else { + FormType.PostContentType.template(state.content) + } + + previewState = FormPreviewFeature.State( + formType: .post(type: type.convert(), topicId: topicId, content: content.convert()) + ) + + case .report: + previewState = FormPreviewFeature.State( + formType: .post(type: .new, topicId: 0, content: .simple(state.content, [])) + ) + + case let .topic(forumId: forumId, content: _): + previewState = FormPreviewFeature.State( + formType: .topic(forumId: forumId, content: state.content) + ) + } + + state.destination = .preview(previewState) + + case .view(.publishButtonTapped): + return .send(.internal(.publishPost(flag: .default))) +// return .run { send in +// await send(.internal(.publishPost(flag: .default))) +// } + + case let .internal(.loadForm(id: id, isTopic: isTopic)): + return .run { send in + let result = await Result { try await apiClient.getTemplate(id, isTopic) } + await send(.internal(.formResponse(result))) + } catch: { error, send in + await send(.internal(.formResponse(.failure(error)))) + } + + case let .internal(.formResponse(.success(fields))): + print(fields) + state.isFormLoading = false + for (index, field) in fields.enumerated() { + switch field { + case let .title(content): + guard !content.isEmpty else { continue } + let titleState = FormTitleFeature.State(id: index, text: content) + state.rows.append(.title(titleState)) + + case let .text(content): + let textFieldState = FormTextFieldFeature.State( + id: index, + title: content.name, + description: content.description, + placeholder: content.example, + flag: content.flag, + defaultText: content.defaultValue + ) + state.rows.append(.textField(textFieldState)) + + case let .editor(content): + let editorState = FormEditorFeature.State( + id: index, + title: content.name, + description: content.description, + placeholder: content.example, + flag: content.flag, + defaultText: content.defaultValue + ) + state.rows.append(.editor(editorState)) + + case let .checkboxList(content, _): + let checkboxState = FormCheckBoxFeature.State( + id: index, + flag: content.flag + ) + state.rows.append(.checkBox(checkboxState)) + + case let .dropdown(content, options): + let dropdownState = FormDropdownFeature.State( + id: index, + title: content.name, + description: content.description, + flag: content.flag, + options: options + ) + state.rows.append(.dropdown(dropdownState)) + + case let .uploadbox(content, extensions): + let uploadboxState = FormUploadBoxFeature.State( + id: index, + title: content.name, + description: content.description, + flag: content.flag, + allowedExtensions: extensions + ) + state.rows.append(.uploadBox(uploadboxState)) + } + } + + case let .internal(.formResponse(.failure(error))): + print(error) + state.isFormLoading = false + state.destination = .alert(.unknownError) + + case let .internal(.publishPost(flag: flag)): + state.isPublishing = true + switch state.type { + case .topic(forumId: let id, content: _), .post(type: .new, topicId: let id, content: .template): + return .run { [isTopic = state.type.isTopic, content = state.content] send in + let result = await Result { try await apiClient.sendTemplate(id: id, content: content, isTopic: isTopic) } + await send(.internal(.templateResponse(result))) + } + + case let .post(type: type, topicId: topicId, content: .simple(_, attachments)): + let editPostFlag = state.isShowMarkEnabled ? 4 : 0 + return .run { [ + content = state.content, + reason = state.editReasonText + ] send in + switch type { + case .new: + var newPostFlag = 0 + newPostFlag |= flag.rawValue + let request = PostRequest( + topicId: topicId, + content: content, + flag: newPostFlag, + attachments: attachments + ) + let result = await Result { try await apiClient.sendPost(request) } + await send(.internal(.simplePostResponse(result))) + + case let .edit(postId: postId): + let request = PostEditRequest( + postId: postId, + reason: reason, + data: PostRequest( + topicId: topicId, + content: content, + flag: editPostFlag, + attachments: attachments + ) + ) + let result = await Result { try await apiClient.editPost(request) } + await send(.internal(.simplePostResponse(result))) + } + } + + case let .report(id: id, type: type): + return .run { [content = state.content] send in + let request = ReportRequest(id: id, type: type.convert(), message: content) + let result = await Result { try await apiClient.sendReport(request) } + await send(.internal(.reportResponse(result))) + } + + default: + fatalError() + } + + case let .internal(.reportResponse(.success(report))): + return .send(.delegate(.formSent(.report(report)))) + + case let .internal(.reportResponse(.failure(error))): + state.isPublishing = false + state.destination = .alert(.unknownError) + analyticsClient.capture(error) + + case let .internal(.simplePostResponse(.success(.success(post)))): + return .send(.delegate(.formSent(.post(.success(post))))) + + case let .internal(.simplePostResponse(.success(.failure(errorStatus)))): + state.isPublishing = false + switch errorStatus { + case .premoderation: + state.destination = .alert(.postIsSentToPremoderation) + case .tooLong: + state.destination = .alert(.postIsTooLong) + case .alreadySent: + state.destination = .alert(.postIsAlreadySent) + case .attach: + state.destination = .alert(.attachToPreviousPost) + case .unknown: + state.destination = .alert(.unknownError) + } + + case let .internal(.simplePostResponse(.failure(error))): + state.isPublishing = false + state.destination = .alert(.unknownError) + analyticsClient.capture(error) + + case let .internal(.templateResponse(.success(result))): + return .send(.delegate(.formSent(.template(result)))) + + case let .internal(.templateResponse(.failure(error))): + state.isPublishing = false + #warning("add error") + } + + return .none + } + .ifLet(\.$destination, action: \.destination) + .forEach(\.rows, action: \.rows) { + FormFieldFeature() + } + } +} + +// MARK: - Alerts + +public extension AlertState where Action == FormFeature.Destination.Alert { + + nonisolated(unsafe) static let postIsSentToPremoderation = AlertState { + TextState("Post is sent to premoderation") + } actions: { + ButtonState(action: .dismiss) { + TextState("OK") + } + } + + nonisolated(unsafe) static let postIsTooLong = AlertState { + TextState("Post is too long") + } actions: { + ButtonState { + TextState("OK") + } + } + + nonisolated(unsafe) static let postIsAlreadySent = AlertState { + TextState("Post is already sent") + } actions: { + ButtonState { + TextState("OK") + } + } + + nonisolated(unsafe) static let attachToPreviousPost = AlertState { + TextState("Attach this post to previous one?") + } actions: { + ButtonState(action: .attach) { + TextState("Yes, attach") + } + ButtonState(action: .doNotAttach) { + TextState("No, no need") + } + } message: { + TextState("It will be attached as a dialog to your last post") + } + + nonisolated(unsafe) static let unknownError = AlertState { + TextState("Unknown error") + } actions: { + ButtonState { + TextState("OK") + } + } +} diff --git a/Modules/Sources/FormFeature/New/FormScreen.swift b/Modules/Sources/FormFeature/New/FormScreen.swift new file mode 100644 index 00000000..a676224b --- /dev/null +++ b/Modules/Sources/FormFeature/New/FormScreen.swift @@ -0,0 +1,226 @@ +// +// FormScreen.swift +// ForPDA +// +// Created by Ilia Lubianoi on 08.08.2025. +// + +import ComposableArchitecture +import Models +import SharedUI +import SwiftUI + +// MARK: - Form Screen + +@ViewAction(for: FormFeature.self) +public struct FormScreen: View { + + // MARK: - Properties + + @Perception.Bindable public var store: StoreOf + @FocusState private var focusedField: Int? + @Environment(\.tintColor) private var tintColor + + // MARK: - Init + + public init(store: StoreOf) { + self.store = store + } + + // MARK: - Body + + public var body: some View { + WithPerceptionTracking { + ScrollView(.vertical) { + VStack(spacing: 28) { + ForEach(store.scope(state: \.rows, action: \.rows)) { fieldStore in + FormFieldRow(store: fieldStore, focusedField: $focusedField) + } + + if store.rows.count == 1 && store.inPostEditingMode { + EditReasonView( + id: 1, + text: $store.editReasonText, + isEditingReasonEnabled: $store.isEditingReasonEnabled, + isShowMarkEnabled: $store.isShowMarkEnabled, + focusedField: $focusedField, + canShowShowMark: store.canShowShowMark + ) + } + } + .padding(.top, 16) + .padding(.horizontal, 16) + } + .scrollIndicators(.hidden) + .navigationTitle(Text(navigationTitleText(), bundle: .module)) + .navigationBarTitleDisplayMode(.inline) + .safeAreaInset(edge: .bottom) { + PublishButton() + } + .onTapGesture { + focusedField = nil + } + .overlay { + if store.rows.isEmpty || store.isFormLoading { + PDALoader() + .frame(width: 24, height: 24) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .toolbar { + Toolbar() + } + .background(Color(.Background.primary)) + .disabled(store.isPublishing) + .animation(.default, value: store.isPublishing) + .bind($store.focusedField, to: $focusedField) + .onAppear { + send(.onAppear) + } + } + } + + // MARK: - Publish Button + + @ViewBuilder + private func PublishButton() -> some View { + Button { + send(.publishButtonTapped) + } label: { + if store.isPublishing { + ProgressView() + .progressViewStyle(.circular) + .frame(maxWidth: .infinity) + .padding(8) + } else { + Text("Publish", bundle: .module) + .frame(maxWidth: .infinity) + .padding(8) + } + } + .buttonStyle(.borderedProminent) + .tint(tintColor) + .disabled(store.isPublishButtonDisabled) + .frame(height: 48) + .padding(.vertical, 8) + .padding(.horizontal, 16) + .background(Color(.Background.primary)) + } + + // MARK: - Toolbar + + @ToolbarContentBuilder + private func Toolbar() -> some ToolbarContent { + ToolbarItem(placement: .navigationBarLeading) { + Button { + send(.cancelButtonTapped) + } label: { + Text("Cancel", bundle: .module) + } + .tint(tintColor) + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button { + send(.previewButtonTapped) + } label: { + Image(systemSymbol: .eye) + .font(.body) + .frame(width: 34, height: 22) + } + .tint(tintColor) + .disabled(store.isPreviewButtonDisabled) + } + } + + // MARK: - Helpers + + private func navigationTitleText() -> LocalizedStringKey { + return switch store.type { + case let .post(type, _, _): + switch type { + case .new: "New post" + case .edit: "Edit post" + } + case .topic: "New topic" + case .report: "Send report" + } + } +} + +// MARK: - Previews + +#Preview("Form (Simple, New)") { + NavigationStack { + FormScreen( + store: Store( + initialState: FormFeature.State( + type: .post(type: .new, topicId: 0, content: .simple("", [])) + ) + ) { + FormFeature() + } + ) + } + .environment(\.tintColor, Color(.Theme.primary)) +} + +#Preview("Form (Simple, Edit)") { + let id = 0 + @Shared(.userSession) var userSession = UserSession.mock(userId: 0) + NavigationStack { + FormScreen( + store: Store( + initialState: FormFeature.State( + type: .post(type: .edit(postId: 0), topicId: 0, content: .simple("", [])) + ) + ) { + FormFeature() + } withDependencies: { + $0.cacheClient.getUser = { _ in + return User.mock(id: id, group: .admin) + } + } + ) + } + .environment(\.tintColor, Color(.Theme.primary)) +} + +#Preview("Form (Template)") { + NavigationStack { + FormScreen( + store: Store( + initialState: FormFeature.State( + type: .post(type: .new, topicId: 0, content: .template("")) + ) + ) { + FormFeature() + } withDependencies: { + $0.apiClient.getTemplate = { _, _ in + return [ + .mockTitle, + .mockText, + .mockEditor, + .mockUploadBox, + ] + } + } + ) + } + .environment(\.tintColor, Color(.Theme.primary)) +} + +#Preview("Form (Report, Post)") { + NavigationStack { + FormScreen( + store: Store( + initialState: FormFeature.State( + type: .report(id: 0, type: .post) + ) + ) { + FormFeature() + } + ) + } + .environment(\.tintColor, Color(.Theme.primary)) +} diff --git a/Modules/Sources/FormFeature/New/Support/FormNodeBuilder.swift b/Modules/Sources/FormFeature/New/Support/FormNodeBuilder.swift new file mode 100644 index 00000000..3aebdfb1 --- /dev/null +++ b/Modules/Sources/FormFeature/New/Support/FormNodeBuilder.swift @@ -0,0 +1,94 @@ +// +// FormNodeBuilder.swift +// FormFeature +// +// Created by Ilia Lubianoi on 20.07.2025. +// + +import BBBuilder +import SharedUI +import SwiftUI + +// MARK: - Node + +enum FormNode: Hashable { + case text(AttributedString) + case center([FormNode]) + case left([FormNode]) + case right([FormNode]) + case justify([FormNode]) +} + +// MARK: - Builder + +struct FormNodeBuilder { + + private let text: String + + init(text: String) { + self.text = text + } + + func build(isDescription: Bool = false) -> [FormNode] { + var text = text + if isDescription { + text = "[color=gray][size=1]\(text)[/size][/color]" + } + let nodes = BBBuilder.build(text: text) + return convert(nodes) + } + + private func convert(_ nodes: [BBContainerNode]) -> [FormNode] { + var elements: [FormNode] = [] + for node in nodes { + switch node { + case let .text(text): + elements.append(.text(AttributedString(text))) + + case let .center(nodes), let .left(nodes), let .right(nodes), let .justify(nodes): + let subElements = convert(nodes) + elements.append(contentsOf: subElements) + + default: + continue + } + } + return elements + } +} + +// MARK: - View + +struct FormNodeView: View { + + let node: FormNode + + var body: some View { + switch node { + case let .text(text): + RichText(text: text) + #warning("Обработать тапы на ссылки") + + case let .center(nodes), let .justify(nodes): + VStack(alignment: .center) { + ForEach(nodes, id: \.self) { node in + FormNodeView(node: node) + } + } + + case let .left(nodes): + VStack(alignment: .leading) { + ForEach(nodes, id: \.self) { node in + FormNodeView(node: node) + } + } + + case let .right(nodes): + VStack(alignment: .trailing) { + ForEach(nodes, id: \.self) { node in + FormNodeView(node: node) + } + } + } + } +} diff --git a/Modules/Sources/FormFeature/New/Support/FormType.swift b/Modules/Sources/FormFeature/New/Support/FormType.swift new file mode 100644 index 00000000..49361b86 --- /dev/null +++ b/Modules/Sources/FormFeature/New/Support/FormType.swift @@ -0,0 +1,63 @@ +// +// FormType.swift +// FormFeature +// +// Created by Ilia Lubianoi on 20.07.2025. +// + +import Models + +public enum FormType: Sendable, Equatable { + case post(type: PostType, topicId: Int, content: PostContentType) + case report(id: Int, type: ReportType) + case topic(forumId: Int, content: String) + + public enum PostType: Sendable, Equatable { + case new + case edit(postId: Int) + + @available(*, deprecated, message: "delete") + func convert() -> WriteFormForType.PostType { + switch self { + case .new: + return .new + case .edit(let postId): + return .edit(postId: postId) + } + } + } + + public enum PostContentType: Sendable, Equatable { + case simple(String, [Int]) + case template(String) + + @available(*, deprecated, message: "delete") + func convert() -> WriteFormForType.PostContentType { + switch self { + case .simple(let string, let array): + return .simple(string, array) + case .template(let string): + return .template(string) + } + } + } + + public enum ReportType: Sendable, Equatable { + case post + case comment + case reputation + + @available(*, deprecated, message: "delete") + func convert() -> Models.ReportType { + switch self { + case .post: return .post + case .comment: return .comment + case .reputation: return .reputation + } + } + } + + public var isTopic: Bool { + if case .topic = self { true } else { false } + } +} diff --git a/Modules/Sources/FormFeature/New/Views/CheckBox.swift b/Modules/Sources/FormFeature/New/Views/CheckBox.swift new file mode 100644 index 00000000..4573d9f4 --- /dev/null +++ b/Modules/Sources/FormFeature/New/Views/CheckBox.swift @@ -0,0 +1,35 @@ +// +// CheckBox.swift +// ForPDA +// +// Created by Ilia Lubianoi on 13.08.2025. +// + +import SwiftUI + +struct CheckBox: ToggleStyle { + func makeBody(configuration: Configuration) -> some View { + HStack { + Button(action: { + configuration.isOn.toggle() + }, label: { + if !configuration.isOn { + RoundedRectangle(cornerRadius: 6) + .stroke(Color(.Separator.secondary), lineWidth: 1.5) + .frame(width: 22, height: 22) + } else { + RoundedRectangle(cornerRadius: 6) + .frame(width: 22, height: 22) + .overlay { + Image(systemSymbol: .checkmark) + .font(.footnote) + .fontWeight(.semibold) + .foregroundStyle(Color(.white)) + } + } + }) + + configuration.label + } + } +} diff --git a/Modules/Sources/FormFeature/New/Views/EditReasonView.swift b/Modules/Sources/FormFeature/New/Views/EditReasonView.swift new file mode 100644 index 00000000..4863e200 --- /dev/null +++ b/Modules/Sources/FormFeature/New/Views/EditReasonView.swift @@ -0,0 +1,84 @@ +// +// EditReasonView.swift +// FormFeature +// +// Created by Ilia Lubianoi on 20.07.2025. +// + +import SwiftUI + +struct EditReasonView: View { + + // MARK: - Properties + + @Environment(\.tintColor) private var tintColor + + let id: Int + @Binding var text: String + @Binding var isEditingReasonEnabled: Bool + @Binding var isShowMarkEnabled: Bool + @FocusState.Binding var focusedField: Int? + let canShowShowMark: Bool + + // MARK: - Body + + var body: some View { + VStack { + HStack(spacing: 0) { + Text("Editing reason", bundle: .module) + .foregroundStyle(Color(.Labels.teritary)) + .font(.footnote) + .fontWeight(.semibold) + .frame(maxWidth: .infinity, alignment: .leading) + + Toggle(String(""), isOn: $isEditingReasonEnabled) + .labelsHidden() + .tint(tintColor) + } + .padding(.horizontal, 2) + + if isEditingReasonEnabled { + Field( + id: id, + text: $text, + placeholder: "Введите причину", + isEditor: true, + focusedField: $focusedField + ) + + if canShowShowMark { + Toggle(isOn: $isShowMarkEnabled) { + Text("Show mark", bundle: .module) + .font(.subheadline) + .foregroundStyle(Color(.Labels.secondary)) + .frame(maxWidth: .infinity, alignment: .leading) + } + .toggleStyle(CheckBox()) + .tint(tintColor) + .padding(6) + } + } + } + .animation(.default, value: isEditingReasonEnabled) + } +} + +// MARK: - Previews + +@available(iOS 17.0, *) +#Preview { + @Previewable @State var text = "" + @Previewable @State var isEditingReasonEnabled = false + @Previewable @State var isShowMarkEnabled = false + @Previewable @FocusState var focusedField: Int? + + EditReasonView( + id: 0, + text: $text, + isEditingReasonEnabled: $isEditingReasonEnabled, + isShowMarkEnabled: $isShowMarkEnabled, + focusedField: $focusedField, + canShowShowMark: true + ) + .padding(.horizontal, 16) +} diff --git a/Modules/Sources/FormFeature/New/Views/FieldNew.swift b/Modules/Sources/FormFeature/New/Views/FieldNew.swift new file mode 100644 index 00000000..d8f3d938 --- /dev/null +++ b/Modules/Sources/FormFeature/New/Views/FieldNew.swift @@ -0,0 +1,54 @@ +// +// Field.swift +// FormFeature +// +// Created by Ilia Lubianoi on 20.07.2025. +// + +import SwiftUI + +struct Field: View { + + // MARK: - Properties + + let id: Int + let text: Binding + let placeholder: String + var isEditor: Bool + + @FocusState.Binding var focusedField: Int? + + // MARK: - Body + + var body: some View { + VStack { + Group { + TextField(text: text, axis: .vertical) { + Text(placeholder) + .font(.body) + .foregroundStyle(Color(.quaternaryLabel)) + } + .focused($focusedField, equals: id) + .font(.body) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .foregroundStyle(Color(.Labels.primary)) + .frame(minHeight: isEditor ? 144 : nil, alignment: .top) + } + .padding(.vertical, 15) + .padding(.horizontal, 12) + .background { + RoundedRectangle(cornerRadius: 14) + .fill(Color(.Background.teritary)) + .onTapGesture { + focusedField = nil + } + } + .overlay { + RoundedRectangle(cornerRadius: 14) + .strokeBorder(Color(.Separator.primary)) + } + } + .animation(.default, value: false) + } +} diff --git a/Modules/Sources/WriteFormFeature/Preview/FormPreviewFeature.swift b/Modules/Sources/FormFeature/Preview/FormPreviewFeature.swift similarity index 100% rename from Modules/Sources/WriteFormFeature/Preview/FormPreviewFeature.swift rename to Modules/Sources/FormFeature/Preview/FormPreviewFeature.swift diff --git a/Modules/Sources/WriteFormFeature/Preview/FormPreviewView.swift b/Modules/Sources/FormFeature/Preview/FormPreviewView.swift similarity index 100% rename from Modules/Sources/WriteFormFeature/Preview/FormPreviewView.swift rename to Modules/Sources/FormFeature/Preview/FormPreviewView.swift diff --git a/Modules/Sources/WriteFormFeature/Resources/Localizable.xcstrings b/Modules/Sources/FormFeature/Resources/Localizable.xcstrings similarity index 91% rename from Modules/Sources/WriteFormFeature/Resources/Localizable.xcstrings rename to Modules/Sources/FormFeature/Resources/Localizable.xcstrings index 6e066d6d..08a2b94f 100644 --- a/Modules/Sources/WriteFormFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/FormFeature/Resources/Localizable.xcstrings @@ -1,9 +1,6 @@ { "sourceLanguage" : "en", "strings" : { - "" : { - - }, "Add more" : { "localizations" : { "ru" : { @@ -40,6 +37,26 @@ } } }, + "Choose from Files" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбрать из файлов" + } + } + } + }, + "Choose from Gallery" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбрать из галереи" + } + } + } + }, "Edit post" : { "localizations" : { "ru" : { @@ -138,22 +155,6 @@ } } }, - "Oops, error with loading title :(" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Oops, error with loading title :(" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ой, ошибка при загрузке заголовка :(" - } - } - } - }, "Post is already sent" : { "localizations" : { "ru" : { @@ -232,26 +233,6 @@ } } }, - "Select from Files" : { - "localizations" : { - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Выбрать из файлов" - } - } - } - }, - "Select from Gallery" : { - "localizations" : { - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Выбрать из галлереи" - } - } - } - }, "Send report" : { "localizations" : { "en" : { diff --git a/Modules/Sources/ForumFeature/ForumFeature.swift b/Modules/Sources/ForumFeature/ForumFeature.swift index 40014ba3..06388f6e 100644 --- a/Modules/Sources/ForumFeature/ForumFeature.swift +++ b/Modules/Sources/ForumFeature/ForumFeature.swift @@ -15,7 +15,7 @@ import PasteboardClient import PersistenceKeys import TCAExtensions import ToastClient -import WriteFormFeature +import FormFeature @Reducer public struct ForumFeature: Reducer, Sendable { @@ -28,7 +28,7 @@ public struct ForumFeature: Reducer, Sendable { public struct State: Equatable { @Shared(.appSettings) var appSettings: AppSettings @Shared(.userSession) var userSession: UserSession? - @Presents var writeForm: WriteFormFeature.State? + @Presents var writeForm: FormFeature.State? public var forumId: Int public var forumName: String? @@ -71,7 +71,7 @@ public struct ForumFeature: Reducer, Sendable { case contextTopicMenu(ForumTopicContextMenuAction, TopicInfo) case contextCommonMenu(ForumCommonContextMenuAction, Int, Bool) - case writeForm(PresentationAction) + case writeForm(PresentationAction) case pageNavigation(PageNavigationFeature.Action) @@ -116,7 +116,7 @@ public struct ForumFeature: Reducer, Sendable { case let .pageNavigation(.offsetChanged(to: newOffset)): return .send(._loadForum(offset: newOffset)) - case let .writeForm(.presented(.delegate(.writeFormSent(response)))): + case let .writeForm(.presented(.delegate(.formSent(response)))): if case let .template(status) = response { switch status { case .success(.topic(let id)): @@ -162,8 +162,8 @@ public struct ForumFeature: Reducer, Sendable { case .contextOptionMenu(let action): switch action { case .createTopic: - state.writeForm = WriteFormFeature.State( - formFor: .topic( + state.writeForm = FormFeature.State( + type: .topic( forumId: state.forumId, content: "" ) @@ -270,7 +270,7 @@ public struct ForumFeature: Reducer, Sendable { } } .ifLet(\.$writeForm, action: \.writeForm) { - WriteFormFeature() + FormFeature() } Analytics() diff --git a/Modules/Sources/ForumFeature/ForumScreen.swift b/Modules/Sources/ForumFeature/ForumScreen.swift index 26723bc4..7e6332c2 100644 --- a/Modules/Sources/ForumFeature/ForumScreen.swift +++ b/Modules/Sources/ForumFeature/ForumScreen.swift @@ -11,7 +11,7 @@ import PageNavigationFeature import SFSafeSymbols import SharedUI import Models -import WriteFormFeature +import FormFeature public struct ForumScreen: View { @@ -61,7 +61,7 @@ public struct ForumScreen: View { .navigationBarTitleDisplayMode(.large) .fullScreenCover(item: $store.scope(state: \.writeForm, action: \.writeForm)) { store in NavigationStack { - WriteFormScreen(store: store) + FormScreen(store: store) } } .toolbar { diff --git a/Modules/Sources/Models/Auth/UserSession.swift b/Modules/Sources/Models/Auth/UserSession.swift index a97c7a14..3cc41929 100644 --- a/Modules/Sources/Models/Auth/UserSession.swift +++ b/Modules/Sources/Models/Auth/UserSession.swift @@ -20,5 +20,17 @@ public struct UserSession: Sendable, Equatable, Codable { } public extension UserSession { - static let mock = UserSession(userId: 0, token: "", isHidden: false) + static let mock = mock() + + static func mock( + userId: Int = 0, + token: String = "", + isHidden: Bool = false + ) -> UserSession { + return UserSession( + userId: userId, + token: token, + isHidden: isHidden + ) + } } diff --git a/Modules/Sources/Models/Common/WriteFormFieldType.swift b/Modules/Sources/Models/Common/WriteFormFieldType.swift index dda77028..e0c2115d 100644 --- a/Modules/Sources/Models/Common/WriteFormFieldType.swift +++ b/Modules/Sources/Models/Common/WriteFormFieldType.swift @@ -47,15 +47,18 @@ public enum WriteFormFieldType: Sendable, Equatable, Hashable { } } +// MARK: - Mocks + public extension WriteFormFieldType { + static let mockTitle: WriteFormFieldType = - .title("[b]This is absolute simple title[/b]") + .title("This is an absolute [b]simple[/b] [i]title[/i]") static let mockText: WriteFormFieldType = .text( FormField( id: 0, name: "Topic name", - description: "Enter topic name.", + description: "Enter topic name", example: "Starting from For, ends with PDA", flag: 1, defaultValue: "" @@ -66,7 +69,7 @@ public extension WriteFormFieldType { FormField( id: 0, name: "Topic content", - description: "This field contains topic [color=red]hat[/color] content.", + description: "This [B]field[/B] contains topic [color=red]hat[/color] content", example: "ForPDA Forever!", flag: 1, defaultValue: "" @@ -83,4 +86,92 @@ public extension WriteFormFieldType { defaultValue: "" ) ) + + static let mockUploadBox: WriteFormFieldType = .uploadbox( + .init( + id: 0, + name: "Device photos", + description: "Upload device photos. Allowed formats JPG, GIF, PNG", + example: "", + flag: 1, + defaultValue: "" + ), + ["jpg", "gif", "png"] + ) +} + +extension Array where Element == WriteFormFieldType { + public static let releaser: [WriteFormFieldType] = [ + .title("[size=2][center][b][color=royalblue]Важно![/color][/b]\r\n[SIZE=1] [/SIZE]\r\nЕсли Вы используете инструмент впервые, просьба ознакомиться с темой [url=\"https://4pda.to/forum/index.php?showtopic=950823\"][b]Релизер[/b][/url], а также [url=\"https://4pda.to/forum/index.php?act=announce&f=212&st=250\"][b]Правилами раздела и FAQ по созданию и обновлению тем[/b][/url][/center][/size]\r\n"), + .title(""), + .dropdown( + .init( + id: 2, + name: "Тип обновления", + description: "Что публикуем?", + example: "", + flag: 1, + defaultValue: "" + ), + [ + "Новая версия", + "Beta", + "Модификация", + "Другое" + ] + ), + .text( + .init( + id: 3, + name: "Версия", + description: "Укажите версию. Например: 1.3.7", + example: "", + flag: 1, + defaultValue: "" + ) + ), + .text( + .init( + id: 4, + name: "Краткое описание", + description: "Здесь можно указать: [I][U]источник, дату публикации, архитектуру, авторство, номер сборки, тип модификации[/U][/I] и так далее.\r\n[COLOR=red][I]Не повторяйте тут версию или название программы! Здесь запрещены ВВ-коды и ссылки.[/I][/COLOR]\r\nПример 1: Для ARM64 от 01/02/2022 из F-Droid\r\nПример 2: AdFree от ModMaker", + example: "", + flag: 1, + defaultValue: "" + ) + ), + .editor( + .init( + id: 5, + name: "Описание", + description: "Введите дополнительную полезную информацию, например для:\r\n[b]\"Новая версия\"[/b] - список \"что нового\".\r\n[b]\"Модификация\"[/b] - \"на чем основано\", \"особенности\", \"обновлено\". ", + example: "", + flag: 3, + defaultValue: "" + ) + ), + .uploadbox( + .init( + id: 6, + name: "Файлы", + description: "", + example: "", + flag: 3, + defaultValue: "" + ), + [ + "apk", + "apks", + "exe", + "zip", + "rar", + "obb", + "7z", + "r00", + "r01", + "apkm", + "ipa" + ] + ) + ] } diff --git a/Modules/Sources/Models/Profile/User.swift b/Modules/Sources/Models/Profile/User.swift index a1ef7354..92717bbf 100644 --- a/Modules/Sources/Models/Profile/User.swift +++ b/Modules/Sources/Models/Profile/User.swift @@ -260,32 +260,34 @@ public extension User { // MARK: - Mock public extension User { - static let mock = User( - id: 0, - nickname: "Test Nickname", - imageUrl: Links.defaultAvatar, - group: .active, - status: "Just a status", - signature: "[b][color=blue]Developer[/color][/b]", - aboutMe: "A lot of text about me. A lot of text about me. A lot of text about me. A lot of text about me. A lot of text about me.", - registrationDate: Date(timeIntervalSince1970: 1168875045), - lastSeenDate: Date(timeIntervalSince1970: 1200000000), - birthdate: "01.01.2000", - gender: .male, - userTime: 10800, - city: "Moscow", - devDBdevices: [], - karma: 1500, - posts: 23, - comments: 173, - reputation: 78, - topics: 5, - replies: 82, - qmsMessages: nil, - forumDevices: nil, - email: "some@email.com", - achievements: [ - .init( + static let mock = mock() + + static func mock( + id: Int = 0, + nickname: String = "Test Nickname", + imageUrl: URL = Links.defaultAvatar, + group: Group = .active, + status: String? = "Just a status", + signature: String? = "[b][color=blue]Developer[/color][/b]", + aboutMe: String? = "A lot of text about me. A lot of text about me. A lot of text about me. A lot of text about me. A lot of text about me.", + registrationDate: Date = Date(timeIntervalSince1970: 1168875045), + lastSeenDate: Date = Date(timeIntervalSince1970: 1200000000), + birthdate: String? = "01.01.2000", + gender: Gender? = .male, + userTime: Int? = 10800, + city: String? = "Moscow", + devDBdevices: [Device] = [], + karma: Double = 1500, + posts: Int = 23, + comments: Int = 173, + reputation: Int = 78, + topics: Int = 5, + replies: Int = 82, + qmsMessages: Int? = nil, + forumDevices: [Device]? = nil, + email: String? = "some@email.com", + achievements: [Achievement] = [ + Achievement( name: "Призер Аллеи Славы", description: "Описание награды", count: 1, @@ -294,5 +296,32 @@ public extension User { presentationDate: .now ) ] - ) + ) -> User { + return User( + id: id, + nickname: nickname, + imageUrl: imageUrl, + group: group, + status: status, + signature: signature, + aboutMe: aboutMe, + registrationDate: registrationDate, + lastSeenDate: lastSeenDate, + birthdate: birthdate, + gender: gender, + userTime: userTime, + city: city, + devDBdevices: devDBdevices, + karma: karma, + posts: posts, + comments: comments, + reputation: reputation, + topics: topics, + replies: replies, + qmsMessages: qmsMessages, + forumDevices: forumDevices, + email: email, + achievements: achievements + ) + } } diff --git a/Modules/Sources/TopicFeature/TopicFeature.swift b/Modules/Sources/TopicFeature/TopicFeature.swift index bef7610c..ee750aff 100644 --- a/Modules/Sources/TopicFeature/TopicFeature.swift +++ b/Modules/Sources/TopicFeature/TopicFeature.swift @@ -15,7 +15,7 @@ import PersistenceKeys import ParsingClient import PasteboardClient import NotificationCenterClient -import WriteFormFeature +import FormFeature import TCAExtensions import AnalyticsClient import TopicBuilder @@ -33,7 +33,7 @@ public struct TopicFeature: Reducer, Sendable { @ReducerCaseIgnored case gallery([URL], [Int], Int) case editWarning - case writeForm(WriteFormFeature) + case form(FormFeature) } // MARK: - State @@ -169,9 +169,8 @@ public struct TopicFeature: Reducer, Sendable { .send(.internal(.loadTopic(newOffset))) ]) - case let .destination(.presented(.writeForm(.delegate(.writeFormSent(response))))): - if case let .post(data) = response, - case let .success(post) = data { + case let .destination(.presented(.form(.delegate(.formSent(response))))): + if case let .post(data) = response, case let .success(post) = data { return jumpTo(.post(id: post.id), true, &state) } return .none @@ -210,25 +209,25 @@ public struct TopicFeature: Reducer, Sendable { guard let topic = state.topic else { return .none } switch action { case .writePost: - let feature = WriteFormFeature.State( - formFor: .post( + let formState = FormFeature.State( + type: .post( type: .new, topicId: topic.id, content: .simple("", []) ) ) - state.destination = .writeForm(feature) + state.destination = .form(formState) return .none case .writePostWithTemplate: - let feature = WriteFormFeature.State( - formFor: .post( + let formState = FormFeature.State( + type: .post( type: .new, topicId: topic.id, content: .template("") ) ) - state.destination = .writeForm(feature) + state.destination = .form(formState) return .none case .openInBrowser: @@ -256,33 +255,33 @@ public struct TopicFeature: Reducer, Sendable { case let .view(.contextPostMenu(action)): switch action { - case .reply(let postId, let authorName): - let feature = WriteFormFeature.State( - formFor: .post( + case let .reply(postId, authorName): + let formState = FormFeature.State( + type: .post( type: .new, topicId: state.topicId, content: .simple("[SNAPBACK]\(postId)[/SNAPBACK] [B]\(authorName)[/B], ", []) ) ) - state.destination = .writeForm(feature) + state.destination = .form(formState) return .none - case .edit(let post): - let feature = WriteFormFeature.State( - formFor: .post( - type: .edit(postId: post.id), - topicId: state.topicId, - content: .simple(post.content, post.attachments.map { $0.id }) - ) - ) + case let .edit(post): if post.attachments.isEmpty { - state.destination = .writeForm(feature) + let formState = FormFeature.State( + type: .post( + type: .edit(postId: post.id), + topicId: state.topicId, + content: .simple(post.content, post.attachments.map { $0.id }) + ) + ) + state.destination = .form(formState) } else { state.destination = .editWarning } return .none - case .delete(let id): + case let .delete(id): return .concatenate( .run { _ in let status = try await apiClient.deletePosts(postIds: [id]) diff --git a/Modules/Sources/TopicFeature/TopicScreen.swift b/Modules/Sources/TopicFeature/TopicScreen.swift index 37f5df10..8f2c702c 100644 --- a/Modules/Sources/TopicFeature/TopicScreen.swift +++ b/Modules/Sources/TopicFeature/TopicScreen.swift @@ -8,7 +8,7 @@ import SwiftUI import ComposableArchitecture import PageNavigationFeature -import WriteFormFeature +import FormFeature import SFSafeSymbols import SharedUI import NukeUI @@ -371,9 +371,9 @@ struct NavigationModifier: ViewModifier { content .navigationTitle(Text(store.topic?.name ?? store.topicName ?? String(localized: "Loading...", bundle: .module))) .navigationBarTitleDisplayMode(.inline) - .fullScreenCover(item: $store.scope(state: \.destination?.writeForm, action: \.destination.writeForm)) { store in + .fullScreenCover(item: $store.scope(state: \.destination?.form, action: \.destination.form)) { store in NavigationStack { - WriteFormScreen(store: store) + FormScreen(store: store) } } .fullScreenCover(item: $store.scope(state: \.destination?.gallery, action: \.destination.gallery)) { store in @@ -526,10 +526,12 @@ private extension Date { initialState: TopicFeature.State( topicId: 0, topicName: "Test Topic", - destination: .writeForm( - WriteFormFeature.State( - formFor: .post( - type: .new, topicId: 0, content: .simple("Test Text", []) + destination: .form( + FormFeature.State( + type: .post( + type: .new, + topicId: 0, + content: .simple("Test Text", []) ) ) ) @@ -559,12 +561,14 @@ private extension Date { TopicScreen( store: Store( initialState: TopicFeature.State( - topicId: 0, + topicId: 0, topicName: "Test Topic", - destination: .writeForm( - WriteFormFeature.State( - formFor: .post( - type: .new, topicId: 0, content: .simple("Test Text", []) + destination: .form( + FormFeature.State( + type: .post( + type: .new, + topicId: 0, + content: .simple("Test Text", []) ) ) ) diff --git a/Modules/Sources/WriteFormFeature/Models/FormContentData.swift b/Modules/Sources/WriteFormFeature/Models/FormContentData.swift deleted file mode 100644 index 47e22754..00000000 --- a/Modules/Sources/WriteFormFeature/Models/FormContentData.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// FormContentData.swift -// ForPDA -// -// Created by Xialtal on 25.05.25. -// - -public enum FormContentData: Equatable { - case text(String) - case dropdown(Int, String) - case uploadbox([File]) // TODO: .. - case checkbox([Int: Bool]) - - public struct File: Equatable { - let id: Int - let filename: String - let uploadError: Bool - let isRemoved: Bool - let isUploading: Bool - } -} diff --git a/Modules/Sources/WriteFormFeature/Models/FormUploadEvent.swift b/Modules/Sources/WriteFormFeature/Models/FormUploadEvent.swift deleted file mode 100644 index 1e324af2..00000000 --- a/Modules/Sources/WriteFormFeature/Models/FormUploadEvent.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// FormUploadEvent.swift -// ForPDA -// -// Created by Xialtal on 19.07.25. -// - -public enum FormUploadEvent { - case uploaded - case removed - case uploading - case uploadError - case selectError -} diff --git a/Modules/Sources/WriteFormFeature/UploadBox/UploadBoxFeature.swift b/Modules/Sources/WriteFormFeature/UploadBox/UploadBoxFeature.swift deleted file mode 100644 index f0884ec4..00000000 --- a/Modules/Sources/WriteFormFeature/UploadBox/UploadBoxFeature.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// UploadBoxFeature.swift -// ForPDA -// -// Created by Xialtal on 19.07.25. -// - -import Foundation -import ComposableArchitecture -import APIClient -import Models - -@Reducer -public struct UploadBoxFeature: Reducer, Sendable { - public init() {} - - // MARK: - State - - @ObservableState - public struct State: Equatable, Sendable { - -// public init( -// formType: WriteFormForType -// ) { -// self.formType = formType -// } - } - - // MARK: - Action - - public enum Action { - case onAppear - - case cancelButtonTapped - } - - // MARK: - Dependencies - - @Dependency(\.apiClient) private var apiClient - @Dependency(\.dismiss) var dismiss - - // MARK: - Body - - public var body: some Reducer { - Reduce { state, action in - switch action { - case .onAppear: - return .none - - case .cancelButtonTapped: - return .none - } - } - } -} diff --git a/Modules/Sources/WriteFormFeature/UploadBox/UploadBoxView.swift b/Modules/Sources/WriteFormFeature/UploadBox/UploadBoxView.swift deleted file mode 100644 index f0898972..00000000 --- a/Modules/Sources/WriteFormFeature/UploadBox/UploadBoxView.swift +++ /dev/null @@ -1,298 +0,0 @@ -// -// UploadBoxView.swift -// ForPDA -// -// Created by Xialtal on 19.07.25. -// - -import SwiftUI -import ComposableArchitecture -import SharedUI -import Models -import PhotosUI - -public struct UploadBoxView: View { - private let content: WriteFormFieldType.FormField - private let allowedFileExtensions: [String] - - @State private var pickerItem: PhotosPickerItem? - - @State private var showFilePicker = false - @State private var showImagePicker = false - @State private var uploadOptionsShowing = false - - @State private var uploadboxFiles: [File] = [] - - private let onUploadFile: (_ id: Int, _ event: FormUploadEvent) -> Void - - public init( - _ content: WriteFormFieldType.FormField, - _ extensions: [String], - onUploadFile: @escaping (Int, FormUploadEvent) -> Void - ) { - self.content = content - self.allowedFileExtensions = extensions - self.onUploadFile = onUploadFile - } - - public var body: some View { - VStack(spacing: 6) { - HStack { - Header(title: content.name, required: content.isRequired) - - if !uploadboxFiles.isEmpty { - Button { - uploadOptionsShowing = true - } label: { - Label("Add more", systemSymbol: .plus) - .font(.footnote) - } - } - } - - if !uploadboxFiles.isEmpty { - ScrollView(.horizontal) { - HStack(spacing: 12) { - ForEach(uploadboxFiles) { file in - // TODO: Fix this... - file - //.frame(maxWidth: uploadboxFiles.count == 1 ? .infinity : 170) - } - } - } - } else { - Button { - uploadOptionsShowing = true - } label: { - VStack { - Image(systemSymbol: .docBadgePlus) - .font(.title) - .foregroundStyle(Color(.tintColor)) - .frame(width: 48, height: 48) - - Text("Select files...", bundle: .module) - .font(.body) - .foregroundColor(Color(.Labels.quaternary)) - } - .padding(.vertical, 15) - .padding(.horizontal, 12) - .frame(maxWidth: .infinity, minHeight: 144) - .background { - RoundedRectangle(cornerRadius: 14) - .fill(Color(.Background.teritary)) - } - .overlay { - RoundedRectangle(cornerRadius: 14) - .stroke( - Color(.tintColor), - style: StrokeStyle(lineWidth: 1, dash: [8]) - ) - } - } - } - - if !content.description.isEmpty { - DescriptionText(text: content.description) - } - } - .confirmationDialog("", isPresented: $uploadOptionsShowing, titleVisibility: .hidden) { - Button("Select from Gallery") { - showImagePicker = true - } - - Button("Select from Files") { - showFilePicker = true - } - } - .photosPicker(isPresented: $showImagePicker, selection: $pickerItem) - .fileImporter(isPresented: $showFilePicker, allowedContentTypes: [.item]) { result in - switch result { - case .success(let url): - let fileId = url.hashValue + .random(in: 1..<100) - uploadboxFiles.append(File( - id: fileId, - name: url.lastPathComponent, - type: .file, - isUploading: true, - onCancelButtonTapped: { - onUploadFile(fileId, .removed) - - //TODO: uploadboxFiles.remove(at: ...) - print("IMPLEMENT CANCELLATION!") - } - )) - onUploadFile(fileId, .uploading) - - case .failure(let error): - onUploadFile(0, .selectError) - } - } - .task(id: pickerItem) { - guard let image = try? await pickerItem?.loadTransferable(type: Image.self) else { - onUploadFile(0, .selectError) - return - } - let fileId = Int.random(in: 1..<100) - uploadboxFiles.append(File( - id: fileId, - name: "img-\(fileId)", - type: .image(image), - isUploading: false, - onCancelButtonTapped: { - onUploadFile(fileId, .removed) - - //TODO: uploadboxFiles.remove(at: ...) - print("IMPLEMENT CANCELLATION!") - } - )) - onUploadFile(fileId, .uploading) - // Drop "remembered" image. - pickerItem = nil - } - } - - // TODO: MAKE COMMON - - @ViewBuilder - private func DescriptionText(text: String) -> some View { - Text(text) - .font(.caption) - .foregroundStyle(Color(.Labels.teritary)) - .textCase(nil) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.leading, 16) - } - - // MARK: - Header - - @ViewBuilder - private func Header(title: String, required: Bool) -> some View { - HStack { - Text(title) - .font(.footnote) - .fontWeight(.semibold) - .foregroundStyle(Color(.Labels.teritary)) - .textCase(nil) - .overlay(alignment: .bottomTrailing) { - if required { - Text(verbatim: "*") - .font(.headline) - .offset(x: 8) - .foregroundStyle(.red) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - } -} - -// MARK: - File View - -enum FileType: Equatable { - case file - case image(Image) -} - -struct File: View, Identifiable { - let id: Int - let name: String - let type: FileType - let onCancelButtonTapped: () -> Void - - @State var isUploading: Bool - - init( - id: Int, - name: String, - type: FileType, - isUploading: Bool, - onCancelButtonTapped: @escaping () -> Void - ) { - self.id = id - self.name = name - self.type = type - self.isUploading = isUploading - self.onCancelButtonTapped = onCancelButtonTapped - } - - var body: some View { - VStack { - if !isUploading { - if type == .file { - Image(systemSymbol: .doc) - .font(.title) - .foregroundStyle(Color(.tintColor)) - .frame(width: 48, height: 48) - - Text(name) - .lineLimit(2) - .font(.footnote) - .multilineTextAlignment(.center) - .foregroundColor(Color(.Labels.primary)) - } - } else { - ProgressView() - } - } - .padding(.vertical, 15) - .padding(.horizontal, 12) - .frame(minWidth: 170, maxWidth: .infinity, minHeight: 144) - .background { - RoundedRectangle(cornerRadius: 14) - .fill(Color(.Background.teritary)) - .overlay { - if !isUploading, case .image(let image) = type { - image.resizable() - .aspectRatio(contentMode: .fill) - .frame(minWidth: 170, maxWidth: .infinity, maxHeight: 144) - .clipShape(RoundedRectangle(cornerRadius: 14)) - } - } - } - .overlay(alignment: .topTrailing) { - Button { - onCancelButtonTapped() - } label: { - Image(systemSymbol: .xmark) - .font(.body) - .foregroundStyle(type == .file ? Color(.Labels.teritary) : Color(.Labels.primaryInvariably)) - .frame(width: 30, height: 30) - .background( - Circle() - .fill(Color(.Background.quaternary)) - .clipShape(Circle()) - ) - } - .padding(10) - } - } -} - -// MARK: - File View Preview - -#Preview("File View") { - VStack { - ScrollView(.horizontal) { - HStack(spacing: 12) { - File( - id: 0, - name: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, se", - type: .file, - isUploading: false, - onCancelButtonTapped: {} - ) - - File( - id: 1, - name: "IMG", - type: .image(Image(.Settings.lightThemeExample)), - isUploading: false, - onCancelButtonTapped: {} - ) - } - } - - Color.white - } - .padding(.horizontal, 16) -} diff --git a/Modules/Sources/WriteFormFeature/WriteFormFeature.swift b/Modules/Sources/WriteFormFeature/WriteFormFeature.swift deleted file mode 100644 index 0a902095..00000000 --- a/Modules/Sources/WriteFormFeature/WriteFormFeature.swift +++ /dev/null @@ -1,522 +0,0 @@ -// -// WriteFormFeature.swift -// ForPDA -// -// Created by Xialtal on 14.03.25. -// - -import Foundation -import ComposableArchitecture -import APIClient -import Models - -@Reducer -public struct WriteFormFeature: Reducer, Sendable { - - public init() {} - - // MARK: - Helper Enums - - public enum PostSendFlag: Int, Sendable { - case `default` = 0 - case attach = 1 - case doNotAttach = 3 - } - - // MARK: - Destinations - - @Reducer(state: .equatable) - public enum Destination { - case preview(FormPreviewFeature) - case alert(AlertState) - - @CasePathable - public enum Alert { - case attach - case doNotAttach - case dismiss - } - } - - // MARK: - State - - @ObservableState - public struct State: Equatable { - @Presents public var destination: Destination.State? - - @Shared(.userSession) var userSession - - public let formFor: WriteFormForType - - var content: [Int: FormContentData] = [:] - - var isEditReasonToggleSelected = false - var editReasonContent = "" - var canShowShowMark = false - var isShowMarkToggleSelected = false - var inPostEditingMode: Bool { - if case let .post(type, _, _) = formFor, case .edit = type { - return true - } - return false - } - - var isSubmitDisabled: Bool { - !isFieldsValid(fields: formFields, content: content) - } - - var textContent: String { - if content.count == 1, case .text(let content) = content[0] { - content - } else { buildContent(fields: content) } - } - - var formFields: [WriteFormFieldType] = [] - - var isFormLoading = true - var isPublishing = false - - public init( - formFor: WriteFormForType - ) { - self.formFor = formFor - } - } - - // MARK: - Action - - public enum Action: BindableAction, ViewAction { - case binding(BindingAction) - case destination(PresentationAction) - - case view(View) - public enum View { - case onAppear - case updateContent(Int, FormContentData) - case publishButtonTapped - case dismissButtonTapped - case previewButtonTapped - } - - case `internal`(Internal) - public enum Internal { - case publishPost(flag: PostSendFlag) - case loadForm(id: Int, isTopic: Bool) - case formResponse(Result<[WriteFormFieldType], any Error>) - case simplePostResponse(Result) - case templateResponse(Result) - case reportResponse(Result) - } - - case delegate(Delegate) - public enum Delegate { - case writeFormSent(WriteFormSend) - } - } - - @Dependency(\.apiClient) private var apiClient - @Dependency(\.cacheClient) private var cacheClient - @Dependency(\.dismiss) var dismiss - - // MARK: - Body - - public var body: some Reducer { - BindingReducer() - - Reduce { state, action in - switch action { - case .view(.onAppear): - switch state.formFor { - case .topic(let forumId, _): - return .send(.internal(.loadForm(id: forumId, isTopic: true))) - - case .post(_, let topicId, let contentType): - if state.inPostEditingMode, - let userId = state.userSession?.userId, - let user = cacheClient.getUser(userId), - user.canSetShowMarkOnPostEdit { - state.canShowShowMark = true - } - switch contentType { - case .simple(let content, _): - state.content[0] = .text(content) - let response: [WriteFormFieldType] = [ - .editor( - .init( - id: 0, - name: "", - description: "", - example: "", - flag: 1, - defaultValue: content - ) - ) - ] - return .send(.internal(.formResponse(.success(response)))) - - case .template: - return .send(.internal(.loadForm(id: topicId, isTopic: false))) - } - - case .report: - let response: [WriteFormFieldType] = [ - .editor( - .init( - id: 0, - name: "", - description: "", - example: "", - flag: 1, - defaultValue: "" - ) - ) - ] - return .send(.internal(.formResponse(.success(response)))) - } - - case .view(.publishButtonTapped): - state.isPublishing = true - return .send(.internal(.publishPost(flag: .default))) - - case .view(.previewButtonTapped): - switch state.formFor { - case .topic(let forumId, _): - state.destination = .preview(FormPreviewFeature.State(formType: .topic(forumId: forumId, content: state.textContent))) - - case .post(let type, let topicId, let content): - let data = if case .simple(_, let attachments) = content { - WriteFormForType.PostContentType.simple(state.textContent, attachments) - } else { - WriteFormForType.PostContentType.template(state.textContent) - } - state.destination = .preview(FormPreviewFeature.State(formType: .post(type: type, topicId: topicId, content: data))) - - case .report: - state.destination = .preview(FormPreviewFeature.State(formType: .post(type: .new, topicId: 0, content: .simple(state.textContent, [])))) - } - return .none - - case .view(.dismissButtonTapped): - return .run { _ in await dismiss() } - - case let .internal(.publishPost(flag: postTypeFlag)): - switch state.formFor { - case .topic(let id, _), .post(type: .new, let id, content: .template(_)): - return .run { [isTopic = state.formFor.isTopic, content = state.textContent] send in - let result = await Result { try await apiClient.sendTemplate(id, content, isTopic) } - await send(.internal(.templateResponse(result))) - } - - case .post(let type, let topicId, content: .simple(_, let attachments)): - let editPostFlag = state.isShowMarkToggleSelected ? 4 : 0 - return .run { [editPostFlag, topicId, attachments, reason = state.editReasonContent, content = state.textContent] send in - switch type { - case .new: - var newPostFlag = 0 - newPostFlag |= postTypeFlag.rawValue - let request = PostRequest( - topicId: topicId, - content: content, - flag: newPostFlag, - attachments: attachments - ) - let result = await Result { try await apiClient.sendPost(request) } - await send(.internal(.simplePostResponse(result))) - - case .edit(let postId): - let request = PostEditRequest( - postId: postId, - reason: reason, - data: PostRequest( - topicId: topicId, - content: content, - flag: editPostFlag, - attachments: attachments - ) - ) - let result = await Result { try await apiClient.editPost(request) } - await send(.internal(.simplePostResponse(result))) - } - } - - case .report(let id, let type): - return .run { [content = state.textContent] send in - let request = ReportRequest(id: id, type: type, message: content) - let result = await Result { try await apiClient.sendReport(request) } - await send(.internal(.reportResponse(result))) - } - - default: - return .none - } - - case let .view(.updateContent(fieldId, data)): - switch data { - case .text(let content): - state.content[fieldId] = .text(content) - - case .dropdown(let id, let name): - state.content[fieldId] = .dropdown(id, name) - - case .uploadbox(let data): - // TODO: Implement - return .none - - case .checkbox(let data): - let new = if case .checkbox(let ndata) = state.content[fieldId] { - data.reduce(into: ndata) { result, entry in - result[entry.key] = entry.value - } - } else { data } - state.content[fieldId] = .checkbox(new) - } - return .none - - case let .internal(.loadForm(id, isTopic)): - return .run { send in - let result = await Result { try await apiClient.getTemplate(id, isTopic) } - await send(.internal(.formResponse(result))) - } catch: { error, send in - await send(.internal(.formResponse(.failure(error)))) - } - - case let .internal(.formResponse(.success(form))): - state.formFields = form - - state.isFormLoading = false - - for (key, field) in form.enumerated() { - switch field { - case .title: - state.content[key] = .text("") - - case .text(let content), .editor(let content): - state.content[content.id] = .text(content.defaultValue) - - case .checkboxList(let content, _): - state.content[content.id] = .checkbox([0: false]) - - case .dropdown(let content, let options): - state.content[content.id] = .dropdown(0, options[0]) - - case .uploadbox(let content, _): - // TODO: Implement file upload. - state.content[content.id] = .uploadbox([]) - } - } - - return .none - - case let .internal(.formResponse(.failure(error))): - print(error) - return .none - - case let .internal(.simplePostResponse(.success(.success(post)))): - return .send(.delegate(.writeFormSent(.post(.success(post))))) - - case let .internal(.simplePostResponse(.success(.failure(status)))): - switch status { - case .premoderation: - state.destination = .alert(.postIsSentToPremoderation) - case .tooLong: - state.destination = .alert(.postIsTooLong) - case .alreadySent: - state.destination = .alert(.postIsAlreadySent) - case .attach: - state.destination = .alert(.attachToPreviousPost) - case .unknown: - state.destination = .alert(.unknownError) - } - return .none - - case let .destination(.presented(.alert(action))): - let editorFlag: Int - switch action { - case .attach: - editorFlag = PostSendFlag.attach.rawValue - case .doNotAttach: - editorFlag = PostSendFlag.doNotAttach.rawValue - case .dismiss: - return .run { _ in await dismiss() } - } - - return .send(.internal(.publishPost(flag: PostSendFlag(rawValue: editorFlag)!))) - - case .destination(.dismiss): - state.isPublishing = false - return .none - - case let .internal(.templateResponse(.success(result))): - return .send(.delegate(.writeFormSent(.template(result)))) - - case let .internal(.templateResponse(.failure(error))): - state.isPublishing = false - print(error) - return .none - - case let .internal(.simplePostResponse(.success(response))): - switch response { - case let .success(post): - return .send(.delegate(.writeFormSent(.post(.success(PostSend(id: post.id, topicId: post.topicId, offset: post.offset)))))) - case let .failure(error): - // TODO: ??? - print(error) - } - - case let .internal(.simplePostResponse(.failure(error))): - state.isPublishing = false - print(error) - return .none - - case let .internal(.reportResponse(.success(result))): - return .send(.delegate(.writeFormSent(.report(result)))) - - case let .internal(.reportResponse(.failure(error))): - state.isPublishing = false - print(error) - return .none - - case .delegate(.writeFormSent(let result)): - // Not closing form if error. - switch result { - case .report(let status): - if status.isError { - return .none - } - - case .template(let status): - if status.isError { - return .none - } - - // TODO: handle. - case .post: - break - } - return .run { _ in await dismiss() } - - case .binding(\.isEditReasonToggleSelected): - if !state.isEditReasonToggleSelected { - state.editReasonContent = "" - state.isShowMarkToggleSelected = false - } - return .none - - case .binding, .destination: - return .none - } - } - .ifLet(\.$destination, action: \.destination) - } -} - - -// MARK: - Alert Extension - -extension AlertState where Action == WriteFormFeature.Destination.Alert { - - nonisolated(unsafe) static let postIsSentToPremoderation = AlertState { - TextState("Post is sent to premoderation") - } actions: { - ButtonState(action: .dismiss) { - TextState("OK") - } - } - - nonisolated(unsafe) static let postIsTooLong = AlertState { - TextState("Post is too long") - } actions: { - ButtonState { - TextState("OK") - } - } - - nonisolated(unsafe) static let postIsAlreadySent = AlertState { - TextState("Post is already sent") - } actions: { - ButtonState { - TextState("OK") - } - } - - nonisolated(unsafe) static let attachToPreviousPost = AlertState { - TextState("Attach this post to previous one?") - } actions: { - ButtonState(action: .attach) { - TextState("Yes, attach") - } - ButtonState(action: .doNotAttach) { - TextState("No, no need") - } - } message: { - TextState("It will be attached as a dialog to your last post") - } - - nonisolated(unsafe) static let unknownError = AlertState { - TextState("Unknown error") - } actions: { - ButtonState { - TextState("OK") - } - } -} - -// MARK: - Helpers - -private extension WriteFormFeature { - static func buildContent(fields: [Int: FormContentData]) -> String { - var request: [Any] = [] - for (_, field) in fields.sorted(by: { $0.0 < $1.0 }) { - switch field { - case .text(let content): - request.append(content) - - case .checkbox(let content): - request.append(content - .filter { $0.value == true } - .map { $0.key + 1 } ) - - case .dropdown(let id, _): - request.append(id + 1) - - case .uploadbox(_): - // TODO: Implement. - request.append([]) - } - } - return request.description - } - - static func isFieldsValid(fields: [WriteFormFieldType], content: [Int: FormContentData]) -> Bool { - for field in fields { - switch field { - case .title(_): continue - - case .text(let info), .editor(let info), .dropdown(let info, _), - .checkboxList(let info, _), .uploadbox(let info, _): - switch content[info.id] { - case .text(let data): - if info.isRequired && data.isEmpty { - return false - } - - case .uploadbox(let data): - if info.isRequired && data.isEmpty { - return false - } - - case .checkbox(let data): - if info.isRequired && data.isEmpty { - return false - } - - // always initialized with default value - case .dropdown: continue - - case .none: return false - } - } - } - return true - } -} diff --git a/Modules/Sources/WriteFormFeature/WriteFormScreen.swift b/Modules/Sources/WriteFormFeature/WriteFormScreen.swift deleted file mode 100644 index bf9e798a..00000000 --- a/Modules/Sources/WriteFormFeature/WriteFormScreen.swift +++ /dev/null @@ -1,245 +0,0 @@ -// -// WriteFormScreen.swift -// ForPDA -// -// Created by Xialtal on 14.03.25. -// - -import Foundation -import ComposableArchitecture -import SwiftUI -import Models -import SharedUI - -@ViewAction(for: WriteFormFeature.self) -public struct WriteFormScreen: View { - - @Perception.Bindable public var store: StoreOf - @Environment(\.tintColor) private var tintColor - - @State private var isPreviewPresented: Bool = false - @FocusState private var isFocused: Bool - - public init(store: StoreOf) { - self.store = store - } - - public var body: some View { - WithPerceptionTracking { - NavigationStack { - WriteForm() - .navigationTitle(Text(formTitle(), bundle: .module)) - .padding(.horizontal, 16) - .background(Color(.Background.primary)) - .navigationBarTitleDisplayMode(.inline) - .onTapGesture { - isFocused = false - } - .overlay { - if store.formFields.isEmpty || store.isFormLoading { - PDALoader() - .frame(width: 24, height: 24) - } - } - } - .disabled(store.isPublishing) - .animation(.default, value: store.isPublishing) - .animation(.default, value: store.isEditReasonToggleSelected) - .animation(.default, value: store.isShowMarkToggleSelected) - .alert($store.scope(state: \.destination?.alert, action: \.destination.alert)) - .sheet(item: $store.scope(state: \.destination?.preview, action: \.destination.preview)) { store in - NavigationStack { - FormPreviewView(store: store) - } - } - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button { - send(.dismissButtonTapped) - } label: { - Text("Cancel", bundle: .module) - } - .disabled(store.isPublishing) - } - - ToolbarItem(placement: .navigationBarTrailing) { - Button { - send(.previewButtonTapped) - } label: { - Image(systemSymbol: .eye) - .font(.body) - .frame(width: 34, height: 22) - } - .disabled(store.isSubmitDisabled) - .disabled(store.isPublishing) - } - } - .onAppear { - send(.onAppear) - } - } - } - - @ViewBuilder - private func WriteForm() -> some View { - ScrollView { - VStack { - ForEach(store.formFields.indices, id: \.self) { index in - VStack { - WriteFormView( - type: store.formFields[index], - isFocused: $isFocused, - onUpdateContent: { fieldId, data in - send(.updateContent(fieldId, data)) - }, - onFetchContent: { fieldId in - store.content[fieldId] ?? nil - } - ) - } - .padding(.top, 16) - } - - if store.inPostEditingMode { - EditReason() - } - } - } - - Spacer() - - Button { - send(.publishButtonTapped) - } label: { - if store.isPublishing { - ProgressView() - .progressViewStyle(.circular) - .id(UUID()) - .frame(maxWidth: .infinity) - .padding(8) - } else { - Text("Publish", bundle: .module) - .frame(maxWidth: .infinity) - .padding(8) - } - } - .buttonStyle(.borderedProminent) - .frame(height: 48) - .disabled(store.isSubmitDisabled) - .disabled(store.isPublishing) - - Spacer() - } - - @ViewBuilder - private func EditReason() -> some View { - VStack { - HStack(spacing: 0) { - Text("Editing reason", bundle: .module) - .foregroundStyle(Color(.Labels.teritary)) - .font(.footnote) - .fontWeight(.semibold) - .frame(maxWidth: .infinity, alignment: .leading) - - Toggle(String(""), isOn: $store.isEditReasonToggleSelected) - .labelsHidden() - } - .padding(.horizontal, 2) - - if store.isEditReasonToggleSelected { - Field(text: $store.editReasonContent, description: "", guideText: "", isFocused: $isFocused) - .disabled(store.isPublishing || !store.isEditReasonToggleSelected) - - if store.canShowShowMark { - Toggle(isOn: $store.isShowMarkToggleSelected) { - Text("Show mark", bundle: .module) - .font(.subheadline) - .foregroundStyle(Color(.Labels.secondary)) - .frame(maxWidth: .infinity, alignment: .leading) - } - .toggleStyle(CheckBox()) - .padding(6) - } - } - } - .padding(.top, 18) - } -} - -// MARK: - Helpers - -private extension WriteFormScreen { - - private func formTitle() -> LocalizedStringKey { - return switch store.formFor { - case let .post(type, _, _): - switch type { - case .new: - LocalizedStringKey("New post") - case .edit: - LocalizedStringKey("Edit post") - } - case .topic: - LocalizedStringKey("New topic") - case .report: - LocalizedStringKey("Send report") - } - } -} - -private extension String { - - func isEmptyAfterTrimming() -> Bool { - return self.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } -} - -// MARK: - Previews - -#Preview("New Post") { - NavigationStack { - WriteFormScreen( - store: Store( - initialState: WriteFormFeature.State( - formFor: .post(type: .new, topicId: 0, content: .simple("", [])) - ) - ) { - WriteFormFeature() - } withDependencies: { - $0.apiClient.sendPost = { _ in - try await Task.sleep(for: .seconds(3)) - return .success(PostSend(id: 0, topicId: 0, offset: 0)) - } - } - ) - .tint(Color(.Theme.primary)) - } -} - -#Preview("Edit Post") { - NavigationStack { - WriteFormScreen( - store: Store( - initialState: WriteFormFeature.State( - formFor: .post( - type: .edit(postId: 0), - topicId: 0, - content: .simple("Some text", []) - ) - ) - ) { - WriteFormFeature() - } withDependencies: { - $0.apiClient.sendPost = { _ in - try await Task.sleep(for: .seconds(3)) - return .success(PostSend(id: 0, topicId: 0, offset: 0)) - } - } - ) - .tint(Color(.Theme.primary)) - } -} - -#Preview("Failure statuses") { - Text("Failure statuses located in TopicScreen") -} diff --git a/Modules/Sources/WriteFormFeature/WriteFormView.swift b/Modules/Sources/WriteFormFeature/WriteFormView.swift deleted file mode 100644 index 001f9c7a..00000000 --- a/Modules/Sources/WriteFormFeature/WriteFormView.swift +++ /dev/null @@ -1,490 +0,0 @@ -// -// WriteFormView.swift -// ForPDA -// -// Created by Xialtal on 14.03.25. -// - -import SwiftUI -import ComposableArchitecture -import SharedUI -import Models -import BBBuilder - -struct WriteFormView: View { - - let type: WriteFormFieldType - @FocusState.Binding var isFocused: Bool - - let onUpdateContent: (_ fieldId: Int, _ data: FormContentData) -> Void - let onFetchContent: (_ fieldId: Int) -> FormContentData? - - var body: some View { - switch type { - case .uploadbox(let content, let extensions): - UploadBoxView(content, extensions, onUploadFile: { id, event in - switch event { - case .uploaded: - print("uploaded") - case .uploading: - print("UPLOADING") - case .uploadError: - print("uploadError") - case .selectError: - print("selectError") - case .removed: - print("removed") - } - }) - - case .text(let content): - Section { - Field( - text: Binding( - get: { getTextFieldContent(fieldId: content.id) }, - set: { onUpdateContent(content.id, .text($0)) } - ), - description: content.description, - guideText: content.example, - isFocused: $isFocused - ) - } header: { - Header(title: content.name, required: content.isRequired) - } - - case .title(let content): - VStack(spacing: 6) { - let nodes = BBBuilder.build(text: content, attachments: []) - if case let .text(text) = nodes.first { - RichText(text: text) - } else { - Text("Oops, error with loading title :(", bundle: .module) - .font(.subheadline) - .foregroundStyle(Color(.Labels.primary)) - } - } - .frame(maxWidth: .infinity) - .padding(.vertical, 10) - .padding(.horizontal, 12) - .background { - RoundedRectangle(cornerRadius: 14) - .fill(Color(.Background.teritary)) - } - - case .editor(let content): - Section { - Field( - text: Binding( - get: { getTextFieldContent(fieldId: content.id) }, - set: { onUpdateContent(content.id, .text($0)) } - ), - description: content.description, - guideText: content.example, - isEditor: true, - isFocused: $isFocused - ) - } header: { - if !content.name.isEmpty { - Header(title: content.name, required: content.isRequired) - } - } - - case .dropdown(let content, let options): - Section { - VStack { - HStack { - Menu { - ForEach(options.indices, id: \.hashValue) { index in - Button { - onUpdateContent(content.id, .dropdown(index, options[index])) - } label: { Text(options[index]) } - } - } label: { - HStack { - Text(getDropdownSelectedName(fieldId: content.id)) - .foregroundStyle(Color(.Labels.primary)) - .padding(.leading, 16) - - Spacer() - - Image(systemSymbol: .chevronUpChevronDown) - .foregroundStyle(Color(.Labels.teritary)) - .padding(.trailing, 11) - } - .padding(.vertical, 15) - .background(Color(.Background.teritary)) - .cornerRadius(14) - .overlay { - RoundedRectangle(cornerRadius: 14) - .stroke(Color(.Separator.primary), lineWidth: 1) - } - } - } - .listRowBackground(Color(.Background.teritary)) - - if !content.description.isEmpty { - DescriptionText(text: content.description) - } - } - } header: { - Header(title: content.name, required: content.isRequired) - } - - case .checkboxList(let content, let options): - Section { - VStack(spacing: 6) { - ForEach(options.indices, id: \.hashValue) { index in - Toggle(isOn: Binding( - get: { isCheckBoxSelected(fieldId: content.id, checkboxId: index) }, - set: { isSelected in - onUpdateContent(content.id, .checkbox([index: isSelected])) - } - )) { - Text(options[index]) - .font(.subheadline) - .frame(maxWidth: .infinity, alignment: .leading) - } - .toggleStyle(CheckBox()) - .padding(6) - } - } - .padding(.vertical, 10) - .padding(.horizontal, 12) - .frame(maxWidth: .infinity, alignment: .leading) - .background { - RoundedRectangle(cornerRadius: 14) - .fill(Color(.Background.teritary)) - } - - if !content.description.isEmpty { - DescriptionText(text: content.description) - } - } header: { - Header(title: content.name, required: content.isRequired) - } - } - } - - @ViewBuilder - private func DescriptionText(text: String) -> some View { - Text(text) - .font(.caption) - .foregroundStyle(Color(.Labels.teritary)) - .textCase(nil) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.leading, 16) - } - - // MARK: - Header - - @ViewBuilder - private func Header(title: String, required: Bool) -> some View { - HStack { - Text(title) - .font(.footnote) - .fontWeight(.semibold) - .foregroundStyle(Color(.Labels.teritary)) - .textCase(nil) - .overlay(alignment: .bottomTrailing) { - if required { - Text(verbatim: "*") - .font(.headline) - .offset(x: 8) - .foregroundStyle(.red) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - } -} - -// MARK: - Helpers - -private extension WriteFormView { - - func getTextFieldContent(fieldId: Int) -> String { - return if case .text(let content) = onFetchContent(fieldId) { - content - } else { "" } - } - - func getDropdownSelectedName(fieldId: Int) -> String { - return if case .dropdown(_, let name) = onFetchContent(fieldId) { - name - } else { "" } - } - - func getUploadBoxFiles(fieldId: Int) -> [FormContentData.File] { - return if case .uploadbox(let files) = onFetchContent(fieldId) { - files - } else { [] } - } - - func isCheckBoxSelected(fieldId: Int, checkboxId: Int) -> Bool { - return if case .checkbox(let data) = onFetchContent(fieldId) { - data[checkboxId] ?? false - } else { false } - } -} - -// MARK: - CheckBox Toggle Style - -struct CheckBox: ToggleStyle { - func makeBody(configuration: Configuration) -> some View { - HStack { - Button(action: { - configuration.isOn.toggle() - }, label: { - if !configuration.isOn { - RoundedRectangle(cornerRadius: 6) - .stroke(Color(.Separator.secondary), lineWidth: 1.5) - .frame(width: 22, height: 22) - } else { - RoundedRectangle(cornerRadius: 6) - .frame(width: 22, height: 22) - .overlay { - Image(systemSymbol: .checkmark) - .font(.footnote) - .fontWeight(.semibold) - .foregroundStyle(Color(.white)) - } - } - }) - - configuration.label - } - } -} - -// MARK: - Field View - -struct Field: View { - - let text: Binding - let description: String - let guideText: String - var isEditor = false - - @FocusState.Binding var isFocused: Bool - - var body: some View { - VStack { - Group { - TextField(text: text, axis: .vertical) { - Text(guideText) - .font(.body) - .foregroundStyle(Color(.quaternaryLabel)) - } - .focused($isFocused) - .font(.body) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - .foregroundStyle(Color(.Labels.primary)) - .frame(minHeight: isEditor ? 144 : nil, alignment: .top) - } - .padding(.vertical, 15) - .padding(.horizontal, 12) - .background { - RoundedRectangle(cornerRadius: 14) - .fill(Color(.Background.teritary)) - .onTapGesture { - isFocused = true - } - } - .overlay { - RoundedRectangle(cornerRadius: 14) - .strokeBorder(Color(.Separator.primary)) - } - - if !description.isEmpty { - Text(description) - .font(.caption) - .foregroundStyle(Color(.Labels.teritary)) - .textCase(nil) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.leading, 16) - } - } - .animation(.default, value: false) - .onAppear { - isFocused = true - } - } -} - -// MARK: - Field View Preview - -@available(iOS 17, *) -#Preview("Field View") { - @Previewable @FocusState var isFocused: Bool - VStack { - Spacer() - - Field( - text: Binding( get: { "" }, set: { _ in } ), - description: "Some basic description$", - guideText: "Some guide text", - isFocused: $isFocused - ) - .bounceUpByLayerEffect(value: false) - - Color.white - } - .padding(.horizontal, 16) -} - -// MARK: - Write Form Text Preview - -@available(iOS 17, *) -#Preview("Write Form Text Preview") { - @Previewable @FocusState var isFocused: Bool - VStack { - WriteFormView( - type: .text( - .init( - id: 0, - name: "Topic name", - description: "Set the topic name with some logic.", - example: "Example: How I can do not love ForPDA?", - flag: 1, - defaultValue: "" - ) - ), - isFocused: $isFocused, - onUpdateContent: { _, _ in }, - onFetchContent: { _ in .text("Some basic text") } - ) - - Color.white - } - .padding(.horizontal, 16) -} - -// MARK: - Write Form Title Preview - -@available(iOS 17, *) -#Preview("Write Form Title Preview") { - @Previewable @FocusState var isFocused: Bool - VStack { - WriteFormView( - type: .title("[b]Absolute simple.[/b]"), - isFocused: $isFocused, - onUpdateContent: { _, _ in }, - onFetchContent: { _ in nil } - ) - - Color.white - } - .padding(.horizontal, 16) -} - -// MARK: - Write Form Editor Preview - -@available(iOS 17, *) -#Preview("Write Form Editor Preview") { - @Previewable @FocusState var isFocused: Bool - VStack { - WriteFormView( - type: .editor( - .init( - id: 0, - name: "Topic name", - description: "Set the topic name with some logic.", - example: "Example: How I can do not love ForPDA?", - flag: 1, - defaultValue: "" - ) - ), - isFocused: $isFocused, - onUpdateContent: { _, _ in }, - onFetchContent: { _ in .text("Some editor text") } - ) - - Color.white - } - .padding(.horizontal, 16) -} - -// MARK: - Write Form Dropdown Preview - -@available(iOS 17, *) -#Preview("Write Form Dropdown Preview") { - @Previewable @FocusState var isFocused: Bool - VStack { - WriteFormView( - type: .dropdown( - .init( - id: 0, - name: "Device type", - description: "Select device type.", - example: "Example: Phone", - flag: 1, - defaultValue: "" - ), - ["Phone", "SmartWatch"] - ), - isFocused: $isFocused, - onUpdateContent: { _, _ in }, - onFetchContent: { _ in .dropdown(0, "Phone") } - ) - - Color.white - } - .padding(.horizontal, 16) -} - -// MARK: - Write Form CheckBox Preview - -@available(iOS 17, *) -#Preview("Write Form CheckBox Preview") { - @Previewable @FocusState var isFocused: Bool - VStack { - WriteFormView( - type: .checkboxList( - .init( - id: 0, - name: "", - description: "", - example: "", - flag: 1, - defaultValue: "" - ), - ["I accept all"] - ), - isFocused: $isFocused, - onUpdateContent: { _, _ in }, - onFetchContent: { _ in .checkbox([0: true]) } - ) - - Color.white - } - .padding(.horizontal, 16) -} - -// MARK: - Write Form UploadBox Preview - -@available(iOS 17, *) -#Preview("Write Form UploadBox Preview") { - @Previewable @FocusState var isFocused: Bool - VStack { - WriteFormView( - type: .uploadbox( - .init( - id: 0, - name: "Device photos", - description: "Upload device photos. Allowed formats JPG, GIF, PNG", - example: "", - flag: 1, - defaultValue: "" - ), - ["jpg", "gif", "png"] - ), - isFocused: $isFocused, - onUpdateContent: { _, _ in }, - onFetchContent: { _ in .uploadbox([]) } - ) - - Color.white - } - .padding(.horizontal, 16) -} diff --git a/Modules/Tests/FormFeatureTests/WriteFormFeatureTests.swift b/Modules/Tests/FormFeatureTests/WriteFormFeatureTests.swift new file mode 100644 index 00000000..fcf8badf --- /dev/null +++ b/Modules/Tests/FormFeatureTests/WriteFormFeatureTests.swift @@ -0,0 +1,508 @@ +// +// WriteFormFeatureTests.swift +// ForPDA +// +// Created by Ilia Lubianoi on 08.08.2025. +// + +import APIClient +import ComposableArchitecture +import Foundation +import Models +import Testing +import FormFeature + +@MainActor +struct WriteFormFeatureTests { + + // MARK: - Report Success + + @Test func reportSuccess() async throws { + let store = TestStore( + initialState: FormFeature.State(type: .report(id: 0, type: .comment)) + ) { + FormFeature() + } withDependencies: { + $0.apiClient.sendReport = { _ in + return .success + } + } + + var editorState = FormEditorFeature.State(id: 0, flag: 1) + await store.send(.view(.onAppear)) { + $0.rows = [.editor(editorState)] + $0.focusedField = 0 + } + + #expect(store.state.isPublishButtonDisabled) + + await store.send(.rows(.element(id: 0, action: .editor(.binding(.set(\.text, "text")))))) { + editorState.text = "text" + $0.rows[id: 0] = .editor(editorState) + } + + #expect(!store.state.isPublishButtonDisabled) + + await store.send(.view(.publishButtonTapped)) + + await store.receive(\.internal.publishPost) { + $0.isPublishing = true + } + + await store.receive(\.internal.reportResponse) + + await store.receive(\.delegate.formSent) + } + + // MARK: - Report Network Failure + + @Test func reportNetworkFailure() async throws { + let store = TestStore( + initialState: FormFeature.State(type: .report(id: 0, type: .comment)) + ) { + FormFeature() + } withDependencies: { + $0.apiClient.sendReport = { _ in + throw NSError(domain: "network", code: 0) + } + } + + var editorState = FormEditorFeature.State(id: 0, flag: 1) + await store.send(.view(.onAppear)) { + $0.rows = [.editor(editorState)] + $0.focusedField = 0 + } + + #expect(store.state.isPublishButtonDisabled) + + await store.send(.rows(.element(id: 0, action: .editor(.binding(.set(\.text, "text")))))) { + editorState.text = "text" + $0.rows[id: 0] = .editor(editorState) + } + + #expect(!store.state.isPublishButtonDisabled) + + await store.send(.view(.publishButtonTapped)) + + await store.receive(\.internal.publishPost) { + $0.isPublishing = true + } + + await store.receive(\.internal.reportResponse) { + $0.isPublishing = false + $0.destination = .alert(.unknownError) + } + } + + // MARK: - New Post Success + + @Test func newPostSuccess() async throws { + let store = TestStore( + initialState: FormFeature.State( + type: .post( + type: .new, + topicId: 0, + content: .simple("", []) + ) + ) + ) { + FormFeature() + } withDependencies: { + $0.apiClient.sendPost = { _ in + return .success(PostSend(id: 0, topicId: 0, offset: 0)) + } + } + + var editorState = FormEditorFeature.State(id: 0, flag: 1, defaultText: "") + await store.send(.view(.onAppear)) { + $0.rows = [.editor(editorState)] + $0.focusedField = 0 + } + + #expect(store.state.isPublishButtonDisabled) + + await store.send(.rows(.element(id: 0, action: .editor(.binding(.set(\.text, "text")))))) { + editorState.text = "text" + $0.rows[id: 0] = .editor(editorState) + } + + #expect(!store.state.isPublishButtonDisabled) + + await store.send(.view(.publishButtonTapped)) + + await store.receive(\.internal.publishPost) { + $0.isPublishing = true + } + + await store.receive(\.internal.simplePostResponse) + + await store.receive(\.delegate.formSent) + } + + // MARK: - New Post Error Status + + @Test func newPostErrorStatus() async throws { + let store = TestStore( + initialState: FormFeature.State( + type: .post( + type: .new, + topicId: 0, + content: .simple("", []) + ) + ) + ) { + FormFeature() + } withDependencies: { + $0.apiClient.sendPost = { _ in + return .failure(.unknown) + } + } + + var editorState = FormEditorFeature.State(id: 0, flag: 1, defaultText: "") + await store.send(.view(.onAppear)) { + $0.rows = [.editor(editorState)] + $0.focusedField = 0 + } + + #expect(store.state.isPublishButtonDisabled) + + await store.send(.rows(.element(id: 0, action: .editor(.binding(.set(\.text, "text")))))) { + editorState.text = "text" + $0.rows[id: 0] = .editor(editorState) + } + + #expect(!store.state.isPublishButtonDisabled) + + await store.send(.view(.publishButtonTapped)) + + await store.receive(\.internal.publishPost) { + $0.isPublishing = true + } + + await store.receive(\.internal.simplePostResponse) { + $0.isPublishing = false + $0.destination = .alert(.unknownError) + } + } + + // MARK: - New Post Attach Status + + @Test func newPostAttachStatus() async throws { + let store = TestStore( + initialState: FormFeature.State( + type: .post( + type: .new, + topicId: 0, + content: .simple("", []) + ) + ) + ) { + FormFeature() + } withDependencies: { + $0.apiClient.sendPost = { request in + if request.flag == 0 { + return .failure(.attach) + } else { + return .success(PostSend(id: 0, topicId: 0, offset: 0)) + } + } + } + + var editorState = FormEditorFeature.State(id: 0, flag: 1, defaultText: "") + await store.send(.view(.onAppear)) { + $0.rows = [.editor(editorState)] + $0.focusedField = 0 + } + + #expect(store.state.isPublishButtonDisabled) + + await store.send(.rows(.element(id: 0, action: .editor(.binding(.set(\.text, "text")))))) { + editorState.text = "text" + $0.rows[id: 0] = .editor(editorState) + } + + #expect(!store.state.isPublishButtonDisabled) + + await store.send(.view(.publishButtonTapped)) + + await store.receive(\.internal.publishPost) { + $0.isPublishing = true + } + + await store.receive(\.internal.simplePostResponse) { + $0.isPublishing = false + $0.destination = .alert(.attachToPreviousPost) + } + + await store.send(.destination(.presented(.alert(.attach)))) { + $0.destination = nil + } + + await store.receive(\.internal.publishPost) { + $0.isPublishing = true + } + + await store.receive(\.internal.simplePostResponse) + + await store.receive(\.delegate.formSent) + } + + // MARK: - New Post Network Failure + + @Test func newPostNetworkFailure() async throws { + let store = TestStore( + initialState: FormFeature.State( + type: .post( + type: .new, + topicId: 0, + content: .simple("", []) + ) + ) + ) { + FormFeature() + } withDependencies: { + $0.apiClient.sendPost = { _ in + throw NSError(domain: "network", code: 0) + } + } + + var editorState = FormEditorFeature.State(id: 0, flag: 1, defaultText: "") + await store.send(.view(.onAppear)) { + $0.rows = [.editor(editorState)] + $0.focusedField = 0 + } + + #expect(store.state.isPublishButtonDisabled) + + await store.send(.rows(.element(id: 0, action: .editor(.binding(.set(\.text, "text")))))) { + editorState.text = "text" + $0.rows[id: 0] = .editor(editorState) + } + + #expect(!store.state.isPublishButtonDisabled) + + await store.send(.view(.publishButtonTapped)) + + await store.receive(\.internal.publishPost) { + $0.isPublishing = true + } + + await store.receive(\.internal.simplePostResponse) { + $0.isPublishing = false + $0.destination = .alert(.unknownError) + } + } + + // MARK: - Edit Post + + @Test func editPost() async throws { + let store = TestStore( + initialState: FormFeature.State( + type: .post( + type: .edit(postId: 0), + topicId: 0, + content: .simple("some text", []) + ) + ) + ) { + FormFeature() + } withDependencies: { + $0.apiClient.editPost = { _ in + return .success(PostSend(id: 0, topicId: 0, offset: 0)) + } + } + + let editorState = FormEditorFeature.State(id: 0, flag: 1, defaultText: "some text") + await store.send(.view(.onAppear)) { + $0.rows = [.editor(editorState)] + $0.focusedField = 0 + } + + #expect(store.state.inPostEditingMode) + #expect(!store.state.isPublishButtonDisabled) + + await store.send(.binding(.set(\.isEditingReasonEnabled, true))) { + $0.isEditingReasonEnabled = true + } + await store.send(.binding(.set(\.editReasonText, "reason"))) { + $0.editReasonText = "reason" + } + + await store.send(.view(.publishButtonTapped)) + + await store.receive(\.internal.publishPost) { + $0.isPublishing = true + } + await store.receive(\.internal.simplePostResponse) + await store.receive(\.delegate.formSent) + } + + // MARK: - Template Success + + @Test func templateSuccess() async throws { + let store = TestStore( + initialState: FormFeature.State( + type: .post( + type: .new, + topicId: 0, + content: .template("") + ) + ) + ) { + FormFeature() + } withDependencies: { + $0.apiClient.getTemplate = { _, _ in + return .releaser + } + $0.apiClient.sendTemplate = { _, _, _ in + return .success(.post(.init(id: 0, topicId: 0, offset: 0))) + } + } + + let title = FormTitleFeature.State(id: 0, text: "[size=2][center][b][color=royalblue]Важно![/color][/b]\r\n[SIZE=1] [/SIZE]\r\nЕсли Вы используете инструмент впервые, просьба ознакомиться с темой [url=\"https://4pda.to/forum/index.php?showtopic=950823\"][b]Релизер[/b][/url], а также [url=\"https://4pda.to/forum/index.php?act=announce&f=212&st=250\"][b]Правилами раздела и FAQ по созданию и обновлению тем[/b][/url][/center][/size]\r\n") + var dropdown = FormDropdownFeature.State( + id: 2, + title: "Тип обновления", + description: "Что публикуем?", + flag: 1, + options: [ + "Новая версия", + "Beta", + "Модификация", + "Другое" + ] + ) + var text1 = FormTextFieldFeature.State( + id: 3, + title: "Версия", + description: "Укажите версию. Например: 1.3.7", + placeholder: "", + flag: 1, + defaultText: "" + ) + var text2 = FormTextFieldFeature.State( + id: 4, + title: "Краткое описание", + description: "Здесь можно указать: [I][U]источник, дату публикации, архитектуру, авторство, номер сборки, тип модификации[/U][/I] и так далее.\r\n[COLOR=red][I]Не повторяйте тут версию или название программы! Здесь запрещены ВВ-коды и ссылки.[/I][/COLOR]\r\nПример 1: Для ARM64 от 01/02/2022 из F-Droid\r\nПример 2: AdFree от ModMaker", + placeholder: "", + flag: 1, + defaultText: "" + ) + var editor = FormEditorFeature.State( + id: 5, + title: "Описание", + description: "Введите дополнительную полезную информацию, например для:\r\n[b]\"Новая версия\"[/b] - список \"что нового\".\r\n[b]\"Модификация\"[/b] - \"на чем основано\", \"особенности\", \"обновлено\". ", + placeholder: "", + flag: 3, + defaultText: "" + ) + var uploadbox = FormUploadBoxFeature.State( + id: 6, + title: "Файлы", + description: "", + flag: 3, + allowedExtensions: ["apk", "apks", "exe", "zip", "rar", "obb", "7z", "r00", "r01", "apkm", "ipa"] + ) + + await store.send(.view(.onAppear)) { + $0.isFormLoading = true + } + + await store.receive(\.internal.loadForm) + + await store.receive(\.internal.formResponse) { + $0.isFormLoading = false + $0.rows = [ + .title(title), + .dropdown(dropdown), + .textField(text1), + .textField(text2), + .editor(editor), + .uploadBox(uploadbox) + ] + } + + await store.send(.rows(.element(id: 2, action: .dropdown(.view(.menuOptionSelected("Beta")))))) { + dropdown.selectedOption = "Beta" + $0.rows[id: 2] = .dropdown(dropdown) + } + + await store.send(.rows(.element(id: 3, action: .textField(.binding(.set(\.text, "1.0.0")))))) { + text1.text = "1.0.0" + $0.rows[id: 3] = .textField(text1) + } + + await store.send(.rows(.element(id: 4, action: .textField(.binding(.set(\.text, "Beta Update")))))) { + text2.text = "Beta Update" + $0.rows[id: 4] = .textField(text2) + } + + await store.send(.rows(.element(id: 5, action: .editor(.binding(.set(\.text, "New beta update")))))) { + editor.text = "New beta update" + $0.rows[id: 5] = .editor(editor) + } + + await store.send(.rows(.element(id: 6, action: .uploadBox(.view(.selectFilesButtonTapped))))) { + uploadbox.destination = .confirmationDialog( + ConfirmationDialogState( + title: { TextState(verbatim: "") }, + actions: { + ButtonState(action: .gallery) { + TextState("Choose from Gallery", bundle: .module) + } + ButtonState(action: .files) { + TextState("Choose from Files", bundle: .module) + } + } + ) + ) + $0.rows[id: 6] = .uploadBox(uploadbox) + } + + #expect(store.state.isPublishButtonDisabled) + + let fileURL = try! saveBase64StringToDocuments(base64String: baseImage, filename: "base") + + await store.send(.rows(.element(id: 6, action: .uploadBox(.view(.fileImporterURLsRecieved([fileURL])))))) { + uploadbox.files = [ + FormUploadBoxFeature.File( + id: 0, + name: "base", + type: .file, + data: try! Data(contentsOf: fileURL) + ) + ] + $0.rows[id: 6] = .uploadBox(uploadbox) + } + + #expect(!store.state.isPublishButtonDisabled) + + await store.send(.view(.publishButtonTapped)) + + await store.receive(\.internal.publishPost) { + $0.isPublishing = true + } + + await store.receive(\.internal.templateResponse) + + await store.receive(\.delegate.formSent) + } +} + +private func saveBase64StringToDocuments(base64String: String, filename: String) throws -> URL { + guard let data = Data(base64Encoded: base64String) else { + throw NSError(domain: "Base64Error", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid Base64"]) + } + + let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + + let fileURL = documentsURL.appendingPathComponent(filename) + + try data.write(to: fileURL, options: .atomic) + + return fileURL +} + +let baseImage = """ +/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAIBAQEBAQIBAQECAgICAgQDAgICAgUEBAMEBgUGBgYFBgYGBwkIBgcJBwYGCAsICQoKCgoKBggLDAsKDAkKCgr/2wBDAQICAgICAgUDAwUKBwYHCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgr/wAARCAABAAEDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD9/KKKKAP/2Q== +""" diff --git a/Project.swift b/Project.swift index 250d6e71..919211f7 100644 --- a/Project.swift +++ b/Project.swift @@ -50,7 +50,7 @@ let project = Project( .Internal.TCAExtensions, .Internal.ToastClient, .Internal.TopicFeature, - .Internal.WriteFormFeature, + .Internal.FormFeature, .SPM.AlertToast, .SPM.TCA ] @@ -90,7 +90,7 @@ let project = Project( .Internal.SharedUI, .Internal.TCAExtensions, .Internal.ToastClient, - .Internal.WriteFormFeature, + .Internal.FormFeature, .SPM.NukeUI, .SPM.SFSafeSymbols, .SPM.SkeletonUI, @@ -211,7 +211,7 @@ let project = Project( .Internal.SharedUI, .Internal.TCAExtensions, .Internal.ToastClient, - .Internal.WriteFormFeature, + .Internal.FormFeature, .SPM.NukeUI, .SPM.TCA ] @@ -376,7 +376,7 @@ let project = Project( .Internal.TCAExtensions, .Internal.ToastClient, .Internal.TopicBuilder, - .Internal.WriteFormFeature, + .Internal.FormFeature, .SPM.MemberwiseInit, .SPM.NukeUI, .SPM.RichTextKit, @@ -385,7 +385,7 @@ let project = Project( ), .feature( - name: "WriteFormFeature", + name: "FormFeature", dependencies: [ .Internal.APIClient, .Internal.Models, @@ -569,6 +569,16 @@ let project = Project( ] ), + .tests( + name: "FormFeatureTests", + dependencies: [ + .Internal.APIClient, + .Internal.Models, + .Internal.FormFeature, + .SPM.TCA + ] + ), + // MARK: - Extensions - .target( @@ -832,6 +842,7 @@ extension TargetDependency.Internal { static let DeveloperFeature = TargetDependency.target(name: "DeveloperFeature") static let FavoritesFeature = TargetDependency.target(name: "FavoritesFeature") static let FavoritesRootFeature = TargetDependency.target(name: "FavoritesRootFeature") + static let FormFeature = TargetDependency.target(name: "FormFeature") static let ForumFeature = TargetDependency.target(name: "ForumFeature") static let ForumsListFeature = TargetDependency.target(name: "ForumsListFeature") static let GalleryFeature = TargetDependency.target(name: "GalleryFeature") @@ -844,7 +855,6 @@ extension TargetDependency.Internal { static let SettingsFeature = TargetDependency.target(name: "SettingsFeature") static let TopicBuilder = TargetDependency.target(name: "TopicBuilder") static let TopicFeature = TargetDependency.target(name: "TopicFeature") - static let WriteFormFeature = TargetDependency.target(name: "WriteFormFeature") // Clients static let AnalyticsClient = TargetDependency.target(name: "AnalyticsClient") From d9d94b5159cc14e3eb2f4cfb75f166c4785f205e Mon Sep 17 00:00:00 2001 From: Ilia Lubianoi Date: Thu, 14 Aug 2025 01:35:55 +0300 Subject: [PATCH 018/118] Unit tests changes --- ...tureTests.swift => FormFeatureTests.swift} | 2 +- Project.swift | 22 +++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) rename Modules/Tests/FormFeatureTests/{WriteFormFeatureTests.swift => FormFeatureTests.swift} (99%) diff --git a/Modules/Tests/FormFeatureTests/WriteFormFeatureTests.swift b/Modules/Tests/FormFeatureTests/FormFeatureTests.swift similarity index 99% rename from Modules/Tests/FormFeatureTests/WriteFormFeatureTests.swift rename to Modules/Tests/FormFeatureTests/FormFeatureTests.swift index fcf8badf..661e18b4 100644 --- a/Modules/Tests/FormFeatureTests/WriteFormFeatureTests.swift +++ b/Modules/Tests/FormFeatureTests/FormFeatureTests.swift @@ -13,7 +13,7 @@ import Testing import FormFeature @MainActor -struct WriteFormFeatureTests { +struct FormFeatureTests { // MARK: - Report Success diff --git a/Project.swift b/Project.swift index 919211f7..1dddb7fe 100644 --- a/Project.swift +++ b/Project.swift @@ -548,17 +548,17 @@ let project = Project( // MARK: - Tests - - .target( - name: "ForPDATests", - destinations: .iOS, - product: .unitTests, - bundleId: "com.subvert.forpda.tests", - deploymentTargets: .iOS("16.0"), - infoPlist: .default, - sources: ["Modules/Tests/**"], - resources: [], - dependencies: [.target(name: "ForPDA")] - ), +// .target( +// name: "ForPDATests", +// destinations: .iOS, +// product: .unitTests, +// bundleId: "com.subvert.forpda.tests", +// deploymentTargets: .iOS("16.0"), +// infoPlist: .default, +// sources: ["Modules/Tests/**"], +// resources: [], +// dependencies: [.target(name: "ForPDA")] +// ), .tests( name: "BBBuilderTests", From dac3598575a97be8ceae1b5192ff322791f179d9 Mon Sep 17 00:00:00 2001 From: Ilia Lubianoi Date: Thu, 14 Aug 2025 01:36:11 +0300 Subject: [PATCH 019/118] Bump tuist version --- mise.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mise.toml b/mise.toml index 0d69f5ad..ba74aee8 100644 --- a/mise.toml +++ b/mise.toml @@ -1,3 +1,3 @@ [tools] ruby = "3.4.4" -tuist = "4.53.3" +tuist = "4.59.2" From caf1a243b4b03c79fc160183593508bb0e30c51a Mon Sep 17 00:00:00 2001 From: Xialtal Date: Thu, 1 Jan 2026 19:42:03 +0300 Subject: [PATCH 020/118] [WIP] BB Panel --- .../BBPanelFeature/BBPanelFeature.swift | 141 +++++++++++++++ .../Sources/BBPanelFeature/BBPanelView.swift | 164 ++++++++++++++++++ .../BBPanelFeature/Models/BBPanelTag.swift | 109 ++++++++++++ .../BBPanelFeature/Models/BBPanelType.swift | 33 ++++ .../Resources/Localizable.xcstrings | 7 + .../Views/ColorPickerView.swift | 49 ++++++ Project.swift | 11 ++ 7 files changed, 514 insertions(+) create mode 100644 Modules/Sources/BBPanelFeature/BBPanelFeature.swift create mode 100644 Modules/Sources/BBPanelFeature/BBPanelView.swift create mode 100644 Modules/Sources/BBPanelFeature/Models/BBPanelTag.swift create mode 100644 Modules/Sources/BBPanelFeature/Models/BBPanelType.swift create mode 100644 Modules/Sources/BBPanelFeature/Resources/Localizable.xcstrings create mode 100644 Modules/Sources/BBPanelFeature/Views/ColorPickerView.swift diff --git a/Modules/Sources/BBPanelFeature/BBPanelFeature.swift b/Modules/Sources/BBPanelFeature/BBPanelFeature.swift new file mode 100644 index 00000000..826c7b89 --- /dev/null +++ b/Modules/Sources/BBPanelFeature/BBPanelFeature.swift @@ -0,0 +1,141 @@ +// +// BBPanelFeature.swift +// ForPDA +// +// Created by Xialtal on 28.12.25. +// + +import SwiftUI +import Foundation +import ComposableArchitecture + +@Reducer +public struct BBPanelFeature: Reducer, Sendable { + + public init() {} + + // MARK: - Localizations + + public enum Localization { + static let inputFullUrl = LocalizedStringResource("Input full URL-address", bundle: .module) + static let inputSpoilerTitle = LocalizedStringResource("Input spoiler title", bundle: .module) + } + + // MARK: - Destinations + + @Reducer + public enum Destination { + case sizeTag + case listTag + case colorTag + case smileTag + + case urlTag + case spoilerWithTitleTag + } + + // MARK: - State + + @ObservableState + public struct State: Equatable { + @Presents var destination: Destination.State? + + let panelWith: BBPanelType + + var tags: [BBPanelTag] = [] + + var alertInput = "" + + public init( + with: BBPanelType + ) { + self.panelWith = with + } + } + + // MARK: - Action + + public enum Action: BindableAction, ViewAction { + case binding(BindingAction) + case destination(PresentationAction) + + case view(View) + public enum View { + case onAppear + case tagButtonTapped(BBPanelTag) + + case alertTagButtonTapped(BBPanelTag) + + case colorSelected(String) + } + + case delegate(Delegate) + public enum Delegate { + case tagTapped((String, String)) + case smileTapped(String) + } + } + + // MARK: - Body + + public var body: some Reducer { + BindingReducer() + + Reduce { state, action in + switch action { + case .view(.onAppear): + var tags = state.panelWith.kit + if case let .post(isCurator, canModerate) = state.panelWith { + if canModerate { + tags.append(.cur) + tags.append(.mod) + tags.append(.ex) + } else if isCurator { + tags.append(.cur) + } + } + state.tags = tags + return .none + + case let .view(.tagButtonTapped(tag)): + switch tag { + case .b, .i, .s, .u, .sup, .sub, .offtop, .center, .left, .right, .hide, .code, .cur, .mod, .ex, .quote, .spoiler: + return .send(.delegate(.tagTapped(("[\(tag.code)]", "[/\(tag.code)]")))) + case .size: + return .none + case .color: + state.destination = .colorTag + case .url: + state.destination = .urlTag + case .listNumber: + return .none + case .listBullet: + return .none + case .upload: + // TODO: Attachments... + return .none + case .spoilerWithTitle: + state.destination = .spoilerWithTitleTag + case .smile: + state.destination = .smileTag + } + return .none + + case let .view(.colorSelected(color)): + state.destination = nil + return .send(.delegate(.tagTapped(("[COLOR=\(color)]", "[/COLOR]")))) + + case let .view(.alertTagButtonTapped(tag)): + let input = state.alertInput + state.alertInput = "" + return .send(.delegate(.tagTapped(("[\(tag.code)=\(input)]", "[/\(tag.code)]")))) + + case .delegate, .destination, .binding: + return .none + } + } + .ifLet(\.$destination, action: \.destination) + } +} + +extension BBPanelFeature.Destination.State: Equatable {} diff --git a/Modules/Sources/BBPanelFeature/BBPanelView.swift b/Modules/Sources/BBPanelFeature/BBPanelView.swift new file mode 100644 index 00000000..4df97807 --- /dev/null +++ b/Modules/Sources/BBPanelFeature/BBPanelView.swift @@ -0,0 +1,164 @@ +// +// BBPanelView.swift +// ForPDA +// +// Created by Xialtal on 28.12.25. +// + +import SwiftUI +import ComposableArchitecture +import SharedUI + +@ViewAction(for: BBPanelFeature.self) +public struct BBPanelView: View { + + // MARK: - Properties + + @Perception.Bindable public var store: StoreOf + @Environment(\.tintColor) private var tintColor + + @State private var selectedColor: Color = .clear + + // MARK: - Init + + public init(store: StoreOf) { + self.store = store + } + + // MARK: - Body + + public var body: some View { + WithPerceptionTracking { + if #available(iOS 26.0, *) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 20) { + ForEach(store.tags, id: \.self) { tag in + Button { + send(.tagButtonTapped(tag)) + } label: { + Image(systemSymbol: tag.icon) + .foregroundStyle(Color(.Labels.primary)) + } + .buttonStyle(.plain) + } + } + .padding(.top, 6) + .padding(.bottom, 8) + .padding(.horizontal, 12) + } + .sheet(isPresented: Binding($store.destination.smileTag)) { + SmilesList() + } + .sheet(isPresented: Binding($store.destination.colorTag)) { + ColorPickerView(onColorSelected: { color in + send(.colorSelected(color.description)) + }) + .presentationDetents([.medium]) + } + .alert( + BBPanelFeature.Localization.inputFullUrl, + isPresented: Binding($store.destination.urlTag) + ) { + AlertInput({ + send(.alertTagButtonTapped(.url)) + }) + } + .alert( + BBPanelFeature.Localization.inputSpoilerTitle, + isPresented: Binding($store.destination.spoilerWithTitleTag) + ) { + AlertInput({ + send(.alertTagButtonTapped(.spoilerWithTitle)) + }) + } + .background(.bar.opacity(0.5), in: .capsule) + .glassEffect() + .shadow(color: .black.opacity(0.2), radius: 20, y: 10) + .onAppear { + send(.onAppear) + } + } else { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 20) { + Button { + + } label: { + Image(systemSymbol: .plusAppFill) + .foregroundStyle(Color(.Labels.primary)) + } + .buttonStyle(.plain) + } + .padding(.top, 6) + .padding(.bottom, 8) + .padding(.horizontal, 12) + .border(Color(.red/*Background.secondary*/)) + .background(Color(.Background.secondary)) + } + } + } + } + + @ViewBuilder + private func AlertInput(_ action: @escaping () -> Void) -> some View { + TextField(LocalizedStringKey("Input..."), text: $store.alertInput) + + Button(LocalizedStringResource("Cancel", bundle: .module)) { } + + Button(LocalizedStringResource("OK", bundle: .module)) { + action() + } + .disabled(store.alertInput.isEmpty) + } + + @ViewBuilder + private func SmilesList() -> some View { + Grid { + GridRow { + Text("Sheet") + Text("Sheet") + Text("Sheet") + } + + GridRow { + Text("Sheet") + Text("Sheet") + Text("Sheet") + } + + GridRow { + Text("Sheet") + Text("Sheet") + Text("Sheet") + } + + GridRow { + Text("Sheet") + Text("Sheet") + Text("Sheet") + } + + GridRow { + Text("Sheet") + Text("Sheet") + Text("Sheet") + } + } + .presentationDetents([.height(337)]) + .presentationDragIndicator(.visible) + + } +} + +// MARK: - Previews + +#Preview { + BBPanelView( + store: Store( + initialState: BBPanelFeature.State( + with: .qms + ), + ) { + BBPanelFeature() + } + ) +} diff --git a/Modules/Sources/BBPanelFeature/Models/BBPanelTag.swift b/Modules/Sources/BBPanelFeature/Models/BBPanelTag.swift new file mode 100644 index 00000000..6c037b3b --- /dev/null +++ b/Modules/Sources/BBPanelFeature/Models/BBPanelTag.swift @@ -0,0 +1,109 @@ +// +// BBPanelTag.swift +// ForPDA +// +// Created by Xialtal on 1.01.26. +// + +import SFSafeSymbols + +public enum BBPanelTag { + case b + case i + case s + case u + case sup + case sub + case size + case color + case url + case offtop + case center + case left + case right + case spoiler + case spoilerWithTitle + case listBullet + case listNumber + case quote + case code + case hide + case cur + case mod + case ex + + case smile + case upload +} + +extension BBPanelTag { + + var code: String { + switch self { + case .spoilerWithTitle: + "SPOILER" + case .listBullet: + "LIST" + case .listNumber: + "LIST=1" + default: + "\(self)".uppercased() + } + } + + var icon: SFSymbol { + switch self { + case .b: + return .bold + case .i: + return .italic + case .s: + return .strikethrough + case .u: + return .underline + case .sup: + return .textformatSuperscript + case .sub: + return .textformatSubscript + case .size: + return .textformat + case .color: + return .paintbrushPointedFill + case .url: + return .link + case .offtop: + return .cupAndSaucer + case .center: + return .alignHorizontalCenter + case .left: + return .alignHorizontalLeft + case .right: + return .alignHorizontalRight + case .spoiler: + return .plusAppFill + case .spoilerWithTitle: + return .hSquareFill + case .listBullet: + return .listBullet + case .listNumber: + return .listNumber + case .quote: + return .quoteOpening + case .code: + return .chevronLeftForwardslashChevronRight + case .hide: + return .eyeSlash + case .cur: + return .kSquare + case .mod: + return .mSquare + case .ex: + return .exclamationmarkSquare + case .upload: + return .paperclip + case .smile: + return .faceSmiling + + } + } +} diff --git a/Modules/Sources/BBPanelFeature/Models/BBPanelType.swift b/Modules/Sources/BBPanelFeature/Models/BBPanelType.swift new file mode 100644 index 00000000..00d837d2 --- /dev/null +++ b/Modules/Sources/BBPanelFeature/Models/BBPanelType.swift @@ -0,0 +1,33 @@ +// +// BBPanelType.swift +// ForPDA +// +// Created by Xialtal on 28.12.25. +// + +import SwiftUI + +public enum BBPanelType: Equatable { + case qms + case post(isCurator: Bool, canModerate: Bool) + case profile + case custom([BBPanelTag]) +} + +extension BBPanelType { + var kit: [BBPanelTag] { + switch self { + case .qms: + return [.smile, .b, .i, .u, .s, .quote, .code] + case .post: + return [ + .smile, .b, .i, .u, .s, .size, .color, .url, .listBullet, .listNumber, .quote, + .spoiler, .spoilerWithTitle, .code, .left, .center, .right, .sub, .sup, .offtop, .hide + ] + case .profile: + return [.b, .i, .u, .s, .color, .url, .left, .center, .right, .sub, .sup, .offtop] + case .custom(let array): + return array + } + } +} diff --git a/Modules/Sources/BBPanelFeature/Resources/Localizable.xcstrings b/Modules/Sources/BBPanelFeature/Resources/Localizable.xcstrings new file mode 100644 index 00000000..900453da --- /dev/null +++ b/Modules/Sources/BBPanelFeature/Resources/Localizable.xcstrings @@ -0,0 +1,7 @@ +{ + "sourceLanguage" : "en", + "strings" : { + + }, + "version" : "1.1" +} \ No newline at end of file diff --git a/Modules/Sources/BBPanelFeature/Views/ColorPickerView.swift b/Modules/Sources/BBPanelFeature/Views/ColorPickerView.swift new file mode 100644 index 00000000..f6a726a2 --- /dev/null +++ b/Modules/Sources/BBPanelFeature/Views/ColorPickerView.swift @@ -0,0 +1,49 @@ +// +// ColorPickerView.swift +// ForPDA +// +// Created by Xialtal on 1.01.26. +// + +import SwiftUI + +struct ColorPickerView: UIViewControllerRepresentable { + private let delegate: ColorPickerDelegate + + init(onColorSelected: @escaping (Color) -> Void) { + self.delegate = ColorPickerDelegate(onColorSelected: { color in + onColorSelected(color) + }) + } + + func makeUIViewController(context: Context ) -> UIColorPickerViewController { + let picker = UIColorPickerViewController() + picker.title = String(localized: "Colors", bundle: .module) + picker.selectedColor = .clear + picker.supportsAlpha = false + picker.delegate = delegate + + if #available(iOS 26.0, *) { + picker.supportsEyedropper = false + } + + return picker + } + + func updateUIViewController(_ uiViewController: UIColorPickerViewController, context: Context) { + } + + private class ColorPickerDelegate: NSObject, UIColorPickerViewControllerDelegate { + let onColorSelected: (Color) -> Void + + public init(onColorSelected: @escaping (Color) -> Void) { + self.onColorSelected = onColorSelected + } + + func colorPickerViewController(_ viewController: UIColorPickerViewController, didSelect color: UIColor, continuously: Bool) { + if !continuously { + onColorSelected(Color(uiColor: viewController.selectedColor)) + } + } + } +} diff --git a/Project.swift b/Project.swift index cd97d1d3..bc3735ba 100644 --- a/Project.swift +++ b/Project.swift @@ -139,6 +139,16 @@ let project = Project( .SPM.TCA ] ), + + .feature( + name: "BBPanelFeature", + dependencies: [ + .Internal.Models, + .Internal.SharedUI, + .SPM.SFSafeSymbols, + .SPM.TCA + ] + ), .feature( name: "BookmarksFeature", @@ -909,6 +919,7 @@ extension TargetDependency.Internal { static let ArticleFeature = TargetDependency.target(name: "ArticleFeature") static let ArticlesListFeature = TargetDependency.target(name: "ArticlesListFeature") static let AuthFeature = TargetDependency.target(name: "AuthFeature") + static let BBPanelFeature = TargetDependency.target(name: "BBPanelFeature") static let BookmarksFeature = TargetDependency.target(name: "BookmarksFeature") static let DeeplinkHandler = TargetDependency.target(name: "DeeplinkHandler") static let DeveloperFeature = TargetDependency.target(name: "DeveloperFeature") From 1fc35484a7511044373d9e43a52ecf8193f7eeff Mon Sep 17 00:00:00 2001 From: Xialtal Date: Thu, 1 Jan 2026 19:55:14 +0300 Subject: [PATCH 021/118] Disable all write form field previews --- Modules/Sources/WriteFormFeature/WriteFormView.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Modules/Sources/WriteFormFeature/WriteFormView.swift b/Modules/Sources/WriteFormFeature/WriteFormView.swift index e5c456b8..1ad2a105 100644 --- a/Modules/Sources/WriteFormFeature/WriteFormView.swift +++ b/Modules/Sources/WriteFormFeature/WriteFormView.swift @@ -251,6 +251,8 @@ struct CheckBox: ToggleStyle { } } +#warning("Uncomment previews") +/* // MARK: - Field View Preview @available(iOS 17, *) @@ -381,3 +383,4 @@ struct CheckBox: ToggleStyle { } .padding(.horizontal, 16) } +*/ From f0b5544617d93d3327320fe931c6665c30386d4d Mon Sep 17 00:00:00 2001 From: Xialtal Date: Thu, 1 Jan 2026 20:34:45 +0300 Subject: [PATCH 022/118] Add selection support for ForField --- Modules/Sources/SharedUI/ForField.swift | 192 ++++++++++++++++++++---- 1 file changed, 162 insertions(+), 30 deletions(-) diff --git a/Modules/Sources/SharedUI/ForField.swift b/Modules/Sources/SharedUI/ForField.swift index f73cedcc..272cd181 100644 --- a/Modules/Sources/SharedUI/ForField.swift +++ b/Modules/Sources/SharedUI/ForField.swift @@ -8,6 +8,9 @@ import SwiftUI public struct ForField: View { + + // MARK: - Properties + @Environment(\.tintColor) private var tintColor @FocusState.Binding var focus: T? @@ -15,60 +18,189 @@ public struct ForField: View { let placeholder: LocalizedStringResource let focusEqual: T let characterLimit: Int? + let minHeight: CGFloat? + var selection: Binding + // MARK: - Init + + @available(*, deprecated, message: "Use init with Binding") public init( content: Binding, placeholder: LocalizedStringResource, focusEqual: T, focus: FocusState.Binding, - characterLimit: Int? = nil + characterLimit: Int? = nil, + minHeight: CGFloat? = nil, ) { self.content = content self.placeholder = placeholder self.focusEqual = focusEqual self.characterLimit = characterLimit + self.minHeight = minHeight + + self.selection = .constant(nil) self._focus = focus } + public init( + content: Binding, + placeholder: LocalizedStringResource, + focusEqual: T, + focus: FocusState.Binding, + characterLimit: Int? = nil, + minHeight: CGFloat? = nil, + selection: Binding + ) { + self.content = content + self.placeholder = placeholder + self.focusEqual = focusEqual + self.characterLimit = characterLimit + self.minHeight = minHeight + self.selection = selection + + self._focus = focus + } + + // MARK: - Body + public var body: some View { Group { - TextField(text: content, axis: .vertical) { - Text(placeholder) - .font(.body) - .foregroundStyle(Color(.Labels.quaternary)) - } - .onChange(of: content.wrappedValue) { newValue in - if let limit = characterLimit, newValue.count > limit { - content.wrappedValue = String(newValue.prefix(limit)) - } - } - .focused($focus, equals: focusEqual) - .font(.body) - .foregroundStyle(Color(.Labels.primary)) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - .frame(minHeight: nil, alignment: .top) + SelectableTextView( + content: content, + selection: selection, + placeholder: placeholder, + characterLimit: characterLimit + ) } .padding(.vertical, 15) .padding(.horizontal, 12) + .focused($focus, equals: focusEqual) + .foregroundStyle(Color(.Labels.primary)) + .frame(minHeight: minHeight, alignment: .top) .background { - if #available(iOS 26, *) { - ConcentricRectangle() - .fill(Color(.Background.teritary)) - } else { - RoundedRectangle(cornerRadius: 14) - .fill(Color(.Background.teritary)) - } + RoundedRectangle(cornerRadius: isLiquidGlass ? 28 : 14) + .fill(Color(.Background.teritary)) } .overlay { - if #available(iOS 26, *) { - ConcentricRectangle() - .stroke($focus.wrappedValue == focusEqual ? tintColor : Color(.Separator.primary), lineWidth: 1) - } else { - RoundedRectangle(cornerRadius: 14) - .stroke($focus.wrappedValue == focusEqual ? tintColor : Color(.Separator.primary), lineWidth: 1) + RoundedRectangle(cornerRadius: isLiquidGlass ? 28 : 14) + .stroke($focus.wrappedValue == focusEqual ? tintColor : Color(.Separator.primary), lineWidth: 1) + } + .onTapGesture { + focus = focusEqual + } + } +} + +// MARK: - Selectable Text View + +private struct SelectableTextView: UIViewRepresentable { + @Binding var content: String + @Binding var selection: NSRange? + let placeholder: LocalizedStringResource + let characterLimit: Int? + + static let placeholderColor = UIColor.lightGray + + func makeUIView(context: Context) -> UITextView { + let view = UITextView() + view.text = content.isEmpty ? String(localized: placeholder) : content + view.textColor = content.isEmpty ? Self.placeholderColor : UIColor.label + view.delegate = context.coordinator + view.backgroundColor = .clear + view.textContainer.lineFragmentPadding = 0 + view.textContainerInset = .zero + view.isScrollEnabled = false + + view.font = UIFont.preferredFont(forTextStyle: .body) + + return view + } + + func updateUIView(_ uiView: UITextView, context: Context) { + if uiView.textColor != Self.placeholderColor, uiView.text != content { + uiView.text = content + } + + // when range (selection binding) has been changed in external place + if let externalSelection = selection, uiView.selectedRange != externalSelection { + uiView.selectedRange = externalSelection + } + } + + func sizeThatFits(_ proposal: ProposedViewSize, uiView: UITextView, context: Context) -> CGSize? { + guard let width = proposal.width else { return nil } + let dimensions = content.boundingRect( + with: CGSize(width: width, height: CGFloat.greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading], + attributes: [.font: UIFont.preferredFont(forTextStyle: .body)], + context: nil + ) + return CGSize(width: width, height: ceil(dimensions.height)) + } + + func makeCoordinator() -> Coordinator { + return Coordinator( + content: $content, + selection: $selection, + characterLimit: characterLimit, + placeholder: placeholder + ) + } + + final class Coordinator: NSObject, UITextViewDelegate { + var content: Binding + var selection: Binding + let characterLimit: Int? + let placeholder: LocalizedStringResource + + init( + content: Binding, + selection: Binding, + characterLimit: Int?, + placeholder: LocalizedStringResource + ) { + self.content = content + self.selection = selection + self.characterLimit = characterLimit + self.placeholder = placeholder + } + + func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText: String) -> Bool { + if let characterLimit = characterLimit { + return textView.text.count + replacementText.count <= characterLimit + } + return true + } + + func textViewDidChangeSelection(_ textView: UITextView) { + selection.wrappedValue = textView.selectedRange + } + + func textViewDidChange(_ textView: UITextView) { + content.wrappedValue = textView.text + } + + func textViewDidBeginEditing(_ textView: UITextView) { + if textView.textColor == placeholderColor { + textView.text = nil + textView.textColor = UIColor.label + } + } + + func textViewDidEndEditing(_ textView: UITextView) { + if textView.text.isEmpty { + textView.text = String(localized: placeholder) + textView.textColor = placeholderColor + } + } + + func textViewShouldBeginEditing(_ textView: UITextView) -> Bool { + // init selection, cause until user not enter something, it always nil. + if selection.wrappedValue == nil { + selection.wrappedValue = NSMakeRange(0, 0) } + return true } } } From 09fd4b54c0996832c062f33b07412e61911a64b0 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Thu, 1 Jan 2026 20:37:01 +0300 Subject: [PATCH 023/118] Add bb panel support for write form --- .../WriteFormFeature+Analytics.swift | 2 +- .../Resources/Localizable.xcstrings | 10 ++++ .../WriteFormFeature/WriteFormFeature.swift | 41 ++++++++++++++- .../WriteFormFeature/WriteFormScreen.swift | 34 ++++++++++--- .../WriteFormFeature/WriteFormView.swift | 50 +++++++++++++------ Project.swift | 1 + 6 files changed, 113 insertions(+), 25 deletions(-) diff --git a/Modules/Sources/WriteFormFeature/Analytics/WriteFormFeature+Analytics.swift b/Modules/Sources/WriteFormFeature/Analytics/WriteFormFeature+Analytics.swift index 181103fd..26b7c05e 100644 --- a/Modules/Sources/WriteFormFeature/Analytics/WriteFormFeature+Analytics.swift +++ b/Modules/Sources/WriteFormFeature/Analytics/WriteFormFeature+Analytics.swift @@ -19,7 +19,7 @@ extension WriteFormFeature { var body: some Reducer { Reduce { state, action in switch action { - case .binding, .destination, .internal: + case .binding, .destination, .bbPanel, .internal: break case .delegate(.writeFormSent): diff --git a/Modules/Sources/WriteFormFeature/Resources/Localizable.xcstrings b/Modules/Sources/WriteFormFeature/Resources/Localizable.xcstrings index c9646642..d4a93fbc 100644 --- a/Modules/Sources/WriteFormFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/WriteFormFeature/Resources/Localizable.xcstrings @@ -47,6 +47,16 @@ } } }, + "Input..." : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Введите…" + } + } + } + }, "It will be attached as a dialog to your last post" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/WriteFormFeature/WriteFormFeature.swift b/Modules/Sources/WriteFormFeature/WriteFormFeature.swift index 3b3ffb39..a7fc83da 100644 --- a/Modules/Sources/WriteFormFeature/WriteFormFeature.swift +++ b/Modules/Sources/WriteFormFeature/WriteFormFeature.swift @@ -9,6 +9,7 @@ import Foundation import ComposableArchitecture import APIClient import Models +import BBPanelFeature @Reducer public struct WriteFormFeature: Reducer, Sendable { @@ -29,6 +30,7 @@ public struct WriteFormFeature: Reducer, Sendable { public enum Destination { case preview(FormPreviewFeature) case alert(AlertState) + case bbPanel(BBPanelFeature) @CasePathable public enum Alert { @@ -42,12 +44,20 @@ public struct WriteFormFeature: Reducer, Sendable { @ObservableState public struct State: Equatable { + public enum Field: Equatable, Hashable { case reason, field(Int) } + @Presents public var destination: Destination.State? @Shared(.userSession) var userSession public let formFor: WriteFormForType + public var bbPanel = BBPanelFeature.State(with: .post(isCurator: true, canModerate: true)) + + var editorRange: NSRange? + + var focus: Field? + var textContent = "" var isEditReasonToggleSelected = false var editReasonContent = "" @@ -77,6 +87,7 @@ public struct WriteFormFeature: Reducer, Sendable { public enum Action: BindableAction, ViewAction { case binding(BindingAction) case destination(PresentationAction) + case bbPanel(BBPanelFeature.Action) case view(View) public enum View { @@ -111,8 +122,36 @@ public struct WriteFormFeature: Reducer, Sendable { public var body: some Reducer { BindingReducer() + Scope(state: \.bbPanel, action: \.bbPanel) { + BBPanelFeature() + } + Reduce { state, action in switch action { + case let .bbPanel(.delegate(.tagTapped(tag))): + if let range = state.editorRange { + if !state.textContent.isEmpty { + if range.lowerBound == range.upperBound { + let index = state.textContent.index(state.textContent.startIndex, offsetBy: range.lowerBound) + state.textContent.insert(contentsOf: "\(tag.0)\(tag.1)", at: index) + state.editorRange = NSMakeRange(range.lowerBound + tag.0.count, 0) + } else { + let ubIndex = state.textContent.index(state.textContent.startIndex, offsetBy: range.upperBound) + let lbIndex = state.textContent.index(state.textContent.startIndex, offsetBy: range.lowerBound) + state.textContent.insert(contentsOf: tag.1, at: ubIndex) + state.textContent.insert(contentsOf: tag.0, at: lbIndex) + state.editorRange = NSMakeRange(range.lowerBound + tag.0.count, range.upperBound - range.lowerBound) + } + } else { + state.textContent = "\(tag.0)\(tag.1)" + state.editorRange = NSMakeRange(tag.0.count, 0) + } + } else { + print(".bbPanel(.delegate(.tagTapped(tag))): [\(tag), \"\(state.textContent)\", \(state.editorRange)]") + //fatalError("How tag can tapped without tap to editor field? [\(tag), \(state.textContent)]") + } + return .none + case .view(.onAppear): switch state.formFor { case .topic(let forumId, _): @@ -308,7 +347,7 @@ public struct WriteFormFeature: Reducer, Sendable { } return .none - case .binding, .destination: + case .binding, .destination, .bbPanel: return .none } } diff --git a/Modules/Sources/WriteFormFeature/WriteFormScreen.swift b/Modules/Sources/WriteFormFeature/WriteFormScreen.swift index 670d3512..5c9cb9f3 100644 --- a/Modules/Sources/WriteFormFeature/WriteFormScreen.swift +++ b/Modules/Sources/WriteFormFeature/WriteFormScreen.swift @@ -10,6 +10,7 @@ import ComposableArchitecture import SwiftUI import Models import SharedUI +import BBPanelFeature @ViewAction(for: WriteFormFeature.self) public struct WriteFormScreen: View { @@ -18,7 +19,7 @@ public struct WriteFormScreen: View { @Environment(\.tintColor) private var tintColor @State private var isPreviewPresented: Bool = false - @FocusState private var isFocused: Bool + @FocusState public var focus: WriteFormFeature.State.Field? public init(store: StoreOf) { self.store = store @@ -33,7 +34,7 @@ public struct WriteFormScreen: View { .background(Color(.Background.primary)) ._toolbarTitleDisplayMode(.inline) .onTapGesture { - isFocused = false + focus = nil } .overlay { if store.formFields.isEmpty || store.isFormLoading { @@ -52,6 +53,7 @@ public struct WriteFormScreen: View { FormPreviewView(store: store) } } + .bind($store.focus, to: $focus) .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button { @@ -73,6 +75,17 @@ public struct WriteFormScreen: View { .disabled(store.textContent.isEmptyAfterTrimming()) .disabled(store.isPublishing) } + + if #available(iOS 26.0, *) { + ToolbarItem(placement: .bottomBar) { + BBPanelView(store: store.scope(state: \.bbPanel, action: \.bbPanel)) + } + .sharedBackgroundVisibility(.hidden) + } else { + ToolbarItem(placement: .keyboard) { + BBPanelView(store: store.scope(state: \.bbPanel, action: \.bbPanel)) + } + } } .onAppear { send(.onAppear) @@ -88,7 +101,9 @@ public struct WriteFormScreen: View { VStack { WriteFormView( type: store.formFields[index], - isFocused: $isFocused, + range: $store.editorRange, + focus: $focus, + focusEqual: WriteFormFeature.State.Field.field(index), onUpdateContent: { content in if content != nil { send(.updateFieldContent(index, content!)) @@ -102,6 +117,8 @@ public struct WriteFormScreen: View { if store.inPostEditingMode { EditReason() + } else { + Text("textContent: \(store.textContent)") } } } @@ -127,8 +144,7 @@ public struct WriteFormScreen: View { .frame(height: 48) .disabled(store.textContent.isEmptyAfterTrimming()) .disabled(store.isPublishing) - - Spacer() + .disabled(true) } @ViewBuilder @@ -147,8 +163,12 @@ public struct WriteFormScreen: View { .padding(.horizontal, 2) if store.isEditReasonToggleSelected { - Field(text: $store.editReasonContent, description: "", guideText: "", isFocused: $isFocused) - .disabled(store.isPublishing || !store.isEditReasonToggleSelected) + ForField( + content: $store.editReasonContent, + placeholder: LocalizedStringResource("Input...", bundle: .module), + focusEqual: WriteFormFeature.State.Field.reason, + focus: $focus + ) if store.canShowShowMark { Toggle(isOn: $store.isShowMarkToggleSelected) { diff --git a/Modules/Sources/WriteFormFeature/WriteFormView.swift b/Modules/Sources/WriteFormFeature/WriteFormView.swift index 1ad2a105..b8cd3e92 100644 --- a/Modules/Sources/WriteFormFeature/WriteFormView.swift +++ b/Modules/Sources/WriteFormFeature/WriteFormView.swift @@ -14,23 +14,40 @@ import BBBuilder struct WriteFormView: View { let type: WriteFormFieldType - @FocusState.Binding var isFocused: Bool + let focusEqual: WriteFormFeature.State.Field + let onUpdateContent: (String?) -> String + + @FocusState.Binding var focus: WriteFormFeature.State.Field? + + @Binding var range: NSRange? + + public init( + type: WriteFormFieldType, + range: Binding, + focus: FocusState.Binding, + focusEqual: WriteFormFeature.State.Field, + onUpdateContent: @escaping (String?) -> String, + ) { + self.type = type + self._range = range + self.focusEqual = focusEqual + self.onUpdateContent = onUpdateContent + + self._focus = focus + } - let onUpdateContent: (String?) -> String // (String) -> Void?, - var onUpdateSelection: ((Int, String, Bool) -> Void)? - var body: some View { switch type { case .text(let content): Section { - Field( - text: Binding( + ForField( + content: Binding( get: { onUpdateContent(nil) }, set: { _ = onUpdateContent($0) } ), - description: content.description, - guideText: content.example, - isFocused: $isFocused + placeholder: LocalizedStringResource("Input...", bundle: .module), + focusEqual: focusEqual, + focus: $focus ) } header: { Header(title: content.name, required: content.isRequired) @@ -57,15 +74,16 @@ struct WriteFormView: View { case .editor(let content): Section { - Field( - text: Binding( + ForField( + content: Binding( get: { onUpdateContent(nil) }, set: { _ = onUpdateContent($0) } ), - description: content.description, - guideText: content.example, - isEditor: true, - isFocused: $isFocused + placeholder: LocalizedStringResource("Input...", bundle: .module), + focusEqual: focusEqual, + focus: $focus, + minHeight: 144, + selection: $range ) } header: { if !content.name.isEmpty { @@ -123,7 +141,7 @@ struct WriteFormView: View { // FIXME: Now all checkboxes always false. Find the solution with getter. get: { false }, set: { isSelected in - onUpdateSelection?(index, options[index], isSelected) + //onUpdateSelection?(index, options[index], isSelected) } )) { Text(options[index]) diff --git a/Project.swift b/Project.swift index bc3735ba..2574ce90 100644 --- a/Project.swift +++ b/Project.swift @@ -465,6 +465,7 @@ let project = Project( name: "WriteFormFeature", dependencies: [ .Internal.APIClient, + .Internal.BBPanelFeature, .Internal.Models, .Internal.ParsingClient, .Internal.SharedUI, From 1c52ed827fc1a241e52244b761de4333f8b9deca Mon Sep 17 00:00:00 2001 From: Xialtal Date: Thu, 1 Jan 2026 20:45:47 +0300 Subject: [PATCH 024/118] Use ForField for reputation change --- .../ReputationChangeFeature.swift | 12 ++++++++++-- .../ReputationChangeView.swift | 18 ++++++++---------- .../Resources/Localizable.xcstrings | 10 ++++++++++ 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/Modules/Sources/ReputationChangeFeature/ReputationChangeFeature.swift b/Modules/Sources/ReputationChangeFeature/ReputationChangeFeature.swift index 82cfeeaf..219337a7 100644 --- a/Modules/Sources/ReputationChangeFeature/ReputationChangeFeature.swift +++ b/Modules/Sources/ReputationChangeFeature/ReputationChangeFeature.swift @@ -44,6 +44,10 @@ public struct ReputationChangeFeature: Reducer, Sendable { @ObservableState public struct State: Equatable { + public enum Field { case reason } + + var focus: Field? = .reason + let userId: Int let username: String let content: ReputationChangeRequest.ContentType @@ -63,7 +67,9 @@ public struct ReputationChangeFeature: Reducer, Sendable { // MARK: - Action - public enum Action { + public enum Action: BindableAction { + case binding(BindingAction) + case onAppear case upButtonTapped @@ -85,9 +91,11 @@ public struct ReputationChangeFeature: Reducer, Sendable { // MARK: - Body public var body: some Reducer { + BindingReducer() + Reduce { state, action in switch action { - case .onAppear: + case .onAppear, .binding: return .none case .cancelButtonTapped: diff --git a/Modules/Sources/ReputationChangeFeature/ReputationChangeView.swift b/Modules/Sources/ReputationChangeFeature/ReputationChangeView.swift index 6de7eb81..14bc046a 100644 --- a/Modules/Sources/ReputationChangeFeature/ReputationChangeView.swift +++ b/Modules/Sources/ReputationChangeFeature/ReputationChangeView.swift @@ -17,7 +17,7 @@ public struct ReputationChangeView: View { @Perception.Bindable public var store: StoreOf @Environment(\.tintColor) private var tintColor - @FocusState private var isFocused: Bool + @FocusState public var focus: ReputationChangeFeature.State.Field? // MARK: - Init @@ -38,12 +38,12 @@ public struct ReputationChangeView: View { .offset(y: isLiquidGlass ? -6 : 0) Section { - Field( - text: $store.changeReason.sending(\.reasonChanged), - description: "", - guideText: "", - isEditor: true, - isFocused: $isFocused + ForField( + content: $store.changeReason.sending(\.reasonChanged), + placeholder: LocalizedStringResource("Input", bundle: .module), + focusEqual: ReputationChangeFeature.State.Field.reason, + focus: $focus, + minHeight: 144 ) } header: { Text("Input reason", bundle: .module) @@ -55,15 +55,13 @@ public struct ReputationChangeView: View { ActionButtons() } + .bind($store.focus, to: $focus) .padding(.horizontal, 16) .background { if !isLiquidGlass { Color(.Background.primary) } } - .onTapGesture { - isFocused = false - } .modifier(NavigationTitle()) .toolbar { ToolbarItem(placement: .topBarTrailing) { diff --git a/Modules/Sources/ReputationChangeFeature/Resources/Localizable.xcstrings b/Modules/Sources/ReputationChangeFeature/Resources/Localizable.xcstrings index c5a4f59a..5e78bde8 100644 --- a/Modules/Sources/ReputationChangeFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/ReputationChangeFeature/Resources/Localizable.xcstrings @@ -61,6 +61,16 @@ } } }, + "Input" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Введите" + } + } + } + }, "Input reason" : { "localizations" : { "ru" : { From 3aa41b06fc5c77400fb7697ebc711abb4d1f97bd Mon Sep 17 00:00:00 2001 From: Xialtal Date: Thu, 1 Jan 2026 20:58:55 +0300 Subject: [PATCH 025/118] Field refactoring --- .../ProfileFeature/Edit/EditScreen.swift | 2 +- .../ReputationChangeView.swift | 2 +- .../Sources/SearchFeature/SearchScreen.swift | 2 +- Modules/Sources/SharedUI/Field.swift | 217 +++++++++++++----- Modules/Sources/SharedUI/ForField.swift | 206 ----------------- .../WriteFormFeature/WriteFormScreen.swift | 2 +- .../WriteFormFeature/WriteFormView.swift | 4 +- 7 files changed, 169 insertions(+), 266 deletions(-) delete mode 100644 Modules/Sources/SharedUI/ForField.swift diff --git a/Modules/Sources/ProfileFeature/Edit/EditScreen.swift b/Modules/Sources/ProfileFeature/Edit/EditScreen.swift index 30c6d10f..a3d3e5d4 100644 --- a/Modules/Sources/ProfileFeature/Edit/EditScreen.swift +++ b/Modules/Sources/ProfileFeature/Edit/EditScreen.swift @@ -353,7 +353,7 @@ public struct EditScreen: View { characterLimit: Int? = nil ) -> some View { Section { - ForField( + SharedUI.Field( content: content, placeholder: LocalizedStringResource("Input...", bundle: .module), focusEqual: focusEqual, diff --git a/Modules/Sources/ReputationChangeFeature/ReputationChangeView.swift b/Modules/Sources/ReputationChangeFeature/ReputationChangeView.swift index 14bc046a..dcbe2487 100644 --- a/Modules/Sources/ReputationChangeFeature/ReputationChangeView.swift +++ b/Modules/Sources/ReputationChangeFeature/ReputationChangeView.swift @@ -38,7 +38,7 @@ public struct ReputationChangeView: View { .offset(y: isLiquidGlass ? -6 : 0) Section { - ForField( + Field( content: $store.changeReason.sending(\.reasonChanged), placeholder: LocalizedStringResource("Input", bundle: .module), focusEqual: ReputationChangeFeature.State.Field.reason, diff --git a/Modules/Sources/SearchFeature/SearchScreen.swift b/Modules/Sources/SearchFeature/SearchScreen.swift index eac213d0..c47b27a7 100644 --- a/Modules/Sources/SearchFeature/SearchScreen.swift +++ b/Modules/Sources/SearchFeature/SearchScreen.swift @@ -190,7 +190,7 @@ public struct SearchScreen: View { private func AuthorSection() -> some View { Section { - SharedUI.ForField( + SharedUI.Field( content: $store.authorName.removeDuplicates(), placeholder: LocalizedStringResource("Input...", bundle: .module), focusEqual: SearchFeature.State.Field.authorName, diff --git a/Modules/Sources/SharedUI/Field.swift b/Modules/Sources/SharedUI/Field.swift index 84c79a03..af64078c 100644 --- a/Modules/Sources/SharedUI/Field.swift +++ b/Modules/Sources/SharedUI/Field.swift @@ -1,77 +1,186 @@ // -// Field.swift +// ForField.swift // ForPDA // -// Created by Xialtal on 14.06.25. +// Created by Xialtal on 25.11.25. // import SwiftUI -public struct Field: View { +public struct Field: View { - @FocusState.Binding var isFocused: Bool + // MARK: - Properties - let text: Binding - let description: String - let guideText: String - var isEditor = false + @Environment(\.tintColor) private var tintColor + @FocusState.Binding var focus: T? + + var content: Binding + let placeholder: LocalizedStringResource + let focusEqual: T + let characterLimit: Int? + let minHeight: CGFloat? + var selection: Binding + + // MARK: - Init public init( - text: Binding, - description: String, - guideText: String, - isEditor: Bool = false, - isFocused: FocusState.Binding + content: Binding, + placeholder: LocalizedStringResource, + focusEqual: T, + focus: FocusState.Binding, + characterLimit: Int? = nil, + minHeight: CGFloat? = nil, + selection: Binding = .constant(nil) ) { - self.text = text - self.description = description - self.guideText = guideText - self.isEditor = isEditor + self.content = content + self.placeholder = placeholder + self.focusEqual = focusEqual + self.characterLimit = characterLimit + self.minHeight = minHeight + self.selection = selection - self._isFocused = isFocused + self._focus = focus } + // MARK: - Body + public var body: some View { - VStack { - Group { - TextField(text: text, axis: .vertical) { - Text(guideText) - .font(.body) - .foregroundStyle(Color(.quaternaryLabel)) - } - .focused($isFocused) - .font(.body) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - .foregroundStyle(Color(.Labels.primary)) - .frame(minHeight: isEditor ? 144 : nil, alignment: .top) - } - .padding(.vertical, 15) - .padding(.horizontal, 12) - .background { - RoundedRectangle(cornerRadius: isLiquidGlass ? 28 : 14) - .fill(Color(.Background.teritary)) - .onTapGesture { - isFocused = true - } + Group { + SelectableTextView( + content: content, + selection: selection, + placeholder: placeholder, + characterLimit: characterLimit + ) + } + .padding(.vertical, 15) + .padding(.horizontal, 12) + .focused($focus, equals: focusEqual) + .foregroundStyle(Color(.Labels.primary)) + .frame(minHeight: minHeight, alignment: .top) + .background { + RoundedRectangle(cornerRadius: isLiquidGlass ? 28 : 14) + .fill(Color(.Background.teritary)) + } + .overlay { + RoundedRectangle(cornerRadius: isLiquidGlass ? 28 : 14) + .stroke($focus.wrappedValue == focusEqual ? tintColor : Color(.Separator.primary), lineWidth: 1) + } + .onTapGesture { + focus = focusEqual + } + } +} + +// MARK: - Selectable Text View + +private struct SelectableTextView: UIViewRepresentable { + @Binding var content: String + @Binding var selection: NSRange? + let placeholder: LocalizedStringResource + let characterLimit: Int? + + static let placeholderColor = UIColor.lightGray + + func makeUIView(context: Context) -> UITextView { + let view = UITextView() + view.text = content.isEmpty ? String(localized: placeholder) : content + view.textColor = content.isEmpty ? Self.placeholderColor : UIColor.label + view.delegate = context.coordinator + view.backgroundColor = .clear + view.textContainer.lineFragmentPadding = 0 + view.textContainerInset = .zero + view.isScrollEnabled = false + + view.font = UIFont.preferredFont(forTextStyle: .body) + + return view + } + + func updateUIView(_ uiView: UITextView, context: Context) { + if uiView.textColor != Self.placeholderColor, uiView.text != content { + uiView.text = content + } + + // when range (selection binding) has been changed in external place + if let externalSelection = selection, uiView.selectedRange != externalSelection { + uiView.selectedRange = externalSelection + } + } + + func sizeThatFits(_ proposal: ProposedViewSize, uiView: UITextView, context: Context) -> CGSize? { + guard let width = proposal.width else { return nil } + let dimensions = content.boundingRect( + with: CGSize(width: width, height: CGFloat.greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading], + attributes: [.font: UIFont.preferredFont(forTextStyle: .body)], + context: nil + ) + return CGSize(width: width, height: ceil(dimensions.height)) + } + + func makeCoordinator() -> Coordinator { + return Coordinator( + content: $content, + selection: $selection, + characterLimit: characterLimit, + placeholder: placeholder + ) + } + + final class Coordinator: NSObject, UITextViewDelegate { + var content: Binding + var selection: Binding + let characterLimit: Int? + let placeholder: LocalizedStringResource + + init( + content: Binding, + selection: Binding, + characterLimit: Int?, + placeholder: LocalizedStringResource + ) { + self.content = content + self.selection = selection + self.characterLimit = characterLimit + self.placeholder = placeholder + } + + func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText: String) -> Bool { + if let characterLimit = characterLimit { + return textView.text.count + replacementText.count <= characterLimit } - .overlay { - RoundedRectangle(cornerRadius: isLiquidGlass ? 28 : 14) - .strokeBorder(Color(.Separator.primary)) + return true + } + + func textViewDidChangeSelection(_ textView: UITextView) { + selection.wrappedValue = textView.selectedRange + } + + func textViewDidChange(_ textView: UITextView) { + content.wrappedValue = textView.text + } + + func textViewDidBeginEditing(_ textView: UITextView) { + if textView.textColor == placeholderColor { + textView.text = nil + textView.textColor = UIColor.label } - - if !description.isEmpty { - Text(description) - .font(.caption) - .foregroundStyle(Color(.Labels.teritary)) - .textCase(nil) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.leading, 16) + } + + func textViewDidEndEditing(_ textView: UITextView) { + if textView.text.isEmpty { + textView.text = String(localized: placeholder) + textView.textColor = placeholderColor } } - .animation(.default, value: false) - .onAppear { - isFocused = true + + func textViewShouldBeginEditing(_ textView: UITextView) -> Bool { + // init selection, cause until user not enter something, it always nil. + if selection.wrappedValue == nil { + selection.wrappedValue = NSMakeRange(0, 0) + } + return true } } } diff --git a/Modules/Sources/SharedUI/ForField.swift b/Modules/Sources/SharedUI/ForField.swift deleted file mode 100644 index 272cd181..00000000 --- a/Modules/Sources/SharedUI/ForField.swift +++ /dev/null @@ -1,206 +0,0 @@ -// -// ForField.swift -// ForPDA -// -// Created by Xialtal on 25.11.25. -// - -import SwiftUI - -public struct ForField: View { - - // MARK: - Properties - - @Environment(\.tintColor) private var tintColor - @FocusState.Binding var focus: T? - - var content: Binding - let placeholder: LocalizedStringResource - let focusEqual: T - let characterLimit: Int? - let minHeight: CGFloat? - var selection: Binding - - // MARK: - Init - - @available(*, deprecated, message: "Use init with Binding") - public init( - content: Binding, - placeholder: LocalizedStringResource, - focusEqual: T, - focus: FocusState.Binding, - characterLimit: Int? = nil, - minHeight: CGFloat? = nil, - ) { - self.content = content - self.placeholder = placeholder - self.focusEqual = focusEqual - self.characterLimit = characterLimit - self.minHeight = minHeight - - self.selection = .constant(nil) - - self._focus = focus - } - - public init( - content: Binding, - placeholder: LocalizedStringResource, - focusEqual: T, - focus: FocusState.Binding, - characterLimit: Int? = nil, - minHeight: CGFloat? = nil, - selection: Binding - ) { - self.content = content - self.placeholder = placeholder - self.focusEqual = focusEqual - self.characterLimit = characterLimit - self.minHeight = minHeight - self.selection = selection - - self._focus = focus - } - - // MARK: - Body - - public var body: some View { - Group { - SelectableTextView( - content: content, - selection: selection, - placeholder: placeholder, - characterLimit: characterLimit - ) - } - .padding(.vertical, 15) - .padding(.horizontal, 12) - .focused($focus, equals: focusEqual) - .foregroundStyle(Color(.Labels.primary)) - .frame(minHeight: minHeight, alignment: .top) - .background { - RoundedRectangle(cornerRadius: isLiquidGlass ? 28 : 14) - .fill(Color(.Background.teritary)) - } - .overlay { - RoundedRectangle(cornerRadius: isLiquidGlass ? 28 : 14) - .stroke($focus.wrappedValue == focusEqual ? tintColor : Color(.Separator.primary), lineWidth: 1) - } - .onTapGesture { - focus = focusEqual - } - } -} - -// MARK: - Selectable Text View - -private struct SelectableTextView: UIViewRepresentable { - @Binding var content: String - @Binding var selection: NSRange? - let placeholder: LocalizedStringResource - let characterLimit: Int? - - static let placeholderColor = UIColor.lightGray - - func makeUIView(context: Context) -> UITextView { - let view = UITextView() - view.text = content.isEmpty ? String(localized: placeholder) : content - view.textColor = content.isEmpty ? Self.placeholderColor : UIColor.label - view.delegate = context.coordinator - view.backgroundColor = .clear - view.textContainer.lineFragmentPadding = 0 - view.textContainerInset = .zero - view.isScrollEnabled = false - - view.font = UIFont.preferredFont(forTextStyle: .body) - - return view - } - - func updateUIView(_ uiView: UITextView, context: Context) { - if uiView.textColor != Self.placeholderColor, uiView.text != content { - uiView.text = content - } - - // when range (selection binding) has been changed in external place - if let externalSelection = selection, uiView.selectedRange != externalSelection { - uiView.selectedRange = externalSelection - } - } - - func sizeThatFits(_ proposal: ProposedViewSize, uiView: UITextView, context: Context) -> CGSize? { - guard let width = proposal.width else { return nil } - let dimensions = content.boundingRect( - with: CGSize(width: width, height: CGFloat.greatestFiniteMagnitude), - options: [.usesLineFragmentOrigin, .usesFontLeading], - attributes: [.font: UIFont.preferredFont(forTextStyle: .body)], - context: nil - ) - return CGSize(width: width, height: ceil(dimensions.height)) - } - - func makeCoordinator() -> Coordinator { - return Coordinator( - content: $content, - selection: $selection, - characterLimit: characterLimit, - placeholder: placeholder - ) - } - - final class Coordinator: NSObject, UITextViewDelegate { - var content: Binding - var selection: Binding - let characterLimit: Int? - let placeholder: LocalizedStringResource - - init( - content: Binding, - selection: Binding, - characterLimit: Int?, - placeholder: LocalizedStringResource - ) { - self.content = content - self.selection = selection - self.characterLimit = characterLimit - self.placeholder = placeholder - } - - func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText: String) -> Bool { - if let characterLimit = characterLimit { - return textView.text.count + replacementText.count <= characterLimit - } - return true - } - - func textViewDidChangeSelection(_ textView: UITextView) { - selection.wrappedValue = textView.selectedRange - } - - func textViewDidChange(_ textView: UITextView) { - content.wrappedValue = textView.text - } - - func textViewDidBeginEditing(_ textView: UITextView) { - if textView.textColor == placeholderColor { - textView.text = nil - textView.textColor = UIColor.label - } - } - - func textViewDidEndEditing(_ textView: UITextView) { - if textView.text.isEmpty { - textView.text = String(localized: placeholder) - textView.textColor = placeholderColor - } - } - - func textViewShouldBeginEditing(_ textView: UITextView) -> Bool { - // init selection, cause until user not enter something, it always nil. - if selection.wrappedValue == nil { - selection.wrappedValue = NSMakeRange(0, 0) - } - return true - } - } -} diff --git a/Modules/Sources/WriteFormFeature/WriteFormScreen.swift b/Modules/Sources/WriteFormFeature/WriteFormScreen.swift index 5c9cb9f3..85398cbf 100644 --- a/Modules/Sources/WriteFormFeature/WriteFormScreen.swift +++ b/Modules/Sources/WriteFormFeature/WriteFormScreen.swift @@ -163,7 +163,7 @@ public struct WriteFormScreen: View { .padding(.horizontal, 2) if store.isEditReasonToggleSelected { - ForField( + Field( content: $store.editReasonContent, placeholder: LocalizedStringResource("Input...", bundle: .module), focusEqual: WriteFormFeature.State.Field.reason, diff --git a/Modules/Sources/WriteFormFeature/WriteFormView.swift b/Modules/Sources/WriteFormFeature/WriteFormView.swift index b8cd3e92..99847bcd 100644 --- a/Modules/Sources/WriteFormFeature/WriteFormView.swift +++ b/Modules/Sources/WriteFormFeature/WriteFormView.swift @@ -40,7 +40,7 @@ struct WriteFormView: View { switch type { case .text(let content): Section { - ForField( + Field( content: Binding( get: { onUpdateContent(nil) }, set: { _ = onUpdateContent($0) } @@ -74,7 +74,7 @@ struct WriteFormView: View { case .editor(let content): Section { - ForField( + Field( content: Binding( get: { onUpdateContent(nil) }, set: { _ = onUpdateContent($0) } From 850e869ba3a0df2beaa2821c1b7d8bb40725f805 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Thu, 1 Jan 2026 21:08:57 +0300 Subject: [PATCH 026/118] Fix bb panel localization --- .../Sources/BBPanelFeature/BBPanelView.swift | 2 +- .../Resources/Localizable.xcstrings | 61 ++++++++++++++++++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/Modules/Sources/BBPanelFeature/BBPanelView.swift b/Modules/Sources/BBPanelFeature/BBPanelView.swift index 4df97807..256a139b 100644 --- a/Modules/Sources/BBPanelFeature/BBPanelView.swift +++ b/Modules/Sources/BBPanelFeature/BBPanelView.swift @@ -100,7 +100,7 @@ public struct BBPanelView: View { @ViewBuilder private func AlertInput(_ action: @escaping () -> Void) -> some View { - TextField(LocalizedStringKey("Input..."), text: $store.alertInput) + TextField(String(), text: $store.alertInput) Button(LocalizedStringResource("Cancel", bundle: .module)) { } diff --git a/Modules/Sources/BBPanelFeature/Resources/Localizable.xcstrings b/Modules/Sources/BBPanelFeature/Resources/Localizable.xcstrings index 900453da..529ef5ac 100644 --- a/Modules/Sources/BBPanelFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/BBPanelFeature/Resources/Localizable.xcstrings @@ -1,7 +1,66 @@ { "sourceLanguage" : "en", "strings" : { - + "Cancel" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отмена" + } + } + } + }, + "Colors" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Цвета" + } + } + } + }, + "Input full URL-address" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Введите полный URL-адрес" + } + } + } + }, + "Input spoiler title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Введите заголовок спойлера" + } + } + } + }, + "OK" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "ОК" + } + } + } + }, + "Sheet" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Шит" + } + } + } + } }, "version" : "1.1" } \ No newline at end of file From 2f90164878b34ea832711de8adf3bfe156c795fc Mon Sep 17 00:00:00 2001 From: Xialtal Date: Thu, 1 Jan 2026 21:18:07 +0300 Subject: [PATCH 027/118] Fix color code in bb panel --- Modules/Sources/BBPanelFeature/BBPanelView.swift | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Modules/Sources/BBPanelFeature/BBPanelView.swift b/Modules/Sources/BBPanelFeature/BBPanelView.swift index 256a139b..80f5d14f 100644 --- a/Modules/Sources/BBPanelFeature/BBPanelView.swift +++ b/Modules/Sources/BBPanelFeature/BBPanelView.swift @@ -51,7 +51,9 @@ public struct BBPanelView: View { } .sheet(isPresented: Binding($store.destination.colorTag)) { ColorPickerView(onColorSelected: { color in - send(.colorSelected(color.description)) + if let color = color.hexColor { + send(.colorSelected(color)) + } }) .presentationDetents([.medium]) } @@ -149,6 +151,18 @@ public struct BBPanelView: View { } } +// MARK: - Helpers + +extension Color { + var hexColor: String? { + let components = self.cgColor?.components + guard let r = components?[0], let g = components?[1], let b = components?[2] else { + return nil + } + return String(format: "#%02x%02x%02x", Int(r * 255), Int(g * 255), Int(b * 255)) + } +} + // MARK: - Previews #Preview { From b37d9bda7ffba78428366987212ed82e295ef097 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 2 Jan 2026 16:17:39 +0300 Subject: [PATCH 028/118] Remove smile tag from bb panel --- .../BBPanelFeature/BBPanelFeature.swift | 4 -- .../Sources/BBPanelFeature/BBPanelView.swift | 41 ------------------- .../BBPanelFeature/Models/BBPanelTag.swift | 4 -- .../BBPanelFeature/Models/BBPanelType.swift | 4 +- 4 files changed, 2 insertions(+), 51 deletions(-) diff --git a/Modules/Sources/BBPanelFeature/BBPanelFeature.swift b/Modules/Sources/BBPanelFeature/BBPanelFeature.swift index 826c7b89..cdd914f4 100644 --- a/Modules/Sources/BBPanelFeature/BBPanelFeature.swift +++ b/Modules/Sources/BBPanelFeature/BBPanelFeature.swift @@ -28,7 +28,6 @@ public struct BBPanelFeature: Reducer, Sendable { case sizeTag case listTag case colorTag - case smileTag case urlTag case spoilerWithTitleTag @@ -72,7 +71,6 @@ public struct BBPanelFeature: Reducer, Sendable { case delegate(Delegate) public enum Delegate { case tagTapped((String, String)) - case smileTapped(String) } } @@ -116,8 +114,6 @@ public struct BBPanelFeature: Reducer, Sendable { return .none case .spoilerWithTitle: state.destination = .spoilerWithTitleTag - case .smile: - state.destination = .smileTag } return .none diff --git a/Modules/Sources/BBPanelFeature/BBPanelView.swift b/Modules/Sources/BBPanelFeature/BBPanelView.swift index 80f5d14f..3b09e93a 100644 --- a/Modules/Sources/BBPanelFeature/BBPanelView.swift +++ b/Modules/Sources/BBPanelFeature/BBPanelView.swift @@ -46,9 +46,6 @@ public struct BBPanelView: View { .padding(.bottom, 8) .padding(.horizontal, 12) } - .sheet(isPresented: Binding($store.destination.smileTag)) { - SmilesList() - } .sheet(isPresented: Binding($store.destination.colorTag)) { ColorPickerView(onColorSelected: { color in if let color = color.hexColor { @@ -111,44 +108,6 @@ public struct BBPanelView: View { } .disabled(store.alertInput.isEmpty) } - - @ViewBuilder - private func SmilesList() -> some View { - Grid { - GridRow { - Text("Sheet") - Text("Sheet") - Text("Sheet") - } - - GridRow { - Text("Sheet") - Text("Sheet") - Text("Sheet") - } - - GridRow { - Text("Sheet") - Text("Sheet") - Text("Sheet") - } - - GridRow { - Text("Sheet") - Text("Sheet") - Text("Sheet") - } - - GridRow { - Text("Sheet") - Text("Sheet") - Text("Sheet") - } - } - .presentationDetents([.height(337)]) - .presentationDragIndicator(.visible) - - } } // MARK: - Helpers diff --git a/Modules/Sources/BBPanelFeature/Models/BBPanelTag.swift b/Modules/Sources/BBPanelFeature/Models/BBPanelTag.swift index 6c037b3b..91ae0778 100644 --- a/Modules/Sources/BBPanelFeature/Models/BBPanelTag.swift +++ b/Modules/Sources/BBPanelFeature/Models/BBPanelTag.swift @@ -32,7 +32,6 @@ public enum BBPanelTag { case mod case ex - case smile case upload } @@ -101,9 +100,6 @@ extension BBPanelTag { return .exclamationmarkSquare case .upload: return .paperclip - case .smile: - return .faceSmiling - } } } diff --git a/Modules/Sources/BBPanelFeature/Models/BBPanelType.swift b/Modules/Sources/BBPanelFeature/Models/BBPanelType.swift index 00d837d2..5bd0a7b0 100644 --- a/Modules/Sources/BBPanelFeature/Models/BBPanelType.swift +++ b/Modules/Sources/BBPanelFeature/Models/BBPanelType.swift @@ -18,10 +18,10 @@ extension BBPanelType { var kit: [BBPanelTag] { switch self { case .qms: - return [.smile, .b, .i, .u, .s, .quote, .code] + return [.b, .i, .u, .s, .quote, .code] case .post: return [ - .smile, .b, .i, .u, .s, .size, .color, .url, .listBullet, .listNumber, .quote, + .b, .i, .u, .s, .size, .color, .url, .listBullet, .listNumber, .quote, .spoiler, .spoilerWithTitle, .code, .left, .center, .right, .sub, .sup, .offtop, .hide ] case .profile: From baa9c27649eecf2aa1b6231f2ae65e45dd336c4d Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 2 Jan 2026 16:29:09 +0300 Subject: [PATCH 029/118] Remove unused import --- Modules/Sources/BBPanelFeature/BBPanelFeature.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Modules/Sources/BBPanelFeature/BBPanelFeature.swift b/Modules/Sources/BBPanelFeature/BBPanelFeature.swift index cdd914f4..00ac84ad 100644 --- a/Modules/Sources/BBPanelFeature/BBPanelFeature.swift +++ b/Modules/Sources/BBPanelFeature/BBPanelFeature.swift @@ -5,7 +5,6 @@ // Created by Xialtal on 28.12.25. // -import SwiftUI import Foundation import ComposableArchitecture From ae6a99e6e4aefd87bcf0e5623abdd9778a40ed02 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 2 Jan 2026 18:45:18 +0300 Subject: [PATCH 030/118] [WIP] BBPanel List Tag --- .../BBPanelFeature/BBPanelFeature.swift | 10 +- .../Sources/BBPanelFeature/BBPanelView.swift | 5 + .../LIstBuilder/ListTagBuilderFeature.swift | 103 +++++++++++++++ .../LIstBuilder/ListTagBuilderView.swift | 125 ++++++++++++++++++ .../LIstBuilder/Models/ListItemField.swift | 19 +++ .../Resources/Localizable.xcstrings | 66 +++++++-- 6 files changed, 317 insertions(+), 11 deletions(-) create mode 100644 Modules/Sources/BBPanelFeature/LIstBuilder/ListTagBuilderFeature.swift create mode 100644 Modules/Sources/BBPanelFeature/LIstBuilder/ListTagBuilderView.swift create mode 100644 Modules/Sources/BBPanelFeature/LIstBuilder/Models/ListItemField.swift diff --git a/Modules/Sources/BBPanelFeature/BBPanelFeature.swift b/Modules/Sources/BBPanelFeature/BBPanelFeature.swift index 00ac84ad..97cd1620 100644 --- a/Modules/Sources/BBPanelFeature/BBPanelFeature.swift +++ b/Modules/Sources/BBPanelFeature/BBPanelFeature.swift @@ -25,11 +25,12 @@ public struct BBPanelFeature: Reducer, Sendable { @Reducer public enum Destination { case sizeTag - case listTag case colorTag case urlTag case spoilerWithTitleTag + + case listTag(ListTagBuilderFeature) } // MARK: - State @@ -105,9 +106,9 @@ public struct BBPanelFeature: Reducer, Sendable { case .url: state.destination = .urlTag case .listNumber: - return .none + state.destination = .listTag(ListTagBuilderFeature.State(isBullet: false)) case .listBullet: - return .none + state.destination = .listTag(ListTagBuilderFeature.State(isBullet: true)) case .upload: // TODO: Attachments... return .none @@ -125,6 +126,9 @@ public struct BBPanelFeature: Reducer, Sendable { state.alertInput = "" return .send(.delegate(.tagTapped(("[\(tag.code)=\(input)]", "[/\(tag.code)]")))) + case let .destination(.presented(.listTag(.delegate(.listTagBuilded(tag))))): + return .send(.delegate(.tagTapped(tag))) + case .delegate, .destination, .binding: return .none } diff --git a/Modules/Sources/BBPanelFeature/BBPanelView.swift b/Modules/Sources/BBPanelFeature/BBPanelView.swift index 3b09e93a..f4c0a63e 100644 --- a/Modules/Sources/BBPanelFeature/BBPanelView.swift +++ b/Modules/Sources/BBPanelFeature/BBPanelView.swift @@ -46,6 +46,11 @@ public struct BBPanelView: View { .padding(.bottom, 8) .padding(.horizontal, 12) } + .sheet(item: $store.scope(state: \.destination?.listTag, action: \.destination.listTag)) { store in + NavigationStack { + ListTagBuilderView(store: store) + } + } .sheet(isPresented: Binding($store.destination.colorTag)) { ColorPickerView(onColorSelected: { color in if let color = color.hexColor { diff --git a/Modules/Sources/BBPanelFeature/LIstBuilder/ListTagBuilderFeature.swift b/Modules/Sources/BBPanelFeature/LIstBuilder/ListTagBuilderFeature.swift new file mode 100644 index 00000000..ef4895e0 --- /dev/null +++ b/Modules/Sources/BBPanelFeature/LIstBuilder/ListTagBuilderFeature.swift @@ -0,0 +1,103 @@ +// +// ListTagBuilderFeature.swift +// ForPDA +// +// Created by Xialtal on 2.01.26. +// + +import ComposableArchitecture + +@Reducer +public struct ListTagBuilderFeature: Reducer, Sendable { + + public init() {} + + // MARK: - State + + @ObservableState + public struct State: Equatable { + public enum Field: Hashable { case item(Int) } + + let isBullet: Bool + + var focus: Field? + + var listItems: [ListItemField] = [] + + var isAddItemButtonDisabled: Bool { + for item in listItems { + if item.content.isEmpty { + return true + } + } + return false + } + + public init( + isBullet: Bool + ) { + self.isBullet = isBullet + } + } + + // MARK: - Action + + public enum Action: BindableAction, ViewAction { + case binding(BindingAction) + + case view(View) + public enum View { + case onAppear + case createButtonTapped + case cancelButtonTapped + + case addListItemButtonTapped + } + + case delegate(Delegate) + public enum Delegate { + case listTagBuilded((String, String)) + } + } + + // MARK: - Dependencies + + @Dependency(\.dismiss) private var dismiss + + // MARK: - Body + + public var body: some Reducer { + BindingReducer() + + Reduce { state, action in + switch action { + case .view(.onAppear): + state.listItems.append(.init(id: 0, content: "")) + state.focus = .item(0) + return .none + + case .view(.addListItemButtonTapped): + let newId = state.listItems.count + state.listItems.append(.init(id: newId, content: "")) + state.focus = .item(newId) + return .none + + case .view(.createButtonTapped): + var leftTag = "[LIST\(!state.isBullet ? "=1" : "")]" + for item in state.listItems { + if item != state.listItems.first { + leftTag.append("\n") + } + leftTag.append("[*]\(item.content)") + } + return .send(.delegate(.listTagBuilded((leftTag, "[/LIST]")))) + + case .view(.cancelButtonTapped), .delegate(.listTagBuilded): + return .run { _ in await dismiss() } + + case .delegate, .binding: + return .none + } + } + } +} diff --git a/Modules/Sources/BBPanelFeature/LIstBuilder/ListTagBuilderView.swift b/Modules/Sources/BBPanelFeature/LIstBuilder/ListTagBuilderView.swift new file mode 100644 index 00000000..5aa5bac1 --- /dev/null +++ b/Modules/Sources/BBPanelFeature/LIstBuilder/ListTagBuilderView.swift @@ -0,0 +1,125 @@ +// +// ListTagBuilderView.swift +// ForPDA +// +// Created by Xialtal on 2.01.26. +// + +import SwiftUI +import ComposableArchitecture + +@ViewAction(for: ListTagBuilderFeature.self) +public struct ListTagBuilderView: View { + + // MARK: - Properties + + @Perception.Bindable public var store: StoreOf + @Environment(\.tintColor) private var tintColor + + @FocusState var focus: ListTagBuilderFeature.State.Field? + + // MARK: - Init + + public init(store: StoreOf) { + self.store = store + } + + // MARK: - Body + + public var body: some View { + ZStack { + Color(.Background.primary) + .ignoresSafeArea() + + List { + Section { + ForEach(store.listItems) { item in + ItemField(id: item.id) + } + + AddItemButton() + } + .fixedSize(horizontal: false, vertical: true) + .listRowBackground(Color(.Background.teritary)) + .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 0)) + + Text("New list items are created automatically", bundle: .module) + .font(.footnote) + .foregroundStyle(Color(.Labels.secondary)) + } + .scrollContentBackground(.hidden) + } + .navigationTitle(Text("New list", bundle: .module)) + .navigationBarTitleDisplayMode(.inline) + .bind($store.focus, to: $focus) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + send(.cancelButtonTapped) + } label: { + Text("Cancel", bundle: .module) + .foregroundStyle(tintColor) + } + } + + ToolbarItem(placement: .topBarTrailing) { + Button { + send(.createButtonTapped) + } label: { + Text("Create", bundle: .module) + .foregroundStyle(tintColor) + } + .disabled(store.isAddItemButtonDisabled) + } + } + .onAppear { + send(.onAppear) + } + } + + // MARK: - Add Item Button + + private func AddItemButton() -> some View { + Button { + send(.addListItemButtonTapped) + } label: { + Text("Add item", bundle: .module) + .font(.body) + .foregroundStyle(Color(.Labels.quaternary)) + } + .buttonStyle(.plain) + .disabled(store.isAddItemButtonDisabled) + } + + // MARK: - Item Field + + @ViewBuilder + public func ItemField(id: Int) -> some View { + TextField(text: $store.listItems[id].content, axis: .vertical) { + Text("Item \(id + 1)", bundle: .module) + .font(.body) + .foregroundStyle(Color(.Labels.quaternary)) + } + .focused($focus, equals: .item(id)) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .frame(height: 44) + .cornerRadius(10) + } +} + +// MARK: - Preview + +#Preview { + NavigationStack { + ListTagBuilderView( + store: Store( + initialState: ListTagBuilderFeature.State( + isBullet: true + ), + ) { + ListTagBuilderFeature() + } + ) + } +} diff --git a/Modules/Sources/BBPanelFeature/LIstBuilder/Models/ListItemField.swift b/Modules/Sources/BBPanelFeature/LIstBuilder/Models/ListItemField.swift new file mode 100644 index 00000000..6f2051dc --- /dev/null +++ b/Modules/Sources/BBPanelFeature/LIstBuilder/Models/ListItemField.swift @@ -0,0 +1,19 @@ +// +// ListItemField.swift +// ForPDA +// +// Created by Xialtal on 2.01.26. +// + +struct ListItemField: Equatable, Identifiable { + let id: Int + var content: String + + init( + id: Int, + content: String + ) { + self.id = id + self.content = content + } +} diff --git a/Modules/Sources/BBPanelFeature/Resources/Localizable.xcstrings b/Modules/Sources/BBPanelFeature/Resources/Localizable.xcstrings index 529ef5ac..9d8af142 100644 --- a/Modules/Sources/BBPanelFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/BBPanelFeature/Resources/Localizable.xcstrings @@ -1,9 +1,19 @@ { "sourceLanguage" : "en", "strings" : { + "Add item" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить пункт" + } + } + } + }, "Cancel" : { "localizations" : { - "en" : { + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Отмена" @@ -13,7 +23,7 @@ }, "Colors" : { "localizations" : { - "en" : { + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Цвета" @@ -21,9 +31,19 @@ } } }, + "Create" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Создать" + } + } + } + }, "Input full URL-address" : { "localizations" : { - "en" : { + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Введите полный URL-адрес" @@ -33,7 +53,7 @@ }, "Input spoiler title" : { "localizations" : { - "en" : { + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Введите заголовок спойлера" @@ -41,9 +61,39 @@ } } }, + "Item %lld" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пункт %lld" + } + } + } + }, + "New list" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Новый список" + } + } + } + }, + "New list items are created automatically" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Новые пункты создаются автоматически" + } + } + } + }, "OK" : { "localizations" : { - "en" : { + "ru" : { "stringUnit" : { "state" : "translated", "value" : "ОК" @@ -51,12 +101,12 @@ } } }, - "Sheet" : { + "Select text size" : { "localizations" : { - "en" : { + "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Шит" + "value" : "Select text size" } } } From 7a242b745e85e63a0c3f40f32c4357df8828bf70 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 2 Jan 2026 18:53:52 +0300 Subject: [PATCH 031/118] Fix input field for list code in bb panel --- .../BBPanelFeature/LIstBuilder/ListTagBuilderView.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Modules/Sources/BBPanelFeature/LIstBuilder/ListTagBuilderView.swift b/Modules/Sources/BBPanelFeature/LIstBuilder/ListTagBuilderView.swift index 5aa5bac1..3ab0d098 100644 --- a/Modules/Sources/BBPanelFeature/LIstBuilder/ListTagBuilderView.swift +++ b/Modules/Sources/BBPanelFeature/LIstBuilder/ListTagBuilderView.swift @@ -39,9 +39,8 @@ public struct ListTagBuilderView: View { AddItemButton() } - .fixedSize(horizontal: false, vertical: true) .listRowBackground(Color(.Background.teritary)) - .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 0)) + .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) Text("New list items are created automatically", bundle: .module) .font(.footnote) @@ -100,10 +99,11 @@ public struct ListTagBuilderView: View { .font(.body) .foregroundStyle(Color(.Labels.quaternary)) } + .padding(.vertical, 11) .focused($focus, equals: .item(id)) .multilineTextAlignment(.leading) .fixedSize(horizontal: false, vertical: true) - .frame(height: 44) + .frame(minHeight: 44) .cornerRadius(10) } } From 194d8db6961718c2038a76248a343e3ce555c5a9 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 2 Jan 2026 19:08:49 +0300 Subject: [PATCH 032/118] BB Panel list tag improvements --- .../BBPanelFeature/LIstBuilder/ListTagBuilderView.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Modules/Sources/BBPanelFeature/LIstBuilder/ListTagBuilderView.swift b/Modules/Sources/BBPanelFeature/LIstBuilder/ListTagBuilderView.swift index 3ab0d098..e9aa1fa1 100644 --- a/Modules/Sources/BBPanelFeature/LIstBuilder/ListTagBuilderView.swift +++ b/Modules/Sources/BBPanelFeature/LIstBuilder/ListTagBuilderView.swift @@ -38,13 +38,13 @@ public struct ListTagBuilderView: View { } AddItemButton() + } footer: { + Text("New list items are created automatically", bundle: .module) + .font(.footnote) + .foregroundStyle(Color(.Labels.teritary)) } .listRowBackground(Color(.Background.teritary)) .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) - - Text("New list items are created automatically", bundle: .module) - .font(.footnote) - .foregroundStyle(Color(.Labels.secondary)) } .scrollContentBackground(.hidden) } From 7f1ccf42c98e84246e4174e0ca7e06c058809b71 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 22 Feb 2026 14:34:31 +0300 Subject: [PATCH 033/118] [WIP] Upload and BBPanel --- Modules/Sources/APIClient/APIClient.swift | 1 + .../BBPanelFeature/BBPanelFeature.swift | 69 ++++- .../Sources/BBPanelFeature/BBPanelView.swift | 251 +++++++++++---- .../LIstBuilder/ListTagBuilderView.swift | 82 ++--- .../BBPanelFeature/Models/BBPanelTag.swift | 13 + .../Resources/Localizable.xcstrings | 18 ++ .../UploadBox/Models/UploadBoxFile.swift | 49 +++ .../UploadBox/Models/UploadBoxType.swift | 11 + .../UploadBox/UploadBoxFeature.swift | 287 ++++++++++++++++++ .../UploadBox/UploadBoxView.swift | 265 ++++++++++++++++ .../ProfileFeature/Edit/EditFeature.swift | 14 +- .../ProfileFeature/Edit/EditScreen.swift | 22 +- .../WriteFormFeature/WriteFormScreen.swift | 17 +- Project.swift | 2 + 14 files changed, 973 insertions(+), 128 deletions(-) create mode 100644 Modules/Sources/BBPanelFeature/UploadBox/Models/UploadBoxFile.swift create mode 100644 Modules/Sources/BBPanelFeature/UploadBox/Models/UploadBoxType.swift create mode 100644 Modules/Sources/BBPanelFeature/UploadBox/UploadBoxFeature.swift create mode 100644 Modules/Sources/BBPanelFeature/UploadBox/UploadBoxView.swift diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index 656a5f47..4be9ee0e 100644 --- a/Modules/Sources/APIClient/APIClient.swift +++ b/Modules/Sources/APIClient/APIClient.swift @@ -15,6 +15,7 @@ import ComposableArchitecture import PersistenceKeys public typealias ConnectionState = API.ConnectionState +public typealias UploadRequest = PDAPI.UploadRequest public typealias UploadProgressStatus = PDAPI.UploadProgressStatus public typealias PDAPIError = APIError diff --git a/Modules/Sources/BBPanelFeature/BBPanelFeature.swift b/Modules/Sources/BBPanelFeature/BBPanelFeature.swift index 97cd1620..940561d6 100644 --- a/Modules/Sources/BBPanelFeature/BBPanelFeature.swift +++ b/Modules/Sources/BBPanelFeature/BBPanelFeature.swift @@ -33,22 +33,44 @@ public struct BBPanelFeature: Reducer, Sendable { case listTag(ListTagBuilderFeature) } + // MARK: - View State + + public enum BBPanelViewState { + case tags + case colorPicker + case fontSizePicker + } + // MARK: - State @ObservableState public struct State: Equatable { @Presents var destination: Destination.State? + var upload = UploadBoxFeature.State( + type: .bbPanel, + allowedExtensions: [] + ) + let panelWith: BBPanelType + let supportsUpload: Bool var tags: [BBPanelTag] = [] + var viewState: BBPanelViewState = .tags var alertInput = "" + var textSize = 1 + + var isUploading = false + var uploadedFiles = 0 + var showUploadBox = false public init( - with: BBPanelType + for panelType: BBPanelType, + supportsUpload: Bool = false ) { - self.panelWith = with + self.panelWith = panelType + self.supportsUpload = supportsUpload } } @@ -57,13 +79,15 @@ public struct BBPanelFeature: Reducer, Sendable { public enum Action: BindableAction, ViewAction { case binding(BindingAction) case destination(PresentationAction) + case upload(UploadBoxFeature.Action) case view(View) public enum View { case onAppear case tagButtonTapped(BBPanelTag) - case alertTagButtonTapped(BBPanelTag) + case hideUploadBoxButtonTapped + case returnTagsButtonTapped case colorSelected(String) } @@ -79,10 +103,17 @@ public struct BBPanelFeature: Reducer, Sendable { public var body: some Reducer { BindingReducer() + Scope(state: \.upload, action: \.upload) { + UploadBoxFeature() + } + Reduce { state, action in switch action { case .view(.onAppear): var tags = state.panelWith.kit + if state.supportsUpload { + tags.insert(.upload, at: 0) + } if case let .post(isCurator, canModerate) = state.panelWith { if canModerate { tags.append(.cur) @@ -98,25 +129,31 @@ public struct BBPanelFeature: Reducer, Sendable { case let .view(.tagButtonTapped(tag)): switch tag { case .b, .i, .s, .u, .sup, .sub, .offtop, .center, .left, .right, .hide, .code, .cur, .mod, .ex, .quote, .spoiler: + print("SIMPLE Tag tapped: \(tag)") return .send(.delegate(.tagTapped(("[\(tag.code)]", "[/\(tag.code)]")))) case .size: - return .none + //state.destination = .sizeTag + state.viewState = .fontSizePicker case .color: - state.destination = .colorTag + //state.destination = .colorTag + state.viewState = .colorPicker case .url: state.destination = .urlTag + case .spoilerWithTitle: + state.destination = .spoilerWithTitleTag case .listNumber: state.destination = .listTag(ListTagBuilderFeature.State(isBullet: false)) case .listBullet: state.destination = .listTag(ListTagBuilderFeature.State(isBullet: true)) case .upload: - // TODO: Attachments... - return .none - case .spoilerWithTitle: - state.destination = .spoilerWithTitleTag + state.showUploadBox.toggle() } return .none + case .view(.returnTagsButtonTapped): + state.viewState = .tags + return .none + case let .view(.colorSelected(color)): state.destination = nil return .send(.delegate(.tagTapped(("[COLOR=\(color)]", "[/COLOR]")))) @@ -126,10 +163,22 @@ public struct BBPanelFeature: Reducer, Sendable { state.alertInput = "" return .send(.delegate(.tagTapped(("[\(tag.code)=\(input)]", "[/\(tag.code)]")))) + case .view(.hideUploadBoxButtonTapped): + state.showUploadBox = false + return .none + case let .destination(.presented(.listTag(.delegate(.listTagBuilded(tag))))): return .send(.delegate(.tagTapped(tag))) - case .delegate, .destination, .binding: + case .upload(.delegate(.someFileUploading)): + state.isUploading = true + return .none + + case .upload(.delegate(.allFilesAreUploaded)): + state.isUploading = false + return .none + + case .delegate, .destination, .binding, .upload: return .none } } diff --git a/Modules/Sources/BBPanelFeature/BBPanelView.swift b/Modules/Sources/BBPanelFeature/BBPanelView.swift index f4c0a63e..d453ae3c 100644 --- a/Modules/Sources/BBPanelFeature/BBPanelView.swift +++ b/Modules/Sources/BBPanelFeature/BBPanelView.swift @@ -29,79 +29,213 @@ public struct BBPanelView: View { public var body: some View { WithPerceptionTracking { - if #available(iOS 26.0, *) { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 20) { - ForEach(store.tags, id: \.self) { tag in + VStack { + if store.showUploadBox { + UploadBox() + } + + if #available(iOS 26.0, *) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 20) { + switch store.viewState { + case .tags: + Tags() + case .colorPicker: + Text("ColorPicker") + case .fontSizePicker: + FontSize() + } + } + .padding(.top, 6) + .padding(.bottom, 8) + .padding(.horizontal, 12) + } + .sheet(isPresented: Binding($store.destination.sizeTag)) { + Picker("Select text size", selection: $store.textSize) { + ForEach(1..<8) { size in + Text(verbatim: "\(size)") + } + } + .pickerStyle(.wheel) + .presentationDetents([.medium]) + } + .sheet(item: $store.scope(state: \.destination?.listTag, action: \.destination.listTag)) { store in + NavigationStack { + ListTagBuilderView(store: store) + } + } + .sheet(isPresented: Binding($store.destination.colorTag)) { + ColorPickerView(onColorSelected: { color in + if let color = color.hexColor { + send(.colorSelected(color)) + } + }) + .presentationDetents([.medium]) + } + .alert( + BBPanelFeature.Localization.inputFullUrl, + isPresented: $store.destination.urlTag + ) { + AlertInput({ + send(.alertTagButtonTapped(.url)) + }) + } + .alert( + BBPanelFeature.Localization.inputSpoilerTitle, + isPresented: Binding($store.destination.spoilerWithTitleTag) + ) { + AlertInput({ + send(.alertTagButtonTapped(.spoilerWithTitle)) + }) + } + .animation(.bouncy, value: store.viewState) + .background(.bar.opacity(0.5), in: .capsule) + .glassEffect() + .shadow(color: .black.opacity(0.2), radius: 20, y: 10) + .onAppear { + send(.onAppear) + } + } else { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 20) { Button { - send(.tagButtonTapped(tag)) + } label: { - Image(systemSymbol: tag.icon) + Image(systemSymbol: .plusAppFill) .foregroundStyle(Color(.Labels.primary)) } .buttonStyle(.plain) } + .padding(.top, 6) + .padding(.bottom, 8) + .padding(.horizontal, 12) + .border(Color(.red/*Background.secondary*/)) + .background(Color(.Background.secondary)) } - .padding(.top, 6) - .padding(.bottom, 8) - .padding(.horizontal, 12) } - .sheet(item: $store.scope(state: \.destination?.listTag, action: \.destination.listTag)) { store in - NavigationStack { - ListTagBuilderView(store: store) + } + } + } + + @ViewBuilder + private func FontSize() -> some View { + Button { + send(.returnTagsButtonTapped) + } label: { + Image(systemName: "xmark") + .font(.title3) + .padding(.vertical, 2) + } + .buttonStyle(.plain) + + ForEach(2..<9) { size in + Button { + // TODO: dsaddd + } label: { + Image(systemName: "\(size).square") + .font(.title3) + .foregroundStyle(tintColor) + } + .buttonStyle(.plain) + } + } + + // MARK: - Tags + + @ViewBuilder + private func Tags() -> some View { + ForEach(store.tags, id: \.self) { tag in + TagButton(tag) + } + } + + // MARK: - Tag Button + + @ViewBuilder + private func TagButton(_ tag: BBPanelTag) -> some View { + Button { + send(.tagButtonTapped(tag)) + } label: { + Image(systemSymbol: tag.icon) + .font(.title3) + .foregroundStyle(tagButtonColor(tag)) + .overlay(alignment: .topTrailing) { + if tag == .upload, store.uploadedFiles != 0 { + Circle() + .overlay { + Text(verbatim: "\(store.uploadedFiles)") + .font(.caption2) + .fontWeight(.semibold) + .foregroundStyle(Color(.Labels.primaryInvariably)) + } + .frame(width: 21, height: 18) + .foregroundStyle(tintColor) + .offset(x: 10, y: -5) } } - .sheet(isPresented: Binding($store.destination.colorTag)) { - ColorPickerView(onColorSelected: { color in - if let color = color.hexColor { - send(.colorSelected(color)) - } - }) - .presentationDetents([.medium]) - } - .alert( - BBPanelFeature.Localization.inputFullUrl, - isPresented: Binding($store.destination.urlTag) - ) { - AlertInput({ - send(.alertTagButtonTapped(.url)) - }) - } - .alert( - BBPanelFeature.Localization.inputSpoilerTitle, - isPresented: Binding($store.destination.spoilerWithTitleTag) - ) { - AlertInput({ - send(.alertTagButtonTapped(.spoilerWithTitle)) - }) - } - .background(.bar.opacity(0.5), in: .capsule) - .glassEffect() - .shadow(color: .black.opacity(0.2), radius: 20, y: 10) - .onAppear { - send(.onAppear) + } + .buttonStyle(.plain) + } + + private func tagButtonColor(_ tag: BBPanelTag) -> Color { + return tag == .upload && (store.isUploading || store.showUploadBox) + ? tintColor + : Color(.Labels.primary) + } + + // MARK: - Upload Box + + private func UploadBox() -> some View { + VStack { + HStack { + Text("Attachments", bundle: .module) + .font(.title3) + .fontWeight(.semibold) + .foregroundStyle(Color(.Labels.primary)) + + Spacer() + + Button { + send(.hideUploadBoxButtonTapped) + } label: { + Image(systemSymbol: .xmark) + .font(.caption2) + .fontWeight(.bold) + .foregroundStyle(Color(.Labels.teritary)) + .frame(width: 30, height: 30) + .background( + Circle() + .fill(Color(.Background.quaternary)) + .clipShape(Circle()) + ) } + } + + UploadBoxView(store: store.scope(state: \.upload, action: \.upload)) + .padding(.bottom, 32) + } + .padding(.top, 16) + .padding(.horizontal, 16) + .background { + if #available(iOS 26.0, *) { + UnevenRoundedRectangle(cornerRadii: .init( + topLeading: 28, + bottomLeading: 0, + bottomTrailing: 0, + topTrailing: 28 + )) + .fill(Color(.Background.primary)) } else { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 20) { - Button { - - } label: { - Image(systemSymbol: .plusAppFill) - .foregroundStyle(Color(.Labels.primary)) - } - .buttonStyle(.plain) - } - .padding(.top, 6) - .padding(.bottom, 8) - .padding(.horizontal, 12) - .border(Color(.red/*Background.secondary*/)) - .background(Color(.Background.secondary)) - } + RoundedRectangle(cornerRadius: 14) + .fill(Color(.Background.primary)) } } + .frame(height: 190) + .shadow(color: Color(.Labels.primary).opacity(0.15), radius: 10, y: 4) } + // MARK: - Alert Input + @ViewBuilder private func AlertInput(_ action: @escaping () -> Void) -> some View { TextField(String(), text: $store.alertInput) @@ -133,7 +267,8 @@ extension Color { BBPanelView( store: Store( initialState: BBPanelFeature.State( - with: .qms + for: .post(isCurator: true, canModerate: true), + supportsUpload: true ), ) { BBPanelFeature() diff --git a/Modules/Sources/BBPanelFeature/LIstBuilder/ListTagBuilderView.swift b/Modules/Sources/BBPanelFeature/LIstBuilder/ListTagBuilderView.swift index e9aa1fa1..cea1f700 100644 --- a/Modules/Sources/BBPanelFeature/LIstBuilder/ListTagBuilderView.swift +++ b/Modules/Sources/BBPanelFeature/LIstBuilder/ListTagBuilderView.swift @@ -27,52 +27,54 @@ public struct ListTagBuilderView: View { // MARK: - Body public var body: some View { - ZStack { - Color(.Background.primary) - .ignoresSafeArea() - - List { - Section { - ForEach(store.listItems) { item in - ItemField(id: item.id) + WithPerceptionTracking { + ZStack { + Color(.Background.primary) + .ignoresSafeArea() + + List { + Section { + ForEach(store.listItems) { item in + ItemField(id: item.id) + } + + AddItemButton() + } footer: { + Text("New list items are created automatically", bundle: .module) + .font(.footnote) + .foregroundStyle(Color(.Labels.teritary)) } - - AddItemButton() - } footer: { - Text("New list items are created automatically", bundle: .module) - .font(.footnote) - .foregroundStyle(Color(.Labels.teritary)) + .listRowBackground(Color(.Background.teritary)) + .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) } - .listRowBackground(Color(.Background.teritary)) - .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + .scrollContentBackground(.hidden) } - .scrollContentBackground(.hidden) - } - .navigationTitle(Text("New list", bundle: .module)) - .navigationBarTitleDisplayMode(.inline) - .bind($store.focus, to: $focus) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button { - send(.cancelButtonTapped) - } label: { - Text("Cancel", bundle: .module) - .foregroundStyle(tintColor) + .navigationTitle(Text("New list", bundle: .module)) + .navigationBarTitleDisplayMode(.inline) + .bind($store.focus, to: $focus) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + send(.cancelButtonTapped) + } label: { + Text("Cancel", bundle: .module) + .foregroundStyle(tintColor) + } } - } - - ToolbarItem(placement: .topBarTrailing) { - Button { - send(.createButtonTapped) - } label: { - Text("Create", bundle: .module) - .foregroundStyle(tintColor) + + ToolbarItem(placement: .topBarTrailing) { + Button { + send(.createButtonTapped) + } label: { + Text("Create", bundle: .module) + .foregroundStyle(tintColor) + } + .disabled(store.isAddItemButtonDisabled) } - .disabled(store.isAddItemButtonDisabled) } - } - .onAppear { - send(.onAppear) + .onAppear { + send(.onAppear) + } } } diff --git a/Modules/Sources/BBPanelFeature/Models/BBPanelTag.swift b/Modules/Sources/BBPanelFeature/Models/BBPanelTag.swift index 91ae0778..86f64cf9 100644 --- a/Modules/Sources/BBPanelFeature/Models/BBPanelTag.swift +++ b/Modules/Sources/BBPanelFeature/Models/BBPanelTag.swift @@ -35,6 +35,19 @@ public enum BBPanelTag { case upload } +public enum ColorTagColors { + case teal +} + +public enum FontTagSize { + case two + case three + case four + case five + case six + case seven + case eight +} extension BBPanelTag { var code: String { diff --git a/Modules/Sources/BBPanelFeature/Resources/Localizable.xcstrings b/Modules/Sources/BBPanelFeature/Resources/Localizable.xcstrings index 9d8af142..ad85e1d0 100644 --- a/Modules/Sources/BBPanelFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/BBPanelFeature/Resources/Localizable.xcstrings @@ -10,6 +10,9 @@ } } } + }, + "Attachments" : { + }, "Cancel" : { "localizations" : { @@ -20,6 +23,15 @@ } } } + }, + "Choose from Files" : { + + }, + "Choose from Gallery" : { + + }, + "ColorPicker" : { + }, "Colors" : { "localizations" : { @@ -100,6 +112,9 @@ } } } + }, + "Select files..." : { + }, "Select text size" : { "localizations" : { @@ -110,6 +125,9 @@ } } } + }, + "The selected file will be inserted into the text as code wherever your cursor is located. Or it will also be automatically added to the end of the post." : { + } }, "version" : "1.1" diff --git a/Modules/Sources/BBPanelFeature/UploadBox/Models/UploadBoxFile.swift b/Modules/Sources/BBPanelFeature/UploadBox/Models/UploadBoxFile.swift new file mode 100644 index 00000000..209504bd --- /dev/null +++ b/Modules/Sources/BBPanelFeature/UploadBox/Models/UploadBoxFile.swift @@ -0,0 +1,49 @@ +// +// UploadBoxFile.swift +// ForPDA +// +// Created by Xialtal on 2.01.26. +// + +import Foundation + +public struct UploadBoxFile: Sendable, Identifiable, Equatable { + public let id = UUID() + public let name: String + public let type: FileType + public let data: Data + public var isUploading: Bool + public var isUploadError: Bool + + public enum FileType: Sendable { + case file, image + } + + public init( + name: String, + type: FileType, + data: Data, + isUploading: Bool = false, + isUploadError: Bool = false + ) { + self.name = name + self.type = type + self.data = data + self.isUploading = isUploading + self.isUploadError = isUploadError + } +} + +extension UploadBoxFile { + static let mockImage = UploadBoxFile( + name: UUID().uuidString, + type: .image, + data: Data() + ) + + static let mockFile = UploadBoxFile( + name: UUID().uuidString, + type: .file, + data: Data() + ) +} diff --git a/Modules/Sources/BBPanelFeature/UploadBox/Models/UploadBoxType.swift b/Modules/Sources/BBPanelFeature/UploadBox/Models/UploadBoxType.swift new file mode 100644 index 00000000..8e766732 --- /dev/null +++ b/Modules/Sources/BBPanelFeature/UploadBox/Models/UploadBoxType.swift @@ -0,0 +1,11 @@ +// +// UploadBoxType.swift +// ForPDA +// +// Created by Xialtal on 2.01.26. +// + +public enum UploadBoxType { + case form + case bbPanel +} diff --git a/Modules/Sources/BBPanelFeature/UploadBox/UploadBoxFeature.swift b/Modules/Sources/BBPanelFeature/UploadBox/UploadBoxFeature.swift new file mode 100644 index 00000000..931a0acf --- /dev/null +++ b/Modules/Sources/BBPanelFeature/UploadBox/UploadBoxFeature.swift @@ -0,0 +1,287 @@ +// +// UploadBoxFeature.swift +// ForPDA +// +// Created by Xialtal on 2.01.26. +// + +import SwiftUI +import ComposableArchitecture +import APIClient +import CryptoKit + +@Reducer +public struct UploadBoxFeature: Reducer, Sendable { + + // MARK: - Helpers + + var isPreview: Bool { + return ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" + } + + // MARK: - Destination + + @Reducer + public enum Destination { + case confirmationDialog(ConfirmationDialogState) + case fileImporter + case photosPicker + + @CasePathable + public enum Dialog { + case gallery, files + } + } + + // MARK: - State + + @ObservableState + public struct State: Equatable { + @Presents public var destination: Destination.State? + + let type: UploadBoxType + let allowedExtensions: [String] + + var files: [UploadBoxFile] + //var uploadQueue: [UUID] = [] + var isAnyFileUploading = false + + public var filesCount: Int { + return files.count + } + + public init( + type: UploadBoxType, + allowedExtensions: [String], + files: [UploadBoxFile] = [] + ) { + self.type = type + self.allowedExtensions = allowedExtensions + self.files = files + } + } + + // MARK: - Actions + + public enum Action: BindableAction, ViewAction { + case binding(BindingAction) + case destination(PresentationAction) + + case view(View) + public enum View { + case selectFilesButtonTapped + case removeFileButtonTapped(UploadBoxFile) + case photosPickerPhotoSelected(Data) + case fileImporterURLsRecieved([URL]) + } + + case `internal`(Internal) + public enum Internal { + case uploadFile(UploadBoxFile) + case updateFileUploadStatus(UUID, UploadProgressStatus) + } + + case delegate(Delegate) + public enum Delegate { + case someFileUploading + case allFilesAreUploaded + case filesHasBeenTapped(String) + } + } + + // MARK: - Dependencies + + @Dependency(\.apiClient) private var apiClient + + // MARK: - Cancellable + + private enum CancelID: Hashable { case uploading(UUID) } + + // MARK: - Body + + public var body: some Reducer { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding: + break + + case .delegate: + break + + case .destination(.presented(.confirmationDialog(.files))): + if isPreview { + return .send(.view(.fileImporterURLsRecieved([]))) + } else { + state.destination = .fileImporter + } + + case .destination(.presented(.confirmationDialog(.gallery))): + if isPreview { + return .send(.view(.photosPickerPhotoSelected(Data()))) + } else { + state.destination = .photosPicker + } + + case .destination: + break + + case .view(.selectFilesButtonTapped): + let dialogState = ConfirmationDialogState( + title: { TextState(verbatim: "") }, + actions: { + ButtonState(action: .gallery) { + TextState("Choose from Gallery", bundle: .module) + } + ButtonState(action: .files) { + TextState("Choose from Files", bundle: .module) + } + } + ) + state.destination = .confirmationDialog(dialogState) + + case let .view(.removeFileButtonTapped(file)): + state.files.removeAll(where: { $0.id == file.id }) + if file.isUploading { + return .cancel(id: CancelID.uploading(file.id)) + } + + case let .view(.photosPickerPhotoSelected(data)): + if !isPreview { + if let imageExtension = data.imageExtension { + let file = UploadBoxFile( + name: "\(UUID().uuidString).\(imageExtension)", + type: .image, + data: data + ) + return .send(.internal(.uploadFile(file))) + } else { + // TODO: send error alert + } + } else { + state.files.append(.mockImage) + } + + case let .view(.fileImporterURLsRecieved(urls)): + //var urls = urls +#warning("Fix preview") + if isPreview { + let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let fileURL = documentsURL.appending(path: "data.dat") + try! Data().write(to: fileURL) + //urls.append(fileURL) + state.files.append(.mockFile) + return .none + } + return .run { [urls = urls] send in + for url in urls { + if url.startAccessingSecurityScopedResource() { + guard let data = try? Data(contentsOf: url) else { + // TODO: Add alert + print("Couldn't extract data from url: \(url)") + continue + } + url.stopAccessingSecurityScopedResource() + + let file = UploadBoxFile( + name: url.lastPathComponent, + type: .file, + data: data, + isUploading: true + ) + await send(.internal(.uploadFile(file))) + + while file.isUploading { /* waiting... */ } + } else { + print("=============== NO ACCESS ================") + } + } + } + + case let .internal(.uploadFile(file)): + state.files.append(file) + state.isAnyFileUploading = true + return .run(priority: .userInitiated) { [file = file] send in + let request = UploadRequest( + fileName: file.name, + fileSize: file.data.count, + fileData: file.data, + md5: calculateFileHash(data: file.data), + isQms: false + ) + for await status in apiClient.upload(request) { + await send(.internal(.updateFileUploadStatus(file.id, status))) + } + } + .cancellable(id: CancelID.uploading(file.id), cancelInFlight: true) + + case let .internal(.updateFileUploadStatus(id, status)): + if let index = state.files.firstIndex(where: { $0.id == id }) { + switch status { + case .done(let response): + print("YEY<<<<<<< \(response)") + state.files[index].isUploading = false + state.isAnyFileUploading = false + case .uploading(let value): + print("Reducer UPLAODING: \(value)") + state.files[index].isUploading = true + case .initialized: + print("FILE UPLOADING INITIALIZED") + state.files[index].isUploading = true + case .error(let err): + // TODO: Alert? + print("ERROR ON FILE UPLOADING: \(err)") + state.files[index].isUploading = false + state.files[index].isUploadError = true + @unknown default: + print("UNKNOWN DEFAULT ERROR! \(id), \(status)") + state.files[index].isUploading = false + state.files[index].isUploadError = true + } + } else { + // TODO: Handle error. + } + return .none + } + + return .none + } + .ifLet(\.$destination, action: \.destination) + } +} + +extension UploadBoxFeature.Destination.State: Equatable {} + +// MARK: - Helpers + +private extension UploadBoxFeature { + func calculateFileHash(data: Data) -> String { + return Insecure.MD5.hash(data: data) + .map { byte in String(format: "%02X", byte) } + .joined() + } +} + +private extension Data { + var imageExtension: String? { + switch mimeType { + case 0xFF: + return "jpeg" + case 0x89: + return "png" + case 0x47: + return "gif" + case 0x49, 0x4D: + return "tiff" + default: + return nil + } + } + + private var mimeType: UInt8 { + var mt: UInt8 = 0 + copyBytes(to: &mt, count: 1) + return mt + } +} diff --git a/Modules/Sources/BBPanelFeature/UploadBox/UploadBoxView.swift b/Modules/Sources/BBPanelFeature/UploadBox/UploadBoxView.swift new file mode 100644 index 00000000..30f358f5 --- /dev/null +++ b/Modules/Sources/BBPanelFeature/UploadBox/UploadBoxView.swift @@ -0,0 +1,265 @@ +// +// UploadBoxView.swift +// ForPDA +// +// Created by Xialtal on 2.01.26. +// + +import SwiftUI +import ComposableArchitecture +import PhotosUI + +@ViewAction(for: UploadBoxFeature.self) +struct UploadBoxView: View { + + // MARK: - Properties + + @Perception.Bindable var store: StoreOf + @Environment(\.tintColor) private var tintColor + + @State private var pickerItem: PhotosPickerItem? + + // MARK: - Body + + var body: some View { + WithPerceptionTracking { + VStack(spacing: 6) { + WithPerceptionTracking { + switch store.type { + case .bbPanel: + FilesGrid() + case .form: + if store.files.isEmpty { + FormUploadView() + } else { + FilesGrid() + } + } + } + } + .confirmationDialog( + $store.scope( + state: \.destination?.confirmationDialog, + action: \.destination.confirmationDialog + ) + ) + .fileImporter( + isPresented: Binding($store.destination.fileImporter), + allowedContentTypes: [.item], // server will decide + allowsMultipleSelection: true, + onCompletion: { result in + switch result { + case let .success(urls): + send(.fileImporterURLsRecieved(urls)) + case let .failure(error): + print("File importer error: \(error)") +#warning("Handle error") + } + } + ) + .photosPicker( + isPresented: Binding($store.destination.photosPicker), + selection: $pickerItem + ) + .task(id: pickerItem) { + guard let data = try? await pickerItem?.loadTransferable(type: Data.self) else { + return + } + send(.photosPickerPhotoSelected(data)) + } + .tint(tintColor) + } + } + + // MARK: - Form Upload View + + @ViewBuilder + private func FormUploadView() -> some View { + Button { + send(.selectFilesButtonTapped) + } label: { + VStack(spacing: 8) { + Image(systemSymbol: .docBadgePlus) + .font(.title) + .frame(width: 48, height: 48) + + Text("Select files...", bundle: .module) + .font(.body) + .foregroundColor(Color(.Labels.quaternary)) + } + .frame(maxWidth: .infinity, minHeight: 144) + .background { + RoundedRectangle(cornerRadius: 14) + .fill(Color(.Background.teritary)) + } + .overlay { + RoundedRectangle(cornerRadius: 14) + .strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [8])) + } + } + .disabled(store.isAnyFileUploading) + } + + // MARK: - Files Grid + + private func FilesGrid() -> some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + Button { + send(.selectFilesButtonTapped) + } label: { + Image(systemSymbol: .plus) + .font(.title) + .foregroundStyle(tintColor) + .frame(width: 48, height: 48) + } + .frame(minWidth: 48, maxWidth: 48, minHeight: 144) + .padding(.horizontal, 12) + .background(Color(.Background.teritary)) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .disabled(store.isAnyFileUploading) + + ForEach(store.files, id: \.id) { file in + FileView(file) + } + + if store.files.isEmpty { + HStack { + Image(systemSymbol: .infoCircle) + .font(.body) + .foregroundStyle(Color(.Labels.teritary)) + + Text("The selected file will be inserted into the text as code wherever your cursor is located. Or it will also be automatically added to the end of the post.", bundle: .module) + .font(.footnote) + .foregroundStyle(Color(.Labels.teritary)) + } + .padding(.leading, 6) + .padding(.vertical, 12) + .frame(width: 296) + } + } + } + .animation(.default, value: store.files) + } + + // MARK: - File View + + @ViewBuilder + private func FileView(_ file: UploadBoxFile) -> some View { + VStack(spacing: 0) { + if file.isUploading { + ProgressView() + .frame(width: 28, height: 28) + } else if file.isUploadError { + Text(verbatim: "File upload ERROR") + .font(.title) + .foregroundColor(tintColor) + } else { + Image(systemSymbol: file.type == .file ? .doc : .photo) + .font(.title) + .foregroundColor(tintColor) + .frame(width: 48, height: 48) + + Text(file.name) + .font(.footnote) + .foregroundStyle(Color(.Labels.primary)) + .lineLimit(2) + .multilineTextAlignment(.center) + } + } + .padding(.horizontal, 12) + .frame(minWidth: 144, maxWidth: 144, minHeight: 144) + .background( + RoundedRectangle(cornerRadius: 14) + .fill(Color(.Background.teritary)) + ) + .overlay(alignment: .topTrailing) { + Button { + send(.removeFileButtonTapped(file)) + } label: { + Circle() + .fill(Color(.Background.teritary)) + .frame(width: 28, height: 28) + .overlay { + Image(systemSymbol: .xmark) + .font(.body) + .foregroundStyle(Color(.Labels.teritary)) + } + .padding([.top, .trailing], 6) + } + .buttonStyle(.plain) + } + } +} + +// MARK: - Previews + +#Preview("Form Upload Box (Empty)") { + UploadBoxView( + store: Store( + initialState: UploadBoxFeature.State( + type: .form, + allowedExtensions: ["jpg", "jpeg", "gif", "png"] + ) + ) { + UploadBoxFeature() + } + ) + .padding(.horizontal, 16) + .environment(\.tintColor, Color(.Theme.primary)) +} + +#Preview("Form Upload Box (Filled, 3)") { + UploadBoxView( + store: Store( + initialState: UploadBoxFeature.State( + type: .form, + allowedExtensions: ["jpg", "jpeg", "gif", "png"], + files: [ + .init(name: "File 1", type: .file, data: Data()), + .init(name: "Image 1", type: .image, data: Data()), + .init(name: "File 2", type: .file, data: Data()), + ] + ) + ) { + UploadBoxFeature() + } + ) + .padding(.horizontal, 16) + .environment(\.tintColor, Color(.Theme.primary)) +} + +#Preview("BBPanel Upload Box (Empty)") { + UploadBoxView( + store: Store( + initialState: UploadBoxFeature.State( + type: .bbPanel, + allowedExtensions: ["jpg", "jpeg", "gif", "png"] + ) + ) { + UploadBoxFeature() + } + ) + .padding(.horizontal, 16) + .environment(\.tintColor, Color(.Theme.primary)) +} + +#Preview("BBPanel Upload Box (Filled, 3)") { + UploadBoxView( + store: Store( + initialState: UploadBoxFeature.State( + type: .bbPanel, + allowedExtensions: ["jpg", "jpeg", "gif", "png"], + files: [ + .init(name: "File 1", type: .file, data: Data()), + .init(name: "Image 1", type: .image, data: Data()), + .init(name: "File 2", type: .file, data: Data()), + ] + ) + ) { + UploadBoxFeature() + } + ) + .padding(.horizontal, 16) + .environment(\.tintColor, Color(.Theme.primary)) +} diff --git a/Modules/Sources/ProfileFeature/Edit/EditFeature.swift b/Modules/Sources/ProfileFeature/Edit/EditFeature.swift index a0c434e4..847e3a04 100644 --- a/Modules/Sources/ProfileFeature/Edit/EditFeature.swift +++ b/Modules/Sources/ProfileFeature/Edit/EditFeature.swift @@ -10,6 +10,7 @@ import ComposableArchitecture import APIClient import Models import ToastClient +import BBPanelFeature @Reducer public struct EditFeature: Reducer, Sendable { @@ -32,9 +33,15 @@ public struct EditFeature: Reducer, Sendable { @Presents public var destination: Destination.State? @Presents public var alert: AlertState? + public var bbPanel = BBPanelFeature.State( + with: .post(isCurator: true, canModerate: true), + supportsUpload: true + ) + let user: User var draftUser: User var focus: Field? + var editorRange: NSRange? var isSending = false var isAvatarUploading = false @@ -78,6 +85,7 @@ public struct EditFeature: Reducer, Sendable { public enum Action: BindableAction, ViewAction { case binding(BindingAction) case destination(PresentationAction) + case bbPanel(BBPanelFeature.Action) case view(View) public enum View { @@ -126,9 +134,13 @@ public struct EditFeature: Reducer, Sendable { public var body: some Reducer { BindingReducer() + Scope(state: \.bbPanel, action: \.bbPanel) { + BBPanelFeature() + } + Reduce { state, action in switch action { - case .binding: + case .binding, .bbPanel: return .none case .alert(.presented(.deleteAvatar)): diff --git a/Modules/Sources/ProfileFeature/Edit/EditScreen.swift b/Modules/Sources/ProfileFeature/Edit/EditScreen.swift index a3d3e5d4..3654a2ba 100644 --- a/Modules/Sources/ProfileFeature/Edit/EditScreen.swift +++ b/Modules/Sources/ProfileFeature/Edit/EditScreen.swift @@ -12,6 +12,7 @@ import Models import SharedUI import PhotosUI import SFSafeSymbols +import BBPanelFeature @ViewAction(for: EditFeature.self) public struct EditScreen: View { @@ -79,7 +80,10 @@ public struct EditScreen: View { .navigationTitle(Text("Edit profile", bundle: .module)) .navigationBarTitleDisplayMode(.inline) ._safeAreaBar(edge: .bottom) { - SendButton() + if focus == .about || focus == .signature { + BBPanelView(store: store.scope(state: \.bbPanel, action: \.bbPanel)) + .padding(isLiquidGlass ? 8 : 0) + } } .toolbar { ToolbarItem(placement: .navigationBarLeading) { @@ -353,13 +357,15 @@ public struct EditScreen: View { characterLimit: Int? = nil ) -> some View { Section { - SharedUI.Field( - content: content, - placeholder: LocalizedStringResource("Input...", bundle: .module), - focusEqual: focusEqual, - focus: $focus, - characterLimit: characterLimit - ) + WithPerceptionTracking { + SharedUI.Field( + content: content, + placeholder: LocalizedStringResource("Input...", bundle: .module), + focusEqual: focusEqual, + focus: $focus, + characterLimit: characterLimit + ) + } } header: { Header(title: title) } diff --git a/Modules/Sources/WriteFormFeature/WriteFormScreen.swift b/Modules/Sources/WriteFormFeature/WriteFormScreen.swift index 85398cbf..540ca3e1 100644 --- a/Modules/Sources/WriteFormFeature/WriteFormScreen.swift +++ b/Modules/Sources/WriteFormFeature/WriteFormScreen.swift @@ -75,16 +75,11 @@ public struct WriteFormScreen: View { .disabled(store.textContent.isEmptyAfterTrimming()) .disabled(store.isPublishing) } - - if #available(iOS 26.0, *) { - ToolbarItem(placement: .bottomBar) { - BBPanelView(store: store.scope(state: \.bbPanel, action: \.bbPanel)) - } - .sharedBackgroundVisibility(.hidden) - } else { - ToolbarItem(placement: .keyboard) { - BBPanelView(store: store.scope(state: \.bbPanel, action: \.bbPanel)) - } + } + ._safeAreaBar(edge: .bottom) { + if focus != nil { + BBPanelView(store: store.scope(state: \.bbPanel, action: \.bbPanel)) + .padding(isLiquidGlass ? 8 : 0) } } .onAppear { @@ -118,7 +113,7 @@ public struct WriteFormScreen: View { if store.inPostEditingMode { EditReason() } else { - Text("textContent: \(store.textContent)") + Text(verbatim: "textContent: \(store.textContent)") } } } diff --git a/Project.swift b/Project.swift index 2574ce90..28ffe6eb 100644 --- a/Project.swift +++ b/Project.swift @@ -143,6 +143,7 @@ let project = Project( .feature( name: "BBPanelFeature", dependencies: [ + .Internal.APIClient, .Internal.Models, .Internal.SharedUI, .SPM.SFSafeSymbols, @@ -298,6 +299,7 @@ let project = Project( .Internal.AnalyticsClient, .Internal.APIClient, .Internal.BBBuilder, + .Internal.BBPanelFeature, .Internal.HapticClient, .Internal.Models, .Internal.PersistenceKeys, From a884f26ead005f3d77d5afcc67288560a425920c Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 22 Feb 2026 15:27:29 +0300 Subject: [PATCH 034/118] FormFeature improvements --- .../Fields/FormCheckBoxFeature.swift | 0 .../Fields/FormDropdownFeature.swift | 0 .../{New => }/Fields/FormEditorFeature.swift | 0 .../Fields/FormFieldConformable.swift | 0 .../{New => }/Fields/FormFieldFeature.swift | 0 .../Fields/FormTextFieldFeature.swift | 0 .../{New => }/Fields/FormTitleFeature.swift | 0 .../Fields/FormUploadBoxFeature.swift | 0 .../FormFeature/{New => }/FormFeature.swift | 18 +++--- .../FormFeature/{New => }/FormScreen.swift | 0 .../FormFeature/New/Support/FormType.swift | 63 ------------------- .../Preview/FormPreviewFeature.swift | 4 +- .../{New => }/Support/FormNodeBuilder.swift | 0 .../Support/FormType.swift} | 14 ++--- .../{New => }/Views/CheckBox.swift | 0 .../{New => }/Views/EditReasonView.swift | 0 .../{New => }/Views/FieldNew.swift | 0 17 files changed, 19 insertions(+), 80 deletions(-) rename Modules/Sources/FormFeature/{New => }/Fields/FormCheckBoxFeature.swift (100%) rename Modules/Sources/FormFeature/{New => }/Fields/FormDropdownFeature.swift (100%) rename Modules/Sources/FormFeature/{New => }/Fields/FormEditorFeature.swift (100%) rename Modules/Sources/FormFeature/{New => }/Fields/FormFieldConformable.swift (100%) rename Modules/Sources/FormFeature/{New => }/Fields/FormFieldFeature.swift (100%) rename Modules/Sources/FormFeature/{New => }/Fields/FormTextFieldFeature.swift (100%) rename Modules/Sources/FormFeature/{New => }/Fields/FormTitleFeature.swift (100%) rename Modules/Sources/FormFeature/{New => }/Fields/FormUploadBoxFeature.swift (100%) rename Modules/Sources/FormFeature/{New => }/FormFeature.swift (98%) rename Modules/Sources/FormFeature/{New => }/FormScreen.swift (100%) delete mode 100644 Modules/Sources/FormFeature/New/Support/FormType.swift rename Modules/Sources/FormFeature/{New => }/Support/FormNodeBuilder.swift (100%) rename Modules/Sources/{Models/Common/WriteFormForType.swift => FormFeature/Support/FormType.swift} (77%) rename Modules/Sources/FormFeature/{New => }/Views/CheckBox.swift (100%) rename Modules/Sources/FormFeature/{New => }/Views/EditReasonView.swift (100%) rename Modules/Sources/FormFeature/{New => }/Views/FieldNew.swift (100%) diff --git a/Modules/Sources/FormFeature/New/Fields/FormCheckBoxFeature.swift b/Modules/Sources/FormFeature/Fields/FormCheckBoxFeature.swift similarity index 100% rename from Modules/Sources/FormFeature/New/Fields/FormCheckBoxFeature.swift rename to Modules/Sources/FormFeature/Fields/FormCheckBoxFeature.swift diff --git a/Modules/Sources/FormFeature/New/Fields/FormDropdownFeature.swift b/Modules/Sources/FormFeature/Fields/FormDropdownFeature.swift similarity index 100% rename from Modules/Sources/FormFeature/New/Fields/FormDropdownFeature.swift rename to Modules/Sources/FormFeature/Fields/FormDropdownFeature.swift diff --git a/Modules/Sources/FormFeature/New/Fields/FormEditorFeature.swift b/Modules/Sources/FormFeature/Fields/FormEditorFeature.swift similarity index 100% rename from Modules/Sources/FormFeature/New/Fields/FormEditorFeature.swift rename to Modules/Sources/FormFeature/Fields/FormEditorFeature.swift diff --git a/Modules/Sources/FormFeature/New/Fields/FormFieldConformable.swift b/Modules/Sources/FormFeature/Fields/FormFieldConformable.swift similarity index 100% rename from Modules/Sources/FormFeature/New/Fields/FormFieldConformable.swift rename to Modules/Sources/FormFeature/Fields/FormFieldConformable.swift diff --git a/Modules/Sources/FormFeature/New/Fields/FormFieldFeature.swift b/Modules/Sources/FormFeature/Fields/FormFieldFeature.swift similarity index 100% rename from Modules/Sources/FormFeature/New/Fields/FormFieldFeature.swift rename to Modules/Sources/FormFeature/Fields/FormFieldFeature.swift diff --git a/Modules/Sources/FormFeature/New/Fields/FormTextFieldFeature.swift b/Modules/Sources/FormFeature/Fields/FormTextFieldFeature.swift similarity index 100% rename from Modules/Sources/FormFeature/New/Fields/FormTextFieldFeature.swift rename to Modules/Sources/FormFeature/Fields/FormTextFieldFeature.swift diff --git a/Modules/Sources/FormFeature/New/Fields/FormTitleFeature.swift b/Modules/Sources/FormFeature/Fields/FormTitleFeature.swift similarity index 100% rename from Modules/Sources/FormFeature/New/Fields/FormTitleFeature.swift rename to Modules/Sources/FormFeature/Fields/FormTitleFeature.swift diff --git a/Modules/Sources/FormFeature/New/Fields/FormUploadBoxFeature.swift b/Modules/Sources/FormFeature/Fields/FormUploadBoxFeature.swift similarity index 100% rename from Modules/Sources/FormFeature/New/Fields/FormUploadBoxFeature.swift rename to Modules/Sources/FormFeature/Fields/FormUploadBoxFeature.swift diff --git a/Modules/Sources/FormFeature/New/FormFeature.swift b/Modules/Sources/FormFeature/FormFeature.swift similarity index 98% rename from Modules/Sources/FormFeature/New/FormFeature.swift rename to Modules/Sources/FormFeature/FormFeature.swift index 173e0295..0f62155a 100644 --- a/Modules/Sources/FormFeature/New/FormFeature.swift +++ b/Modules/Sources/FormFeature/FormFeature.swift @@ -26,7 +26,7 @@ public struct FormFeature: Reducer, Sendable { // MARK: - Destinations - @Reducer(state: .equatable) + @Reducer public enum Destination { case preview(FormPreviewFeature) case alert(AlertState) @@ -46,15 +46,17 @@ public struct FormFeature: Reducer, Sendable { @Shared(.userSession) var userSession let type: FormType + public var rows: IdentifiedArrayOf = [] public var focusedField: Int? public var isFormLoading = false public var isPublishing = false - var canShowShowMark = false public var isEditingReasonEnabled = false - var isShowMarkEnabled = false public var editReasonText = "" + var canShowShowMark = false + var isShowMarkEnabled = false + public var inPostEditingMode: Bool { if case let .post(type, _, _) = type, case .edit = type { return true @@ -129,9 +131,7 @@ public struct FormFeature: Reducer, Sendable { public var body: some Reducer { BindingReducer() - Reduce { - state, - action in + Reduce { state, action in switch action { case .binding(\.isEditingReasonEnabled): if !state.isEditingReasonEnabled { @@ -237,7 +237,7 @@ public struct FormFeature: Reducer, Sendable { } previewState = FormPreviewFeature.State( - formType: .post(type: type.convert(), topicId: topicId, content: content.convert()) + formType: .post(type: type, topicId: topicId, content: content) ) case .report: @@ -380,7 +380,7 @@ public struct FormFeature: Reducer, Sendable { case let .report(id: id, type: type): return .run { [content = state.content] send in - let request = ReportRequest(id: id, type: type.convert(), message: content) + let request = ReportRequest(id: id, type: type, message: content) let result = await Result { try await apiClient.sendReport(request) } await send(.internal(.reportResponse(result))) } @@ -437,6 +437,8 @@ public struct FormFeature: Reducer, Sendable { } } +extension FormFeature.Destination.State: Equatable {} + // MARK: - Alerts public extension AlertState where Action == FormFeature.Destination.Alert { diff --git a/Modules/Sources/FormFeature/New/FormScreen.swift b/Modules/Sources/FormFeature/FormScreen.swift similarity index 100% rename from Modules/Sources/FormFeature/New/FormScreen.swift rename to Modules/Sources/FormFeature/FormScreen.swift diff --git a/Modules/Sources/FormFeature/New/Support/FormType.swift b/Modules/Sources/FormFeature/New/Support/FormType.swift deleted file mode 100644 index 49361b86..00000000 --- a/Modules/Sources/FormFeature/New/Support/FormType.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// FormType.swift -// FormFeature -// -// Created by Ilia Lubianoi on 20.07.2025. -// - -import Models - -public enum FormType: Sendable, Equatable { - case post(type: PostType, topicId: Int, content: PostContentType) - case report(id: Int, type: ReportType) - case topic(forumId: Int, content: String) - - public enum PostType: Sendable, Equatable { - case new - case edit(postId: Int) - - @available(*, deprecated, message: "delete") - func convert() -> WriteFormForType.PostType { - switch self { - case .new: - return .new - case .edit(let postId): - return .edit(postId: postId) - } - } - } - - public enum PostContentType: Sendable, Equatable { - case simple(String, [Int]) - case template(String) - - @available(*, deprecated, message: "delete") - func convert() -> WriteFormForType.PostContentType { - switch self { - case .simple(let string, let array): - return .simple(string, array) - case .template(let string): - return .template(string) - } - } - } - - public enum ReportType: Sendable, Equatable { - case post - case comment - case reputation - - @available(*, deprecated, message: "delete") - func convert() -> Models.ReportType { - switch self { - case .post: return .post - case .comment: return .comment - case .reputation: return .reputation - } - } - } - - public var isTopic: Bool { - if case .topic = self { true } else { false } - } -} diff --git a/Modules/Sources/FormFeature/Preview/FormPreviewFeature.swift b/Modules/Sources/FormFeature/Preview/FormPreviewFeature.swift index 35544b91..522cfd0c 100644 --- a/Modules/Sources/FormFeature/Preview/FormPreviewFeature.swift +++ b/Modules/Sources/FormFeature/Preview/FormPreviewFeature.swift @@ -21,14 +21,14 @@ public struct FormPreviewFeature: Reducer, Sendable { @ObservableState public struct State: Equatable, Sendable { - public let formType: WriteFormForType + public let formType: FormType var contentTypes: [UITopicType] = [] var isPreviewLoading = false public init( - formType: WriteFormForType + formType: FormType ) { self.formType = formType } diff --git a/Modules/Sources/FormFeature/New/Support/FormNodeBuilder.swift b/Modules/Sources/FormFeature/Support/FormNodeBuilder.swift similarity index 100% rename from Modules/Sources/FormFeature/New/Support/FormNodeBuilder.swift rename to Modules/Sources/FormFeature/Support/FormNodeBuilder.swift diff --git a/Modules/Sources/Models/Common/WriteFormForType.swift b/Modules/Sources/FormFeature/Support/FormType.swift similarity index 77% rename from Modules/Sources/Models/Common/WriteFormForType.swift rename to Modules/Sources/FormFeature/Support/FormType.swift index f31bc2c4..80b50668 100644 --- a/Modules/Sources/Models/Common/WriteFormForType.swift +++ b/Modules/Sources/FormFeature/Support/FormType.swift @@ -1,16 +1,16 @@ // -// WriteFormForType.swift -// ForPDA +// FormType.swift +// FormFeature // -// Created by Xialtal on 14.03.25. +// Created by Ilia Lubianoi on 20.07.2025. // -import Foundation +import Models -public enum WriteFormForType: Sendable, Equatable { +public enum FormType: Sendable, Equatable { + case post(type: PostType, topicId: Int, content: PostContentType) case report(id: Int, type: ReportType) case topic(forumId: Int, content: String) - case post(type: PostType, topicId: Int, content: PostContentType) public enum PostType: Sendable, Equatable { case new @@ -18,8 +18,8 @@ public enum WriteFormForType: Sendable, Equatable { } public enum PostContentType: Sendable, Equatable { - case template(String) case simple(String, [Int]) + case template(String) } public var isTopic: Bool { diff --git a/Modules/Sources/FormFeature/New/Views/CheckBox.swift b/Modules/Sources/FormFeature/Views/CheckBox.swift similarity index 100% rename from Modules/Sources/FormFeature/New/Views/CheckBox.swift rename to Modules/Sources/FormFeature/Views/CheckBox.swift diff --git a/Modules/Sources/FormFeature/New/Views/EditReasonView.swift b/Modules/Sources/FormFeature/Views/EditReasonView.swift similarity index 100% rename from Modules/Sources/FormFeature/New/Views/EditReasonView.swift rename to Modules/Sources/FormFeature/Views/EditReasonView.swift diff --git a/Modules/Sources/FormFeature/New/Views/FieldNew.swift b/Modules/Sources/FormFeature/Views/FieldNew.swift similarity index 100% rename from Modules/Sources/FormFeature/New/Views/FieldNew.swift rename to Modules/Sources/FormFeature/Views/FieldNew.swift From b3f0505cdd6229ce491a90ddca7ef2eeabccef8b Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 22 Feb 2026 15:38:35 +0300 Subject: [PATCH 035/118] Extract UploadBox to own feature --- .../BBPanelFeature/BBPanelFeature.swift | 1 + .../Sources/BBPanelFeature/BBPanelView.swift | 1 + .../Resources/Localizable.xcstrings | 12 ------------ .../Models/UploadBoxFile.swift | 0 .../Models/UploadBoxType.swift | 0 .../Resources/Localizable.xcstrings | 18 ++++++++++++++++++ .../UploadBoxFeature.swift | 2 ++ .../UploadBoxView.swift | 12 +++++++++--- Project.swift | 12 ++++++++++++ 9 files changed, 43 insertions(+), 15 deletions(-) rename Modules/Sources/{BBPanelFeature/UploadBox => UploadBoxFeature}/Models/UploadBoxFile.swift (100%) rename Modules/Sources/{BBPanelFeature/UploadBox => UploadBoxFeature}/Models/UploadBoxType.swift (100%) create mode 100644 Modules/Sources/UploadBoxFeature/Resources/Localizable.xcstrings rename Modules/Sources/{BBPanelFeature/UploadBox => UploadBoxFeature}/UploadBoxFeature.swift (99%) rename Modules/Sources/{BBPanelFeature/UploadBox => UploadBoxFeature}/UploadBoxView.swift (97%) diff --git a/Modules/Sources/BBPanelFeature/BBPanelFeature.swift b/Modules/Sources/BBPanelFeature/BBPanelFeature.swift index 940561d6..0347b068 100644 --- a/Modules/Sources/BBPanelFeature/BBPanelFeature.swift +++ b/Modules/Sources/BBPanelFeature/BBPanelFeature.swift @@ -7,6 +7,7 @@ import Foundation import ComposableArchitecture +import UploadBoxFeature @Reducer public struct BBPanelFeature: Reducer, Sendable { diff --git a/Modules/Sources/BBPanelFeature/BBPanelView.swift b/Modules/Sources/BBPanelFeature/BBPanelView.swift index d453ae3c..c83e3848 100644 --- a/Modules/Sources/BBPanelFeature/BBPanelView.swift +++ b/Modules/Sources/BBPanelFeature/BBPanelView.swift @@ -8,6 +8,7 @@ import SwiftUI import ComposableArchitecture import SharedUI +import UploadBoxFeature @ViewAction(for: BBPanelFeature.self) public struct BBPanelView: View { diff --git a/Modules/Sources/BBPanelFeature/Resources/Localizable.xcstrings b/Modules/Sources/BBPanelFeature/Resources/Localizable.xcstrings index ad85e1d0..8361adf0 100644 --- a/Modules/Sources/BBPanelFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/BBPanelFeature/Resources/Localizable.xcstrings @@ -23,12 +23,6 @@ } } } - }, - "Choose from Files" : { - - }, - "Choose from Gallery" : { - }, "ColorPicker" : { @@ -112,9 +106,6 @@ } } } - }, - "Select files..." : { - }, "Select text size" : { "localizations" : { @@ -125,9 +116,6 @@ } } } - }, - "The selected file will be inserted into the text as code wherever your cursor is located. Or it will also be automatically added to the end of the post." : { - } }, "version" : "1.1" diff --git a/Modules/Sources/BBPanelFeature/UploadBox/Models/UploadBoxFile.swift b/Modules/Sources/UploadBoxFeature/Models/UploadBoxFile.swift similarity index 100% rename from Modules/Sources/BBPanelFeature/UploadBox/Models/UploadBoxFile.swift rename to Modules/Sources/UploadBoxFeature/Models/UploadBoxFile.swift diff --git a/Modules/Sources/BBPanelFeature/UploadBox/Models/UploadBoxType.swift b/Modules/Sources/UploadBoxFeature/Models/UploadBoxType.swift similarity index 100% rename from Modules/Sources/BBPanelFeature/UploadBox/Models/UploadBoxType.swift rename to Modules/Sources/UploadBoxFeature/Models/UploadBoxType.swift diff --git a/Modules/Sources/UploadBoxFeature/Resources/Localizable.xcstrings b/Modules/Sources/UploadBoxFeature/Resources/Localizable.xcstrings new file mode 100644 index 00000000..33963347 --- /dev/null +++ b/Modules/Sources/UploadBoxFeature/Resources/Localizable.xcstrings @@ -0,0 +1,18 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "Choose from Files" : { + + }, + "Choose from Gallery" : { + + }, + "Select files..." : { + + }, + "The selected file will be inserted into the text as code wherever your cursor is located. Or it will also be automatically added to the end of the post." : { + + } + }, + "version" : "1.1" +} \ No newline at end of file diff --git a/Modules/Sources/BBPanelFeature/UploadBox/UploadBoxFeature.swift b/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift similarity index 99% rename from Modules/Sources/BBPanelFeature/UploadBox/UploadBoxFeature.swift rename to Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift index 931a0acf..798913c8 100644 --- a/Modules/Sources/BBPanelFeature/UploadBox/UploadBoxFeature.swift +++ b/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift @@ -13,6 +13,8 @@ import CryptoKit @Reducer public struct UploadBoxFeature: Reducer, Sendable { + public init() {} + // MARK: - Helpers var isPreview: Bool { diff --git a/Modules/Sources/BBPanelFeature/UploadBox/UploadBoxView.swift b/Modules/Sources/UploadBoxFeature/UploadBoxView.swift similarity index 97% rename from Modules/Sources/BBPanelFeature/UploadBox/UploadBoxView.swift rename to Modules/Sources/UploadBoxFeature/UploadBoxView.swift index 30f358f5..cbeb4bae 100644 --- a/Modules/Sources/BBPanelFeature/UploadBox/UploadBoxView.swift +++ b/Modules/Sources/UploadBoxFeature/UploadBoxView.swift @@ -10,18 +10,24 @@ import ComposableArchitecture import PhotosUI @ViewAction(for: UploadBoxFeature.self) -struct UploadBoxView: View { +public struct UploadBoxView: View { // MARK: - Properties - @Perception.Bindable var store: StoreOf + @Perception.Bindable public var store: StoreOf @Environment(\.tintColor) private var tintColor @State private var pickerItem: PhotosPickerItem? + // MARK: - Init + + public init(store: StoreOf) { + self.store = store + } + // MARK: - Body - var body: some View { + public var body: some View { WithPerceptionTracking { VStack(spacing: 6) { WithPerceptionTracking { diff --git a/Project.swift b/Project.swift index a4ead335..91b716fb 100644 --- a/Project.swift +++ b/Project.swift @@ -146,6 +146,7 @@ let project = Project( .Internal.APIClient, .Internal.Models, .Internal.SharedUI, + .Internal.UploadBoxFeature, .SPM.SFSafeSymbols, .SPM.TCA ] @@ -463,6 +464,15 @@ let project = Project( .SPM.TCA ] ), + + .feature( + name: "UploadBoxFeature", + dependencies: [ + .Internal.APIClient, + .Internal.Models, + .SPM.TCA, + ] + ), .feature( name: "FormFeature", @@ -473,6 +483,7 @@ let project = Project( .Internal.ParsingClient, .Internal.SharedUI, .Internal.TopicBuilder, + .Internal.UploadBoxFeature, .SPM.NukeUI, .SPM.RichTextKit, .SPM.TCA, @@ -956,6 +967,7 @@ extension TargetDependency.Internal { static let SettingsFeature = TargetDependency.target(name: "SettingsFeature") static let TopicBuilder = TargetDependency.target(name: "TopicBuilder") static let TopicFeature = TargetDependency.target(name: "TopicFeature") + static let UploadBoxFeature = TargetDependency.target(name: "UploadBoxFeature") // Clients static let AnalyticsClient = TargetDependency.target(name: "AnalyticsClient") From 84bc5489098190deccf8dcaf2e5a81c7e717c22c Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 22 Feb 2026 15:54:37 +0300 Subject: [PATCH 036/118] Move FormFeature to common Field --- .../Fields/FormEditorFeature.swift | 11 ++-- .../Fields/FormTextFieldFeature.swift | 10 ++-- .../Resources/Localizable.xcstrings | 5 +- .../FormFeature/Views/EditReasonView.swift | 10 ++-- .../Sources/FormFeature/Views/FieldNew.swift | 54 ------------------- 5 files changed, 18 insertions(+), 72 deletions(-) delete mode 100644 Modules/Sources/FormFeature/Views/FieldNew.swift diff --git a/Modules/Sources/FormFeature/Fields/FormEditorFeature.swift b/Modules/Sources/FormFeature/Fields/FormEditorFeature.swift index 34e346ba..d388a083 100644 --- a/Modules/Sources/FormFeature/Fields/FormEditorFeature.swift +++ b/Modules/Sources/FormFeature/Fields/FormEditorFeature.swift @@ -7,6 +7,7 @@ import SwiftUI import ComposableArchitecture +import SharedUI // MARK: - Feature @@ -82,11 +83,11 @@ struct FormEditorRow: View { ) { WithPerceptionTracking { Field( - id: store.id, - text: $store.text, - placeholder: store.placeholder, - isEditor: true, - focusedField: $focusedField + content: $store.text, + placeholder: LocalizedStringResource(stringLiteral: store.placeholder), + focusEqual: store.id, + focus: $focusedField, + minHeight: 144 ) } } diff --git a/Modules/Sources/FormFeature/Fields/FormTextFieldFeature.swift b/Modules/Sources/FormFeature/Fields/FormTextFieldFeature.swift index 6f2ce4c8..f09ccb48 100644 --- a/Modules/Sources/FormFeature/Fields/FormTextFieldFeature.swift +++ b/Modules/Sources/FormFeature/Fields/FormTextFieldFeature.swift @@ -7,6 +7,7 @@ import SwiftUI import ComposableArchitecture +import SharedUI // MARK: - Feature @@ -82,11 +83,10 @@ struct FormTextFieldRow: View { ) { WithPerceptionTracking { Field( - id: store.id, - text: $store.text, - placeholder: store.placeholder, - isEditor: false, - focusedField: $focusedField + content: $store.text, + placeholder: LocalizedStringResource(stringLiteral: store.placeholder), + focusEqual: store.id, + focus: $focusedField ) } } diff --git a/Modules/Sources/FormFeature/Resources/Localizable.xcstrings b/Modules/Sources/FormFeature/Resources/Localizable.xcstrings index 89654cee..3e6bcd0d 100644 --- a/Modules/Sources/FormFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/FormFeature/Resources/Localizable.xcstrings @@ -77,12 +77,12 @@ } } }, - "Input..." : { + "Input reason" : { "localizations" : { "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Введите…" + "value" : "Введите причину" } } } @@ -212,7 +212,6 @@ } }, "Publish" : { - "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { diff --git a/Modules/Sources/FormFeature/Views/EditReasonView.swift b/Modules/Sources/FormFeature/Views/EditReasonView.swift index 4863e200..0533d471 100644 --- a/Modules/Sources/FormFeature/Views/EditReasonView.swift +++ b/Modules/Sources/FormFeature/Views/EditReasonView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import SharedUI struct EditReasonView: View { @@ -39,11 +40,10 @@ struct EditReasonView: View { if isEditingReasonEnabled { Field( - id: id, - text: $text, - placeholder: "Введите причину", - isEditor: true, - focusedField: $focusedField + content: $text, + placeholder: LocalizedStringResource("Input reason"), + focusEqual: id, + focus: $focusedField ) if canShowShowMark { diff --git a/Modules/Sources/FormFeature/Views/FieldNew.swift b/Modules/Sources/FormFeature/Views/FieldNew.swift deleted file mode 100644 index d8f3d938..00000000 --- a/Modules/Sources/FormFeature/Views/FieldNew.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// Field.swift -// FormFeature -// -// Created by Ilia Lubianoi on 20.07.2025. -// - -import SwiftUI - -struct Field: View { - - // MARK: - Properties - - let id: Int - let text: Binding - let placeholder: String - var isEditor: Bool - - @FocusState.Binding var focusedField: Int? - - // MARK: - Body - - var body: some View { - VStack { - Group { - TextField(text: text, axis: .vertical) { - Text(placeholder) - .font(.body) - .foregroundStyle(Color(.quaternaryLabel)) - } - .focused($focusedField, equals: id) - .font(.body) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - .foregroundStyle(Color(.Labels.primary)) - .frame(minHeight: isEditor ? 144 : nil, alignment: .top) - } - .padding(.vertical, 15) - .padding(.horizontal, 12) - .background { - RoundedRectangle(cornerRadius: 14) - .fill(Color(.Background.teritary)) - .onTapGesture { - focusedField = nil - } - } - .overlay { - RoundedRectangle(cornerRadius: 14) - .strokeBorder(Color(.Separator.primary)) - } - } - .animation(.default, value: false) - } -} From f048f6242982d8cb8ef5610825d4aa8753f2aee7 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 22 Feb 2026 15:55:11 +0300 Subject: [PATCH 037/118] Add releaser for topic --- Modules/Sources/TopicFeature/TopicScreen.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Modules/Sources/TopicFeature/TopicScreen.swift b/Modules/Sources/TopicFeature/TopicScreen.swift index c32f1233..863a0689 100644 --- a/Modules/Sources/TopicFeature/TopicScreen.swift +++ b/Modules/Sources/TopicFeature/TopicScreen.swift @@ -156,6 +156,12 @@ public struct TopicScreen: View { ContextButton(text: LocalizedStringResource("Write Post", bundle: .module), symbol: .plusCircle) { send(.contextMenu(.writePost)) } + + if let postTemplate = topic.postTemplateName { + ContextButton(text: LocalizedStringResource(stringLiteral: postTemplate), symbol: .plusApp) { + send(.contextMenu(.writePostWithTemplate)) + } + } } } From cdf257410f30be2cb0f34e26ac1f868bd788213d Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 22 Feb 2026 18:03:41 +0300 Subject: [PATCH 038/118] Add checkboxList support for FormFeature --- .../Fields/FormCheckBoxFeature.swift | 60 ------- .../Fields/FormCheckBoxListFeature.swift | 150 ++++++++++++++++++ .../Fields/FormDropdownFeature.swift | 4 +- .../Fields/FormEditorFeature.swift | 6 +- .../Fields/FormFieldConformable.swift | 2 +- .../FormFeature/Fields/FormFieldFeature.swift | 24 +-- .../Fields/FormTextFieldFeature.swift | 6 +- .../FormFeature/Fields/FormTitleFeature.swift | 4 +- .../Fields/FormUploadBoxFeature.swift | 9 +- Modules/Sources/FormFeature/FormFeature.swift | 15 +- .../FormFeature/Support/FormValue.swift | 30 ++++ 11 files changed, 217 insertions(+), 93 deletions(-) delete mode 100644 Modules/Sources/FormFeature/Fields/FormCheckBoxFeature.swift create mode 100644 Modules/Sources/FormFeature/Fields/FormCheckBoxListFeature.swift create mode 100644 Modules/Sources/FormFeature/Support/FormValue.swift diff --git a/Modules/Sources/FormFeature/Fields/FormCheckBoxFeature.swift b/Modules/Sources/FormFeature/Fields/FormCheckBoxFeature.swift deleted file mode 100644 index c38f8359..00000000 --- a/Modules/Sources/FormFeature/Fields/FormCheckBoxFeature.swift +++ /dev/null @@ -1,60 +0,0 @@ -// -// FormCheckBoxFeature.swift -// FormFeature -// -// Created by Ilia Lubianoi on 19.07.2025. -// - -#warning("todo") - -import SwiftUI -import ComposableArchitecture - -// MARK: - Feature - -@Reducer -public struct FormCheckBoxFeature: Reducer { - - // MARK: - State - - @ObservableState - public struct State: Equatable, FormFieldConformable { - public let id: Int - let flag: Int - - func getValue() -> String { - return "" - } - - func isValid() -> Bool { - return true - } - } - - // MARK: - Actions - - public enum Action: BindableAction { - case binding(BindingAction) - } - - // MARK: - Body - - public var body: some Reducer { - BindingReducer() - - Reduce { state, action in - return .none - } - } -} - -// MARK: - View - -struct FormCheckBoxRow: View { - - @Perception.Bindable var store: StoreOf - - var body: some View { - Text(verbatim: "CheckBox") - } -} diff --git a/Modules/Sources/FormFeature/Fields/FormCheckBoxListFeature.swift b/Modules/Sources/FormFeature/Fields/FormCheckBoxListFeature.swift new file mode 100644 index 00000000..c13f548e --- /dev/null +++ b/Modules/Sources/FormFeature/Fields/FormCheckBoxListFeature.swift @@ -0,0 +1,150 @@ +// +// FormCheckBoxFeature.swift +// FormFeature +// +// Created by Ilia Lubianoi on 19.07.2025. +// + +import SwiftUI +import ComposableArchitecture + +// MARK: - Feature + +@Reducer +public struct FormCheckBoxListFeature: Reducer { + + // MARK: - State + + @ObservableState + public struct State: Equatable, FormFieldConformable { + public let id: Int + let title: String + let description: String + let flag: Int + let options: [String] + + var selectedOptions: [Int: Bool] + + public init( + id: Int, + title: String, + description: String, + flag: Int, + options: [String] + ) { + self.id = id + self.title = title + self.description = description + self.flag = flag + self.options = options + + self.selectedOptions = [0: false] + } + + func getValue() -> FormValue { + return .array(selectedOptions + .filter { $0.value == true } + .map { .integer($0.key + 1) }) + } + + func isValid() -> Bool { + return isRequired + ? !selectedOptions.filter { $0.value == true }.isEmpty + : true + } + } + + // MARK: - Actions + + public enum Action: BindableAction, ViewAction { + case binding(BindingAction) + case view(View) + + public enum View { + case checkboxClicked(Int, Bool) + } + } + + // MARK: - Body + + public var body: some Reducer { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding: + break + + case let .view(.checkboxClicked(id, isSelected)): + state.selectedOptions[id] = isSelected + } + return .none + } + } +} + +// MARK: - View + +@ViewAction(for: FormCheckBoxListFeature.self) +struct FormCheckBoxListRow: View { + + @Perception.Bindable var store: StoreOf + @Environment(\.tintColor) private var tintColor + + @State var isChecked = false + + var body: some View { + WithPerceptionTracking { + FieldSection( + title: store.title, + description: store.description, + required: store.isRequired + ) { + VStack(spacing: 6) { + ForEach(store.options.indices, id: \.hashValue) { index in + WithPerceptionTracking { + Toggle(isOn: Binding(get: { + store.selectedOptions[index] ?? false + }, set: { value in + send(.checkboxClicked(index, value)) + })) { + Text(store.options[index]) + .font(.subheadline) + .frame(maxWidth: .infinity, alignment: .leading) + } + .toggleStyle(CheckBox()) + .padding(6) + } + } + } + .padding(.vertical, 10) + .padding(.horizontal, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .background { + RoundedRectangle(cornerRadius: 14) + .fill(Color(.Background.teritary)) + } + } + } + } +} + +// MARK: - Previews + +#Preview { + FormCheckBoxListRow( + store: Store( + initialState: FormCheckBoxListFeature.State( + id: 0, + title: "Select answer", + description: "This is checkbox list description...", + flag: 0, + options: ["Yes", "No"] + ) + ) { + FormCheckBoxListFeature() + } + ) + .padding(.horizontal, 16) + .environment(\.tintColor, Color(.Theme.primary)) +} diff --git a/Modules/Sources/FormFeature/Fields/FormDropdownFeature.swift b/Modules/Sources/FormFeature/Fields/FormDropdownFeature.swift index 4aa79802..326e2a12 100644 --- a/Modules/Sources/FormFeature/Fields/FormDropdownFeature.swift +++ b/Modules/Sources/FormFeature/Fields/FormDropdownFeature.swift @@ -39,8 +39,8 @@ public struct FormDropdownFeature: Reducer { self.selectedOption = options.first ?? "" } - func getValue() -> String { - return selectedOption + func getValue() -> FormValue { + return .string(selectedOption) } func isValid() -> Bool { diff --git a/Modules/Sources/FormFeature/Fields/FormEditorFeature.swift b/Modules/Sources/FormFeature/Fields/FormEditorFeature.swift index d388a083..e5817916 100644 --- a/Modules/Sources/FormFeature/Fields/FormEditorFeature.swift +++ b/Modules/Sources/FormFeature/Fields/FormEditorFeature.swift @@ -41,12 +41,12 @@ public struct FormEditorFeature: Reducer { self.text = defaultText } - func getValue() -> String { - return text + func getValue() -> FormValue { + return .string(text) } func isValid() -> Bool { - return !text.isEmpty + return isRequired ? !text.isEmpty : true } } diff --git a/Modules/Sources/FormFeature/Fields/FormFieldConformable.swift b/Modules/Sources/FormFeature/Fields/FormFieldConformable.swift index 9e5421c2..45a5acbb 100644 --- a/Modules/Sources/FormFeature/Fields/FormFieldConformable.swift +++ b/Modules/Sources/FormFeature/Fields/FormFieldConformable.swift @@ -10,7 +10,7 @@ protocol FormFieldConformable: Identifiable { var isRequired: Bool { get } func isValid() -> Bool - func getValue() -> String + func getValue() -> FormValue } extension FormFieldConformable { diff --git a/Modules/Sources/FormFeature/Fields/FormFieldFeature.swift b/Modules/Sources/FormFeature/Fields/FormFieldFeature.swift index 6fbacd3d..0fa93834 100644 --- a/Modules/Sources/FormFeature/Fields/FormFieldFeature.swift +++ b/Modules/Sources/FormFeature/Fields/FormFieldFeature.swift @@ -17,7 +17,7 @@ public struct FormFieldFeature: Reducer { public enum State: Equatable, Identifiable, FormFieldConformable { var flag: Int { return -1 } - case checkBox(FormCheckBoxFeature.State) + case checkBoxList(FormCheckBoxListFeature.State) case dropdown(FormDropdownFeature.State) case editor(FormEditorFeature.State) case textField(FormTextFieldFeature.State) @@ -26,7 +26,7 @@ public struct FormFieldFeature: Reducer { public var id: Int { switch self { - case .checkBox(let state): return state.id + case .checkBoxList(let state): return state.id case .dropdown(let state): return state.id case .editor(let state): return state.id case .textField(let state): return state.id @@ -35,9 +35,9 @@ public struct FormFieldFeature: Reducer { } } - func getValue() -> String { + func getValue() -> FormValue { switch self { - case .checkBox(let state): state.getValue() + case .checkBoxList(let state): state.getValue() case .dropdown(let state): state.getValue() case .editor(let state): state.getValue() case .textField(let state): state.getValue() @@ -48,7 +48,7 @@ public struct FormFieldFeature: Reducer { func isValid() -> Bool { switch self { - case .checkBox(let state): state.isValid() + case .checkBoxList(let state): state.isValid() case .dropdown(let state): state.isValid() case .editor(let state): state.isValid() case .textField(let state): state.isValid() @@ -59,7 +59,7 @@ public struct FormFieldFeature: Reducer { func isRequired() -> Bool { switch self { - case .checkBox(let state): state.isRequired + case .checkBoxList(let state): state.isRequired case .dropdown(let state): state.isRequired case .editor(let state): state.isRequired case .textField(let state): state.isRequired @@ -72,7 +72,7 @@ public struct FormFieldFeature: Reducer { // MARK: - Actions public enum Action { - case checkBox(FormCheckBoxFeature.Action) + case checkBoxList(FormCheckBoxListFeature.Action) case dropdown(FormDropdownFeature.Action) case editor(FormEditorFeature.Action) case textField(FormTextFieldFeature.Action) @@ -83,8 +83,8 @@ public struct FormFieldFeature: Reducer { // MARK: - Body public var body: some Reducer { - Scope(state: \.checkBox, action: \.checkBox) { - FormCheckBoxFeature() + Scope(state: \.checkBoxList, action: \.checkBoxList) { + FormCheckBoxListFeature() } Scope(state: \.dropdown, action: \.dropdown) { FormDropdownFeature() @@ -116,9 +116,9 @@ struct FormFieldRow: View { var body: some View { switch store.state { - case .checkBox: - if let store = store.scope(state: \.checkBox, action: \.checkBox) { - FormCheckBoxRow(store: store) + case .checkBoxList: + if let store = store.scope(state: \.checkBoxList, action: \.checkBoxList) { + FormCheckBoxListRow(store: store) } case .dropdown: diff --git a/Modules/Sources/FormFeature/Fields/FormTextFieldFeature.swift b/Modules/Sources/FormFeature/Fields/FormTextFieldFeature.swift index f09ccb48..d4c99205 100644 --- a/Modules/Sources/FormFeature/Fields/FormTextFieldFeature.swift +++ b/Modules/Sources/FormFeature/Fields/FormTextFieldFeature.swift @@ -41,12 +41,12 @@ public struct FormTextFieldFeature: Reducer { self.text = defaultText } - func getValue() -> String { - return text + func getValue() -> FormValue { + return .string(text) } func isValid() -> Bool { - return !text.isEmpty + return isRequired ? !text.isEmpty : true } } diff --git a/Modules/Sources/FormFeature/Fields/FormTitleFeature.swift b/Modules/Sources/FormFeature/Fields/FormTitleFeature.swift index 025e86e3..9d3d3442 100644 --- a/Modules/Sources/FormFeature/Fields/FormTitleFeature.swift +++ b/Modules/Sources/FormFeature/Fields/FormTitleFeature.swift @@ -28,8 +28,8 @@ public struct FormTitleFeature: Reducer { var nodes: [FormNode] = [] - func getValue() -> String { - return "\"\"" + func getValue() -> FormValue { + return .string("\"\"") } func isValid() -> Bool { diff --git a/Modules/Sources/FormFeature/Fields/FormUploadBoxFeature.swift b/Modules/Sources/FormFeature/Fields/FormUploadBoxFeature.swift index 56ee04d2..8f5a8aa2 100644 --- a/Modules/Sources/FormFeature/Fields/FormUploadBoxFeature.swift +++ b/Modules/Sources/FormFeature/Fields/FormUploadBoxFeature.swift @@ -85,15 +85,16 @@ public struct FormUploadBoxFeature: Reducer { self.flag = flag self.allowedExtensions = allowedExtensions self.isLoading = isLoading - self.files = files + //self.files = files } - func getValue() -> String { - return files.map { "[\($0.id),\($0.name)]" }.joined(separator: ",") + func getValue() -> FormValue { + return .array([]) + //return Array().map { "[\($0.id),\($0.name)]" }.joined(separator: ",") } func isValid() -> Bool { - return !files.isEmpty + return false } } diff --git a/Modules/Sources/FormFeature/FormFeature.swift b/Modules/Sources/FormFeature/FormFeature.swift index 0f62155a..2069fc86 100644 --- a/Modules/Sources/FormFeature/FormFeature.swift +++ b/Modules/Sources/FormFeature/FormFeature.swift @@ -77,7 +77,7 @@ public struct FormFeature: Reducer, Sendable { return editorState.text } else { let values = rows.map { $0.getValue() } - return "[" + values.joined(separator: ",") + "]" + return "[" + values.description + "]" } } @@ -185,7 +185,7 @@ public struct FormFeature: Reducer, Sendable { case let .rows(action): if case let .element(id: id, action: .uploadBox(.delegate(.filesHasBeenUploaded))) = action { if case let .uploadBox(uploadBoxState) = state.rows[id: id] { - print("Files: \(uploadBoxState.files)") + print("Files: \(uploadBoxState)") } else { fatalError("Non UploadBox state casted by action id") } @@ -300,12 +300,15 @@ public struct FormFeature: Reducer, Sendable { ) state.rows.append(.editor(editorState)) - case let .checkboxList(content, _): - let checkboxState = FormCheckBoxFeature.State( + case let .checkboxList(content, options): + let checkboxListState = FormCheckBoxListFeature.State( id: index, - flag: content.flag + title: content.name, + description: content.description, + flag: content.flag, + options: options ) - state.rows.append(.checkBox(checkboxState)) + state.rows.append(.checkBoxList(checkboxListState)) case let .dropdown(content, options): let dropdownState = FormDropdownFeature.State( diff --git a/Modules/Sources/FormFeature/Support/FormValue.swift b/Modules/Sources/FormFeature/Support/FormValue.swift new file mode 100644 index 00000000..c80c1e66 --- /dev/null +++ b/Modules/Sources/FormFeature/Support/FormValue.swift @@ -0,0 +1,30 @@ +// +// FormValue.swift +// ForPDA +// +// Created by Xialtal on 22.02.26. +// + +import PDAPI + +public enum FormValue: Hashable { + case string(String) + case integer(Int) + + case array([FormValue]) + + static func toDocument(_ value: FormValue) throws -> Document { + var document = Document() + switch value { + case .string(let string): + _ = try document.append(string) + case .integer(let int): + _ = try document.append(int) + case .array(let array): + for element in array { + _ = try document.append(toDocument(element)) + } + } + return document + } +} From a76c075bc4fa2215e42842f0ea25fcb927b5a0ec Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 22 Feb 2026 18:04:42 +0300 Subject: [PATCH 039/118] Add create topic context menu action for forum --- .../Analytics/ForumFeature+Analytics.swift | 2 +- .../Sources/ForumFeature/ForumFeature.swift | 30 +++++++++++++++++-- .../Sources/ForumFeature/ForumScreen.swift | 14 +++++++++ 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/Modules/Sources/ForumFeature/Analytics/ForumFeature+Analytics.swift b/Modules/Sources/ForumFeature/Analytics/ForumFeature+Analytics.swift index 27761fac..6be4ed79 100644 --- a/Modules/Sources/ForumFeature/Analytics/ForumFeature+Analytics.swift +++ b/Modules/Sources/ForumFeature/Analytics/ForumFeature+Analytics.swift @@ -19,7 +19,7 @@ extension ForumFeature { var body: some Reducer { Reduce { state, action in switch action { - case .pageNavigation: + case .destination, .pageNavigation: break case .view(.onFirstAppear), .view(.onNextAppear), .view(.searchButtonTapped): diff --git a/Modules/Sources/ForumFeature/ForumFeature.swift b/Modules/Sources/ForumFeature/ForumFeature.swift index c6924be4..17350d6a 100644 --- a/Modules/Sources/ForumFeature/ForumFeature.swift +++ b/Modules/Sources/ForumFeature/ForumFeature.swift @@ -15,6 +15,7 @@ import PasteboardClient import PersistenceKeys import TCAExtensions import ToastClient +import FormFeature @Reducer public struct ForumFeature: Reducer, Sendable { @@ -49,12 +50,21 @@ public struct ForumFeature: Reducer, Sendable { } } + // MARK: - Destinations + + @Reducer + public enum Destination { + case form(FormFeature) + } + // MARK: - State @ObservableState public struct State: Equatable { @Shared(.appSettings) var appSettings: AppSettings @Shared(.userSession) var userSession: UserSession? + + @Presents public var destination: Destination.State? public var forumId: Int public var forumName: String? @@ -90,6 +100,7 @@ public struct ForumFeature: Reducer, Sendable { // MARK: - Action public enum Action: ViewAction { + case destination(PresentationAction) case pageNavigation(PageNavigationFeature.Action) case view(View) @@ -147,7 +158,7 @@ public struct ForumFeature: Reducer, Sendable { case let .pageNavigation(.offsetChanged(to: newOffset)): return .send(.internal(.loadForum(offset: newOffset))) - case .pageNavigation: + case .destination, .pageNavigation: return .none case .view(.onFirstAppear): @@ -197,8 +208,18 @@ public struct ForumFeature: Reducer, Sendable { case .view(.contextOptionMenu(let action)): switch action { - // TODO: sort, to bookmarks - // TODO: Add analytics + case .createTopic: + let formState = FormFeature.State( + type: .topic( + forumId: state.forumId, + content: "" + ) + ) + state.destination = .form(formState) + return .none + + // TODO: sort, to bookmarks + // TODO: Add analytics default: return .none } @@ -308,6 +329,7 @@ public struct ForumFeature: Reducer, Sendable { return .none } } + .ifLet(\.$destination, action: \.destination) Analytics() } @@ -320,3 +342,5 @@ public struct ForumFeature: Reducer, Sendable { state.didLoadOnce = true } } + +extension ForumFeature.Destination.State: Equatable {} diff --git a/Modules/Sources/ForumFeature/ForumScreen.swift b/Modules/Sources/ForumFeature/ForumScreen.swift index 3590a2ce..fd7db401 100644 --- a/Modules/Sources/ForumFeature/ForumScreen.swift +++ b/Modules/Sources/ForumFeature/ForumScreen.swift @@ -12,6 +12,7 @@ import SFSafeSymbols import SharedUI import Models import BBBuilder +import FormFeature @ViewAction(for: ForumFeature.self) public struct ForumScreen: View { @@ -78,6 +79,11 @@ public struct ForumScreen: View { .animation(.default, value: store.sectionsExpandState) .navigationTitle(Text(store.forumName ?? "Загрузка...")) ._toolbarTitleDisplayMode(.large) + .fullScreenCover(item: $store.scope(state: \.destination?.form, action: \.destination.form)) { store in + NavigationStack { + FormScreen(store: store) + } + } .safeAreaInset(edge: .bottom) { if isLiquidGlass, store.appSettings.floatingNavigation, @@ -122,6 +128,14 @@ public struct ForumScreen: View { private func OptionsMenu() -> some View { Menu { if let forum = store.forum { + if forum.canCreateTopic { + Section { + ContextButton(text: LocalizedStringResource("Create Topic", bundle: .module), symbol: .plusCircle) { + send(.contextOptionMenu(.createTopic)) + } + } + } + CommonContextMenu( id: forum.id, isFavorite: forum.isFavorite, From 63c378f0807fc8031f9b49b61dba5153fa5c9a6a Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 22 Feb 2026 21:57:41 +0300 Subject: [PATCH 040/118] [WIP] UploadBox --- .../Fields/FormUploadBoxFeature.swift | 329 ++---------------- Modules/Sources/FormFeature/FormFeature.swift | 7 - .../Models/UploadBoxFile.swift | 4 +- .../Resources/Localizable.xcstrings | 27 +- .../UploadBoxFeature/UploadBoxFeature.swift | 136 +++++--- .../UploadBoxFeature/UploadBoxView.swift | 17 +- 6 files changed, 155 insertions(+), 365 deletions(-) diff --git a/Modules/Sources/FormFeature/Fields/FormUploadBoxFeature.swift b/Modules/Sources/FormFeature/Fields/FormUploadBoxFeature.swift index 8f5a8aa2..5b22dd61 100644 --- a/Modules/Sources/FormFeature/Fields/FormUploadBoxFeature.swift +++ b/Modules/Sources/FormFeature/Fields/FormUploadBoxFeature.swift @@ -5,61 +5,20 @@ // Created by Ilia Lubianoi on 19.07.2025. // -#warning("to do") - import SwiftUI import ComposableArchitecture -import PhotosUI +import UploadBoxFeature // MARK: - Feature @Reducer public struct FormUploadBoxFeature: Reducer { - // MARK: - Helpers - - public struct File: Identifiable, Equatable { - - public enum FileType { - case file, image - } - - public let id: Int - let name: String - let type: FileType - let data: Data - - public init(id: Int, name: String, type: FileType, data: Data) { - self.id = id - self.name = name - self.type = type - self.data = data - } - } - - var isPreview: Bool { - return ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" - } - - // MARK: - Destination - - @Reducer(state: .equatable) - public enum Destination { - case confirmationDialog(ConfirmationDialogState) - case fileImporter - case photosPicker - - @CasePathable - public enum Dialog { - case gallery, files - } - } - // MARK: - State @ObservableState public struct State: Equatable, FormFieldConformable { - @Presents public var destination: Destination.State? + public var upload = UploadBoxFeature.State(type: .form) public let id: Int let title: String @@ -68,7 +27,7 @@ public struct FormUploadBoxFeature: Reducer { let allowedExtensions: [String] var isLoading: Bool - public var files: [File] + var uploadedFilesIds: [Int] = [] public init( id: Int, @@ -76,8 +35,7 @@ public struct FormUploadBoxFeature: Reducer { description: String, flag: Int, allowedExtensions: [String], - isLoading: Bool = false, - files: [File] = [] + isLoading: Bool = false ) { self.id = id self.title = title @@ -85,16 +43,15 @@ public struct FormUploadBoxFeature: Reducer { self.flag = flag self.allowedExtensions = allowedExtensions self.isLoading = isLoading - //self.files = files } func getValue() -> FormValue { - return .array([]) - //return Array().map { "[\($0.id),\($0.name)]" }.joined(separator: ",") + return .array(uploadedFilesIds.map { .integer($0) }) } func isValid() -> Bool { - return false + if isLoading { return false } + return isRequired ? !uploadedFilesIds.isEmpty : true } } @@ -102,25 +59,11 @@ public struct FormUploadBoxFeature: Reducer { public enum Action: BindableAction, ViewAction { case binding(BindingAction) - case destination(PresentationAction) + case upload(UploadBoxFeature.Action) case view(View) public enum View { - case selectFilesButtonTapped - case removeFileButtonTapped(File) - case addMoreButtonTapped - case photosPickerPhotoSelected(Data) - case fileImporterURLsRecieved([URL]) - } - - case `internal`(Internal) - public enum Internal { - case showFiles - } - - case delegate(Delegate) - public enum Delegate { - case filesHasBeenUploaded + case onAppear } } @@ -129,90 +72,32 @@ public struct FormUploadBoxFeature: Reducer { public var body: some Reducer { BindingReducer() - Reduce { - state, - action in + Scope(state: \.upload, action: \.upload) { + UploadBoxFeature() + } + + Reduce { state, action in switch action { - case .binding: - break - - case .delegate: - break - - case .destination(.presented(.confirmationDialog(.files))): - if isPreview { - return .send(.view(.fileImporterURLsRecieved([]))) - } else { - state.destination = .fileImporter - } - - case .destination(.presented(.confirmationDialog(.gallery))): - if isPreview { - return .send(.view(.photosPickerPhotoSelected(Data()))) - } else { - state.destination = .photosPicker - } - - case .destination: - break + case .upload(.delegate(.someFileUploading)): + state.isLoading = true - case .view(.selectFilesButtonTapped), .view(.addMoreButtonTapped): - let dialogState = ConfirmationDialogState( - title: { TextState(verbatim: "") }, - actions: { - ButtonState(action: .gallery) { - TextState("Choose from Gallery", bundle: .module) - } - ButtonState(action: .files) { - TextState("Choose from Files", bundle: .module) - } - } - ) - state.destination = .confirmationDialog(dialogState) - - case let .view(.removeFileButtonTapped(file)): - state.files.removeAll(where: { $0.id == file.id }) + case .upload(.delegate(.allFilesAreUploaded)): + state.isLoading = false - case let .view(.photosPickerPhotoSelected(data)): - let file = File( - id: state.files.count, - name: UUID().uuidString, - type: .image, - data: data - ) - state.files.append(file) + case let .upload(.delegate(.fileHasBeenUploaded(id))): + state.uploadedFilesIds.append(id) - case let .view(.fileImporterURLsRecieved(urls)): - var urls = urls - if isPreview { - let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - let fileURL = documentsURL.appending(path: "data.dat") - try! Data().write(to: fileURL) - urls.append(fileURL) - } + case let .upload(.delegate(.fileHasBeenRemoved(id))): + state.uploadedFilesIds.removeAll(where: { $0 == id }) - for url in urls { - guard let data = try? Data(contentsOf: url) else { - print("Couldn't extract data from url: \(url)") - continue - } - let file = File( - id: state.files.count, - name: url.lastPathComponent, - type: .file, - data: data - ) - state.files.append(file) - } + case .view(.onAppear): + state.upload.allowedExtensions = state.allowedExtensions - case .internal(.showFiles): - state.isLoading = false - return .send(.delegate(.filesHasBeenUploaded)) + case .binding, .upload: + break } - return .none } - .ifLet(\.$destination, action: \.destination) } } @@ -225,7 +110,6 @@ struct FormUploadBoxRow: View { @Perception.Bindable var store: StoreOf @Environment(\.tintColor) private var tintColor - @State private var pickerItem: PhotosPickerItem? // MARK: - Body @@ -238,146 +122,14 @@ struct FormUploadBoxRow: View { required: store.isRequired ) { WithPerceptionTracking { - if store.files.isEmpty { - UploadView() - } else { - FilesGrid() - } - } - } - .overlay(alignment: .topTrailing) { - if !store.files.isEmpty { - Button { - send(.addMoreButtonTapped) - } label: { - Label("Add more", systemSymbol: .plus) - .font(.footnote) - .tint(tintColor) - } - } - } - } - .confirmationDialog( - $store.scope( - state: \.destination?.confirmationDialog, - action: \.destination.confirmationDialog - ) - ) - .fileImporter( - isPresented: Binding($store.destination.fileImporter), - allowedContentTypes: [.png, .jpeg], - allowsMultipleSelection: true, - onCompletion: { result in - switch result { - case let .success(urls): - send(.fileImporterURLsRecieved(urls)) - case let .failure(error): - print("File importer error: \(error)") - #warning("Handle error") + UploadBoxView(store: store.scope(state: \.upload, action: \.upload)) } } - ) - .photosPicker( - isPresented: Binding($store.destination.photosPicker), - selection: $pickerItem - ) - .task(id: pickerItem) { - guard let data = try? await pickerItem?.loadTransferable(type: Data.self) else { - return - } - if let pickerItem, let localID = pickerItem.itemIdentifier { - let result = PHAsset.fetchAssets(withLocalIdentifiers: [localID], options: nil) - if let asset = result.firstObject { - print("Got " + asset.debugDescription) - #warning("Check if its working") - } - } - send(.photosPickerPhotoSelected(data)) } .tint(tintColor) - } - } - - // MARK: - Upload View - - @ViewBuilder - private func UploadView() -> some View { - Button { - send(.selectFilesButtonTapped) - } label: { - VStack(spacing: 8) { - Image(systemSymbol: .docBadgePlus) - .font(.title) - .frame(width: 48, height: 48) - - Text("Select files...", bundle: .module) - .font(.body) - .foregroundColor(Color(.Labels.quaternary)) - } - .frame(maxWidth: .infinity, minHeight: 144) - .background { - RoundedRectangle(cornerRadius: 14) - .fill(Color(.Background.teritary)) - } - .overlay { - RoundedRectangle(cornerRadius: 14) - .strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [8])) - } - } - } - - // MARK: - Files Grid - - @ViewBuilder - private func FilesGrid() -> some View { - ScrollView(.horizontal) { - HStack(spacing: 12) { - ForEach(store.files) { file in - FileView(file) - } - } - } - .scrollIndicators(.hidden) - .animation(.default, value: store.files) - } - - // MARK: - File View - - @ViewBuilder - private func FileView(_ file: FormUploadBoxFeature.File) -> some View { - VStack(spacing: 0) { - Image(systemSymbol: file.type == .file ? .doc : .photo) - .font(.title) - .foregroundColor(tintColor) - .frame(width: 48, height: 48) - - Text(file.name) - .font(.footnote) - .foregroundStyle(Color(.Labels.primary)) - .lineLimit(2) - .multilineTextAlignment(.center) - } - .padding(.horizontal, 12) - .frame(minWidth: 144, maxWidth: 144, minHeight: 144) - .background( - RoundedRectangle(cornerRadius: 14) - .fill(Color(.Background.teritary)) - ) - .overlay(alignment: .topTrailing) { - Button { - send(.removeFileButtonTapped(file)) - } label: { - Circle() - .fill(Color(.Background.teritary)) - .frame(width: 28, height: 28) - .overlay { - Image(systemSymbol: .xmark) - .font(.body) - .foregroundStyle(Color(.Labels.teritary)) - } - .padding([.top, .trailing], 6) + .onAppear { + send(.onAppear) } - .buttonStyle(.plain) } } } @@ -401,26 +153,3 @@ struct FormUploadBoxRow: View { .padding(.horizontal, 16) .environment(\.tintColor, Color(.Theme.primary)) } - -#Preview("Upload Box (Filled, 3)") { - FormUploadBoxRow( - store: Store( - initialState: FormUploadBoxFeature.State( - id: 0, - title: "File skin", - description: "Supported formats: jpg, jpeg, gif, png", - flag: 1, - allowedExtensions: ["jpg", "jpeg", "gif", "png"], - files: [ - .init(id: 0, name: "File 1", type: .file, data: Data()), - .init(id: 1, name: "Image 1", type: .image, data: Data()), - .init(id: 2, name: "File 2", type: .file, data: Data()), - ] - ) - ) { - FormUploadBoxFeature() - } - ) - .padding(.horizontal, 16) - .environment(\.tintColor, Color(.Theme.primary)) -} diff --git a/Modules/Sources/FormFeature/FormFeature.swift b/Modules/Sources/FormFeature/FormFeature.swift index 2069fc86..b1191444 100644 --- a/Modules/Sources/FormFeature/FormFeature.swift +++ b/Modules/Sources/FormFeature/FormFeature.swift @@ -183,13 +183,6 @@ public struct FormFeature: Reducer, Sendable { break case let .rows(action): - if case let .element(id: id, action: .uploadBox(.delegate(.filesHasBeenUploaded))) = action { - if case let .uploadBox(uploadBoxState) = state.rows[id: id] { - print("Files: \(uploadBoxState)") - } else { - fatalError("Non UploadBox state casted by action id") - } - } break case .view(.onAppear): diff --git a/Modules/Sources/UploadBoxFeature/Models/UploadBoxFile.swift b/Modules/Sources/UploadBoxFeature/Models/UploadBoxFile.swift index 209504bd..5cfbb852 100644 --- a/Modules/Sources/UploadBoxFeature/Models/UploadBoxFile.swift +++ b/Modules/Sources/UploadBoxFeature/Models/UploadBoxFile.swift @@ -15,7 +15,9 @@ public struct UploadBoxFile: Sendable, Identifiable, Equatable { public var isUploading: Bool public var isUploadError: Bool - public enum FileType: Sendable { + public var serverId: Int? = nil + + public enum FileType: Sendable, Equatable { case file, image } diff --git a/Modules/Sources/UploadBoxFeature/Resources/Localizable.xcstrings b/Modules/Sources/UploadBoxFeature/Resources/Localizable.xcstrings index 33963347..afe7bc89 100644 --- a/Modules/Sources/UploadBoxFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/UploadBoxFeature/Resources/Localizable.xcstrings @@ -2,13 +2,34 @@ "sourceLanguage" : "en", "strings" : { "Choose from Files" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбрать из файлов" + } + } + } }, "Choose from Gallery" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбрать из галереи" + } + } + } }, "Select files..." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите файлы…" + } + } + } }, "The selected file will be inserted into the text as code wherever your cursor is located. Or it will also be automatically added to the end of the post." : { diff --git a/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift b/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift index 798913c8..9a9e8511 100644 --- a/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift +++ b/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift @@ -21,6 +21,13 @@ public struct UploadBoxFeature: Reducer, Sendable { return ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" } + // MARK: - File Type + + public enum FileType: Equatable, Sendable { + case file(url: URL) + case image(url: URL, ext: String?) + } + // MARK: - Destination @Reducer @@ -42,10 +49,10 @@ public struct UploadBoxFeature: Reducer, Sendable { @Presents public var destination: Destination.State? let type: UploadBoxType - let allowedExtensions: [String] - + public var allowedExtensions: [String] var files: [UploadBoxFile] - //var uploadQueue: [UUID] = [] + + var uploadQueue: [FileType] = [] var isAnyFileUploading = false public var filesCount: Int { @@ -54,7 +61,7 @@ public struct UploadBoxFeature: Reducer, Sendable { public init( type: UploadBoxType, - allowedExtensions: [String], + allowedExtensions: [String] = [], files: [UploadBoxFile] = [] ) { self.type = type @@ -73,13 +80,15 @@ public struct UploadBoxFeature: Reducer, Sendable { public enum View { case selectFilesButtonTapped case removeFileButtonTapped(UploadBoxFile) - case photosPickerPhotoSelected(Data) + case photosPickerPhotosSelected([FileType]) case fileImporterURLsRecieved([URL]) } case `internal`(Internal) public enum Internal { + case startNextUpload case uploadFile(UploadBoxFile) + case uploadFileFinished(index: Int, Int) case updateFileUploadStatus(UUID, UploadProgressStatus) } @@ -87,7 +96,11 @@ public struct UploadBoxFeature: Reducer, Sendable { public enum Delegate { case someFileUploading case allFilesAreUploaded - case filesHasBeenTapped(String) + case fileHasBeenRemoved(Int) + case fileHasBeenUploaded(Int) + + // TODO: Implement + case fileHasBeenTapped(Int) } } @@ -121,7 +134,9 @@ public struct UploadBoxFeature: Reducer, Sendable { case .destination(.presented(.confirmationDialog(.gallery))): if isPreview { - return .send(.view(.photosPickerPhotoSelected(Data()))) + return .send(.view(.photosPickerPhotosSelected( + [.image(url: URL(fileURLWithPath: ""), ext: nil)] + ))) } else { state.destination = .photosPicker } @@ -148,57 +163,63 @@ public struct UploadBoxFeature: Reducer, Sendable { if file.isUploading { return .cancel(id: CancelID.uploading(file.id)) } + if let serverId = file.serverId { + return .send(.delegate(.fileHasBeenRemoved(serverId))) + } - case let .view(.photosPickerPhotoSelected(data)): - if !isPreview { - if let imageExtension = data.imageExtension { - let file = UploadBoxFile( - name: "\(UUID().uuidString).\(imageExtension)", - type: .image, - data: data - ) - return .send(.internal(.uploadFile(file))) - } else { - // TODO: send error alert - } - } else { + case let .view(.photosPickerPhotosSelected(images)): + if isPreview { state.files.append(.mockImage) + return .none } + state.uploadQueue = images + return .send(.internal(.startNextUpload)) case let .view(.fileImporterURLsRecieved(urls)): - //var urls = urls -#warning("Fix preview") if isPreview { let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let fileURL = documentsURL.appending(path: "data.dat") try! Data().write(to: fileURL) - //urls.append(fileURL) state.files.append(.mockFile) return .none } - return .run { [urls = urls] send in - for url in urls { - if url.startAccessingSecurityScopedResource() { - guard let data = try? Data(contentsOf: url) else { - // TODO: Add alert - print("Couldn't extract data from url: \(url)") - continue - } - url.stopAccessingSecurityScopedResource() - - let file = UploadBoxFile( - name: url.lastPathComponent, - type: .file, - data: data, - isUploading: true - ) - await send(.internal(.uploadFile(file))) - - while file.isUploading { /* waiting... */ } - } else { - print("=============== NO ACCESS ================") - } + state.uploadQueue = urls.map { .file(url: $0) } + return .send(.internal(.startNextUpload)) + + case .internal(.startNextUpload): + guard let item = state.uploadQueue.first else { + state.isAnyFileUploading = false + return .send(.delegate(.allFilesAreUploaded)) + } + state.isAnyFileUploading = true + state.uploadQueue.removeFirst() + + return .run { send in + let (url, uploadType): (URL, UploadBoxFile.FileType) + switch item { + case .file(let u): + url = u + uploadType = .file + case .image(let u, _): + url = u + uploadType = .image + } + + guard url.startAccessingSecurityScopedResource() else { return } + defer { url.stopAccessingSecurityScopedResource() } + + guard let data = try? Data(contentsOf: url) else { + await send(.internal(.startNextUpload)) + return } + + let file = UploadBoxFile( + name: data.imageExtension ?? url.lastPathComponent, + type: uploadType, + data: data, + isUploading: true + ) + await send(.internal(.uploadFile(file))) } case let .internal(.uploadFile(file)): @@ -212,6 +233,8 @@ public struct UploadBoxFeature: Reducer, Sendable { md5: calculateFileHash(data: file.data), isQms: false ) + await send(.delegate(.someFileUploading)) + for await status in apiClient.upload(request) { await send(.internal(.updateFileUploadStatus(file.id, status))) } @@ -222,20 +245,29 @@ public struct UploadBoxFeature: Reducer, Sendable { if let index = state.files.firstIndex(where: { $0.id == id }) { switch status { case .done(let response): - print("YEY<<<<<<< \(response)") - state.files[index].isUploading = false - state.isAnyFileUploading = false + guard let fileId = Int(response.replacingOccurrences(of: "[", with: "") + .replacingOccurrences(of: "]", with: "") + .components(separatedBy: ",")[2]) else { + state.files[index].isUploading = false + state.files[index].isUploadError = true + return .none + } + return .send(.internal(.uploadFileFinished(index: index, fileId))) + case .uploading(let value): print("Reducer UPLAODING: \(value)") state.files[index].isUploading = true + case .initialized: print("FILE UPLOADING INITIALIZED") state.files[index].isUploading = true + case .error(let err): // TODO: Alert? print("ERROR ON FILE UPLOADING: \(err)") state.files[index].isUploading = false state.files[index].isUploadError = true + @unknown default: print("UNKNOWN DEFAULT ERROR! \(id), \(status)") state.files[index].isUploading = false @@ -245,6 +277,14 @@ public struct UploadBoxFeature: Reducer, Sendable { // TODO: Handle error. } return .none + + case let .internal(.uploadFileFinished(index, responseFileId)): + state.files[index].serverId = responseFileId + state.files[index].isUploading = false + return .concatenate( + .send(.delegate(.fileHasBeenUploaded(responseFileId))), + .send(.internal(.startNextUpload)) + ) } return .none diff --git a/Modules/Sources/UploadBoxFeature/UploadBoxView.swift b/Modules/Sources/UploadBoxFeature/UploadBoxView.swift index cbeb4bae..be1c392d 100644 --- a/Modules/Sources/UploadBoxFeature/UploadBoxView.swift +++ b/Modules/Sources/UploadBoxFeature/UploadBoxView.swift @@ -17,7 +17,7 @@ public struct UploadBoxView: View { @Perception.Bindable public var store: StoreOf @Environment(\.tintColor) private var tintColor - @State private var pickerItem: PhotosPickerItem? + @State private var pickerItems: [PhotosPickerItem] = [] // MARK: - Init @@ -65,13 +65,18 @@ public struct UploadBoxView: View { ) .photosPicker( isPresented: Binding($store.destination.photosPicker), - selection: $pickerItem + selection: $pickerItems, + maxSelectionCount: 10 ) - .task(id: pickerItem) { - guard let data = try? await pickerItem?.loadTransferable(type: Data.self) else { - return + .task(id: pickerItems) { + var photos: [UploadBoxFeature.FileType] = [] + for item in pickerItems { + if let url = try? await item.loadTransferable(type: URL.self) { + let type = item.supportedContentTypes.first + photos.append(.image(url: url, ext: type?.preferredFilenameExtension)) + } } - send(.photosPickerPhotoSelected(data)) + send(.photosPickerPhotosSelected(photos)) } .tint(tintColor) } From 966532536bd0b6aec005f64217476b5751de228d Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 22 Feb 2026 21:58:07 +0300 Subject: [PATCH 041/118] Update localization --- .../Resources/Localizable.xcstrings | 37 +------------------ 1 file changed, 1 insertion(+), 36 deletions(-) diff --git a/Modules/Sources/FormFeature/Resources/Localizable.xcstrings b/Modules/Sources/FormFeature/Resources/Localizable.xcstrings index 3e6bcd0d..1c338705 100644 --- a/Modules/Sources/FormFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/FormFeature/Resources/Localizable.xcstrings @@ -2,6 +2,7 @@ "sourceLanguage" : "en", "strings" : { "Add more" : { + "extractionState" : "stale", "localizations" : { "ru" : { "stringUnit" : { @@ -37,26 +38,6 @@ } } }, - "Choose from Files" : { - "localizations" : { - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Выбрать из файлов" - } - } - } - }, - "Choose from Gallery" : { - "localizations" : { - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Выбрать из галереи" - } - } - } - }, "Edit post" : { "localizations" : { "ru" : { @@ -227,22 +208,6 @@ } } }, - "Select files..." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Select files..." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Выберите файлы…" - } - } - } - }, "Send report" : { "localizations" : { "en" : { From 497358cc3493054dfe932bf7f741d470525bca9a Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 22 Feb 2026 22:25:38 +0300 Subject: [PATCH 042/118] Post-merge fixes --- .../FormFeature/Tests}/FormFeatureTests.swift | 0 Modules/Sources/TopicFeature/TopicFeature.swift | 6 +++--- 2 files changed, 3 insertions(+), 3 deletions(-) rename Modules/{Tests/FormFeatureTests => Sources/FormFeature/Tests}/FormFeatureTests.swift (100%) diff --git a/Modules/Tests/FormFeatureTests/FormFeatureTests.swift b/Modules/Sources/FormFeature/Tests/FormFeatureTests.swift similarity index 100% rename from Modules/Tests/FormFeatureTests/FormFeatureTests.swift rename to Modules/Sources/FormFeature/Tests/FormFeatureTests.swift diff --git a/Modules/Sources/TopicFeature/TopicFeature.swift b/Modules/Sources/TopicFeature/TopicFeature.swift index fe03acf7..1f9d4189 100644 --- a/Modules/Sources/TopicFeature/TopicFeature.swift +++ b/Modules/Sources/TopicFeature/TopicFeature.swift @@ -431,14 +431,14 @@ public struct TopicFeature: Reducer, Sendable { let currentDate = Date().formatted(date: .numeric, time: .shortened) let formattedQuote = "[quote name=\"\(post.post.author.name)\" date=\"\(currentDate)\" post=\"\(post.id)\"]\n\(quotedText)\n[/quote]\n" - let feature = WriteFormFeature.State( - formFor: .post( + let feature = FormFeature.State( + type: .post( type: .new, topicId: state.topicId, content: .simple(formattedQuote, []) ) ) - state.destination = .writeForm(feature) + state.destination = .form(feature) return .none case .view(.finishedPostAnimation): From 4d7252efd3cf4c5de8c9470b2aaa1be212d916e9 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 22 Feb 2026 22:33:01 +0300 Subject: [PATCH 043/118] Post-merge fixes --- Project.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Project.swift b/Project.swift index ebe1cb37..42dba502 100644 --- a/Project.swift +++ b/Project.swift @@ -695,7 +695,7 @@ let project = Project( ), .tests( - name: "FormFeatureTests", + name: "FormFeature", dependencies: [ .Internal.APIClient, .Internal.Models, From 40953c17207d59874362bf61569b988ec15b3b29 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 22 Feb 2026 22:48:10 +0300 Subject: [PATCH 044/118] Improve dropdown for liquid glass in FormFeature --- Modules/Sources/FormFeature/Fields/FormDropdownFeature.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Modules/Sources/FormFeature/Fields/FormDropdownFeature.swift b/Modules/Sources/FormFeature/Fields/FormDropdownFeature.swift index 326e2a12..bf433957 100644 --- a/Modules/Sources/FormFeature/Fields/FormDropdownFeature.swift +++ b/Modules/Sources/FormFeature/Fields/FormDropdownFeature.swift @@ -7,6 +7,7 @@ import SwiftUI import ComposableArchitecture +import SharedUI // MARK: - Feature @@ -114,11 +115,11 @@ struct FormDropdownRow: View { } .padding() .background( - RoundedRectangle(cornerRadius: 14) + RoundedRectangle(cornerRadius: isLiquidGlass ? 28 : 14) .fill(Color(.Background.teritary)) ) .overlay { - RoundedRectangle(cornerRadius: 14) + RoundedRectangle(cornerRadius: isLiquidGlass ? 28 : 14) .strokeBorder(Color(.Separator.primary)) } } From 85e3026cda0f74c68077bf8024bf2589cf8ba8fb Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 22 Feb 2026 23:01:09 +0300 Subject: [PATCH 045/118] Add analytics to FormFeature --- .../Analytics/FormFeature+Analytics.swift | 45 +++++++++++++++++++ Modules/Sources/FormFeature/FormFeature.swift | 5 ++- 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 Modules/Sources/FormFeature/Analytics/FormFeature+Analytics.swift diff --git a/Modules/Sources/FormFeature/Analytics/FormFeature+Analytics.swift b/Modules/Sources/FormFeature/Analytics/FormFeature+Analytics.swift new file mode 100644 index 00000000..e04f87ae --- /dev/null +++ b/Modules/Sources/FormFeature/Analytics/FormFeature+Analytics.swift @@ -0,0 +1,45 @@ +// +// WriteFormFeature+Analytics.swift +// ForPDA +// +// Created by Ilia Lubianoi on 13.12.2025. +// + +import ComposableArchitecture +import AnalyticsClient + +extension FormFeature { + + struct Analytics: Reducer { + typealias State = FormFeature.State + typealias Action = FormFeature.Action + + @Dependency(\.analyticsClient) var analytics + + var body: some Reducer { + Reduce { state, action in + switch action { + case .binding, .destination, .rows, .internal: + break + + case .delegate(.formSent): + analytics.log(WriteFormEvent.writeFormSent) + + case .view(.publishButtonTapped): + analytics.log(WriteFormEvent.publishTapped) + + case .view(.cancelButtonTapped): + analytics.log(WriteFormEvent.dismissTapped) + + case .view(.previewButtonTapped): + analytics.log(WriteFormEvent.previewTapped) + + case .view: + break + } + + return .none + } + } + } +} diff --git a/Modules/Sources/FormFeature/FormFeature.swift b/Modules/Sources/FormFeature/FormFeature.swift index b1191444..f5893c01 100644 --- a/Modules/Sources/FormFeature/FormFeature.swift +++ b/Modules/Sources/FormFeature/FormFeature.swift @@ -421,7 +421,8 @@ public struct FormFeature: Reducer, Sendable { case let .internal(.templateResponse(.failure(error))): state.isPublishing = false - #warning("add error") + state.destination = .alert(.unknownError) + analyticsClient.capture(error) } return .none @@ -430,6 +431,8 @@ public struct FormFeature: Reducer, Sendable { .forEach(\.rows, action: \.rows) { FormFieldFeature() } + + Analytics() } } From 8a96728e3ee63d311dd84b1e66cb8ef3aa7c6817 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 22 Feb 2026 23:36:40 +0300 Subject: [PATCH 046/118] Post-merge fix --- .../WriteFormFeature+Analytics.swift | 45 ------------------- 1 file changed, 45 deletions(-) delete mode 100644 Modules/Sources/WriteFormFeature/Analytics/WriteFormFeature+Analytics.swift diff --git a/Modules/Sources/WriteFormFeature/Analytics/WriteFormFeature+Analytics.swift b/Modules/Sources/WriteFormFeature/Analytics/WriteFormFeature+Analytics.swift deleted file mode 100644 index 26b7c05e..00000000 --- a/Modules/Sources/WriteFormFeature/Analytics/WriteFormFeature+Analytics.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// WriteFormFeature+Analytics.swift -// ForPDA -// -// Created by Ilia Lubianoi on 13.12.2025. -// - -import ComposableArchitecture -import AnalyticsClient - -extension WriteFormFeature { - - struct Analytics: Reducer { - typealias State = WriteFormFeature.State - typealias Action = WriteFormFeature.Action - - @Dependency(\.analyticsClient) var analytics - - var body: some Reducer { - Reduce { state, action in - switch action { - case .binding, .destination, .bbPanel, .internal: - break - - case .delegate(.writeFormSent): - analytics.log(WriteFormEvent.writeFormSent) - - case .view(.publishButtonTapped): - analytics.log(WriteFormEvent.publishTapped) - - case .view(.dismissButtonTapped): - analytics.log(WriteFormEvent.dismissTapped) - - case .view(.previewButtonTapped): - analytics.log(WriteFormEvent.previewTapped) - - case .view: - break - } - - return .none - } - } - } -} From 550bb1f5d5d704067679adba5cca8bd94dca97bc Mon Sep 17 00:00:00 2001 From: Xialtal Date: Mon, 23 Feb 2026 14:18:44 +0300 Subject: [PATCH 047/118] Add input length limit for text field in FormFeature --- .../FormFeature/Fields/FormTextFieldFeature.swift | 8 ++++++-- Modules/Sources/FormFeature/FormFeature.swift | 5 +++-- .../Sources/Models/Common/WriteFormFieldType.swift | 11 +++++++---- .../ParsingClient/Parsers/WriteFormParser.swift | 7 ++++++- 4 files changed, 22 insertions(+), 9 deletions(-) diff --git a/Modules/Sources/FormFeature/Fields/FormTextFieldFeature.swift b/Modules/Sources/FormFeature/Fields/FormTextFieldFeature.swift index d4c99205..3950fe39 100644 --- a/Modules/Sources/FormFeature/Fields/FormTextFieldFeature.swift +++ b/Modules/Sources/FormFeature/Fields/FormTextFieldFeature.swift @@ -23,6 +23,7 @@ public struct FormTextFieldFeature: Reducer { let description: String let placeholder: String let flag: Int + let maxLength: Int? public var text = "" public init( @@ -31,7 +32,8 @@ public struct FormTextFieldFeature: Reducer { description: String = "", placeholder: String = "", flag: Int, - defaultText: String = "" + defaultText: String = "", + maxLength: Int? = nil ) { self.id = id self.title = title @@ -39,6 +41,7 @@ public struct FormTextFieldFeature: Reducer { self.placeholder = placeholder self.flag = flag self.text = defaultText + self.maxLength = maxLength } func getValue() -> FormValue { @@ -86,7 +89,8 @@ struct FormTextFieldRow: View { content: $store.text, placeholder: LocalizedStringResource(stringLiteral: store.placeholder), focusEqual: store.id, - focus: $focusedField + focus: $focusedField, + characterLimit: store.maxLength ) } } diff --git a/Modules/Sources/FormFeature/FormFeature.swift b/Modules/Sources/FormFeature/FormFeature.swift index f5893c01..8d2f0770 100644 --- a/Modules/Sources/FormFeature/FormFeature.swift +++ b/Modules/Sources/FormFeature/FormFeature.swift @@ -271,14 +271,15 @@ public struct FormFeature: Reducer, Sendable { let titleState = FormTitleFeature.State(id: index, text: content) state.rows.append(.title(titleState)) - case let .text(content): + case let .text(content, maxLength): let textFieldState = FormTextFieldFeature.State( id: index, title: content.name, description: content.description, placeholder: content.example, flag: content.flag, - defaultText: content.defaultValue + defaultText: content.defaultValue, + maxLength: maxLength ) state.rows.append(.textField(textFieldState)) diff --git a/Modules/Sources/Models/Common/WriteFormFieldType.swift b/Modules/Sources/Models/Common/WriteFormFieldType.swift index e0c2115d..6eb8d50d 100644 --- a/Modules/Sources/Models/Common/WriteFormFieldType.swift +++ b/Modules/Sources/Models/Common/WriteFormFieldType.swift @@ -7,7 +7,7 @@ public enum WriteFormFieldType: Sendable, Equatable, Hashable { case title(String) - case text(FormField) + case text(FormField, maxLenght: Int?) case editor(FormField) case dropdown(FormField, _ options: [String]) case uploadbox(FormField, _ extensions: [String]) @@ -62,7 +62,8 @@ public extension WriteFormFieldType { example: "Starting from For, ends with PDA", flag: 1, defaultValue: "" - ) + ), + maxLenght: 255 ) static let mockEditor: WriteFormFieldType = .editor( @@ -128,7 +129,8 @@ extension Array where Element == WriteFormFieldType { example: "", flag: 1, defaultValue: "" - ) + ), + maxLenght: 255 ), .text( .init( @@ -138,7 +140,8 @@ extension Array where Element == WriteFormFieldType { example: "", flag: 1, defaultValue: "" - ) + ), + maxLenght: nil ), .editor( .init( diff --git a/Modules/Sources/ParsingClient/Parsers/WriteFormParser.swift b/Modules/Sources/ParsingClient/Parsers/WriteFormParser.swift index c0f0d4cf..aa41a6e1 100644 --- a/Modules/Sources/ParsingClient/Parsers/WriteFormParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/WriteFormParser.swift @@ -112,7 +112,12 @@ public struct WriteFormParser { switch type { case "text", "editor": - formFields.append(type == "text" ? .text(content) : .editor(content)) + let maxLenght: Int? = field[safe: 7] as? Int + formFields.append( + type == "text" + ? .text(content, maxLenght: maxLenght == 0 ? nil : maxLenght) + : .editor(content) + ) case "dropdown", "checkbox_list": guard let options = field[6] as? [String] else { From 65e80a03cbdc0a457ad69aa041f22858c48b951a Mon Sep 17 00:00:00 2001 From: Xialtal Date: Mon, 23 Feb 2026 16:56:23 +0300 Subject: [PATCH 048/118] [WIP] UploadBox --- .../Models/UploadBoxFile.swift | 12 +- .../Resources/Localizable.xcstrings | 89 ++++++- .../UploadBoxFeature/UploadBoxFeature.swift | 236 +++++++++++++++--- .../UploadBoxFeature/UploadBoxView.swift | 9 +- 4 files changed, 302 insertions(+), 44 deletions(-) diff --git a/Modules/Sources/UploadBoxFeature/Models/UploadBoxFile.swift b/Modules/Sources/UploadBoxFeature/Models/UploadBoxFile.swift index 5cfbb852..7c6ded28 100644 --- a/Modules/Sources/UploadBoxFeature/Models/UploadBoxFile.swift +++ b/Modules/Sources/UploadBoxFeature/Models/UploadBoxFile.swift @@ -13,7 +13,7 @@ public struct UploadBoxFile: Sendable, Identifiable, Equatable { public let type: FileType public let data: Data public var isUploading: Bool - public var isUploadError: Bool + public var uploadingError: UploadErrorType? public var serverId: Int? = nil @@ -21,18 +21,24 @@ public struct UploadBoxFile: Sendable, Identifiable, Equatable { case file, image } + public enum UploadErrorType: Sendable { + case sizeTooBig + case badExtension + case uploadFailure + } + public init( name: String, type: FileType, data: Data, isUploading: Bool = false, - isUploadError: Bool = false + uploadingError: UploadErrorType? = nil ) { self.name = name self.type = type self.data = data self.isUploading = isUploading - self.isUploadError = isUploadError + self.uploadingError = uploadingError } } diff --git a/Modules/Sources/UploadBoxFeature/Resources/Localizable.xcstrings b/Modules/Sources/UploadBoxFeature/Resources/Localizable.xcstrings index afe7bc89..8e7356a4 100644 --- a/Modules/Sources/UploadBoxFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/UploadBoxFeature/Resources/Localizable.xcstrings @@ -1,6 +1,26 @@ { "sourceLanguage" : "en", "strings" : { + "An error occurred while uploading the file" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Во время загрузки файла произошла ошибка" + } + } + } + }, + "Cancel" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отмена" + } + } + } + }, "Choose from Files" : { "localizations" : { "en" : { @@ -21,6 +41,36 @@ } } }, + "Delete" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить" + } + } + } + }, + "File size too big" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Слишком большой размер" + } + } + } + }, + "Select another file. If there are already files in the queue, it will be uploaded last" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите другой файл. Если в очереди уже есть файлы, то он будет загружен последним" + } + } + } + }, "Select files..." : { "localizations" : { "en" : { @@ -31,8 +81,45 @@ } } }, + "Sorry, this format is not supported" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Извините, данный формат не поддерживается" + } + } + } + }, "The selected file will be inserted into the text as code wherever your cursor is located. Or it will also be automatically added to the end of the post." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбранный файл будет вставлен в текст, в виде кода, где у вас находится курсор. Или добавится автоматически в конец поста." + } + } + } + }, + "Try Again" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Попробовать еще раз" + } + } + } + }, + "You can try uploading it again. If there are already files in the queue, it will be uploaded last" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вы можете попробовать загрузить его еще раз. Если в очереди уже есть файлы, то он будет загружен последним" + } + } + } } }, "version" : "1.1" diff --git a/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift b/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift index 9a9e8511..38e4822b 100644 --- a/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift +++ b/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift @@ -23,9 +23,9 @@ public struct UploadBoxFeature: Reducer, Sendable { // MARK: - File Type - public enum FileType: Equatable, Sendable { + public enum FileType: Equatable, Hashable, Sendable { case file(url: URL) - case image(url: URL, ext: String?) + case image(data: Data, ext: String?) } // MARK: - Destination @@ -35,6 +35,16 @@ public struct UploadBoxFeature: Reducer, Sendable { case confirmationDialog(ConfirmationDialogState) case fileImporter case photosPicker + case alert(AlertState) + + @CasePathable + public enum Alert: Equatable { + case removeFile(UUID) + case reuploadFile(UUID) + + case selectFileFromFiles(oldFile: UUID?) + case selectFileFromGallery(oldFile: UUID?) + } @CasePathable public enum Dialog { @@ -78,6 +88,7 @@ public struct UploadBoxFeature: Reducer, Sendable { case view(View) public enum View { + case fileWithErrorTapped(UUID) case selectFilesButtonTapped case removeFileButtonTapped(UploadBoxFile) case photosPickerPhotosSelected([FileType]) @@ -90,6 +101,7 @@ public struct UploadBoxFeature: Reducer, Sendable { case uploadFile(UploadBoxFile) case uploadFileFinished(index: Int, Int) case updateFileUploadStatus(UUID, UploadProgressStatus) + case uploadFileCanceledByValidation(UUID?, UploadBoxFile.UploadErrorType) } case delegate(Delegate) @@ -135,15 +147,64 @@ public struct UploadBoxFeature: Reducer, Sendable { case .destination(.presented(.confirmationDialog(.gallery))): if isPreview { return .send(.view(.photosPickerPhotosSelected( - [.image(url: URL(fileURLWithPath: ""), ext: nil)] + [.image(data: Data(), ext: nil)] ))) } else { state.destination = .photosPicker } + case let .destination(.presented(.alert(.selectFileFromFiles(oldFileId)))): + if let oldFileId = oldFileId, + let oldIndex = state.files.firstIndex(where: { $0.id == oldFileId }) { + return .concatenate( + .send(.view(.removeFileButtonTapped(state.files[oldIndex]))), + .send(.destination(.presented(.confirmationDialog(.files)))) + ) + } + return .send(.destination(.presented(.confirmationDialog(.files)))) + + case let .destination(.presented(.alert(.selectFileFromGallery(oldFileId)))): + if let oldFileId = oldFileId, + let oldIndex = state.files.firstIndex(where: { $0.id == oldFileId }) { + return .concatenate( + .send(.view(.removeFileButtonTapped(state.files[oldIndex]))), + .send(.destination(.presented(.confirmationDialog(.gallery)))) + ) + } + return .send(.destination(.presented(.confirmationDialog(.gallery)))) + + case let .destination(.presented(.alert(.removeFile(id)))): + if let index = state.files.firstIndex(where: { $0.id == id }) { + return .send(.view(.removeFileButtonTapped(state.files[index]))) + } + + case let .destination(.presented(.alert(.reuploadFile(id)))): + break // TODO: Implement + case .destination: break + case let .view(.fileWithErrorTapped(id)): + if let index = state.files.firstIndex(where: { $0.id == id }), + let error = state.files[index].uploadingError { + switch error { + case .sizeTooBig: + state.destination = .alert(.criticalFileConfirmation( + fileId: id, + title: TextState("File size too big", bundle: .module), + message: TextState("Select another file. If there are already files in the queue, it will be uploaded last", bundle: .module) + )) + case .badExtension: + state.destination = .alert(.criticalFileConfirmation( + fileId: id, + title: TextState("Sorry, this format is not supported", bundle: .module), + message: TextState("Select another file. If there are already files in the queue, it will be uploaded last", bundle: .module) + )) + case .uploadFailure: + state.destination = .alert(.reuploadFileConfirmation(id: id)) + } + } + case .view(.selectFilesButtonTapped): let dialogState = ConfirmationDialogState( title: { TextState(verbatim: "") }, @@ -172,7 +233,8 @@ public struct UploadBoxFeature: Reducer, Sendable { state.files.append(.mockImage) return .none } - state.uploadQueue = images + let filtered = Array(Set(state.uploadQueue + images)) + state.uploadQueue.append(contentsOf: filtered) return .send(.internal(.startNextUpload)) case let .view(.fileImporterURLsRecieved(urls)): @@ -183,44 +245,82 @@ public struct UploadBoxFeature: Reducer, Sendable { state.files.append(.mockFile) return .none } - state.uploadQueue = urls.map { .file(url: $0) } + let filtered = Array(Set(state.uploadQueue + urls.map { .file(url: $0) })) + state.uploadQueue.append(contentsOf: filtered) return .send(.internal(.startNextUpload)) - + + case let .internal(.uploadFileCanceledByValidation(id, status)): + switch status { + case .sizeTooBig: + state.destination = .alert(.criticalFileConfirmation( + fileId: id, + title: TextState("File size too big", bundle: .module), + message: TextState("Select another file. If there are already files in the queue, it will be uploaded last", bundle: .module) + )) + case .badExtension: + state.destination = .alert(.criticalFileConfirmation( + fileId: id, + title: TextState("Sorry, this format is not supported", bundle: .module), + message: TextState("Select another file. If there are already files in the queue, it will be uploaded last", bundle: .module) + )) + case .uploadFailure: + if let id { + state.destination = .alert(.reuploadFileConfirmation(id: id)) + } + } + case .internal(.startNextUpload): guard let item = state.uploadQueue.first else { state.isAnyFileUploading = false return .send(.delegate(.allFilesAreUploaded)) } + state.isAnyFileUploading = true state.uploadQueue.removeFirst() - return .run { send in - let (url, uploadType): (URL, UploadBoxFile.FileType) - switch item { - case .file(let u): - url = u - uploadType = .file - case .image(let u, _): - url = u - uploadType = .image + let data: Data? + let name: String + let uploadType: UploadBoxFile.FileType + let fileExtension: String? + + switch item { + case .file(let url): + guard url.startAccessingSecurityScopedResource() else { + return .send(.internal(.startNextUpload)) } - - guard url.startAccessingSecurityScopedResource() else { return } defer { url.stopAccessingSecurityScopedResource() } - - guard let data = try? Data(contentsOf: url) else { - await send(.internal(.startNextUpload)) - return - } - let file = UploadBoxFile( - name: data.imageExtension ?? url.lastPathComponent, - type: uploadType, - data: data, - isUploading: true + data = try? Data(contentsOf: url) + name = url.lastPathComponent + uploadType = .file + fileExtension = url.pathExtension + case .image(let d, let ext): + data = d + uploadType = .image + fileExtension = if let ext = ext { ext } else { d.imageExtension } + name = "\(UUID().uuidString).\(fileExtension ?? "bin")" + } + + guard let ext = fileExtension, fileExtensionAllowed(ext: ext, allowed: state.allowedExtensions) else { + return .concatenate( + .send(.internal(.uploadFileCanceledByValidation(nil, .badExtension))), + .send(.internal(.startNextUpload)) ) - await send(.internal(.uploadFile(file))) } + guard let data else { + return .concatenate( + .send(.internal(.uploadFileCanceledByValidation(nil, .sizeTooBig))), + .send(.internal(.startNextUpload)) + ) + } + + let file = UploadBoxFile( + name: name, + type: uploadType, + data: data, + isUploading: true + ) + return .send(.internal(.uploadFile(file))) case let .internal(.uploadFile(file)): state.files.append(file) @@ -249,8 +349,8 @@ public struct UploadBoxFeature: Reducer, Sendable { .replacingOccurrences(of: "]", with: "") .components(separatedBy: ",")[2]) else { state.files[index].isUploading = false - state.files[index].isUploadError = true - return .none + state.files[index].uploadingError = .uploadFailure + return .send(.internal(.startNextUpload)) } return .send(.internal(.uploadFileFinished(index: index, fileId))) @@ -262,19 +362,27 @@ public struct UploadBoxFeature: Reducer, Sendable { print("FILE UPLOADING INITIALIZED") state.files[index].isUploading = true - case .error(let err): - // TODO: Alert? - print("ERROR ON FILE UPLOADING: \(err)") + case .error(let error): + state.files[index].uploadingError = switch error { + case .serverDenied: .uploadFailure + case .fileSizeTooBig: .sizeTooBig + case .fileNotAllowed, .fileTypeNotAllowed: .badExtension + case .responseStatus, .other: .uploadFailure + @unknown default: .uploadFailure + } state.files[index].isUploading = false - state.files[index].isUploadError = true + // TODO: capture? + print("ERROR ON FILE UPLOADING: \(error)") + return .send(.internal(.startNextUpload)) @unknown default: print("UNKNOWN DEFAULT ERROR! \(id), \(status)") state.files[index].isUploading = false - state.files[index].isUploadError = true + state.files[index].uploadingError = .uploadFailure + return .send(.internal(.startNextUpload)) } } else { - // TODO: Handle error. + // Do nothing... File removed by user. } return .none @@ -295,9 +403,63 @@ public struct UploadBoxFeature: Reducer, Sendable { extension UploadBoxFeature.Destination.State: Equatable {} +// MARK: - Alert Extension + +extension AlertState where Action == UploadBoxFeature.Destination.Alert { + + nonisolated static func reuploadFileConfirmation(id: UUID) -> AlertState { + return AlertState( + title: { TextState("An error occurred while uploading the file", bundle: .module) }, + actions: { + ButtonState(action: .reuploadFile(id)) { + TextState("Try Again", bundle: .module) + } + ButtonState(role: .destructive, action: .removeFile(id)) { + TextState("Delete", bundle: .module) + } + ButtonState(role: .cancel) { + TextState("Cancel", bundle: .module) + } + }, + message: { + TextState("You can try uploading it again. If there are already files in the queue, it will be uploaded last") + } + ) + } + + nonisolated static func criticalFileConfirmation(fileId: UUID?, title: TextState, message: TextState) -> AlertState { + return AlertState( + title: { title }, + actions: { + ButtonState(action: .selectFileFromGallery(oldFile: fileId)) { + TextState("Choose from Gallery", bundle: .module) + } + ButtonState(action: .selectFileFromFiles(oldFile: fileId)) { + TextState("Choose from Files", bundle: .module) + } + ButtonState(role: .cancel) { + TextState("Cancel", bundle: .module) + } + }, + message: { message } + ) + } +} + // MARK: - Helpers private extension UploadBoxFeature { + func fileExtensionAllowed(ext: String?, allowed: [String]) -> Bool { + guard let fileExtension = ext else { return false } + guard !allowed.isEmpty else { return true } + for allowedExtension in allowed { + if fileExtension.lowercased() == allowedExtension.lowercased() { + return true + } + } + return false + } + func calculateFileHash(data: Data) -> String { return Insecure.MD5.hash(data: data) .map { byte in String(format: "%02X", byte) } @@ -314,6 +476,8 @@ private extension Data { return "png" case 0x47: return "gif" + case 0x52: + return "webp" case 0x49, 0x4D: return "tiff" default: diff --git a/Modules/Sources/UploadBoxFeature/UploadBoxView.swift b/Modules/Sources/UploadBoxFeature/UploadBoxView.swift index be1c392d..edd1f0e3 100644 --- a/Modules/Sources/UploadBoxFeature/UploadBoxView.swift +++ b/Modules/Sources/UploadBoxFeature/UploadBoxView.swift @@ -71,11 +71,12 @@ public struct UploadBoxView: View { .task(id: pickerItems) { var photos: [UploadBoxFeature.FileType] = [] for item in pickerItems { - if let url = try? await item.loadTransferable(type: URL.self) { + if let data = try? await item.loadTransferable(type: Data.self) { let type = item.supportedContentTypes.first - photos.append(.image(url: url, ext: type?.preferredFilenameExtension)) + photos.append(.image(data: data, ext: type?.preferredFilenameExtension)) } } + print("PHOTOSL \(photos)") send(.photosPickerPhotosSelected(photos)) } .tint(tintColor) @@ -161,8 +162,8 @@ public struct UploadBoxView: View { if file.isUploading { ProgressView() .frame(width: 28, height: 28) - } else if file.isUploadError { - Text(verbatim: "File upload ERROR") + } else if file.uploadingError != nil { + Text(verbatim: "File upload ERROR \(file.uploadingError)") .font(.title) .foregroundColor(tintColor) } else { From d292f9bced4db27d507403ef490c785b129a69fc Mon Sep 17 00:00:00 2001 From: Xialtal Date: Mon, 23 Feb 2026 17:15:12 +0300 Subject: [PATCH 049/118] Fix alerts in FormFeature --- Modules/Sources/UploadBoxFeature/UploadBoxView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Modules/Sources/UploadBoxFeature/UploadBoxView.swift b/Modules/Sources/UploadBoxFeature/UploadBoxView.swift index edd1f0e3..00e87031 100644 --- a/Modules/Sources/UploadBoxFeature/UploadBoxView.swift +++ b/Modules/Sources/UploadBoxFeature/UploadBoxView.swift @@ -43,6 +43,7 @@ public struct UploadBoxView: View { } } } + .alert($store.scope(state: \.destination?.alert, action: \.destination.alert)) .confirmationDialog( $store.scope( state: \.destination?.confirmationDialog, From d7b4cdf5a8377fdc33cad6cbc7a6ef1ac3b4d9a7 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Mon, 23 Feb 2026 19:25:30 +0300 Subject: [PATCH 050/118] Bump API version --- Tuist/Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tuist/Package.swift b/Tuist/Package.swift index 8f8a4c0b..ebe5373a 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -91,7 +91,7 @@ let package = Package( // Forks & stuff .package(url: "https://github.com/SubvertDev/AlertToast.git", revision: "d0f7d6b"), - .package(url: "https://github.com/SubvertDev/PDAPI_SPM.git", exact: "0.7.0"), + .package(url: "https://github.com/SubvertDev/PDAPI_SPM.git", exact: "0.7.1"), .package(url: "https://github.com/SubvertDev/RichTextKit.git", branch: "main"), ] ) From 462124418e7c145b379d2688866a4edfa1f35b32 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Mon, 23 Feb 2026 19:26:30 +0300 Subject: [PATCH 051/118] [WIP] FormFeature --- Modules/Sources/APIClient/APIClient.swift | 7 ++-- .../Requests/ForumTemplateRequest.swift | 8 ++--- .../Fields/FormDropdownFeature.swift | 2 +- .../FormFeature/Fields/FormTitleFeature.swift | 32 +++++++++-------- Modules/Sources/FormFeature/FormFeature.swift | 35 ++++++++++++------- Modules/Sources/FormFeature/FormScreen.swift | 8 ++++- .../Preview/FormPreviewFeature.swift | 29 +++++++++++---- .../FormFeature/Support/FormType.swift | 4 +-- .../FormFeature/Support/FormValue.swift | 32 ++++++++++++----- .../Sources/ForumFeature/ForumFeature.swift | 2 +- .../Sources/Models/Common/Attachment.swift | 11 ++++++ .../Models/Forum/TemplatePreview.swift | 19 ++++++++++ .../Parsers/WriteFormParser.swift | 7 ++-- .../Sources/ParsingClient/ParsingClient.swift | 2 +- .../Sources/TopicFeature/TopicFeature.swift | 2 +- 15 files changed, 140 insertions(+), 60 deletions(-) create mode 100644 Modules/Sources/Models/Forum/TemplatePreview.swift diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index c9a6fae0..29207a4f 100644 --- a/Modules/Sources/APIClient/APIClient.swift +++ b/Modules/Sources/APIClient/APIClient.swift @@ -17,6 +17,7 @@ import PersistenceKeys public typealias ConnectionState = API.ConnectionState public typealias UploadRequest = PDAPI.UploadRequest public typealias UploadProgressStatus = PDAPI.UploadProgressStatus +public typealias PDAPIDocument = PDAPI.Document public typealias PDAPIError = APIError // MARK: - Client @@ -61,11 +62,11 @@ public struct APIClient: Sendable { public var getAnnouncement: @Sendable (_ id: Int) async throws -> Announcement public var getTopic: @Sendable (_ id: Int, _ page: Int, _ perPage: Int, _ postsFilter: TopicPostsFilter) async throws -> Topic public var getTemplate: @Sendable (_ request: ForumTemplateRequest, _ isTopic: Bool) async throws -> [WriteFormFieldType] - public var sendTemplate: @Sendable (_ id: Int, _ content: String, _ isTopic: Bool) async throws -> TemplateSend + public var sendTemplate: @Sendable (_ id: Int, _ content: PDAPIDocument, _ isTopic: Bool) async throws -> TemplateSend public var getHistory: @Sendable (_ offset: Int, _ perPage: Int) async throws -> History public var getMentions: @Sendable (_ showPosts: Bool, _ offset: Int, _ perPage: Int) async throws -> Mentions public var previewPost: @Sendable (_ request: PostPreviewRequest) async throws -> PostPreview - public var previewTemplate: @Sendable (_ id: Int, _ content: String, _ isTopic: Bool) async throws -> PostPreview + public var previewTemplate: @Sendable (_ id: Int, _ content: PDAPIDocument, _ isTopic: Bool) async throws -> TemplatePreview public var sendPost: @Sendable (_ request: PostRequest) async throws -> PostSendResponse public var editPost: @Sendable (_ request: PostEditRequest) async throws -> PostSendResponse public var deletePosts: @Sendable (_ postIds: [Int]) async throws -> Bool @@ -652,7 +653,7 @@ extension APIClient: DependencyKey { ) }, previewTemplate: { _, _, _ in - return PostPreview(content: "Builded", attachmentIds: []) + return TemplatePreview(content: "content", attachments: [.mock]) }, sendPost: { _ in return .success(PostSend(id: 0, topicId: 1, offset: 2)) diff --git a/Modules/Sources/APIClient/Requests/ForumTemplateRequest.swift b/Modules/Sources/APIClient/Requests/ForumTemplateRequest.swift index 8c097e04..64f455a4 100644 --- a/Modules/Sources/APIClient/Requests/ForumTemplateRequest.swift +++ b/Modules/Sources/APIClient/Requests/ForumTemplateRequest.swift @@ -13,8 +13,8 @@ public struct ForumTemplateRequest { public enum TemplateAction { case get - case send([Any]) - case preview([Any]) + case send(PDAPIDocument) + case preview(PDAPIDocument) } public init(id: Int, action: TemplateAction) { @@ -27,8 +27,8 @@ extension ForumTemplateRequest.TemplateAction { var transferType: ForumCommand.TemplateAction { switch self { case .get: return .get - case .preview(let data): return .preview(data.description) - case .send(let data): return .send(data.description) + case .preview(let data): return .preview(data) + case .send(let data): return .send(data) } } } diff --git a/Modules/Sources/FormFeature/Fields/FormDropdownFeature.swift b/Modules/Sources/FormFeature/Fields/FormDropdownFeature.swift index bf433957..26adedb6 100644 --- a/Modules/Sources/FormFeature/Fields/FormDropdownFeature.swift +++ b/Modules/Sources/FormFeature/Fields/FormDropdownFeature.swift @@ -41,7 +41,7 @@ public struct FormDropdownFeature: Reducer { } func getValue() -> FormValue { - return .string(selectedOption) + return .integer(options.firstIndex(of: selectedOption)! + 1) } func isValid() -> Bool { diff --git a/Modules/Sources/FormFeature/Fields/FormTitleFeature.swift b/Modules/Sources/FormFeature/Fields/FormTitleFeature.swift index 9d3d3442..d1e83228 100644 --- a/Modules/Sources/FormFeature/Fields/FormTitleFeature.swift +++ b/Modules/Sources/FormFeature/Fields/FormTitleFeature.swift @@ -29,7 +29,7 @@ public struct FormTitleFeature: Reducer { var nodes: [FormNode] = [] func getValue() -> FormValue { - return .string("\"\"") + return .integer(0)//.string("") } func isValid() -> Bool { @@ -75,20 +75,24 @@ struct FormTitleRow: View { var body: some View { WithPerceptionTracking { - VStack(spacing: 6) { - ForEach(store.nodes, id: \.self) { node in - FormNodeView(node: node) + if !store.text.isEmpty { + VStack(spacing: 6) { + ForEach(store.nodes, id: \.self) { node in + FormNodeView(node: node) + } } - } - .frame(maxWidth: .infinity) - .padding(.vertical, 10) - .padding(.horizontal, 12) - .background { - RoundedRectangle(cornerRadius: 14) - .fill(Color(.Background.teritary)) - } - .onAppear { - send(.onAppear) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .padding(.horizontal, 12) + .background { + RoundedRectangle(cornerRadius: 14) + .fill(Color(.Background.teritary)) + } + .onAppear { + send(.onAppear) + } + } else { + EmptyView() } } } diff --git a/Modules/Sources/FormFeature/FormFeature.swift b/Modules/Sources/FormFeature/FormFeature.swift index 8d2f0770..1eb2b889 100644 --- a/Modules/Sources/FormFeature/FormFeature.swift +++ b/Modules/Sources/FormFeature/FormFeature.swift @@ -72,12 +72,11 @@ public struct FormFeature: Reducer, Sendable { return !rows.allSatisfy { $0.isValid() } || isPublishing } - var content: String { + var content: [FormValue] { if rows.count == 1, case let .editor(editorState) = rows.first { - return editorState.text + return [.string(editorState.text)] } else { - let values = rows.map { $0.getValue() } - return "[" + values.description + "]" + return rows.map { $0.getValue() } } } @@ -179,10 +178,7 @@ public struct FormFeature: Reducer, Sendable { } return .run { _ in await dismiss() } - case .delegate: - break - - case let .rows(action): + case .delegate, .rows: break case .view(.onAppear): @@ -224,7 +220,11 @@ public struct FormFeature: Reducer, Sendable { switch state.type { case let .post(type: type, topicId: topicId, content: content): let content = if case .simple(_, let attachments) = content { - FormType.PostContentType.simple(state.content, attachments) + if case let .string(text) = state.content.first { + FormType.PostContentType.simple(text, attachments) + } else { + fatalError("Simple content SHOULD be .string()!") + } } else { FormType.PostContentType.template(state.content) } @@ -234,8 +234,11 @@ public struct FormFeature: Reducer, Sendable { ) case .report: + let content = if case let .string(text) = state.content.first { text } else { + fatalError("Simple content SHOULD be .string()!") + } previewState = FormPreviewFeature.State( - formType: .post(type: .new, topicId: 0, content: .simple(state.content, [])) + formType: .post(type: .new, topicId: 0, content: .simple(content, [])) ) case let .topic(forumId: forumId, content: _): @@ -267,7 +270,6 @@ public struct FormFeature: Reducer, Sendable { for (index, field) in fields.enumerated() { switch field { case let .title(content): - guard !content.isEmpty else { continue } let titleState = FormTitleFeature.State(id: index, text: content) state.rows.append(.title(titleState)) @@ -336,14 +338,18 @@ public struct FormFeature: Reducer, Sendable { switch state.type { case .topic(forumId: let id, content: _), .post(type: .new, topicId: let id, content: .template): return .run { [isTopic = state.type.isTopic, content = state.content] send in + let content = try! FormValue.toDocument(content) let result = await Result { try await apiClient.sendTemplate(id: id, content: content, isTopic: isTopic) } await send(.internal(.templateResponse(result))) } case let .post(type: type, topicId: topicId, content: .simple(_, attachments)): let editPostFlag = state.isShowMarkEnabled ? 4 : 0 + let content = if case let .string(text) = state.content.first { text } else { + fatalError("Simple content SHOULD be .string()!") + } return .run { [ - content = state.content, + content = content, reason = state.editReasonText ] send in switch type { @@ -376,7 +382,10 @@ public struct FormFeature: Reducer, Sendable { } case let .report(id: id, type: type): - return .run { [content = state.content] send in + let content = if case let .string(text) = state.content.first { text } else { + fatalError("Simple content SHOULD be .string()!") + } + return .run { [content = content] send in let request = ReportRequest(id: id, type: type, message: content) let result = await Result { try await apiClient.sendReport(request) } await send(.internal(.reportResponse(result))) diff --git a/Modules/Sources/FormFeature/FormScreen.swift b/Modules/Sources/FormFeature/FormScreen.swift index c54dad8f..b897ba58 100644 --- a/Modules/Sources/FormFeature/FormScreen.swift +++ b/Modules/Sources/FormFeature/FormScreen.swift @@ -54,6 +54,12 @@ public struct FormScreen: View { .scrollIndicators(.hidden) .navigationTitle(Text(navigationTitleText(), bundle: .module)) .navigationBarTitleDisplayMode(.inline) + //.alert($store.scope(state: \.destination?.alert, action: \.destination.alert)) + .sheet(item: $store.scope(state: \.destination?.preview, action: \.destination.preview)) { store in + NavigationStack { + FormPreviewView(store: store) + } + } .safeAreaInset(edge: .bottom) { PublishButton() } @@ -191,7 +197,7 @@ public struct FormScreen: View { FormScreen( store: Store( initialState: FormFeature.State( - type: .post(type: .new, topicId: 0, content: .template("")) + type: .post(type: .new, topicId: 0, content: .template([])) ) ) { FormFeature() diff --git a/Modules/Sources/FormFeature/Preview/FormPreviewFeature.swift b/Modules/Sources/FormFeature/Preview/FormPreviewFeature.swift index 522cfd0c..b11e4743 100644 --- a/Modules/Sources/FormFeature/Preview/FormPreviewFeature.swift +++ b/Modules/Sources/FormFeature/Preview/FormPreviewFeature.swift @@ -41,9 +41,10 @@ public struct FormPreviewFeature: Reducer, Sendable { case cancelButtonTapped - case _loadPreview(id: Int, content: String) + case _loadPreview(id: Int, content: [FormValue]) case _loadSimplePreview(id: Int, content: String, attIds: [Int]) - case _previewResponse(Result) + case _previewResponse(Result) + case _simplePreviewResponse(Result) } // MARK: - Dependencies @@ -84,14 +85,14 @@ public struct FormPreviewFeature: Reducer, Sendable { return .run { [isTopic = state.formType.isTopic] send in let result = await Result { try await apiClient.previewTemplate( id: id, - content: content, + content: try! FormValue.toDocument(content), isTopic: isTopic )} await send(._previewResponse(result)) } catch: { error, send in await send(._previewResponse(.failure(error))) } - + case let ._loadSimplePreview(id, content, attachments): state.isPreviewLoading = true return .run { send in @@ -106,12 +107,12 @@ public struct FormPreviewFeature: Reducer, Sendable { ) ) )} - await send(._previewResponse(result)) + await send(._simplePreviewResponse(result)) } catch: { error, send in - await send(._previewResponse(.failure(error))) + await send(._simplePreviewResponse(.failure(error))) } - case let ._previewResponse(.success(preview)): + case let ._simplePreviewResponse(.success(preview)): state.contentTypes = TopicNodeBuilder( text: preview.content, attachments: [] ).build() @@ -122,6 +123,20 @@ public struct FormPreviewFeature: Reducer, Sendable { return .none + case let ._simplePreviewResponse(.failure(error)): + // TODO: Toast? + print(error) + return .send(.cancelButtonTapped) + + case let ._previewResponse(.success(preview)): + state.contentTypes = TopicNodeBuilder( + text: preview.content, attachments: preview.attachments + ).build() + + state.isPreviewLoading = false + + return .none + case let ._previewResponse(.failure(error)): // TODO: Toast? print(error) diff --git a/Modules/Sources/FormFeature/Support/FormType.swift b/Modules/Sources/FormFeature/Support/FormType.swift index 80b50668..49403088 100644 --- a/Modules/Sources/FormFeature/Support/FormType.swift +++ b/Modules/Sources/FormFeature/Support/FormType.swift @@ -10,7 +10,7 @@ import Models public enum FormType: Sendable, Equatable { case post(type: PostType, topicId: Int, content: PostContentType) case report(id: Int, type: ReportType) - case topic(forumId: Int, content: String) + case topic(forumId: Int, content: [FormValue]) public enum PostType: Sendable, Equatable { case new @@ -19,7 +19,7 @@ public enum FormType: Sendable, Equatable { public enum PostContentType: Sendable, Equatable { case simple(String, [Int]) - case template(String) + case template([FormValue]) } public var isTopic: Bool { diff --git a/Modules/Sources/FormFeature/Support/FormValue.swift b/Modules/Sources/FormFeature/Support/FormValue.swift index c80c1e66..1897c55c 100644 --- a/Modules/Sources/FormFeature/Support/FormValue.swift +++ b/Modules/Sources/FormFeature/Support/FormValue.swift @@ -5,26 +5,40 @@ // Created by Xialtal on 22.02.26. // -import PDAPI +import APIClient -public enum FormValue: Hashable { +public enum FormValue: Sendable, Hashable { case string(String) case integer(Int) case array([FormValue]) - - static func toDocument(_ value: FormValue) throws -> Document { - var document = Document() +} + +extension FormValue { + static func toDocument(_ values: [FormValue]) throws -> PDAPIDocument { + let document = PDAPIDocument() + for value in values { + try document.append(value) + } + return document + } +} + +private extension PDAPIDocument { + func append(_ value: FormValue) throws { switch value { case .string(let string): - _ = try document.append(string) + _ = try append(string) + case .integer(let int): - _ = try document.append(int) + _ = try append(int) + case .array(let array): + let nestedDocument = PDAPIDocument() for element in array { - _ = try document.append(toDocument(element)) + try nestedDocument.append(element) } + _ = try append(nestedDocument) } - return document } } diff --git a/Modules/Sources/ForumFeature/ForumFeature.swift b/Modules/Sources/ForumFeature/ForumFeature.swift index 17350d6a..8bac2b4a 100644 --- a/Modules/Sources/ForumFeature/ForumFeature.swift +++ b/Modules/Sources/ForumFeature/ForumFeature.swift @@ -212,7 +212,7 @@ public struct ForumFeature: Reducer, Sendable { let formState = FormFeature.State( type: .topic( forumId: state.forumId, - content: "" + content: [] ) ) state.destination = .form(formState) diff --git a/Modules/Sources/Models/Common/Attachment.swift b/Modules/Sources/Models/Common/Attachment.swift index 55d54f51..202ba446 100644 --- a/Modules/Sources/Models/Common/Attachment.swift +++ b/Modules/Sources/Models/Common/Attachment.swift @@ -76,3 +76,14 @@ public struct Attachment: Sendable, Hashable, Codable { self.downloadCount = downloadCount } } + +public extension Attachment { + static let mock = Attachment( + id: 0, + type: .file, + name: "FileName.dat", + size: 1709, + metadata: nil, + downloadCount: 2 + ) +} diff --git a/Modules/Sources/Models/Forum/TemplatePreview.swift b/Modules/Sources/Models/Forum/TemplatePreview.swift new file mode 100644 index 00000000..03102509 --- /dev/null +++ b/Modules/Sources/Models/Forum/TemplatePreview.swift @@ -0,0 +1,19 @@ +// +// TemplatePreview.swift +// ForPDA +// +// Created by Xialtal on 23.02.26. +// + +public struct TemplatePreview: Sendable { + public let content: String + public let attachments: [Attachment] + + public init( + content: String, + attachments: [Attachment] + ) { + self.content = content + self.attachments = attachments + } +} diff --git a/Modules/Sources/ParsingClient/Parsers/WriteFormParser.swift b/Modules/Sources/ParsingClient/Parsers/WriteFormParser.swift index aa41a6e1..4344797d 100644 --- a/Modules/Sources/ParsingClient/Parsers/WriteFormParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/WriteFormParser.swift @@ -26,7 +26,7 @@ public struct WriteFormParser { return try parseFormFields(fields) } - public static func parseTemplatePreview(from string: String) throws(ParsingError) -> PostPreview { + public static func parseTemplatePreview(from string: String) throws(ParsingError) -> TemplatePreview { guard let data = string.data(using: .utf8) else { throw ParsingError.failedToCreateDataFromString } @@ -37,11 +37,12 @@ public struct WriteFormParser { guard let template = array[safe: 2] as? [Any], let content = template[safe: 2] as? String, - let attachmentIds = template[safe: 3] as? [Int] else { + let attachmentsRaw = template[safe: 3] as? [[Any]], + let attachments = try? AttachmentParser.parseAttachment(attachmentsRaw) else { throw ParsingError.failedToCastFields } - return PostPreview(content: content, attachmentIds: attachmentIds) + return TemplatePreview(content: content, attachments: attachments) } public static func parseTemplateSend(from string: String) throws(ParsingError) -> TemplateSend { diff --git a/Modules/Sources/ParsingClient/ParsingClient.swift b/Modules/Sources/ParsingClient/ParsingClient.swift index e6ad5fbd..c27ec9ed 100644 --- a/Modules/Sources/ParsingClient/ParsingClient.swift +++ b/Modules/Sources/ParsingClient/ParsingClient.swift @@ -41,7 +41,7 @@ public struct ParsingClient: Sendable { public var parseMentions: @Sendable (_ response: String) async throws -> Mentions public var parsePostPreview: @Sendable (_ response: String) async throws -> PostPreview public var parsePostSendResponse: @Sendable (_ response: String) async throws -> PostSendResponse - public var parseTemplatePreview: @Sendable (_ response: String) async throws -> PostPreview + public var parseTemplatePreview: @Sendable (_ response: String) async throws -> TemplatePreview public var parseTemplateSend: @Sendable (_ response: String) async throws -> TemplateSend // Search diff --git a/Modules/Sources/TopicFeature/TopicFeature.swift b/Modules/Sources/TopicFeature/TopicFeature.swift index 1f9d4189..4b16bf1d 100644 --- a/Modules/Sources/TopicFeature/TopicFeature.swift +++ b/Modules/Sources/TopicFeature/TopicFeature.swift @@ -297,7 +297,7 @@ public struct TopicFeature: Reducer, Sendable { type: .post( type: .new, topicId: topic.id, - content: .template("") + content: .template([]) ) ) state.destination = .form(formState) From ab7de702e2cfdd4aad4c743ce85e942b78b537f6 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Mon, 23 Feb 2026 21:09:37 +0300 Subject: [PATCH 052/118] Fix attachments in template preview --- Modules/Sources/FormFeature/Fields/FormTitleFeature.swift | 2 +- .../Sources/FormFeature/Preview/FormPreviewFeature.swift | 4 +++- Modules/Sources/FormFeature/Preview/FormPreviewView.swift | 2 +- Modules/Sources/SharedUI/Topic/TopicView.swift | 8 ++++++-- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/Modules/Sources/FormFeature/Fields/FormTitleFeature.swift b/Modules/Sources/FormFeature/Fields/FormTitleFeature.swift index d1e83228..072e00bc 100644 --- a/Modules/Sources/FormFeature/Fields/FormTitleFeature.swift +++ b/Modules/Sources/FormFeature/Fields/FormTitleFeature.swift @@ -29,7 +29,7 @@ public struct FormTitleFeature: Reducer { var nodes: [FormNode] = [] func getValue() -> FormValue { - return .integer(0)//.string("") + return .integer(0) } func isValid() -> Bool { diff --git a/Modules/Sources/FormFeature/Preview/FormPreviewFeature.swift b/Modules/Sources/FormFeature/Preview/FormPreviewFeature.swift index b11e4743..4c60d79e 100644 --- a/Modules/Sources/FormFeature/Preview/FormPreviewFeature.swift +++ b/Modules/Sources/FormFeature/Preview/FormPreviewFeature.swift @@ -24,6 +24,7 @@ public struct FormPreviewFeature: Reducer, Sendable { public let formType: FormType var contentTypes: [UITopicType] = [] + var attachments: [Attachment] = [] var isPreviewLoading = false @@ -85,7 +86,7 @@ public struct FormPreviewFeature: Reducer, Sendable { return .run { [isTopic = state.formType.isTopic] send in let result = await Result { try await apiClient.previewTemplate( id: id, - content: try! FormValue.toDocument(content), + content: try FormValue.toDocument(content), isTopic: isTopic )} await send(._previewResponse(result)) @@ -132,6 +133,7 @@ public struct FormPreviewFeature: Reducer, Sendable { state.contentTypes = TopicNodeBuilder( text: preview.content, attachments: preview.attachments ).build() + state.attachments = preview.attachments state.isPreviewLoading = false diff --git a/Modules/Sources/FormFeature/Preview/FormPreviewView.swift b/Modules/Sources/FormFeature/Preview/FormPreviewView.swift index 86454f88..55756300 100644 --- a/Modules/Sources/FormFeature/Preview/FormPreviewView.swift +++ b/Modules/Sources/FormFeature/Preview/FormPreviewView.swift @@ -27,7 +27,7 @@ struct FormPreviewView: View { VStack(alignment: .leading, spacing: 0) { if !store.contentTypes.isEmpty { ForEach(store.contentTypes, id: \.self) { type in - TopicView(type: type, attachments: []) { _ in + TopicView(type: type, attachments: store.attachments) { _ in // Not handling URLs. Do not remove, cause else // links will be opening in browser. } diff --git a/Modules/Sources/SharedUI/Topic/TopicView.swift b/Modules/Sources/SharedUI/Topic/TopicView.swift index 15f73d6a..4caee120 100644 --- a/Modules/Sources/SharedUI/Topic/TopicView.swift +++ b/Modules/Sources/SharedUI/Topic/TopicView.swift @@ -64,8 +64,12 @@ public struct TopicView: View { let scaleFactor = availableWidth / CGFloat(metadata.width) let isWidthMoreThanAvailable = scaleFactor < 1 - let width = isWidthMoreThanAvailable ? availableWidth : CGFloat(metadata.width) - let height = width / ratioWH + let width: CGFloat? = if metadata.width != 0 { + isWidthMoreThanAvailable ? availableWidth : CGFloat(metadata.width) + } else { + nil + } + let height: CGFloat? = if let width = width { (width / ratioWH) } else { nil } LazyImage(url: metadata.url) { state in if let container = state.imageContainer { From d85c1714581e7c2d333ab6ef510a5eb7a6054a70 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Mon, 23 Feb 2026 22:35:10 +0300 Subject: [PATCH 053/118] Lock form while file uploading --- .../Fields/FormUploadBoxFeature.swift | 20 ++++++++++++------- Modules/Sources/FormFeature/FormFeature.swift | 16 ++++++++++++++- Modules/Sources/FormFeature/FormScreen.swift | 5 +++-- 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/Modules/Sources/FormFeature/Fields/FormUploadBoxFeature.swift b/Modules/Sources/FormFeature/Fields/FormUploadBoxFeature.swift index 5b22dd61..7ce54ecb 100644 --- a/Modules/Sources/FormFeature/Fields/FormUploadBoxFeature.swift +++ b/Modules/Sources/FormFeature/Fields/FormUploadBoxFeature.swift @@ -25,8 +25,8 @@ public struct FormUploadBoxFeature: Reducer { let description: String let flag: Int let allowedExtensions: [String] + public var isLocked: Bool - var isLoading: Bool var uploadedFilesIds: [Int] = [] public init( @@ -35,14 +35,14 @@ public struct FormUploadBoxFeature: Reducer { description: String, flag: Int, allowedExtensions: [String], - isLoading: Bool = false + isLocked: Bool = false ) { self.id = id self.title = title self.description = description self.flag = flag self.allowedExtensions = allowedExtensions - self.isLoading = isLoading + self.isLocked = isLocked } func getValue() -> FormValue { @@ -50,7 +50,7 @@ public struct FormUploadBoxFeature: Reducer { } func isValid() -> Bool { - if isLoading { return false } + if isLocked { return false } return isRequired ? !uploadedFilesIds.isEmpty : true } } @@ -65,6 +65,11 @@ public struct FormUploadBoxFeature: Reducer { public enum View { case onAppear } + + case delegate(Delegate) + public enum Delegate { + case anyFileUploading(Bool) + } } // MARK: - Body @@ -79,10 +84,10 @@ public struct FormUploadBoxFeature: Reducer { Reduce { state, action in switch action { case .upload(.delegate(.someFileUploading)): - state.isLoading = true + return .send(.delegate(.anyFileUploading(true))) case .upload(.delegate(.allFilesAreUploaded)): - state.isLoading = false + return .send(.delegate(.anyFileUploading(false))) case let .upload(.delegate(.fileHasBeenUploaded(id))): state.uploadedFilesIds.append(id) @@ -93,7 +98,7 @@ public struct FormUploadBoxFeature: Reducer { case .view(.onAppear): state.upload.allowedExtensions = state.allowedExtensions - case .binding, .upload: + case .binding, .upload, .delegate: break } return .none @@ -127,6 +132,7 @@ struct FormUploadBoxRow: View { } } .tint(tintColor) + .disabled(store.isLocked) .onAppear { send(.onAppear) } diff --git a/Modules/Sources/FormFeature/FormFeature.swift b/Modules/Sources/FormFeature/FormFeature.swift index 1eb2b889..bdae92ef 100644 --- a/Modules/Sources/FormFeature/FormFeature.swift +++ b/Modules/Sources/FormFeature/FormFeature.swift @@ -54,6 +54,8 @@ public struct FormFeature: Reducer, Sendable { public var isEditingReasonEnabled = false public var editReasonText = "" + var isFormLocked = false + var canShowShowMark = false var isShowMarkEnabled = false @@ -178,9 +180,21 @@ public struct FormFeature: Reducer, Sendable { } return .run { _ in await dismiss() } - case .delegate, .rows: + case .delegate: break + case let .rows(action): + if case let .element(id: id, action: .uploadBox(.delegate(.anyFileUploading(status)))) = action { + state.isFormLocked = status + + // Lock all uploadboxes, exclude one that uploading. + for index in state.rows.indices { + if index != id, case var .uploadBox(uploadBoxState) = state.rows[id: index] { + uploadBoxState.isLocked = status + } + } + } + case .view(.onAppear): switch state.type { case let .post(type: _, topicId: topicId, content: content): diff --git a/Modules/Sources/FormFeature/FormScreen.swift b/Modules/Sources/FormFeature/FormScreen.swift index b897ba58..26ba8d8f 100644 --- a/Modules/Sources/FormFeature/FormScreen.swift +++ b/Modules/Sources/FormFeature/FormScreen.swift @@ -106,7 +106,7 @@ public struct FormScreen: View { } .buttonStyle(.borderedProminent) .tint(tintColor) - .disabled(store.isPublishButtonDisabled) + .disabled(store.isPublishButtonDisabled || store.isFormLocked) .frame(height: 48) .padding(.vertical, 8) .padding(.horizontal, 16) @@ -124,6 +124,7 @@ public struct FormScreen: View { Text("Cancel", bundle: .module) } .tint(tintColor) + .disabled(store.isFormLocked) } ToolbarItem(placement: .navigationBarTrailing) { @@ -135,7 +136,7 @@ public struct FormScreen: View { .frame(width: 34, height: 22) } .tint(tintColor) - .disabled(store.isPreviewButtonDisabled) + .disabled(store.isPreviewButtonDisabled || store.isFormLocked) } } From 5a14c6dc31da6c13c67fa9b1694d271a4b0391d8 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 24 Feb 2026 12:02:42 +0300 Subject: [PATCH 054/118] Fix alerts for FormScreen --- Modules/Sources/FormFeature/FormScreen.swift | 29 ++++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/Modules/Sources/FormFeature/FormScreen.swift b/Modules/Sources/FormFeature/FormScreen.swift index 26ba8d8f..ad26860a 100644 --- a/Modules/Sources/FormFeature/FormScreen.swift +++ b/Modules/Sources/FormFeature/FormScreen.swift @@ -54,12 +54,7 @@ public struct FormScreen: View { .scrollIndicators(.hidden) .navigationTitle(Text(navigationTitleText(), bundle: .module)) .navigationBarTitleDisplayMode(.inline) - //.alert($store.scope(state: \.destination?.alert, action: \.destination.alert)) - .sheet(item: $store.scope(state: \.destination?.preview, action: \.destination.preview)) { store in - NavigationStack { - FormPreviewView(store: store) - } - } + .modifier(DestinationModifier(store: store)) // extracted to modifier, due to .alert() compilation error .safeAreaInset(edge: .bottom) { PublishButton() } @@ -155,6 +150,28 @@ public struct FormScreen: View { } } +// MARK: - Destination Modifier + +struct DestinationModifier: ViewModifier { + @Perception.Bindable private var store: StoreOf + + init(store: StoreOf) { + self.store = store + } + + func body(content: Content) -> some View { + WithPerceptionTracking { + content + .alert($store.scope(state: \.destination?.alert, action: \.destination.alert)) + .sheet(item: $store.scope(state: \.destination?.preview, action: \.destination.preview)) { store in + NavigationStack { + FormPreviewView(store: store) + } + } + } + } +} + // MARK: - Previews #Preview("Form (Simple, New)") { From 5e010e86e3f2fb38782c29819afd8bb7b3f2ef99 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 24 Feb 2026 12:07:07 +0300 Subject: [PATCH 055/118] Add comment for empty form titles --- Modules/Sources/FormFeature/FormFeature.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Modules/Sources/FormFeature/FormFeature.swift b/Modules/Sources/FormFeature/FormFeature.swift index bdae92ef..2ef61b34 100644 --- a/Modules/Sources/FormFeature/FormFeature.swift +++ b/Modules/Sources/FormFeature/FormFeature.swift @@ -284,6 +284,7 @@ public struct FormFeature: Reducer, Sendable { for (index, field) in fields.enumerated() { switch field { case let .title(content): + // do not skip empty titles, cause they are needed for future request building let titleState = FormTitleFeature.State(id: index, text: content) state.rows.append(.title(titleState)) From e64169de324d84b6a026c355f74424d9e0b12ff9 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 24 Feb 2026 12:59:45 +0300 Subject: [PATCH 056/118] Add template response handling for FormFeature --- Modules/Sources/FormFeature/FormFeature.swift | 69 +++++++++++++++++-- .../Sources/Models/Common/WriteFormSend.swift | 4 +- .../Sources/Models/Forum/TemplateSend.swift | 4 +- .../Parsers/WriteFormParser.swift | 8 +-- .../Sources/TopicFeature/TopicFeature.swift | 2 +- 5 files changed, 71 insertions(+), 16 deletions(-) diff --git a/Modules/Sources/FormFeature/FormFeature.swift b/Modules/Sources/FormFeature/FormFeature.swift index 2ef61b34..ca7f9331 100644 --- a/Modules/Sources/FormFeature/FormFeature.swift +++ b/Modules/Sources/FormFeature/FormFeature.swift @@ -169,10 +169,8 @@ public struct FormFeature: Reducer, Sendable { return .none } - case let .template(status): - if status.isError { - return .none - } + case let .topic(id): + break case let .post(status): #warning("handle") @@ -419,7 +417,7 @@ public struct FormFeature: Reducer, Sendable { analyticsClient.capture(error) case let .internal(.simplePostResponse(.success(.success(post)))): - return .send(.delegate(.formSent(.post(.success(post))))) + return .send(.delegate(.formSent(.post(post)))) case let .internal(.simplePostResponse(.success(.failure(errorStatus)))): state.isPublishing = false @@ -441,8 +439,26 @@ public struct FormFeature: Reducer, Sendable { state.destination = .alert(.unknownError) analyticsClient.capture(error) - case let .internal(.templateResponse(.success(result))): - return .send(.delegate(.formSent(.template(result)))) + case let .internal(.templateResponse(.success(.success(result)))): + switch result { + case .post(let post): + return .send(.delegate(.formSent(.post(post)))) + case .topic(let id): + return .send(.delegate(.formSent(.topic(id)))) + } + + case let .internal(.templateResponse(.success(.failure(errorStatus)))): + state.isPublishing = false + switch errorStatus { + case .badParam: + state.destination = .alert(.templateRequestHasBadParam) + case .sentToPremod: + state.destination = .alert(.topicIsSentToPremoderation) + case .fieldsError: + state.destination = .alert(.notAllFieldsAreFilledInTemplate) + case .status(let status): + state.destination = .alert(.serverReturnStatusForTopic(status)) + } case let .internal(.templateResponse(.failure(error))): state.isPublishing = false @@ -467,6 +483,45 @@ extension FormFeature.Destination.State: Equatable {} public extension AlertState where Action == FormFeature.Destination.Alert { + // Topic & Template + + nonisolated(unsafe) static let topicIsSentToPremoderation = AlertState { + TextState("Topic is sent to premoderation") + } actions: { + ButtonState(action: .dismiss) { + TextState("OK") + } + } + + nonisolated(unsafe) static let templateRequestHasBadParam = AlertState { + TextState("The server refused to create the topic (invalid parameter)") + } actions: { + ButtonState { + TextState("OK") + } + } + + nonisolated(unsafe) static let notAllFieldsAreFilledInTemplate = AlertState { + TextState("Not all required fields are filled in") + } actions: { + ButtonState { + TextState("OK") + } + } + + static func serverReturnStatusForTopic(_ status: Int) -> AlertState { + return AlertState( + title: { TextState("The server refused to create the topic (status \(status))") }, + actions: { + ButtonState { + TextState("OK") + } + } + ) + } + + // Post + nonisolated(unsafe) static let postIsSentToPremoderation = AlertState { TextState("Post is sent to premoderation") } actions: { diff --git a/Modules/Sources/Models/Common/WriteFormSend.swift b/Modules/Sources/Models/Common/WriteFormSend.swift index 1f5d5e96..23bf0cf6 100644 --- a/Modules/Sources/Models/Common/WriteFormSend.swift +++ b/Modules/Sources/Models/Common/WriteFormSend.swift @@ -6,7 +6,7 @@ // public enum WriteFormSend: Sendable { - case post(PostSendResponse) + case post(PostSend) case report(ReportResponseType) - case template(TemplateSend) + case topic(Int) } diff --git a/Modules/Sources/Models/Forum/TemplateSend.swift b/Modules/Sources/Models/Forum/TemplateSend.swift index dd896899..c0414039 100644 --- a/Modules/Sources/Models/Forum/TemplateSend.swift +++ b/Modules/Sources/Models/Forum/TemplateSend.swift @@ -7,7 +7,7 @@ public enum TemplateSend: Sendable { case success(TemplateSendType) - case error(TemplateSendError) + case failure(TemplateSendError) public enum TemplateSendType: Sendable { case topic(id: Int) @@ -22,7 +22,7 @@ public enum TemplateSend: Sendable { } public var isError: Bool { - return if case .error = self { + return if case .failure = self { true } else { false } } diff --git a/Modules/Sources/ParsingClient/Parsers/WriteFormParser.swift b/Modules/Sources/ParsingClient/Parsers/WriteFormParser.swift index 4344797d..9503c5ee 100644 --- a/Modules/Sources/ParsingClient/Parsers/WriteFormParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/WriteFormParser.swift @@ -77,16 +77,16 @@ public struct WriteFormParser { guard let errors = array[safe: 2] as? [Any] else { throw ParsingError.failedToCastFields } - return .error(.fieldsError(errors.description)) + return .failure(.fieldsError(errors.description)) case 3: - return .error(.badParam) + return .failure(.badParam) case 4: - return .error(.sentToPremod) + return .failure(.sentToPremod) default: - return .error(.status(status)) + return .failure(.status(status)) } } diff --git a/Modules/Sources/TopicFeature/TopicFeature.swift b/Modules/Sources/TopicFeature/TopicFeature.swift index 4b16bf1d..f0c68b86 100644 --- a/Modules/Sources/TopicFeature/TopicFeature.swift +++ b/Modules/Sources/TopicFeature/TopicFeature.swift @@ -208,7 +208,7 @@ public struct TopicFeature: Reducer, Sendable { ]) case let .destination(.presented(.form(.delegate(.formSent(response))))): - if case let .post(data) = response, case let .success(post) = data { + if case let .post(post) = response { return jumpTo(.post(id: post.id), true, &state) } return .none From d7d53e01c66d686f9e61899cfc4957745af67e67 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 24 Feb 2026 13:38:05 +0300 Subject: [PATCH 057/118] Add post template preview --- Modules/Sources/APIClient/APIClient.swift | 2 +- Modules/Sources/FormFeature/FormScreen.swift | 2 +- .../Models/Common/WriteFormFieldType.swift | 6 +- .../Sources/TopicFeature/TopicScreen.swift | 60 +++++++++++++++++++ .../UploadBoxFeature/UploadBoxFeature.swift | 4 +- 5 files changed, 67 insertions(+), 7 deletions(-) diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index 29207a4f..ec6c6ab8 100644 --- a/Modules/Sources/APIClient/APIClient.swift +++ b/Modules/Sources/APIClient/APIClient.swift @@ -635,7 +635,7 @@ extension APIClient: DependencyKey { return .mock }, getTemplate: { _, _ in - return [.mockTitle, .mockText, .mockEditor] + return [.mockTitle, .mockRequiredText, .mockRequiredEditor, .mockEditor, .mockUploadBox] }, sendTemplate: { _, _, isTopic in return .success(isTopic ? .topic(id: 0) : .post(PostSend(id: 0, topicId: 1, offset: 2))) diff --git a/Modules/Sources/FormFeature/FormScreen.swift b/Modules/Sources/FormFeature/FormScreen.swift index ad26860a..992df28f 100644 --- a/Modules/Sources/FormFeature/FormScreen.swift +++ b/Modules/Sources/FormFeature/FormScreen.swift @@ -223,7 +223,7 @@ struct DestinationModifier: ViewModifier { $0.apiClient.getTemplate = { _, _ in return [ .mockTitle, - .mockText, + .mockRequiredText, .mockEditor, .mockUploadBox, ] diff --git a/Modules/Sources/Models/Common/WriteFormFieldType.swift b/Modules/Sources/Models/Common/WriteFormFieldType.swift index 6eb8d50d..b14b84d2 100644 --- a/Modules/Sources/Models/Common/WriteFormFieldType.swift +++ b/Modules/Sources/Models/Common/WriteFormFieldType.swift @@ -54,7 +54,7 @@ public extension WriteFormFieldType { static let mockTitle: WriteFormFieldType = .title("This is an absolute [b]simple[/b] [i]title[/i]") - static let mockText: WriteFormFieldType = .text( + static let mockRequiredText: WriteFormFieldType = .text( FormField( id: 0, name: "Topic name", @@ -66,7 +66,7 @@ public extension WriteFormFieldType { maxLenght: 255 ) - static let mockEditor: WriteFormFieldType = .editor( + static let mockRequiredEditor: WriteFormFieldType = .editor( FormField( id: 0, name: "Topic content", @@ -77,7 +77,7 @@ public extension WriteFormFieldType { ) ) - static let mockEditorSimple: WriteFormFieldType = .editor( + static let mockEditor: WriteFormFieldType = .editor( FormField( id: 0, name: "", diff --git a/Modules/Sources/TopicFeature/TopicScreen.swift b/Modules/Sources/TopicFeature/TopicScreen.swift index 4334da07..fe124b09 100644 --- a/Modules/Sources/TopicFeature/TopicScreen.swift +++ b/Modules/Sources/TopicFeature/TopicScreen.swift @@ -688,3 +688,63 @@ private extension TopicPostsFilter { ) .tint(Color(.Theme.primary)) } + +#Preview("New template post requests") { + @Shared(.userSession) var userSession = UserSession.mock + templatePostSendingPreview +} + +@MainActor private var templatePostSendingPreview: some View { + TopicScreen( + store: Store( + initialState: TopicFeature.State( + topicId: 0, + topicName: "Test Topic", + destination: .form( + FormFeature.State( + type: .post( + type: .new, topicId: 0, content: .template([]) + ) + ) + ) + ) + ) { + TopicFeature() + } withDependencies: { + $0.apiClient.sendTemplate = { _, _, _ in + try await Task.sleep(for: .seconds(1)) + return .success(.post(.init(id: 1, topicId: 0, offset: 0))) + } + } + ) + .tint(Color(.Theme.primary)) +} + +#Preview("Post Template sending returns error status") { + @Shared(.userSession) var userSession = UserSession.mock + postTemplateErrorStatusPreview +} + +@MainActor private var postTemplateErrorStatusPreview: some View { + TopicScreen( + store: Store( + initialState: TopicFeature.State( + topicId: 0, + topicName: "Test Topic", + destination: .form( + FormFeature.State( + type: .post(type: .new, topicId: 0, content: .template([])) + ) + ) + ) + ) { + TopicFeature() + } withDependencies: { + $0.apiClient.sendTemplate = { _, _, _ in + try await Task.sleep(for: .seconds(1)) + return .failure(.badParam) // <---------- + } + } + ) + .tint(Color(.Theme.primary)) +} diff --git a/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift b/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift index 38e4822b..e9f4d302 100644 --- a/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift +++ b/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift @@ -231,7 +231,7 @@ public struct UploadBoxFeature: Reducer, Sendable { case let .view(.photosPickerPhotosSelected(images)): if isPreview { state.files.append(.mockImage) - return .none + return .send(.delegate(.fileHasBeenUploaded(0))) } let filtered = Array(Set(state.uploadQueue + images)) state.uploadQueue.append(contentsOf: filtered) @@ -243,7 +243,7 @@ public struct UploadBoxFeature: Reducer, Sendable { let fileURL = documentsURL.appending(path: "data.dat") try! Data().write(to: fileURL) state.files.append(.mockFile) - return .none + return .send(.delegate(.fileHasBeenUploaded(0))) } let filtered = Array(Set(state.uploadQueue + urls.map { .file(url: $0) })) state.uploadQueue.append(contentsOf: filtered) From c726d48504e19b058787bdb80455ef64997b5e47 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 24 Feb 2026 13:39:02 +0300 Subject: [PATCH 058/118] Improve localizable --- .../Resources/Localizable.xcstrings | 51 +++++++++++++++---- .../Resources/Localizable.xcstrings | 50 ------------------ 2 files changed, 40 insertions(+), 61 deletions(-) diff --git a/Modules/Sources/FormFeature/Resources/Localizable.xcstrings b/Modules/Sources/FormFeature/Resources/Localizable.xcstrings index 1c338705..a81abfef 100644 --- a/Modules/Sources/FormFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/FormFeature/Resources/Localizable.xcstrings @@ -1,17 +1,6 @@ { "sourceLanguage" : "en", "strings" : { - "Add more" : { - "extractionState" : "stale", - "localizations" : { - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Добавить еще" - } - } - } - }, "Attach this post to previous one?" : { "localizations" : { "ru" : { @@ -120,6 +109,16 @@ } } }, + "Not all required fields are filled in" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Заполните все обязательные поля" + } + } + } + }, "OK" : { "localizations" : { "ru" : { @@ -234,6 +233,36 @@ } } }, + "The server refused to create the topic (invalid parameter)" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сервер отказал в создании темы (неверный параметр)" + } + } + } + }, + "The server refused to create the topic (status %lld)" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сервер отказал в создании темы (статус %lld)" + } + } + } + }, + "Topic is sent to premoderation" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тема отправлена на премодерацию" + } + } + } + }, "Unknown error" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/ToastClient/Resources/Localizable.xcstrings b/Modules/Sources/ToastClient/Resources/Localizable.xcstrings index 0d9822db..592cff6a 100644 --- a/Modules/Sources/ToastClient/Resources/Localizable.xcstrings +++ b/Modules/Sources/ToastClient/Resources/Localizable.xcstrings @@ -11,56 +11,6 @@ } } }, - "The server refused to create the topic (invalid parameter)" : { - "localizations" : { - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Сервер отказал в создании темы (неверный параметр)" - } - } - } - }, - "There were errors in filling out the form" : { - "localizations" : { - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "При заполнении формы допущены ошибки" - } - } - } - }, - "Topic sending error. Status %lld" : { - "localizations" : { - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ошибка создания темы. Статус %lld" - } - } - } - }, - "Topic sent" : { - "localizations" : { - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Тема создана" - } - } - } - }, - "Topic sent to pre-moderation" : { - "localizations" : { - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Тема отправлена на премодерацию" - } - } - } - }, "Whoops, something went wrong.." : { "localizations" : { "ru" : { From 33e0405be0cb36356630242a25df7b338d19807b Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 24 Feb 2026 14:02:39 +0300 Subject: [PATCH 059/118] UploadBox improvements --- .../Models/UploadBoxFile.swift | 13 ++++++----- .../UploadBoxFeature/UploadBoxFeature.swift | 22 +++++-------------- .../UploadBoxFeature/UploadBoxView.swift | 5 +++++ 3 files changed, 18 insertions(+), 22 deletions(-) diff --git a/Modules/Sources/UploadBoxFeature/Models/UploadBoxFile.swift b/Modules/Sources/UploadBoxFeature/Models/UploadBoxFile.swift index 7c6ded28..a372488c 100644 --- a/Modules/Sources/UploadBoxFeature/Models/UploadBoxFile.swift +++ b/Modules/Sources/UploadBoxFeature/Models/UploadBoxFile.swift @@ -14,8 +14,7 @@ public struct UploadBoxFile: Sendable, Identifiable, Equatable { public let data: Data public var isUploading: Bool public var uploadingError: UploadErrorType? - - public var serverId: Int? = nil + public var serverId: Int? public enum FileType: Sendable, Equatable { case file, image @@ -32,13 +31,15 @@ public struct UploadBoxFile: Sendable, Identifiable, Equatable { type: FileType, data: Data, isUploading: Bool = false, - uploadingError: UploadErrorType? = nil + uploadingError: UploadErrorType? = nil, + serverId: Int? = nil ) { self.name = name self.type = type self.data = data self.isUploading = isUploading self.uploadingError = uploadingError + self.serverId = serverId } } @@ -46,12 +47,14 @@ extension UploadBoxFile { static let mockImage = UploadBoxFile( name: UUID().uuidString, type: .image, - data: Data() + data: Data(), + serverId: 0 ) static let mockFile = UploadBoxFile( name: UUID().uuidString, type: .file, - data: Data() + data: Data(), + serverId: 1 ) } diff --git a/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift b/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift index e9f4d302..e56a2738 100644 --- a/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift +++ b/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift @@ -88,6 +88,7 @@ public struct UploadBoxFeature: Reducer, Sendable { case view(View) public enum View { + case onFileButtonTapped(Int) case fileWithErrorTapped(UUID) case selectFilesButtonTapped case removeFileButtonTapped(UploadBoxFile) @@ -111,7 +112,6 @@ public struct UploadBoxFeature: Reducer, Sendable { case fileHasBeenRemoved(Int) case fileHasBeenUploaded(Int) - // TODO: Implement case fileHasBeenTapped(Int) } } @@ -184,25 +184,13 @@ public struct UploadBoxFeature: Reducer, Sendable { case .destination: break + case let .view(.onFileButtonTapped(id)): + return .send(.delegate(.fileHasBeenTapped(id))) + case let .view(.fileWithErrorTapped(id)): if let index = state.files.firstIndex(where: { $0.id == id }), let error = state.files[index].uploadingError { - switch error { - case .sizeTooBig: - state.destination = .alert(.criticalFileConfirmation( - fileId: id, - title: TextState("File size too big", bundle: .module), - message: TextState("Select another file. If there are already files in the queue, it will be uploaded last", bundle: .module) - )) - case .badExtension: - state.destination = .alert(.criticalFileConfirmation( - fileId: id, - title: TextState("Sorry, this format is not supported", bundle: .module), - message: TextState("Select another file. If there are already files in the queue, it will be uploaded last", bundle: .module) - )) - case .uploadFailure: - state.destination = .alert(.reuploadFileConfirmation(id: id)) - } + return .send(.internal(.uploadFileCanceledByValidation(id, error))) } case .view(.selectFilesButtonTapped): diff --git a/Modules/Sources/UploadBoxFeature/UploadBoxView.swift b/Modules/Sources/UploadBoxFeature/UploadBoxView.swift index 00e87031..2d467c95 100644 --- a/Modules/Sources/UploadBoxFeature/UploadBoxView.swift +++ b/Modules/Sources/UploadBoxFeature/UploadBoxView.swift @@ -180,6 +180,11 @@ public struct UploadBoxView: View { .multilineTextAlignment(.center) } } + .onTapGesture { + if let serverId = file.serverId, file.uploadingError == nil { + send(.onFileButtonTapped(serverId)) + } + } .padding(.horizontal, 12) .frame(minWidth: 144, maxWidth: 144, minHeight: 144) .background( From 7881cb42ced98bb1017a1f12fb73cda6074914e6 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 24 Feb 2026 14:27:18 +0300 Subject: [PATCH 060/118] Improvements --- .../UploadBoxFeature/UploadBoxFeature.swift | 8 +++---- .../UploadBoxFeature/UploadBoxView.swift | 24 ++++++++++++------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift b/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift index e56a2738..f1f6d15d 100644 --- a/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift +++ b/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift @@ -88,8 +88,8 @@ public struct UploadBoxFeature: Reducer, Sendable { case view(View) public enum View { - case onFileButtonTapped(Int) - case fileWithErrorTapped(UUID) + case fileButtonTapped(_ serverId: Int) + case fileWithErrorButtonTapped(_ id: UUID) case selectFilesButtonTapped case removeFileButtonTapped(UploadBoxFile) case photosPickerPhotosSelected([FileType]) @@ -184,10 +184,10 @@ public struct UploadBoxFeature: Reducer, Sendable { case .destination: break - case let .view(.onFileButtonTapped(id)): + case let .view(.fileButtonTapped(id)): return .send(.delegate(.fileHasBeenTapped(id))) - case let .view(.fileWithErrorTapped(id)): + case let .view(.fileWithErrorButtonTapped(id)): if let index = state.files.firstIndex(where: { $0.id == id }), let error = state.files[index].uploadingError { return .send(.internal(.uploadFileCanceledByValidation(id, error))) diff --git a/Modules/Sources/UploadBoxFeature/UploadBoxView.swift b/Modules/Sources/UploadBoxFeature/UploadBoxView.swift index 2d467c95..ad48b034 100644 --- a/Modules/Sources/UploadBoxFeature/UploadBoxView.swift +++ b/Modules/Sources/UploadBoxFeature/UploadBoxView.swift @@ -163,15 +163,19 @@ public struct UploadBoxView: View { if file.isUploading { ProgressView() .frame(width: 28, height: 28) - } else if file.uploadingError != nil { - Text(verbatim: "File upload ERROR \(file.uploadingError)") - .font(.title) - .foregroundColor(tintColor) } else { - Image(systemSymbol: file.type == .file ? .doc : .photo) - .font(.title) - .foregroundColor(tintColor) - .frame(width: 48, height: 48) + if file.uploadingError == nil { + Image(systemSymbol: file.type == .file ? .doc : .photo) + .font(.title) + .foregroundColor(tintColor) + .frame(width: 48, height: 48) + + } else { + Image(systemSymbol: .xmark) + .font(.title) + .foregroundColor(.red) + .frame(width: 48, height: 48) + } Text(file.name) .font(.footnote) @@ -182,7 +186,9 @@ public struct UploadBoxView: View { } .onTapGesture { if let serverId = file.serverId, file.uploadingError == nil { - send(.onFileButtonTapped(serverId)) + send(.fileButtonTapped(serverId)) + } else { + send(.fileWithErrorButtonTapped(file.id)) } } .padding(.horizontal, 12) From ef54aa36c545a9fbaf23170058fcf1a06d2b1c3c Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 24 Feb 2026 14:27:36 +0300 Subject: [PATCH 061/118] Localizable improvements --- .../Resources/Localizable.xcstrings | 24 +++++++++---------- .../UploadBoxFeature/UploadBoxFeature.swift | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Modules/Sources/UploadBoxFeature/Resources/Localizable.xcstrings b/Modules/Sources/UploadBoxFeature/Resources/Localizable.xcstrings index 8e7356a4..0ff5d5fe 100644 --- a/Modules/Sources/UploadBoxFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/UploadBoxFeature/Resources/Localizable.xcstrings @@ -3,7 +3,7 @@ "strings" : { "An error occurred while uploading the file" : { "localizations" : { - "en" : { + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Во время загрузки файла произошла ошибка" @@ -13,7 +13,7 @@ }, "Cancel" : { "localizations" : { - "en" : { + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Отмена" @@ -23,7 +23,7 @@ }, "Choose from Files" : { "localizations" : { - "en" : { + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Выбрать из файлов" @@ -33,7 +33,7 @@ }, "Choose from Gallery" : { "localizations" : { - "en" : { + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Выбрать из галереи" @@ -43,7 +43,7 @@ }, "Delete" : { "localizations" : { - "en" : { + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Удалить" @@ -53,7 +53,7 @@ }, "File size too big" : { "localizations" : { - "en" : { + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Слишком большой размер" @@ -63,7 +63,7 @@ }, "Select another file. If there are already files in the queue, it will be uploaded last" : { "localizations" : { - "en" : { + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Выберите другой файл. Если в очереди уже есть файлы, то он будет загружен последним" @@ -73,7 +73,7 @@ }, "Select files..." : { "localizations" : { - "en" : { + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Выберите файлы…" @@ -83,7 +83,7 @@ }, "Sorry, this format is not supported" : { "localizations" : { - "en" : { + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Извините, данный формат не поддерживается" @@ -93,7 +93,7 @@ }, "The selected file will be inserted into the text as code wherever your cursor is located. Or it will also be automatically added to the end of the post." : { "localizations" : { - "en" : { + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Выбранный файл будет вставлен в текст, в виде кода, где у вас находится курсор. Или добавится автоматически в конец поста." @@ -103,7 +103,7 @@ }, "Try Again" : { "localizations" : { - "en" : { + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Попробовать еще раз" @@ -113,7 +113,7 @@ }, "You can try uploading it again. If there are already files in the queue, it will be uploaded last" : { "localizations" : { - "en" : { + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Вы можете попробовать загрузить его еще раз. Если в очереди уже есть файлы, то он будет загружен последним" diff --git a/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift b/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift index f1f6d15d..93814111 100644 --- a/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift +++ b/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift @@ -410,7 +410,7 @@ extension AlertState where Action == UploadBoxFeature.Destination.Alert { } }, message: { - TextState("You can try uploading it again. If there are already files in the queue, it will be uploaded last") + TextState("You can try uploading it again. If there are already files in the queue, it will be uploaded last", bundle: .module) } ) } From 2e2dafac695eb6c4fe3ed15d858698e881187b17 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 24 Feb 2026 14:35:57 +0300 Subject: [PATCH 062/118] Add attachment tag to BBPanel --- Modules/Sources/BBPanelFeature/BBPanelFeature.swift | 3 +++ Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift | 8 ++++---- Modules/Sources/UploadBoxFeature/UploadBoxView.swift | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Modules/Sources/BBPanelFeature/BBPanelFeature.swift b/Modules/Sources/BBPanelFeature/BBPanelFeature.swift index 0347b068..fa51c4b1 100644 --- a/Modules/Sources/BBPanelFeature/BBPanelFeature.swift +++ b/Modules/Sources/BBPanelFeature/BBPanelFeature.swift @@ -171,6 +171,9 @@ public struct BBPanelFeature: Reducer, Sendable { case let .destination(.presented(.listTag(.delegate(.listTagBuilded(tag))))): return .send(.delegate(.tagTapped(tag))) + case let .upload(.delegate(.fileHasBeenTapped(id, name))): + return .send(.delegate(.tagTapped(("[attachment=\"\(id):\(name)\"]", "")))) + case .upload(.delegate(.someFileUploading)): state.isUploading = true return .none diff --git a/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift b/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift index 93814111..3a8ec6ba 100644 --- a/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift +++ b/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift @@ -88,7 +88,7 @@ public struct UploadBoxFeature: Reducer, Sendable { case view(View) public enum View { - case fileButtonTapped(_ serverId: Int) + case fileButtonTapped(_ serverId: Int, _ name: String) case fileWithErrorButtonTapped(_ id: UUID) case selectFilesButtonTapped case removeFileButtonTapped(UploadBoxFile) @@ -112,7 +112,7 @@ public struct UploadBoxFeature: Reducer, Sendable { case fileHasBeenRemoved(Int) case fileHasBeenUploaded(Int) - case fileHasBeenTapped(Int) + case fileHasBeenTapped(Int, String) } } @@ -184,8 +184,8 @@ public struct UploadBoxFeature: Reducer, Sendable { case .destination: break - case let .view(.fileButtonTapped(id)): - return .send(.delegate(.fileHasBeenTapped(id))) + case let .view(.fileButtonTapped(id, name)): + return .send(.delegate(.fileHasBeenTapped(id, name))) case let .view(.fileWithErrorButtonTapped(id)): if let index = state.files.firstIndex(where: { $0.id == id }), diff --git a/Modules/Sources/UploadBoxFeature/UploadBoxView.swift b/Modules/Sources/UploadBoxFeature/UploadBoxView.swift index ad48b034..ea81bd0f 100644 --- a/Modules/Sources/UploadBoxFeature/UploadBoxView.swift +++ b/Modules/Sources/UploadBoxFeature/UploadBoxView.swift @@ -186,7 +186,7 @@ public struct UploadBoxView: View { } .onTapGesture { if let serverId = file.serverId, file.uploadingError == nil { - send(.fileButtonTapped(serverId)) + send(.fileButtonTapped(serverId, file.name)) } else { send(.fileWithErrorButtonTapped(file.id)) } From 735d259dd1a784958e8e9d0f1fb12d20f466b9f8 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 24 Feb 2026 15:32:30 +0300 Subject: [PATCH 063/118] Remove warning --- Modules/Sources/FormFeature/FormFeature.swift | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/Modules/Sources/FormFeature/FormFeature.swift b/Modules/Sources/FormFeature/FormFeature.swift index ca7f9331..b53bdb88 100644 --- a/Modules/Sources/FormFeature/FormFeature.swift +++ b/Modules/Sources/FormFeature/FormFeature.swift @@ -169,11 +169,8 @@ public struct FormFeature: Reducer, Sendable { return .none } - case let .topic(id): - break - - case let .post(status): - #warning("handle") + case .topic, .post: + // .formSent not called when an error occurs break } return .run { _ in await dismiss() } From f2e736b89091d442a85da5b1a27da72c670a15e8 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 24 Feb 2026 17:05:13 +0300 Subject: [PATCH 064/118] [WIP] UploadBox --- .../Models/UploadBoxFile.swift | 16 +++- .../UploadBoxFeature/UploadBoxFeature.swift | 77 +++++++++++-------- .../UploadBoxFeature/UploadBoxView.swift | 14 +--- 3 files changed, 63 insertions(+), 44 deletions(-) diff --git a/Modules/Sources/UploadBoxFeature/Models/UploadBoxFile.swift b/Modules/Sources/UploadBoxFeature/Models/UploadBoxFile.swift index a372488c..a483206f 100644 --- a/Modules/Sources/UploadBoxFeature/Models/UploadBoxFile.swift +++ b/Modules/Sources/UploadBoxFeature/Models/UploadBoxFile.swift @@ -12,14 +12,22 @@ public struct UploadBoxFile: Sendable, Identifiable, Equatable { public let name: String public let type: FileType public let data: Data + public let md5: String public var isUploading: Bool public var uploadingError: UploadErrorType? public var serverId: Int? + var fileSource: FileSource? = nil // for reupload + public enum FileType: Sendable, Equatable { case file, image } + public enum FileSource: Equatable, Hashable, Sendable { + case file(url: URL) + case image(data: Data, ext: String?) + } + public enum UploadErrorType: Sendable { case sizeTooBig case badExtension @@ -30,16 +38,20 @@ public struct UploadBoxFile: Sendable, Identifiable, Equatable { name: String, type: FileType, data: Data, + md5: String, isUploading: Bool = false, uploadingError: UploadErrorType? = nil, - serverId: Int? = nil + serverId: Int? = nil, + fileSource: FileSource? = nil ) { self.name = name self.type = type self.data = data + self.md5 = md5 self.isUploading = isUploading self.uploadingError = uploadingError self.serverId = serverId + self.fileSource = fileSource } } @@ -48,6 +60,7 @@ extension UploadBoxFile { name: UUID().uuidString, type: .image, data: Data(), + md5: UUID().uuidString, serverId: 0 ) @@ -55,6 +68,7 @@ extension UploadBoxFile { name: UUID().uuidString, type: .file, data: Data(), + md5: UUID().uuidString, serverId: 1 ) } diff --git a/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift b/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift index 3a8ec6ba..092657d7 100644 --- a/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift +++ b/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift @@ -21,13 +21,6 @@ public struct UploadBoxFeature: Reducer, Sendable { return ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" } - // MARK: - File Type - - public enum FileType: Equatable, Hashable, Sendable { - case file(url: URL) - case image(data: Data, ext: String?) - } - // MARK: - Destination @Reducer @@ -62,7 +55,7 @@ public struct UploadBoxFeature: Reducer, Sendable { public var allowedExtensions: [String] var files: [UploadBoxFile] - var uploadQueue: [FileType] = [] + var uploadQueue: [UploadBoxFile.FileSource] = [] var isAnyFileUploading = false public var filesCount: Int { @@ -92,7 +85,7 @@ public struct UploadBoxFeature: Reducer, Sendable { case fileWithErrorButtonTapped(_ id: UUID) case selectFilesButtonTapped case removeFileButtonTapped(UploadBoxFile) - case photosPickerPhotosSelected([FileType]) + case photosPickerPhotosSelected([UploadBoxFile.FileSource]) case fileImporterURLsRecieved([URL]) } @@ -122,7 +115,7 @@ public struct UploadBoxFeature: Reducer, Sendable { // MARK: - Cancellable - private enum CancelID: Hashable { case uploading(UUID) } + private enum CancelID: Hashable { case uploading } // MARK: - Body @@ -179,7 +172,10 @@ public struct UploadBoxFeature: Reducer, Sendable { } case let .destination(.presented(.alert(.reuploadFile(id)))): - break // TODO: Implement + if let index = state.files.firstIndex(where: { $0.id == id }), + let fileSource = state.files[index].fileSource { + state.uploadQueue.append(fileSource) + } case .destination: break @@ -210,9 +206,14 @@ public struct UploadBoxFeature: Reducer, Sendable { case let .view(.removeFileButtonTapped(file)): state.files.removeAll(where: { $0.id == file.id }) if file.isUploading { - return .cancel(id: CancelID.uploading(file.id)) + return .concatenate( + .cancel(id: CancelID.uploading), + .send(.internal(.startNextUpload)) + // no need to send delegate .fileHasBeenRemoved, + // cause file at level upper not exists (cause not uploaded) + ) } - if let serverId = file.serverId { + if let serverId = file.serverId { // file already uploaded return .send(.delegate(.fileHasBeenRemoved(serverId))) } @@ -221,8 +222,7 @@ public struct UploadBoxFeature: Reducer, Sendable { state.files.append(.mockImage) return .send(.delegate(.fileHasBeenUploaded(0))) } - let filtered = Array(Set(state.uploadQueue + images)) - state.uploadQueue.append(contentsOf: filtered) + state.uploadQueue.append(contentsOf: images) return .send(.internal(.startNextUpload)) case let .view(.fileImporterURLsRecieved(urls)): @@ -233,8 +233,7 @@ public struct UploadBoxFeature: Reducer, Sendable { state.files.append(.mockFile) return .send(.delegate(.fileHasBeenUploaded(0))) } - let filtered = Array(Set(state.uploadQueue + urls.map { .file(url: $0) })) - state.uploadQueue.append(contentsOf: filtered) + state.uploadQueue.append(contentsOf: urls.map { .file(url: $0) }) return .send(.internal(.startNextUpload)) case let .internal(.uploadFileCanceledByValidation(id, status)): @@ -302,13 +301,23 @@ public struct UploadBoxFeature: Reducer, Sendable { ) } - let file = UploadBoxFile( - name: name, - type: uploadType, - data: data, - isUploading: true - ) - return .send(.internal(.uploadFile(file))) + return .run { [files = state.files] send in + let fileHash = await calculateFileHash(data: data) + guard !files.contains(where: { $0.md5 == fileHash }) else { + await send(.internal(.startNextUpload)) + return + } + + let file = UploadBoxFile( + name: name, + type: uploadType, + data: data, + md5: fileHash, + isUploading: true, + fileSource: item + ) + await send(.internal(.uploadFile(file))) + } case let .internal(.uploadFile(file)): state.files.append(file) @@ -318,7 +327,7 @@ public struct UploadBoxFeature: Reducer, Sendable { fileName: file.name, fileSize: file.data.count, fileData: file.data, - md5: calculateFileHash(data: file.data), + md5: file.md5, isQms: false ) await send(.delegate(.someFileUploading)) @@ -327,7 +336,7 @@ public struct UploadBoxFeature: Reducer, Sendable { await send(.internal(.updateFileUploadStatus(file.id, status))) } } - .cancellable(id: CancelID.uploading(file.id), cancelInFlight: true) + .cancellable(id: CancelID.uploading) case let .internal(.updateFileUploadStatus(id, status)): if let index = state.files.firstIndex(where: { $0.id == id }) { @@ -344,11 +353,9 @@ public struct UploadBoxFeature: Reducer, Sendable { case .uploading(let value): print("Reducer UPLAODING: \(value)") - state.files[index].isUploading = true case .initialized: print("FILE UPLOADING INITIALIZED") - state.files[index].isUploading = true case .error(let error): state.files[index].uploadingError = switch error { @@ -376,6 +383,7 @@ public struct UploadBoxFeature: Reducer, Sendable { case let .internal(.uploadFileFinished(index, responseFileId)): state.files[index].serverId = responseFileId + state.files[index].fileSource = nil state.files[index].isUploading = false return .concatenate( .send(.delegate(.fileHasBeenUploaded(responseFileId))), @@ -448,10 +456,15 @@ private extension UploadBoxFeature { return false } - func calculateFileHash(data: Data) -> String { - return Insecure.MD5.hash(data: data) - .map { byte in String(format: "%02X", byte) } - .joined() + func calculateFileHash(data: Data) async -> String { + await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + let hash = Insecure.MD5.hash(data: data) + .map { byte in String(format: "%02X", byte) } + .joined() + continuation.resume(returning: hash) + } + } } } diff --git a/Modules/Sources/UploadBoxFeature/UploadBoxView.swift b/Modules/Sources/UploadBoxFeature/UploadBoxView.swift index ea81bd0f..35e759cb 100644 --- a/Modules/Sources/UploadBoxFeature/UploadBoxView.swift +++ b/Modules/Sources/UploadBoxFeature/UploadBoxView.swift @@ -70,7 +70,7 @@ public struct UploadBoxView: View { maxSelectionCount: 10 ) .task(id: pickerItems) { - var photos: [UploadBoxFeature.FileType] = [] + var photos: [UploadBoxFile.FileSource] = [] for item in pickerItems { if let data = try? await item.loadTransferable(type: Data.self) { let type = item.supportedContentTypes.first @@ -239,11 +239,7 @@ public struct UploadBoxView: View { initialState: UploadBoxFeature.State( type: .form, allowedExtensions: ["jpg", "jpeg", "gif", "png"], - files: [ - .init(name: "File 1", type: .file, data: Data()), - .init(name: "Image 1", type: .image, data: Data()), - .init(name: "File 2", type: .file, data: Data()), - ] + files: [.mockFile, .mockImage, .mockFile] ) ) { UploadBoxFeature() @@ -274,11 +270,7 @@ public struct UploadBoxView: View { initialState: UploadBoxFeature.State( type: .bbPanel, allowedExtensions: ["jpg", "jpeg", "gif", "png"], - files: [ - .init(name: "File 1", type: .file, data: Data()), - .init(name: "Image 1", type: .image, data: Data()), - .init(name: "File 2", type: .file, data: Data()), - ] + files: [.mockFile, .mockImage, .mockFile] ) ) { UploadBoxFeature() From fa0f7b1f796227dcbfa3a65d267e0b63649e7abd Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 24 Feb 2026 17:30:09 +0300 Subject: [PATCH 065/118] Use TopicBuilder for fields BB codes parsing --- .../FormFeature/Support/FormNodeBuilder.swift | 68 +++---------------- 1 file changed, 11 insertions(+), 57 deletions(-) diff --git a/Modules/Sources/FormFeature/Support/FormNodeBuilder.swift b/Modules/Sources/FormFeature/Support/FormNodeBuilder.swift index 3aebdfb1..73a0cc67 100644 --- a/Modules/Sources/FormFeature/Support/FormNodeBuilder.swift +++ b/Modules/Sources/FormFeature/Support/FormNodeBuilder.swift @@ -5,19 +5,11 @@ // Created by Ilia Lubianoi on 20.07.2025. // -import BBBuilder import SharedUI import SwiftUI +import TopicBuilder -// MARK: - Node - -enum FormNode: Hashable { - case text(AttributedString) - case center([FormNode]) - case left([FormNode]) - case right([FormNode]) - case justify([FormNode]) -} +typealias FormNode = UITopicType // MARK: - Builder @@ -29,31 +21,12 @@ struct FormNodeBuilder { self.text = text } - func build(isDescription: Bool = false) -> [FormNode] { + func build(isDescription: Bool = false) -> [UITopicType] { var text = text if isDescription { text = "[color=gray][size=1]\(text)[/size][/color]" } - let nodes = BBBuilder.build(text: text) - return convert(nodes) - } - - private func convert(_ nodes: [BBContainerNode]) -> [FormNode] { - var elements: [FormNode] = [] - for node in nodes { - switch node { - case let .text(text): - elements.append(.text(AttributedString(text))) - - case let .center(nodes), let .left(nodes), let .right(nodes), let .justify(nodes): - let subElements = convert(nodes) - elements.append(contentsOf: subElements) - - default: - continue - } - } - return elements + return TopicNodeBuilder(text: text, attachments: []).build() } } @@ -61,34 +34,15 @@ struct FormNodeBuilder { struct FormNodeView: View { - let node: FormNode + let node: UITopicType var body: some View { - switch node { - case let .text(text): - RichText(text: text) - #warning("Обработать тапы на ссылки") - - case let .center(nodes), let .justify(nodes): - VStack(alignment: .center) { - ForEach(nodes, id: \.self) { node in - FormNodeView(node: node) - } - } - - case let .left(nodes): - VStack(alignment: .leading) { - ForEach(nodes, id: \.self) { node in - FormNodeView(node: node) - } + TopicView( + type: node, + attachments: [], + onUrlTap: { _ in + #warning("Обработать тапы на ссылки") } - - case let .right(nodes): - VStack(alignment: .trailing) { - ForEach(nodes, id: \.self) { node in - FormNodeView(node: node) - } - } - } + ) } } From e30350831396f253c78a685df62ef9943a93df62 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Wed, 25 Feb 2026 14:53:49 +0300 Subject: [PATCH 066/118] UploadBox improvements --- .../Resources/Localizable.xcstrings | 20 +++++++++++++ .../UploadBoxFeature/UploadBoxFeature.swift | 28 +++++++++++++------ .../UploadBoxFeature/UploadBoxView.swift | 10 ++++--- 3 files changed, 45 insertions(+), 13 deletions(-) diff --git a/Modules/Sources/UploadBoxFeature/Resources/Localizable.xcstrings b/Modules/Sources/UploadBoxFeature/Resources/Localizable.xcstrings index 0ff5d5fe..f1c28f10 100644 --- a/Modules/Sources/UploadBoxFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/UploadBoxFeature/Resources/Localizable.xcstrings @@ -51,6 +51,16 @@ } } }, + "File import failed. Please, try again" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка получения файлов. Пожалуйста, попробуйте снова" + } + } + } + }, "File size too big" : { "localizations" : { "ru" : { @@ -61,6 +71,16 @@ } } }, + "OK" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ОК" + } + } + } + }, "Select another file. If there are already files in the queue, it will be uploaded last" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift b/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift index 092657d7..21f239cd 100644 --- a/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift +++ b/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift @@ -87,6 +87,9 @@ public struct UploadBoxFeature: Reducer, Sendable { case removeFileButtonTapped(UploadBoxFile) case photosPickerPhotosSelected([UploadBoxFile.FileSource]) case fileImporterURLsRecieved([URL]) + case fileImporterURLsRecievingFailure + + case fileUploadCanceled(UUID?, UploadBoxFile.UploadErrorType) } case `internal`(Internal) @@ -95,7 +98,6 @@ public struct UploadBoxFeature: Reducer, Sendable { case uploadFile(UploadBoxFile) case uploadFileFinished(index: Int, Int) case updateFileUploadStatus(UUID, UploadProgressStatus) - case uploadFileCanceledByValidation(UUID?, UploadBoxFile.UploadErrorType) } case delegate(Delegate) @@ -186,7 +188,7 @@ public struct UploadBoxFeature: Reducer, Sendable { case let .view(.fileWithErrorButtonTapped(id)): if let index = state.files.firstIndex(where: { $0.id == id }), let error = state.files[index].uploadingError { - return .send(.internal(.uploadFileCanceledByValidation(id, error))) + return .send(.view(.fileUploadCanceled(id, error))) } case .view(.selectFilesButtonTapped): @@ -227,17 +229,17 @@ public struct UploadBoxFeature: Reducer, Sendable { case let .view(.fileImporterURLsRecieved(urls)): if isPreview { - let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - let fileURL = documentsURL.appending(path: "data.dat") - try! Data().write(to: fileURL) state.files.append(.mockFile) return .send(.delegate(.fileHasBeenUploaded(0))) } state.uploadQueue.append(contentsOf: urls.map { .file(url: $0) }) return .send(.internal(.startNextUpload)) - case let .internal(.uploadFileCanceledByValidation(id, status)): - switch status { + case .view(.fileImporterURLsRecievingFailure): + state.destination = .alert(.fileImportFailed) + + case let .view(.fileUploadCanceled(id, reason)): + switch reason { case .sizeTooBig: state.destination = .alert(.criticalFileConfirmation( fileId: id, @@ -290,13 +292,13 @@ public struct UploadBoxFeature: Reducer, Sendable { guard let ext = fileExtension, fileExtensionAllowed(ext: ext, allowed: state.allowedExtensions) else { return .concatenate( - .send(.internal(.uploadFileCanceledByValidation(nil, .badExtension))), + .send(.view(.fileUploadCanceled(nil, .badExtension))), .send(.internal(.startNextUpload)) ) } guard let data else { return .concatenate( - .send(.internal(.uploadFileCanceledByValidation(nil, .sizeTooBig))), + .send(.view(.fileUploadCanceled(nil, .sizeTooBig))), .send(.internal(.startNextUpload)) ) } @@ -440,6 +442,14 @@ extension AlertState where Action == UploadBoxFeature.Destination.Alert { message: { message } ) } + + nonisolated(unsafe) static let fileImportFailed = AlertState { + TextState("File import failed. Please, try again") + } actions: { + ButtonState { + TextState("OK") + } + } } // MARK: - Helpers diff --git a/Modules/Sources/UploadBoxFeature/UploadBoxView.swift b/Modules/Sources/UploadBoxFeature/UploadBoxView.swift index 35e759cb..4232f011 100644 --- a/Modules/Sources/UploadBoxFeature/UploadBoxView.swift +++ b/Modules/Sources/UploadBoxFeature/UploadBoxView.swift @@ -52,15 +52,18 @@ public struct UploadBoxView: View { ) .fileImporter( isPresented: Binding($store.destination.fileImporter), - allowedContentTypes: [.item], // server will decide + allowedContentTypes: [.item], allowsMultipleSelection: true, onCompletion: { result in switch result { case let .success(urls): send(.fileImporterURLsRecieved(urls)) case let .failure(error): - print("File importer error: \(error)") -#warning("Handle error") + if let error = error as? CocoaError, error.code == .userCancelled { + // canceled by user + } else { + send(.fileImporterURLsRecievingFailure) + } } } ) @@ -77,7 +80,6 @@ public struct UploadBoxView: View { photos.append(.image(data: data, ext: type?.preferredFilenameExtension)) } } - print("PHOTOSL \(photos)") send(.photosPickerPhotosSelected(photos)) } .tint(tintColor) From 28740884c970479f361b44e23e7308447330b304 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Wed, 25 Feb 2026 14:57:48 +0300 Subject: [PATCH 067/118] Fix file reupload --- Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift b/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift index 21f239cd..33e5c9f2 100644 --- a/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift +++ b/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift @@ -177,6 +177,9 @@ public struct UploadBoxFeature: Reducer, Sendable { if let index = state.files.firstIndex(where: { $0.id == id }), let fileSource = state.files[index].fileSource { state.uploadQueue.append(fileSource) + if !state.isAnyFileUploading && state.uploadQueue.count == 1 { + return .send(.internal(.startNextUpload)) + } } case .destination: From 396433e4ab80d897094e7f660dd647c61bc4b8d6 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Wed, 25 Feb 2026 15:39:55 +0300 Subject: [PATCH 068/118] Fix uploaded files counter for BBPanel --- .../BBPanelFeature/BBPanelFeature.swift | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/Modules/Sources/BBPanelFeature/BBPanelFeature.swift b/Modules/Sources/BBPanelFeature/BBPanelFeature.swift index fa51c4b1..43e472cf 100644 --- a/Modules/Sources/BBPanelFeature/BBPanelFeature.swift +++ b/Modules/Sources/BBPanelFeature/BBPanelFeature.swift @@ -48,13 +48,11 @@ public struct BBPanelFeature: Reducer, Sendable { public struct State: Equatable { @Presents var destination: Destination.State? - var upload = UploadBoxFeature.State( - type: .bbPanel, - allowedExtensions: [] - ) + var upload = UploadBoxFeature.State(type: .bbPanel) let panelWith: BBPanelType - let supportsUpload: Bool + public var supportsUpload: Bool + public var allowedExtensions: [String] var tags: [BBPanelTag] = [] var viewState: BBPanelViewState = .tags @@ -68,10 +66,12 @@ public struct BBPanelFeature: Reducer, Sendable { public init( for panelType: BBPanelType, - supportsUpload: Bool = false + supportsUpload: Bool = false, + allowedExtensions: [String] = [] ) { self.panelWith = panelType self.supportsUpload = supportsUpload + self.allowedExtensions = allowedExtensions } } @@ -114,6 +114,7 @@ public struct BBPanelFeature: Reducer, Sendable { var tags = state.panelWith.kit if state.supportsUpload { tags.insert(.upload, at: 0) + state.upload.allowedExtensions = state.allowedExtensions } if case let .post(isCurator, canModerate) = state.panelWith { if canModerate { @@ -130,7 +131,6 @@ public struct BBPanelFeature: Reducer, Sendable { case let .view(.tagButtonTapped(tag)): switch tag { case .b, .i, .s, .u, .sup, .sub, .offtop, .center, .left, .right, .hide, .code, .cur, .mod, .ex, .quote, .spoiler: - print("SIMPLE Tag tapped: \(tag)") return .send(.delegate(.tagTapped(("[\(tag.code)]", "[/\(tag.code)]")))) case .size: //state.destination = .sizeTag @@ -178,6 +178,16 @@ public struct BBPanelFeature: Reducer, Sendable { state.isUploading = true return .none + case .upload(.delegate(.fileHasBeenUploaded)): + state.uploadedFiles += 1 + return .none + + case .upload(.delegate(.fileHasBeenRemoved)): + if state.uploadedFiles != 0 { + state.uploadedFiles -= 1 + } + return .none + case .upload(.delegate(.allFilesAreUploaded)): state.isUploading = false return .none From 62294ed651d8576ff3a5185366b0049073b70937 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Thu, 26 Feb 2026 22:42:46 +0300 Subject: [PATCH 069/118] Bump API version --- Tuist/Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tuist/Package.swift b/Tuist/Package.swift index ebe5373a..a7e5d789 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -91,7 +91,7 @@ let package = Package( // Forks & stuff .package(url: "https://github.com/SubvertDev/AlertToast.git", revision: "d0f7d6b"), - .package(url: "https://github.com/SubvertDev/PDAPI_SPM.git", exact: "0.7.1"), + .package(url: "https://github.com/SubvertDev/PDAPI_SPM.git", exact: "0.7.2"), .package(url: "https://github.com/SubvertDev/RichTextKit.git", branch: "main"), ] ) From f478e7f940b0361a078a2f6ff6605870f2d3c733 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Thu, 26 Feb 2026 22:43:03 +0300 Subject: [PATCH 070/118] [WIP] UploadBox --- .../Helpers/PhotosPickerMedia.swift | 34 +++ .../Models/UploadBoxFile.swift | 22 +- .../Resources/Localizable.xcstrings | 30 +++ .../UploadBoxFeature/UploadBoxFeature.swift | 218 ++++++++++-------- .../UploadBoxFeature/UploadBoxView.swift | 5 +- 5 files changed, 201 insertions(+), 108 deletions(-) create mode 100644 Modules/Sources/UploadBoxFeature/Helpers/PhotosPickerMedia.swift diff --git a/Modules/Sources/UploadBoxFeature/Helpers/PhotosPickerMedia.swift b/Modules/Sources/UploadBoxFeature/Helpers/PhotosPickerMedia.swift new file mode 100644 index 00000000..01879975 --- /dev/null +++ b/Modules/Sources/UploadBoxFeature/Helpers/PhotosPickerMedia.swift @@ -0,0 +1,34 @@ +// +// PhotosPickerMedia.swift +// ForPDA +// +// Created by Xialtal on 25.02.26. +// + +import SwiftUI + +struct PhotosPickerMedia: Transferable { + let url: URL + + static var transferRepresentation: some TransferRepresentation { + FileRepresentation(contentType: .item) { media in + SentTransferredFile(media.url) + } importing: { received in + return try await PhotosPickerMedia( + url: loadFileToTempDirectory(received.file) + ) + } + } + + private static func loadFileToTempDirectory(_ url: URL) async throws -> URL { + try await Task.detached(priority: .utility) { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("\(UUID().uuidString)") + + try FileManager.default.copyItem(at: url, to: fileURL) + + return fileURL + }.value + } +} + diff --git a/Modules/Sources/UploadBoxFeature/Models/UploadBoxFile.swift b/Modules/Sources/UploadBoxFeature/Models/UploadBoxFile.swift index a483206f..0b84636d 100644 --- a/Modules/Sources/UploadBoxFeature/Models/UploadBoxFile.swift +++ b/Modules/Sources/UploadBoxFeature/Models/UploadBoxFile.swift @@ -11,8 +11,8 @@ public struct UploadBoxFile: Sendable, Identifiable, Equatable { public let id = UUID() public let name: String public let type: FileType - public let data: Data - public let md5: String + public let url: URL + public var md5: String? public var isUploading: Bool public var uploadingError: UploadErrorType? public var serverId: Int? @@ -25,20 +25,24 @@ public struct UploadBoxFile: Sendable, Identifiable, Equatable { public enum FileSource: Equatable, Hashable, Sendable { case file(url: URL) - case image(data: Data, ext: String?) + case image(url: URL, ext: String?) } - public enum UploadErrorType: Sendable { + public enum UploadErrorType: Sendable, Equatable { case sizeTooBig case badExtension case uploadFailure + + case noAccessToSSR + case emptyFileData + case other(NSError) } public init( name: String, type: FileType, - data: Data, - md5: String, + url: URL, + md5: String? = nil, isUploading: Bool = false, uploadingError: UploadErrorType? = nil, serverId: Int? = nil, @@ -46,7 +50,7 @@ public struct UploadBoxFile: Sendable, Identifiable, Equatable { ) { self.name = name self.type = type - self.data = data + self.url = url self.md5 = md5 self.isUploading = isUploading self.uploadingError = uploadingError @@ -59,7 +63,7 @@ extension UploadBoxFile { static let mockImage = UploadBoxFile( name: UUID().uuidString, type: .image, - data: Data(), + url: URL(string: "")!, md5: UUID().uuidString, serverId: 0 ) @@ -67,7 +71,7 @@ extension UploadBoxFile { static let mockFile = UploadBoxFile( name: UUID().uuidString, type: .file, - data: Data(), + url: URL(string: "")!, md5: UUID().uuidString, serverId: 1 ) diff --git a/Modules/Sources/UploadBoxFeature/Resources/Localizable.xcstrings b/Modules/Sources/UploadBoxFeature/Resources/Localizable.xcstrings index f1c28f10..065a21cf 100644 --- a/Modules/Sources/UploadBoxFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/UploadBoxFeature/Resources/Localizable.xcstrings @@ -71,6 +71,16 @@ } } }, + "Incorrect size" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Некорректный размер" + } + } + } + }, "OK" : { "localizations" : { "ru" : { @@ -111,6 +121,16 @@ } } }, + "Sorry, unknown error occured" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Извините, произошла неизвестная ошибка" + } + } + } + }, "The selected file will be inserted into the text as code wherever your cursor is located. Or it will also be automatically added to the end of the post." : { "localizations" : { "ru" : { @@ -131,6 +151,16 @@ } } }, + "Unable to access security scoped resource" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unable to access security scoped resource" + } + } + } + }, "You can try uploading it again. If there are already files in the queue, it will be uploaded last" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift b/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift index 33e5c9f2..b7863ade 100644 --- a/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift +++ b/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift @@ -21,6 +21,16 @@ public struct UploadBoxFeature: Reducer, Sendable { return ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" } + // MARK: - Localization + + public enum Localization { + static let badFileSize = LocalizedStringResource("Incorrect size", bundle: .module) + static let fileSizeTooBig = LocalizedStringResource("File size too big", bundle: .module) + static let badFileExtension = LocalizedStringResource("Sorry, this format is not supported", bundle: .module) + static let unknownUploadError = LocalizedStringResource("Sorry, unknown error occured", bundle: .module) + static let selectAnotherFile = LocalizedStringResource("Select another file. If there are already files in the queue, it will be uploaded last", bundle: .module) + } + // MARK: - Destination @Reducer @@ -142,7 +152,7 @@ public struct UploadBoxFeature: Reducer, Sendable { case .destination(.presented(.confirmationDialog(.gallery))): if isPreview { return .send(.view(.photosPickerPhotosSelected( - [.image(data: Data(), ext: nil)] + [.image(url: URL(string: "")!, ext: nil)] ))) } else { state.destination = .photosPicker @@ -178,7 +188,12 @@ public struct UploadBoxFeature: Reducer, Sendable { let fileSource = state.files[index].fileSource { state.uploadQueue.append(fileSource) if !state.isAnyFileUploading && state.uploadQueue.count == 1 { - return .send(.internal(.startNextUpload)) + return .concatenate( + .send(.view(.removeFileButtonTapped(state.files[index]))), + .send(.internal(.startNextUpload)) + ) + } else { + return .send(.view(.removeFileButtonTapped(state.files[index]))) } } @@ -246,19 +261,33 @@ public struct UploadBoxFeature: Reducer, Sendable { case .sizeTooBig: state.destination = .alert(.criticalFileConfirmation( fileId: id, - title: TextState("File size too big", bundle: .module), - message: TextState("Select another file. If there are already files in the queue, it will be uploaded last", bundle: .module) + title: TextState(Localization.fileSizeTooBig), + message: TextState(Localization.selectAnotherFile) )) case .badExtension: state.destination = .alert(.criticalFileConfirmation( fileId: id, - title: TextState("Sorry, this format is not supported", bundle: .module), - message: TextState("Select another file. If there are already files in the queue, it will be uploaded last", bundle: .module) + title: TextState(Localization.badFileExtension), + message: TextState(Localization.selectAnotherFile) + )) + case .emptyFileData: + state.destination = .alert(.criticalFileConfirmation( + fileId: id, + title: TextState(Localization.badFileSize), + message: TextState(Localization.selectAnotherFile) + )) + case .other(let error): + state.destination = .alert(.criticalFileConfirmation( + fileId: id, + title: TextState(Localization.unknownUploadError), + message: TextState(verbatim: error.description) )) case .uploadFailure: if let id { state.destination = .alert(.reuploadFileConfirmation(id: id)) } + case .noAccessToSSR: + state.destination = .alert(.noAccessToSecurityScopedResource) } case .internal(.startNextUpload): @@ -270,71 +299,72 @@ public struct UploadBoxFeature: Reducer, Sendable { state.isAnyFileUploading = true state.uploadQueue.removeFirst() - let data: Data? + let url: URL let name: String let uploadType: UploadBoxFile.FileType let fileExtension: String? switch item { - case .file(let url): - guard url.startAccessingSecurityScopedResource() else { - return .send(.internal(.startNextUpload)) - } - defer { url.stopAccessingSecurityScopedResource() } - - data = try? Data(contentsOf: url) + case .file(let u): + url = u name = url.lastPathComponent uploadType = .file fileExtension = url.pathExtension - case .image(let d, let ext): - data = d + case .image(let u, let ext): + url = u uploadType = .image - fileExtension = if let ext = ext { ext } else { d.imageExtension } + fileExtension = ext name = "\(UUID().uuidString).\(fileExtension ?? "bin")" } - guard let ext = fileExtension, fileExtensionAllowed(ext: ext, allowed: state.allowedExtensions) else { + guard let ext = fileExtension, fileExtensionAllowed(ext, state.allowedExtensions) else { return .concatenate( .send(.view(.fileUploadCanceled(nil, .badExtension))), .send(.internal(.startNextUpload)) ) } - guard let data else { - return .concatenate( - .send(.view(.fileUploadCanceled(nil, .sizeTooBig))), - .send(.internal(.startNextUpload)) - ) - } - return .run { [files = state.files] send in - let fileHash = await calculateFileHash(data: data) - guard !files.contains(where: { $0.md5 == fileHash }) else { + let file = UploadBoxFile( + name: name, + type: uploadType, + url: url, + isUploading: true, + fileSource: item + ) + state.files.append(file) + + return .send(.internal(.uploadFile(file))) + + case let .internal(.uploadFile(file)): + state.isAnyFileUploading = true + + return .run(priority: .userInitiated, name: file.name) { send in + if file.type == .file { + guard file.url.startAccessingSecurityScopedResource() else { + await send(.view(.fileUploadCanceled(file.id, .noAccessToSSR))) + await send(.internal(.startNextUpload)) + return + } + } + + let data = try? Data(contentsOf: file.url) + + if file.type == .file { + file.url.stopAccessingSecurityScopedResource() + } + + guard let data else { + await send(.view(.fileUploadCanceled(file.id, .emptyFileData))) await send(.internal(.startNextUpload)) return } - let file = UploadBoxFile( - name: name, - type: uploadType, - data: data, - md5: fileHash, - isUploading: true, - fileSource: item - ) - await send(.internal(.uploadFile(file))) - } - - case let .internal(.uploadFile(file)): - state.files.append(file) - state.isAnyFileUploading = true - return .run(priority: .userInitiated) { [file = file] send in let request = UploadRequest( fileName: file.name, - fileSize: file.data.count, - fileData: file.data, - md5: file.md5, + fileData: data, isQms: false ) + await send(.delegate(.someFileUploading)) for await status in apiClient.upload(request) { @@ -344,47 +374,44 @@ public struct UploadBoxFeature: Reducer, Sendable { .cancellable(id: CancelID.uploading) case let .internal(.updateFileUploadStatus(id, status)): - if let index = state.files.firstIndex(where: { $0.id == id }) { - switch status { - case .done(let response): - guard let fileId = Int(response.replacingOccurrences(of: "[", with: "") - .replacingOccurrences(of: "]", with: "") - .components(separatedBy: ",")[2]) else { - state.files[index].isUploading = false - state.files[index].uploadingError = .uploadFailure - return .send(.internal(.startNextUpload)) - } - return .send(.internal(.uploadFileFinished(index: index, fileId))) - - case .uploading(let value): - print("Reducer UPLAODING: \(value)") - - case .initialized: - print("FILE UPLOADING INITIALIZED") - - case .error(let error): - state.files[index].uploadingError = switch error { - case .serverDenied: .uploadFailure - case .fileSizeTooBig: .sizeTooBig - case .fileNotAllowed, .fileTypeNotAllowed: .badExtension - case .responseStatus, .other: .uploadFailure - @unknown default: .uploadFailure - } - state.files[index].isUploading = false - // TODO: capture? - print("ERROR ON FILE UPLOADING: \(error)") - return .send(.internal(.startNextUpload)) - - @unknown default: - print("UNKNOWN DEFAULT ERROR! \(id), \(status)") - state.files[index].isUploading = false - state.files[index].uploadingError = .uploadFailure - return .send(.internal(.startNextUpload)) + guard let index = state.files.firstIndex(where: { $0.id == id }) else { break } + switch status { + case let .done(response): + guard let fileId = Int(response.replacingOccurrences(of: "[", with: "") + .replacingOccurrences(of: "]", with: "") + .components(separatedBy: ",")[2]) else { + break } - } else { - // Do nothing... File removed by user. + return .send(.internal(.uploadFileFinished(index: index, fileId))) + + case .uploading: + break + + case let .initialized(md5, _): + guard (state.files.firstIndex(where: { !$0.isUploading && $0.md5 == md5 }) == nil) else { + // file already uploaded - remove this duplicate + return .send(.view(.removeFileButtonTapped(state.files[index]))) + } + state.files[index].md5 = md5 + return .none + + case let .error(error): + state.files[index].uploadingError = switch error { + case .serverDenied: .uploadFailure + case .fileSizeTooBig: .sizeTooBig + case .fileNotAllowed, .fileTypeNotAllowed: .badExtension + case .responseStatus: .uploadFailure + case .other(let error): .other(error as NSError) + @unknown default: .uploadFailure + } + state.files[index].isUploading = false + + @unknown default: + print("UNKNOWN DEFAULT ERROR! \(id), \(status)") + state.files[index].isUploading = false + state.files[index].uploadingError = .uploadFailure } - return .none + return .send(.internal(.startNextUpload)) case let .internal(.uploadFileFinished(index, responseFileId)): state.files[index].serverId = responseFileId @@ -447,7 +474,15 @@ extension AlertState where Action == UploadBoxFeature.Destination.Alert { } nonisolated(unsafe) static let fileImportFailed = AlertState { - TextState("File import failed. Please, try again") + TextState("File import failed. Please, try again", bundle: .module) + } actions: { + ButtonState { + TextState("OK") + } + } + + nonisolated(unsafe) static let noAccessToSecurityScopedResource = AlertState { + TextState("Unable to access security scoped resource") } actions: { ButtonState { TextState("OK") @@ -458,7 +493,7 @@ extension AlertState where Action == UploadBoxFeature.Destination.Alert { // MARK: - Helpers private extension UploadBoxFeature { - func fileExtensionAllowed(ext: String?, allowed: [String]) -> Bool { + func fileExtensionAllowed(_ ext: String?, _ allowed: [String]) -> Bool { guard let fileExtension = ext else { return false } guard !allowed.isEmpty else { return true } for allowedExtension in allowed { @@ -468,17 +503,6 @@ private extension UploadBoxFeature { } return false } - - func calculateFileHash(data: Data) async -> String { - await withCheckedContinuation { continuation in - DispatchQueue.global(qos: .userInitiated).async { - let hash = Insecure.MD5.hash(data: data) - .map { byte in String(format: "%02X", byte) } - .joined() - continuation.resume(returning: hash) - } - } - } } private extension Data { diff --git a/Modules/Sources/UploadBoxFeature/UploadBoxView.swift b/Modules/Sources/UploadBoxFeature/UploadBoxView.swift index 4232f011..43bd690d 100644 --- a/Modules/Sources/UploadBoxFeature/UploadBoxView.swift +++ b/Modules/Sources/UploadBoxFeature/UploadBoxView.swift @@ -75,11 +75,12 @@ public struct UploadBoxView: View { .task(id: pickerItems) { var photos: [UploadBoxFile.FileSource] = [] for item in pickerItems { - if let data = try? await item.loadTransferable(type: Data.self) { + if let media = try? await item.loadTransferable(type: PhotosPickerMedia.self) { let type = item.supportedContentTypes.first - photos.append(.image(data: data, ext: type?.preferredFilenameExtension)) + photos.append(.image(url: media.url, ext: type?.preferredFilenameExtension)) } } + pickerItems = [] send(.photosPickerPhotosSelected(photos)) } .tint(tintColor) From e7825af5a7c6ea0608ba048dc5ac746d95258fcc Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 27 Feb 2026 14:07:53 +0300 Subject: [PATCH 071/118] Remove unused code --- .../UploadBoxFeature/UploadBoxFeature.swift | 25 ------------------- 1 file changed, 25 deletions(-) diff --git a/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift b/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift index b7863ade..88cd660f 100644 --- a/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift +++ b/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift @@ -504,28 +504,3 @@ private extension UploadBoxFeature { return false } } - -private extension Data { - var imageExtension: String? { - switch mimeType { - case 0xFF: - return "jpeg" - case 0x89: - return "png" - case 0x47: - return "gif" - case 0x52: - return "webp" - case 0x49, 0x4D: - return "tiff" - default: - return nil - } - } - - private var mimeType: UInt8 { - var mt: UInt8 = 0 - copyBytes(to: &mt, count: 1) - return mt - } -} From 44da2fd5a45748e9b620f71c3518a9e3e351ba81 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 27 Feb 2026 14:11:07 +0300 Subject: [PATCH 072/118] Form improvements --- Modules/Sources/FormFeature/FormFeature.swift | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/Modules/Sources/FormFeature/FormFeature.swift b/Modules/Sources/FormFeature/FormFeature.swift index b53bdb88..a655d7d2 100644 --- a/Modules/Sources/FormFeature/FormFeature.swift +++ b/Modules/Sources/FormFeature/FormFeature.swift @@ -110,7 +110,7 @@ public struct FormFeature: Reducer, Sendable { case reportResponse(Result) case simplePostResponse(Result) case templateResponse(Result) - case publishPost(flag: PostSendFlag) + case publishForm(flag: PostSendFlag) } case delegate(Delegate) @@ -154,7 +154,7 @@ public struct FormFeature: Reducer, Sendable { return .run { _ in await dismiss() } } - return .send(.internal(.publishPost(flag: PostSendFlag(rawValue: editorFlag)!))) + return .send(.internal(.publishForm(flag: PostSendFlag(rawValue: editorFlag)!))) case .destination(.dismiss): state.isPublishing = false @@ -259,10 +259,7 @@ public struct FormFeature: Reducer, Sendable { state.destination = .preview(previewState) case .view(.publishButtonTapped): - return .send(.internal(.publishPost(flag: .default))) -// return .run { send in -// await send(.internal(.publishPost(flag: .default))) -// } + return .send(.internal(.publishForm(flag: .default))) case let .internal(.loadForm(id: id, isTopic: isTopic)): return .run { send in @@ -343,7 +340,7 @@ public struct FormFeature: Reducer, Sendable { state.isFormLoading = false state.destination = .alert(.unknownError) - case let .internal(.publishPost(flag: flag)): + case let .internal(.publishForm(flag: flag)): state.isPublishing = true switch state.type { case .topic(forumId: let id, content: _), .post(type: .new, topicId: let id, content: .template): From 1ce175ca3a28b94821b2448cccb483a08b778787 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 27 Feb 2026 14:17:35 +0300 Subject: [PATCH 073/118] Disable action buttons on form loading --- Modules/Sources/FormFeature/FormFeature.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Modules/Sources/FormFeature/FormFeature.swift b/Modules/Sources/FormFeature/FormFeature.swift index a655d7d2..8fb9c8a5 100644 --- a/Modules/Sources/FormFeature/FormFeature.swift +++ b/Modules/Sources/FormFeature/FormFeature.swift @@ -67,10 +67,12 @@ public struct FormFeature: Reducer, Sendable { } var isPreviewButtonDisabled: Bool { + if isFormLoading { return true } return !rows.filter { $0.isRequired() }.allSatisfy { $0.isValid() } } public var isPublishButtonDisabled: Bool { + if isFormLoading { return true } return !rows.allSatisfy { $0.isValid() } || isPublishing } From a79791bae75751c854dc234404d5850d0acbe39b Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 27 Feb 2026 14:27:10 +0300 Subject: [PATCH 074/118] Improve Form namings --- Modules/Sources/APIClient/APIClient.swift | 2 +- Modules/Sources/FormFeature/FormFeature.swift | 6 +++--- ...ormFieldType.swift => FormFieldType.swift} | 20 +++++++++---------- .../{WriteFormSend.swift => FormSend.swift} | 4 ++-- ...WriteFormParser.swift => FormParser.swift} | 12 +++++------ .../Sources/ParsingClient/ParsingClient.swift | 8 ++++---- 6 files changed, 26 insertions(+), 26 deletions(-) rename Modules/Sources/Models/Common/{WriteFormFieldType.swift => FormFieldType.swift} (91%) rename Modules/Sources/Models/Common/{WriteFormSend.swift => FormSend.swift} (69%) rename Modules/Sources/ParsingClient/Parsers/{WriteFormParser.swift => FormParser.swift} (95%) diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index ec6c6ab8..17ec66dd 100644 --- a/Modules/Sources/APIClient/APIClient.swift +++ b/Modules/Sources/APIClient/APIClient.swift @@ -61,7 +61,7 @@ public struct APIClient: Sendable { public var markRead: @Sendable (_ id: Int, _ isTopic: Bool) async throws -> Bool public var getAnnouncement: @Sendable (_ id: Int) async throws -> Announcement public var getTopic: @Sendable (_ id: Int, _ page: Int, _ perPage: Int, _ postsFilter: TopicPostsFilter) async throws -> Topic - public var getTemplate: @Sendable (_ request: ForumTemplateRequest, _ isTopic: Bool) async throws -> [WriteFormFieldType] + public var getTemplate: @Sendable (_ request: ForumTemplateRequest, _ isTopic: Bool) async throws -> [FormFieldType] public var sendTemplate: @Sendable (_ id: Int, _ content: PDAPIDocument, _ isTopic: Bool) async throws -> TemplateSend public var getHistory: @Sendable (_ offset: Int, _ perPage: Int) async throws -> History public var getMentions: @Sendable (_ showPosts: Bool, _ offset: Int, _ perPage: Int) async throws -> Mentions diff --git a/Modules/Sources/FormFeature/FormFeature.swift b/Modules/Sources/FormFeature/FormFeature.swift index 8fb9c8a5..94fb2520 100644 --- a/Modules/Sources/FormFeature/FormFeature.swift +++ b/Modules/Sources/FormFeature/FormFeature.swift @@ -108,7 +108,7 @@ public struct FormFeature: Reducer, Sendable { @CasePathable public enum Internal { case loadForm(id: Int, isTopic: Bool) - case formResponse(Result<[WriteFormFieldType], any Error>) + case formResponse(Result<[FormFieldType], any Error>) case reportResponse(Result) case simplePostResponse(Result) case templateResponse(Result) @@ -118,7 +118,7 @@ public struct FormFeature: Reducer, Sendable { case delegate(Delegate) @CasePathable public enum Delegate { - case formSent(WriteFormSend) + case formSent(FormSend) } } @@ -348,7 +348,7 @@ public struct FormFeature: Reducer, Sendable { case .topic(forumId: let id, content: _), .post(type: .new, topicId: let id, content: .template): return .run { [isTopic = state.type.isTopic, content = state.content] send in let content = try! FormValue.toDocument(content) - let result = await Result { try await apiClient.sendTemplate(id: id, content: content, isTopic: isTopic) } + let result = await Result { try await apiClient.sendTemplate(id, content, isTopic) } await send(.internal(.templateResponse(result))) } diff --git a/Modules/Sources/Models/Common/WriteFormFieldType.swift b/Modules/Sources/Models/Common/FormFieldType.swift similarity index 91% rename from Modules/Sources/Models/Common/WriteFormFieldType.swift rename to Modules/Sources/Models/Common/FormFieldType.swift index b14b84d2..589c46c2 100644 --- a/Modules/Sources/Models/Common/WriteFormFieldType.swift +++ b/Modules/Sources/Models/Common/FormFieldType.swift @@ -1,11 +1,11 @@ // -// WriteFormFieldType.swift +// FormFieldType.swift // ForPDA // // Created by Xialtal on 14.03.25. // -public enum WriteFormFieldType: Sendable, Equatable, Hashable { +public enum FormFieldType: Sendable, Equatable, Hashable { case title(String) case text(FormField, maxLenght: Int?) case editor(FormField) @@ -49,12 +49,12 @@ public enum WriteFormFieldType: Sendable, Equatable, Hashable { // MARK: - Mocks -public extension WriteFormFieldType { +public extension FormFieldType { - static let mockTitle: WriteFormFieldType = + static let mockTitle: FormFieldType = .title("This is an absolute [b]simple[/b] [i]title[/i]") - static let mockRequiredText: WriteFormFieldType = .text( + static let mockRequiredText: FormFieldType = .text( FormField( id: 0, name: "Topic name", @@ -66,7 +66,7 @@ public extension WriteFormFieldType { maxLenght: 255 ) - static let mockRequiredEditor: WriteFormFieldType = .editor( + static let mockRequiredEditor: FormFieldType = .editor( FormField( id: 0, name: "Topic content", @@ -77,7 +77,7 @@ public extension WriteFormFieldType { ) ) - static let mockEditor: WriteFormFieldType = .editor( + static let mockEditor: FormFieldType = .editor( FormField( id: 0, name: "", @@ -88,7 +88,7 @@ public extension WriteFormFieldType { ) ) - static let mockUploadBox: WriteFormFieldType = .uploadbox( + static let mockUploadBox: FormFieldType = .uploadbox( .init( id: 0, name: "Device photos", @@ -101,8 +101,8 @@ public extension WriteFormFieldType { ) } -extension Array where Element == WriteFormFieldType { - public static let releaser: [WriteFormFieldType] = [ +extension Array where Element == FormFieldType { + public static let releaser: [FormFieldType] = [ .title("[size=2][center][b][color=royalblue]Важно![/color][/b]\r\n[SIZE=1] [/SIZE]\r\nЕсли Вы используете инструмент впервые, просьба ознакомиться с темой [url=\"https://4pda.to/forum/index.php?showtopic=950823\"][b]Релизер[/b][/url], а также [url=\"https://4pda.to/forum/index.php?act=announce&f=212&st=250\"][b]Правилами раздела и FAQ по созданию и обновлению тем[/b][/url][/center][/size]\r\n"), .title(""), .dropdown( diff --git a/Modules/Sources/Models/Common/WriteFormSend.swift b/Modules/Sources/Models/Common/FormSend.swift similarity index 69% rename from Modules/Sources/Models/Common/WriteFormSend.swift rename to Modules/Sources/Models/Common/FormSend.swift index 23bf0cf6..02d5c502 100644 --- a/Modules/Sources/Models/Common/WriteFormSend.swift +++ b/Modules/Sources/Models/Common/FormSend.swift @@ -1,11 +1,11 @@ // -// WriteFormSend.swift +// FormSend.swift // ForPDA // // Created by Xialtal on 18.03.25. // -public enum WriteFormSend: Sendable { +public enum FormSend: Sendable { case post(PostSend) case report(ReportResponseType) case topic(Int) diff --git a/Modules/Sources/ParsingClient/Parsers/WriteFormParser.swift b/Modules/Sources/ParsingClient/Parsers/FormParser.swift similarity index 95% rename from Modules/Sources/ParsingClient/Parsers/WriteFormParser.swift rename to Modules/Sources/ParsingClient/Parsers/FormParser.swift index 9503c5ee..c902475b 100644 --- a/Modules/Sources/ParsingClient/Parsers/WriteFormParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/FormParser.swift @@ -1,5 +1,5 @@ // -// WriteFormParser.swift +// FormParser.swift // ForPDA // // Created by Xialtal on 14.03.25. @@ -8,9 +8,9 @@ import Foundation import Models -public struct WriteFormParser { +public struct FormParser { - public static func parse(from string: String) throws(ParsingError) -> [WriteFormFieldType] { + public static func parse(from string: String) throws(ParsingError) -> [FormFieldType] { guard let data = string.data(using: .utf8) else { throw ParsingError.failedToCreateDataFromString } @@ -90,8 +90,8 @@ public struct WriteFormParser { } } - private static func parseFormFields(_ fieldsRaw: [[Any]]) throws(ParsingError)-> [WriteFormFieldType] { - var formFields: [WriteFormFieldType] = [] + private static func parseFormFields(_ fieldsRaw: [[Any]]) throws(ParsingError)-> [FormFieldType] { + var formFields: [FormFieldType] = [] for (index, field) in fieldsRaw.enumerated() { guard let type = field[safe: 0] as? String, let name = field[safe: 1] as? String, @@ -102,7 +102,7 @@ public struct WriteFormParser { throw ParsingError.failedToCastFields } - let content = WriteFormFieldType.FormField( + let content = FormFieldType.FormField( id: index, name: name, description: description, diff --git a/Modules/Sources/ParsingClient/ParsingClient.swift b/Modules/Sources/ParsingClient/ParsingClient.swift index c27ec9ed..d4fc3d97 100644 --- a/Modules/Sources/ParsingClient/ParsingClient.swift +++ b/Modules/Sources/ParsingClient/ParsingClient.swift @@ -49,7 +49,7 @@ public struct ParsingClient: Sendable { public var parseSearchUsers: @Sendable (_ response: String) async throws -> SearchUsersResponse // Write Form - public var parseWriteForm: @Sendable (_ response: String) async throws -> [WriteFormFieldType] + public var parseWriteForm: @Sendable (_ response: String) async throws -> [FormFieldType] // Extra public var parseUnread: @Sendable (_ response: String) async throws -> Unread @@ -125,10 +125,10 @@ extension ParsingClient: DependencyKey { return try TopicParser.parsePostSendResponse(from: response) }, parseTemplatePreview: { response in - return try WriteFormParser.parseTemplatePreview(from: response) + return try FormParser.parseTemplatePreview(from: response) }, parseTemplateSend: { response in - return try WriteFormParser.parseTemplateSend(from: response) + return try FormParser.parseTemplateSend(from: response) }, parseSearch: { response in return try SearchParser.parse(from: response) @@ -137,7 +137,7 @@ extension ParsingClient: DependencyKey { return try SearchUsersParser.parse(from: response) }, parseWriteForm: { response in - return try WriteFormParser.parse(from: response) + return try FormParser.parse(from: response) }, parseUnread: { response in return try UnreadParser.parse(from: response) From d7749e9dddeef7f8072203227bbc309c1862c41a Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 27 Feb 2026 15:13:03 +0300 Subject: [PATCH 075/118] Fix Form tests --- .../FormFeature/Tests/FormFeatureTests.swift | 72 +++++++++---------- 1 file changed, 32 insertions(+), 40 deletions(-) diff --git a/Modules/Sources/FormFeature/Tests/FormFeatureTests.swift b/Modules/Sources/FormFeature/Tests/FormFeatureTests.swift index 661e18b4..ffdfaf7c 100644 --- a/Modules/Sources/FormFeature/Tests/FormFeatureTests.swift +++ b/Modules/Sources/FormFeature/Tests/FormFeatureTests.swift @@ -11,6 +11,7 @@ import Foundation import Models import Testing import FormFeature +import UploadBoxFeature @MainActor struct FormFeatureTests { @@ -45,7 +46,7 @@ struct FormFeatureTests { await store.send(.view(.publishButtonTapped)) - await store.receive(\.internal.publishPost) { + await store.receive(\.internal.publishForm) { $0.isPublishing = true } @@ -84,7 +85,7 @@ struct FormFeatureTests { await store.send(.view(.publishButtonTapped)) - await store.receive(\.internal.publishPost) { + await store.receive(\.internal.publishForm) { $0.isPublishing = true } @@ -130,7 +131,7 @@ struct FormFeatureTests { await store.send(.view(.publishButtonTapped)) - await store.receive(\.internal.publishPost) { + await store.receive(\.internal.publishForm) { $0.isPublishing = true } @@ -175,7 +176,7 @@ struct FormFeatureTests { await store.send(.view(.publishButtonTapped)) - await store.receive(\.internal.publishPost) { + await store.receive(\.internal.publishForm) { $0.isPublishing = true } @@ -225,7 +226,7 @@ struct FormFeatureTests { await store.send(.view(.publishButtonTapped)) - await store.receive(\.internal.publishPost) { + await store.receive(\.internal.publishForm) { $0.isPublishing = true } @@ -238,7 +239,7 @@ struct FormFeatureTests { $0.destination = nil } - await store.receive(\.internal.publishPost) { + await store.receive(\.internal.publishForm) { $0.isPublishing = true } @@ -283,7 +284,7 @@ struct FormFeatureTests { await store.send(.view(.publishButtonTapped)) - await store.receive(\.internal.publishPost) { + await store.receive(\.internal.publishForm) { $0.isPublishing = true } @@ -330,7 +331,7 @@ struct FormFeatureTests { await store.send(.view(.publishButtonTapped)) - await store.receive(\.internal.publishPost) { + await store.receive(\.internal.publishForm) { $0.isPublishing = true } await store.receive(\.internal.simplePostResponse) @@ -345,7 +346,7 @@ struct FormFeatureTests { type: .post( type: .new, topicId: 0, - content: .template("") + content: .template([]) ) ) ) { @@ -360,6 +361,7 @@ struct FormFeatureTests { } let title = FormTitleFeature.State(id: 0, text: "[size=2][center][b][color=royalblue]Важно![/color][/b]\r\n[SIZE=1] [/SIZE]\r\nЕсли Вы используете инструмент впервые, просьба ознакомиться с темой [url=\"https://4pda.to/forum/index.php?showtopic=950823\"][b]Релизер[/b][/url], а также [url=\"https://4pda.to/forum/index.php?act=announce&f=212&st=250\"][b]Правилами раздела и FAQ по созданию и обновлению тем[/b][/url][/center][/size]\r\n") + let title1 = FormTitleFeature.State(id: 1, text: "") var dropdown = FormDropdownFeature.State( id: 2, title: "Тип обновления", @@ -378,7 +380,8 @@ struct FormFeatureTests { description: "Укажите версию. Например: 1.3.7", placeholder: "", flag: 1, - defaultText: "" + defaultText: "", + maxLength: 255 ) var text2 = FormTextFieldFeature.State( id: 4, @@ -414,6 +417,7 @@ struct FormFeatureTests { $0.isFormLoading = false $0.rows = [ .title(title), + .title(title1), .dropdown(dropdown), .textField(text1), .textField(text2), @@ -442,44 +446,32 @@ struct FormFeatureTests { $0.rows[id: 5] = .editor(editor) } - await store.send(.rows(.element(id: 6, action: .uploadBox(.view(.selectFilesButtonTapped))))) { - uploadbox.destination = .confirmationDialog( - ConfirmationDialogState( - title: { TextState(verbatim: "") }, - actions: { - ButtonState(action: .gallery) { - TextState("Choose from Gallery", bundle: .module) - } - ButtonState(action: .files) { - TextState("Choose from Files", bundle: .module) - } - } - ) - ) + await store.send(.rows(.element(id: 6, action: .uploadBox(.view(.onAppear))))) { + uploadbox.upload.allowedExtensions = ["apk", "apks", "exe", "zip", "rar", "obb", "7z", "r00", "r01", "apkm", "ipa"] $0.rows[id: 6] = .uploadBox(uploadbox) } #expect(store.state.isPublishButtonDisabled) - let fileURL = try! saveBase64StringToDocuments(base64String: baseImage, filename: "base") - - await store.send(.rows(.element(id: 6, action: .uploadBox(.view(.fileImporterURLsRecieved([fileURL])))))) { - uploadbox.files = [ - FormUploadBoxFeature.File( - id: 0, - name: "base", - type: .file, - data: try! Data(contentsOf: fileURL) - ) - ] - $0.rows[id: 6] = .uploadBox(uploadbox) - } - - #expect(!store.state.isPublishButtonDisabled) +// let fileURL = try! saveBase64StringToDocuments(base64String: baseImage, filename: "base") +// +// await store.send(.rows(.element(id: 6, action: .uploadBox(.view(.fileImporterURLsRecieved([fileURL])))))) { +// uploadbox.files = [ +// FormUploadBoxFeature.File( +// id: 0, +// name: "base", +// type: .file, +// data: try! Data(contentsOf: fileURL) +// ) +// ] +// $0.rows[id: 6] = .uploadBox(uploadbox) +// } +// +// #expect(!store.state.isPublishButtonDisabled) await store.send(.view(.publishButtonTapped)) - await store.receive(\.internal.publishPost) { + await store.receive(\.internal.publishForm) { $0.isPublishing = true } From 181302626700d9c59a02c7286fed2b616f754524 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 27 Feb 2026 16:01:14 +0300 Subject: [PATCH 076/118] UploadBox improvements --- .../UploadBoxFeature/UploadBoxFeature.swift | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift b/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift index 88cd660f..debfaeda 100644 --- a/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift +++ b/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift @@ -242,8 +242,10 @@ public struct UploadBoxFeature: Reducer, Sendable { state.files.append(.mockImage) return .send(.delegate(.fileHasBeenUploaded(0))) } - state.uploadQueue.append(contentsOf: images) - return .send(.internal(.startNextUpload)) + if !images.isEmpty { + state.uploadQueue.append(contentsOf: images) + return .send(.internal(.startNextUpload)) + } case let .view(.fileImporterURLsRecieved(urls)): if isPreview { @@ -380,7 +382,9 @@ public struct UploadBoxFeature: Reducer, Sendable { guard let fileId = Int(response.replacingOccurrences(of: "[", with: "") .replacingOccurrences(of: "]", with: "") .components(separatedBy: ",")[2]) else { - break + state.files[index].isUploading = false + state.files[index].uploadingError = .other(NSError(domain: response, code: 0)) + return .send(.internal(.startNextUpload)) } return .send(.internal(.uploadFileFinished(index: index, fileId))) @@ -393,7 +397,6 @@ public struct UploadBoxFeature: Reducer, Sendable { return .send(.view(.removeFileButtonTapped(state.files[index]))) } state.files[index].md5 = md5 - return .none case let .error(error): state.files[index].uploadingError = switch error { @@ -405,13 +408,14 @@ public struct UploadBoxFeature: Reducer, Sendable { @unknown default: .uploadFailure } state.files[index].isUploading = false + return .send(.internal(.startNextUpload)) @unknown default: print("UNKNOWN DEFAULT ERROR! \(id), \(status)") state.files[index].isUploading = false state.files[index].uploadingError = .uploadFailure + return .send(.internal(.startNextUpload)) } - return .send(.internal(.startNextUpload)) case let .internal(.uploadFileFinished(index, responseFileId)): state.files[index].serverId = responseFileId From 0afb8affce2b4662bd83f89ea415e690982b953e Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 27 Feb 2026 17:14:34 +0300 Subject: [PATCH 077/118] Add post preview support --- .../Preview/FormPreviewFeature.swift | 41 +++++++------------ .../FormFeature/Tests/FormFeatureTests.swift | 1 - ...atePreview.swift => PreviewResponse.swift} | 6 +-- Modules/Sources/Models/Post/PostPreview.swift | 19 --------- .../ParsingClient/Parsers/FormParser.swift | 4 +- .../ParsingClient/Parsers/TopicParser.swift | 7 ++-- .../Sources/ParsingClient/ParsingClient.swift | 4 +- 7 files changed, 26 insertions(+), 56 deletions(-) rename Modules/Sources/Models/Forum/{TemplatePreview.swift => PreviewResponse.swift} (71%) delete mode 100644 Modules/Sources/Models/Post/PostPreview.swift diff --git a/Modules/Sources/FormFeature/Preview/FormPreviewFeature.swift b/Modules/Sources/FormFeature/Preview/FormPreviewFeature.swift index 4c60d79e..b4938f14 100644 --- a/Modules/Sources/FormFeature/Preview/FormPreviewFeature.swift +++ b/Modules/Sources/FormFeature/Preview/FormPreviewFeature.swift @@ -43,9 +43,8 @@ public struct FormPreviewFeature: Reducer, Sendable { case cancelButtonTapped case _loadPreview(id: Int, content: [FormValue]) - case _loadSimplePreview(id: Int, content: String, attIds: [Int]) - case _previewResponse(Result) - case _simplePreviewResponse(Result) + case _loadSimplePreview(postId: Int, topicId: Int, content: String, attIds: [Int]) + case _previewResponse(Result) } // MARK: - Dependencies @@ -63,10 +62,16 @@ public struct FormPreviewFeature: Reducer, Sendable { case .topic(let forumId, let content): return .send(._loadPreview(id: forumId, content: content)) - case .post(_, let topicId, let contentType): + case .post(let type, let topicId, let contentType): switch contentType { case .simple(let content, let attachments): - return .send(._loadSimplePreview(id: topicId, content: content, attIds: attachments)) + let postId = if case let .edit(id) = type { id } else { 0 } + return .send(._loadSimplePreview( + postId: postId, + topicId: topicId, + content: content, + attIds: attachments + )) case .template(let content): return .send(._loadPreview(id: topicId, content: content)) @@ -94,41 +99,25 @@ public struct FormPreviewFeature: Reducer, Sendable { await send(._previewResponse(.failure(error))) } - case let ._loadSimplePreview(id, content, attachments): + case let ._loadSimplePreview(postId, topicId, content, attachments): state.isPreviewLoading = true return .run { send in let result = await Result { try await apiClient.previewPost( request: PostPreviewRequest( - id: 0, // TODO: until we not adding support to edit post. + id: postId, post: PostRequest( - topicId: id, + topicId: topicId, content: content, flag: 0, attachments: attachments ) ) )} - await send(._simplePreviewResponse(result)) + await send(._previewResponse(result)) } catch: { error, send in - await send(._simplePreviewResponse(.failure(error))) + await send(._previewResponse(.failure(error))) } - case let ._simplePreviewResponse(.success(preview)): - state.contentTypes = TopicNodeBuilder( - text: preview.content, attachments: [] - ).build() - - // TODO: Attachments. - - state.isPreviewLoading = false - - return .none - - case let ._simplePreviewResponse(.failure(error)): - // TODO: Toast? - print(error) - return .send(.cancelButtonTapped) - case let ._previewResponse(.success(preview)): state.contentTypes = TopicNodeBuilder( text: preview.content, attachments: preview.attachments diff --git a/Modules/Sources/FormFeature/Tests/FormFeatureTests.swift b/Modules/Sources/FormFeature/Tests/FormFeatureTests.swift index ffdfaf7c..e19c2160 100644 --- a/Modules/Sources/FormFeature/Tests/FormFeatureTests.swift +++ b/Modules/Sources/FormFeature/Tests/FormFeatureTests.swift @@ -11,7 +11,6 @@ import Foundation import Models import Testing import FormFeature -import UploadBoxFeature @MainActor struct FormFeatureTests { diff --git a/Modules/Sources/Models/Forum/TemplatePreview.swift b/Modules/Sources/Models/Forum/PreviewResponse.swift similarity index 71% rename from Modules/Sources/Models/Forum/TemplatePreview.swift rename to Modules/Sources/Models/Forum/PreviewResponse.swift index 03102509..ab5f895c 100644 --- a/Modules/Sources/Models/Forum/TemplatePreview.swift +++ b/Modules/Sources/Models/Forum/PreviewResponse.swift @@ -1,11 +1,11 @@ // -// TemplatePreview.swift +// PreviewResponse.swift // ForPDA // -// Created by Xialtal on 23.02.26. +// Created by Xialtal on 27.02.26. // -public struct TemplatePreview: Sendable { +public struct PreviewResponse: Sendable { public let content: String public let attachments: [Attachment] diff --git a/Modules/Sources/Models/Post/PostPreview.swift b/Modules/Sources/Models/Post/PostPreview.swift deleted file mode 100644 index 262cacbc..00000000 --- a/Modules/Sources/Models/Post/PostPreview.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// PostPreview.swift -// ForPDA -// -// Created by Xialtal on 15.03.25. -// - -public struct PostPreview: Sendable { - public let content: String - public let attachmentIds: [Int] - - public init( - content: String, - attachmentIds: [Int] - ) { - self.content = content - self.attachmentIds = attachmentIds - } -} diff --git a/Modules/Sources/ParsingClient/Parsers/FormParser.swift b/Modules/Sources/ParsingClient/Parsers/FormParser.swift index c902475b..2c32116b 100644 --- a/Modules/Sources/ParsingClient/Parsers/FormParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/FormParser.swift @@ -26,7 +26,7 @@ public struct FormParser { return try parseFormFields(fields) } - public static func parseTemplatePreview(from string: String) throws(ParsingError) -> TemplatePreview { + public static func parseTemplatePreview(from string: String) throws(ParsingError) -> PreviewResponse { guard let data = string.data(using: .utf8) else { throw ParsingError.failedToCreateDataFromString } @@ -42,7 +42,7 @@ public struct FormParser { throw ParsingError.failedToCastFields } - return TemplatePreview(content: content, attachments: attachments) + return PreviewResponse(content: content, attachments: attachments) } public static func parseTemplateSend(from string: String) throws(ParsingError) -> TemplateSend { diff --git a/Modules/Sources/ParsingClient/Parsers/TopicParser.swift b/Modules/Sources/ParsingClient/Parsers/TopicParser.swift index 2673e7e1..a33c8b00 100644 --- a/Modules/Sources/ParsingClient/Parsers/TopicParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/TopicParser.swift @@ -57,7 +57,7 @@ public struct TopicParser { ) } - public static func parsePostPreview(from string: String) throws(ParsingError) -> PostPreview { + public static func parsePostPreview(from string: String) throws(ParsingError) -> PreviewResponse { guard let data = string.data(using: .utf8) else { throw ParsingError.failedToCreateDataFromString } @@ -67,11 +67,12 @@ public struct TopicParser { } guard let content = array[safe: 2] as? String, - let attachmentIds = array[safe: 3] as? [Int] else { + let attachmentsRaw = array[safe: 3] as? [[Any]], + let attachments = try? AttachmentParser.parseAttachment(attachmentsRaw) else { throw ParsingError.failedToCastFields } - return PostPreview(content: content, attachmentIds: attachmentIds) + return PreviewResponse(content: content, attachments: attachments) } public static func parsePostSendResponse(from string: String) throws(ParsingError) -> PostSendResponse { diff --git a/Modules/Sources/ParsingClient/ParsingClient.swift b/Modules/Sources/ParsingClient/ParsingClient.swift index d4fc3d97..398f696a 100644 --- a/Modules/Sources/ParsingClient/ParsingClient.swift +++ b/Modules/Sources/ParsingClient/ParsingClient.swift @@ -39,9 +39,9 @@ public struct ParsingClient: Sendable { public var parseFavorites: @Sendable (_ response: String) async throws -> Favorite public var parseHistory: @Sendable (_ response: String) async throws -> History public var parseMentions: @Sendable (_ response: String) async throws -> Mentions - public var parsePostPreview: @Sendable (_ response: String) async throws -> PostPreview + public var parsePostPreview: @Sendable (_ response: String) async throws -> PreviewResponse public var parsePostSendResponse: @Sendable (_ response: String) async throws -> PostSendResponse - public var parseTemplatePreview: @Sendable (_ response: String) async throws -> TemplatePreview + public var parseTemplatePreview: @Sendable (_ response: String) async throws -> PreviewResponse public var parseTemplateSend: @Sendable (_ response: String) async throws -> TemplateSend // Search From ea9eb1354506a9dd5dfc2d5fced29f9ef0330bc5 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 27 Feb 2026 17:16:43 +0300 Subject: [PATCH 078/118] Improvements --- Modules/Sources/APIClient/APIClient.swift | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index 17ec66dd..198ea5a8 100644 --- a/Modules/Sources/APIClient/APIClient.swift +++ b/Modules/Sources/APIClient/APIClient.swift @@ -65,8 +65,8 @@ public struct APIClient: Sendable { public var sendTemplate: @Sendable (_ id: Int, _ content: PDAPIDocument, _ isTopic: Bool) async throws -> TemplateSend public var getHistory: @Sendable (_ offset: Int, _ perPage: Int) async throws -> History public var getMentions: @Sendable (_ showPosts: Bool, _ offset: Int, _ perPage: Int) async throws -> Mentions - public var previewPost: @Sendable (_ request: PostPreviewRequest) async throws -> PostPreview - public var previewTemplate: @Sendable (_ id: Int, _ content: PDAPIDocument, _ isTopic: Bool) async throws -> TemplatePreview + public var previewPost: @Sendable (_ request: PostPreviewRequest) async throws -> PreviewResponse + public var previewTemplate: @Sendable (_ id: Int, _ content: PDAPIDocument, _ isTopic: Bool) async throws -> PreviewResponse public var sendPost: @Sendable (_ request: PostRequest) async throws -> PostSendResponse public var editPost: @Sendable (_ request: PostEditRequest) async throws -> PostSendResponse public var deletePosts: @Sendable (_ postIds: [Int]) async throws -> Bool @@ -647,13 +647,10 @@ extension APIClient: DependencyKey { return .mock }, previewPost: { request in - return PostPreview( - content: request.post.content, - attachmentIds: request.post.attachments - ) + return PreviewResponse(content: request.post.content, attachments: [.mock]) }, previewTemplate: { _, _, _ in - return TemplatePreview(content: "content", attachments: [.mock]) + return PreviewResponse(content: "content", attachments: [.mock]) }, sendPost: { _ in return .success(PostSend(id: 0, topicId: 1, offset: 2)) From 6178bb88adfef7b01bda6989a02118bd3cc0d1c8 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 27 Feb 2026 17:39:10 +0300 Subject: [PATCH 079/118] UploadBox improvements --- .../UploadBoxFeature/Models/UploadBoxFile.swift | 5 ----- .../Sources/UploadBoxFeature/UploadBoxFeature.swift | 13 ++++++------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/Modules/Sources/UploadBoxFeature/Models/UploadBoxFile.swift b/Modules/Sources/UploadBoxFeature/Models/UploadBoxFile.swift index 0b84636d..69e9612b 100644 --- a/Modules/Sources/UploadBoxFeature/Models/UploadBoxFile.swift +++ b/Modules/Sources/UploadBoxFeature/Models/UploadBoxFile.swift @@ -11,7 +11,6 @@ public struct UploadBoxFile: Sendable, Identifiable, Equatable { public let id = UUID() public let name: String public let type: FileType - public let url: URL public var md5: String? public var isUploading: Bool public var uploadingError: UploadErrorType? @@ -41,7 +40,6 @@ public struct UploadBoxFile: Sendable, Identifiable, Equatable { public init( name: String, type: FileType, - url: URL, md5: String? = nil, isUploading: Bool = false, uploadingError: UploadErrorType? = nil, @@ -50,7 +48,6 @@ public struct UploadBoxFile: Sendable, Identifiable, Equatable { ) { self.name = name self.type = type - self.url = url self.md5 = md5 self.isUploading = isUploading self.uploadingError = uploadingError @@ -63,7 +60,6 @@ extension UploadBoxFile { static let mockImage = UploadBoxFile( name: UUID().uuidString, type: .image, - url: URL(string: "")!, md5: UUID().uuidString, serverId: 0 ) @@ -71,7 +67,6 @@ extension UploadBoxFile { static let mockFile = UploadBoxFile( name: UUID().uuidString, type: .file, - url: URL(string: "")!, md5: UUID().uuidString, serverId: 1 ) diff --git a/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift b/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift index debfaeda..9f18eb04 100644 --- a/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift +++ b/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift @@ -105,7 +105,7 @@ public struct UploadBoxFeature: Reducer, Sendable { case `internal`(Internal) public enum Internal { case startNextUpload - case uploadFile(UploadBoxFile) + case uploadFile(UploadBoxFile, URL) case uploadFileFinished(index: Int, Int) case updateFileUploadStatus(UUID, UploadProgressStatus) } @@ -329,30 +329,29 @@ public struct UploadBoxFeature: Reducer, Sendable { let file = UploadBoxFile( name: name, type: uploadType, - url: url, isUploading: true, fileSource: item ) state.files.append(file) - return .send(.internal(.uploadFile(file))) + return .send(.internal(.uploadFile(file, url))) - case let .internal(.uploadFile(file)): + case let .internal(.uploadFile(file, url)): state.isAnyFileUploading = true return .run(priority: .userInitiated, name: file.name) { send in if file.type == .file { - guard file.url.startAccessingSecurityScopedResource() else { + guard url.startAccessingSecurityScopedResource() else { await send(.view(.fileUploadCanceled(file.id, .noAccessToSSR))) await send(.internal(.startNextUpload)) return } } - let data = try? Data(contentsOf: file.url) + let data = try? Data(contentsOf: url) if file.type == .file { - file.url.stopAccessingSecurityScopedResource() + url.stopAccessingSecurityScopedResource() } guard let data else { From 63b094d5570294c8dabe59881ccd4177cb562a3e Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 27 Feb 2026 18:06:30 +0300 Subject: [PATCH 080/118] Add FormAttachment model --- Modules/Sources/FormFeature/FormFeature.swift | 3 ++- .../Preview/FormPreviewFeature.swift | 1 + .../FormFeature/Support/FormAttachment.swift | 24 +++++++++++++++++++ .../FormFeature/Support/FormType.swift | 2 +- 4 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 Modules/Sources/FormFeature/Support/FormAttachment.swift diff --git a/Modules/Sources/FormFeature/FormFeature.swift b/Modules/Sources/FormFeature/FormFeature.swift index 94fb2520..94700b54 100644 --- a/Modules/Sources/FormFeature/FormFeature.swift +++ b/Modules/Sources/FormFeature/FormFeature.swift @@ -273,7 +273,7 @@ public struct FormFeature: Reducer, Sendable { } case let .internal(.formResponse(.success(fields))): - print(fields) + //print(fields) state.isFormLoading = false for (index, field) in fields.enumerated() { switch field { @@ -357,6 +357,7 @@ public struct FormFeature: Reducer, Sendable { let content = if case let .string(text) = state.content.first { text } else { fatalError("Simple content SHOULD be .string()!") } + let attachments = attachments.map { $0.id } return .run { [ content = content, reason = state.editReasonText diff --git a/Modules/Sources/FormFeature/Preview/FormPreviewFeature.swift b/Modules/Sources/FormFeature/Preview/FormPreviewFeature.swift index b4938f14..5ba8a649 100644 --- a/Modules/Sources/FormFeature/Preview/FormPreviewFeature.swift +++ b/Modules/Sources/FormFeature/Preview/FormPreviewFeature.swift @@ -66,6 +66,7 @@ public struct FormPreviewFeature: Reducer, Sendable { switch contentType { case .simple(let content, let attachments): let postId = if case let .edit(id) = type { id } else { 0 } + let attachments = attachments.map { $0.id } return .send(._loadSimplePreview( postId: postId, topicId: topicId, diff --git a/Modules/Sources/FormFeature/Support/FormAttachment.swift b/Modules/Sources/FormFeature/Support/FormAttachment.swift new file mode 100644 index 00000000..cc5571d5 --- /dev/null +++ b/Modules/Sources/FormFeature/Support/FormAttachment.swift @@ -0,0 +1,24 @@ +// +// FormAttachment.swift +// ForPDA +// +// Created by Xialtal on 27.02.26. +// + +import Models + +public struct FormAttachment: Sendable, Equatable { + public let id: Int + public let name: String + public let type: Attachment.AttachmentType + + public init( + id: Int, + name: String, + type: Attachment.AttachmentType + ) { + self.id = id + self.name = name + self.type = type + } +} diff --git a/Modules/Sources/FormFeature/Support/FormType.swift b/Modules/Sources/FormFeature/Support/FormType.swift index 49403088..0f1e889f 100644 --- a/Modules/Sources/FormFeature/Support/FormType.swift +++ b/Modules/Sources/FormFeature/Support/FormType.swift @@ -18,7 +18,7 @@ public enum FormType: Sendable, Equatable { } public enum PostContentType: Sendable, Equatable { - case simple(String, [Int]) + case simple(String, [FormAttachment]) case template([FormValue]) } From c19f0bfdfb9de3bca07e22c3e38ed66bca698196 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 27 Feb 2026 18:15:32 +0300 Subject: [PATCH 081/118] Add post with attachment edit support --- .../Sources/TopicFeature/TopicFeature.swift | 26 ++---- .../Sources/TopicFeature/TopicScreen.swift | 88 ------------------- 2 files changed, 9 insertions(+), 105 deletions(-) diff --git a/Modules/Sources/TopicFeature/TopicFeature.swift b/Modules/Sources/TopicFeature/TopicFeature.swift index f0c68b86..18adb31f 100644 --- a/Modules/Sources/TopicFeature/TopicFeature.swift +++ b/Modules/Sources/TopicFeature/TopicFeature.swift @@ -48,7 +48,6 @@ public struct TopicFeature: Reducer, Sendable { case gallery([URL], [Int], Int) @ReducerCaseIgnored case karmaChange(Int) - case editWarning case form(FormFeature) case changeReputation(ReputationChangeFeature) case alert(AlertState) @@ -139,7 +138,6 @@ public struct TopicFeature: Reducer, Sendable { case textQuoted(UIPost, String) case contextMenu(TopicContextMenuAction) case contextPostMenu(PostMenuAction) - case editWarningSheetCloseButtonTapped } case `internal`(Internal) @@ -344,18 +342,16 @@ public struct TopicFeature: Reducer, Sendable { return .none case let .edit(post): - if post.attachments.isEmpty { - let formState = FormFeature.State( - type: .post( - type: .edit(postId: post.id), - topicId: state.topicId, - content: .simple(post.content, post.attachments.map { $0.id }) - ) + let formState = FormFeature.State( + type: .post( + type: .edit(postId: post.id), + topicId: state.topicId, + content: .simple(post.content, post.attachments.map { + .init(id: $0.id, name: $0.name, type: $0.type) + }) ) - state.destination = .form(formState) - } else { - state.destination = .editWarning - } + ) + state.destination = .form(formState) return .none case let .report(id): @@ -445,10 +441,6 @@ public struct TopicFeature: Reducer, Sendable { state.postId = nil return .none.animation() - case .view(.editWarningSheetCloseButtonTapped): - state.destination = nil - return .none - case .internal(.load): switch state.goTo { case .first: return loadPage(&state) diff --git a/Modules/Sources/TopicFeature/TopicScreen.swift b/Modules/Sources/TopicFeature/TopicScreen.swift index fe124b09..85a3e786 100644 --- a/Modules/Sources/TopicFeature/TopicScreen.swift +++ b/Modules/Sources/TopicFeature/TopicScreen.swift @@ -466,95 +466,7 @@ struct NavigationModifier: ViewModifier { ) { store in ReputationChangeView(store: store) } - .sheet(isPresented: Binding($store.destination.editWarning)) { - EditWarningSheet() - .presentationDetents([.medium]) - .presentationDragIndicator(.visible) - } - } - } - - // TODO: Move to SharedUI? - // MARK: - Edit Warning Sheet - - @ViewBuilder - private func EditWarningSheet() -> some View { - VStack(spacing: 0) { - Spacer() - - Image(systemSymbol: .hammer) - .font(.title) - .foregroundStyle(tintColor) - .padding(.bottom, 8) - - Text("Editing posts with attachments is not yet supported", bundle: .module) - .font(.title3) - .bold() - .foregroundStyle(Color(.Labels.primary)) - .multilineTextAlignment(.center) - .padding(.bottom, 6) - - Spacer() - - Button { - store.send(.view(.editWarningSheetCloseButtonTapped)) - } label: { - Text("Understood", bundle: .module) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - .buttonStyle(.borderedProminent) - .tint(tintColor) - .frame(height: 48) - .padding(.vertical, 8) - .padding(.horizontal, 16) - .background(ignoresSafeAreaEdges: .bottom) - } - .background { - VStack(spacing: 0) { - ComingSoonTape() - .rotationEffect(Angle(degrees: 12)) - .padding(.top, 32) - - Spacer() - - ComingSoonTape() - .rotationEffect(Angle(degrees: -12)) - .padding(.bottom, 96) - } - } - .frame(maxWidth: .infinity) - .overlay(alignment: .topTrailing) { - Button { - store.send(.view(.editWarningSheetCloseButtonTapped)) - } label: { - ZStack { - Circle() - .fill(Color(.Background.quaternary)) - .frame(width: 30, height: 30) - - Image(systemSymbol: .xmark) - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(Color(.Labels.teritary)) - } - .padding(.top, 14) - .padding(.trailing, 16) - } - } - } - - @ViewBuilder - private func ComingSoonTape() -> some View { - HStack(spacing: 8) { - ForEach(0..<6, id: \.self) { index in - Text("IN DEVELOPMENT", bundle: .module) - .font(.footnote) - .foregroundStyle(Color(.Labels.primaryInvariably)) - .fixedSize(horizontal: true, vertical: false) - .lineLimit(1) - } } - .frame(width: UIScreen.main.bounds.width * 2, height: 26) - .background(tintColor) } } } From 498afdbcdae3610cdcca0aaec467462a3d6408e2 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 27 Feb 2026 18:16:24 +0300 Subject: [PATCH 082/118] Add ComingSoonView --- Modules/Sources/SharedUI/ComingSoonView.swift | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 Modules/Sources/SharedUI/ComingSoonView.swift diff --git a/Modules/Sources/SharedUI/ComingSoonView.swift b/Modules/Sources/SharedUI/ComingSoonView.swift new file mode 100644 index 00000000..d3aebd2f --- /dev/null +++ b/Modules/Sources/SharedUI/ComingSoonView.swift @@ -0,0 +1,114 @@ +// +// ComingSoonView.swift +// ForPDA +// +// Created by Xialtal on 27.02.26. +// + +import SwiftUI + +public struct ComingSoonView: View { + + // MARK: - Properties + + @Environment(\.tintColor) private var tintColor + + public let reason: LocalizedStringResource + public let understoodButtonTapped: () -> () + public let closeButtonTapped: () -> () + + // MARK: - Init + + public init( + reason: LocalizedStringResource, + understoodButtonTapped: @escaping () -> Void, + closeButtonTapped: @escaping () -> Void + ) { + self.reason = reason + self.understoodButtonTapped = understoodButtonTapped + self.closeButtonTapped = closeButtonTapped + } + + // MARK: - Body + + public var body: some View { + VStack(spacing: 0) { + Spacer() + + Image(systemSymbol: .hammer) + .font(.title) + .foregroundStyle(tintColor) + .padding(.bottom, 8) + + Text(reason) + .font(.title3) + .bold() + .foregroundStyle(Color(.Labels.primary)) + .multilineTextAlignment(.center) + .padding(.bottom, 6) + + Spacer() + + Button { + understoodButtonTapped() + } label: { + Text("Understood", bundle: .module) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(tintColor) + .frame(height: 48) + .padding(.vertical, 8) + .padding(.horizontal, 16) + .background(ignoresSafeAreaEdges: .bottom) + } + .background { + VStack(spacing: 0) { + ComingSoonTape() + .rotationEffect(Angle(degrees: 12)) + .padding(.top, 32) + + Spacer() + + ComingSoonTape() + .rotationEffect(Angle(degrees: -12)) + .padding(.bottom, 96) + } + } + .frame(maxWidth: .infinity) + .overlay(alignment: .topTrailing) { + Button { + closeButtonTapped() + } label: { + ZStack { + Circle() + .fill(Color(.Background.quaternary)) + .frame(width: 30, height: 30) + + Image(systemSymbol: .xmark) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(Color(.Labels.teritary)) + } + .padding(.top, 14) + .padding(.trailing, 16) + } + } + } + + // MARK: - Coming Soon Tape + + @ViewBuilder + private func ComingSoonTape() -> some View { + HStack(spacing: 8) { + ForEach(0..<6, id: \.self) { index in + Text("IN DEVELOPMENT", bundle: .module) + .font(.footnote) + .foregroundStyle(Color(.Labels.primaryInvariably)) + .fixedSize(horizontal: true, vertical: false) + .lineLimit(1) + } + } + .frame(width: UIScreen.main.bounds.width * 2, height: 26) + .background(tintColor) + } +} From 09ca6d9c90e680659ceca136377f965a0118bf01 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 27 Feb 2026 18:19:07 +0300 Subject: [PATCH 083/118] Fix topic analytics --- Modules/Sources/AnalyticsClient/Events/TopicEvent.swift | 1 - .../TopicFeature/Analytics/TopicFeature+Analytics.swift | 3 --- 2 files changed, 4 deletions(-) diff --git a/Modules/Sources/AnalyticsClient/Events/TopicEvent.swift b/Modules/Sources/AnalyticsClient/Events/TopicEvent.swift index 00a10207..dc00c1bd 100644 --- a/Modules/Sources/AnalyticsClient/Events/TopicEvent.swift +++ b/Modules/Sources/AnalyticsClient/Events/TopicEvent.swift @@ -14,7 +14,6 @@ public enum TopicEvent: Event { case userTapped(Int) case urlTapped(URL) case imageTapped(URL) - case editWarningSheetClosed case textQuoted(Int) case menuCopyLink diff --git a/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift b/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift index 4da7e73c..7ed9a1d9 100644 --- a/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift +++ b/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift @@ -96,9 +96,6 @@ extension TopicFeature { case let .view(.textQuoted(post, _)): analytics.log(TopicEvent.textQuoted(post.id)) - - case .view(.editWarningSheetCloseButtonTapped): - analytics.log(TopicEvent.editWarningSheetClosed) case .internal(.loadTopic): break From 6314beb53769138679b35836f9727963532477ee Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 27 Feb 2026 18:19:22 +0300 Subject: [PATCH 084/118] Improve localizable --- .../SharedUI/Resources/Localizable.xcstrings | 20 +++++++++++++ .../Resources/Localizable.xcstrings | 30 ------------------- 2 files changed, 20 insertions(+), 30 deletions(-) diff --git a/Modules/Sources/SharedUI/Resources/Localizable.xcstrings b/Modules/Sources/SharedUI/Resources/Localizable.xcstrings index adec1057..1dc8cd8c 100644 --- a/Modules/Sources/SharedUI/Resources/Localizable.xcstrings +++ b/Modules/Sources/SharedUI/Resources/Localizable.xcstrings @@ -77,6 +77,16 @@ } } }, + "IN DEVELOPMENT" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В РАЗРАБОТКЕ" + } + } + } + }, "Open In Browser" : { "localizations" : { "ru" : { @@ -177,6 +187,16 @@ } } }, + "Understood" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Понятно" + } + } + } + }, "Yesterday, %@" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings b/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings index 7870a568..f9f8bab6 100644 --- a/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings @@ -71,16 +71,6 @@ } } }, - "Editing posts with attachments is not yet supported" : { - "localizations" : { - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Редактирование постов с вложениями пока что не поддерживается" - } - } - } - }, "Except deleted" : { "localizations" : { "ru" : { @@ -101,16 +91,6 @@ } } }, - "IN DEVELOPMENT" : { - "localizations" : { - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "В РАЗРАБОТКЕ" - } - } - } - }, "Link copied" : { "localizations" : { "ru" : { @@ -281,16 +261,6 @@ } } }, - "Understood" : { - "localizations" : { - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Понятно" - } - } - } - }, "Up" : { "localizations" : { "ru" : { From b4ff02b1ee08bf5dc73ca6965e50ed7e514fadb9 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 27 Feb 2026 18:26:18 +0300 Subject: [PATCH 085/118] Improve Form analytics --- .../Events/{WriteFormEvent.swift => FormEvent.swift} | 8 ++++---- .../FormFeature/Analytics/FormFeature+Analytics.swift | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) rename Modules/Sources/AnalyticsClient/Events/{WriteFormEvent.swift => FormEvent.swift} (67%) diff --git a/Modules/Sources/AnalyticsClient/Events/WriteFormEvent.swift b/Modules/Sources/AnalyticsClient/Events/FormEvent.swift similarity index 67% rename from Modules/Sources/AnalyticsClient/Events/WriteFormEvent.swift rename to Modules/Sources/AnalyticsClient/Events/FormEvent.swift index 9a8d8b44..20735676 100644 --- a/Modules/Sources/AnalyticsClient/Events/WriteFormEvent.swift +++ b/Modules/Sources/AnalyticsClient/Events/FormEvent.swift @@ -1,19 +1,19 @@ // -// WriteFormEvent.swift +// FormEvent.swift // ForPDA // // Created by Ilia Lubianoi on 13.12.2025. // -public enum WriteFormEvent: Event { +public enum FormEvent: Event { - case writeFormSent + case formSent case publishTapped case dismissTapped case previewTapped public var name: String { - return "Write Form " + eventName(for: self).inProperCase + return "Form " + eventName(for: self).inProperCase } public var properties: [String: String]? { diff --git a/Modules/Sources/FormFeature/Analytics/FormFeature+Analytics.swift b/Modules/Sources/FormFeature/Analytics/FormFeature+Analytics.swift index e04f87ae..70a69f18 100644 --- a/Modules/Sources/FormFeature/Analytics/FormFeature+Analytics.swift +++ b/Modules/Sources/FormFeature/Analytics/FormFeature+Analytics.swift @@ -23,16 +23,16 @@ extension FormFeature { break case .delegate(.formSent): - analytics.log(WriteFormEvent.writeFormSent) + analytics.log(FormEvent.formSent) case .view(.publishButtonTapped): - analytics.log(WriteFormEvent.publishTapped) + analytics.log(FormEvent.publishTapped) case .view(.cancelButtonTapped): - analytics.log(WriteFormEvent.dismissTapped) + analytics.log(FormEvent.dismissTapped) case .view(.previewButtonTapped): - analytics.log(WriteFormEvent.previewTapped) + analytics.log(FormEvent.previewTapped) case .view: break From 18a14e3b24f59003c9c6d36bfc47fac9a2bde6a4 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 27 Feb 2026 21:55:17 +0300 Subject: [PATCH 086/118] FormPreview improvements --- .../Preview/FormPreviewFeature.swift | 53 +++++++++------- .../FormFeature/Preview/FormPreviewView.swift | 62 +++++++++++++------ 2 files changed, 73 insertions(+), 42 deletions(-) diff --git a/Modules/Sources/FormFeature/Preview/FormPreviewFeature.swift b/Modules/Sources/FormFeature/Preview/FormPreviewFeature.swift index 5ba8a649..e5ee57a7 100644 --- a/Modules/Sources/FormFeature/Preview/FormPreviewFeature.swift +++ b/Modules/Sources/FormFeature/Preview/FormPreviewFeature.swift @@ -37,19 +37,25 @@ public struct FormPreviewFeature: Reducer, Sendable { // MARK: - Action - public enum Action { - case onAppear - - case cancelButtonTapped + public enum Action: ViewAction { + case view(View) + public enum View { + case onAppear + case cancelButtonTapped + } - case _loadPreview(id: Int, content: [FormValue]) - case _loadSimplePreview(postId: Int, topicId: Int, content: String, attIds: [Int]) - case _previewResponse(Result) + case `internal`(Internal) + public enum Internal { + case loadPreview(id: Int, content: [FormValue]) + case loadSimplePreview(postId: Int, topicId: Int, content: String, attIds: [Int]) + case previewResponse(Result) + } } // MARK: - Dependencies @Dependency(\.apiClient) private var apiClient + @Dependency(\.analyticsClient) private var analyticsClient @Dependency(\.dismiss) var dismiss // MARK: - Body @@ -57,25 +63,25 @@ public struct FormPreviewFeature: Reducer, Sendable { public var body: some Reducer { Reduce { state, action in switch action { - case .onAppear: + case .view(.onAppear): switch state.formType { case .topic(let forumId, let content): - return .send(._loadPreview(id: forumId, content: content)) + return .send(.internal(.loadPreview(id: forumId, content: content))) case .post(let type, let topicId, let contentType): switch contentType { case .simple(let content, let attachments): let postId = if case let .edit(id) = type { id } else { 0 } let attachments = attachments.map { $0.id } - return .send(._loadSimplePreview( + return .send(.internal(.loadSimplePreview( postId: postId, topicId: topicId, content: content, attIds: attachments - )) + ))) case .template(let content): - return .send(._loadPreview(id: topicId, content: content)) + return .send(.internal(.loadPreview(id: topicId, content: content))) } case .report(_, _): @@ -84,10 +90,10 @@ public struct FormPreviewFeature: Reducer, Sendable { } return .none - case .cancelButtonTapped: + case .view(.cancelButtonTapped): return .run { _ in await dismiss() } - case let ._loadPreview(id, content): + case let .internal(.loadPreview(id, content)): state.isPreviewLoading = true return .run { [isTopic = state.formType.isTopic] send in let result = await Result { try await apiClient.previewTemplate( @@ -95,12 +101,12 @@ public struct FormPreviewFeature: Reducer, Sendable { content: try FormValue.toDocument(content), isTopic: isTopic )} - await send(._previewResponse(result)) + await send(.internal(.previewResponse(result))) } catch: { error, send in - await send(._previewResponse(.failure(error))) + await send(.internal(.previewResponse(.failure(error)))) } - case let ._loadSimplePreview(postId, topicId, content, attachments): + case let .internal(.loadSimplePreview(postId, topicId, content, attachments)): state.isPreviewLoading = true return .run { send in let result = await Result { try await apiClient.previewPost( @@ -114,12 +120,12 @@ public struct FormPreviewFeature: Reducer, Sendable { ) ) )} - await send(._previewResponse(result)) + await send(.internal(.previewResponse(result))) } catch: { error, send in - await send(._previewResponse(.failure(error))) + await send(.internal(.previewResponse(.failure(error)))) } - case let ._previewResponse(.success(preview)): + case let .internal(.previewResponse(.success(preview))): state.contentTypes = TopicNodeBuilder( text: preview.content, attachments: preview.attachments ).build() @@ -129,10 +135,9 @@ public struct FormPreviewFeature: Reducer, Sendable { return .none - case let ._previewResponse(.failure(error)): - // TODO: Toast? - print(error) - return .send(.cancelButtonTapped) + case let .internal(.previewResponse(.failure(error))): + analyticsClient.capture(error) + return .send(.view(.cancelButtonTapped)) } } } diff --git a/Modules/Sources/FormFeature/Preview/FormPreviewView.swift b/Modules/Sources/FormFeature/Preview/FormPreviewView.swift index 55756300..ee62381e 100644 --- a/Modules/Sources/FormFeature/Preview/FormPreviewView.swift +++ b/Modules/Sources/FormFeature/Preview/FormPreviewView.swift @@ -11,6 +11,7 @@ import SharedUI import Models import TopicBuilder +@ViewAction(for: FormPreviewFeature.self) struct FormPreviewView: View { @Perception.Bindable var store: StoreOf @@ -23,31 +24,35 @@ struct FormPreviewView: View { var body: some View { WithPerceptionTracking { - ScrollView { - VStack(alignment: .leading, spacing: 0) { - if !store.contentTypes.isEmpty { - ForEach(store.contentTypes, id: \.self) { type in - TopicView(type: type, attachments: store.attachments) { _ in - // Not handling URLs. Do not remove, cause else - // links will be opening in browser. + ZStack { + Color(.Background.primary) + .ignoresSafeArea() + + ScrollView { + VStack(alignment: .leading, spacing: 0) { + if !store.contentTypes.isEmpty { + ForEach(store.contentTypes, id: \.self) { type in + TopicView(type: type, attachments: store.attachments) { _ in + // Not handling URLs. Do not remove, cause else + // links will be opening in browser. + } } + } else if !store.isPreviewLoading { + Text("Oops, error with loading preview :(", bundle: .module) + .font(.headline) + .foregroundStyle(tintColor) + .frame(maxWidth: .infinity, alignment: .center) } - } else if !store.isPreviewLoading { - Text("Oops, error with loading preview :(", bundle: .module) - .font(.headline) - .foregroundStyle(tintColor) - .frame(maxWidth: .infinity, alignment: .center) } + .padding(16) + ._toolbarTitleDisplayMode(.inline) + .navigationTitle(Text("Preview", bundle: .module)) } - .padding(16) - ._toolbarTitleDisplayMode(.inline) - .navigationTitle(Text("Preview", bundle: .module)) } - .background(Color(.Background.primary)) .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button { - store.send(.cancelButtonTapped) + send(.cancelButtonTapped) } label: { Text("Cancel", bundle: .module) } @@ -60,8 +65,29 @@ struct FormPreviewView: View { } } .onAppear { - store.send(.onAppear) + send(.onAppear) } } } } + +// MARK: - Preview + +#Preview("Preview") { + NavigationStack { + FormPreviewView( + store: Store( + initialState: FormPreviewFeature.State( + formType: .post( + type: .new, + topicId: 0, + content: .simple("Content", []) + ) + ) + ) { + FormPreviewFeature() + } + ) + } + .environment(\.tintColor, Color(.Theme.primary)) +} From bd05ed6310c691367f55c2d755295fdde30b2648 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Mon, 2 Mar 2026 15:50:29 +0300 Subject: [PATCH 087/118] Add model for Form flag --- .../Fields/FormCheckBoxListFeature.swift | 6 ++--- .../Fields/FormDropdownFeature.swift | 6 ++--- .../Fields/FormEditorFeature.swift | 6 ++--- .../Fields/FormFieldConformable.swift | 4 ++-- .../FormFeature/Fields/FormFieldFeature.swift | 2 +- .../Fields/FormTextFieldFeature.swift | 6 ++--- .../FormFeature/Fields/FormTitleFeature.swift | 2 +- .../Fields/FormUploadBoxFeature.swift | 6 ++--- Modules/Sources/FormFeature/FormFeature.swift | 18 ++++++++------ .../FormFeature/Support/FormFlag.swift | 17 +++++++++++++ .../FormFeature/Tests/FormFeatureTests.swift | 24 +++++++++---------- 11 files changed, 59 insertions(+), 38 deletions(-) create mode 100644 Modules/Sources/FormFeature/Support/FormFlag.swift diff --git a/Modules/Sources/FormFeature/Fields/FormCheckBoxListFeature.swift b/Modules/Sources/FormFeature/Fields/FormCheckBoxListFeature.swift index c13f548e..611cd41d 100644 --- a/Modules/Sources/FormFeature/Fields/FormCheckBoxListFeature.swift +++ b/Modules/Sources/FormFeature/Fields/FormCheckBoxListFeature.swift @@ -20,7 +20,7 @@ public struct FormCheckBoxListFeature: Reducer { public let id: Int let title: String let description: String - let flag: Int + let flag: FormFlag let options: [String] var selectedOptions: [Int: Bool] @@ -29,7 +29,7 @@ public struct FormCheckBoxListFeature: Reducer { id: Int, title: String, description: String, - flag: Int, + flag: FormFlag, options: [String] ) { self.id = id @@ -138,7 +138,7 @@ struct FormCheckBoxListRow: View { id: 0, title: "Select answer", description: "This is checkbox list description...", - flag: 0, + flag: .required, options: ["Yes", "No"] ) ) { diff --git a/Modules/Sources/FormFeature/Fields/FormDropdownFeature.swift b/Modules/Sources/FormFeature/Fields/FormDropdownFeature.swift index 26adedb6..3d701056 100644 --- a/Modules/Sources/FormFeature/Fields/FormDropdownFeature.swift +++ b/Modules/Sources/FormFeature/Fields/FormDropdownFeature.swift @@ -21,7 +21,7 @@ public struct FormDropdownFeature: Reducer { public let id: Int let title: String let description: String - let flag: Int + let flag: FormFlag let options: [String] public var selectedOption: String @@ -29,7 +29,7 @@ public struct FormDropdownFeature: Reducer { id: Int, title: String, description: String, - flag: Int, + flag: FormFlag, options: [String] ) { self.id = id @@ -139,7 +139,7 @@ struct FormDropdownRow: View { id: 0, title: "Update type", description: "What do we publish?", - flag: 1, + flag: .required, options: ["New version", "Beta", "Modification", "Other"] ) ) { diff --git a/Modules/Sources/FormFeature/Fields/FormEditorFeature.swift b/Modules/Sources/FormFeature/Fields/FormEditorFeature.swift index e5817916..96935753 100644 --- a/Modules/Sources/FormFeature/Fields/FormEditorFeature.swift +++ b/Modules/Sources/FormFeature/Fields/FormEditorFeature.swift @@ -22,7 +22,7 @@ public struct FormEditorFeature: Reducer { let title: String let description: String let placeholder: String - let flag: Int + let flag: FormFlag public var text = "" public init( @@ -30,7 +30,7 @@ public struct FormEditorFeature: Reducer { title: String = "", description: String = "", placeholder: String = "", - flag: Int, + flag: FormFlag, defaultText: String = "" ) { self.id = id @@ -108,7 +108,7 @@ struct FormEditorRow: View { title: "Editor Title", description: "Editor Description", placeholder: "Editor Placeholder", - flag: 1, + flag: .required, defaultText: "Editor Default Text" ) ) { diff --git a/Modules/Sources/FormFeature/Fields/FormFieldConformable.swift b/Modules/Sources/FormFeature/Fields/FormFieldConformable.swift index 45a5acbb..99046309 100644 --- a/Modules/Sources/FormFeature/Fields/FormFieldConformable.swift +++ b/Modules/Sources/FormFeature/Fields/FormFieldConformable.swift @@ -6,7 +6,7 @@ // protocol FormFieldConformable: Identifiable { - var flag: Int { get } + var flag: FormFlag { get } var isRequired: Bool { get } func isValid() -> Bool @@ -15,6 +15,6 @@ protocol FormFieldConformable: Identifiable { extension FormFieldConformable { var isRequired: Bool { - return flag & 1 != 0 + return flag == .required } } diff --git a/Modules/Sources/FormFeature/Fields/FormFieldFeature.swift b/Modules/Sources/FormFeature/Fields/FormFieldFeature.swift index 0fa93834..d0642aca 100644 --- a/Modules/Sources/FormFeature/Fields/FormFieldFeature.swift +++ b/Modules/Sources/FormFeature/Fields/FormFieldFeature.swift @@ -15,7 +15,7 @@ public struct FormFieldFeature: Reducer { @ObservableState public enum State: Equatable, Identifiable, FormFieldConformable { - var flag: Int { return -1 } + var flag: FormFlag { return [] } case checkBoxList(FormCheckBoxListFeature.State) case dropdown(FormDropdownFeature.State) diff --git a/Modules/Sources/FormFeature/Fields/FormTextFieldFeature.swift b/Modules/Sources/FormFeature/Fields/FormTextFieldFeature.swift index 3950fe39..08ef8ecf 100644 --- a/Modules/Sources/FormFeature/Fields/FormTextFieldFeature.swift +++ b/Modules/Sources/FormFeature/Fields/FormTextFieldFeature.swift @@ -22,7 +22,7 @@ public struct FormTextFieldFeature: Reducer { let title: String let description: String let placeholder: String - let flag: Int + let flag: FormFlag let maxLength: Int? public var text = "" @@ -31,7 +31,7 @@ public struct FormTextFieldFeature: Reducer { title: String = "", description: String = "", placeholder: String = "", - flag: Int, + flag: FormFlag, defaultText: String = "", maxLength: Int? = nil ) { @@ -111,7 +111,7 @@ struct FormTextFieldRow: View { title: "TextField Title", description: "TextField Description", placeholder: "TextField Placeholder", - flag: 1, + flag: .required, defaultText: "TextField Default Text" ) ) { diff --git a/Modules/Sources/FormFeature/Fields/FormTitleFeature.swift b/Modules/Sources/FormFeature/Fields/FormTitleFeature.swift index 072e00bc..9addbe96 100644 --- a/Modules/Sources/FormFeature/Fields/FormTitleFeature.swift +++ b/Modules/Sources/FormFeature/Fields/FormTitleFeature.swift @@ -19,7 +19,7 @@ public struct FormTitleFeature: Reducer { public struct State: Equatable, FormFieldConformable { public let id: Int let text: String - let flag = 0 + let flag: FormFlag = [] public init(id: Int, text: String) { self.id = id diff --git a/Modules/Sources/FormFeature/Fields/FormUploadBoxFeature.swift b/Modules/Sources/FormFeature/Fields/FormUploadBoxFeature.swift index 7ce54ecb..05e6d2c5 100644 --- a/Modules/Sources/FormFeature/Fields/FormUploadBoxFeature.swift +++ b/Modules/Sources/FormFeature/Fields/FormUploadBoxFeature.swift @@ -23,7 +23,7 @@ public struct FormUploadBoxFeature: Reducer { public let id: Int let title: String let description: String - let flag: Int + let flag: FormFlag let allowedExtensions: [String] public var isLocked: Bool @@ -33,7 +33,7 @@ public struct FormUploadBoxFeature: Reducer { id: Int, title: String, description: String, - flag: Int, + flag: FormFlag, allowedExtensions: [String], isLocked: Bool = false ) { @@ -149,7 +149,7 @@ struct FormUploadBoxRow: View { id: 0, title: "File skin", description: "Supported formats: jpg, jpeg, gif, png", - flag: 1, + flag: .required, allowedExtensions: ["jpg", "jpeg", "gif", "png"] ) ) { diff --git a/Modules/Sources/FormFeature/FormFeature.swift b/Modules/Sources/FormFeature/FormFeature.swift index 94700b54..59fd3455 100644 --- a/Modules/Sources/FormFeature/FormFeature.swift +++ b/Modules/Sources/FormFeature/FormFeature.swift @@ -204,7 +204,11 @@ public struct FormFeature: Reducer, Sendable { switch content { case let .simple(content, _): - let editorState = FormEditorFeature.State(id: 0, flag: 1, defaultText: content) + let editorState = FormEditorFeature.State( + id: 0, + flag: [.required, .uploadable], + defaultText: content + ) state.rows.append(.editor(editorState)) state.focusedField = 0 @@ -218,7 +222,7 @@ public struct FormFeature: Reducer, Sendable { return .send(.internal(.loadForm(id: forumId, isTopic: true))) case .report: - let editorState = FormEditorFeature.State(id: 0, flag: 1) + let editorState = FormEditorFeature.State(id: 0, flag: .required) state.rows.append(.editor(editorState)) state.focusedField = 0 } @@ -288,7 +292,7 @@ public struct FormFeature: Reducer, Sendable { title: content.name, description: content.description, placeholder: content.example, - flag: content.flag, + flag: FormFlag(rawValue: content.flag), defaultText: content.defaultValue, maxLength: maxLength ) @@ -300,7 +304,7 @@ public struct FormFeature: Reducer, Sendable { title: content.name, description: content.description, placeholder: content.example, - flag: content.flag, + flag: FormFlag(rawValue: content.flag), defaultText: content.defaultValue ) state.rows.append(.editor(editorState)) @@ -310,7 +314,7 @@ public struct FormFeature: Reducer, Sendable { id: index, title: content.name, description: content.description, - flag: content.flag, + flag: FormFlag(rawValue: content.flag), options: options ) state.rows.append(.checkBoxList(checkboxListState)) @@ -320,7 +324,7 @@ public struct FormFeature: Reducer, Sendable { id: index, title: content.name, description: content.description, - flag: content.flag, + flag: FormFlag(rawValue: content.flag), options: options ) state.rows.append(.dropdown(dropdownState)) @@ -330,7 +334,7 @@ public struct FormFeature: Reducer, Sendable { id: index, title: content.name, description: content.description, - flag: content.flag, + flag: FormFlag(rawValue: content.flag), allowedExtensions: extensions ) state.rows.append(.uploadBox(uploadboxState)) diff --git a/Modules/Sources/FormFeature/Support/FormFlag.swift b/Modules/Sources/FormFeature/Support/FormFlag.swift new file mode 100644 index 00000000..8111a471 --- /dev/null +++ b/Modules/Sources/FormFeature/Support/FormFlag.swift @@ -0,0 +1,17 @@ +// +// FormFlag.swift +// ForPDA +// +// Created by Xialtal on 28.02.26. +// + +public struct FormFlag: OptionSet, Sendable { + public var rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } + + public static let required = FormFlag(rawValue: 1 << 0) + public static let uploadable = FormFlag(rawValue: 1 << 1) +} diff --git a/Modules/Sources/FormFeature/Tests/FormFeatureTests.swift b/Modules/Sources/FormFeature/Tests/FormFeatureTests.swift index e19c2160..6db221d6 100644 --- a/Modules/Sources/FormFeature/Tests/FormFeatureTests.swift +++ b/Modules/Sources/FormFeature/Tests/FormFeatureTests.swift @@ -28,7 +28,7 @@ struct FormFeatureTests { } } - var editorState = FormEditorFeature.State(id: 0, flag: 1) + var editorState = FormEditorFeature.State(id: 0, flag: .required) await store.send(.view(.onAppear)) { $0.rows = [.editor(editorState)] $0.focusedField = 0 @@ -67,7 +67,7 @@ struct FormFeatureTests { } } - var editorState = FormEditorFeature.State(id: 0, flag: 1) + var editorState = FormEditorFeature.State(id: 0, flag: .required) await store.send(.view(.onAppear)) { $0.rows = [.editor(editorState)] $0.focusedField = 0 @@ -113,7 +113,7 @@ struct FormFeatureTests { } } - var editorState = FormEditorFeature.State(id: 0, flag: 1, defaultText: "") + var editorState = FormEditorFeature.State(id: 0, flag: .required, defaultText: "") await store.send(.view(.onAppear)) { $0.rows = [.editor(editorState)] $0.focusedField = 0 @@ -158,7 +158,7 @@ struct FormFeatureTests { } } - var editorState = FormEditorFeature.State(id: 0, flag: 1, defaultText: "") + var editorState = FormEditorFeature.State(id: 0, flag: .required, defaultText: "") await store.send(.view(.onAppear)) { $0.rows = [.editor(editorState)] $0.focusedField = 0 @@ -208,7 +208,7 @@ struct FormFeatureTests { } } - var editorState = FormEditorFeature.State(id: 0, flag: 1, defaultText: "") + var editorState = FormEditorFeature.State(id: 0, flag: .required, defaultText: "") await store.send(.view(.onAppear)) { $0.rows = [.editor(editorState)] $0.focusedField = 0 @@ -266,7 +266,7 @@ struct FormFeatureTests { } } - var editorState = FormEditorFeature.State(id: 0, flag: 1, defaultText: "") + var editorState = FormEditorFeature.State(id: 0, flag: .required, defaultText: "") await store.send(.view(.onAppear)) { $0.rows = [.editor(editorState)] $0.focusedField = 0 @@ -312,7 +312,7 @@ struct FormFeatureTests { } } - let editorState = FormEditorFeature.State(id: 0, flag: 1, defaultText: "some text") + let editorState = FormEditorFeature.State(id: 0, flag: .required, defaultText: "some text") await store.send(.view(.onAppear)) { $0.rows = [.editor(editorState)] $0.focusedField = 0 @@ -365,7 +365,7 @@ struct FormFeatureTests { id: 2, title: "Тип обновления", description: "Что публикуем?", - flag: 1, + flag: .required, options: [ "Новая версия", "Beta", @@ -378,7 +378,7 @@ struct FormFeatureTests { title: "Версия", description: "Укажите версию. Например: 1.3.7", placeholder: "", - flag: 1, + flag: .required, defaultText: "", maxLength: 255 ) @@ -387,7 +387,7 @@ struct FormFeatureTests { title: "Краткое описание", description: "Здесь можно указать: [I][U]источник, дату публикации, архитектуру, авторство, номер сборки, тип модификации[/U][/I] и так далее.\r\n[COLOR=red][I]Не повторяйте тут версию или название программы! Здесь запрещены ВВ-коды и ссылки.[/I][/COLOR]\r\nПример 1: Для ARM64 от 01/02/2022 из F-Droid\r\nПример 2: AdFree от ModMaker", placeholder: "", - flag: 1, + flag: .required, defaultText: "" ) var editor = FormEditorFeature.State( @@ -395,14 +395,14 @@ struct FormFeatureTests { title: "Описание", description: "Введите дополнительную полезную информацию, например для:\r\n[b]\"Новая версия\"[/b] - список \"что нового\".\r\n[b]\"Модификация\"[/b] - \"на чем основано\", \"особенности\", \"обновлено\". ", placeholder: "", - flag: 3, + flag: [.required, .uploadable], defaultText: "" ) var uploadbox = FormUploadBoxFeature.State( id: 6, title: "Файлы", description: "", - flag: 3, + flag: [.required, .uploadable], allowedExtensions: ["apk", "apks", "exe", "zip", "rar", "obb", "7z", "r00", "r01", "apkm", "ipa"] ) From 60080d358804a31c917bda0a5aa8cc2871a6672a Mon Sep 17 00:00:00 2001 From: Xialtal Date: Mon, 2 Mar 2026 16:40:49 +0300 Subject: [PATCH 088/118] Fix flag check for required field --- Modules/Sources/FormFeature/Fields/FormFieldConformable.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/FormFeature/Fields/FormFieldConformable.swift b/Modules/Sources/FormFeature/Fields/FormFieldConformable.swift index 99046309..2eea9dd1 100644 --- a/Modules/Sources/FormFeature/Fields/FormFieldConformable.swift +++ b/Modules/Sources/FormFeature/Fields/FormFieldConformable.swift @@ -15,6 +15,6 @@ protocol FormFieldConformable: Identifiable { extension FormFieldConformable { var isRequired: Bool { - return flag == .required + return flag.contains(.required) } } From 1b9b13ad430fe926f20689fb0d1baff537dbd198 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Mon, 2 Mar 2026 17:01:39 +0300 Subject: [PATCH 089/118] Use FormFlag as global model for Form --- .../Fields/FormCheckBoxListFeature.swift | 1 + .../Fields/FormDropdownFeature.swift | 1 + .../Fields/FormEditorFeature.swift | 1 + .../Fields/FormFieldConformable.swift | 2 ++ .../FormFeature/Fields/FormFieldFeature.swift | 1 + .../Fields/FormTextFieldFeature.swift | 1 + .../FormFeature/Fields/FormTitleFeature.swift | 1 + .../Fields/FormUploadBoxFeature.swift | 1 + Modules/Sources/FormFeature/FormFeature.swift | 11 +++---- .../{Common => Form}/FormFieldType.swift | 30 +++++++------------ .../Support => Models/Form}/FormFlag.swift | 2 +- .../Models/{Common => Form}/FormSend.swift | 0 .../ParsingClient/Parsers/FormParser.swift | 2 +- 13 files changed, 28 insertions(+), 26 deletions(-) rename Modules/Sources/Models/{Common => Form}/FormFieldType.swift (92%) rename Modules/Sources/{FormFeature/Support => Models/Form}/FormFlag.swift (82%) rename Modules/Sources/Models/{Common => Form}/FormSend.swift (100%) diff --git a/Modules/Sources/FormFeature/Fields/FormCheckBoxListFeature.swift b/Modules/Sources/FormFeature/Fields/FormCheckBoxListFeature.swift index 611cd41d..c8a19dfe 100644 --- a/Modules/Sources/FormFeature/Fields/FormCheckBoxListFeature.swift +++ b/Modules/Sources/FormFeature/Fields/FormCheckBoxListFeature.swift @@ -7,6 +7,7 @@ import SwiftUI import ComposableArchitecture +import Models // MARK: - Feature diff --git a/Modules/Sources/FormFeature/Fields/FormDropdownFeature.swift b/Modules/Sources/FormFeature/Fields/FormDropdownFeature.swift index 3d701056..4698b22c 100644 --- a/Modules/Sources/FormFeature/Fields/FormDropdownFeature.swift +++ b/Modules/Sources/FormFeature/Fields/FormDropdownFeature.swift @@ -8,6 +8,7 @@ import SwiftUI import ComposableArchitecture import SharedUI +import Models // MARK: - Feature diff --git a/Modules/Sources/FormFeature/Fields/FormEditorFeature.swift b/Modules/Sources/FormFeature/Fields/FormEditorFeature.swift index 96935753..19927101 100644 --- a/Modules/Sources/FormFeature/Fields/FormEditorFeature.swift +++ b/Modules/Sources/FormFeature/Fields/FormEditorFeature.swift @@ -8,6 +8,7 @@ import SwiftUI import ComposableArchitecture import SharedUI +import Models // MARK: - Feature diff --git a/Modules/Sources/FormFeature/Fields/FormFieldConformable.swift b/Modules/Sources/FormFeature/Fields/FormFieldConformable.swift index 2eea9dd1..67758c50 100644 --- a/Modules/Sources/FormFeature/Fields/FormFieldConformable.swift +++ b/Modules/Sources/FormFeature/Fields/FormFieldConformable.swift @@ -5,6 +5,8 @@ // Created by Ilia Lubianoi on 20.07.2025. // +import Models + protocol FormFieldConformable: Identifiable { var flag: FormFlag { get } var isRequired: Bool { get } diff --git a/Modules/Sources/FormFeature/Fields/FormFieldFeature.swift b/Modules/Sources/FormFeature/Fields/FormFieldFeature.swift index d0642aca..8446f30f 100644 --- a/Modules/Sources/FormFeature/Fields/FormFieldFeature.swift +++ b/Modules/Sources/FormFeature/Fields/FormFieldFeature.swift @@ -7,6 +7,7 @@ import SwiftUI import ComposableArchitecture +import Models @Reducer public struct FormFieldFeature: Reducer { diff --git a/Modules/Sources/FormFeature/Fields/FormTextFieldFeature.swift b/Modules/Sources/FormFeature/Fields/FormTextFieldFeature.swift index 08ef8ecf..408b5144 100644 --- a/Modules/Sources/FormFeature/Fields/FormTextFieldFeature.swift +++ b/Modules/Sources/FormFeature/Fields/FormTextFieldFeature.swift @@ -8,6 +8,7 @@ import SwiftUI import ComposableArchitecture import SharedUI +import Models // MARK: - Feature diff --git a/Modules/Sources/FormFeature/Fields/FormTitleFeature.swift b/Modules/Sources/FormFeature/Fields/FormTitleFeature.swift index 9addbe96..c2f60f93 100644 --- a/Modules/Sources/FormFeature/Fields/FormTitleFeature.swift +++ b/Modules/Sources/FormFeature/Fields/FormTitleFeature.swift @@ -7,6 +7,7 @@ import SwiftUI import ComposableArchitecture +import Models // MARK: - Feature diff --git a/Modules/Sources/FormFeature/Fields/FormUploadBoxFeature.swift b/Modules/Sources/FormFeature/Fields/FormUploadBoxFeature.swift index 05e6d2c5..61abafcf 100644 --- a/Modules/Sources/FormFeature/Fields/FormUploadBoxFeature.swift +++ b/Modules/Sources/FormFeature/Fields/FormUploadBoxFeature.swift @@ -8,6 +8,7 @@ import SwiftUI import ComposableArchitecture import UploadBoxFeature +import Models // MARK: - Feature diff --git a/Modules/Sources/FormFeature/FormFeature.swift b/Modules/Sources/FormFeature/FormFeature.swift index 59fd3455..0b087526 100644 --- a/Modules/Sources/FormFeature/FormFeature.swift +++ b/Modules/Sources/FormFeature/FormFeature.swift @@ -292,7 +292,7 @@ public struct FormFeature: Reducer, Sendable { title: content.name, description: content.description, placeholder: content.example, - flag: FormFlag(rawValue: content.flag), + flag: content.flag, defaultText: content.defaultValue, maxLength: maxLength ) @@ -304,7 +304,7 @@ public struct FormFeature: Reducer, Sendable { title: content.name, description: content.description, placeholder: content.example, - flag: FormFlag(rawValue: content.flag), + flag: content.flag, defaultText: content.defaultValue ) state.rows.append(.editor(editorState)) @@ -314,7 +314,7 @@ public struct FormFeature: Reducer, Sendable { id: index, title: content.name, description: content.description, - flag: FormFlag(rawValue: content.flag), + flag: content.flag, options: options ) state.rows.append(.checkBoxList(checkboxListState)) @@ -324,7 +324,7 @@ public struct FormFeature: Reducer, Sendable { id: index, title: content.name, description: content.description, - flag: FormFlag(rawValue: content.flag), + flag: content.flag, options: options ) state.rows.append(.dropdown(dropdownState)) @@ -334,12 +334,13 @@ public struct FormFeature: Reducer, Sendable { id: index, title: content.name, description: content.description, - flag: FormFlag(rawValue: content.flag), + flag: content.flag, allowedExtensions: extensions ) state.rows.append(.uploadBox(uploadboxState)) } } + state.isFormLoading = false case let .internal(.formResponse(.failure(error))): print(error) diff --git a/Modules/Sources/Models/Common/FormFieldType.swift b/Modules/Sources/Models/Form/FormFieldType.swift similarity index 92% rename from Modules/Sources/Models/Common/FormFieldType.swift rename to Modules/Sources/Models/Form/FormFieldType.swift index 589c46c2..8200f094 100644 --- a/Modules/Sources/Models/Common/FormFieldType.swift +++ b/Modules/Sources/Models/Form/FormFieldType.swift @@ -18,23 +18,15 @@ public enum FormFieldType: Sendable, Equatable, Hashable { public let name: String public let description: String public let example: String - public let flag: Int + public let flag: FormFlag public let defaultValue: String - public var isRequired: Bool { - return flag & 1 != 0 - } - - public var isVisible: Bool { - return flag & 2 != 0 - } - public init( id: Int, name: String, description: String, example: String, - flag: Int, + flag: FormFlag, defaultValue: String ) { self.id = id @@ -60,7 +52,7 @@ public extension FormFieldType { name: "Topic name", description: "Enter topic name", example: "Starting from For, ends with PDA", - flag: 1, + flag: .required, defaultValue: "" ), maxLenght: 255 @@ -72,7 +64,7 @@ public extension FormFieldType { name: "Topic content", description: "This [B]field[/B] contains topic [color=red]hat[/color] content", example: "ForPDA Forever!", - flag: 1, + flag: .required, defaultValue: "" ) ) @@ -83,7 +75,7 @@ public extension FormFieldType { name: "", description: "", example: "Post text...", - flag: 0, + flag: [], defaultValue: "" ) ) @@ -94,7 +86,7 @@ public extension FormFieldType { name: "Device photos", description: "Upload device photos. Allowed formats JPG, GIF, PNG", example: "", - flag: 1, + flag: .required, defaultValue: "" ), ["jpg", "gif", "png"] @@ -111,7 +103,7 @@ extension Array where Element == FormFieldType { name: "Тип обновления", description: "Что публикуем?", example: "", - flag: 1, + flag: .required, defaultValue: "" ), [ @@ -127,7 +119,7 @@ extension Array where Element == FormFieldType { name: "Версия", description: "Укажите версию. Например: 1.3.7", example: "", - flag: 1, + flag: .required, defaultValue: "" ), maxLenght: 255 @@ -138,7 +130,7 @@ extension Array where Element == FormFieldType { name: "Краткое описание", description: "Здесь можно указать: [I][U]источник, дату публикации, архитектуру, авторство, номер сборки, тип модификации[/U][/I] и так далее.\r\n[COLOR=red][I]Не повторяйте тут версию или название программы! Здесь запрещены ВВ-коды и ссылки.[/I][/COLOR]\r\nПример 1: Для ARM64 от 01/02/2022 из F-Droid\r\nПример 2: AdFree от ModMaker", example: "", - flag: 1, + flag: .required, defaultValue: "" ), maxLenght: nil @@ -149,7 +141,7 @@ extension Array where Element == FormFieldType { name: "Описание", description: "Введите дополнительную полезную информацию, например для:\r\n[b]\"Новая версия\"[/b] - список \"что нового\".\r\n[b]\"Модификация\"[/b] - \"на чем основано\", \"особенности\", \"обновлено\". ", example: "", - flag: 3, + flag: [.required, .uploadable], defaultValue: "" ) ), @@ -159,7 +151,7 @@ extension Array where Element == FormFieldType { name: "Файлы", description: "", example: "", - flag: 3, + flag: [.required, .uploadable], defaultValue: "" ), [ diff --git a/Modules/Sources/FormFeature/Support/FormFlag.swift b/Modules/Sources/Models/Form/FormFlag.swift similarity index 82% rename from Modules/Sources/FormFeature/Support/FormFlag.swift rename to Modules/Sources/Models/Form/FormFlag.swift index 8111a471..c0d8f931 100644 --- a/Modules/Sources/FormFeature/Support/FormFlag.swift +++ b/Modules/Sources/Models/Form/FormFlag.swift @@ -5,7 +5,7 @@ // Created by Xialtal on 28.02.26. // -public struct FormFlag: OptionSet, Sendable { +public struct FormFlag: OptionSet, Sendable, Equatable, Hashable { public var rawValue: Int public init(rawValue: Int) { diff --git a/Modules/Sources/Models/Common/FormSend.swift b/Modules/Sources/Models/Form/FormSend.swift similarity index 100% rename from Modules/Sources/Models/Common/FormSend.swift rename to Modules/Sources/Models/Form/FormSend.swift diff --git a/Modules/Sources/ParsingClient/Parsers/FormParser.swift b/Modules/Sources/ParsingClient/Parsers/FormParser.swift index 2c32116b..70bca3cc 100644 --- a/Modules/Sources/ParsingClient/Parsers/FormParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/FormParser.swift @@ -107,7 +107,7 @@ public struct FormParser { name: name, description: description, example: example, - flag: flag, + flag: FormFlag(rawValue: flag), defaultValue: defaultValue ) From e0d3f8494b105aa46cec044c6248adf6fd98cd2d Mon Sep 17 00:00:00 2001 From: Xialtal Date: Mon, 2 Mar 2026 19:13:22 +0300 Subject: [PATCH 090/118] Fix perception tracking in Form preview --- Modules/Sources/FormFeature/Preview/FormPreviewView.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Modules/Sources/FormFeature/Preview/FormPreviewView.swift b/Modules/Sources/FormFeature/Preview/FormPreviewView.swift index ee62381e..b3fda022 100644 --- a/Modules/Sources/FormFeature/Preview/FormPreviewView.swift +++ b/Modules/Sources/FormFeature/Preview/FormPreviewView.swift @@ -32,9 +32,11 @@ struct FormPreviewView: View { VStack(alignment: .leading, spacing: 0) { if !store.contentTypes.isEmpty { ForEach(store.contentTypes, id: \.self) { type in - TopicView(type: type, attachments: store.attachments) { _ in - // Not handling URLs. Do not remove, cause else - // links will be opening in browser. + WithPerceptionTracking { + TopicView(type: type, attachments: store.attachments) { _ in + // Not handling URLs. Do not remove, cause else + // links will be opening in browser. + } } } } else if !store.isPreviewLoading { From 1f687d77cf81292e12f45c2e8c822c830f5c22a8 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 7 Mar 2026 22:37:10 +0300 Subject: [PATCH 091/118] [WIP] Form Editor with upload --- .../Fields/FormEditorFeature.swift | 15 +++- .../Fields/FormUploadBoxFeature.swift | 34 ++++---- Modules/Sources/FormFeature/FormFeature.swift | 84 +++++++++++++++---- .../Support/FormStickedUploadBox.swift | 16 ++++ .../FormFeature/Support/FormValue.swift | 10 +++ .../FormFeature/Tests/FormFeatureTests.swift | 3 +- 6 files changed, 129 insertions(+), 33 deletions(-) create mode 100644 Modules/Sources/FormFeature/Support/FormStickedUploadBox.swift diff --git a/Modules/Sources/FormFeature/Fields/FormEditorFeature.swift b/Modules/Sources/FormFeature/Fields/FormEditorFeature.swift index 19927101..cdab37b6 100644 --- a/Modules/Sources/FormFeature/Fields/FormEditorFeature.swift +++ b/Modules/Sources/FormFeature/Fields/FormEditorFeature.swift @@ -24,6 +24,7 @@ public struct FormEditorFeature: Reducer { let description: String let placeholder: String let flag: FormFlag + let uploadBox: FormStickedUploadBox? public var text = "" public init( @@ -32,7 +33,8 @@ public struct FormEditorFeature: Reducer { description: String = "", placeholder: String = "", flag: FormFlag, - defaultText: String = "" + defaultText: String = "", + uploadBox: FormStickedUploadBox? = nil ) { self.id = id self.title = title @@ -40,12 +42,23 @@ public struct FormEditorFeature: Reducer { self.placeholder = placeholder self.flag = flag self.text = defaultText + self.uploadBox = uploadBox } func getValue() -> FormValue { return .string(text) } + func getAttachments() -> [Int] { + var attachments: [Int] = [] + for file in bbPanel.existsFiles { + if let serverId = file.serverId { + attachments.append(serverId) + } + } + return attachments + } + func isValid() -> Bool { return isRequired ? !text.isEmpty : true } diff --git a/Modules/Sources/FormFeature/Fields/FormUploadBoxFeature.swift b/Modules/Sources/FormFeature/Fields/FormUploadBoxFeature.swift index 61abafcf..0290d79a 100644 --- a/Modules/Sources/FormFeature/Fields/FormUploadBoxFeature.swift +++ b/Modules/Sources/FormFeature/Fields/FormUploadBoxFeature.swift @@ -26,6 +26,7 @@ public struct FormUploadBoxFeature: Reducer { let description: String let flag: FormFlag let allowedExtensions: [String] + let isHidden: Bool public var isLocked: Bool var uploadedFilesIds: [Int] = [] @@ -36,6 +37,7 @@ public struct FormUploadBoxFeature: Reducer { description: String, flag: FormFlag, allowedExtensions: [String], + isHidden: Bool, isLocked: Bool = false ) { self.id = id @@ -43,6 +45,7 @@ public struct FormUploadBoxFeature: Reducer { self.description = description self.flag = flag self.allowedExtensions = allowedExtensions + self.isHidden = isHidden self.isLocked = isLocked } @@ -121,21 +124,23 @@ struct FormUploadBoxRow: View { var body: some View { WithPerceptionTracking { - VStack(spacing: 6) { - FieldSection( - title: store.title, - description: store.description, - required: store.isRequired - ) { - WithPerceptionTracking { - UploadBoxView(store: store.scope(state: \.upload, action: \.upload)) + if !store.isHidden { + VStack(spacing: 6) { + FieldSection( + title: store.title, + description: store.description, + required: store.isRequired + ) { + WithPerceptionTracking { + UploadBoxView(store: store.scope(state: \.upload, action: \.upload)) + } } } - } - .tint(tintColor) - .disabled(store.isLocked) - .onAppear { - send(.onAppear) + .tint(tintColor) + .disabled(store.isLocked) + .onAppear { + send(.onAppear) + } } } } @@ -151,7 +156,8 @@ struct FormUploadBoxRow: View { title: "File skin", description: "Supported formats: jpg, jpeg, gif, png", flag: .required, - allowedExtensions: ["jpg", "jpeg", "gif", "png"] + allowedExtensions: ["jpg", "jpeg", "gif", "png"], + isHidden: false ) ) { FormUploadBoxFeature() diff --git a/Modules/Sources/FormFeature/FormFeature.swift b/Modules/Sources/FormFeature/FormFeature.swift index 0b087526..5911ae49 100644 --- a/Modules/Sources/FormFeature/FormFeature.swift +++ b/Modules/Sources/FormFeature/FormFeature.swift @@ -77,10 +77,28 @@ public struct FormFeature: Reducer, Sendable { } var content: [FormValue] { - if rows.count == 1, case let .editor(editorState) = rows.first { - return [.string(editorState.text)] + if case let .editor(editorState) = rows.first { + if rows.count == 1 { // report + return [.string(editorState.text)] + } else if rows.count == 2 { // simple post + let attachments = editorState.getAttachments() + return [.string(editorState.text), .array(attachments.map { .integer($0) })] + } else { + fatalError("Incorrect data? \(rows)") + } } else { - return rows.map { $0.getValue() } + var content: [FormValue] = [] + var combinedAttachments: [Int] = [] + for row in rows { + if case let .editor(state) = row, state.uploadBox != nil { + combinedAttachments = state.getAttachments() + } else if case let .uploadBox(state) = row, state.isHidden { + content.append(.array(combinedAttachments.map { .integer($0) })) + } else { + content.append(row.getValue()) + } + } + return content } } @@ -207,9 +225,19 @@ public struct FormFeature: Reducer, Sendable { let editorState = FormEditorFeature.State( id: 0, flag: [.required, .uploadable], - defaultText: content + defaultText: content, + uploadBox: .init(id: 1, allowedExtensions: []) + ) + let uploadBoxState = FormUploadBoxFeature.State( + id: 1, + title: "", + description: "", + flag: [.uploadable], + allowedExtensions: [], + isHidden: true ) state.rows.append(.editor(editorState)) + state.rows.append(.uploadBox(uploadBoxState)) state.focusedField = 0 case .template: @@ -222,7 +250,7 @@ public struct FormFeature: Reducer, Sendable { return .send(.internal(.loadForm(id: forumId, isTopic: true))) case .report: - let editorState = FormEditorFeature.State(id: 0, flag: .required) + let editorState = FormEditorFeature.State(id: 0, flag: .required, uploadBox: nil) state.rows.append(.editor(editorState)) state.focusedField = 0 } @@ -234,11 +262,15 @@ public struct FormFeature: Reducer, Sendable { let previewState: FormPreviewFeature.State switch state.type { case let .post(type: type, topicId: topicId, content: content): - let content = if case .simple(_, let attachments) = content { - if case let .string(text) = state.content.first { - FormType.PostContentType.simple(text, attachments) + let content = if case .simple = content { + if case let .string(text) = state.content.first, + case let .array(attachments) = state.content.last { + FormType.PostContentType.simple( + text, + FormValue.getIntArray(attachments).map { .init(id: $0, name: "", type: .file)} + ) } else { - fatalError("Simple content SHOULD be .string()!") + fatalError("Bad simple post content! \(state.content)") } } else { FormType.PostContentType.template(state.content) @@ -250,7 +282,7 @@ public struct FormFeature: Reducer, Sendable { case .report: let content = if case let .string(text) = state.content.first { text } else { - fatalError("Simple content SHOULD be .string()!") + fatalError("Report content field should contains only one .string()!") } previewState = FormPreviewFeature.State( formType: .post(type: .new, topicId: 0, content: .simple(content, [])) @@ -277,8 +309,20 @@ public struct FormFeature: Reducer, Sendable { } case let .internal(.formResponse(.success(fields))): - //print(fields) - state.isFormLoading = false + var combined: (editorId: Int, uploadBox: FormStickedUploadBox?)? = nil + for (index, field) in fields.enumerated() { + if case let .editor(content) = field, content.flag == [.required, .uploadable] { + combined = (index, nil) + } else if case let .uploadbox(content, extensions) = field { + if content.flag == [.required, .uploadable] { + combined = (combined!.editorId, .init(id: index, allowedExtensions: extensions)) + } else if let editorId = combined?.editorId, index - 1 == editorId { + // if previous field is editor, that means editor supports upload + combined = (combined!.editorId, .init(id: index, allowedExtensions: extensions)) + } + } + } + for (index, field) in fields.enumerated() { switch field { case let .title(content): @@ -305,7 +349,8 @@ public struct FormFeature: Reducer, Sendable { description: content.description, placeholder: content.example, flag: content.flag, - defaultText: content.defaultValue + defaultText: content.defaultValue, + uploadBox: index == combined?.editorId ? combined?.uploadBox : nil ) state.rows.append(.editor(editorState)) @@ -335,7 +380,8 @@ public struct FormFeature: Reducer, Sendable { title: content.name, description: content.description, flag: content.flag, - allowedExtensions: extensions + allowedExtensions: extensions, + isHidden: index == combined?.uploadBox?.id ) state.rows.append(.uploadBox(uploadboxState)) } @@ -357,12 +403,16 @@ public struct FormFeature: Reducer, Sendable { await send(.internal(.templateResponse(result))) } - case let .post(type: type, topicId: topicId, content: .simple(_, attachments)): + case let .post(type: type, topicId: topicId, content: .simple): let editPostFlag = state.isShowMarkEnabled ? 4 : 0 let content = if case let .string(text) = state.content.first { text } else { - fatalError("Simple content SHOULD be .string()!") + fatalError("Bad simple post content: \(state.content)") + } + let attachments = if case let .array(attachments) = state.content.last { + FormValue.getIntArray(attachments) + } else { + fatalError("Bad simple post attachments: \(state.content)") } - let attachments = attachments.map { $0.id } return .run { [ content = content, reason = state.editReasonText diff --git a/Modules/Sources/FormFeature/Support/FormStickedUploadBox.swift b/Modules/Sources/FormFeature/Support/FormStickedUploadBox.swift new file mode 100644 index 00000000..f0a84a75 --- /dev/null +++ b/Modules/Sources/FormFeature/Support/FormStickedUploadBox.swift @@ -0,0 +1,16 @@ +// +// FormStickedUploadBox.swift +// ForPDA +// +// Created by Xialtal on 2.03.26. +// + +public struct FormStickedUploadBox: Equatable { + public let id: Int + public let allowedExtensions: [String] + + public init(id: Int, allowedExtensions: [String]) { + self.id = id + self.allowedExtensions = allowedExtensions + } +} diff --git a/Modules/Sources/FormFeature/Support/FormValue.swift b/Modules/Sources/FormFeature/Support/FormValue.swift index 1897c55c..13e4fb44 100644 --- a/Modules/Sources/FormFeature/Support/FormValue.swift +++ b/Modules/Sources/FormFeature/Support/FormValue.swift @@ -22,6 +22,16 @@ extension FormValue { } return document } + + static func getIntArray(_ values: [FormValue]) -> [Int] { + var array: [Int] = [] + for value in values { + if case let .integer(int) = value { + array.append(int) + } + } + return array + } } private extension PDAPIDocument { diff --git a/Modules/Sources/FormFeature/Tests/FormFeatureTests.swift b/Modules/Sources/FormFeature/Tests/FormFeatureTests.swift index 6db221d6..e335bb48 100644 --- a/Modules/Sources/FormFeature/Tests/FormFeatureTests.swift +++ b/Modules/Sources/FormFeature/Tests/FormFeatureTests.swift @@ -403,7 +403,8 @@ struct FormFeatureTests { title: "Файлы", description: "", flag: [.required, .uploadable], - allowedExtensions: ["apk", "apks", "exe", "zip", "rar", "obb", "7z", "r00", "r01", "apkm", "ipa"] + allowedExtensions: ["apk", "apks", "exe", "zip", "rar", "obb", "7z", "r00", "r01", "apkm", "ipa"], + isHidden: true ) await store.send(.view(.onAppear)) { From 90879cd09921befdc93e58997ddad8d034cc8d4d Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 7 Mar 2026 22:37:44 +0300 Subject: [PATCH 092/118] UploadBox improvements --- Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift b/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift index 9f18eb04..b62b9a82 100644 --- a/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift +++ b/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift @@ -8,7 +8,6 @@ import SwiftUI import ComposableArchitecture import APIClient -import CryptoKit @Reducer public struct UploadBoxFeature: Reducer, Sendable { @@ -63,7 +62,7 @@ public struct UploadBoxFeature: Reducer, Sendable { let type: UploadBoxType public var allowedExtensions: [String] - var files: [UploadBoxFile] + public var files: [UploadBoxFile] var uploadQueue: [UploadBoxFile.FileSource] = [] var isAnyFileUploading = false @@ -360,11 +359,7 @@ public struct UploadBoxFeature: Reducer, Sendable { return } - let request = UploadRequest( - fileName: file.name, - fileData: data, - isQms: false - ) + let request = UploadRequest(fileName: file.name, fileData: data, isQms: false) await send(.delegate(.someFileUploading)) From ff33131934a02c7d0dfe32b02a843de3d54f4433 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 7 Mar 2026 22:53:15 +0300 Subject: [PATCH 093/118] Rename FormFlag to FormFieldFlag --- .../Fields/FormCheckBoxListFeature.swift | 4 ++-- .../Fields/FormDropdownFeature.swift | 4 ++-- .../FormFeature/Fields/FormEditorFeature.swift | 4 ++-- .../Fields/FormFieldConformable.swift | 2 +- .../FormFeature/Fields/FormFieldFeature.swift | 2 +- .../Fields/FormTextFieldFeature.swift | 4 ++-- .../FormFeature/Fields/FormTitleFeature.swift | 2 +- .../Fields/FormUploadBoxFeature.swift | 4 ++-- Modules/Sources/Models/Form/FormFieldFlag.swift | 17 +++++++++++++++++ Modules/Sources/Models/Form/FormFieldType.swift | 4 ++-- Modules/Sources/Models/Form/FormFlag.swift | 17 ----------------- .../ParsingClient/Parsers/FormParser.swift | 2 +- 12 files changed, 33 insertions(+), 33 deletions(-) create mode 100644 Modules/Sources/Models/Form/FormFieldFlag.swift delete mode 100644 Modules/Sources/Models/Form/FormFlag.swift diff --git a/Modules/Sources/FormFeature/Fields/FormCheckBoxListFeature.swift b/Modules/Sources/FormFeature/Fields/FormCheckBoxListFeature.swift index c8a19dfe..9f4ac3b4 100644 --- a/Modules/Sources/FormFeature/Fields/FormCheckBoxListFeature.swift +++ b/Modules/Sources/FormFeature/Fields/FormCheckBoxListFeature.swift @@ -21,7 +21,7 @@ public struct FormCheckBoxListFeature: Reducer { public let id: Int let title: String let description: String - let flag: FormFlag + let flag: FormFieldFlag let options: [String] var selectedOptions: [Int: Bool] @@ -30,7 +30,7 @@ public struct FormCheckBoxListFeature: Reducer { id: Int, title: String, description: String, - flag: FormFlag, + flag: FormFieldFlag, options: [String] ) { self.id = id diff --git a/Modules/Sources/FormFeature/Fields/FormDropdownFeature.swift b/Modules/Sources/FormFeature/Fields/FormDropdownFeature.swift index 4698b22c..0b4a8b74 100644 --- a/Modules/Sources/FormFeature/Fields/FormDropdownFeature.swift +++ b/Modules/Sources/FormFeature/Fields/FormDropdownFeature.swift @@ -22,7 +22,7 @@ public struct FormDropdownFeature: Reducer { public let id: Int let title: String let description: String - let flag: FormFlag + let flag: FormFieldFlag let options: [String] public var selectedOption: String @@ -30,7 +30,7 @@ public struct FormDropdownFeature: Reducer { id: Int, title: String, description: String, - flag: FormFlag, + flag: FormFieldFlag, options: [String] ) { self.id = id diff --git a/Modules/Sources/FormFeature/Fields/FormEditorFeature.swift b/Modules/Sources/FormFeature/Fields/FormEditorFeature.swift index cdab37b6..e356ce74 100644 --- a/Modules/Sources/FormFeature/Fields/FormEditorFeature.swift +++ b/Modules/Sources/FormFeature/Fields/FormEditorFeature.swift @@ -23,7 +23,7 @@ public struct FormEditorFeature: Reducer { let title: String let description: String let placeholder: String - let flag: FormFlag + let flag: FormFieldFlag let uploadBox: FormStickedUploadBox? public var text = "" @@ -32,7 +32,7 @@ public struct FormEditorFeature: Reducer { title: String = "", description: String = "", placeholder: String = "", - flag: FormFlag, + flag: FormFieldFlag, defaultText: String = "", uploadBox: FormStickedUploadBox? = nil ) { diff --git a/Modules/Sources/FormFeature/Fields/FormFieldConformable.swift b/Modules/Sources/FormFeature/Fields/FormFieldConformable.swift index 67758c50..ec1df85b 100644 --- a/Modules/Sources/FormFeature/Fields/FormFieldConformable.swift +++ b/Modules/Sources/FormFeature/Fields/FormFieldConformable.swift @@ -8,7 +8,7 @@ import Models protocol FormFieldConformable: Identifiable { - var flag: FormFlag { get } + var flag: FormFieldFlag { get } var isRequired: Bool { get } func isValid() -> Bool diff --git a/Modules/Sources/FormFeature/Fields/FormFieldFeature.swift b/Modules/Sources/FormFeature/Fields/FormFieldFeature.swift index 8446f30f..162d83a0 100644 --- a/Modules/Sources/FormFeature/Fields/FormFieldFeature.swift +++ b/Modules/Sources/FormFeature/Fields/FormFieldFeature.swift @@ -16,7 +16,7 @@ public struct FormFieldFeature: Reducer { @ObservableState public enum State: Equatable, Identifiable, FormFieldConformable { - var flag: FormFlag { return [] } + var flag: FormFieldFlag { return [] } case checkBoxList(FormCheckBoxListFeature.State) case dropdown(FormDropdownFeature.State) diff --git a/Modules/Sources/FormFeature/Fields/FormTextFieldFeature.swift b/Modules/Sources/FormFeature/Fields/FormTextFieldFeature.swift index 408b5144..a705a373 100644 --- a/Modules/Sources/FormFeature/Fields/FormTextFieldFeature.swift +++ b/Modules/Sources/FormFeature/Fields/FormTextFieldFeature.swift @@ -23,7 +23,7 @@ public struct FormTextFieldFeature: Reducer { let title: String let description: String let placeholder: String - let flag: FormFlag + let flag: FormFieldFlag let maxLength: Int? public var text = "" @@ -32,7 +32,7 @@ public struct FormTextFieldFeature: Reducer { title: String = "", description: String = "", placeholder: String = "", - flag: FormFlag, + flag: FormFieldFlag, defaultText: String = "", maxLength: Int? = nil ) { diff --git a/Modules/Sources/FormFeature/Fields/FormTitleFeature.swift b/Modules/Sources/FormFeature/Fields/FormTitleFeature.swift index c2f60f93..2bf1e3e6 100644 --- a/Modules/Sources/FormFeature/Fields/FormTitleFeature.swift +++ b/Modules/Sources/FormFeature/Fields/FormTitleFeature.swift @@ -20,7 +20,7 @@ public struct FormTitleFeature: Reducer { public struct State: Equatable, FormFieldConformable { public let id: Int let text: String - let flag: FormFlag = [] + let flag: FormFieldFlag = [] public init(id: Int, text: String) { self.id = id diff --git a/Modules/Sources/FormFeature/Fields/FormUploadBoxFeature.swift b/Modules/Sources/FormFeature/Fields/FormUploadBoxFeature.swift index 0290d79a..7366236a 100644 --- a/Modules/Sources/FormFeature/Fields/FormUploadBoxFeature.swift +++ b/Modules/Sources/FormFeature/Fields/FormUploadBoxFeature.swift @@ -24,7 +24,7 @@ public struct FormUploadBoxFeature: Reducer { public let id: Int let title: String let description: String - let flag: FormFlag + let flag: FormFieldFlag let allowedExtensions: [String] let isHidden: Bool public var isLocked: Bool @@ -35,7 +35,7 @@ public struct FormUploadBoxFeature: Reducer { id: Int, title: String, description: String, - flag: FormFlag, + flag: FormFieldFlag, allowedExtensions: [String], isHidden: Bool, isLocked: Bool = false diff --git a/Modules/Sources/Models/Form/FormFieldFlag.swift b/Modules/Sources/Models/Form/FormFieldFlag.swift new file mode 100644 index 00000000..ee5fb81c --- /dev/null +++ b/Modules/Sources/Models/Form/FormFieldFlag.swift @@ -0,0 +1,17 @@ +// +// FormFieldFlag.swift +// ForPDA +// +// Created by Xialtal on 28.02.26. +// + +public struct FormFieldFlag: OptionSet, Sendable, Equatable, Hashable { + public var rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } + + public static let required = FormFieldFlag(rawValue: 1 << 0) + public static let uploadable = FormFieldFlag(rawValue: 1 << 1) +} diff --git a/Modules/Sources/Models/Form/FormFieldType.swift b/Modules/Sources/Models/Form/FormFieldType.swift index 8200f094..110afe61 100644 --- a/Modules/Sources/Models/Form/FormFieldType.swift +++ b/Modules/Sources/Models/Form/FormFieldType.swift @@ -18,7 +18,7 @@ public enum FormFieldType: Sendable, Equatable, Hashable { public let name: String public let description: String public let example: String - public let flag: FormFlag + public let flag: FormFieldFlag public let defaultValue: String public init( @@ -26,7 +26,7 @@ public enum FormFieldType: Sendable, Equatable, Hashable { name: String, description: String, example: String, - flag: FormFlag, + flag: FormFieldFlag, defaultValue: String ) { self.id = id diff --git a/Modules/Sources/Models/Form/FormFlag.swift b/Modules/Sources/Models/Form/FormFlag.swift deleted file mode 100644 index c0d8f931..00000000 --- a/Modules/Sources/Models/Form/FormFlag.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// FormFlag.swift -// ForPDA -// -// Created by Xialtal on 28.02.26. -// - -public struct FormFlag: OptionSet, Sendable, Equatable, Hashable { - public var rawValue: Int - - public init(rawValue: Int) { - self.rawValue = rawValue - } - - public static let required = FormFlag(rawValue: 1 << 0) - public static let uploadable = FormFlag(rawValue: 1 << 1) -} diff --git a/Modules/Sources/ParsingClient/Parsers/FormParser.swift b/Modules/Sources/ParsingClient/Parsers/FormParser.swift index 70bca3cc..8f484ca7 100644 --- a/Modules/Sources/ParsingClient/Parsers/FormParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/FormParser.swift @@ -107,7 +107,7 @@ public struct FormParser { name: name, description: description, example: example, - flag: FormFlag(rawValue: flag), + flag: FormFieldFlag(rawValue: flag), defaultValue: defaultValue ) From 4d50a5d885fe6b34836c2b86f0ef804e59ac3b07 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Wed, 11 Mar 2026 12:45:38 +0300 Subject: [PATCH 094/118] Fix post edit reason field in Form --- Modules/Sources/FormFeature/FormFeature.swift | 4 ++-- Modules/Sources/FormFeature/FormScreen.swift | 2 +- Modules/Sources/FormFeature/Views/EditReasonView.swift | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Modules/Sources/FormFeature/FormFeature.swift b/Modules/Sources/FormFeature/FormFeature.swift index 5911ae49..f950c547 100644 --- a/Modules/Sources/FormFeature/FormFeature.swift +++ b/Modules/Sources/FormFeature/FormFeature.swift @@ -232,8 +232,8 @@ public struct FormFeature: Reducer, Sendable { id: 1, title: "", description: "", - flag: [.uploadable], - allowedExtensions: [], + flag: .uploadable, + allowedExtensions: [], // server will decide isHidden: true ) state.rows.append(.editor(editorState)) diff --git a/Modules/Sources/FormFeature/FormScreen.swift b/Modules/Sources/FormFeature/FormScreen.swift index 992df28f..fbf42b0d 100644 --- a/Modules/Sources/FormFeature/FormScreen.swift +++ b/Modules/Sources/FormFeature/FormScreen.swift @@ -37,7 +37,7 @@ public struct FormScreen: View { FormFieldRow(store: fieldStore, focusedField: $focusedField) } - if store.rows.count == 1 && store.inPostEditingMode { + if store.inPostEditingMode { EditReasonView( id: 1, text: $store.editReasonText, diff --git a/Modules/Sources/FormFeature/Views/EditReasonView.swift b/Modules/Sources/FormFeature/Views/EditReasonView.swift index 0533d471..4eaa6ab3 100644 --- a/Modules/Sources/FormFeature/Views/EditReasonView.swift +++ b/Modules/Sources/FormFeature/Views/EditReasonView.swift @@ -41,7 +41,7 @@ struct EditReasonView: View { if isEditingReasonEnabled { Field( content: $text, - placeholder: LocalizedStringResource("Input reason"), + placeholder: LocalizedStringResource("Input reason", bundle: .module), focusEqual: id, focus: $focusedField ) From c09605df527e3fb9ce351476a36bab6aa9745245 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Wed, 11 Mar 2026 14:11:42 +0300 Subject: [PATCH 095/118] Improve bbPanel list tag --- .../LIstBuilder/ListTagBuilderFeature.swift | 14 +--- .../LIstBuilder/ListTagBuilderView.swift | 81 ++++++++++++------- .../LIstBuilder/Models/ListItemField.swift | 19 ----- 3 files changed, 54 insertions(+), 60 deletions(-) delete mode 100644 Modules/Sources/BBPanelFeature/LIstBuilder/Models/ListItemField.swift diff --git a/Modules/Sources/BBPanelFeature/LIstBuilder/ListTagBuilderFeature.swift b/Modules/Sources/BBPanelFeature/LIstBuilder/ListTagBuilderFeature.swift index ef4895e0..710e4922 100644 --- a/Modules/Sources/BBPanelFeature/LIstBuilder/ListTagBuilderFeature.swift +++ b/Modules/Sources/BBPanelFeature/LIstBuilder/ListTagBuilderFeature.swift @@ -22,15 +22,10 @@ public struct ListTagBuilderFeature: Reducer, Sendable { var focus: Field? - var listItems: [ListItemField] = [] + var listItems: [String] = [""] var isAddItemButtonDisabled: Bool { - for item in listItems { - if item.content.isEmpty { - return true - } - } - return false + return listItems.contains(where: { $0.isEmpty }) } public init( @@ -72,13 +67,12 @@ public struct ListTagBuilderFeature: Reducer, Sendable { Reduce { state, action in switch action { case .view(.onAppear): - state.listItems.append(.init(id: 0, content: "")) state.focus = .item(0) return .none case .view(.addListItemButtonTapped): let newId = state.listItems.count - state.listItems.append(.init(id: newId, content: "")) + state.listItems.append("") state.focus = .item(newId) return .none @@ -88,7 +82,7 @@ public struct ListTagBuilderFeature: Reducer, Sendable { if item != state.listItems.first { leftTag.append("\n") } - leftTag.append("[*]\(item.content)") + leftTag.append("[*]\(item)") } return .send(.delegate(.listTagBuilded((leftTag, "[/LIST]")))) diff --git a/Modules/Sources/BBPanelFeature/LIstBuilder/ListTagBuilderView.swift b/Modules/Sources/BBPanelFeature/LIstBuilder/ListTagBuilderView.swift index cea1f700..fe2c7c0b 100644 --- a/Modules/Sources/BBPanelFeature/LIstBuilder/ListTagBuilderView.swift +++ b/Modules/Sources/BBPanelFeature/LIstBuilder/ListTagBuilderView.swift @@ -7,6 +7,7 @@ import SwiftUI import ComposableArchitecture +import SharedUI @ViewAction(for: ListTagBuilderFeature.self) public struct ListTagBuilderView: View { @@ -34,8 +35,8 @@ public struct ListTagBuilderView: View { List { Section { - ForEach(store.listItems) { item in - ItemField(id: item.id) + ForEach(0.. some View { - TextField(text: $store.listItems[id].content, axis: .vertical) { - Text("Item \(id + 1)", bundle: .module) - .font(.body) - .foregroundStyle(Color(.Labels.quaternary)) + private func ItemField(id: Int) -> some View { + WithPerceptionTracking { + TextField(text: $store.listItems[id], axis: .vertical) { + Text("Item \(id + 1)", bundle: .module) + .font(.body) + .foregroundStyle(Color(.Labels.quaternary)) + } + .padding(.vertical, 11) + .focused($focus, equals: .item(id)) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .frame(minHeight: 44) + .cornerRadius(10) + } + } + + // MARK: - Toolbar + + @ToolbarContentBuilder + private func Toolbar() -> some ToolbarContent { + ToolbarItem(placement: .topBarLeading) { + Button { + send(.cancelButtonTapped) + } label: { + if isLiquidGlass { + Image(systemSymbol: .xmark) + } else { + Text("Cancel", bundle: .module) + } + } + .tint(tintColor) + } + + ToolbarItem(placement: .topBarTrailing) { + Button { + send(.createButtonTapped) + } label: { + if isLiquidGlass { + Image(systemSymbol: .checkmark) + } else { + Text("Create", bundle: .module) + } + } + .tint(tintColor) + .disabled(store.isAddItemButtonDisabled) } - .padding(.vertical, 11) - .focused($focus, equals: .item(id)) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - .frame(minHeight: 44) - .cornerRadius(10) } } @@ -124,4 +142,5 @@ public struct ListTagBuilderView: View { } ) } + .environment(\.tintColor, Color(.Theme.primary)) } diff --git a/Modules/Sources/BBPanelFeature/LIstBuilder/Models/ListItemField.swift b/Modules/Sources/BBPanelFeature/LIstBuilder/Models/ListItemField.swift deleted file mode 100644 index 6f2051dc..00000000 --- a/Modules/Sources/BBPanelFeature/LIstBuilder/Models/ListItemField.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// ListItemField.swift -// ForPDA -// -// Created by Xialtal on 2.01.26. -// - -struct ListItemField: Equatable, Identifiable { - let id: Int - var content: String - - init( - id: Int, - content: String - ) { - self.id = id - self.content = content - } -} From 01fc716c2aa594cbe5e73657520dc11a7e7a02a8 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Wed, 11 Mar 2026 20:15:13 +0300 Subject: [PATCH 096/118] WIP --- .../BBPanelFeature/BBPanelFeature.swift | 44 ++- .../Sources/BBPanelFeature/BBPanelView.swift | 292 +++++++++--------- .../BBPanelFeature/Models/BBPanelColor.swift | 60 ++++ .../Resources/Localizable.xcstrings | 16 +- .../Views/ColorPickerView.swift | 49 --- .../Fields/FormEditorFeature.swift | 76 ++++- Modules/Sources/FormFeature/FormFeature.swift | 25 +- .../Support/FormStickedUploadBox.swift | 10 +- Modules/Sources/SharedUI/Field.swift | 15 +- 9 files changed, 346 insertions(+), 241 deletions(-) create mode 100644 Modules/Sources/BBPanelFeature/Models/BBPanelColor.swift delete mode 100644 Modules/Sources/BBPanelFeature/Views/ColorPickerView.swift diff --git a/Modules/Sources/BBPanelFeature/BBPanelFeature.swift b/Modules/Sources/BBPanelFeature/BBPanelFeature.swift index 43e472cf..28a112b2 100644 --- a/Modules/Sources/BBPanelFeature/BBPanelFeature.swift +++ b/Modules/Sources/BBPanelFeature/BBPanelFeature.swift @@ -25,7 +25,6 @@ public struct BBPanelFeature: Reducer, Sendable { @Reducer public enum Destination { - case sizeTag case colorTag case urlTag @@ -38,8 +37,7 @@ public struct BBPanelFeature: Reducer, Sendable { public enum BBPanelViewState { case tags - case colorPicker - case fontSizePicker + case fontSizes } // MARK: - State @@ -54,6 +52,17 @@ public struct BBPanelFeature: Reducer, Sendable { public var supportsUpload: Bool public var allowedExtensions: [String] + public var existsFiles: [UploadBoxFile] { + get { upload.files } + set(files) { + if !isUploading { + upload.files = files + } else { + fatalError("Could not update files, cause some is uploading (\(upload.files))") + } + } + } + var tags: [BBPanelTag] = [] var viewState: BBPanelViewState = .tags @@ -67,11 +76,13 @@ public struct BBPanelFeature: Reducer, Sendable { public init( for panelType: BBPanelType, supportsUpload: Bool = false, - allowedExtensions: [String] = [] + allowedExtensions: [String] = [], + existsFiles: [UploadBoxFile] = [] ) { self.panelWith = panelType self.supportsUpload = supportsUpload self.allowedExtensions = allowedExtensions + self.existsFiles = existsFiles } } @@ -86,11 +97,12 @@ public struct BBPanelFeature: Reducer, Sendable { public enum View { case onAppear case tagButtonTapped(BBPanelTag) + case fontSizeButtonTapped(Int) + case colorButtonTapped(String) + case colorCancelButtonTapped case alertTagButtonTapped(BBPanelTag) case hideUploadBoxButtonTapped case returnTagsButtonTapped - - case colorSelected(String) } case delegate(Delegate) @@ -114,6 +126,9 @@ public struct BBPanelFeature: Reducer, Sendable { var tags = state.panelWith.kit if state.supportsUpload { tags.insert(.upload, at: 0) + + state.upload.files = state.existsFiles + state.uploadedFiles = state.existsFiles.count state.upload.allowedExtensions = state.allowedExtensions } if case let .post(isCurator, canModerate) = state.panelWith { @@ -133,11 +148,9 @@ public struct BBPanelFeature: Reducer, Sendable { case .b, .i, .s, .u, .sup, .sub, .offtop, .center, .left, .right, .hide, .code, .cur, .mod, .ex, .quote, .spoiler: return .send(.delegate(.tagTapped(("[\(tag.code)]", "[/\(tag.code)]")))) case .size: - //state.destination = .sizeTag - state.viewState = .fontSizePicker + state.viewState = .fontSizes case .color: - //state.destination = .colorTag - state.viewState = .colorPicker + state.destination = .colorTag case .url: state.destination = .urlTag case .spoilerWithTitle: @@ -151,13 +164,20 @@ public struct BBPanelFeature: Reducer, Sendable { } return .none + case let .view(.fontSizeButtonTapped(size)): + return .send(.delegate(.tagTapped(("[SIZE=\(size)]", "[/SIZE]")))) + case .view(.returnTagsButtonTapped): state.viewState = .tags return .none - case let .view(.colorSelected(color)): + case let .view(.colorButtonTapped(name)): + state.destination = nil + return .send(.delegate(.tagTapped(("[COLOR=\(name)]", "[/COLOR]")))) + + case .view(.colorCancelButtonTapped): state.destination = nil - return .send(.delegate(.tagTapped(("[COLOR=\(color)]", "[/COLOR]")))) + return .none case let .view(.alertTagButtonTapped(tag)): let input = state.alertInput diff --git a/Modules/Sources/BBPanelFeature/BBPanelView.swift b/Modules/Sources/BBPanelFeature/BBPanelView.swift index c83e3848..745c35df 100644 --- a/Modules/Sources/BBPanelFeature/BBPanelView.swift +++ b/Modules/Sources/BBPanelFeature/BBPanelView.swift @@ -30,158 +30,186 @@ public struct BBPanelView: View { public var body: some View { WithPerceptionTracking { - VStack { + VStack(spacing: 32) { if store.showUploadBox { UploadBox() } - if #available(iOS 26.0, *) { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 20) { - switch store.viewState { - case .tags: - Tags() - case .colorPicker: - Text("ColorPicker") - case .fontSizePicker: - FontSize() - } - } - .padding(.top, 6) - .padding(.bottom, 8) - .padding(.horizontal, 12) - } - .sheet(isPresented: Binding($store.destination.sizeTag)) { - Picker("Select text size", selection: $store.textSize) { - ForEach(1..<8) { size in - Text(verbatim: "\(size)") - } + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 24) { + switch store.viewState { + case .tags: + Tags() + case .fontSizes: + FontSizes() } - .pickerStyle(.wheel) - .presentationDetents([.medium]) } - .sheet(item: $store.scope(state: \.destination?.listTag, action: \.destination.listTag)) { store in - NavigationStack { - ListTagBuilderView(store: store) - } + .padding(.top, 6) + .padding(.bottom, 8) + .padding(.horizontal, 12) + } + .sheet(item: $store.scope(state: \.destination?.listTag, action: \.destination.listTag)) { store in + NavigationStack { + ListTagBuilderView(store: store) } - .sheet(isPresented: Binding($store.destination.colorTag)) { - ColorPickerView(onColorSelected: { color in - if let color = color.hexColor { - send(.colorSelected(color)) - } - }) + } + .sheet(isPresented: Binding($store.destination.colorTag)) { + ColorsGrid() .presentationDetents([.medium]) - } - .alert( - BBPanelFeature.Localization.inputFullUrl, - isPresented: $store.destination.urlTag - ) { - AlertInput({ - send(.alertTagButtonTapped(.url)) - }) - } - .alert( - BBPanelFeature.Localization.inputSpoilerTitle, - isPresented: Binding($store.destination.spoilerWithTitleTag) - ) { - AlertInput({ - send(.alertTagButtonTapped(.spoilerWithTitle)) - }) - } - .animation(.bouncy, value: store.viewState) - .background(.bar.opacity(0.5), in: .capsule) - .glassEffect() - .shadow(color: .black.opacity(0.2), radius: 20, y: 10) - .onAppear { - send(.onAppear) - } - } else { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 20) { - Button { - - } label: { - Image(systemSymbol: .plusAppFill) - .foregroundStyle(Color(.Labels.primary)) - } - .buttonStyle(.plain) - } - .padding(.top, 6) - .padding(.bottom, 8) - .padding(.horizontal, 12) - .border(Color(.red/*Background.secondary*/)) - .background(Color(.Background.secondary)) - } + .presentationDragIndicator(.visible) + } + .alert( + BBPanelFeature.Localization.inputFullUrl, + isPresented: $store.destination.urlTag + ) { + AlertInput({ + send(.alertTagButtonTapped(.url)) + }) + } + .alert( + BBPanelFeature.Localization.inputSpoilerTitle, + isPresented: Binding($store.destination.spoilerWithTitleTag) + ) { + AlertInput({ + send(.alertTagButtonTapped(.spoilerWithTitle)) + }) + } + .background { + RoundedRectangle(cornerRadius: isLiquidGlass ? 28 : 14) + .fill(Color(.Background.primary)) + } + .onAppear { + send(.onAppear) } } } } + // MARK: - Tags + @ViewBuilder - private func FontSize() -> some View { - Button { - send(.returnTagsButtonTapped) - } label: { - Image(systemName: "xmark") - .font(.title3) - .padding(.vertical, 2) + private func Tags() -> some View { + ForEach(store.tags, id: \.self) { tag in + TagButton(tag) } - .buttonStyle(.plain) - - ForEach(2..<9) { size in + } + + // MARK: - Tag Button + + @ViewBuilder + private func TagButton(_ tag: BBPanelTag) -> some View { + WithPerceptionTracking { Button { - // TODO: dsaddd + send(.tagButtonTapped(tag)) } label: { - Image(systemName: "\(size).square") + Image(systemSymbol: tag.icon) .font(.title3) - .foregroundStyle(tintColor) + .foregroundStyle(tagButtonColor(tag)) + .overlay(alignment: .topTrailing) { + if tag == .upload, store.uploadedFiles != 0 { + Circle() + .overlay { + Text(verbatim: "\(store.uploadedFiles)") + .font(.caption2) + .fontWeight(.semibold) + .foregroundStyle(Color(.Labels.primaryInvariably)) + } + .frame(width: 21, height: 18) + .foregroundStyle(tintColor) + .offset(x: 10, y: -5) + } + } } .buttonStyle(.plain) } } - // MARK: - Tags + private func tagButtonColor(_ tag: BBPanelTag) -> Color { + return tag == .upload && (store.isUploading || store.showUploadBox) + ? tintColor + : Color(.Labels.primary) + } + + // MARK: - Font Sizes @ViewBuilder - private func Tags() -> some View { - ForEach(store.tags, id: \.self) { tag in - TagButton(tag) + private func FontSizes() -> some View { + Button { + send(.returnTagsButtonTapped) + } label: { + Image(systemName: "chevron.left") + .font(.title2) + .foregroundStyle(tintColor) + } + .buttonStyle(.plain) + + ForEach(1..<8) { size in + Button { + send(.fontSizeButtonTapped(size)) + } label: { + Text(verbatim: "\(size)") + } + .font(.title2) + .buttonStyle(.plain) } } - // MARK: - Tag Button + // MARK: - Colors Grid @ViewBuilder - private func TagButton(_ tag: BBPanelTag) -> some View { - Button { - send(.tagButtonTapped(tag)) - } label: { - Image(systemSymbol: tag.icon) - .font(.title3) - .foregroundStyle(tagButtonColor(tag)) - .overlay(alignment: .topTrailing) { - if tag == .upload, store.uploadedFiles != 0 { + private func ColorsGrid() -> some View { + ZStack { + Color(.Background.primary) + .ignoresSafeArea() + + VStack(spacing: 12) { + HStack { + Text("Select color", bundle: .module) + .font(.title3) + .fontWeight(.semibold) + .foregroundStyle(Color(.Labels.primary)) + + Spacer() + + Button { + send(.colorCancelButtonTapped) + } label: { + Image(systemSymbol: .xmark) + .font(.caption) + .fontWeight(.bold) + .foregroundStyle(Color(.Labels.teritary)) + .frame(width: 30, height: 30) + .background( + Circle() + .fill(Color(.Background.quaternary)) + .clipShape(Circle()) + ) + } + } + .padding(.top, 18) + + Spacer() + + let colorsColumns = Array(repeating: GridItem(.flexible()), count: 5) + LazyVGrid(columns: colorsColumns, spacing: 16) { + ForEach(BBPanelColor.allCases, id: \.self) { color in Circle() - .overlay { - Text(verbatim: "\(store.uploadedFiles)") - .font(.caption2) - .fontWeight(.semibold) - .foregroundStyle(Color(.Labels.primaryInvariably)) + .fill(color.color) + .frame(width: 44, height: 44) + .overlay( + Circle() + .stroke(Color(.Separator.secondary), lineWidth: color == .white ? 1 : 0) + ) + .onTapGesture { + send(.colorButtonTapped(color.title)) } - .frame(width: 21, height: 18) - .foregroundStyle(tintColor) - .offset(x: 10, y: -5) } } + + Spacer() + } + .padding(.horizontal, 16) } - .buttonStyle(.plain) - } - - private func tagButtonColor(_ tag: BBPanelTag) -> Color { - return tag == .upload && (store.isUploading || store.showUploadBox) - ? tintColor - : Color(.Labels.primary) } // MARK: - Upload Box @@ -212,24 +240,16 @@ public struct BBPanelView: View { } } - UploadBoxView(store: store.scope(state: \.upload, action: \.upload)) - .padding(.bottom, 32) + WithPerceptionTracking { + UploadBoxView(store: store.scope(state: \.upload, action: \.upload)) + .padding(.bottom, 32) + } } .padding(.top, 16) .padding(.horizontal, 16) .background { - if #available(iOS 26.0, *) { - UnevenRoundedRectangle(cornerRadii: .init( - topLeading: 28, - bottomLeading: 0, - bottomTrailing: 0, - topTrailing: 28 - )) + RoundedRectangle(cornerRadius: isLiquidGlass ? 28 : 14) .fill(Color(.Background.primary)) - } else { - RoundedRectangle(cornerRadius: 14) - .fill(Color(.Background.primary)) - } } .frame(height: 190) .shadow(color: Color(.Labels.primary).opacity(0.15), radius: 10, y: 4) @@ -250,18 +270,6 @@ public struct BBPanelView: View { } } -// MARK: - Helpers - -extension Color { - var hexColor: String? { - let components = self.cgColor?.components - guard let r = components?[0], let g = components?[1], let b = components?[2] else { - return nil - } - return String(format: "#%02x%02x%02x", Int(r * 255), Int(g * 255), Int(b * 255)) - } -} - // MARK: - Previews #Preview { diff --git a/Modules/Sources/BBPanelFeature/Models/BBPanelColor.swift b/Modules/Sources/BBPanelFeature/Models/BBPanelColor.swift new file mode 100644 index 00000000..df414e16 --- /dev/null +++ b/Modules/Sources/BBPanelFeature/Models/BBPanelColor.swift @@ -0,0 +1,60 @@ +// +// BBPanelColor.swift +// ForPDA +// +// Created by Xialtal on 11.03.26. +// + +import SwiftUI + +enum BBPanelColor: Int, CaseIterable, Identifiable { + case black = -16777216 + case white = -1 + case skyBlue = -7876885 + case royalBlue = -12490271 + case blue = -16776961 + case darkBlue = -16777077 + case orange = -23296 + case orangeRed = -47872 + case crimson = -2354116 + case red = -65536 + case darkRed = -7667712 + case green = -16711936 + case limeGreen = -13447886 + case seaGreen = -13726889 + case deepPink = -60269 + case tomato = -40121 + case coral = -32944 + case purple = -8388480 + case indigo = -11861886 + case burlyWood = -2180985 + case sandyBrown = -5952982 + case sienna = -7852777 + case chocolate = -2987746 + case teal = -16744320 + case silver = -4144960 + + var id: Int { self.rawValue } + + var color: Color { + Color(UIColor(argb: self.rawValue)) + } + + var title: String { + let name = String(describing: self) + return name.prefix(1).uppercased() + name.dropFirst() + } +} + +private extension UIColor { + convenience init(argb: Int) { + let value = UInt32(bitPattern: Int32(argb)) + + let a = CGFloat((value >> 24) & 0xff) / 255 + let r = CGFloat((value >> 16) & 0xff) / 255 + let g = CGFloat((value >> 8) & 0xff) / 255 + let b = CGFloat(value & 0xff) / 255 + + self.init(red: r, green: g, blue: b, alpha: a) + } +} diff --git a/Modules/Sources/BBPanelFeature/Resources/Localizable.xcstrings b/Modules/Sources/BBPanelFeature/Resources/Localizable.xcstrings index 8361adf0..5294ad65 100644 --- a/Modules/Sources/BBPanelFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/BBPanelFeature/Resources/Localizable.xcstrings @@ -12,27 +12,21 @@ } }, "Attachments" : { - - }, - "Cancel" : { "localizations" : { "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Отмена" + "value" : "Вложения" } } } }, - "ColorPicker" : { - - }, - "Colors" : { + "Cancel" : { "localizations" : { "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Цвета" + "value" : "Отмена" } } } @@ -107,12 +101,12 @@ } } }, - "Select text size" : { + "Select color" : { "localizations" : { "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Select text size" + "value" : "Выберите цвет" } } } diff --git a/Modules/Sources/BBPanelFeature/Views/ColorPickerView.swift b/Modules/Sources/BBPanelFeature/Views/ColorPickerView.swift deleted file mode 100644 index f6a726a2..00000000 --- a/Modules/Sources/BBPanelFeature/Views/ColorPickerView.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// ColorPickerView.swift -// ForPDA -// -// Created by Xialtal on 1.01.26. -// - -import SwiftUI - -struct ColorPickerView: UIViewControllerRepresentable { - private let delegate: ColorPickerDelegate - - init(onColorSelected: @escaping (Color) -> Void) { - self.delegate = ColorPickerDelegate(onColorSelected: { color in - onColorSelected(color) - }) - } - - func makeUIViewController(context: Context ) -> UIColorPickerViewController { - let picker = UIColorPickerViewController() - picker.title = String(localized: "Colors", bundle: .module) - picker.selectedColor = .clear - picker.supportsAlpha = false - picker.delegate = delegate - - if #available(iOS 26.0, *) { - picker.supportsEyedropper = false - } - - return picker - } - - func updateUIViewController(_ uiViewController: UIColorPickerViewController, context: Context) { - } - - private class ColorPickerDelegate: NSObject, UIColorPickerViewControllerDelegate { - let onColorSelected: (Color) -> Void - - public init(onColorSelected: @escaping (Color) -> Void) { - self.onColorSelected = onColorSelected - } - - func colorPickerViewController(_ viewController: UIColorPickerViewController, didSelect color: UIColor, continuously: Bool) { - if !continuously { - onColorSelected(Color(uiColor: viewController.selectedColor)) - } - } - } -} diff --git a/Modules/Sources/FormFeature/Fields/FormEditorFeature.swift b/Modules/Sources/FormFeature/Fields/FormEditorFeature.swift index e356ce74..55f567a6 100644 --- a/Modules/Sources/FormFeature/Fields/FormEditorFeature.swift +++ b/Modules/Sources/FormFeature/Fields/FormEditorFeature.swift @@ -9,6 +9,7 @@ import SwiftUI import ComposableArchitecture import SharedUI import Models +import BBPanelFeature // MARK: - Feature @@ -19,6 +20,9 @@ public struct FormEditorFeature: Reducer { @ObservableState public struct State: Equatable, FormFieldConformable { + + var bbPanel: BBPanelFeature.State + public let id: Int let title: String let description: String @@ -26,6 +30,9 @@ public struct FormEditorFeature: Reducer { let flag: FormFieldFlag let uploadBox: FormStickedUploadBox? public var text = "" + public var textRange: NSRange? = nil + + var focus: Int? = nil public init( id: Int, @@ -43,6 +50,11 @@ public struct FormEditorFeature: Reducer { self.flag = flag self.text = defaultText self.uploadBox = uploadBox + + self.bbPanel = BBPanelFeature.State( + for: .post(isCurator: false, canModerate: false), + supportsUpload: flag.contains(.uploadable) + ) } func getValue() -> FormValue { @@ -66,8 +78,14 @@ public struct FormEditorFeature: Reducer { // MARK: - Action - public enum Action: BindableAction { + public enum Action: ViewAction, BindableAction { case binding(BindingAction) + case bbPanel(BBPanelFeature.Action) + + case view(View) + public enum View { + case onAppear + } } // MARK: - Body @@ -75,7 +93,49 @@ public struct FormEditorFeature: Reducer { public var body: some Reducer { BindingReducer() + Scope(state: \.bbPanel, action: \.bbPanel) { + BBPanelFeature() + } + Reduce { state, action in + switch action { + case .view(.onAppear): + if let uploadBox = state.uploadBox { + return .concatenate( + .send(.binding(.set(\.bbPanel.allowedExtensions, uploadBox.allowedExtensions))), + .send(.binding(.set(\.bbPanel.existsFiles, uploadBox.existsAttachments.map { + .init( + name: $0.name, + type: $0.type == .image ? .image : .file, + serverId: $0.id + ) + }))) + ) + } + + case let .bbPanel(.delegate(.tagTapped(tag))): + if let range = state.textRange, !state.text.isEmpty { + // если мы вставляем бб код в текст БЕЗ выделенной области + if range.lowerBound == range.upperBound { + let index = state.text.index(state.text.startIndex, offsetBy: range.lowerBound) + state.text.insert(contentsOf: "\(tag.0)\(tag.1)", at: index) + state.textRange = NSMakeRange(range.lowerBound + tag.0.count, 0) + } else { + let ubIndex = state.text.index(state.text.startIndex, offsetBy: range.upperBound) + let lbIndex = state.text.index(state.text.startIndex, offsetBy: range.lowerBound) + state.text.insert(contentsOf: tag.1, at: ubIndex) + state.text.insert(contentsOf: tag.0, at: lbIndex) + state.textRange = NSMakeRange(range.lowerBound + tag.0.count, range.upperBound - range.lowerBound) + } + } else { + state.text = "\(tag.0)\(tag.1)" + state.textRange = NSMakeRange(tag.0.count, 0) + } + state.focus = state.id + + case .binding, .bbPanel: + break + } return .none } } @@ -83,6 +143,7 @@ public struct FormEditorFeature: Reducer { // MARK: - View +@ViewAction(for: FormEditorFeature.self) struct FormEditorRow: View { @Perception.Bindable var store: StoreOf @@ -101,10 +162,21 @@ struct FormEditorRow: View { placeholder: LocalizedStringResource(stringLiteral: store.placeholder), focusEqual: store.id, focus: $focusedField, - minHeight: 144 + minHeight: 144, + selection: $store.textRange, + bbPanel: { + BBPanelView(store: store.scope(state: \.bbPanel, action: \.bbPanel)) + .onTapGesture { + focusedField = store.id + } + } ) } } + .bind($focusedField, to: $store.focus) + .onAppear { + send(.onAppear) + } } } } diff --git a/Modules/Sources/FormFeature/FormFeature.swift b/Modules/Sources/FormFeature/FormFeature.swift index f950c547..122f99c0 100644 --- a/Modules/Sources/FormFeature/FormFeature.swift +++ b/Modules/Sources/FormFeature/FormFeature.swift @@ -77,15 +77,9 @@ public struct FormFeature: Reducer, Sendable { } var content: [FormValue] { - if case let .editor(editorState) = rows.first { - if rows.count == 1 { // report - return [.string(editorState.text)] - } else if rows.count == 2 { // simple post - let attachments = editorState.getAttachments() - return [.string(editorState.text), .array(attachments.map { .integer($0) })] - } else { - fatalError("Incorrect data? \(rows)") - } + if rows.count == 1, case let .editor(editorState) = rows.first { + let attachments = editorState.getAttachments() + return [.string(editorState.text), .array(attachments.map { .integer($0) })] } else { var content: [FormValue] = [] var combinedAttachments: [Int] = [] @@ -221,23 +215,14 @@ public struct FormFeature: Reducer, Sendable { } switch content { - case let .simple(content, _): + case let .simple(content, attachments): let editorState = FormEditorFeature.State( id: 0, flag: [.required, .uploadable], defaultText: content, - uploadBox: .init(id: 1, allowedExtensions: []) - ) - let uploadBoxState = FormUploadBoxFeature.State( - id: 1, - title: "", - description: "", - flag: .uploadable, - allowedExtensions: [], // server will decide - isHidden: true + uploadBox: .init(id: 1, existsAttachments: attachments, allowedExtensions: []) ) state.rows.append(.editor(editorState)) - state.rows.append(.uploadBox(uploadBoxState)) state.focusedField = 0 case .template: diff --git a/Modules/Sources/FormFeature/Support/FormStickedUploadBox.swift b/Modules/Sources/FormFeature/Support/FormStickedUploadBox.swift index f0a84a75..4618b761 100644 --- a/Modules/Sources/FormFeature/Support/FormStickedUploadBox.swift +++ b/Modules/Sources/FormFeature/Support/FormStickedUploadBox.swift @@ -5,12 +5,18 @@ // Created by Xialtal on 2.03.26. // -public struct FormStickedUploadBox: Equatable { +public struct FormStickedUploadBox: Sendable, Equatable { public let id: Int + public let existsAttachments: [FormAttachment] public let allowedExtensions: [String] - public init(id: Int, allowedExtensions: [String]) { + public init( + id: Int, + existsAttachments: [FormAttachment] = [], + allowedExtensions: [String] + ) { self.id = id + self.existsAttachments = existsAttachments self.allowedExtensions = allowedExtensions } } diff --git a/Modules/Sources/SharedUI/Field.swift b/Modules/Sources/SharedUI/Field.swift index af64078c..0ab10016 100644 --- a/Modules/Sources/SharedUI/Field.swift +++ b/Modules/Sources/SharedUI/Field.swift @@ -7,7 +7,7 @@ import SwiftUI -public struct Field: View { +public struct Field: View { // MARK: - Properties @@ -20,6 +20,7 @@ public struct Field: View { let characterLimit: Int? let minHeight: CGFloat? var selection: Binding + let bbPanel: () -> BBPanel // MARK: - Init @@ -30,7 +31,8 @@ public struct Field: View { focus: FocusState.Binding, characterLimit: Int? = nil, minHeight: CGFloat? = nil, - selection: Binding = .constant(nil) + selection: Binding = .constant(nil), + @ViewBuilder bbPanel: @escaping () -> BBPanel = { EmptyView() } ) { self.content = content self.placeholder = placeholder @@ -38,6 +40,7 @@ public struct Field: View { self.characterLimit = characterLimit self.minHeight = minHeight self.selection = selection + self.bbPanel = bbPanel self._focus = focus } @@ -45,13 +48,19 @@ public struct Field: View { // MARK: - Body public var body: some View { - Group { + VStack { SelectableTextView( content: content, selection: selection, placeholder: placeholder, characterLimit: characterLimit ) + + if BBPanel.self != EmptyView.self { + Spacer() + } + + bbPanel() } .padding(.vertical, 15) .padding(.horizontal, 12) From 166d735bb3f00cee3b1623a65238a578d48c5b78 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Wed, 11 Mar 2026 20:49:31 +0300 Subject: [PATCH 097/118] Fix Form content building --- Modules/Sources/FormFeature/FormFeature.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Modules/Sources/FormFeature/FormFeature.swift b/Modules/Sources/FormFeature/FormFeature.swift index 122f99c0..d1a2ff48 100644 --- a/Modules/Sources/FormFeature/FormFeature.swift +++ b/Modules/Sources/FormFeature/FormFeature.swift @@ -79,13 +79,14 @@ public struct FormFeature: Reducer, Sendable { var content: [FormValue] { if rows.count == 1, case let .editor(editorState) = rows.first { let attachments = editorState.getAttachments() - return [.string(editorState.text), .array(attachments.map { .integer($0) })] + return [editorState.getValue(), .array(attachments.map { .integer($0) })] } else { var content: [FormValue] = [] var combinedAttachments: [Int] = [] for row in rows { if case let .editor(state) = row, state.uploadBox != nil { combinedAttachments = state.getAttachments() + content.append(row.getValue()) } else if case let .uploadBox(state) = row, state.isHidden { content.append(.array(combinedAttachments.map { .integer($0) })) } else { From 7244c19172199bb99d517d8d44765d5702b92d4c Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 14 Mar 2026 14:03:18 +0300 Subject: [PATCH 098/118] Fix send button on profile edit screen --- Modules/Sources/ProfileFeature/Edit/EditScreen.swift | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Modules/Sources/ProfileFeature/Edit/EditScreen.swift b/Modules/Sources/ProfileFeature/Edit/EditScreen.swift index 3654a2ba..29805654 100644 --- a/Modules/Sources/ProfileFeature/Edit/EditScreen.swift +++ b/Modules/Sources/ProfileFeature/Edit/EditScreen.swift @@ -80,10 +80,7 @@ public struct EditScreen: View { .navigationTitle(Text("Edit profile", bundle: .module)) .navigationBarTitleDisplayMode(.inline) ._safeAreaBar(edge: .bottom) { - if focus == .about || focus == .signature { - BBPanelView(store: store.scope(state: \.bbPanel, action: \.bbPanel)) - .padding(isLiquidGlass ? 8 : 0) - } + SendButton() } .toolbar { ToolbarItem(placement: .navigationBarLeading) { From d139d48fe4b56ed08d0dd1545d1d9d8d389ff1a1 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 14 Mar 2026 14:49:46 +0300 Subject: [PATCH 099/118] Fix Form warning --- Modules/Sources/FormFeature/Support/FormNodeBuilder.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Modules/Sources/FormFeature/Support/FormNodeBuilder.swift b/Modules/Sources/FormFeature/Support/FormNodeBuilder.swift index 73a0cc67..12e7927b 100644 --- a/Modules/Sources/FormFeature/Support/FormNodeBuilder.swift +++ b/Modules/Sources/FormFeature/Support/FormNodeBuilder.swift @@ -41,7 +41,10 @@ struct FormNodeView: View { type: node, attachments: [], onUrlTap: { _ in - #warning("Обработать тапы на ссылки") + // We don't process taps on links. + // If you open them, the form's sheet will close. + // It works in the official client for topics, because the form opens as a page + // and links open in new tabs. But it doesn't work properly for posts at all - it breaks the UI. } ) } From 04c2a21600d17391edd0ef4cd0520134e07198bd Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 14 Mar 2026 14:58:16 +0300 Subject: [PATCH 100/118] Fix tests for FormFeature --- .../{ => Sources}/Analytics/FormFeature+Analytics.swift | 0 .../{ => Sources}/Fields/FormCheckBoxListFeature.swift | 0 .../FormFeature/{ => Sources}/Fields/FormDropdownFeature.swift | 0 .../FormFeature/{ => Sources}/Fields/FormEditorFeature.swift | 0 .../FormFeature/{ => Sources}/Fields/FormFieldConformable.swift | 0 .../FormFeature/{ => Sources}/Fields/FormFieldFeature.swift | 0 .../FormFeature/{ => Sources}/Fields/FormTextFieldFeature.swift | 0 .../FormFeature/{ => Sources}/Fields/FormTitleFeature.swift | 0 .../FormFeature/{ => Sources}/Fields/FormUploadBoxFeature.swift | 0 Modules/Sources/FormFeature/{ => Sources}/FormFeature.swift | 0 Modules/Sources/FormFeature/{ => Sources}/FormScreen.swift | 0 .../FormFeature/{ => Sources}/Preview/FormPreviewFeature.swift | 0 .../FormFeature/{ => Sources}/Preview/FormPreviewView.swift | 0 .../FormFeature/{ => Sources}/Support/FormAttachment.swift | 0 .../FormFeature/{ => Sources}/Support/FormNodeBuilder.swift | 0 .../FormFeature/{ => Sources}/Support/FormStickedUploadBox.swift | 0 Modules/Sources/FormFeature/{ => Sources}/Support/FormType.swift | 0 .../Sources/FormFeature/{ => Sources}/Support/FormValue.swift | 0 Modules/Sources/FormFeature/{ => Sources}/Views/CheckBox.swift | 0 .../Sources/FormFeature/{ => Sources}/Views/EditReasonView.swift | 0 Project.swift | 1 + 21 files changed, 1 insertion(+) rename Modules/Sources/FormFeature/{ => Sources}/Analytics/FormFeature+Analytics.swift (100%) rename Modules/Sources/FormFeature/{ => Sources}/Fields/FormCheckBoxListFeature.swift (100%) rename Modules/Sources/FormFeature/{ => Sources}/Fields/FormDropdownFeature.swift (100%) rename Modules/Sources/FormFeature/{ => Sources}/Fields/FormEditorFeature.swift (100%) rename Modules/Sources/FormFeature/{ => Sources}/Fields/FormFieldConformable.swift (100%) rename Modules/Sources/FormFeature/{ => Sources}/Fields/FormFieldFeature.swift (100%) rename Modules/Sources/FormFeature/{ => Sources}/Fields/FormTextFieldFeature.swift (100%) rename Modules/Sources/FormFeature/{ => Sources}/Fields/FormTitleFeature.swift (100%) rename Modules/Sources/FormFeature/{ => Sources}/Fields/FormUploadBoxFeature.swift (100%) rename Modules/Sources/FormFeature/{ => Sources}/FormFeature.swift (100%) rename Modules/Sources/FormFeature/{ => Sources}/FormScreen.swift (100%) rename Modules/Sources/FormFeature/{ => Sources}/Preview/FormPreviewFeature.swift (100%) rename Modules/Sources/FormFeature/{ => Sources}/Preview/FormPreviewView.swift (100%) rename Modules/Sources/FormFeature/{ => Sources}/Support/FormAttachment.swift (100%) rename Modules/Sources/FormFeature/{ => Sources}/Support/FormNodeBuilder.swift (100%) rename Modules/Sources/FormFeature/{ => Sources}/Support/FormStickedUploadBox.swift (100%) rename Modules/Sources/FormFeature/{ => Sources}/Support/FormType.swift (100%) rename Modules/Sources/FormFeature/{ => Sources}/Support/FormValue.swift (100%) rename Modules/Sources/FormFeature/{ => Sources}/Views/CheckBox.swift (100%) rename Modules/Sources/FormFeature/{ => Sources}/Views/EditReasonView.swift (100%) diff --git a/Modules/Sources/FormFeature/Analytics/FormFeature+Analytics.swift b/Modules/Sources/FormFeature/Sources/Analytics/FormFeature+Analytics.swift similarity index 100% rename from Modules/Sources/FormFeature/Analytics/FormFeature+Analytics.swift rename to Modules/Sources/FormFeature/Sources/Analytics/FormFeature+Analytics.swift diff --git a/Modules/Sources/FormFeature/Fields/FormCheckBoxListFeature.swift b/Modules/Sources/FormFeature/Sources/Fields/FormCheckBoxListFeature.swift similarity index 100% rename from Modules/Sources/FormFeature/Fields/FormCheckBoxListFeature.swift rename to Modules/Sources/FormFeature/Sources/Fields/FormCheckBoxListFeature.swift diff --git a/Modules/Sources/FormFeature/Fields/FormDropdownFeature.swift b/Modules/Sources/FormFeature/Sources/Fields/FormDropdownFeature.swift similarity index 100% rename from Modules/Sources/FormFeature/Fields/FormDropdownFeature.swift rename to Modules/Sources/FormFeature/Sources/Fields/FormDropdownFeature.swift diff --git a/Modules/Sources/FormFeature/Fields/FormEditorFeature.swift b/Modules/Sources/FormFeature/Sources/Fields/FormEditorFeature.swift similarity index 100% rename from Modules/Sources/FormFeature/Fields/FormEditorFeature.swift rename to Modules/Sources/FormFeature/Sources/Fields/FormEditorFeature.swift diff --git a/Modules/Sources/FormFeature/Fields/FormFieldConformable.swift b/Modules/Sources/FormFeature/Sources/Fields/FormFieldConformable.swift similarity index 100% rename from Modules/Sources/FormFeature/Fields/FormFieldConformable.swift rename to Modules/Sources/FormFeature/Sources/Fields/FormFieldConformable.swift diff --git a/Modules/Sources/FormFeature/Fields/FormFieldFeature.swift b/Modules/Sources/FormFeature/Sources/Fields/FormFieldFeature.swift similarity index 100% rename from Modules/Sources/FormFeature/Fields/FormFieldFeature.swift rename to Modules/Sources/FormFeature/Sources/Fields/FormFieldFeature.swift diff --git a/Modules/Sources/FormFeature/Fields/FormTextFieldFeature.swift b/Modules/Sources/FormFeature/Sources/Fields/FormTextFieldFeature.swift similarity index 100% rename from Modules/Sources/FormFeature/Fields/FormTextFieldFeature.swift rename to Modules/Sources/FormFeature/Sources/Fields/FormTextFieldFeature.swift diff --git a/Modules/Sources/FormFeature/Fields/FormTitleFeature.swift b/Modules/Sources/FormFeature/Sources/Fields/FormTitleFeature.swift similarity index 100% rename from Modules/Sources/FormFeature/Fields/FormTitleFeature.swift rename to Modules/Sources/FormFeature/Sources/Fields/FormTitleFeature.swift diff --git a/Modules/Sources/FormFeature/Fields/FormUploadBoxFeature.swift b/Modules/Sources/FormFeature/Sources/Fields/FormUploadBoxFeature.swift similarity index 100% rename from Modules/Sources/FormFeature/Fields/FormUploadBoxFeature.swift rename to Modules/Sources/FormFeature/Sources/Fields/FormUploadBoxFeature.swift diff --git a/Modules/Sources/FormFeature/FormFeature.swift b/Modules/Sources/FormFeature/Sources/FormFeature.swift similarity index 100% rename from Modules/Sources/FormFeature/FormFeature.swift rename to Modules/Sources/FormFeature/Sources/FormFeature.swift diff --git a/Modules/Sources/FormFeature/FormScreen.swift b/Modules/Sources/FormFeature/Sources/FormScreen.swift similarity index 100% rename from Modules/Sources/FormFeature/FormScreen.swift rename to Modules/Sources/FormFeature/Sources/FormScreen.swift diff --git a/Modules/Sources/FormFeature/Preview/FormPreviewFeature.swift b/Modules/Sources/FormFeature/Sources/Preview/FormPreviewFeature.swift similarity index 100% rename from Modules/Sources/FormFeature/Preview/FormPreviewFeature.swift rename to Modules/Sources/FormFeature/Sources/Preview/FormPreviewFeature.swift diff --git a/Modules/Sources/FormFeature/Preview/FormPreviewView.swift b/Modules/Sources/FormFeature/Sources/Preview/FormPreviewView.swift similarity index 100% rename from Modules/Sources/FormFeature/Preview/FormPreviewView.swift rename to Modules/Sources/FormFeature/Sources/Preview/FormPreviewView.swift diff --git a/Modules/Sources/FormFeature/Support/FormAttachment.swift b/Modules/Sources/FormFeature/Sources/Support/FormAttachment.swift similarity index 100% rename from Modules/Sources/FormFeature/Support/FormAttachment.swift rename to Modules/Sources/FormFeature/Sources/Support/FormAttachment.swift diff --git a/Modules/Sources/FormFeature/Support/FormNodeBuilder.swift b/Modules/Sources/FormFeature/Sources/Support/FormNodeBuilder.swift similarity index 100% rename from Modules/Sources/FormFeature/Support/FormNodeBuilder.swift rename to Modules/Sources/FormFeature/Sources/Support/FormNodeBuilder.swift diff --git a/Modules/Sources/FormFeature/Support/FormStickedUploadBox.swift b/Modules/Sources/FormFeature/Sources/Support/FormStickedUploadBox.swift similarity index 100% rename from Modules/Sources/FormFeature/Support/FormStickedUploadBox.swift rename to Modules/Sources/FormFeature/Sources/Support/FormStickedUploadBox.swift diff --git a/Modules/Sources/FormFeature/Support/FormType.swift b/Modules/Sources/FormFeature/Sources/Support/FormType.swift similarity index 100% rename from Modules/Sources/FormFeature/Support/FormType.swift rename to Modules/Sources/FormFeature/Sources/Support/FormType.swift diff --git a/Modules/Sources/FormFeature/Support/FormValue.swift b/Modules/Sources/FormFeature/Sources/Support/FormValue.swift similarity index 100% rename from Modules/Sources/FormFeature/Support/FormValue.swift rename to Modules/Sources/FormFeature/Sources/Support/FormValue.swift diff --git a/Modules/Sources/FormFeature/Views/CheckBox.swift b/Modules/Sources/FormFeature/Sources/Views/CheckBox.swift similarity index 100% rename from Modules/Sources/FormFeature/Views/CheckBox.swift rename to Modules/Sources/FormFeature/Sources/Views/CheckBox.swift diff --git a/Modules/Sources/FormFeature/Views/EditReasonView.swift b/Modules/Sources/FormFeature/Sources/Views/EditReasonView.swift similarity index 100% rename from Modules/Sources/FormFeature/Views/EditReasonView.swift rename to Modules/Sources/FormFeature/Sources/Views/EditReasonView.swift diff --git a/Project.swift b/Project.swift index 293df6bd..80839fbc 100644 --- a/Project.swift +++ b/Project.swift @@ -498,6 +498,7 @@ let project = Project( .feature( name: "FormFeature", + hasTests: true, dependencies: [ .Internal.APIClient, .Internal.BBPanelFeature, From 40d95c3c75ef870ab32a0a0406e024a0ff5628d1 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Mon, 16 Mar 2026 16:49:17 +0300 Subject: [PATCH 101/118] Fix caret position in editor field for non-empty text --- .../Sources/FormFeature/Sources/Fields/FormEditorFeature.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Modules/Sources/FormFeature/Sources/Fields/FormEditorFeature.swift b/Modules/Sources/FormFeature/Sources/Fields/FormEditorFeature.swift index 55f567a6..ae09e0c0 100644 --- a/Modules/Sources/FormFeature/Sources/Fields/FormEditorFeature.swift +++ b/Modules/Sources/FormFeature/Sources/Fields/FormEditorFeature.swift @@ -100,6 +100,9 @@ public struct FormEditorFeature: Reducer { Reduce { state, action in switch action { case .view(.onAppear): + if !state.text.isEmpty { + state.textRange = NSMakeRange(state.text.count, 0) + } if let uploadBox = state.uploadBox { return .concatenate( .send(.binding(.set(\.bbPanel.allowedExtensions, uploadBox.allowedExtensions))), From a33ae8682cb2a6e0f5b6eeb61fa333099123a54f Mon Sep 17 00:00:00 2001 From: Xialtal Date: Mon, 16 Mar 2026 21:52:10 +0300 Subject: [PATCH 102/118] Fix bbPanel type for profile edit --- Modules/Sources/BBPanelFeature/Models/BBPanelType.swift | 2 +- Modules/Sources/ProfileFeature/Edit/EditFeature.swift | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/Modules/Sources/BBPanelFeature/Models/BBPanelType.swift b/Modules/Sources/BBPanelFeature/Models/BBPanelType.swift index 5bd0a7b0..d256cea0 100644 --- a/Modules/Sources/BBPanelFeature/Models/BBPanelType.swift +++ b/Modules/Sources/BBPanelFeature/Models/BBPanelType.swift @@ -25,7 +25,7 @@ extension BBPanelType { .spoiler, .spoilerWithTitle, .code, .left, .center, .right, .sub, .sup, .offtop, .hide ] case .profile: - return [.b, .i, .u, .s, .color, .url, .left, .center, .right, .sub, .sup, .offtop] + return [.b, .i, .u, .s, .url, .left, .center, .right, .sub, .sup, .offtop] case .custom(let array): return array } diff --git a/Modules/Sources/ProfileFeature/Edit/EditFeature.swift b/Modules/Sources/ProfileFeature/Edit/EditFeature.swift index df223475..5f26529c 100644 --- a/Modules/Sources/ProfileFeature/Edit/EditFeature.swift +++ b/Modules/Sources/ProfileFeature/Edit/EditFeature.swift @@ -33,10 +33,7 @@ public struct EditFeature: Reducer, Sendable { @Presents public var destination: Destination.State? @Presents public var alert: AlertState? - public var bbPanel = BBPanelFeature.State( - for: .post(isCurator: true, canModerate: true), - supportsUpload: true - ) + public var bbPanel = BBPanelFeature.State(for: .profile) let user: User var draftUser: User From c150fb8e201b9097207499a6661f8d287504e857 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Mon, 16 Mar 2026 22:31:29 +0300 Subject: [PATCH 103/118] Temporary disable url bb tag for profile edit --- Modules/Sources/BBPanelFeature/Models/BBPanelType.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/BBPanelFeature/Models/BBPanelType.swift b/Modules/Sources/BBPanelFeature/Models/BBPanelType.swift index d256cea0..b2cb1818 100644 --- a/Modules/Sources/BBPanelFeature/Models/BBPanelType.swift +++ b/Modules/Sources/BBPanelFeature/Models/BBPanelType.swift @@ -25,7 +25,7 @@ extension BBPanelType { .spoiler, .spoilerWithTitle, .code, .left, .center, .right, .sub, .sup, .offtop, .hide ] case .profile: - return [.b, .i, .u, .s, .url, .left, .center, .right, .sub, .sup, .offtop] + return [.b, .i, .u, .s, /*.url,*/ .left, .center, .right, .sub, .sup, .offtop] case .custom(let array): return array } From a6d1c14d2f6c64979b8fb719aa25cdc60f399a0b Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 17 Mar 2026 22:58:41 +0300 Subject: [PATCH 104/118] UploadBox improvements --- .../UploadBoxFeature/UploadBoxFeature.swift | 43 +++++++++---------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift b/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift index b62b9a82..b9fe6a1b 100644 --- a/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift +++ b/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift @@ -160,20 +160,20 @@ public struct UploadBoxFeature: Reducer, Sendable { case let .destination(.presented(.alert(.selectFileFromFiles(oldFileId)))): if let oldFileId = oldFileId, let oldIndex = state.files.firstIndex(where: { $0.id == oldFileId }) { - return .concatenate( - .send(.view(.removeFileButtonTapped(state.files[oldIndex]))), - .send(.destination(.presented(.confirmationDialog(.files)))) - ) + return .run { [oldFile = state.files[oldIndex]] send in + await send(.view(.removeFileButtonTapped(oldFile))) + await send(.destination(.presented(.confirmationDialog(.files)))) + } } return .send(.destination(.presented(.confirmationDialog(.files)))) case let .destination(.presented(.alert(.selectFileFromGallery(oldFileId)))): if let oldFileId = oldFileId, let oldIndex = state.files.firstIndex(where: { $0.id == oldFileId }) { - return .concatenate( - .send(.view(.removeFileButtonTapped(state.files[oldIndex]))), - .send(.destination(.presented(.confirmationDialog(.gallery)))) - ) + return .run { [oldFile = state.files[oldIndex]] send in + await send(.view(.removeFileButtonTapped(oldFile))) + await send(.destination(.presented(.confirmationDialog(.gallery)))) + } } return .send(.destination(.presented(.confirmationDialog(.gallery)))) @@ -187,13 +187,12 @@ public struct UploadBoxFeature: Reducer, Sendable { let fileSource = state.files[index].fileSource { state.uploadQueue.append(fileSource) if !state.isAnyFileUploading && state.uploadQueue.count == 1 { - return .concatenate( - .send(.view(.removeFileButtonTapped(state.files[index]))), - .send(.internal(.startNextUpload)) - ) - } else { - return .send(.view(.removeFileButtonTapped(state.files[index]))) + return .run { [file = state.files[index]] send in + await send(.view(.removeFileButtonTapped(file))) + await send(.internal(.startNextUpload)) + } } + return .send(.view(.removeFileButtonTapped(state.files[index]))) } case .destination: @@ -319,10 +318,10 @@ public struct UploadBoxFeature: Reducer, Sendable { } guard let ext = fileExtension, fileExtensionAllowed(ext, state.allowedExtensions) else { - return .concatenate( - .send(.view(.fileUploadCanceled(nil, .badExtension))), - .send(.internal(.startNextUpload)) - ) + return .run { send in + await send(.view(.fileUploadCanceled(nil, .badExtension))) + await send(.internal(.startNextUpload)) + } } let file = UploadBoxFile( @@ -415,10 +414,10 @@ public struct UploadBoxFeature: Reducer, Sendable { state.files[index].serverId = responseFileId state.files[index].fileSource = nil state.files[index].isUploading = false - return .concatenate( - .send(.delegate(.fileHasBeenUploaded(responseFileId))), - .send(.internal(.startNextUpload)) - ) + return .run { send in + await send(.delegate(.fileHasBeenUploaded(responseFileId))) + await send(.internal(.startNextUpload)) + } } return .none From 8fb160beb172f3d3ea66ad599b31d3c3c3faab41 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Wed, 18 Mar 2026 12:50:51 +0300 Subject: [PATCH 105/118] Add a check for files with empty extensions --- Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift b/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift index b9fe6a1b..0a5218cd 100644 --- a/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift +++ b/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift @@ -317,7 +317,7 @@ public struct UploadBoxFeature: Reducer, Sendable { name = "\(UUID().uuidString).\(fileExtension ?? "bin")" } - guard let ext = fileExtension, fileExtensionAllowed(ext, state.allowedExtensions) else { + guard let ext = fileExtension, !ext.isEmpty, fileExtensionAllowed(ext, state.allowedExtensions) else { return .run { send in await send(.view(.fileUploadCanceled(nil, .badExtension))) await send(.internal(.startNextUpload)) From d78392791c07fa89e61f6b57bb7dc7fefdbc4d46 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Wed, 18 Mar 2026 15:50:58 +0300 Subject: [PATCH 106/118] Clean BBPanelTag model --- .../Sources/BBPanelFeature/Models/BBPanelTag.swift | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/Modules/Sources/BBPanelFeature/Models/BBPanelTag.swift b/Modules/Sources/BBPanelFeature/Models/BBPanelTag.swift index 86f64cf9..91ae0778 100644 --- a/Modules/Sources/BBPanelFeature/Models/BBPanelTag.swift +++ b/Modules/Sources/BBPanelFeature/Models/BBPanelTag.swift @@ -35,19 +35,6 @@ public enum BBPanelTag { case upload } -public enum ColorTagColors { - case teal -} - -public enum FontTagSize { - case two - case three - case four - case five - case six - case seven - case eight -} extension BBPanelTag { var code: String { From e76ca5bed7b7a28b26f0772d3b5c2476446c0130 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Wed, 18 Mar 2026 18:45:32 +0300 Subject: [PATCH 107/118] Add BBPanel to EditScreen --- .../ProfileFeature/Edit/EditFeature.swift | 41 ++++++++++++++++- .../ProfileFeature/Edit/EditScreen.swift | 45 ++++++++++++------- .../Resources/Localizable.xcstrings | 22 ++++----- 3 files changed, 79 insertions(+), 29 deletions(-) diff --git a/Modules/Sources/ProfileFeature/Edit/EditFeature.swift b/Modules/Sources/ProfileFeature/Edit/EditFeature.swift index 5f26529c..5d9411e7 100644 --- a/Modules/Sources/ProfileFeature/Edit/EditFeature.swift +++ b/Modules/Sources/ProfileFeature/Edit/EditFeature.swift @@ -38,7 +38,7 @@ public struct EditFeature: Reducer, Sendable { let user: User var draftUser: User var focus: Field? - var editorRange: NSRange? + var fieldRange: NSRange? var isSending = false var isAvatarUploading = false @@ -137,6 +137,45 @@ public struct EditFeature: Reducer, Sendable { Reduce { state, action in switch action { + case let .bbPanel(.delegate(.tagTapped(tag))): + var content: String + switch state.focus { + case .about: + content = state.draftUser.aboutMe ?? "" + case .signature: + content = state.draftUser.signature ?? "" + default: + fatalError("BBPanel available only for about and aignature field") + } + var fieldRange = state.fieldRange + if let range = fieldRange, !content.isEmpty { + // если мы вставляем в текст БЕЗ выделенной области бб код + if range.lowerBound == range.upperBound { + let index = content.index(content.startIndex, offsetBy: range.lowerBound) + content.insert(contentsOf: "\(tag.0)\(tag.1)", at: index) + fieldRange = NSMakeRange(range.lowerBound + tag.0.count, 0) + } else { + let ubIndex = content.index(content.startIndex, offsetBy: range.upperBound) + let lbIndex = content.index(content.startIndex, offsetBy: range.lowerBound) + content.insert(contentsOf: tag.1, at: ubIndex) + content.insert(contentsOf: tag.0, at: lbIndex) + fieldRange = NSMakeRange(range.lowerBound + tag.0.count, range.upperBound - range.lowerBound) + } + } else { + content = "\(tag.0)\(tag.1)" + fieldRange = NSMakeRange(tag.0.count, 0) + } + switch state.focus { + case .about: + state.draftUser.aboutMe = content + case .signature: + state.draftUser.signature = content + default: + fatalError("BBPanel available only for about and aignature field") + } + state.fieldRange = fieldRange + return .none + case .binding, .bbPanel: return .none diff --git a/Modules/Sources/ProfileFeature/Edit/EditScreen.swift b/Modules/Sources/ProfileFeature/Edit/EditScreen.swift index 29805654..cb0ecec8 100644 --- a/Modules/Sources/ProfileFeature/Edit/EditScreen.swift +++ b/Modules/Sources/ProfileFeature/Edit/EditScreen.swift @@ -50,14 +50,24 @@ public struct EditScreen: View { content: Binding(unwrapping: $store.draftUser.signature, default: ""), title: LocalizedStringKey("Signature"), focusEqual: .signature, - characterLimit: 300 + characterLimit: 300, + selection: $store.fieldRange, + bbPanel: { + BBPanelView(store: store.scope(state: \.bbPanel, action: \.bbPanel)) + .disabled(focus != .signature) + } ) Field( content: Binding(unwrapping: $store.draftUser.aboutMe, default: ""), title: LocalizedStringKey("About me"), focusEqual: .about, - characterLimit: 500 + characterLimit: 500, + selection: $store.fieldRange, + bbPanel: { + BBPanelView(store: store.scope(state: \.bbPanel, action: \.bbPanel)) + .disabled(focus != .about) + } ) Section { @@ -94,9 +104,6 @@ public struct EditScreen: View { } } .bind($store.focus, to: $focus) - .onTapGesture { - focus = nil - } .onAppear { send(.onAppear) } @@ -347,27 +354,31 @@ public struct EditScreen: View { // MARK: - Helpers @ViewBuilder - private func Field( + private func Field( content: Binding, title: LocalizedStringKey, focusEqual: EditFeature.State.Field, - characterLimit: Int? = nil + characterLimit: Int? = nil, + selection: Binding = .constant(nil), + @ViewBuilder bbPanel: @escaping () -> BBPanel = { EmptyView() } ) -> some View { Section { - WithPerceptionTracking { - SharedUI.Field( - content: content, - placeholder: LocalizedStringResource("Input...", bundle: .module), - focusEqual: focusEqual, - focus: $focus, - characterLimit: characterLimit - ) - } + SharedUI.Field( + content: content, + placeholder: LocalizedStringResource("Input...", bundle: .module), + focusEqual: focusEqual, + focus: $focus, + characterLimit: characterLimit, + selection: selection, + bbPanel: { + bbPanel() + } + ) } header: { Header(title: title) } .listRowBackground(Color.clear) - .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + .listRowInsets(EdgeInsets(top: 1, leading: 1, bottom: 1, trailing: 1)) } private func Header(title: LocalizedStringKey) -> some View { diff --git a/Modules/Sources/ProfileFeature/Resources/Localizable.xcstrings b/Modules/Sources/ProfileFeature/Resources/Localizable.xcstrings index e2d9f7c4..7dc60024 100644 --- a/Modules/Sources/ProfileFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/ProfileFeature/Resources/Localizable.xcstrings @@ -241,16 +241,6 @@ } } }, - "Mentions" : { - "localizations" : { - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Упоминания" - } - } - } - }, "Input..." : { "localizations" : { "ru" : { @@ -321,6 +311,16 @@ } } }, + "Mentions" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Упоминания" + } + } + } + }, "Not set" : { "localizations" : { "ru" : { @@ -533,4 +533,4 @@ } }, "version" : "1.0" -} +} \ No newline at end of file From 55d3842d754f380c99dd62699e7059d883926813 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Wed, 18 Mar 2026 19:00:48 +0300 Subject: [PATCH 108/118] Update API --- Tuist/Package.resolved | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index ab3a56e0..b6c62ef4 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "fbdaa63f3f476bc514a1dee9085f361ceee5fc654e2a36098c66536e592a4848", + "originHash" : "57ddf76d16bf75bc6e7bdf49ab616763f4140f7b1f1ef952f5f7f62a1708c87e", "pins" : [ { "identity" : "activityindicatorview", @@ -105,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/SubvertDev/PDAPI_SPM.git", "state" : { - "revision" : "4f8bd050475e13d7841db57d3ba0057003b0b204", - "version" : "0.7.0" + "revision" : "667fb74d1debff1369b7ce464191d8b0eb78b8ee", + "version" : "0.7.2" } }, { From 479e2bbddcbb67b5a4af7533ba0b3efda5fc8039 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Wed, 18 Mar 2026 19:25:27 +0300 Subject: [PATCH 109/118] Enable tuist cache --- Tuist.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tuist.swift b/Tuist.swift index e82d36a5..90c9d4a0 100644 --- a/Tuist.swift +++ b/Tuist.swift @@ -1,7 +1,7 @@ import ProjectDescription let tuist = Tuist( - //fullHandle: "forpda/forpda", + fullHandle: "forpda/forpda", project: .tuist( compatibleXcodeVersions: .upToNextMajor("26.3"), swiftVersion: "6.2.3", From c099df99cc333048002669d69804bf20e270b970 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 21 Mar 2026 16:52:42 +0300 Subject: [PATCH 110/118] Post-merge fix --- Tuist/Package.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Tuist/Package.swift b/Tuist/Package.swift index f364b3db..e732086b 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -91,7 +91,6 @@ let package = Package( // Forks & stuff .package(url: "https://github.com/SubvertDev/AlertToast.git", revision: "d0f7d6b"), - .package(url: "https://github.com/SubvertDev/PDAPI_SPM.git", exact: "0.7.2"), .package(url: "https://github.com/SubvertDev/Chat", branch: "main"), .package(url: "https://github.com/SubvertDev/PDAPI_SPM.git", exact: "0.7.3"), .package(url: "https://github.com/SubvertDev/RichTextKit.git", branch: "main"), From 121768fe1c75a408fa448993334ea5f7657ab461 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 21 Mar 2026 16:59:15 +0300 Subject: [PATCH 111/118] Post-merge fix --- Project.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Project.swift b/Project.swift index 7c108d7d..e6b7672e 100644 --- a/Project.swift +++ b/Project.swift @@ -330,6 +330,7 @@ let project = Project( .Internal.AnalyticsClient, .Internal.APIClient, .Internal.BBBuilder, + .Internal.BBPanelFeature, .Internal.Models, .Internal.NotificationsClient, .Internal.ParsingClient, From e654069c6d1d05509206d954ea2dc3ce78699630 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 21 Mar 2026 17:29:40 +0300 Subject: [PATCH 112/118] Fix FormFeature tests --- .../FormFeature/Tests/FormFeatureTests.swift | 42 +++++++++++++++---- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/Modules/Sources/FormFeature/Tests/FormFeatureTests.swift b/Modules/Sources/FormFeature/Tests/FormFeatureTests.swift index e335bb48..943e3c29 100644 --- a/Modules/Sources/FormFeature/Tests/FormFeatureTests.swift +++ b/Modules/Sources/FormFeature/Tests/FormFeatureTests.swift @@ -113,7 +113,12 @@ struct FormFeatureTests { } } - var editorState = FormEditorFeature.State(id: 0, flag: .required, defaultText: "") + var editorState = FormEditorFeature.State( + id: 0, + flag: [.required, .uploadable], + defaultText: "", + uploadBox: .init(id: 1, allowedExtensions: []) + ) await store.send(.view(.onAppear)) { $0.rows = [.editor(editorState)] $0.focusedField = 0 @@ -158,7 +163,12 @@ struct FormFeatureTests { } } - var editorState = FormEditorFeature.State(id: 0, flag: .required, defaultText: "") + var editorState = FormEditorFeature.State( + id: 0, + flag: [.required, .uploadable], + defaultText: "", + uploadBox: .init(id: 1, allowedExtensions: []) + ) await store.send(.view(.onAppear)) { $0.rows = [.editor(editorState)] $0.focusedField = 0 @@ -208,7 +218,12 @@ struct FormFeatureTests { } } - var editorState = FormEditorFeature.State(id: 0, flag: .required, defaultText: "") + var editorState = FormEditorFeature.State( + id: 0, + flag: [.required, .uploadable], + defaultText: "", + uploadBox: .init(id: 1, existsAttachments: [], allowedExtensions: []) + ) await store.send(.view(.onAppear)) { $0.rows = [.editor(editorState)] $0.focusedField = 0 @@ -266,7 +281,12 @@ struct FormFeatureTests { } } - var editorState = FormEditorFeature.State(id: 0, flag: .required, defaultText: "") + var editorState = FormEditorFeature.State( + id: 0, + flag: [.required, .uploadable], + defaultText: "", + uploadBox: .init(id: 1, allowedExtensions: []) + ) await store.send(.view(.onAppear)) { $0.rows = [.editor(editorState)] $0.focusedField = 0 @@ -296,12 +316,14 @@ struct FormFeatureTests { // MARK: - Edit Post @Test func editPost() async throws { + let attachment = FormAttachment(id: 1, name: "name", type: .file) + let store = TestStore( initialState: FormFeature.State( type: .post( type: .edit(postId: 0), topicId: 0, - content: .simple("some text", []) + content: .simple("some text", [attachment]) ) ) ) { @@ -312,7 +334,12 @@ struct FormFeatureTests { } } - let editorState = FormEditorFeature.State(id: 0, flag: .required, defaultText: "some text") + let editorState = FormEditorFeature.State( + id: 0, + flag: [.required, .uploadable], + defaultText: "some text", + uploadBox: .init(id: 1, existsAttachments: [attachment], allowedExtensions: []) + ) await store.send(.view(.onAppear)) { $0.rows = [.editor(editorState)] $0.focusedField = 0 @@ -396,7 +423,8 @@ struct FormFeatureTests { description: "Введите дополнительную полезную информацию, например для:\r\n[b]\"Новая версия\"[/b] - список \"что нового\".\r\n[b]\"Модификация\"[/b] - \"на чем основано\", \"особенности\", \"обновлено\". ", placeholder: "", flag: [.required, .uploadable], - defaultText: "" + defaultText: "", + uploadBox: .init(id: 6, allowedExtensions: ["apk", "apks", "exe", "zip", "rar", "obb", "7z", "r00", "r01", "apkm", "ipa"]) ) var uploadbox = FormUploadBoxFeature.State( id: 6, From 4ad995a82cdbbe0a5a3225c60c641476530c38e5 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 21 Mar 2026 22:39:56 +0300 Subject: [PATCH 113/118] Fix uploadbox for not required editors --- Modules/Sources/FormFeature/Sources/FormFeature.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/FormFeature/Sources/FormFeature.swift b/Modules/Sources/FormFeature/Sources/FormFeature.swift index d1a2ff48..ddadd6cb 100644 --- a/Modules/Sources/FormFeature/Sources/FormFeature.swift +++ b/Modules/Sources/FormFeature/Sources/FormFeature.swift @@ -297,7 +297,7 @@ public struct FormFeature: Reducer, Sendable { case let .internal(.formResponse(.success(fields))): var combined: (editorId: Int, uploadBox: FormStickedUploadBox?)? = nil for (index, field) in fields.enumerated() { - if case let .editor(content) = field, content.flag == [.required, .uploadable] { + if case let .editor(content) = field, content.flag.contains(.uploadable) { combined = (index, nil) } else if case let .uploadbox(content, extensions) = field { if content.flag == [.required, .uploadable] { From 7dcf0d082a2e635daa47b59c1061b3caab906729 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 21 Mar 2026 22:47:21 +0300 Subject: [PATCH 114/118] Add auto jump to created topic --- Modules/Sources/ForumFeature/ForumFeature.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Modules/Sources/ForumFeature/ForumFeature.swift b/Modules/Sources/ForumFeature/ForumFeature.swift index 8bac2b4a..4df20884 100644 --- a/Modules/Sources/ForumFeature/ForumFeature.swift +++ b/Modules/Sources/ForumFeature/ForumFeature.swift @@ -158,6 +158,9 @@ public struct ForumFeature: Reducer, Sendable { case let .pageNavigation(.offsetChanged(to: newOffset)): return .send(.internal(.loadForum(offset: newOffset))) + case let .destination(.presented(.form(.delegate(.formSent(.topic(id)))))): + return .send(.delegate(.openTopic(id: id, name: "", goTo: .first))) + case .destination, .pageNavigation: return .none From 9c4e8e75589e186fab51a750ff7ba843251db244 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 21 Mar 2026 22:53:06 +0300 Subject: [PATCH 115/118] Improve form result handling for post --- Modules/Sources/TopicFeature/TopicFeature.swift | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/Modules/Sources/TopicFeature/TopicFeature.swift b/Modules/Sources/TopicFeature/TopicFeature.swift index bcdc2353..f215c3db 100644 --- a/Modules/Sources/TopicFeature/TopicFeature.swift +++ b/Modules/Sources/TopicFeature/TopicFeature.swift @@ -205,11 +205,8 @@ public struct TopicFeature: Reducer, Sendable { .send(.internal(.loadTopic(newOffset))) ]) - case let .destination(.presented(.form(.delegate(.formSent(response))))): - if case let .post(post) = response { - return jumpTo(.post(id: post.id), true, &state) - } - return .none + case let .destination(.presented(.form(.delegate(.formSent(.post(post)))))): + return jumpTo(.post(id: post.id), true, &state) case let .destination(.presented(.alert(.deletePost(id)))): return .run { send in From 37d9e4628c26a773b8dea72b513e2f23527e124d Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 21 Mar 2026 23:19:51 +0300 Subject: [PATCH 116/118] Improve report in FormFeature --- .../Analytics/CommentFeature+Analytics.swift | 2 +- .../Comments/CommentFeature.swift | 30 +++++----------- .../Comments/CommentsView.swift | 2 +- .../Resources/Localizable.xcstrings | 11 +----- .../Resources/Localizable.xcstrings | 10 ++++++ .../FormFeature/Sources/FormFeature.swift | 35 ++++++++++++------- Modules/Sources/Models/Form/FormSend.swift | 2 +- 7 files changed, 44 insertions(+), 48 deletions(-) diff --git a/Modules/Sources/ArticleFeature/Comments/Analytics/CommentFeature+Analytics.swift b/Modules/Sources/ArticleFeature/Comments/Analytics/CommentFeature+Analytics.swift index 8f7be94c..d833a60e 100644 --- a/Modules/Sources/ArticleFeature/Comments/Analytics/CommentFeature+Analytics.swift +++ b/Modules/Sources/ArticleFeature/Comments/Analytics/CommentFeature+Analytics.swift @@ -20,7 +20,7 @@ extension CommentFeature { var body: some Reducer { Reduce { state, action in switch action { - case .onTask, ._timerTicked, ._likeResult, .alert, .delegate, .writeForm, .changeReputation: + case .onTask, ._timerTicked, ._likeResult, .alert, .delegate, .report, .changeReputation: break case .profileTapped: diff --git a/Modules/Sources/ArticleFeature/Comments/CommentFeature.swift b/Modules/Sources/ArticleFeature/Comments/CommentFeature.swift index 6f221869..f500184b 100644 --- a/Modules/Sources/ArticleFeature/Comments/CommentFeature.swift +++ b/Modules/Sources/ArticleFeature/Comments/CommentFeature.swift @@ -28,8 +28,6 @@ public struct CommentFeature: Reducer, Sendable { // MARK: - Localizations public enum Localization { - static let errorSendingReport = LocalizedStringResource("Error sending report", bundle: .module) - static let reportTooShort = LocalizedStringResource("Report too short", bundle: .module) static let reportSent = LocalizedStringResource("Report sent", bundle: .module) } @@ -39,7 +37,7 @@ public struct CommentFeature: Reducer, Sendable { public struct State: Equatable, Identifiable { @Presents public var alert: AlertState? @Presents var changeReputation: ReputationChangeFeature.State? - @Presents var writeForm: FormFeature.State? + @Presents var report: FormFeature.State? @Shared(.userSession) public var userSession: UserSession? public var id: Int { return comment.id } public var comment: Comment @@ -89,7 +87,7 @@ public struct CommentFeature: Reducer, Sendable { case changeReputationButtonTapped case changeReputation(PresentationAction) - case writeForm(PresentationAction) + case report(PresentationAction) case _likeResult(Bool) case _timerTicked @@ -130,24 +128,12 @@ public struct CommentFeature: Reducer, Sendable { case let .profileTapped(id): return .send(.delegate(.commentHeaderTapped(id))) - case .writeForm(.presented(.delegate(.formSent(let response)))): - if case let .report(result) = response { - let toast: ToastMessage - switch result { - case .error: - toast = ToastMessage(text: Localization.errorSendingReport, isError: true, haptic: .error) - case .tooShort: - toast = ToastMessage(text: Localization.reportTooShort, isError: true, haptic: .error) - case .success: - toast = ToastMessage(text: Localization.reportSent, haptic: .success) - } - return .run { _ in - await toastClient.showToast(toast) - } + case .report(.presented(.delegate(.formSent(.report)))): + return .run { _ in + await toastClient.showToast(ToastMessage(text: Localization.reportSent, haptic: .success)) } - return .none - case .writeForm, .changeReputation: + case .report, .changeReputation: return .none case .hiddenLabelTapped: @@ -158,7 +144,7 @@ public struct CommentFeature: Reducer, Sendable { guard state.isAuthorized else { return .send(.delegate(.unauthorizedAction)) } - state.writeForm = FormFeature.State( + state.report = FormFeature.State( type: .report( id: state.comment.id, type: .comment @@ -218,7 +204,7 @@ public struct CommentFeature: Reducer, Sendable { return .none } } - .ifLet(\.$writeForm, action: \.writeForm) { + .ifLet(\.$report, action: \.report) { FormFeature() } .ifLet(\.$changeReputation, action: \.changeReputation) { diff --git a/Modules/Sources/ArticleFeature/Comments/CommentsView.swift b/Modules/Sources/ArticleFeature/Comments/CommentsView.swift index 6fd2df26..7f9445ca 100644 --- a/Modules/Sources/ArticleFeature/Comments/CommentsView.swift +++ b/Modules/Sources/ArticleFeature/Comments/CommentsView.swift @@ -118,7 +118,7 @@ struct CommentView: View { } .padding(.leading, 16 * CGFloat(store.comment.nestLevel)) } - .fullScreenCover(item: $store.scope(state: \.writeForm, action: \.writeForm)) { store in + .fullScreenCover(item: $store.scope(state: \.report, action: \.report)) { store in NavigationStack { FormScreen(store: store) } diff --git a/Modules/Sources/ArticleFeature/Resources/Localizable.xcstrings b/Modules/Sources/ArticleFeature/Resources/Localizable.xcstrings index 81e2e704..3f7f49d9 100644 --- a/Modules/Sources/ArticleFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/ArticleFeature/Resources/Localizable.xcstrings @@ -92,6 +92,7 @@ } }, "Error sending report" : { + "extractionState" : "stale", "localizations" : { "ru" : { "stringUnit" : { @@ -171,16 +172,6 @@ } } }, - "Report too short" : { - "localizations" : { - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Слишком короткая жалоба" - } - } - } - }, "Reputation" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/FormFeature/Resources/Localizable.xcstrings b/Modules/Sources/FormFeature/Resources/Localizable.xcstrings index a81abfef..38049a85 100644 --- a/Modules/Sources/FormFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/FormFeature/Resources/Localizable.xcstrings @@ -207,6 +207,16 @@ } } }, + "Report is too short" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Слишком короткая жалоба" + } + } + } + }, "Send report" : { "localizations" : { "en" : { diff --git a/Modules/Sources/FormFeature/Sources/FormFeature.swift b/Modules/Sources/FormFeature/Sources/FormFeature.swift index ddadd6cb..b7477ee6 100644 --- a/Modules/Sources/FormFeature/Sources/FormFeature.swift +++ b/Modules/Sources/FormFeature/Sources/FormFeature.swift @@ -177,17 +177,7 @@ public struct FormFeature: Reducer, Sendable { case .destination: break - case let .delegate(.formSent(result)): - switch result { - case let .report(status): - if status.isError { - return .none - } - - case .topic, .post: - // .formSent not called when an error occurs - break - } + case .delegate(.formSent): return .run { _ in await dismiss() } case .delegate: @@ -446,8 +436,15 @@ public struct FormFeature: Reducer, Sendable { fatalError() } - case let .internal(.reportResponse(.success(report))): - return .send(.delegate(.formSent(.report(report)))) + case let .internal(.reportResponse(.success(result))): + switch result { + case .error: + state.destination = .alert(.unknownError) + case .tooShort: + state.destination = .alert(.reportIsTooShort) + case .success: + return .send(.delegate(.formSent(.report))) + } case let .internal(.reportResponse(.failure(error))): state.isPublishing = false @@ -597,6 +594,18 @@ public extension AlertState where Action == FormFeature.Destination.Alert { TextState("It will be attached as a dialog to your last post") } + // Report + + nonisolated(unsafe) static let reportIsTooShort = AlertState { + TextState("Report is too short") + } actions: { + ButtonState { + TextState("OK") + } + } + + // Common + nonisolated(unsafe) static let unknownError = AlertState { TextState("Unknown error") } actions: { diff --git a/Modules/Sources/Models/Form/FormSend.swift b/Modules/Sources/Models/Form/FormSend.swift index 02d5c502..500f2acc 100644 --- a/Modules/Sources/Models/Form/FormSend.swift +++ b/Modules/Sources/Models/Form/FormSend.swift @@ -7,6 +7,6 @@ public enum FormSend: Sendable { case post(PostSend) - case report(ReportResponseType) case topic(Int) + case report } From 88d4f79b1a9ecf28bc185f55d12e46b717c791f1 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 21 Mar 2026 23:22:10 +0300 Subject: [PATCH 117/118] Add report creation toast to TopicFeature --- .../TopicFeature/Resources/Localizable.xcstrings | 10 ++++++++++ Modules/Sources/TopicFeature/TopicFeature.swift | 6 ++++++ 2 files changed, 16 insertions(+) diff --git a/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings b/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings index f9f8bab6..07fd08a5 100644 --- a/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings @@ -221,6 +221,16 @@ } } }, + "Report sent" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Жалоба отправлена" + } + } + } + }, "Show results" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/TopicFeature/TopicFeature.swift b/Modules/Sources/TopicFeature/TopicFeature.swift index f215c3db..724c5d2f 100644 --- a/Modules/Sources/TopicFeature/TopicFeature.swift +++ b/Modules/Sources/TopicFeature/TopicFeature.swift @@ -32,6 +32,7 @@ public struct TopicFeature: Reducer, Sendable { private enum Localization { static let linkCopied = LocalizedStringResource("Link copied", bundle: .module) + static let reportSent = LocalizedStringResource("Report sent", bundle: .module) static let favoriteAdded = LocalizedStringResource("Added to favorites", bundle: .module) static let favoriteRemoved = LocalizedStringResource("Removed from favorites", bundle: .module) static let postDeleted = LocalizedStringResource("Post deleted", bundle: .module) @@ -208,6 +209,11 @@ public struct TopicFeature: Reducer, Sendable { case let .destination(.presented(.form(.delegate(.formSent(.post(post)))))): return jumpTo(.post(id: post.id), true, &state) + case .destination(.presented(.form(.delegate(.formSent(.report))))): + return .run { _ in + await toastClient.showToast(ToastMessage(text: Localization.reportSent, haptic: .success)) + } + case let .destination(.presented(.alert(.deletePost(id)))): return .run { send in let status = try await apiClient.deletePosts(postIds: [id]) From 0984468dd826985bd7a06c2e0ec7d6e5d5e31828 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 21 Mar 2026 23:22:34 +0300 Subject: [PATCH 118/118] Add report creation toast to ReputationFeature --- .../ReputationFeature/ReputationFeature.swift | 14 ++++++++++++++ .../Resources/Localizable.xcstrings | 10 ++++++++++ Project.swift | 1 + 3 files changed, 25 insertions(+) diff --git a/Modules/Sources/ReputationFeature/ReputationFeature.swift b/Modules/Sources/ReputationFeature/ReputationFeature.swift index 919efc6d..77953ec6 100644 --- a/Modules/Sources/ReputationFeature/ReputationFeature.swift +++ b/Modules/Sources/ReputationFeature/ReputationFeature.swift @@ -5,17 +5,25 @@ // Created by Рустам Ойтов on 11.07.2025. // +import Foundation import AnalyticsClient import ComposableArchitecture import APIClient import Models import FormFeature +import ToastClient @Reducer public struct ReputationFeature: Reducer, Sendable { public init() {} + // MARK: - Localizations + + public enum Localization { + static let reportSent = LocalizedStringResource("Report sent", bundle: .module) + } + // MARK: - Destinations @Reducer @@ -98,6 +106,7 @@ public struct ReputationFeature: Reducer, Sendable { @Dependency(\.apiClient) private var apiClient @Dependency(\.analyticsClient) private var analyticsClient + @Dependency(\.toastClient) private var toastClient // MARK: - body @@ -113,6 +122,11 @@ public struct ReputationFeature: Reducer, Sendable { return .send(.internal(.loadData)) .merge(with: .cancel(id: CancelID.loadData)) + case .destination(.presented(.report(.delegate(.formSent(.report))))): + return .run { _ in + await toastClient.showToast(ToastMessage(text: Localization.reportSent, haptic: .success)) + } + case .view(.onAppear): return .send(.internal(.loadData)) diff --git a/Modules/Sources/ReputationFeature/Resources/Localizable.xcstrings b/Modules/Sources/ReputationFeature/Resources/Localizable.xcstrings index b99192f7..ce2789b0 100644 --- a/Modules/Sources/ReputationFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/ReputationFeature/Resources/Localizable.xcstrings @@ -113,6 +113,16 @@ } } }, + "Report sent" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Жалоба отправлена" + } + } + } + }, "Reputation" : { "localizations" : { "ru" : { diff --git a/Project.swift b/Project.swift index e6b7672e..b6b8e6e7 100644 --- a/Project.swift +++ b/Project.swift @@ -411,6 +411,7 @@ let project = Project( .Internal.Models, .Internal.SharedUI, .Internal.FormFeature, + .Internal.ToastClient, .SPM.TCA, ] ),